Merge pull request #318 from MacRimi/develop

New version 1.2.5
This commit is contained in:
MacRimi
2026-09-01 19:26:35 +02:00
committed by GitHub
288 changed files with 93734 additions and 8922 deletions
+14
View File
@@ -201,6 +201,20 @@ After setting up your password, you can enable 2FA using any TOTP authenticator
![2FA Setup](https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/monitor/2fa-setup.png)
### Embedding in Trusted Iframes
By default, ProxMenux Monitor blocks embedding in iframes with `frame-ancestors 'none'` and `X-Frame-Options: DENY`.
If you run a trusted local portal or monitoring page and need to embed the Monitor, set `PROXMENUX_ALLOWED_FRAME_ANCESTORS` to the exact parent origins that may frame it:
```bash
PROXMENUX_ALLOWED_FRAME_ANCESTORS="https://portal.example.com http://raspberrypi.local:8080"
```
Only exact `http://` or `https://` origins are accepted. Paths, wildcards, broad schemes, credentials, and malformed values are ignored. When this setting is present, the Monitor sends a matching CSP `frame-ancestors` allowlist and omits `X-Frame-Options`, because that legacy header cannot express multiple allowed parents.
`ALLOWED_FRAME_ANCESTORS` is also accepted as a compatibility alias when `PROXMENUX_ALLOWED_FRAME_ANCESTORS` is not set.
### Security Best Practices for API Tokens
**IMPORTANT**: Never hardcode your API tokens directly in configuration files or scripts. Instead, use environment variables or secrets management.
+8 -5
View File
@@ -5,6 +5,7 @@ import { GeistMono } from "geist/font/mono"
import { ThemeProvider } from "../components/theme-provider"
import { PwaRegister } from "../components/pwa-register"
import { PwaInstallPrompt } from "../components/pwa-install-prompt"
import { I18nProvider } from "../lib/i18n/provider"
import { Suspense } from "react"
import "./globals.css"
@@ -43,13 +44,15 @@ export default function RootLayout({
return (
<html lang="en" suppressHydrationWarning>
<body className={`${GeistSans.variable} ${GeistMono.variable} antialiased bg-background text-foreground`}>
<Suspense fallback={<div>Loading...</div>}>
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange>
{children}
</ThemeProvider>
<Suspense fallback={null}>
<I18nProvider>
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange>
{children}
</ThemeProvider>
<PwaInstallPrompt />
</I18nProvider>
</Suspense>
<PwaRegister />
<PwaInstallPrompt />
</body>
</html>
)
+4 -2
View File
@@ -5,8 +5,10 @@ import { ProxmoxDashboard } from "../components/proxmox-dashboard"
import { Login } from "../components/login"
import { AuthSetup } from "../components/auth-setup"
import { getApiUrl } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
export default function Home() {
const t = useT()
const [authStatus, setAuthStatus] = useState<{
loading: boolean
authEnabled: boolean
@@ -113,8 +115,8 @@ export default function Home() {
<div className="h-12 w-12 rounded-full border-2 border-muted"></div>
<div className="absolute inset-0 h-12 w-12 rounded-full border-2 border-transparent border-t-primary animate-spin"></div>
</div>
<div className="text-sm font-medium text-foreground">Loading...</div>
<p className="text-xs text-muted-foreground">Connecting to ProxMenux Monitor</p>
<div className="text-sm font-medium text-foreground">{t("app.loading")}</div>
<p className="text-xs text-muted-foreground">{t("app.connecting")}</p>
</div>
</div>
)
+26 -26
View File
@@ -13,6 +13,7 @@ import {
} from "lucide-react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
import { APP_VERSION } from "./release-notes-modal"
import { useT } from "../lib/i18n/provider"
// Issue #191: a dedicated About tab. Centralises project metadata
// (version, license, author) and every external link the project
@@ -22,8 +23,8 @@ import { APP_VERSION } from "./release-notes-modal"
// without re-cluttering the dashboard footer.
interface LinkRow {
label: string
description: string
labelKey: string
descriptionKey: string
href: string
Icon: React.ComponentType<{ className?: string }>
accent?: keyof typeof ACCENT_CLASSES
@@ -42,29 +43,29 @@ const ACCENT_CLASSES = {
const PROJECT_LINKS: LinkRow[] = [
{
label: "GitHub repository",
description: "Source code, releases and issue tracker.",
labelKey: "about.links.repository.label",
descriptionKey: "about.links.repository.description",
href: "https://github.com/MacRimi/ProxMenux",
Icon: Github,
accent: "gray",
},
{
label: "Documentation",
description: "Full user guide for ProxMenux and the Monitor.",
labelKey: "about.links.documentation.label",
descriptionKey: "about.links.documentation.description",
href: "https://proxmenux.com",
Icon: BookOpen,
accent: "blue",
},
{
label: "Discussions",
description: "Ask questions, share custom AI prompts, swap ideas.",
labelKey: "about.links.discussions.label",
descriptionKey: "about.links.discussions.description",
href: "https://github.com/MacRimi/ProxMenux/discussions",
Icon: MessageSquare,
accent: "purple",
},
{
label: "Report a bug or request a feature",
description: "Open an issue on GitHub — bugs, ideas, regressions.",
labelKey: "about.links.issues.label",
descriptionKey: "about.links.issues.description",
href: "https://github.com/MacRimi/ProxMenux/issues",
Icon: Bug,
accent: "red",
@@ -73,8 +74,8 @@ const PROJECT_LINKS: LinkRow[] = [
const SUPPORT_LINKS: LinkRow[] = [
{
label: "Support the project on Ko-fi",
description: "ProxMenux is free and open source. Donations cover hosting and dev time.",
labelKey: "about.links.support.label",
descriptionKey: "about.links.support.description",
href: "https://ko-fi.com/macrimi",
Icon: Heart,
accent: "pink",
@@ -82,6 +83,7 @@ const SUPPORT_LINKS: LinkRow[] = [
]
function LinkCard({ row }: { row: LinkRow }) {
const t = useT()
const accentClass = ACCENT_CLASSES[row.accent ?? "blue"]
// Style mirrors the PCI Devices cards in the Hardware tab: subtle
// translucent background by default, slightly lighter on hover, no
@@ -101,16 +103,17 @@ function LinkCard({ row }: { row: LinkRow }) {
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
{row.label}
{t(row.labelKey)}
<ExternalLink className="h-3 w-3 text-muted-foreground" />
</div>
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">{row.description}</p>
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">{t(row.descriptionKey)}</p>
</div>
</a>
)
}
export function About() {
const t = useT()
return (
<div className="space-y-4 md:space-y-6">
{/* Hero — logo, name, version, one-line description. */}
@@ -120,7 +123,7 @@ export function About() {
<div className="relative w-24 h-24 md:w-28 md:h-28 flex-shrink-0">
<Image
src="/images/proxmenux-logo.png"
alt="ProxMenux logo"
alt={t("about.logoAlt")}
fill
priority
className="object-contain"
@@ -131,9 +134,7 @@ export function About() {
ProxMenux Monitor
</h2>
<p className="text-sm text-muted-foreground mt-1">
A web dashboard and management layer for Proxmox VE health monitoring,
notifications, terminal, optimization tracker and more, packaged as a single
AppImage.
{t("about.heroDescription")}
</p>
<div className="flex flex-wrap items-center justify-center md:justify-start gap-2 mt-3">
<span className="inline-flex items-center gap-1.5 rounded-md bg-blue-500/10 text-blue-500 border border-blue-500/30 px-2.5 py-1 text-xs font-mono">
@@ -151,7 +152,7 @@ export function About() {
const href = isPrerelease
? "https://github.com/MacRimi/ProxMenux/releases"
: "https://proxmenux.com/en/changelog"
const label = isPrerelease ? "Release notes" : "Changelog"
const label = isPrerelease ? t("about.releaseNotes") : t("about.changelog")
return (
<a
href={href}
@@ -175,9 +176,9 @@ export function About() {
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Github className="h-4 w-4 text-muted-foreground" />
Project
{t("about.project.title")}
</CardTitle>
<CardDescription>Repository, documentation and community channels.</CardDescription>
<CardDescription>{t("about.project.description")}</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
@@ -195,11 +196,10 @@ export function About() {
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Heart className="h-4 w-4 text-pink-500" />
Support &amp; License
{t("about.support.title")}
</CardTitle>
<CardDescription>
ProxMenux is free and open source under the GPL-3.0 license. If it&apos;s useful to
you, a one-off contribution helps keep it that way.
{t("about.support.description")}
</CardDescription>
</CardHeader>
<CardContent>
@@ -218,11 +218,11 @@ export function About() {
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
GPL-3.0 license
{t("about.license.label")}
<ExternalLink className="h-3 w-3 text-muted-foreground" />
</div>
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">
Free software see the LICENSE file for the full text.
{t("about.license.description")}
</p>
</div>
</a>
+727
View File
@@ -0,0 +1,727 @@
"use client"
import { useEffect, useMemo, useRef, useState } from "react"
import useSWR from "swr"
import { ArrowUpCircle, Check, ExternalLink, Pencil, Plus, Search } from "lucide-react"
import { fetchApi } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
import { ThemeAwareLogo } from "./lxc-app-panel"
import { CustomLinkEditor, type CustomLink, type GuestOption } from "./custom-link-editor"
import { Button } from "./ui/button"
import { categoryChipStyle, useIsLightTheme } from "../lib/category-color"
// ─── Local subset of /api/vms shape ─────────────────────────────
// Kept narrow on purpose — this component only needs what feeds a
// launcher card. Full VMData / LxcAppWatch types live in
// virtual-machines.tsx.
interface AppPort {
port: number
description?: string
scheme?: "http" | "https"
web_path?: string
logo_url?: string | null
category?: string
custom_url?: string
}
interface AppWatch {
id: string
name: string | null
logo_url?: string | null
ports?: AppPort[]
installed_version: string | null
latest_version: string | null
update_available: boolean | null
managed_oci_app_id?: string | null
helper_slug?: string
}
interface DockerImageUpdate {
reference: string
display_name?: string | null
used_by?: string[]
update_available: boolean | null
}
// Locate the docker_inventory image whose lifecycle matches a given
// Web Link. The port's `description` is a user-typed label (e.g.
// "Paperless") so exact match on `used_by` (real container names
// like "paperless-webserver-1") almost never hits. Fall back through:
// 1. exact match in `used_by`
// 2. case-insensitive substring either way in `used_by`
// 3. substring in `display_name`
// 4. substring in `reference` (the full image path)
// Returns undefined when nothing matches — the caller treats that as
// "no upstream update signal for this port".
function findDockerImageForPort(
port: AppPort,
images: DockerImageUpdate[],
): DockerImageUpdate | undefined {
const desc = (port.description || "").trim().toLowerCase()
if (!desc || !images.length) return undefined
const exact = images.find((i) =>
(i.used_by || []).some((c) => c.toLowerCase() === desc),
)
if (exact) return exact
const inclUsedBy = images.find((i) =>
(i.used_by || []).some((c) => {
const cl = c.toLowerCase()
return cl.includes(desc) || desc.includes(cl)
}),
)
if (inclUsedBy) return inclUsedBy
const byDisplay = images.find((i) => {
const d = (i.display_name || "").toLowerCase()
return !!d && (d.includes(desc) || desc.includes(d))
})
if (byDisplay) return byDisplay
return images.find((i) => (i.reference || "").toLowerCase().includes(desc))
}
interface VM {
vmid: number
name: string
ip?: string
type: string
app_watches?: AppWatch[]
docker_inventory?: { images?: DockerImageUpdate[] }
}
interface LaunchLink {
key: string
// Present for LXC-registered apps and for custom links with a
// guest binding. Absent when the link is an unbound custom entry
// (e.g. an external service).
vmid: number | null
guestType: "lxc" | "qemu" | null
ctName: string
appName: string
logoUrl: string | null
weblink: string
category: string
updateAvailable: boolean
// Set for user-defined custom links so the card can offer edit
// and delete actions in edit mode.
isCustom: boolean
customId?: string
}
// ─── Helpers ─────────────────────────────────────────────────────
// Same URL construction as the Web Link row in the App tab.
// Duplicated (small) on purpose — buildWebUrl in lxc-app-panel.tsx
// is scoped to that module, and copying keeps this component free of
// hidden cross-file dependencies. A per-port `custom_url` overrides
// the ip:port composition entirely — used for apps served behind a
// reverse-proxy domain.
function buildWebUrl(ip: string | undefined, port: AppPort): string | null {
const custom = (port.custom_url || "").trim()
if (custom) return custom
const raw = (ip || "").trim().split("/")[0]
if (!raw || raw === "DHCP" || !port?.port) return null
const host = raw.includes(":") && !raw.startsWith("[") ? `[${raw}]` : raw
const scheme = port.scheme || ([443, 8443, 9443].includes(port.port) ? "https" : "http")
const path = port.web_path ? `/${port.web_path.replace(/^\/+/, "")}` : ""
return `${scheme}://${host}:${port.port}${path}`
}
type SortMode = "name" | "ct" | "category"
const SORT_STORAGE_KEY = "proxmenux-apps-sort"
const ALL_CATEGORIES = "__all__"
const fetcher = async (url: string) => fetchApi(url)
// ─── Component ───────────────────────────────────────────────────
export function AppsDashboard() {
const t = useT()
const { data: vms } = useSWR<VM[]>("/api/vms", fetcher, { refreshInterval: 5000, revalidateOnFocus: false })
// Custom links persisted in /etc/proxmenux/custom_links.json. Small
// and rarely changes, so we don't poll on an interval — mutate() is
// called explicitly after create / update / delete.
const { data: customLinks, mutate: mutateCustomLinks } = useSWR<CustomLink[]>(
"/api/apps/custom-links", fetcher, { revalidateOnFocus: false },
)
// Category presets for the "+ Add link" modal.
const { data: categoryPresets } = useSWR<string[]>(
"/api/apps/categories", fetcher, { revalidateOnFocus: false },
)
const isLightTheme = useIsLightTheme()
// Flatten VMs → LaunchLinks. One card per (app × port with weblink)
// for LXC-registered apps, plus one card per user-defined custom
// link. A custom link with a binding resolves its ctName from the
// matching guest in `vms` so renames stay in sync automatically.
const links = useMemo<LaunchLink[]>(() => {
const out: LaunchLink[] = []
const vmsList = Array.isArray(vms) ? vms : []
for (const vm of vmsList) {
const apps = vm.app_watches || []
if (!apps.length) continue
for (const app of apps) {
// Skip the synthetic entry ProxMenux inserts for managed
// OCI apps (Secure Gateway) — it has no user-assigned
// Web Link and doesn't belong in a launcher.
if (app.managed_oci_app_id) continue
// `app.update_available` refers to the app itself. For a
// Docker registration that app is the Docker engine, and its
// ports are containers running INSIDE Docker (Portainer,
// Frigate…) — each with an independent image update
// lifecycle in `vm.docker_inventory.images[]`. Propagating
// the engine-level flag to every container card would falsely
// mark Portainer/Frigate as updatable when only the engine
// needs bumping; missing the per-image flag would hide real
// Portainer/Frigate updates that ARE tracked in the App tab
// and fire notifications. Resolution: for each Docker port,
// find the image entry whose `used_by` includes the port's
// container name (== `port.description`) and use THAT image's
// update_available. Engine update stays out of the port cards
// — it belongs in the Updates tab.
const isDockerApp = app.helper_slug === "docker"
const dockerImages = vm.docker_inventory?.images || []
for (const port of app.ports || []) {
const url = buildWebUrl(vm.ip, port)
if (!url) continue
let updateAvailable = false
if (isDockerApp) {
const img = findDockerImageForPort(port, dockerImages)
updateAvailable = img?.update_available === true
} else {
updateAvailable = app.update_available === true
}
out.push({
key: `lxc-${vm.vmid}-${app.id}-${port.port}`,
vmid: vm.vmid,
guestType: "lxc",
ctName: vm.name,
appName: (port.description || app.name || vm.name || "").trim(),
logoUrl: port.logo_url || app.logo_url || null,
weblink: url,
category: (port.category || "").trim(),
updateAvailable,
isCustom: false,
})
}
}
}
// Merge user-defined custom links. Their `binding` decides how the
// CT/VM reference renders and where clicking it navigates.
const guestByVmid = new Map<number, { name: string; type: string }>()
for (const vm of vmsList) guestByVmid.set(vm.vmid, { name: vm.name, type: vm.type })
for (const link of customLinks || []) {
let ctName = ""
let vmid: number | null = null
let guestType: "lxc" | "qemu" | null = null
if (link.binding) {
const guest = guestByVmid.get(link.binding.vmid)
vmid = link.binding.vmid
guestType = link.binding.guest_type
ctName = guest?.name || ""
}
out.push({
key: `custom-${link.id}`,
vmid,
guestType,
ctName,
appName: link.name,
logoUrl: link.logo_url || null,
weblink: link.url,
category: (link.category || "").trim(),
updateAvailable: false,
isCustom: true,
customId: link.id,
})
}
return out
}, [vms, customLinks])
// Category list for the filter dropdown — built from the data so
// it always reflects reality (presets and custom-entered names).
const categoryCounts = useMemo(() => {
const map = new Map<string, number>()
for (const l of links) {
const key = l.category || t("apps.uncategorized")
map.set(key, (map.get(key) || 0) + 1)
}
return map
}, [links, t])
const sortedCategoryEntries = useMemo(
() => Array.from(categoryCounts.entries()).sort((a, b) => a[0].localeCompare(b[0])),
[categoryCounts],
)
// ─── Controls state ────────────────────────────────────────────
const [query, setQuery] = useState("")
const [currentCat, setCurrentCat] = useState<string>(ALL_CATEGORIES)
const [sortMode, setSortMode] = useState<SortMode>("name")
const [searchExpanded, setSearchExpanded] = useState(false)
const searchInputRef = useRef<HTMLInputElement | null>(null)
// Restore sort from localStorage on mount.
useEffect(() => {
try {
const saved = localStorage.getItem(SORT_STORAGE_KEY)
if (saved === "name" || saved === "ct" || saved === "category") {
setSortMode(saved)
}
} catch (_) { /* private mode / storage disabled — silent */ }
}, [])
// Persist sort choice — only this one preference survives reload;
// category filter and search reset each visit so the dashboard
// always opens showing every app.
useEffect(() => {
try { localStorage.setItem(SORT_STORAGE_KEY, sortMode) } catch (_) {}
}, [sortMode])
// Filter category resets if the user removes/renames the currently
// selected one and it disappears from the list.
useEffect(() => {
if (currentCat === ALL_CATEGORIES) return
if (!categoryCounts.has(currentCat)) setCurrentCat(ALL_CATEGORIES)
}, [currentCat, categoryCounts])
// ─── Custom link editor state ──────────────────────────────────
const [editorOpen, setEditorOpen] = useState(false)
const [editingLink, setEditingLink] = useState<CustomLink | null>(null)
const [editMode, setEditMode] = useState(false)
// Guest list feeds the binding dropdown in the editor modal.
const guestOptions = useMemo<GuestOption[]>(() => {
if (!Array.isArray(vms)) return []
return vms
.filter((v) => v.type === "lxc" || v.type === "qemu")
.map((v) => ({
vmid: v.vmid,
name: v.name,
type: v.type as "lxc" | "qemu",
}))
}, [vms])
const openNewLink = () => {
setEditingLink(null)
setEditorOpen(true)
}
const openEditForLink = (customId: string) => {
const found = (customLinks || []).find((l) => l.id === customId)
if (!found) return
setEditingLink(found)
setEditorOpen(true)
}
// ─── Filter + sort ─────────────────────────────────────────────
const shown = useMemo(() => {
const q = query.trim().toLowerCase()
const uncatKey = t("apps.uncategorized")
let filtered = links
if (currentCat !== ALL_CATEGORIES) {
filtered = filtered.filter((l) => (l.category || uncatKey) === currentCat)
}
if (q) {
filtered = filtered.filter((l) =>
l.appName.toLowerCase().includes(q) ||
(l.ctName || "").toLowerCase().includes(q) ||
(l.vmid != null && String(l.vmid).includes(q)) ||
(l.category || "").toLowerCase().includes(q)
)
}
const sorted = [...filtered]
sorted.sort((a, b) => {
if (sortMode === "name") return a.appName.localeCompare(b.appName)
if (sortMode === "ct") {
// Unbound custom links have no vmid; sort them after every
// bound entry, ordered alphabetically by app name.
if (a.vmid == null && b.vmid == null) return a.appName.localeCompare(b.appName)
if (a.vmid == null) return 1
if (b.vmid == null) return -1
return (a.vmid - b.vmid) || a.appName.localeCompare(b.appName)
}
// category — grouped alphabetically, then by app name inside
const catA = a.category || uncatKey
const catB = b.category || uncatKey
const c = catA.localeCompare(catB)
return c !== 0 ? c : a.appName.localeCompare(b.appName)
})
return sorted
}, [links, query, currentCat, sortMode, t])
// ─── Empty state ───────────────────────────────────────────────
const hasAnyData = links.length > 0 || (customLinks && customLinks.length > 0)
if (vms && !hasAnyData) {
return (
<>
<div className="text-center text-muted-foreground py-16">
<div className="text-sm">{t("apps.emptyTitle")}</div>
<div className="text-xs mt-1 opacity-80">{t("apps.emptyHint")}</div>
<Button
onClick={openNewLink}
variant="outline"
className="mt-4"
>
<Plus className="h-4 w-4 mr-1.5" />
{t("apps.customLinkAdd")}
</Button>
</div>
<CustomLinkEditor
open={editorOpen}
onOpenChange={setEditorOpen}
editing={editingLink}
guests={guestOptions}
categoryPresets={categoryPresets || []}
onSaved={() => mutateCustomLinks()}
/>
</>
)
}
// ─── Render ────────────────────────────────────────────────────
const countLabel = shown.length === 1
? t("apps.countOne")
: t("apps.countMany", { n: shown.length })
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-2">
{/* Search — icon-only until tapped on narrow screens */}
<div className={`relative ${searchExpanded ? "flex-1 min-w-full sm:min-w-0 sm:flex-none sm:w-72" : "sm:flex-1 sm:min-w-40 sm:max-w-xs"}`}>
{!searchExpanded && (
<button
type="button"
onClick={() => {
setSearchExpanded(true)
requestAnimationFrame(() => searchInputRef.current?.focus())
}}
className="sm:hidden inline-flex items-center justify-center w-9 h-9 rounded-md border border-border bg-card text-muted-foreground hover:text-foreground hover:border-border/80 transition-colors"
aria-label={t("apps.searchAriaLabel")}
>
<Search className="h-4 w-4" />
</button>
)}
<div className={`relative ${searchExpanded ? "block" : "hidden sm:block"}`}>
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
<input
ref={searchInputRef}
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
onBlur={() => { if (!query.trim()) setSearchExpanded(false) }}
placeholder={t("apps.searchPlaceholder")}
aria-label={t("apps.searchAriaLabel")}
className="w-full h-9 pl-8 pr-3 text-sm bg-card border border-border rounded-md text-foreground placeholder:text-muted-foreground focus:border-border/80 focus:outline-none focus:ring-1 focus:ring-ring"
/>
</div>
</div>
{/* Category filter */}
<select
value={currentCat}
onChange={(e) => setCurrentCat(e.target.value)}
aria-label={t("apps.filterAriaLabel")}
className="h-9 pl-3 pr-8 text-sm bg-card border border-border rounded-md text-foreground appearance-none cursor-pointer max-w-[9.5rem] sm:max-w-none truncate focus:border-border/80 focus:outline-none focus:ring-1 focus:ring-ring bg-no-repeat bg-[right_0.6rem_center]"
style={{
backgroundImage: "url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'><path d='m6 9 6 6 6-6'/></svg>\")",
}}
>
<option value={ALL_CATEGORIES}>{t("apps.filterAll")}</option>
{sortedCategoryEntries.map(([cat, n]) => (
<option key={cat} value={cat}>{`${cat} · ${n}`}</option>
))}
</select>
{/* Sort */}
<select
value={sortMode}
onChange={(e) => setSortMode(e.target.value as SortMode)}
aria-label={t("apps.sortAriaLabel")}
className="h-9 pl-3 pr-8 text-sm bg-card border border-border rounded-md text-foreground appearance-none cursor-pointer max-w-[8rem] sm:max-w-none truncate focus:border-border/80 focus:outline-none focus:ring-1 focus:ring-ring bg-no-repeat bg-[right_0.6rem_center]"
style={{
backgroundImage: "url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'><path d='m6 9 6 6 6-6'/></svg>\")",
}}
>
<option value="name">{t("apps.sortName")}</option>
<option value="ct">{t("apps.sortId")}</option>
<option value="category">{t("apps.sortCategory")}</option>
</select>
{/* Count — desktop only. Mobile gives the horizontal room to
the + button instead so everything stays on one line. */}
<span className="ml-auto hidden sm:inline-flex items-center h-9 px-3 text-xs text-muted-foreground rounded-md bg-card border border-border font-mono tabular-nums">
{countLabel}
</span>
{/* + Add custom link. Icon-only on mobile (mirrors the search
icon-toggle pattern) so the toolbar fits in one line even
in the narrowest viewport. On desktop shows label + icon. */}
<Button
type="button"
variant="outline"
onClick={openNewLink}
className="sm:ml-2 ml-auto h-9 px-2.5 sm:px-3 flex-shrink-0"
aria-label={t("apps.customLinkAdd")}
>
<Plus className="h-4 w-4 sm:mr-1.5" />
<span className="hidden sm:inline">{t("apps.customLinkAdd")}</span>
</Button>
{/* Edit mode toggle — only shown when at least one custom link
exists, since it's the only card type that carries per-card
edit/delete actions. LXC-registered apps are edited in the
LXC App tab of their guest modal. */}
{(customLinks && customLinks.length > 0) && (
<Button
type="button"
variant="outline"
onClick={() => setEditMode((v) => !v)}
className={`h-9 px-2.5 sm:px-3 flex-shrink-0 ${editMode ? "border-blue-500/60 text-blue-400" : ""}`}
aria-pressed={editMode}
aria-label={t("apps.editModeToggle")}
>
{editMode ? <Check className="h-4 w-4 sm:mr-1.5" /> : <Pencil className="h-4 w-4 sm:mr-1.5" />}
<span className="hidden sm:inline">{editMode ? t("apps.editModeDone") : t("apps.editModeToggle")}</span>
</Button>
)}
</div>
{/* Grid — grouped headers when sorted by category */}
<CardsGrid
links={shown}
grouped={sortMode === "category"}
uncategorizedLabel={t("apps.uncategorized")}
openLabel={t("apps.openAriaLabel")}
isLightTheme={isLightTheme}
editMode={editMode}
onEditCustom={openEditForLink}
/>
<CustomLinkEditor
open={editorOpen}
onOpenChange={setEditorOpen}
editing={editingLink}
guests={guestOptions}
categoryPresets={categoryPresets || []}
onSaved={() => mutateCustomLinks()}
/>
</div>
)
}
// ─── Cards grid + card ───────────────────────────────────────────
function CardsGrid({
links,
grouped,
uncategorizedLabel,
openLabel,
isLightTheme,
editMode,
onEditCustom,
}: {
links: LaunchLink[]
grouped: boolean
uncategorizedLabel: string
openLabel: string
isLightTheme: boolean
editMode: boolean
onEditCustom: (customId: string) => void
}) {
if (!links.length) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-3">
<div className="col-span-full text-center text-muted-foreground text-sm py-8">
{/* No results after filter/search */}
</div>
</div>
)
}
if (!grouped) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-3">
{links.map((link) => (
<AppCard key={link.key} link={link} openLabel={openLabel} isLightTheme={isLightTheme} editMode={editMode} onEditCustom={onEditCustom} />
))}
</div>
)
}
// Group by category, insert header rows spanning the full grid width.
const groups: Array<[string, LaunchLink[]]> = []
let currentCat: string | null = null
let bucket: LaunchLink[] = []
for (const link of links) {
const cat = link.category || uncategorizedLabel
if (cat !== currentCat) {
if (bucket.length) groups.push([currentCat!, bucket])
currentCat = cat
bucket = []
}
bucket.push(link)
}
if (bucket.length) groups.push([currentCat!, bucket])
return (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-3">
{groups.map(([cat, items]) => (
<div key={`grp-${cat}`} className="contents">
<h3 className="col-span-full uppercase text-xs tracking-wider text-muted-foreground font-semibold pt-3 pb-1.5 border-b border-border/60 flex items-center gap-2">
<span>{cat}</span>
<span className="font-mono tabular-nums text-[10px] px-1.5 py-0.5 rounded bg-card border border-border/60 font-normal">{items.length}</span>
</h3>
{items.map((link) => (
<AppCard key={link.key} link={link} openLabel={openLabel} isLightTheme={isLightTheme} editMode={editMode} onEditCustom={onEditCustom} />
))}
</div>
))}
</div>
)
}
// hueForCategory / categoryChipStyle / readIsLightTheme moved to
// lib/category-color.ts so the LXC App tab can render the same chip.
// Dispatch the pair of events that jumps from the Apps dashboard to
// the VMs modal on the App tab for a given CT. Two events by design:
// `changeTab` switches the outer tab (dashboard-level) and
// `openLxcAppModal` tells VirtualMachines which guest to open and on
// which inner tab to land. Both fire in the same tick.
function openLxcModalOnAppTab(vmid: number) {
window.dispatchEvent(new CustomEvent("changeTab", { detail: { tab: "vms" } }))
window.dispatchEvent(new CustomEvent("openLxcAppModal", { detail: { vmid } }))
}
// Same pattern for a QEMU guest: land on the modal's Status tab
// (QEMU guests don't have the App tab). Used by custom links whose
// binding is a VM instead of an LXC.
function openVmModalOnStatusTab(vmid: number) {
window.dispatchEvent(new CustomEvent("changeTab", { detail: { tab: "vms" } }))
window.dispatchEvent(new CustomEvent("openVmStatusModal", { detail: { vmid } }))
}
function AppCard({
link,
openLabel,
isLightTheme,
editMode,
onEditCustom,
}: {
link: LaunchLink
openLabel: string
isLightTheme: boolean
editMode: boolean
onEditCustom: (customId: string) => void
}) {
const t = useT()
// Navigate to the bound guest's modal on the appropriate inner tab:
// LXC → App tab (where the weblink was registered), VM → Status tab
// (VMs don't have an App tab). Unbound custom links have no CT ref
// to click, so this handler is only wired when `link.vmid` exists.
const goToBoundGuest = (e: React.MouseEvent | React.KeyboardEvent) => {
e.preventDefault()
e.stopPropagation()
if (link.vmid == null) return
if (link.guestType === "qemu") {
openVmModalOnStatusTab(link.vmid)
} else {
openLxcModalOnAppTab(link.vmid)
}
}
const goToEditor = (e: React.MouseEvent | React.KeyboardEvent) => {
e.preventDefault()
e.stopPropagation()
if (link.customId) onEditCustom(link.customId)
}
const guestPrefix = link.guestType === "qemu" ? "VM" : "CT"
const hasBinding = link.vmid != null
return (
<a
href={link.weblink}
target="_blank"
rel="noopener noreferrer"
aria-label={openLabel.replace("{name}", link.appName)}
className="group relative flex flex-col gap-2 p-3.5 bg-card border border-border rounded-xl no-underline text-foreground hover:bg-white/5 hover:border-border/80 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring active:translate-y-px"
>
{/* Head: logo + name (+ update icon) */}
<div className="flex items-center gap-3">
<div className="w-14 h-14 rounded-md bg-muted/40 grid place-items-center flex-shrink-0 overflow-hidden">
{link.logoUrl ? (
<ThemeAwareLogo src={link.logoUrl} className="w-9 h-9 object-contain" />
) : (
<span className="text-[10px] font-mono text-muted-foreground uppercase">{link.appName.slice(0, 2)}</span>
)}
</div>
<div className="flex-1 min-w-0">
<div className="text-base font-semibold text-foreground truncate leading-tight">{link.appName}</div>
</div>
{/* Edit mode on a custom card takes over the update-icon slot
with a proper edit button — custom links never carry the
update signal, so nothing is displaced. Falls back to the
update icon in every other case. */}
{editMode && link.isCustom ? (
<button
type="button"
onClick={goToEditor}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") goToEditor(e) }}
className="h-8 w-8 rounded-md border border-border bg-background hover:bg-muted flex items-center justify-center flex-shrink-0 self-start text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={t("apps.customLinkEditAria", { name: link.appName })}
title={t("actions.edit")}
>
<Pencil className="h-4 w-4" />
</button>
) : link.updateAvailable && (
<ArrowUpCircle className="h-5 w-5 text-purple-400 flex-shrink-0 self-start mt-0.5" aria-hidden="true" />
)}
</div>
{/* Foot: weblink + CT ref + category chip */}
<div className="flex flex-col gap-1.5 mt-auto pt-2 border-t border-dashed border-border/60">
<div className="flex items-center gap-1.5 text-blue-400 text-sm font-mono truncate">
<ExternalLink className="h-3.5 w-3.5 flex-shrink-0 opacity-80" />
<span className="truncate">{link.weblink}</span>
</div>
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
{/* Guest ref → click opens that guest's modal (LXC → App
tab, VM → Status tab). stopPropagation keeps the outer
anchor from firing at the same time. Unbound custom
links: in edit mode show the edit button here, otherwise
show nothing. */}
{hasBinding && (
<button
type="button"
onClick={goToBoundGuest}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") goToBoundGuest(e) }}
className="inline-flex items-center gap-1.5 min-w-0 rounded hover:text-blue-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring cursor-pointer"
aria-label={t("apps.openGuestAriaLabel", {
name: link.ctName || String(link.vmid),
type: guestPrefix,
id: link.vmid!,
})}
>
<span className="font-mono px-1.5 py-0.5 rounded bg-muted/40 text-muted-foreground/90 flex-shrink-0">{link.vmid}</span>
<span className="truncate min-w-0">{link.ctName || `${guestPrefix} ${link.vmid}`}</span>
</button>
)}
{link.category && (
<span
style={categoryChipStyle(link.category, isLightTheme)}
className="ml-auto px-1.5 py-0.5 border rounded text-[10px] font-medium flex-shrink-0 truncate max-w-[45%]"
title={link.category}
>
{link.category}
</span>
)}
</div>
</div>
</a>
)
}
+33 -31
View File
@@ -7,12 +7,14 @@ import { Input } from "./ui/input"
import { Label } from "./ui/label"
import { Shield, Lock, User, AlertCircle, Eye, EyeOff, Upload, Trash2 } from "lucide-react"
import { getApiUrl } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
interface AuthSetupProps {
onComplete: () => void
}
export function AuthSetup({ onComplete }: AuthSetupProps) {
const t = useT()
const [open, setOpen] = useState(false)
const [step, setStep] = useState<"choice" | "setup">("choice")
const [username, setUsername] = useState("")
@@ -74,7 +76,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || "Failed to skip authentication")
throw new Error(data.error || t("authSetup.skipFailed"))
}
if (data.auth_declined) {
@@ -86,7 +88,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
onComplete()
} catch (err) {
console.error("Auth skip error:", err)
setError(err instanceof Error ? err.message : "Failed to save preference")
setError(err instanceof Error ? err.message : t("authSetup.savePreferenceFailed"))
} finally {
setLoading(false)
}
@@ -108,17 +110,17 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
setError("")
if (!username || !password) {
setError("Please fill in all fields")
setError(t("authSetup.fillFields"))
return
}
if (password !== confirmPassword) {
setError("Passwords do not match")
setError(t("authSetup.passwordMismatch"))
return
}
if (password.length < 6) {
setError("Password must be at least 6 characters")
setError(t("authSetup.passwordTooShort"))
return
}
@@ -137,7 +139,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || "Failed to setup authentication")
throw new Error(data.error || t("authSetup.setupFailed"))
}
if (data.token) {
@@ -204,7 +206,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
onComplete()
} catch (err) {
console.error("Auth setup error:", err)
setError(err instanceof Error ? err.message : "Failed to setup authentication")
setError(err instanceof Error ? err.message : t("authSetup.setupFailed"))
} finally {
setLoading(false)
}
@@ -214,7 +216,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
<DialogTitle className="sr-only">
{step === "choice" ? "Setup Dashboard Protection" : "Create Password"}
{step === "choice" ? t("authSetup.choiceTitle") : t("authSetup.passwordTitle")}
</DialogTitle>
{step === "choice" ? (
<div className="space-y-6 py-2">
@@ -222,16 +224,16 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
<div className="mx-auto w-16 h-16 bg-blue-500/10 rounded-full flex items-center justify-center">
<Shield className="h-8 w-8 text-blue-500" />
</div>
<h2 className="text-2xl font-bold">Protect Your Dashboard?</h2>
<h2 className="text-2xl font-bold">{t("authSetup.protectTitle")}</h2>
<p className="text-muted-foreground text-sm">
Add an extra layer of security to protect your Proxmox data when accessing from non-private networks.
{t("authSetup.protectDescription")}
</p>
</div>
<div className="space-y-3">
<Button onClick={() => setStep("setup")} className="w-full bg-blue-500 hover:bg-blue-600" size="lg">
<Lock className="h-4 w-4 mr-2" />
Yes, Setup Password
{t("authSetup.setupPassword")}
</Button>
<Button
onClick={handleSkipAuth}
@@ -240,11 +242,11 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
size="lg"
disabled={loading}
>
No, Continue Without Protection
{t("authSetup.skipProtection")}
</Button>
</div>
<p className="text-xs text-center text-muted-foreground">You can always enable this later in Settings</p>
<p className="text-xs text-center text-muted-foreground">{t("authSetup.enableLater")}</p>
</div>
) : (
<div className="space-y-6 py-2">
@@ -252,8 +254,8 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
<div className="mx-auto w-16 h-16 bg-blue-500/10 rounded-full flex items-center justify-center">
<Lock className="h-8 w-8 text-blue-500" />
</div>
<h2 className="text-2xl font-bold">Setup Authentication</h2>
<p className="text-muted-foreground text-sm">Create a username and password to protect your dashboard</p>
<h2 className="text-2xl font-bold">{t("authSetup.setupTitle")}</h2>
<p className="text-muted-foreground text-sm">{t("authSetup.setupDescription")}</p>
</div>
{error && (
@@ -266,14 +268,14 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username" className="text-sm">
Username
{t("authSetup.username")}
</Label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="username"
type="text"
placeholder="Enter username"
placeholder={t("authSetup.usernamePlaceholder")}
value={username}
onChange={(e) => setUsername(e.target.value)}
className="pl-10 text-base"
@@ -285,14 +287,14 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
<div className="space-y-2">
<Label htmlFor="password" className="text-sm">
Password
{t("authSetup.password")}
</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder="Enter password"
placeholder={t("authSetup.passwordPlaceholder")}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="pl-10 text-base"
@@ -312,14 +314,14 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
<div className="space-y-2">
<Label htmlFor="confirm-password" className="text-sm">
Confirm Password
{t("authSetup.confirmPassword")}
</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="confirm-password"
type={showConfirmPassword ? "text" : "password"}
placeholder="Confirm password"
placeholder={t("authSetup.confirmPasswordPlaceholder")}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="pl-10 text-base"
@@ -345,19 +347,19 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
setup endpoint returns the JWT. */}
<div className="pt-3 border-t border-border/60 space-y-4">
<p className="text-xs text-muted-foreground uppercase tracking-wider">
Profile · optional
{t("authSetup.profileOptional")}
</p>
<div className="space-y-2">
<Label htmlFor="display-name" className="text-sm">
Display name
{t("authSetup.displayName")}
</Label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="display-name"
type="text"
placeholder="Shown above the username in the menu"
placeholder={t("authSetup.displayNamePlaceholder")}
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
maxLength={64}
@@ -366,12 +368,12 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
/>
</div>
<p className="text-[11px] text-muted-foreground">
Leave empty to render the username itself. Up to 64 characters.
{t("authSetup.displayNameHint")}
</p>
</div>
<div className="space-y-2">
<Label className="text-sm">Avatar</Label>
<Label className="text-sm">{t("authSetup.avatar")}</Label>
<div className="flex items-center gap-3">
{avatarPreviewUrl ? (
// eslint-disable-next-line @next/next/no-img-element
@@ -407,7 +409,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
className="h-7 text-xs"
>
<Upload className="h-3 w-3 mr-1.5" />
{avatarFile ? "Change" : "Choose image"}
{avatarFile ? t("authSetup.change") : t("authSetup.chooseImage")}
</Button>
{avatarFile && (
<Button
@@ -419,12 +421,12 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
className="h-7 text-xs text-red-500 hover:text-red-500 hover:bg-red-500/10"
>
<Trash2 className="h-3 w-3 mr-1.5" />
Clear
{t("authSetup.clear")}
</Button>
)}
</div>
<p className="text-[11px] text-muted-foreground">
PNG, JPEG, WebP or GIF · up to 2 MB · pre-crop square for best results.
{t("authSetup.avatarHint")}
</p>
</div>
</div>
@@ -434,10 +436,10 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
<div className="space-y-2">
<Button onClick={handleSetupAuth} className="w-full bg-blue-500 hover:bg-blue-600" disabled={loading}>
{loading ? "Setting up..." : "Setup Authentication"}
{loading ? t("authSetup.settingUp") : t("authSetup.setupAuthentication")}
</Button>
<Button onClick={() => setStep("choice")} variant="ghost" className="w-full" disabled={loading}>
Back
{t("authSetup.back")}
</Button>
</div>
</div>
+8 -5
View File
@@ -11,6 +11,7 @@ import {
DropdownMenuTrigger,
} from "./ui/dropdown-menu"
import { fetchApi, getApiUrl, getAuthToken } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
interface AuthStatus {
auth_enabled?: boolean
@@ -57,6 +58,8 @@ interface AvatarMenuProps {
* proper /api/auth/logout that revokes the JWT server-side too.
*/
export function AvatarMenu({ size = "lg", onOpenSecurity, onOpenProfile }: AvatarMenuProps) {
const t = useT()
// IMPORTANT — all hooks must run unconditionally on every render. The
// previous version short-circuited with `if (!auth_enabled) return null`
// BEFORE the avatar blob hooks, so the hook count changed between
@@ -201,7 +204,7 @@ export function AvatarMenu({ size = "lg", onOpenSecurity, onOpenProfile }: Avata
<DropdownMenuTrigger asChild>
<button
className="rounded-full hover:ring-2 hover:ring-cyan-500/30 transition-all relative z-50 focus:outline-none focus-visible:outline-none active:outline-none data-[state=open]:outline-none data-[state=open]:ring-0 select-none"
aria-label="Open user menu"
aria-label={t("actions.openUserMenu")}
// WebKit ignores `outline` for the tap-highlight overlay
// shown on iOS / Android Chrome after a touch. That overlay
// was the white border that lingered on the avatar after
@@ -248,7 +251,7 @@ export function AvatarMenu({ size = "lg", onOpenSecurity, onOpenProfile }: Avata
<div className="text-xs text-muted-foreground truncate">{username}</div>
)}
{!profile?.display_name && (
<div className="text-xs text-muted-foreground truncate">Signed in</div>
<div className="text-xs text-muted-foreground truncate">{t("account.signedIn")}</div>
)}
</div>
</div>
@@ -257,13 +260,13 @@ export function AvatarMenu({ size = "lg", onOpenSecurity, onOpenProfile }: Avata
{onOpenProfile && (
<DropdownMenuItem onClick={onOpenProfile}>
<User className="h-4 w-4 mr-2" />
View profile
{t("account.viewProfile")}
</DropdownMenuItem>
)}
{onOpenSecurity && (
<DropdownMenuItem onClick={onOpenSecurity}>
<Shield className="h-4 w-4 mr-2" />
Security
{t("account.security")}
</DropdownMenuItem>
)}
{(onOpenProfile || onOpenSecurity) && <DropdownMenuSeparator />}
@@ -272,7 +275,7 @@ export function AvatarMenu({ size = "lg", onOpenSecurity, onOpenProfile }: Avata
className="text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400"
>
<LogOut className="h-4 w-4 mr-2" />
Sign out
{t("account.signOut")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
+306
View File
@@ -0,0 +1,306 @@
"use client"
import { useEffect, useState } from "react"
import { Trash2 } from "lucide-react"
import { fetchApi } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog"
import { Button } from "./ui/button"
import { Input } from "./ui/input"
import { Label } from "./ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
// Minimal shape we need from the /api/vms poll. Kept narrow so this
// component stays independent of the fuller VMData type used in
// virtual-machines.tsx.
export interface GuestOption {
vmid: number
name: string
type: "lxc" | "qemu"
}
export interface CustomLink {
id: string
name: string
url: string
logo_url: string
category: string
binding: { vmid: number; guest_type: "lxc" | "qemu" } | null
created_at?: number
updated_at?: number
}
export interface DraftCustomLink {
name: string
url: string
logo_url: string
category: string
bindingKey: string
}
const UNBOUND_KEY = "__none__"
function buildKey(binding: CustomLink["binding"]): string {
if (!binding) return UNBOUND_KEY
return `${binding.guest_type}:${binding.vmid}`
}
function parseKey(key: string): CustomLink["binding"] {
if (!key || key === UNBOUND_KEY) return null
const [type, vmid] = key.split(":")
if (type !== "lxc" && type !== "qemu") return null
const n = Number(vmid)
if (!Number.isFinite(n)) return null
return { guest_type: type, vmid: n }
}
export function CustomLinkEditor({
open,
onOpenChange,
editing,
guests,
categoryPresets,
onSaved,
}: {
open: boolean
onOpenChange: (v: boolean) => void
/** null = create; existing link = edit */
editing: CustomLink | null
/** VMs + LXCs from /api/vms so the user can bind a link to a guest */
guests: GuestOption[]
/** Populated from /api/apps/categories */
categoryPresets: string[]
/** Called on successful save/delete so the parent can refresh */
onSaved: () => void
}) {
const t = useT()
const [draft, setDraft] = useState<DraftCustomLink>({
name: "", url: "", logo_url: "", category: "", bindingKey: UNBOUND_KEY,
})
const [customCategoryMode, setCustomCategoryMode] = useState(false)
const [saving, setSaving] = useState(false)
const [deleting, setDeleting] = useState(false)
const [error, setError] = useState<string | null>(null)
// Reset the draft whenever the modal opens with a new target.
useEffect(() => {
if (!open) return
setError(null)
if (editing) {
setDraft({
name: editing.name,
url: editing.url,
logo_url: editing.logo_url || "",
category: editing.category || "",
bindingKey: buildKey(editing.binding),
})
setCustomCategoryMode(
!!editing.category && !categoryPresets.includes(editing.category),
)
} else {
setDraft({ name: "", url: "", logo_url: "", category: "", bindingKey: UNBOUND_KEY })
setCustomCategoryMode(false)
}
}, [open, editing, categoryPresets])
const canSave = draft.name.trim() && draft.url.trim() && !saving
const handleSave = async () => {
setError(null)
setSaving(true)
try {
const payload = {
name: draft.name.trim(),
url: draft.url.trim(),
logo_url: draft.logo_url.trim(),
category: draft.category.trim(),
binding: parseKey(draft.bindingKey),
}
if (editing) {
await fetchApi(`/api/apps/custom-links/${editing.id}`, {
method: "PUT",
body: JSON.stringify(payload),
headers: { "Content-Type": "application/json" },
})
} else {
await fetchApi("/api/apps/custom-links", {
method: "POST",
body: JSON.stringify(payload),
headers: { "Content-Type": "application/json" },
})
}
onSaved()
onOpenChange(false)
} catch (e: any) {
setError((e && e.message) || t("apps.customLinkSaveError"))
} finally {
setSaving(false)
}
}
const handleDelete = async () => {
if (!editing) return
setError(null)
setDeleting(true)
try {
await fetchApi(`/api/apps/custom-links/${editing.id}`, { method: "DELETE" })
onSaved()
onOpenChange(false)
} catch (e: any) {
setError((e && e.message) || t("apps.customLinkDeleteError"))
} finally {
setDeleting(false)
}
}
// Sort guests by vmid so the dropdown is easy to scan
const sortedGuests = [...guests].sort((a, b) => a.vmid - b.vmid)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[520px] bg-accent [&_input]:bg-background [&_[role=combobox]]:bg-background">
<DialogHeader>
<DialogTitle>
{editing ? t("apps.customLinkEditTitle") : t("apps.customLinkNewTitle")}
</DialogTitle>
</DialogHeader>
<div className="grid gap-3 py-2">
<div className="grid gap-1.5">
<Label htmlFor="cl-name" className="text-xs uppercase tracking-wider text-muted-foreground">{t("apps.customLinkName")}</Label>
<Input
id="cl-name"
autoFocus
value={draft.name}
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
placeholder={t("apps.customLinkNamePlaceholder")}
maxLength={80}
className="text-sm"
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="cl-url" className="text-xs uppercase tracking-wider text-muted-foreground">{t("apps.customLinkUrl")}</Label>
<Input
id="cl-url"
type="url"
value={draft.url}
onChange={(e) => setDraft((d) => ({ ...d, url: e.target.value }))}
placeholder="https://example.com"
maxLength={512}
className="text-sm font-mono"
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="cl-logo" className="text-xs uppercase tracking-wider text-muted-foreground">{t("apps.customLinkLogo")}</Label>
<Input
id="cl-logo"
type="url"
value={draft.logo_url}
onChange={(e) => setDraft((d) => ({ ...d, logo_url: e.target.value }))}
placeholder={t("apps.customLinkLogoPlaceholder")}
maxLength={512}
className="text-sm font-mono"
/>
</div>
<div className="grid gap-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">{t("apps.customLinkCategory")}</Label>
{customCategoryMode ? (
<Input
autoFocus
value={draft.category}
onChange={(e) => setDraft((d) => ({ ...d, category: e.target.value }))}
placeholder={t("vmLxc.appEditor.portCategoryCustomPlaceholder")}
maxLength={60}
className="text-sm"
onBlur={() => { if (!draft.category.trim()) setCustomCategoryMode(false) }}
/>
) : (
<Select
value={draft.category || "__none__"}
onValueChange={(v) => {
if (v === "__add__") {
setCustomCategoryMode(true)
setDraft((d) => ({ ...d, category: "" }))
} else if (v === "__none__") {
setDraft((d) => ({ ...d, category: "" }))
} else {
setDraft((d) => ({ ...d, category: v }))
}
}}
>
<SelectTrigger className="text-sm h-9">
<SelectValue placeholder={t("vmLxc.appEditor.portCategoryPlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">{t("vmLxc.appEditor.portCategoryNone")}</SelectItem>
{categoryPresets.map((c) => (
<SelectItem key={c} value={c}>{c}</SelectItem>
))}
<SelectItem value="__add__">{t("vmLxc.appEditor.portCategoryAddNew")}</SelectItem>
</SelectContent>
</Select>
)}
</div>
<div className="grid gap-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">{t("apps.customLinkBinding")}</Label>
<Select
value={draft.bindingKey}
onValueChange={(v) => setDraft((d) => ({ ...d, bindingKey: v }))}
>
<SelectTrigger className="text-sm h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={UNBOUND_KEY}>{t("apps.customLinkBindingNone")}</SelectItem>
{sortedGuests.map((g) => (
<SelectItem key={`${g.type}:${g.vmid}`} value={`${g.type}:${g.vmid}`}>
{g.type === "qemu" ? "VM" : "CT"} {g.vmid} · {g.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[11px] text-muted-foreground leading-relaxed">
{t("apps.customLinkBindingHelp")}
</p>
</div>
{error && (
<div className="text-xs text-red-400 bg-red-500/10 border border-red-500/30 rounded px-3 py-2">
{error}
</div>
)}
</div>
<DialogFooter className="gap-2 sm:justify-between">
{editing ? (
<Button
variant="ghost"
onClick={handleDelete}
disabled={deleting || saving}
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
>
<Trash2 className="h-4 w-4 mr-1.5" />
{t("apps.customLinkDelete")}
</Button>
) : <div />}
<div className="flex gap-2">
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={saving}>
{t("apps.customLinkCancel")}
</Button>
<Button
onClick={handleSave}
disabled={!canSave}
className="bg-blue-500 hover:bg-blue-600 !text-white"
>
{editing ? t("apps.customLinkSave") : t("apps.customLinkCreate")}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+12 -10
View File
@@ -6,6 +6,7 @@ import { Badge } from "./ui/badge"
import { AreaChart, Area, ResponsiveContainer, Tooltip, YAxis } from "recharts"
import { fetchApi } from "@/lib/api-config"
import { useDiskTempThresholds } from "@/lib/health-thresholds"
import { useT } from "@/lib/i18n/provider"
interface TempPoint {
timestamp: number
@@ -24,11 +25,11 @@ interface DiskTemperatureCardProps {
// Disk-temperature thresholds come from the user-configurable backend
// (lib/health-thresholds.ts). The classifier here takes the resolved
// pair so the consumer can read it from the hook once per render.
function statusFor(temp: number, t: { warn: number; hot: number }) {
if (temp <= 0) return { label: "N/A", className: "bg-gray-500/10 text-gray-500 border-gray-500/20", color: "#6b7280" }
if (temp >= t.hot) return { label: "Hot", className: "bg-red-500/10 text-red-500 border-red-500/20", color: "#ef4444" }
if (temp >= t.warn) return { label: "Warm", className: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20", color: "#f59e0b" }
return { label: "Normal", className: "bg-green-500/10 text-green-500 border-green-500/20", color: "#22c55e" }
function statusFor(temp: number, thresholds: { warn: number; hot: number }) {
if (temp <= 0) return { labelKey: "common.notAvailable", className: "bg-gray-500/10 text-gray-500 border-gray-500/20", color: "#6b7280" }
if (temp >= thresholds.hot) return { labelKey: "details.temperature.status.hot", className: "bg-red-500/10 text-red-500 border-red-500/20", color: "#ef4444" }
if (temp >= thresholds.warn) return { labelKey: "details.temperature.status.warm", className: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20", color: "#f59e0b" }
return { labelKey: "details.temperature.status.normal", className: "bg-green-500/10 text-green-500 border-green-500/20", color: "#22c55e" }
}
const MiniTooltip = ({ active, payload }: any) => {
@@ -55,6 +56,7 @@ export function DiskTemperatureCard({
diskType,
onOpenDetail,
}: DiskTemperatureCardProps) {
const t = useT()
const [data, setData] = useState<TempPoint[]>([])
const [loading, setLoading] = useState(true)
const cancelled = useRef(false)
@@ -98,7 +100,7 @@ export function DiskTemperatureCard({
})()
const status = statusFor(liveTemperature, dt)
const lineColor = status.color
const tempDisplay = liveTemperature > 0 ? `${liveTemperature}°C` : "N/A"
const tempDisplay = liveTemperature > 0 ? `${liveTemperature}°C` : t("common.notAvailable")
const samples = data.length
const interactive = !!onOpenDetail
@@ -112,11 +114,11 @@ export function DiskTemperatureCard({
"w-full text-left border border-white/10 rounded-lg p-3 bg-white/[0.02]",
interactive ? "cursor-pointer hover:bg-white/[0.04] transition-colors focus:outline-none focus:ring-1 focus:ring-white/20" : "",
].join(" ")}
title={interactive ? "Open temperature history" : undefined}
title={interactive ? t("details.temperature.openHistory") : undefined}
>
<div className="flex items-start justify-between gap-3 mb-1.5">
<div className="min-w-0">
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">Temperature</p>
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">{t("details.temperature.diskTitle")}</p>
<p className="text-xl font-bold leading-tight mt-0.5" style={{ color: lineColor }}>
{tempDisplay}
</p>
@@ -124,7 +126,7 @@ export function DiskTemperatureCard({
<div className="flex flex-col items-end gap-1 flex-shrink-0">
<Thermometer className="h-3.5 w-3.5" style={{ color: lineColor }} />
<Badge variant="outline" className={`${status.className} text-[10px] px-2 py-0`}>
{status.label}
{t(status.labelKey)}
</Badge>
</div>
</div>
@@ -134,7 +136,7 @@ export function DiskTemperatureCard({
<div className="h-full w-full animate-pulse bg-white/[0.03] rounded" />
) : samples < 2 ? (
<div className="h-full flex items-center justify-center text-[10px] text-muted-foreground">
Collecting samples chart populates after ~2 minutes
{t("details.temperature.collectingSamples")}
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
@@ -8,12 +8,13 @@ import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContai
import { useIsMobile } from "../hooks/use-mobile"
import { fetchApi } from "@/lib/api-config"
import { useDiskTempThresholds, type DiskTempThreshold } from "@/lib/health-thresholds"
import { useT } from "@/lib/i18n/provider"
const TIMEFRAME_OPTIONS = [
{ value: "hour", label: "1 Hour" },
{ value: "day", label: "24 Hours" },
{ value: "week", label: "7 Days" },
{ value: "month", label: "30 Days" },
{ value: "hour", labelKey: "details.temperature.timeframes.hour" },
{ value: "day", labelKey: "details.temperature.timeframes.day" },
{ value: "week", labelKey: "details.temperature.timeframes.week" },
{ value: "month", labelKey: "details.temperature.timeframes.month" },
]
interface TempHistoryPoint {
@@ -69,10 +70,10 @@ function colorFor(temp: number, t: DiskTempThreshold): string {
}
function statusInfoFor(temp: number, t: DiskTempThreshold) {
if (temp <= 0) return { status: "N/A", color: "bg-gray-500/10 text-gray-500 border-gray-500/20" }
if (temp >= t.hot) return { status: "Hot", color: "bg-red-500/10 text-red-500 border-red-500/20" }
if (temp >= t.warn) return { status: "Warm", color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" }
return { status: "Normal", color: "bg-green-500/10 text-green-500 border-green-500/20" }
if (temp <= 0) return { color: "bg-gray-500/10 text-gray-500 border-gray-500/20" }
if (temp >= t.hot) return { color: "bg-red-500/10 text-red-500 border-red-500/20" }
if (temp >= t.warn) return { color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" }
return { color: "bg-green-500/10 text-green-500 border-green-500/20" }
}
export function DiskTemperatureDetailModal({
@@ -83,6 +84,7 @@ export function DiskTemperatureDetailModal({
liveTemperature,
diskType,
}: DiskTemperatureDetailModalProps) {
const t = useT()
const [timeframe, setTimeframe] = useState("day")
const [data, setData] = useState<TempHistoryPoint[]>([])
const [stats, setStats] = useState<TempStats>({ min: 0, max: 0, avg: 0, current: 0 })
@@ -168,7 +170,7 @@ export function DiskTemperatureDetailModal({
<SelectContent>
{TIMEFRAME_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
{t(opt.labelKey)}
</SelectItem>
))}
</SelectContent>
@@ -181,24 +183,24 @@ export function DiskTemperatureDetailModal({
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-3">
<div className={`rounded-lg p-3 text-center border ${currentStatus.color}`}>
<div className="text-xs opacity-80 mb-1">Current</div>
<div className="text-lg font-bold">{currentTemp > 0 ? `${currentTemp}°C` : "N/A"}</div>
<div className="text-xs opacity-80 mb-1">{t("details.temperature.current")}</div>
<div className="text-lg font-bold">{currentTemp > 0 ? `${currentTemp}°C` : t("common.notAvailable")}</div>
</div>
<div className="bg-muted/50 rounded-lg p-3 text-center">
<div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1">
<TrendingDown className="h-3 w-3" /> Min
<TrendingDown className="h-3 w-3" /> {t("details.temperature.min")}
</div>
<div className="text-lg font-bold text-green-500">{stats.min}°C</div>
</div>
<div className="bg-muted/50 rounded-lg p-3 text-center">
<div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1">
<Minus className="h-3 w-3" /> Avg
<Minus className="h-3 w-3" /> {t("details.temperature.avg")}
</div>
<div className="text-lg font-bold text-foreground">{stats.avg}°C</div>
</div>
<div className="bg-muted/50 rounded-lg p-3 text-center">
<div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1">
<TrendingUp className="h-3 w-3" /> Max
<TrendingUp className="h-3 w-3" /> {t("details.temperature.max")}
</div>
<div className="text-lg font-bold text-red-500">{stats.max}°C</div>
</div>
@@ -216,8 +218,8 @@ export function DiskTemperatureDetailModal({
<div className="h-full flex items-center justify-center text-muted-foreground">
<div className="text-center">
<Thermometer className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>No temperature data yet for this disk</p>
<p className="text-sm mt-1">Samples are collected every 60 seconds</p>
<p>{t("details.temperature.noData")}</p>
<p className="text-sm mt-1">{t("details.temperature.sampleInterval")}</p>
</div>
</div>
) : (
@@ -250,7 +252,7 @@ export function DiskTemperatureDetailModal({
<Area
type="monotone"
dataKey="value"
name="Temperature"
name={t("details.temperature.seriesName")}
stroke={chartColor}
strokeWidth={2}
fill={`url(#diskTempGradient-${diskName})`}
@@ -1,6 +1,7 @@
"use client"
import { cn } from "@/lib/utils"
import { useT } from "../lib/i18n/provider"
interface SriovInfo {
role: "vf" | "pf-active" | "pf-idle"
@@ -26,6 +27,8 @@ export function GpuSwitchModeIndicator({
className,
sriovInfo,
}: GpuSwitchModeIndicatorProps) {
const t = useT()
// SR-IOV is a non-editable hardware state. Pending toggles don't apply here.
const displayMode = mode === "sriov" ? "sriov" : (pendingMode ?? mode)
const isLxcActive = displayMode === "lxc"
@@ -69,9 +72,11 @@ export function GpuSwitchModeIndicator({
// exactly how many VFs are active; for a VF we show its parent PF.
const sriovBadgeText = (() => {
if (!isSriovActive) return ""
if (sriovInfo?.role === "vf") return "SR-IOV VF"
if (sriovInfo?.vfCount && sriovInfo.vfCount > 0) return `SR-IOV ×${sriovInfo.vfCount}`
return "SR-IOV"
if (sriovInfo?.role === "vf") return t("hardware.gpuSwitch.sriovVf")
if (sriovInfo?.vfCount && sriovInfo.vfCount > 0) {
return t("hardware.gpuSwitch.sriovCount", { count: sriovInfo.vfCount })
}
return t("hardware.gpuSwitch.sriov")
})()
return (
@@ -124,7 +129,7 @@ export function GpuSwitchModeIndicator({
className="text-[14px] font-bold transition-all duration-300"
style={{ fontFamily: 'system-ui, sans-serif' }}
>
GPU
{t("hardware.gpuSwitch.gpu")}
</text>
</g>
@@ -268,7 +273,7 @@ export function GpuSwitchModeIndicator({
)}
style={{ fontFamily: 'system-ui, sans-serif' }}
>
LXC
{t("hardware.gpuSwitch.lxc")}
</text>
)}
{isSriovActive && (
@@ -279,7 +284,7 @@ export function GpuSwitchModeIndicator({
className="text-[9px] font-medium"
style={{ fontFamily: 'system-ui, sans-serif' }}
>
LXC
{t("hardware.gpuSwitch.lxc")}
</text>
)}
@@ -332,7 +337,7 @@ export function GpuSwitchModeIndicator({
)}
style={{ fontFamily: 'system-ui, sans-serif' }}
>
VM
{t("hardware.gpuSwitch.vm")}
</text>
)}
{isSriovActive && (
@@ -343,7 +348,7 @@ export function GpuSwitchModeIndicator({
className="text-[9px] font-medium"
style={{ fontFamily: 'system-ui, sans-serif' }}
>
VM
{t("hardware.gpuSwitch.vm")}
</text>
)}
</svg>
@@ -363,34 +368,47 @@ export function GpuSwitchModeIndicator({
)}
>
{isSriovActive
? "SR-IOV active"
? t("hardware.gpuSwitch.sriovActive")
: isLxcActive
? "Ready for LXC containers"
? t("hardware.gpuSwitch.readyForLxc")
: isVmActive
? "Ready for VM passthrough"
: "Mode unknown"}
? t("hardware.gpuSwitch.readyForVm")
: t("hardware.gpuSwitch.modeUnknown")}
</span>
<span className="text-sm text-muted-foreground">
{isSriovActive
? "Virtual Functions managed externally"
? t("hardware.gpuSwitch.virtualFunctionsExternal")
: isLxcActive
? "Native driver active"
? t("hardware.gpuSwitch.nativeDriverActive")
: isVmActive
? "VFIO-PCI driver active"
: "No driver detected"}
? t("hardware.gpuSwitch.vfioDriverActive")
: t("hardware.gpuSwitch.noDriverDetected")}
</span>
{isSriovActive && sriovInfo && (
<span className="text-xs font-mono text-teal-600/80 dark:text-teal-400/80">
{sriovInfo.role === "vf"
? `Virtual Function${sriovInfo.physfn ? ` · parent PF ${sriovInfo.physfn}` : ""}`
? t(
sriovInfo.physfn
? "hardware.gpuSwitch.virtualFunctionWithParent"
: "hardware.gpuSwitch.virtualFunction",
{ parent: sriovInfo.physfn || "" },
)
: sriovInfo.vfCount !== undefined
? `1 PF + ${sriovInfo.vfCount} VF${sriovInfo.vfCount === 1 ? "" : "s"}${sriovInfo.totalvfs ? ` / ${sriovInfo.totalvfs} max` : ""}`
? t(
sriovInfo.totalvfs
? "hardware.gpuSwitch.physicalFunctionWithMax"
: "hardware.gpuSwitch.physicalFunction",
{
count: sriovInfo.vfCount,
max: sriovInfo.totalvfs || "",
},
)
: null}
</span>
)}
{hasChanged && (
<span className="text-sm text-amber-500 font-medium animate-pulse">
Change pending...
{t("hardware.gpuSwitch.changePending")}
</span>
)}
</div>
File diff suppressed because it is too large Load Diff
+163 -132
View File
@@ -32,7 +32,7 @@ import {
FileText,
RefreshCw,
Shield,
Download,
ArrowUpCircle,
X,
Clock,
BellOff,
@@ -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)
@@ -277,21 +279,96 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
}
const getStatusBadge = (status: string) => {
const statusUpper = status?.toUpperCase()
switch (statusUpper) {
case "OK":
return <Badge className="bg-green-500 text-white hover:bg-green-500">OK</Badge>
case "INFO":
return <Badge className="bg-blue-500 text-white hover:bg-blue-500">Info</Badge>
case "WARNING":
return <Badge className="bg-yellow-500 text-white hover:bg-yellow-500">Warning</Badge>
case "CRITICAL":
return <Badge className="bg-red-500 text-white hover:bg-red-500">Critical</Badge>
case "UNKNOWN":
return <Badge className="bg-amber-500 text-white hover:bg-amber-500">UNKNOWN</Badge>
default:
return <Badge>Unknown</Badge>
const s = status?.toUpperCase()
const label =
s === "OK" ? t("healthStatus.status.ok") :
s === "INFO" ? t("healthStatus.status.info") :
s === "WARNING" ? t("healthStatus.status.warning") :
s === "CRITICAL" ? t("healthStatus.status.critical") :
t("healthStatus.status.unknown")
return <Badge variant="outline" className={getOutlineBadgeStyle(status)}>{label}</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: \d+ Proxmox storages unavailable: (.+) \(startup\)$/)
if (match) return t("healthStatus.details.startupStoragesChecking", { storages: match[1] })
match = value.match(/^Storage: (.+) not yet available \(startup\)$/)
if (match) return t("healthStatus.details.startupStorageChecking", { storage: match[1] })
match = value.match(/^(.+) not yet available \(startup\)$/)
if (match) return t("healthStatus.details.startupStorageChecking", { storage: match[1] })
match = value.match(/^\[Startup\] Storage '(.+)' is configured but not found on the server\. \(checking\.\.\.\)$/)
if (match) return t("healthStatus.details.startupStorageNotFound", { storage: match[1] })
match = value.match(/^\[Startup\] Storage '(.+)' is not available \(connection error or backend issue\)\. \(checking\.\.\.\)$/)
if (match) return t("healthStatus.details.startupStorageUnavailable", { storage: match[1] })
match = value.match(/^\[Startup\] Storage '(.+)' has status: (.+)\. \(checking\.\.\.\)$/)
if (match) return t("healthStatus.details.startupStorageStatus", { storage: match[1], status: match[2] })
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)
@@ -444,11 +521,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 +548,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 +586,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 +606,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 +626,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 +649,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 +660,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 +721,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 +734,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 +757,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
handleAcknowledge(`category_${key}_unknown`, hours)
}
busy={dismissingKey === `category_${key}_unknown`}
t={t}
/>
)}
</div>
@@ -722,7 +767,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
@@ -737,8 +782,8 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
onClick={() => setShowUpdateTerminal(true)}
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
<ArrowUpCircle className="h-4 w-4 mr-1.5" />
{t("healthStatus.updateNow")}
</Button>
</div>
)}
@@ -758,12 +803,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 +823,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 +860,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 +883,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 +891,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 +911,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 +925,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 +946,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>
+74 -45
View File
@@ -20,6 +20,7 @@ import {
Waves,
} from "lucide-react"
import { getApiUrl, getAuthToken } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
// Local fetch wrapper that *preserves* the JSON body on non-2xx
// responses so we can surface backend validation messages
@@ -80,7 +81,7 @@ interface ThresholdLeaf {
interface ThresholdsTree {
cpu: { warning: ThresholdLeaf; critical: ThresholdLeaf }
memory: { warning: ThresholdLeaf; critical: ThresholdLeaf; swap_critical: ThresholdLeaf }
memory: { warning: ThresholdLeaf; critical: ThresholdLeaf; swap_high: ThresholdLeaf; available_min: ThresholdLeaf }
host_storage: { warning: ThresholdLeaf; critical: ThresholdLeaf }
lxc_rootfs: { warning: ThresholdLeaf; critical: ThresholdLeaf }
cpu_temperature: { warning: ThresholdLeaf; critical: ThresholdLeaf }
@@ -149,7 +150,8 @@ const SECTIONS: SectionDef[] = [
fields: [
{ path: ["memory", "warning"], label: "Memory warning" },
{ path: ["memory", "critical"], label: "Memory critical" },
{ path: ["memory", "swap_critical"], label: "Swap critical" },
{ path: ["memory", "swap_high"], label: "Swap high" },
{ path: ["memory", "available_min"], label: "Memory available minimum" },
],
},
// ── Heat ────────────────────────────────────────────────────────
@@ -282,6 +284,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 +306,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 +329,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 +369,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 +378,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 +395,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 +409,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 +450,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 +533,12 @@ export function HealthThresholds() {
value={val}
onChange={(e) => setPending((p) => ({ ...p, [key]: e.target.value }))}
className={`absolute inset-0 w-full appearance-none bg-transparent pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-moz-range-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-8 [&::-webkit-slider-thumb]:w-8 sm:[&::-webkit-slider-thumb]:h-4 sm:[&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-background [&::-webkit-slider-thumb]:shadow [&::-moz-range-thumb]:h-8 [&::-moz-range-thumb]:w-8 sm:[&::-moz-range-thumb]:h-4 sm:[&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-background ${handleClass}`}
title={`Recommended: ${leaf.recommended}${unit}`}
title={`${t("settings.healthThresholds.recommended")}: ${leaf.recommended}${unit}`}
/>
</div>
<div className="grid grid-cols-2 gap-2 mt-1.5 text-[10px] uppercase tracking-wider text-muted-foreground">
<span>OK &lt; {val}{unit}</span>
<span className="text-right">{severity === "critical" ? "CRIT" : "WARN"} &gt; {val}{unit}</span>
<span>{t("settings.healthThresholds.ok")} &lt; {val}{unit}</span>
<span className="text-right">{severity === "critical" ? t("settings.healthThresholds.crit") : t("settings.healthThresholds.warn")} &gt; {val}{unit}</span>
</div>
</div>
)
@@ -641,7 +650,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 +661,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 +669,9 @@ export function HealthThresholds() {
"warn" starts and ends without having to read the handles. */}
{!options?.hideLabels && (
<div className="grid grid-cols-3 gap-2 mt-1.5 text-[10px] uppercase tracking-wider text-muted-foreground">
<span>OK &lt; {wVal}{unit}</span>
<span className="text-center">WARN {wVal}{cVal}{unit}</span>
<span className="text-right">CRIT &gt; {cVal}{unit}</span>
<span>{t("settings.healthThresholds.ok")} &lt; {wVal}{unit}</span>
<span className="text-center">{t("settings.healthThresholds.warn")} {wVal}{cVal}{unit}</span>
<span className="text-right">{t("settings.healthThresholds.crit")} &gt; {cVal}{unit}</span>
</div>
)}
</div>
@@ -675,14 +684,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 +701,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 +713,7 @@ export function HealthThresholds() {
) : (
<Check className="h-3 w-3" />
)}
Save
{t("actions.save")}
</button>
</>
) : (
@@ -712,17 +721,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,19 +739,23 @@ 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>
{/* Intentional exception to the global "edit mode contrast"
rule: the numeric inputs already carry semantic colored
backgrounds (red critical, amber warning, blue customised)
which are meaningful and more graphical than a plain form.
A sunken selector would either erase those tints or force
us to `!important` every one — cleaner to keep this card
untouched in edit mode. */}
<CardContent>
{loading ? (
<div className="flex items-center justify-center py-8">
<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 +780,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 +794,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>
@@ -800,20 +813,36 @@ export function HealthThresholds() {
))
) : section.id === "memory" ? (
// Memory & Swap is special: warn/crit pair for
// RAM, plus a single Swap threshold that has no
// companion (it's a "critical only" metric).
// Both use sliders so the section reads as one
// visual language end to end.
// RAM, plus the swap-pressure pair. Swap
// CRITICAL fires only when both conditions hold
// — swap file above `swap_high` AND available
// RAM below `available_min`. Rendering the two
// sliders one under the other under a shared
// header reads as "the two knobs of one signal".
<>
<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)
<div className="border-t border-border/40 pt-1.5 mt-1.5">
<div className="text-[11px] uppercase tracking-wider text-muted-foreground px-1">
{t("settings.healthThresholds.swapPressure")}
</div>
<p className="text-[11px] text-muted-foreground px-1 pt-1 pb-1 leading-snug">
{t("settings.healthThresholds.swapPressureHint")}
</p>
<div>
<div className="text-[10px] uppercase tracking-wider text-muted-foreground/80 px-1 pt-1">
{t("settings.healthThresholds.swapHighLabel")}
</div>
{renderSingleThresholdSlider(["memory", "swap_high"], "critical")}
</div>
<div>
<div className="text-[10px] uppercase tracking-wider text-muted-foreground/80 px-1">
{t("settings.healthThresholds.availableMinLabel")}
</div>
{renderSingleThresholdSlider(["memory", "available_min"], "warning")}
</div>
{renderSingleThresholdSlider(["memory", "swap_critical"], "critical")}
</div>
</>
) : section.fields.length === 2 &&
File diff suppressed because it is too large Load Diff
+209 -140
View File
@@ -9,19 +9,22 @@ import { Activity, TrendingDown, TrendingUp, Minus, RefreshCw, Wifi, FileText, S
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LineChart, Line } from "recharts"
import { useIsMobile } from "../hooks/use-mobile"
import { fetchApi } from "@/lib/api-config"
import { useT } from "../lib/i18n/provider"
type TFunction = (key: string, params?: Record<string, string | number>) => string
const TIMEFRAME_OPTIONS = [
{ value: "hour", label: "1 Hour" },
{ value: "6hour", label: "6 Hours" },
{ value: "day", label: "24 Hours" },
{ value: "3day", label: "3 Days" },
{ value: "week", label: "7 Days" },
{ value: "hour", labelKey: "network.latency.timeframes.hour" },
{ value: "6hour", labelKey: "network.latency.timeframes.sixHours" },
{ value: "day", labelKey: "network.latency.timeframes.day" },
{ value: "3day", labelKey: "network.latency.timeframes.threeDays" },
{ value: "week", labelKey: "network.latency.timeframes.week" },
]
const TARGET_OPTIONS = [
{ value: "gateway", label: "Gateway (Router)", shortLabel: "Gateway", realtime: false },
{ value: "cloudflare", label: "Cloudflare (1.1.1.1)", shortLabel: "Cloudflare", realtime: true },
{ value: "google", label: "Google DNS (8.8.8.8)", shortLabel: "Google DNS", realtime: true },
{ value: "gateway", labelKey: "network.latency.targets.gateway", shortLabelKey: "network.latency.targets.gatewayShort", realtime: false },
{ value: "cloudflare", labelKey: "network.latency.targets.cloudflare", shortLabelKey: "network.latency.targets.cloudflareShort", realtime: true },
{ value: "google", labelKey: "network.latency.targets.google", shortLabelKey: "network.latency.targets.googleShort", realtime: true },
]
// Realtime test configuration
@@ -60,7 +63,22 @@ interface LatencyDetailModalProps {
currentLatency?: number
}
const CustomTooltip = ({ active, payload, label }: any) => {
const getLatencyTimeframeLabel = (value: string, t: TFunction): string =>
TIMEFRAME_OPTIONS.find((option) => option.value === value)
? t(TIMEFRAME_OPTIONS.find((option) => option.value === value)!.labelKey)
: value
const getLatencyTargetLabel = (value: string, t: TFunction): string =>
TARGET_OPTIONS.find((option) => option.value === value)
? t(TARGET_OPTIONS.find((option) => option.value === value)!.labelKey)
: value
const getLatencyTargetShortLabel = (value: string, t: TFunction): string =>
TARGET_OPTIONS.find((option) => option.value === value)
? t(TARGET_OPTIONS.find((option) => option.value === value)!.shortLabelKey)
: value
const CustomTooltip = ({ active, payload, label, t }: any) => {
if (active && payload && payload.length) {
const entry = payload[0]
const data = entry?.payload
@@ -76,17 +94,17 @@ const CustomTooltip = ({ active, payload, label }: any) => {
<>
<div className="flex items-center gap-2">
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-green-500" />
<span className="text-xs text-gray-300 min-w-[60px]">Min:</span>
<span className="text-xs text-gray-300 min-w-[60px]">{t("network.labels.min")}:</span>
<span className="text-sm font-semibold text-green-400">{data.min} ms</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-blue-500" />
<span className="text-xs text-gray-300 min-w-[60px]">Avg:</span>
<span className="text-xs text-gray-300 min-w-[60px]">{t("network.labels.avg")}:</span>
<span className="text-sm font-semibold text-white">{data.value} ms</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-red-500" />
<span className="text-xs text-gray-300 min-w-[60px]">Max:</span>
<span className="text-xs text-gray-300 min-w-[60px]">{t("network.labels.max")}:</span>
<span className="text-sm font-semibold text-red-400">{data.max} ms</span>
</div>
</>
@@ -94,14 +112,14 @@ const CustomTooltip = ({ active, payload, label }: any) => {
// Simple latency display for single data points
<div className="flex items-center gap-2">
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-blue-500" />
<span className="text-xs text-gray-300 min-w-[60px]">Latency:</span>
<span className="text-xs text-gray-300 min-w-[60px]">{t("network.labels.latency")}:</span>
<span className="text-sm font-semibold text-white">{entry.value} ms</span>
</div>
)}
{packetLoss !== undefined && packetLoss > 0 && (
<div className="flex items-center gap-2">
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-orange-500" />
<span className="text-xs text-gray-300 min-w-[60px]">Pkt Loss:</span>
<span className="text-xs text-gray-300 min-w-[60px]">{t("network.labels.packetLossShort")}:</span>
<span className="text-sm font-semibold text-orange-400">{packetLoss}%</span>
</div>
)}
@@ -118,20 +136,38 @@ const getStatusColor = (latency: number) => {
return "#22c55e"
}
const getStatusInfo = (latency: number | null) => {
if (latency === null || latency === 0) return { status: "N/A", color: "bg-gray-500/10 text-gray-500 border-gray-500/20" }
if (latency < 50) return { status: "Excellent", color: "bg-green-500/10 text-green-500 border-green-500/20" }
if (latency < 100) return { status: "Good", color: "bg-green-500/10 text-green-500 border-green-500/20" }
if (latency < 200) return { status: "Fair", color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" }
return { status: "Poor", color: "bg-red-500/10 text-red-500 border-red-500/20" }
const getStatusInfo = (latency: number | null, t: TFunction) => {
if (latency === null || latency === 0) return { status: t("common.notAvailable"), color: "bg-gray-500/10 text-gray-500 border-gray-500/20" }
if (latency < 50) return { status: t("network.latency.status.excellent"), color: "bg-green-500/10 text-green-500 border-green-500/20" }
if (latency < 100) return { status: t("network.latency.status.good"), color: "bg-green-500/10 text-green-500 border-green-500/20" }
if (latency < 200) return { status: t("network.latency.status.fair"), color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" }
return { status: t("network.latency.status.poor"), color: "bg-red-500/10 text-red-500 border-red-500/20" }
}
const getStatusText = (latency: number | null): string => {
if (latency === null || latency === 0) return "N/A"
if (latency < 50) return "Excellent"
if (latency < 100) return "Good"
if (latency < 200) return "Fair"
return "Poor"
const getStatusKey = (latency: number | null): "na" | "excellent" | "good" | "fair" | "poor" => {
if (latency === null || latency === 0) return "na"
if (latency < 50) return "excellent"
if (latency < 100) return "good"
if (latency < 200) return "fair"
return "poor"
}
const getStatusText = (latency: number | null, t: TFunction): string => {
const key = getStatusKey(latency)
return key === "na" ? t("common.notAvailable") : t(`network.latency.status.${key}`)
}
const formatReportDuration = (seconds: number | undefined, t: TFunction, compact = false): string => {
if (!seconds || seconds <= 0) {
return compact ? t("network.latency.report.realTime") : t("network.latency.report.testPeriod")
}
if (seconds < 60) {
return t(compact ? "network.latency.report.secondsShort" : "network.latency.report.seconds", { count: seconds })
}
const minutes = Math.max(1, Math.round(seconds / 60))
return t(compact ? "network.latency.report.minutesShort" : "network.latency.report.minutes", { count: minutes })
}
interface ReportData {
@@ -145,9 +181,10 @@ interface ReportData {
testDuration?: number
}
const generateLatencyReport = (report: ReportData) => {
const generateLatencyReport = (report: ReportData, t: TFunction) => {
const now = new Date().toLocaleString()
const logoUrl = `${window.location.origin}/images/proxmenux-logo.png`
const htmlLang = document.documentElement.lang || "en"
// Calculate stats for realtime results - all values are individual ping measurements in latency_avg
const validRealtimeValues = report.realtimeResults.filter(r => r.latency_avg !== null).map(r => r.latency_avg!)
@@ -160,29 +197,57 @@ const generateLatencyReport = (report: ReportData) => {
} : null
const statusText = report.isRealtime
? getStatusText(realtimeStats?.current ?? null)
: getStatusText(report.stats.current)
? getStatusText(realtimeStats?.current ?? null, t)
: getStatusText(report.stats.current, t)
// Colors matching Lynis report
const statusColorMap: Record<string, string> = {
"Excellent": "#16a34a",
"Good": "#16a34a",
"Fair": "#ca8a04",
"Poor": "#dc2626",
"N/A": "#64748b"
excellent: "#16a34a",
good: "#16a34a",
fair: "#ca8a04",
poor: "#dc2626",
na: "#64748b",
}
const statusColor = statusColorMap[statusText] || "#64748b"
const statusKey = report.isRealtime
? getStatusKey(realtimeStats?.current ?? null)
: getStatusKey(report.stats.current)
const statusColor = statusColorMap[statusKey] || "#64748b"
const timeframeLabel = TIMEFRAME_OPTIONS.find(t => t.value === report.timeframe)?.label || report.timeframe
const timeframeLabel = getLatencyTimeframeLabel(report.timeframe, t)
const reportId = `PMXL-${Date.now().toString(36).toUpperCase()}`
const notAvailable = t("common.notAvailable")
const modeLabel = report.isRealtime
? t("network.latency.report.realTimeTest")
: t("network.latency.report.historicalAnalysis")
const realtimeDurationText = formatReportDuration(report.testDuration, t)
const realtimePacketLossText =
realtimeStats && realtimeStats.avgPacketLoss > 0
? `<span style="color:#dc2626">${t("network.latency.report.averagePacketLoss", {
value: realtimeStats.avgPacketLoss.toFixed(1),
})}</span>`
: `<span style="color:#16a34a">${t("network.latency.report.noPacketLoss")}</span>`
const testPeriodValue = report.isRealtime
? formatReportDuration(report.testDuration, t, true)
: timeframeLabel
const targetIpLabel =
report.target === "gateway"
? t("network.latency.report.defaultGateway")
: report.target === "cloudflare"
? "1.1.1.1"
: "8.8.8.8"
const detailSectionNumber =
(report.isRealtime && report.realtimeResults.length > 0) || (!report.isRealtime && report.data.length > 0)
? "6"
: "5"
// Build test results table for realtime mode - each row is now an individual ping measurement
const realtimeTableRows = report.realtimeResults.map((r, i) => `
<tr${r.packet_loss > 0 ? ' class="warn"' : ''}>
<td>${i + 1}</td>
<td>${new Date(r.timestamp || Date.now()).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}</td>
<td style="font-weight:600;color:${statusColorMap[getStatusText(r.latency_avg)] || '#64748b'}">${r.latency_avg !== null ? r.latency_avg.toFixed(1) + ' ms' : 'Failed'}</td>
<td style="font-weight:600;color:${statusColorMap[getStatusKey(r.latency_avg)] || '#64748b'}">${r.latency_avg !== null ? r.latency_avg.toFixed(1) + ' ms' : t("network.latency.report.failed")}</td>
<td${r.packet_loss > 0 ? ' style="color:#dc2626;font-weight:600;"' : ''}>${r.packet_loss}%</td>
<td><span class="f-tag" style="background:${statusColorMap[getStatusText(r.latency_avg)] || '#64748b'}15;color:${statusColorMap[getStatusText(r.latency_avg)] || '#64748b'}">${getStatusText(r.latency_avg)}</span></td>
<td><span class="f-tag" style="background:${statusColorMap[getStatusKey(r.latency_avg)] || '#64748b'}15;color:${statusColorMap[getStatusKey(r.latency_avg)] || '#64748b'}">${getStatusText(r.latency_avg, t)}</span></td>
</tr>
`).join('')
@@ -199,9 +264,9 @@ const generateLatencyReport = (report: ReportData) => {
<tr${d.packet_loss && d.packet_loss > 0 ? ' class="warn"' : ''}>
<td>${i + 1}</td>
<td>${new Date(d.timestamp * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</td>
<td style="font-weight:600;color:${statusColorMap[getStatusText(d.value)] || '#64748b'}">${d.value !== null ? d.value.toFixed(1) + ' ms' : 'Failed'}</td>
<td style="font-weight:600;color:${statusColorMap[getStatusKey(d.value)] || '#64748b'}">${d.value !== null ? d.value.toFixed(1) + ' ms' : t("network.latency.report.failed")}</td>
<td${d.packet_loss && d.packet_loss > 0 ? ' style="color:#dc2626;font-weight:600;"' : ''}>${d.packet_loss?.toFixed(1) ?? 0}%</td>
<td><span class="f-tag" style="background:${statusColorMap[getStatusText(d.value)] || '#64748b'}15;color:${statusColorMap[getStatusText(d.value)] || '#64748b'}">${getStatusText(d.value)}</span></td>
<td><span class="f-tag" style="background:${statusColorMap[getStatusKey(d.value)] || '#64748b'}15;color:${statusColorMap[getStatusKey(d.value)] || '#64748b'}">${getStatusText(d.value, t)}</span></td>
</tr>
`).join('')
@@ -210,7 +275,7 @@ const generateLatencyReport = (report: ReportData) => {
? report.realtimeResults.filter(r => r.latency_avg !== null).map(r => r.latency_avg!)
: report.data.map(d => d.value || 0)
let chartSvg = '<p style="text-align:center;color:#64748b;padding:20px;">Not enough data points for chart</p>'
let chartSvg = `<p style="text-align:center;color:#64748b;padding:20px;">${t("network.latency.report.notEnoughData")}</p>`
if (chartData.length >= 2) {
const rawMin = Math.min(...chartData)
const rawMax = Math.max(...chartData)
@@ -253,17 +318,17 @@ const generateLatencyReport = (report: ReportData) => {
<text x="${padding - 5}" y="${height - padding + 4}" font-size="9" fill="#64748b" text-anchor="end">${Math.round(minVal)}ms</text>
<polygon points="${areaPoints}" fill="url(#areaGrad)"/>
<polyline points="${points}" fill="none" stroke="#3b82f6" stroke-width="2"/>
<text x="${width / 2}" y="${height - 5}" font-size="9" fill="#64748b" text-anchor="middle">${chartData.length} samples</text>
<text x="${width / 2}" y="${height - 5}" font-size="9" fill="#64748b" text-anchor="middle">${t("network.latency.report.samples", { count: chartData.length })}</text>
</svg>
`
}
const html = `<!DOCTYPE html>
<html lang="en">
<html lang="${htmlLang}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Network Latency Report - ${report.targetLabel}</title>
<title>${t("network.latency.report.title")} - ${report.targetLabel}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #1a1a2e; background: #fff; font-size: 13px; line-height: 1.5; }
@@ -463,11 +528,11 @@ const generateLatencyReport = (report: ReportData) => {
<div class="top-bar no-print">
<div class="top-bar-left">
<div>
<div class="top-bar-title">ProxMenux Network Latency Report</div>
<div class="top-bar-subtitle">Review the report, then print or save as PDF</div>
<div class="top-bar-title">${t("network.latency.report.topBarTitle")}</div>
<div class="top-bar-subtitle">${t("network.latency.report.topBarSubtitle")}</div>
</div>
</div>
<button onclick="window.print()">Print / Save as PDF</button>
<button onclick="window.print()">${t("network.latency.report.printSavePdf")}</button>
</div>
<!-- Header -->
@@ -475,21 +540,21 @@ const generateLatencyReport = (report: ReportData) => {
<div class="rpt-header-left">
<img src="${logoUrl}" alt="ProxMenux" onerror="this.style.display='none'" />
<div>
<h1>Network Latency Report</h1>
<p>ProxMenux Monitor - Network Performance Analysis</p>
<h1>${t("network.latency.report.title")}</h1>
<p>${t("network.latency.report.subtitle")}</p>
</div>
</div>
<div class="rpt-header-right">
<div><strong>Date:</strong> ${now}</div>
<div><strong>Target:</strong> ${report.targetLabel}</div>
<div><strong>Mode:</strong> ${report.isRealtime ? 'Real-time Test' : 'Historical Analysis'}</div>
<div class="rid">ID: PMXL-${Date.now().toString(36).toUpperCase()}</div>
<div><strong>${t("network.latency.report.date")}:</strong> ${now}</div>
<div><strong>${t("network.latency.report.target")}:</strong> ${report.targetLabel}</div>
<div><strong>${t("network.latency.report.mode")}:</strong> ${modeLabel}</div>
<div class="rid">ID: ${reportId}</div>
</div>
</div>
<!-- 1. Executive Summary -->
<div class="section">
<div class="section-title">1. Executive Summary</div>
<div class="section-title">1. ${t("network.latency.report.executiveSummary")}</div>
<div class="exec-box">
<div class="latency-gauge">
<svg viewBox="0 0 120 90" width="160" height="120">
@@ -508,35 +573,41 @@ const generateLatencyReport = (report: ReportData) => {
<text x="98" y="87" font-size="7" fill="#64748b">300+</text>
</svg>
<div class="gauge-value" style="color:${statusColor};">
<span class="gauge-num">${report.isRealtime ? (realtimeStats?.avg?.toFixed(0) ?? 'N/A') : report.stats.avg}</span>
<span class="gauge-num">${report.isRealtime ? (realtimeStats?.avg?.toFixed(0) ?? notAvailable) : report.stats.avg}</span>
<span class="gauge-unit">ms</span>
</div>
<div class="gauge-status" style="color:${statusColor};">${statusText}</div>
</div>
<div class="exec-text">
<h3>Network Latency Assessment${report.isRealtime ? ' (Real-time)' : ''}</h3>
<h3>${t("network.latency.report.assessmentTitle")}${report.isRealtime ? ` (${t("network.latency.report.realTime")})` : ""}</h3>
<p>
${report.isRealtime
? `Real-time latency test to <strong>${report.targetLabel}</strong> with <strong>${report.realtimeResults.length} samples</strong> collected over ${report.testDuration ? Math.round(report.testDuration / 60) + ' minute(s)' : 'the test period'}.
Average latency: <strong style="color:${statusColor}">${realtimeStats?.avg?.toFixed(1) ?? 'N/A'} ms</strong>.
${realtimeStats && realtimeStats.avgPacketLoss > 0 ? `<span style="color:#dc2626">Average packet loss: ${realtimeStats.avgPacketLoss.toFixed(1)}%.</span>` : '<span style="color:#16a34a">No packet loss detected.</span>'}`
: `Historical latency analysis to <strong>Gateway</strong> over <strong>${timeframeLabel.toLowerCase()}</strong>.
<strong>${report.data.length} samples</strong> analyzed.
Average latency: <strong style="color:${statusColor}">${report.stats.avg} ms</strong>.`
? `${t("network.latency.report.realtimeSummary", {
target: `<strong>${report.targetLabel}</strong>`,
count: `<strong>${report.realtimeResults.length}</strong>`,
duration: realtimeDurationText,
avg: `<strong style="color:${statusColor}">${realtimeStats?.avg?.toFixed(1) ?? notAvailable} ms</strong>`,
})} ${realtimePacketLossText}`
: `${t("network.latency.report.historicalSummary", {
target: t("network.latency.targets.gatewayShort"),
timeframe: timeframeLabel.toLowerCase(),
count: report.data.length,
avg: report.stats.avg,
})}`
}
</p>
<div class="latency-range">
<div class="range-item">
<span class="range-label">Minimum</span>
<span class="range-value" style="color:#16a34a;">${report.isRealtime ? (realtimeStats?.min?.toFixed(1) ?? 'N/A') : report.stats.min} ms</span>
<span class="range-label">${t("network.labels.minimum")}</span>
<span class="range-value" style="color:#16a34a;">${report.isRealtime ? (realtimeStats?.min?.toFixed(1) ?? notAvailable) : report.stats.min} ms</span>
</div>
<div class="range-item">
<span class="range-label">Average</span>
<span class="range-value" style="color:${statusColor};">${report.isRealtime ? (realtimeStats?.avg?.toFixed(1) ?? 'N/A') : report.stats.avg} ms</span>
<span class="range-label">${t("network.labels.average")}</span>
<span class="range-value" style="color:${statusColor};">${report.isRealtime ? (realtimeStats?.avg?.toFixed(1) ?? notAvailable) : report.stats.avg} ms</span>
</div>
<div class="range-item">
<span class="range-label">Maximum</span>
<span class="range-value" style="color:#dc2626;">${report.isRealtime ? (realtimeStats?.max?.toFixed(1) ?? 'N/A') : report.stats.max} ms</span>
<span class="range-label">${t("network.labels.maximum")}</span>
<span class="range-value" style="color:#dc2626;">${report.isRealtime ? (realtimeStats?.max?.toFixed(1) ?? notAvailable) : report.stats.max} ms</span>
</div>
</div>
</div>
@@ -545,42 +616,40 @@ const generateLatencyReport = (report: ReportData) => {
<!-- 2. Statistics -->
<div class="section">
<div class="section-title">2. Latency Statistics</div>
<div class="section-title">2. ${t("network.latency.report.latencyStatistics")}</div>
<div class="grid-4">
<div class="card card-c">
<div class="card-value" style="color:${statusColor};">${report.isRealtime ? (realtimeStats?.current?.toFixed(1) ?? 'N/A') : report.stats.current}<span style="font-size:10px;color:#64748b;"> ms</span></div>
<div class="card-label">Current</div>
<div class="card-value" style="color:${statusColor};">${report.isRealtime ? (realtimeStats?.current?.toFixed(1) ?? notAvailable) : report.stats.current}<span style="font-size:10px;color:#64748b;"> ms</span></div>
<div class="card-label">${t("network.labels.current")}</div>
</div>
<div class="card card-c">
<div class="card-value" style="color:#16a34a;">${report.isRealtime ? (realtimeStats?.min?.toFixed(1) ?? 'N/A') : report.stats.min}<span style="font-size:10px;color:#64748b;"> ms</span></div>
<div class="card-label">Minimum</div>
<div class="card-value" style="color:#16a34a;">${report.isRealtime ? (realtimeStats?.min?.toFixed(1) ?? notAvailable) : report.stats.min}<span style="font-size:10px;color:#64748b;"> ms</span></div>
<div class="card-label">${t("network.labels.minimum")}</div>
</div>
<div class="card card-c">
<div class="card-value">${report.isRealtime ? (realtimeStats?.avg?.toFixed(1) ?? 'N/A') : report.stats.avg}<span style="font-size:10px;color:#64748b;"> ms</span></div>
<div class="card-label">Average</div>
<div class="card-value">${report.isRealtime ? (realtimeStats?.avg?.toFixed(1) ?? notAvailable) : report.stats.avg}<span style="font-size:10px;color:#64748b;"> ms</span></div>
<div class="card-label">${t("network.labels.average")}</div>
</div>
<div class="card card-c">
<div class="card-value" style="color:#dc2626;">${report.isRealtime ? (realtimeStats?.max?.toFixed(1) ?? 'N/A') : report.stats.max}<span style="font-size:10px;color:#64748b;"> ms</span></div>
<div class="card-label">Maximum</div>
<div class="card-value" style="color:#dc2626;">${report.isRealtime ? (realtimeStats?.max?.toFixed(1) ?? notAvailable) : report.stats.max}<span style="font-size:10px;color:#64748b;"> ms</span></div>
<div class="card-label">${t("network.labels.maximum")}</div>
</div>
</div>
<div class="grid-3">
<div class="card">
<div class="card-label">Sample Count</div>
<div class="card-label">${t("network.latency.report.sampleCount")}</div>
<div class="card-value">${report.isRealtime ? report.realtimeResults.length : report.data.length}</div>
</div>
<div class="card">
<div class="card-label">Packet Loss (Avg)</div>
<div class="card-label">${t("network.latency.report.packetLossAvg")}</div>
<div class="card-value" style="color:${(report.isRealtime ? (realtimeStats?.avgPacketLoss ?? 0) : parseFloat(historyStats?.avgPacketLoss ?? '0')) > 0 ? '#dc2626' : '#16a34a'};">
${report.isRealtime ? (realtimeStats?.avgPacketLoss?.toFixed(1) ?? '0') : (historyStats?.avgPacketLoss ?? '0')}%
</div>
</div>
<div class="card">
<div class="card-label">Test Period</div>
<div class="card-label">${t("network.latency.report.testPeriodLabel")}</div>
<div class="card-value" style="font-size:11px;">
${report.isRealtime
? (report.testDuration ? Math.round(report.testDuration / 60) + ' min' : 'Real-time')
: timeframeLabel}
${testPeriodValue}
</div>
</div>
</div>
@@ -588,7 +657,7 @@ const generateLatencyReport = (report: ReportData) => {
<!-- 3. Latency Graph (always section 3) -->
<div class="section">
<div class="section-title">3. Latency Graph</div>
<div class="section-title">3. ${t("network.latency.report.latencyGraph")}</div>
<div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:16px;">
${chartSvg}
</div>
@@ -596,37 +665,37 @@ const generateLatencyReport = (report: ReportData) => {
<!-- 4. Performance Thresholds (always section 4) -->
<div class="section">
<div class="section-title">4. Performance Thresholds</div>
<div class="section-title">4. ${t("network.latency.report.performanceThresholds")}</div>
<div class="threshold-item">
<div class="threshold-dot" style="background:#16a34a;"></div>
<p><strong>Excellent (&lt; 50ms):</strong> Optimal for real-time applications, gaming, and video calls.</p>
<p><strong>${t("network.latency.status.excellent")} (&lt; 50ms):</strong> ${t("network.latency.report.thresholdExcellent")}</p>
</div>
<div class="threshold-item">
<div class="threshold-dot" style="background:#16a34a;"></div>
<p><strong>Good (50-100ms):</strong> Acceptable for most applications with minimal impact.</p>
<p><strong>${t("network.latency.status.good")} (50-100ms):</strong> ${t("network.latency.report.thresholdGood")}</p>
</div>
<div class="threshold-item">
<div class="threshold-dot" style="background:#ca8a04;"></div>
<p><strong>Fair (100-200ms):</strong> Noticeable delay. May affect VoIP and interactive applications.</p>
<p><strong>${t("network.latency.status.fair")} (100-200ms):</strong> ${t("network.latency.report.thresholdFair")}</p>
</div>
<div class="threshold-item">
<div class="threshold-dot" style="background:#dc2626;"></div>
<p><strong>Poor (&gt; 200ms):</strong> Significant latency. Investigation recommended.</p>
<p><strong>${t("network.latency.status.poor")} (&gt; 200ms):</strong> ${t("network.latency.report.thresholdPoor")}</p>
</div>
</div>
${report.isRealtime && report.realtimeResults.length > 0 ? `
<!-- 5. Detailed Test Results (for Cloudflare / Google DNS) -->
<div class="section">
<div class="section-title">5. Detailed Test Results</div>
<div class="section-title">5. ${t("network.latency.report.detailedTestResults")}</div>
<table class="chk-tbl">
<thead>
<tr>
<th>#</th>
<th>Time</th>
<th>Latency</th>
<th>Packet Loss</th>
<th>Status</th>
<th>${t("network.labels.time")}</th>
<th>${t("network.labels.latency")}</th>
<th>${t("network.labels.packetLoss")}</th>
<th>${t("network.labels.status")}</th>
</tr>
</thead>
<tbody>
@@ -639,15 +708,15 @@ const generateLatencyReport = (report: ReportData) => {
${!report.isRealtime && report.data.length > 0 ? `
<!-- 5. Detailed History (for Gateway) -->
<div class="section">
<div class="section-title">5. Latency History (Last ${Math.min(20, report.data.length)} Records)</div>
<div class="section-title">5. ${t("network.latency.report.latencyHistory", { count: Math.min(20, report.data.length) })}</div>
<table class="chk-tbl">
<thead>
<tr>
<th>#</th>
<th>Time</th>
<th>Latency</th>
<th>Packet Loss</th>
<th>Status</th>
<th>${t("network.labels.time")}</th>
<th>${t("network.labels.latency")}</th>
<th>${t("network.labels.packetLoss")}</th>
<th>${t("network.labels.status")}</th>
</tr>
</thead>
<tbody>
@@ -659,41 +728,41 @@ ${!report.isRealtime && report.data.length > 0 ? `
<!-- Methodology -->
<div class="section">
<div class="section-title">${(report.isRealtime && report.realtimeResults.length > 0) || (!report.isRealtime && report.data.length > 0) ? '6' : '5'}. Methodology</div>
<div class="section-title">${detailSectionNumber}. ${t("network.latency.report.methodology")}</div>
<div class="grid-2">
<div class="card">
<div class="card-label">Test Method</div>
<div class="card-value" style="font-size:12px;">ICMP Echo Request (Ping)</div>
<div class="card-label">${t("network.latency.report.testMethod")}</div>
<div class="card-value" style="font-size:12px;">${t("network.latency.report.icmpEchoRequest")}</div>
</div>
<div class="card">
<div class="card-label">Samples per Test</div>
<div class="card-value" style="font-size:12px;">3 consecutive pings</div>
<div class="card-label">${t("network.latency.report.samplesPerTest")}</div>
<div class="card-value" style="font-size:12px;">${t("network.latency.report.threeConsecutivePings")}</div>
</div>
<div class="card">
<div class="card-label">Target</div>
<div class="card-label">${t("network.latency.report.target")}</div>
<div class="card-value" style="font-size:12px;">${report.targetLabel}</div>
</div>
<div class="card">
<div class="card-label">Target IP</div>
<div class="card-value" style="font-size:12px;">${report.target === 'gateway' ? 'Default Gateway' : report.target === 'cloudflare' ? '1.1.1.1' : '8.8.8.8'}</div>
<div class="card-label">${t("network.latency.report.targetIp")}</div>
<div class="card-value" style="font-size:12px;">${targetIpLabel}</div>
</div>
</div>
<div class="info-box">
<h4>Performance Assessment</h4>
<h4>${t("network.latency.report.performanceAssessment")}</h4>
<p>${
statusText === 'Excellent' ? 'Network latency is excellent. No action required.' :
statusText === 'Good' ? 'Network latency is within acceptable parameters.' :
statusText === 'Fair' ? 'Network latency is elevated. Consider investigating network congestion or routing issues.' :
statusText === 'Poor' ? 'Network latency is critically high. Immediate investigation recommended.' :
'Unable to determine network status.'
statusKey === 'excellent' ? t("network.latency.report.assessmentExcellent") :
statusKey === 'good' ? t("network.latency.report.assessmentGood") :
statusKey === 'fair' ? t("network.latency.report.assessmentFair") :
statusKey === 'poor' ? t("network.latency.report.assessmentPoor") :
t("network.latency.report.assessmentUnknown")
}</p>
</div>
</div>
<!-- Footer -->
<div class="rpt-footer">
<div>ProxMenux Monitor - Network Performance Report</div>
<div>Generated: ${now} | Report ID: PMXL-${Date.now().toString(36).toUpperCase()}</div>
<div>${t("network.latency.report.footerTitle")}</div>
<div>${t("network.latency.report.generated")}: ${now} | ${t("network.latency.report.reportId")}: ${reportId}</div>
</div>
</body>
@@ -706,6 +775,7 @@ ${!report.isRealtime && report.data.length > 0 ? `
}
export function LatencyDetailModal({ open, onOpenChange, currentLatency }: LatencyDetailModalProps) {
const t = useT()
const [timeframe, setTimeframe] = useState("hour")
const [target, setTarget] = useState("gateway")
const [data, setData] = useState<LatencyHistoryPoint[]>([])
@@ -882,7 +952,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
avg: Math.round((realtimeStats?.avg ?? 0) * 10) / 10,
} : stats
const statusInfo = getStatusInfo(displayStats.current)
const statusInfo = getStatusInfo(displayStats.current, t)
// Calculate test duration for report based on first and last result timestamps
const testDuration = realtimeResults.length >= 2
@@ -897,20 +967,20 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-foreground">
<Wifi className="h-5 w-5 text-blue-500" />
Network Latency
{t("network.cards.latency")}
</DialogTitle>
</DialogHeader>
<div className="flex items-center gap-2 mt-1 flex-nowrap">
<Select value={target} onValueChange={setTarget}>
<SelectTrigger className="w-[140px] sm:w-[180px] h-8 text-xs shrink-0">
<span className="truncate">
{TARGET_OPTIONS.find(t => t.value === target)?.shortLabel || target}
{getLatencyTargetShortLabel(target, t)}
</span>
</SelectTrigger>
<SelectContent>
{TARGET_OPTIONS.map(opt => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label}
{t(opt.labelKey)}
</SelectItem>
))}
</SelectContent>
@@ -923,7 +993,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
<SelectContent>
{TIMEFRAME_OPTIONS.map(opt => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label}
{t(opt.labelKey)}
</SelectItem>
))}
</SelectContent>
@@ -938,7 +1008,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
className="gap-1.5 text-red-500 border-red-500/30 hover:bg-red-500/10 shrink-0 h-8 px-3"
>
<Square className="h-3 w-3 fill-current" />
Stop
{t("network.latency.actions.stop")}
</Button>
) : (
<Button
@@ -948,7 +1018,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
className="gap-1.5 shrink-0 h-8 px-3"
>
<RefreshCw className="h-3 w-3" />
Test Again
{t("network.latency.actions.testAgain")}
</Button>
)
)}
@@ -957,19 +1027,19 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
size="sm"
onClick={() => generateLatencyReport({
target,
targetLabel: TARGET_OPTIONS.find(t => t.value === target)?.label || target,
targetLabel: getLatencyTargetLabel(target, t),
isRealtime,
stats,
realtimeResults,
data,
timeframe,
testDuration: isRealtime ? testDuration : undefined,
})}
}, t)}
disabled={isRealtime ? realtimeResults.length === 0 : data.length === 0}
className="gap-1.5 shrink-0 h-8 px-3"
>
<FileText className="h-3.5 w-3.5" />
Report
{t("network.latency.actions.report")}
</Button>
</div>
@@ -977,8 +1047,8 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
{isRealtime && realtimeTesting && (
<div className="mb-4">
<div className="flex items-center justify-between text-xs text-muted-foreground mb-1">
<span>Testing... {Math.round(testProgress)}%</span>
<span>{Math.round((REALTIME_TEST_DURATION * (1 - testProgress / 100)))}s remaining</span>
<span>{t("network.latency.testingProgress", { percent: Math.round(testProgress) })}</span>
<span>{t("network.latency.secondsRemaining", { seconds: Math.round((REALTIME_TEST_DURATION * (1 - testProgress / 100))) })}</span>
</div>
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
<div
@@ -992,7 +1062,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
{/* Stats Cards - Compact single row */}
<div className="flex items-center justify-between gap-1 mb-2 py-2 px-1 bg-muted/20 rounded-lg">
<div className="flex items-center gap-1 min-w-0">
<span className="text-[10px] text-muted-foreground">Current</span>
<span className="text-[10px] text-muted-foreground">{t("network.labels.current")}</span>
<span className="text-base font-bold" style={{ color: getStatusColor(displayStats.current || 0) }}>
{displayStats.current || '-'}
</span>
@@ -1000,19 +1070,19 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
</div>
<div className="flex items-center gap-1 min-w-0">
<TrendingDown className="h-3 w-3 text-green-500 shrink-0" />
<span className="text-[10px] text-muted-foreground">Min</span>
<span className="text-[10px] text-muted-foreground">{t("network.labels.min")}</span>
<span className="text-base font-bold text-green-500">{displayStats.min || '-'}</span>
<span className="text-[10px] text-muted-foreground">ms</span>
</div>
<div className="flex items-center gap-1 min-w-0">
<Minus className="h-3 w-3 shrink-0" />
<span className="text-[10px] text-muted-foreground">Avg</span>
<span className="text-[10px] text-muted-foreground">{t("network.labels.avg")}</span>
<span className="text-base font-bold">{displayStats.avg || '-'}</span>
<span className="text-[10px] text-muted-foreground">ms</span>
</div>
<div className="flex items-center gap-1 min-w-0">
<TrendingUp className="h-3 w-3 text-red-500 shrink-0" />
<span className="text-[10px] text-muted-foreground">Max</span>
<span className="text-[10px] text-muted-foreground">{t("network.labels.max")}</span>
<span className="text-base font-bold text-red-500">{displayStats.max || '-'}</span>
<span className="text-[10px] text-muted-foreground">ms</span>
</div>
@@ -1025,8 +1095,8 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
</Badge>
{isRealtime && (
<span className="text-xs text-muted-foreground">
{realtimeResults.length} sample{realtimeResults.length !== 1 ? 's' : ''} collected
{realtimeStats?.packetLoss ? ` | ${realtimeStats.packetLoss}% packet loss` : ''}
{t("network.latency.samplesCollected", { count: realtimeResults.length })}
{realtimeStats?.packetLoss ? ` | ${t("network.latency.packetLossValue", { value: realtimeStats.packetLoss })}` : ""}
</span>
)}
</div>
@@ -1058,7 +1128,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
domain={['dataMin - 1', 'dataMax + 2']}
tickFormatter={(v) => `${Number(v).toFixed(1)}ms`}
/>
<Tooltip content={<CustomTooltip />} />
<Tooltip content={<CustomTooltip t={t} />} />
<Area
type="monotone"
dataKey="value"
@@ -1075,7 +1145,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
<div className="h-full flex flex-col items-center justify-center text-muted-foreground">
<Activity className="h-12 w-12 mb-3 opacity-30" />
<p className="text-sm">
{realtimeTesting ? 'Collecting data...' : 'No data yet. Click "Test Again" to start.'}
{realtimeTesting ? t("network.latency.collectingData") : t("network.latency.noRealtimeData")}
</p>
</div>
)
@@ -1107,7 +1177,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
domain={['dataMin - 1', 'dataMax + 2']}
tickFormatter={(v) => `${Number(v).toFixed(1)}ms`}
/>
<Tooltip content={<CustomTooltip />} />
<Tooltip content={<CustomTooltip t={t} />} />
{/* For longer timeframes (6h+), show max values to preserve spikes.
For 1 hour view, show avg values since there's no downsampling */}
<Area
@@ -1123,8 +1193,8 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
) : (
<div className="h-full flex flex-col items-center justify-center text-muted-foreground">
<Activity className="h-12 w-12 mb-3 opacity-30" />
<p className="text-sm">No latency data available for this period</p>
<p className="text-xs mt-1">Data is collected every 60 seconds</p>
<p className="text-sm">{t("network.latency.noDataForPeriod")}</p>
<p className="text-xs mt-1">{t("network.latency.collectionInterval")}</p>
</div>
)}
</div>
@@ -1133,8 +1203,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
{isRealtime && (
<div className="mt-4 p-3 bg-blue-500/10 border border-blue-500/20 rounded-lg">
<p className="text-xs text-blue-400">
<strong>Real-time Mode:</strong> Tests run for 2 minutes with readings every 5 seconds.
Click "Test Again" to add more samples. All data is included in the report.
<strong>{t("network.latency.realTimeMode")}:</strong> {t("network.latency.realTimeModeDescription")}
</p>
</div>
)}
+52 -26
View File
@@ -2,13 +2,14 @@
import type React from "react"
import { useState, useEffect } from "react"
import { useState, useEffect, useRef } from "react"
import { Button } from "./ui/button"
import { Input } from "./ui/input"
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("")
@@ -24,6 +26,7 @@ export function Login({ onLogin }: LoginProps) {
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState("")
const [loading, setLoading] = useState(false)
const lastAutoSubmittedTotp = useRef("")
useEffect(() => {
// The Login screen is, by construction, the recovery path from any
@@ -51,17 +54,18 @@ export function Login({ onLogin }: LoginProps) {
}
}, [])
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
const submitLogin = async (totpOverride?: string) => {
setError("")
const token = totpOverride ?? totpCode
if (!username || !password) {
setError("Please enter username and password")
setError(t("login.missingCredentials"))
return
}
if (requiresTotp && !totpCode) {
setError("Please enter your 2FA code")
if (requiresTotp && !token) {
setError(t("login.missingTotp"))
return
}
@@ -74,20 +78,26 @@ export function Login({ onLogin }: LoginProps) {
body: JSON.stringify({
username,
password,
totp_token: totpCode || undefined, // Include 2FA code if provided
totp_token: token || undefined, // Include 2FA code if provided
}),
})
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,12 +117,28 @@ 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)
}
}
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
await submitLogin()
}
useEffect(() => {
if (!requiresTotp || loading || !/^\d{6}$/.test(totpCode)) {
return
}
if (lastAutoSubmittedTotp.current === totpCode) {
return
}
lastAutoSubmittedTotp.current = totpCode
void submitLogin(totpCode)
}, [requiresTotp, totpCode, loading])
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="w-full max-w-md space-y-8">
@@ -139,8 +165,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 +183,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 +202,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 +240,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,29 +249,29 @@ 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"
type="text"
placeholder="000000"
value={totpCode}
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
onChange={(e) => setTotpCode(e.target.value.toUpperCase().replace(/[^A-Z0-9-]/g, "").slice(0, 9))}
className="text-center text-lg tracking-widest font-mono text-base"
maxLength={6}
maxLength={9}
disabled={loading}
autoComplete="one-time-code"
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 +286,18 @@ export function Login({ onLogin }: LoginProps) {
}}
className="w-full"
>
Back to login
{t("login.backToLogin")}
</Button>
</div>
)}
<Button type="submit" className="w-full bg-blue-500 hover:bg-blue-600" disabled={loading}>
{loading ? "Signing in..." : requiresTotp ? "Verify Code" : "Sign In"}
{loading ? t("login.signingIn") : requiresTotp ? t("login.verifyCode") : t("login.signIn")}
</Button>
</form>
</div>
<p className="text-center text-sm text-muted-foreground">ProxMenux Monitor v1.2.4</p>
<p className="text-center text-sm text-muted-foreground">{t("login.version")}</p>
</div>
</div>
)
File diff suppressed because it is too large Load Diff
+79 -71
View File
@@ -1,7 +1,7 @@
"use client"
import type React from "react"
import { useState, useEffect, useRef, useCallback } from "react"
import { useState, useEffect, useRef, useCallback, useMemo } from "react"
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import {
@@ -37,6 +37,7 @@ import { Dialog as SearchDialog, DialogContent as SearchDialogContent, DialogTit
import "xterm/css/xterm.css"
import { API_PORT, fetchApi } from "@/lib/api-config"
import { getTicketedWsUrl } from "@/lib/terminal-ws"
import { useT } from "@/lib/i18n/provider"
interface LxcTerminalModalProps {
open: boolean
@@ -51,33 +52,35 @@ interface CheatSheetResult {
examples: string[]
}
const proxmoxCommands = [
{ cmd: "ls -la", desc: "List all files with details" },
{ cmd: "cd /path/to/dir", desc: "Change directory" },
{ cmd: "cat filename", desc: "Display file contents" },
{ cmd: "grep 'pattern' file", desc: "Search for pattern in file" },
{ cmd: "find . -name 'file'", desc: "Find files by name" },
{ cmd: "df -h", desc: "Show disk usage" },
{ cmd: "du -sh *", desc: "Show directory sizes" },
{ cmd: "free -h", desc: "Show memory usage" },
{ cmd: "top", desc: "Show running processes" },
{ cmd: "ps aux | grep process", desc: "Find running process" },
{ cmd: "systemctl status service", desc: "Check service status" },
{ cmd: "systemctl restart service", desc: "Restart a service" },
{ cmd: "apt update && apt upgrade", desc: "Update packages" },
{ cmd: "apt install package", desc: "Install package" },
{ cmd: "tail -f /var/log/syslog", desc: "Follow log file" },
{ cmd: "chmod 755 file", desc: "Change file permissions" },
{ cmd: "chown user:group file", desc: "Change file owner" },
{ cmd: "tar -xzf file.tar.gz", desc: "Extract tar.gz archive" },
{ cmd: "docker ps", desc: "List running containers" },
{ cmd: "docker images", desc: "List Docker images" },
{ cmd: "ip addr show", desc: "Show IP addresses" },
{ cmd: "ping host", desc: "Test network connectivity" },
{ cmd: "curl -I url", desc: "Get HTTP headers" },
{ cmd: "history", desc: "Show command history" },
{ cmd: "clear", desc: "Clear terminal screen" },
]
const LXC_COMMANDS = [
{ cmd: "ls -la", descKey: "listFiles" },
{ cmd: "cd /path/to/dir", descKey: "changeDirectory" },
{ cmd: "cat filename", descKey: "displayFile" },
{ cmd: "grep 'pattern' file", descKey: "searchPattern" },
{ cmd: "find . -name 'file'", descKey: "findFiles" },
{ cmd: "df -h", descKey: "diskUsage" },
{ cmd: "du -sh *", descKey: "directorySizes" },
{ cmd: "free -h", descKey: "memoryUsage" },
{ cmd: "top", descKey: "runningProcesses" },
{ cmd: "ps aux | grep process", descKey: "findProcess" },
{ cmd: "systemctl status service", descKey: "serviceStatus" },
{ cmd: "systemctl restart service", descKey: "restartService" },
{ cmd: "apt update && apt upgrade", descKey: "updatePackages" },
{ cmd: "apt install package", descKey: "installPackage" },
{ cmd: "tail -f /var/log/syslog", descKey: "followLog" },
{ cmd: "chmod 755 file", descKey: "changePermissions" },
{ cmd: "chown user:group file", descKey: "changeOwner" },
{ cmd: "tar -xzf file.tar.gz", descKey: "extractArchive" },
{ cmd: "docker ps", descKey: "listContainers" },
{ cmd: "docker images", descKey: "listImages" },
{ cmd: "ip addr show", descKey: "showIpAddresses" },
{ cmd: "ping host", descKey: "testConnectivity" },
{ cmd: "curl -I url", descKey: "httpHeaders" },
{ cmd: "history", descKey: "commandHistory" },
{ cmd: "clear", descKey: "clearScreen" },
] as const
type LocalCommand = { cmd: string; desc: string }
function getWebSocketUrl(): string {
if (typeof window === "undefined") {
@@ -101,6 +104,7 @@ export function LxcTerminalModal({
vmid,
vmName,
}: LxcTerminalModalProps) {
const t = useT()
const termRef = useRef<any>(null)
const wsRef = useRef<WebSocket | null>(null)
const fitAddonRef = useRef<any>(null)
@@ -121,12 +125,18 @@ export function LxcTerminalModal({
// Search state
const [searchModalOpen, setSearchModalOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState("")
const [filteredCommands, setFilteredCommands] = useState<Array<{ cmd: string; desc: string }>>(proxmoxCommands)
const localCommands = useMemo<LocalCommand[]>(
() => LXC_COMMANDS.map((item) => ({ cmd: item.cmd, desc: t(`lxcTerminal.commands.${item.descKey}`) })),
[t],
)
const [filteredCommands, setFilteredCommands] = useState<LocalCommand[]>([])
const [isSearching, setIsSearching] = useState(false)
const [searchResults, setSearchResults] = useState<CheatSheetResult[]>([])
const [useOnline, setUseOnline] = useState(true)
useEffect(() => {
setFilteredCommands(localCommands)
}, [localCommands])
// Detect mobile/tablet
useEffect(() => {
@@ -278,7 +288,7 @@ export function LxcTerminalModal({
// through Number without losing fidelity.
const id = Number(vmid)
if (!Number.isInteger(id) || id <= 0 || id >= 1_000_000) {
term.writeln('\r\n\x1b[31m[ERROR] Invalid VMID — refusing to execute pct enter\x1b[0m')
term.writeln(`\r\n\x1b[31m[ERROR] ${t("lxcTerminal.errors.invalidVmid")}\x1b[0m`)
return
}
ws.send(`pct enter ${id}\r`)
@@ -287,7 +297,7 @@ export function LxcTerminalModal({
ws.onerror = () => {
setConnectionStatus("offline")
term.writeln("\r\n\x1b[31m[ERROR] WebSocket connection error\x1b[0m")
term.writeln(`\r\n\x1b[31m[ERROR] ${t("terminal.websocketError")}\x1b[0m`)
}
ws.onclose = () => {
@@ -295,7 +305,7 @@ export function LxcTerminalModal({
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current)
}
term.writeln("\r\n\x1b[33m[INFO] Connection closed\x1b[0m")
term.writeln(`\r\n\x1b[33m[INFO] ${t("terminal.connectionClosed")}\x1b[0m`)
}
term.onData((data) => {
@@ -395,7 +405,7 @@ export function LxcTerminalModal({
termRef.current.dispose()
}
}
}, [isOpen, vmid])
}, [isOpen, vmid, t])
// Resize handling
useEffect(() => {
@@ -478,7 +488,7 @@ export function LxcTerminalModal({
const searchCheatSh = async (query: string) => {
if (!query.trim()) {
setSearchResults([])
setFilteredCommands(proxmoxCommands)
setFilteredCommands(localCommands)
return
}
@@ -491,7 +501,7 @@ export function LxcTerminalModal({
})
if (!data.success || !data.examples || data.examples.length === 0) {
throw new Error("No examples found")
throw new Error(t("terminal.noExamplesFound"))
}
const formattedResults: CheatSheetResult[] = data.examples.map((example: any) => ({
@@ -503,7 +513,7 @@ export function LxcTerminalModal({
setUseOnline(true)
setSearchResults(formattedResults)
} catch (error) {
const filtered = proxmoxCommands.filter(
const filtered = localCommands.filter(
(item) =>
item.cmd.toLowerCase().includes(query.toLowerCase()) ||
item.desc.toLowerCase().includes(query.toLowerCase()),
@@ -521,12 +531,12 @@ export function LxcTerminalModal({
searchCheatSh(searchQuery)
} else {
setSearchResults([])
setFilteredCommands(proxmoxCommands)
setFilteredCommands(localCommands)
}
}, 800)
return () => clearTimeout(debounce)
}, [searchQuery])
}, [searchQuery, localCommands, t])
const handleClear = useCallback(() => {
if (termRef.current) {
@@ -565,7 +575,7 @@ export function LxcTerminalModal({
{/* Header */}
<div className="flex items-center justify-between px-4 py-2 bg-zinc-900 border-b border-zinc-800">
<DialogTitle className="text-sm font-medium text-white">
Terminal: {vmName} (ID: {vmid})
{t("lxcTerminal.title", { name: vmName, id: vmid })}
</DialogTitle>
<div className="flex gap-2">
<Button
@@ -576,7 +586,7 @@ export function LxcTerminalModal({
className="h-8 gap-2 bg-blue-600/20 hover:bg-blue-600/30 border-blue-600/50 text-blue-400 disabled:opacity-50"
>
<Search className="h-4 w-4" />
<span className="hidden sm:inline">Search</span>
<span className="hidden sm:inline">{t("terminal.search")}</span>
</Button>
<Button
onClick={handleClear}
@@ -586,7 +596,7 @@ export function LxcTerminalModal({
className="h-8 gap-2 bg-yellow-600/20 hover:bg-yellow-600/30 border-yellow-600/50 text-yellow-400 disabled:opacity-50"
>
<Trash2 className="h-4 w-4" />
<span className="hidden sm:inline">Clear</span>
<span className="hidden sm:inline">{t("terminal.clear")}</span>
</Button>
</div>
</div>
@@ -673,29 +683,29 @@ export function LxcTerminalModal({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="text-xs text-muted-foreground">Control Sequences</DropdownMenuLabel>
<DropdownMenuLabel className="text-xs text-muted-foreground">{t("scriptTerminal.controlSequences")}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => sendKey("\x03")}>
<span className="font-mono text-xs mr-2">Ctrl+C</span>
<span className="text-muted-foreground text-xs">Cancel/Interrupt</span>
<span className="text-muted-foreground text-xs">{t("scriptTerminal.cancelInterrupt")}</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => sendKey("\x18")}>
<span className="font-mono text-xs mr-2">Ctrl+X</span>
<span className="text-muted-foreground text-xs">Exit (nano)</span>
<span className="text-muted-foreground text-xs">{t("scriptTerminal.exitNano")}</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => sendKey("\x12")}>
<span className="font-mono text-xs mr-2">Ctrl+R</span>
<span className="text-muted-foreground text-xs">Search history</span>
<span className="text-muted-foreground text-xs">{t("scriptTerminal.searchHistory")}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-muted-foreground">Clipboard</DropdownMenuLabel>
<DropdownMenuLabel className="text-xs text-muted-foreground">{t("scriptTerminal.clipboard")}</DropdownMenuLabel>
<DropdownMenuItem onSelect={() => { void handleCopy() }}>
<Copy className="h-3.5 w-3.5 mr-2" />
<span className="text-xs">Copy selection</span>
<span className="text-xs">{t("scriptTerminal.copySelection")}</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => { void handlePaste() }}>
<Clipboard className="h-3.5 w-3.5 mr-2" />
<span className="text-xs">Paste</span>
<span className="text-xs">{t("scriptTerminal.paste")}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -716,7 +726,7 @@ export function LxcTerminalModal({
: "bg-red-500"
}`}
/>
<span className="text-xs text-zinc-400 capitalize">{connectionStatus}</span>
<span className="text-xs text-zinc-400">{t(`scriptTerminal.${connectionStatus}`)}</span>
</div>
<Button
onClick={onClose}
@@ -725,7 +735,7 @@ export function LxcTerminalModal({
className="h-8 gap-2 bg-red-600/20 hover:bg-red-600/30 border-red-600/50 text-red-400"
>
<X className="h-4 w-4" />
<span className="hidden sm:inline">Close</span>
<span className="hidden sm:inline">{t("actions.close")}</span>
</Button>
</div>
</DialogContent>
@@ -734,22 +744,22 @@ export function LxcTerminalModal({
<SearchDialog open={searchModalOpen} onOpenChange={setSearchModalOpen}>
<SearchDialogContent className="max-w-3xl max-h-[85vh] overflow-hidden flex flex-col">
<DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-4 border-b border-zinc-800">
<SearchDialogTitle className="text-xl font-semibold">Search Commands</SearchDialogTitle>
<SearchDialogTitle className="text-xl font-semibold">{t("terminal.searchCommands")}</SearchDialogTitle>
<div className="flex items-center gap-2">
<div
className={`w-2 h-2 rounded-full ${useOnline ? "bg-green-500" : "bg-red-500"}`}
title={useOnline ? "Online - Using cheat.sh API" : "Offline - Using local commands"}
title={useOnline ? t("terminal.onlineSource") : t("terminal.offlineSource")}
/>
</div>
</DialogHeader>
<DialogDescription className="sr-only">Search for Linux commands</DialogDescription>
<DialogDescription className="sr-only">{t("terminal.searchDescription")}</DialogDescription>
<div className="space-y-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
<Input
placeholder="Search commands... (e.g., tar, docker, systemctl)"
placeholder={t("terminal.searchPlaceholder")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 bg-zinc-900 border-zinc-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 text-base"
@@ -763,7 +773,7 @@ export function LxcTerminalModal({
{isSearching && (
<div className="text-center py-4 text-zinc-400">
<div className="animate-spin inline-block w-6 h-6 border-2 border-current border-t-transparent rounded-full mb-2" />
<p className="text-sm">Searching cheat.sh...</p>
<p className="text-sm">{t("terminal.searchingCheatSh")}</p>
</div>
)}
@@ -790,7 +800,7 @@ export function LxcTerminalModal({
<div className="text-center py-2">
<p className="text-xs text-zinc-500">
<Lightbulb className="inline-block w-3 h-3 mr-1" />
Powered by cheat.sh
{t("terminal.poweredByCheatSh")}
</p>
</div>
</>
@@ -816,13 +826,13 @@ export function LxcTerminalModal({
className="shrink-0 h-7 px-2 text-xs"
>
<Send className="h-3 w-3 mr-1" />
Send
{t("terminal.send")}
</Button>
</div>
</div>
))
) : !isSearching && !searchQuery && !useOnline ? (
proxmoxCommands.map((item, index) => (
localCommands.map((item, index) => (
<div
key={index}
onClick={() => sendToTerminal(item.cmd)}
@@ -843,7 +853,7 @@ export function LxcTerminalModal({
className="shrink-0 h-7 px-2 text-xs"
>
<Send className="h-3 w-3 mr-1" />
Send
{t("terminal.send")}
</Button>
</div>
</div>
@@ -854,17 +864,17 @@ export function LxcTerminalModal({
<>
<Search className="w-12 h-12 text-zinc-600 mx-auto" />
<div>
<p className="text-zinc-400 font-medium">{"No results found for \""}{searchQuery}{"\""}</p>
<p className="text-xs text-zinc-500 mt-1">Try a different command or check your spelling</p>
<p className="text-zinc-400 font-medium">{t("terminal.noResults", { query: searchQuery })}</p>
<p className="text-xs text-zinc-500 mt-1">{t("terminal.tryDifferentSearch")}</p>
</div>
</>
) : (
<>
<Terminal className="w-12 h-12 text-zinc-600 mx-auto" />
<div>
<p className="text-zinc-400 font-medium mb-2">Search for any command</p>
<p className="text-zinc-400 font-medium mb-2">{t("terminal.searchAnyCommand")}</p>
<div className="text-sm text-zinc-500 space-y-1">
<p>Try searching for:</p>
<p>{t("terminal.trySearchingFor")}</p>
<div className="flex flex-wrap justify-center gap-2 mt-2">
{["tar", "grep", "docker", "systemctl", "curl"].map((cmd) => (
<code
@@ -881,7 +891,7 @@ export function LxcTerminalModal({
{useOnline && (
<div className="flex items-center justify-center gap-2 text-xs text-zinc-600 mt-4">
<Lightbulb className="w-3 h-3" />
<span>Powered by cheat.sh</span>
<span>{t("terminal.poweredByCheatSh")}</span>
</div>
)}
</>
@@ -890,13 +900,11 @@ export function LxcTerminalModal({
) : null}
</div>
<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>
{useOnline && searchResults.length > 0 && (
<div className="pt-2 border-t border-zinc-800 text-xs text-zinc-500 text-right">
<span className="text-zinc-600">{t("terminal.poweredByCheatSh")}</span>
</div>
{useOnline && searchResults.length > 0 && <span className="text-zinc-600">Powered by cheat.sh</span>}
</div>
)}
</div>
</SearchDialogContent>
</SearchDialog>
+27 -21
View File
@@ -5,6 +5,7 @@ import { Boxes, Info, Loader2, Settings2, CheckCircle2 } from "lucide-react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
import { Badge } from "./ui/badge"
import { fetchApi } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
interface DetectionResponse {
success: boolean
@@ -14,6 +15,7 @@ interface DetectionResponse {
}
export function LxcUpdateDetection() {
const t = useT()
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [enabled, setEnabled] = useState<boolean>(true)
@@ -32,11 +34,11 @@ export function LxcUpdateDetection() {
setEnabled(data.enabled)
setPending(data.enabled)
} else {
setError(data.message || "Failed to load setting")
setError(data.message || t("settings.lxcUpdateDetection.loadFailed"))
}
})
.catch(e => {
if (!cancelled) setError(String(e))
if (!cancelled) setError(t("settings.lxcUpdateDetection.loadFailed"))
})
.finally(() => {
if (!cancelled) setLoading(false)
@@ -77,7 +79,7 @@ export function LxcUpdateDetection() {
body: JSON.stringify({ enabled: pending }),
})
if (!data.success) {
setError(data.message || "Failed to save setting")
setError(data.message || t("settings.lxcUpdateDetection.saveFailed"))
return
}
setEnabled(pending)
@@ -95,7 +97,7 @@ export function LxcUpdateDetection() {
)
}
} catch (e) {
setError(String(e))
setError(t("settings.lxcUpdateDetection.saveFailed"))
} finally {
setSaving(false)
}
@@ -111,14 +113,14 @@ export function LxcUpdateDetection() {
breakpoint thanks to `items-center` + leading-tight title. */}
<div className="flex items-center gap-2 flex-wrap min-w-0">
<Boxes className="h-5 w-5 text-purple-500 shrink-0" />
<CardTitle className="leading-tight">LXC Update Detection</CardTitle>
<CardTitle className="leading-tight">{t("settings.lxcUpdateDetection.title")}</CardTitle>
{enabled ? (
<Badge variant="outline" className="text-[10px] border-green-500/30 text-green-500">
Active
{t("status.active")}
</Badge>
) : (
<Badge variant="outline" className="text-[10px] border-muted-foreground/30 text-muted-foreground">
Disabled
{t("status.disabled")}
</Badge>
)}
</div>
@@ -126,7 +128,7 @@ export function LxcUpdateDetection() {
{saved && (
<span className="flex items-center gap-1 text-xs text-green-500">
<CheckCircle2 className="h-3.5 w-3.5" />
Saved
{t("status.saved")}
</span>
)}
{error && !editMode && (
@@ -134,7 +136,7 @@ export function LxcUpdateDetection() {
className="flex items-center gap-1 text-xs text-red-500 max-w-[40ch] truncate"
title={error}
>
Save failed: {error}
{t("status.saveFailed")}: {error}
</span>
)}
{editMode ? (
@@ -144,7 +146,7 @@ export function LxcUpdateDetection() {
onClick={handleCancel}
disabled={saving}
>
Cancel
{t("actions.cancel")}
</button>
<button
className="h-7 px-3 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5"
@@ -152,7 +154,7 @@ export function LxcUpdateDetection() {
disabled={saving || !hasChanges}
>
{saving ? <Loader2 className="h-3 w-3 animate-spin" /> : <CheckCircle2 className="h-3 w-3" />}
Save
{t("actions.save")}
</button>
</>
) : (
@@ -162,20 +164,25 @@ 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>
<CardContent className="space-y-5">
<CardContent
className={`space-y-5${
editMode
? " bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background"
: ""
}`}
>
{/* ── Enable/Disable ── single-line label + toggle. The description
paragraph was removed because the CardDescription above already
covers the behaviour; on mobile that second paragraph forced
@@ -185,7 +192,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 +202,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 +216,7 @@ export function LxcUpdateDetection() {
<div className="flex items-start gap-2 p-3 rounded-lg bg-muted/50 border border-border">
<Info className="h-3.5 w-3.5 text-blue-400 shrink-0 mt-0.5" />
<p className="text-[11px] text-muted-foreground leading-relaxed">
{lastPurged} LXC entries removed from the registry. Re-enabling detection will repopulate them on the
next scan cycle.
{t("settings.lxcUpdateDetection.purgedMessage", { count: lastPurged })}
</p>
</div>
)}
+29 -27
View File
@@ -6,6 +6,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { ArrowLeft, Loader2 } from "lucide-react"
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from "recharts"
import { fetchApi } from "@/lib/api-config"
import { useI18n } from "@/lib/i18n/provider"
interface MetricsViewProps {
vmid: number
@@ -15,12 +16,12 @@ interface MetricsViewProps {
}
const TIMEFRAME_OPTIONS = [
{ value: "hour", label: "1 Hour" },
{ value: "day", label: "24 Hours" },
{ value: "week", label: "7 Days" },
{ value: "month", label: "30 Days" },
{ value: "year", label: "1 Year" },
]
{ value: "hour", labelKey: "vmMetrics.timeframes.hour" },
{ value: "day", labelKey: "vmMetrics.timeframes.day" },
{ value: "week", labelKey: "vmMetrics.timeframes.week" },
{ value: "month", labelKey: "vmMetrics.timeframes.month" },
{ value: "year", labelKey: "vmMetrics.timeframes.year" },
] as const
const CustomCPUTooltip = ({ active, payload, label }: any) => {
if (active && payload && payload.length) {
@@ -103,6 +104,7 @@ const CustomNetworkTooltip = ({ active, payload, label }: any) => {
}
export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps) {
const { language, t } = useI18n()
const [timeframe, setTimeframe] = useState("week")
const [data, setData] = useState<any[]>([])
const [loading, setLoading] = useState(false)
@@ -112,7 +114,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
useEffect(() => {
fetchMetrics()
}, [vmid, timeframe])
}, [vmid, timeframe, language])
const fetchMetrics = async () => {
setLoading(true)
@@ -126,19 +128,19 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
let timeLabel = ""
if (timeframe === "hour") {
timeLabel = date.toLocaleString("en-US", {
timeLabel = date.toLocaleString(language, {
hour: "2-digit",
minute: "2-digit",
hour12: false,
})
} else if (timeframe === "day") {
timeLabel = date.toLocaleString("en-US", {
timeLabel = date.toLocaleString(language, {
hour: "2-digit",
minute: "2-digit",
hour12: false,
})
} else if (timeframe === "week") {
timeLabel = date.toLocaleString("en-US", {
timeLabel = date.toLocaleString(language, {
month: "short",
day: "numeric",
hour: "2-digit",
@@ -146,12 +148,12 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
hour12: false,
})
} else if (timeframe === "month") {
timeLabel = date.toLocaleString("en-US", {
timeLabel = date.toLocaleString(language, {
month: "short",
day: "numeric",
})
} else {
timeLabel = date.toLocaleString("en-US", {
timeLabel = date.toLocaleString(language, {
month: "short",
year: "numeric",
})
@@ -173,7 +175,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
setData(transformedData)
} catch (err: any) {
setError(err.message || "Error loading metrics")
setError(err.message || t("vmMetrics.errors.loading"))
} finally {
setLoading(false)
}
@@ -203,7 +205,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
if (data.length === 0) {
return (
<div className="flex items-center justify-center h-[400px]">
<p className="text-muted-foreground">No data available</p>
<p className="text-muted-foreground">{t("vmMetrics.noData")}</p>
</div>
)
}
@@ -214,7 +216,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
<div className="space-y-8">
{/* CPU Chart */}
<div>
<h3 className="text-lg font-semibold mb-4">CPU Usage</h3>
<h3 className="text-lg font-semibold mb-4">{t("vmMetrics.charts.cpu")}</h3>
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={data} margin={{ bottom: 80 }}>
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
@@ -244,7 +246,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
strokeWidth={2}
fill="#3b82f6"
fillOpacity={0.3}
name="CPU %"
name={t("vmMetrics.series.cpu")}
/>
</AreaChart>
</ResponsiveContainer>
@@ -252,7 +254,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
{/* Memory Chart */}
<div>
<h3 className="text-lg font-semibold mb-4">Memory Usage</h3>
<h3 className="text-lg font-semibold mb-4">{t("vmMetrics.charts.memory")}</h3>
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={data} margin={{ bottom: 80 }}>
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
@@ -282,7 +284,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
fill="#10b981"
fillOpacity={0.3}
strokeWidth={2}
name="Memory GB"
name={t("vmMetrics.series.memoryGb")}
/>
</AreaChart>
</ResponsiveContainer>
@@ -290,7 +292,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
{/* Disk I/O Chart */}
<div>
<h3 className="text-lg font-semibold mb-4">Disk I/O</h3>
<h3 className="text-lg font-semibold mb-4">{t("vmMetrics.charts.diskIo")}</h3>
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={data} margin={{ bottom: 80 }}>
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
@@ -321,7 +323,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
fill="#10b981"
fillOpacity={0.3}
strokeWidth={2}
name="Read"
name={t("vmMetrics.series.read")}
hide={hiddenDiskLines.includes("diskread")}
/>
<Area
@@ -331,7 +333,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
fill="#3b82f6"
fillOpacity={0.3}
strokeWidth={2}
name="Write"
name={t("vmMetrics.series.write")}
hide={hiddenDiskLines.includes("diskwrite")}
/>
</AreaChart>
@@ -340,7 +342,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
{/* Network I/O Chart */}
<div>
<h3 className="text-lg font-semibold mb-4">Network I/O</h3>
<h3 className="text-lg font-semibold mb-4">{t("vmMetrics.charts.networkIo")}</h3>
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={data} margin={{ bottom: 80 }}>
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
@@ -371,7 +373,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
fill="#10b981"
fillOpacity={0.3}
strokeWidth={2}
name="Download"
name={t("vmMetrics.series.download")}
hide={hiddenNetworkLines.includes("netin")}
/>
<Area
@@ -381,7 +383,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
fill="#3b82f6"
fillOpacity={0.3}
strokeWidth={2}
name="Upload"
name={t("vmMetrics.series.upload")}
hide={hiddenNetworkLines.includes("netout")}
/>
</AreaChart>
@@ -461,9 +463,9 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
<ArrowLeft className="h-5 w-5" />
</Button>
<div>
<h2 className="text-xl font-semibold">Metrics - {vmName}</h2>
<h2 className="text-xl font-semibold">{t("vmMetrics.title", { name: vmName })}</h2>
<p className="text-sm text-muted-foreground mt-1">
VMID: {vmid} Type: {vmType.toUpperCase()}
VMID: {vmid} {t("vmMetrics.type")}: {vmType.toUpperCase()}
</p>
</div>
</div>
@@ -474,7 +476,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
<SelectContent>
{TIMEFRAME_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
{t(option.labelKey)}
</SelectItem>
))}
</SelectContent>
+373
View File
@@ -0,0 +1,373 @@
"use client"
import React, { useState, useRef, useEffect } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
import {
Boxes,
Check,
ChevronDown,
DatabaseBackup,
GripVertical,
Grid3x3,
LayoutDashboard,
Layers,
RotateCcw,
Server,
Settings2,
Terminal,
} from "lucide-react"
import { useT } from "../lib/i18n/provider"
import {
DEFAULT_TAB_ORDER,
useTabOrder,
type TabId,
} from "../lib/tab-order"
// Long-press activation on touch — matches the delay platform UIs
// use so the user can still scroll a page that happens to start on a
// tab handle.
const TOUCH_ACTIVATION_MS = 250
const TOUCH_TOLERANCE_PX = 5
type TabMeta = {
id: TabId
Icon: React.ComponentType<{ className?: string }>
labelKey: string
hasDropdown: boolean
}
const META: Record<TabId, TabMeta> = {
overview: { id: "overview", Icon: LayoutDashboard, labelKey: "navigation.overview", hasDropdown: false },
apps: { id: "apps", Icon: Grid3x3, labelKey: "navigation.apps", hasDropdown: false },
vms: { id: "vms", Icon: Boxes, labelKey: "navigation.virtualMachines", hasDropdown: false },
node: { id: "node", Icon: Server, labelKey: "navigation.node", hasDropdown: true },
backup: { id: "backup", Icon: DatabaseBackup, labelKey: "navigation.backup", hasDropdown: false },
terminal: { id: "terminal", Icon: Terminal, labelKey: "navigation.terminal", hasDropdown: false },
admin: { id: "admin", Icon: Settings2, labelKey: "navigation.admin", hasDropdown: true },
}
// Sortable list built on Pointer Events. Mouse activates on move
// (2px threshold to survive accidental clicks); touch activates on
// long-press after 250ms unless the finger moves past 5px, which is
// treated as a scroll intent and the drag is cancelled.
export function NavTabOrderCard() {
const t = useT()
const { order: savedOrder, setOrder, reset, isCustom } = useTabOrder()
const [editMode, setEditMode] = useState(false)
const [draft, setDraft] = useState<TabId[]>(savedOrder)
const [saved, setSaved] = useState(false)
useEffect(() => {
if (!editMode) setDraft(savedOrder)
}, [savedOrder, editMode])
const handleCancel = () => {
setDraft(savedOrder)
setEditMode(false)
}
const handleSave = () => {
setOrder(draft)
setEditMode(false)
setSaved(true)
window.setTimeout(() => setSaved(false), 2000)
}
const handleReset = () => {
setDraft([...DEFAULT_TAB_ORDER])
}
const draftIsChanged =
draft.length !== savedOrder.length ||
draft.some((id, i) => id !== savedOrder[i])
const draftIsCustom =
draft.length !== DEFAULT_TAB_ORDER.length ||
draft.some((id, i) => id !== DEFAULT_TAB_ORDER[i])
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Layers className="h-5 w-5 text-blue-500" />
<CardTitle>{t("settings.navOrder.title")}</CardTitle>
</div>
<div className="flex items-center gap-2">
{saved && (
<span className="flex items-center gap-1 text-xs text-green-500">
<Check className="h-3.5 w-3.5" />
{t("status.saved")}
</span>
)}
{editMode ? (
<>
<button
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors text-muted-foreground"
onClick={handleCancel}
>
{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"
onClick={handleSave}
disabled={!draftIsChanged}
>
<Check className="h-3 w-3" />
{t("actions.save")}
</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={() => setEditMode(true)}
>
<Settings2 className="h-3 w-3" />
{t("actions.edit")}
</button>
)}
</div>
</div>
<CardDescription>{t("settings.navOrder.description")}</CardDescription>
</CardHeader>
<CardContent
className={
editMode
? "bg-accent"
: undefined
}
>
<SortableList
items={editMode ? draft : savedOrder}
onReorder={setDraft}
editable={editMode}
t={t}
/>
{editMode && (
<div className="mt-4 flex items-center justify-between">
<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 text-muted-foreground disabled:opacity-50 disabled:pointer-events-none"
onClick={handleReset}
disabled={!draftIsCustom}
>
<RotateCcw className="h-3 w-3" />
{t("settings.navOrder.reset")}
</button>
<span className="text-[11px] text-muted-foreground">
{t("settings.navOrder.hint")}
</span>
</div>
)}
{!editMode && isCustom && (
<div className="mt-3 text-[11px] text-muted-foreground">
{t("settings.navOrder.customActive")}
</div>
)}
</CardContent>
</Card>
)
}
// -----------------------------------------------------------------
// SortableList
// -----------------------------------------------------------------
type DragState = {
fromIdx: number
pointerY: number
offsetY: number
itemHeight: number
} | null
function SortableList({
items,
onReorder,
editable,
t,
}: {
items: TabId[]
onReorder: (next: TabId[]) => void
editable: boolean
t: (k: string) => string
}) {
const [drag, setDrag] = useState<DragState>(null)
const [hoverIdx, setHoverIdx] = useState<number | null>(null)
const listRef = useRef<HTMLUListElement | null>(null)
const commit = (from: number, to: number) => {
if (from === to || to < 0 || to >= items.length) return
const next = items.slice()
const [moved] = next.splice(from, 1)
next.splice(to, 0, moved)
onReorder(next)
}
const handleDragMove = (clientY: number) => {
if (!drag || !listRef.current) return
const rows = Array.from(listRef.current.querySelectorAll<HTMLLIElement>("li[data-row]"))
let target = drag.fromIdx
for (let i = 0; i < rows.length; i++) {
const rect = rows[i].getBoundingClientRect()
const mid = rect.top + rect.height / 2
if (clientY < mid) { target = i; break }
target = i
}
setHoverIdx(target)
setDrag((d) => (d ? { ...d, pointerY: clientY } : d))
}
const handleDragEnd = () => {
if (drag && hoverIdx !== null) commit(drag.fromIdx, hoverIdx)
setDrag(null)
setHoverIdx(null)
}
return (
<ul
ref={listRef}
className="flex flex-col gap-1.5 select-none"
onPointerMove={(e) => { if (drag) handleDragMove(e.clientY) }}
onPointerUp={handleDragEnd}
onPointerCancel={handleDragEnd}
>
{items.map((id, idx) => {
const isDragging = drag?.fromIdx === idx
const meta = META[id]
return (
<SortableRow
key={id}
id={id}
idx={idx}
meta={meta}
label={t(meta.labelKey)}
editable={editable}
isDragging={!!isDragging}
hoverIdx={hoverIdx}
drag={drag}
onDragStart={(fromIdx, pointerY, offsetY, itemHeight) => {
setDrag({ fromIdx, pointerY, offsetY, itemHeight })
setHoverIdx(fromIdx)
}}
/>
)
})}
</ul>
)
}
function SortableRow({
id,
idx,
meta,
label,
editable,
isDragging,
hoverIdx,
drag,
onDragStart,
}: {
id: TabId
idx: number
meta: TabMeta
label: string
editable: boolean
isDragging: boolean
hoverIdx: number | null
drag: DragState
onDragStart: (fromIdx: number, pointerY: number, offsetY: number, itemHeight: number) => void
}) {
const rowRef = useRef<HTMLLIElement | null>(null)
const longPressTimer = useRef<number | null>(null)
const pointerStart = useRef<{ x: number; y: number } | null>(null)
const activatedRef = useRef(false)
const cancelLongPress = () => {
if (longPressTimer.current !== null) {
window.clearTimeout(longPressTimer.current)
longPressTimer.current = null
}
}
const beginDrag = (clientY: number) => {
if (!rowRef.current) return
const rect = rowRef.current.getBoundingClientRect()
activatedRef.current = true
onDragStart(idx, clientY, clientY - rect.top, rect.height)
}
const handlePointerDown = (e: React.PointerEvent<HTMLLIElement>) => {
if (!editable) return
if (e.button !== undefined && e.button !== 0) return
pointerStart.current = { x: e.clientX, y: e.clientY }
activatedRef.current = false
if (e.pointerType === "touch") {
longPressTimer.current = window.setTimeout(() => {
longPressTimer.current = null
beginDrag(e.clientY)
}, TOUCH_ACTIVATION_MS)
} else {
// Mouse/pen: activate immediately on press.
beginDrag(e.clientY)
}
;(e.currentTarget as HTMLLIElement).setPointerCapture(e.pointerId)
}
const handlePointerMove = (e: React.PointerEvent<HTMLLIElement>) => {
if (!editable) return
if (!activatedRef.current && pointerStart.current) {
const dx = e.clientX - pointerStart.current.x
const dy = e.clientY - pointerStart.current.y
if (Math.hypot(dx, dy) > TOUCH_TOLERANCE_PX) {
// Movement before activation → scroll intent on touch. Cancel
// the pending long-press so the page can scroll normally.
cancelLongPress()
}
}
}
const handlePointerUp = () => {
cancelLongPress()
pointerStart.current = null
}
// Simple drag visual: ghost the row being dragged, show a blue
// drop-line above or below the row the pointer is currently over.
// No item swap animation — commit on release.
const isTarget = drag && hoverIdx === idx && drag.fromIdx !== idx
const dropAbove = isTarget && drag!.fromIdx > idx
const dropBelow = isTarget && drag!.fromIdx < idx
const RowIcon = meta.Icon
return (
<li
ref={rowRef}
data-row
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
className={[
"relative rounded-md border transition-colors",
editable
? "bg-background border-border cursor-grab active:cursor-grabbing touch-none"
: "bg-card border-border/60",
isDragging ? "opacity-40" : "",
].join(" ")}
>
{dropAbove && <div className="absolute -top-[3px] left-2 right-2 h-[2px] bg-blue-500 rounded-full pointer-events-none" />}
{dropBelow && <div className="absolute -bottom-[3px] left-2 right-2 h-[2px] bg-blue-500 rounded-full pointer-events-none" />}
<div className="flex items-center gap-3 p-2.5">
<GripVertical
className={
"h-4 w-4 flex-shrink-0 " +
(editable ? "text-muted-foreground" : "text-muted-foreground/40")
}
/>
<RowIcon className="h-4 w-4 flex-shrink-0 text-blue-500" />
<div className="flex-1 min-w-0 flex items-center gap-2">
<span className="text-sm font-medium text-foreground">{label}</span>
{meta.hasDropdown && (
<ChevronDown className="h-3 w-3 text-muted-foreground/70" />
)}
</div>
</div>
</li>
)
}
+37 -26
View File
@@ -6,6 +6,9 @@ import { Wifi, Zap } from 'lucide-react'
import { useState, useEffect } from "react"
import { fetchApi } from "../lib/api-config"
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
import { useT } from "../lib/i18n/provider"
type TFunction = (key: string, params?: Record<string, string | number>) => string
interface NetworkCardProps {
interface_: {
@@ -32,43 +35,51 @@ interface NetworkCardProps {
onClick?: () => void
}
const getInterfaceTypeBadge = (type: string) => {
const getInterfaceTypeBadge = (type: string, t: TFunction) => {
switch (type) {
case "physical":
return { color: "bg-blue-500/10 text-blue-500 border-blue-500/20", label: "Physical" }
return { color: "bg-blue-500/10 text-blue-500 border-blue-500/20", label: t("network.interfaceTypes.physical") }
case "bridge":
return { color: "bg-green-500/10 text-green-500 border-green-500/20", label: "Bridge" }
return { color: "bg-green-500/10 text-green-500 border-green-500/20", label: t("network.interfaceTypes.bridge") }
case "bond":
return { color: "bg-purple-500/10 text-purple-500 border-purple-500/20", label: "Bond" }
return { color: "bg-purple-500/10 text-purple-500 border-purple-500/20", label: t("network.interfaceTypes.bond") }
case "vlan":
return { color: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20", label: "VLAN" }
return { color: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20", label: t("network.interfaceTypes.vlan") }
case "vm_lxc":
return { color: "bg-orange-500/10 text-orange-500 border-orange-500/20", label: "Virtual" }
return { color: "bg-orange-500/10 text-orange-500 border-orange-500/20", label: t("network.interfaceTypes.virtual") }
case "virtual":
return { color: "bg-orange-500/10 text-orange-500 border-orange-500/20", label: "Virtual" }
return { color: "bg-orange-500/10 text-orange-500 border-orange-500/20", label: t("network.interfaceTypes.virtual") }
default:
return { color: "bg-gray-500/10 text-gray-500 border-gray-500/20", label: "Unknown" }
return { color: "bg-gray-500/10 text-gray-500 border-gray-500/20", label: t("common.unknown") }
}
}
const getVMTypeBadge = (vmType: string | undefined) => {
const getVMTypeBadge = (vmType: string | undefined, t: TFunction) => {
if (vmType === "lxc") {
return { color: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20", label: "LXC" }
} else if (vmType === "vm") {
return { color: "bg-purple-500/10 text-purple-500 border-purple-500/20", label: "VM" }
}
return { color: "bg-gray-500/10 text-gray-500 border-gray-500/20", label: "Unknown" }
return { color: "bg-gray-500/10 text-gray-500 border-gray-500/20", label: t("common.unknown") }
}
const formatSpeed = (speed: number): string => {
if (speed === 0) return "N/A"
const formatInterfaceStatus = (status: string | undefined, t: TFunction): string => {
const normalized = (status || "").toLowerCase()
if (normalized === "up") return t("network.status.up")
if (normalized === "down") return t("network.status.down")
return status || t("common.unknown")
}
const formatSpeed = (speed: number, unavailable = "N/A"): string => {
if (speed === 0) return unavailable
if (speed >= 1000) return `${(speed / 1000).toFixed(1)} Gbps`
return `${speed} Mbps`
}
export function NetworkCard({ interface_, timeframe, onClick }: NetworkCardProps) {
const typeBadge = getInterfaceTypeBadge(interface_.type)
const vmTypeBadge = interface_.vm_type ? getVMTypeBadge(interface_.vm_type) : null
const t = useT()
const typeBadge = getInterfaceTypeBadge(interface_.type, t)
const vmTypeBadge = interface_.vm_type ? getVMTypeBadge(interface_.vm_type, t) : null
const [networkUnit, setNetworkUnit] = useState<"Bytes" | "Bits">(getNetworkUnit())
@@ -125,17 +136,17 @@ export function NetworkCard({ interface_, timeframe, onClick }: NetworkCardProps
const getTimeframeLabel = () => {
switch (timeframe) {
case "hour":
return "Last Hour"
return t("network.timeframes.last.hour")
case "day":
return "Last 24 Hours"
return t("network.timeframes.last.day")
case "week":
return "Last 7 Days"
return t("network.timeframes.last.week")
case "month":
return "Last 30 Days"
return t("network.timeframes.last.month")
case "year":
return "Last Year"
return t("network.timeframes.last.year")
default:
return "Last 24 Hours"
return t("network.timeframes.last.day")
}
}
@@ -174,7 +185,7 @@ export function NetworkCard({ interface_, timeframe, onClick }: NetworkCardProps
: "bg-red-500/10 text-red-500 border-red-500/20"
}
>
{interface_.status.toUpperCase()}
{formatInterfaceStatus(interface_.status, t)}
</Badge>
</div>
@@ -182,22 +193,22 @@ export function NetworkCard({ interface_, timeframe, onClick }: NetworkCardProps
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<div className="text-muted-foreground text-xs">
{interface_.type === "vm_lxc" ? "VMID" : "IP Address"}
{interface_.type === "vm_lxc" ? "VMID" : t("network.labels.ipAddress")}
</div>
<div className="font-medium text-foreground font-mono text-sm truncate">
{interface_.type === "vm_lxc"
? (interface_.vmid ?? "N/A")
? (interface_.vmid ?? t("common.notAvailable"))
: interface_.addresses.length > 0
? interface_.addresses[0].ip
: "N/A"}
: t("common.notAvailable")}
</div>
</div>
<div>
<div className="text-muted-foreground text-xs">Speed</div>
<div className="text-muted-foreground text-xs">{t("network.labels.speed")}</div>
<div className="font-medium text-foreground flex items-center gap-1 text-xs">
<Zap className="h-3 w-3" />
{formatSpeed(interface_.speed)}
{formatSpeed(interface_.speed, t("common.notAvailable"))}
</div>
</div>
+60 -23
View File
@@ -7,6 +7,8 @@
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"
import { getNetworkUnit, type NetworkUnit } from "../lib/format-network"
// One animated comet-trail pulse. Returned as DATA from the layout
// renderers instead of an SVG string so the parent component can
@@ -25,6 +27,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,17 +134,26 @@ 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 {
function fmt(v: number, unit: NetworkUnit = "Bytes"): string {
if (unit === "Bits") {
// MB/s (base 2) → bits/s, then decimal SI prefixes (Kb/Mb use ×1000, networking convention).
const bps = (v || 0) * 1024 * 1024 * 8
if (!bps) return "0 b/s"
if (bps < 1000) return `${Math.round(bps)} b/s`
if (bps < 1_000_000) return `${(bps / 1000).toFixed(0)} Kb/s`
return `${(bps / 1_000_000).toFixed(1)} Mb/s`
}
if (!v) return "0 B/s"
// Below 1 KB/s show B/s — the previous "—" hid real-but-low traffic.
if (v < 0.001) return `${Math.round(v * 1024 * 1024)} B/s`
@@ -214,7 +231,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, unit: NetworkUnit): { 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 +360,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>`)
})
@@ -376,7 +393,7 @@ function renderHorizontal(data: NetworkFlowData, W: number): { svg: string; puls
${svgIcon("bond", bondX, y, 16, stroke)}
<text class="nf-label" x="${bondX}" y="${y + radBond + 14}">${b.id}</text>
<text class="nf-sub" x="${bondX}" y="${y + radBond + 26}">${b.mode || "bond"}</text>
<text class="nf-sub" x="${bondX}" y="${y + radBond + 38}">${fmt(b.rx + b.tx)}</text>
<text class="nf-sub" x="${bondX}" y="${y + radBond + 38}">${fmt(b.rx + b.tx, unit)}</text>
</g>`)
})
@@ -384,7 +401,7 @@ function renderHorizontal(data: NetworkFlowData, W: number): { svg: string; puls
<circle class="nf-circle" cx="${hostX}" cy="${hostY}" r="${radHost}" stroke="${COLORS.host}" stroke-width="2.5" />
${svgIcon("host", hostX, hostY, 24, COLORS.host)}
<text class="nf-label" x="${hostX}" y="${hostY + radHost + 14}" font-weight="600">PROXMOX</text>
<text class="nf-sub" x="${hostX}" y="${hostY + radHost + 26}">${fmt((host?.rx || 0) + (host?.tx || 0))}</text>
<text class="nf-sub" x="${hostX}" y="${hostY + radHost + 26}">${fmt((host?.rx || 0) + (host?.tx || 0), unit)}</text>
</g>`)
// Logarithmic mapping rate (MB/s) → pulse animation duration (s),
@@ -432,7 +449,7 @@ function renderHorizontal(data: NetworkFlowData, W: number): { svg: string; puls
<circle class="nf-circle" cx="${bridgesX}" cy="${sec.bridgeY}" r="${radBridge}" stroke="${COLORS.bridge}" />
${svgIcon("bridge", bridgesX, sec.bridgeY, 16, COLORS.bridge)}
<text class="nf-label" x="${bridgesX}" y="${sec.bridgeY + radBridge + 14}">${sec.b.id}</text>
<text class="nf-sub" x="${bridgesX}" y="${sec.bridgeY + radBridge + 26}">${fmt(bridgeRate)}</text>
<text class="nf-sub" x="${bridgesX}" y="${sec.bridgeY + radBridge + 26}">${fmt(bridgeRate, unit)}</text>
</g>`)
// Trunk lines are STATIC only — every per-guest path will travel
@@ -467,7 +484,7 @@ function renderHorizontal(data: NetworkFlowData, W: number): { svg: string; puls
<circle class="nf-circle" cx="${cx}" cy="${circleY}" r="${radGuest}" stroke="${stroke}" />
${svgIcon(g.kind, cx, circleY, 14, stroke)}
<text class="nf-label" x="${cx}" y="${labelY}">${g.label}</text>
<text class="nf-sub" x="${cx}" y="${subY}">${fmt(g.rx + g.tx)}</text>
<text class="nf-sub" x="${cx}" y="${subY}">${fmt(g.rx + g.tx, unit)}</text>
</g>`)
}
@@ -591,7 +608,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, unit: NetworkUnit): { 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 +785,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>`)
})
@@ -786,7 +803,7 @@ function renderVertical(data: NetworkFlowData): { svg: string; pulses: PulseData
${svgIcon("bond", cx, cy, 13, color)}
<text class="nf-label" x="${cx}" y="${cy + r + LABEL_OFFSET_Y}" text-anchor="middle" style="font-size:12.5px">${b.id}</text>
<text class="nf-sub" x="${cx}" y="${cy + r + SUB_OFFSET_Y}" text-anchor="middle" style="font-size:11px">${b.mode || "bond"}</text>
<text class="nf-sub" x="${cx}" y="${cy + r + BOND_TEXT_H}" text-anchor="middle" style="font-size:11px">${fmt(rate)}</text>
<text class="nf-sub" x="${cx}" y="${cy + r + BOND_TEXT_H}" text-anchor="middle" style="font-size:11px">${fmt(rate, unit)}</text>
</g>`)
})
@@ -795,7 +812,7 @@ function renderVertical(data: NetworkFlowData): { svg: string; pulses: PulseData
<circle class="nf-circle" cx="${HOST_X}" cy="${hostY}" r="${RAD_HOST}" stroke="${COLORS.host}" stroke-width="2.5" />
${svgIcon("host", HOST_X, hostY, 20, COLORS.host)}
<text class="nf-label" x="${HOST_X}" y="${hostY + RAD_HOST + LABEL_OFFSET_Y}" text-anchor="middle" font-weight="700" style="font-size:13px">PROXMOX</text>
<text class="nf-sub" x="${HOST_X}" y="${hostY + RAD_HOST + SUB_OFFSET_Y}" text-anchor="middle" style="font-size:12.5px">${fmt((host?.rx || 0) + (host?.tx || 0))}</text>
<text class="nf-sub" x="${HOST_X}" y="${hostY + RAD_HOST + SUB_OFFSET_Y}" text-anchor="middle" style="font-size:12.5px">${fmt((host?.rx || 0) + (host?.tx || 0), unit)}</text>
</g>`)
// ─── 3. Bridges + guests, ARC layout around each bridge ──
@@ -862,7 +879,7 @@ function renderVertical(data: NetworkFlowData): { svg: string; pulses: PulseData
<circle class="nf-circle" cx="${cx}" cy="${cy}" r="${r}" stroke="${COLORS.bridge}" />
${svgIcon("bridge", cx, cy, 13, COLORS.bridge)}
<text class="nf-label" x="${cx}" y="${cy + r + LABEL_OFFSET_Y}" text-anchor="middle" style="font-size:12.5px">${b.id}</text>
<text class="nf-sub" x="${cx}" y="${cy + r + SUB_OFFSET_Y}" text-anchor="middle" style="font-size:12.5px">${fmt(rate)}</text>
<text class="nf-sub" x="${cx}" y="${cy + r + SUB_OFFSET_Y}" text-anchor="middle" style="font-size:12.5px">${fmt(rate, unit)}</text>
</g>`)
if (guests.length === 0) return
@@ -905,7 +922,7 @@ function renderVertical(data: NetworkFlowData): { svg: string; pulses: PulseData
<circle class="nf-circle" cx="${gCx}" cy="${gCy}" r="${gR}" stroke="${stroke}" />
${svgIcon(g.kind, gCx, gCy, 13, stroke)}
<text class="nf-label" x="${gCx}" y="${gCy + gR + LABEL_OFFSET_Y}" text-anchor="middle" style="font-size:12.5px">${g.label}</text>
<text class="nf-sub" x="${gCx}" y="${gCy + gR + SUB_OFFSET_Y}" text-anchor="middle" style="font-size:12.5px">${fmt(gRate)}</text>
<text class="nf-sub" x="${gCx}" y="${gCy + gR + SUB_OFFSET_Y}" text-anchor="middle" style="font-size:12.5px">${fmt(gRate, unit)}</text>
</g>`)
})
})
@@ -936,9 +953,19 @@ 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")
const [networkUnit, setNetworkUnit] = useState<NetworkUnit>("Bytes")
useEffect(() => {
const update = () => {
@@ -953,6 +980,16 @@ export function NetworkFlow({
return () => window.removeEventListener("resize", update)
}, [])
useEffect(() => {
setNetworkUnit(getNetworkUnit())
const onChange = (e: Event) => {
const detail = (e as CustomEvent).detail as NetworkUnit | undefined
setNetworkUnit(detail ?? getNetworkUnit())
}
window.addEventListener("networkUnitChanged", onChange)
return () => window.removeEventListener("networkUnitChanged", onChange)
}, [])
// Stable memo key: the SVG is regenerated ONLY when something
// structurally relevant changes (mode, width, who's online,
// who's linked where, or a rate crossed into a different speed
@@ -980,26 +1017,26 @@ export function NetworkFlow({
`${c.id}:${c.bridge}:${c.kind}:${c.offline ? 1 : 0}:${bucket(c.rx)}:${bucket(c.tx)}:${sig(c.rx)}:${sig(c.tx)}`
).join("|")
const bridges = data.bridges.map((b) => `${b.id}:${b.parent || ""}`).join("|")
return `${mode}|${width}|${nics}||${bonds}||${guests}||${bridges}`
}, [data, mode, width])
return `${mode}|${width}|${networkUnit}|${nics}||${bonds}||${guests}||${bridges}`
}, [data, mode, width, networkUnit])
const { svgContent, pulses, viewBox, height } = useMemo(() => {
if (mode === "mobile") {
const out = renderVertical(data)
const out = renderVertical(data, labels, networkUnit)
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, networkUnit)
return { svgContent: out.svg, pulses: out.pulses, viewBox: `0 0 ${W} ${out.height}`, height: out.height }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [memoKey])
}, [memoKey, labels])
return (
<Card className="bg-card border-border">
<CardHeader>
<CardTitle className="text-foreground flex items-center text-base">
<Activity className="h-5 w-5 mr-2" />
Network Flow (PoC)
{t("network.flow.title")}
</CardTitle>
</CardHeader>
<CardContent>
+231 -173
View File
@@ -13,6 +13,9 @@ import { fetchApi } from "../lib/api-config"
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
import { LatencyDetailModal } from "./latency-detail-modal"
import { AreaChart, Area, LineChart, Line, ResponsiveContainer, YAxis } from "recharts"
import { useT } from "../lib/i18n/provider"
type TFunction = (key: string, params?: Record<string, string | number>) => string
interface NetworkData {
interfaces: NetworkInterface[]
@@ -141,24 +144,57 @@ function getInterfaceIcon(iface: NetworkInterface): React.ComponentType<{ classN
// Match the dark blue badge tone the Storage card uses for the disk
// type chip, but mapped to the actual interface class.
function getInterfaceTypeChip(type: string) {
function getInterfaceTypeLabel(type: string, t: TFunction) {
switch ((type || "").toLowerCase()) {
case "physical":
return { className: "bg-blue-500/10 text-blue-400 border-blue-500/20", label: "Physical" }
return t("network.interfaceTypes.physical")
case "bridge":
return { className: "bg-green-500/10 text-green-400 border-green-500/20", label: "Bridge" }
return t("network.interfaceTypes.bridge")
case "bond":
return { className: "bg-purple-500/10 text-purple-400 border-purple-500/20", label: "Bond" }
return t("network.interfaceTypes.bond")
case "vlan":
return { className: "bg-cyan-500/10 text-cyan-400 border-cyan-500/20", label: "VLAN" }
return t("network.interfaceTypes.vlan")
case "vm_lxc":
case "virtual":
return { className: "bg-orange-500/10 text-orange-400 border-orange-500/20", label: "Virtual" }
return t("network.interfaceTypes.virtual")
default:
return { className: "bg-gray-500/10 text-gray-400 border-gray-500/20", label: type || "Unknown" }
return type || t("common.unknown")
}
}
function getInterfaceTypeChip(type: string, t: TFunction) {
switch ((type || "").toLowerCase()) {
case "physical":
return { className: "bg-blue-500/10 text-blue-400 border-blue-500/20", label: getInterfaceTypeLabel(type, t) }
case "bridge":
return { className: "bg-green-500/10 text-green-400 border-green-500/20", label: getInterfaceTypeLabel(type, t) }
case "bond":
return { className: "bg-purple-500/10 text-purple-400 border-purple-500/20", label: getInterfaceTypeLabel(type, t) }
case "vlan":
return { className: "bg-cyan-500/10 text-cyan-400 border-cyan-500/20", label: getInterfaceTypeLabel(type, t) }
case "vm_lxc":
case "virtual":
return { className: "bg-orange-500/10 text-orange-400 border-orange-500/20", label: getInterfaceTypeLabel(type, t) }
default:
return { className: "bg-gray-500/10 text-gray-400 border-gray-500/20", label: type || t("common.unknown") }
}
}
const formatInterfaceStatus = (status: string | undefined, t: TFunction): string => {
const normalized = (status || "").toLowerCase()
if (normalized === "up") return t("network.status.up")
if (normalized === "down") return t("network.status.down")
return status || t("common.unknown")
}
const formatDuplex = (duplex: string | undefined, t: TFunction): string => {
const normalized = (duplex || "").toLowerCase()
if (normalized === "full") return t("network.duplex.full")
if (normalized === "half") return t("network.duplex.half")
if (!duplex || normalized === "unknown") return t("common.unknown")
return duplex
}
// Per-interface card matching the Storage page's "Physical Disks"
// pattern: 2-line header (identity / live state), horizontal divider,
// vertical key→value stat block, footer with serial + arrow CTA.
@@ -166,18 +202,19 @@ function getInterfaceTypeChip(type: string) {
function renderPhysicalInterfaceCardV2(
iface: NetworkInterface,
onOpen: (iface: NetworkInterface) => void,
t: TFunction,
) {
const Icon = getInterfaceIcon(iface)
const chip = getInterfaceTypeChip(iface.type)
const chip = getInterfaceTypeChip(iface.type, t)
const isUp = (iface.status || "").toLowerCase() === "up"
const firstAddr = iface.addresses?.[0]?.ip || ""
const extraAddrs = Math.max(0, (iface.addresses?.length || 0) - 1)
const speedStr = formatSpeed(iface.speed)
const speedStr = formatSpeed(iface.speed, t("common.notAvailable"))
// Hardware max in Mbps from ethtool. Show only when it's different
// from the negotiated speed (avoids "1 Gbps (max 1 Gbps)" noise).
const maxSpeedStr =
iface.max_speed && iface.max_speed !== iface.speed
? formatSpeed(iface.max_speed)
? formatSpeed(iface.max_speed, t("common.notAvailable"))
: ""
const bridgesUsing = iface.used_by_bridges || []
const errIn = iface.errors_in ?? 0
@@ -206,7 +243,7 @@ function renderPhysicalInterfaceCardV2(
}`}
>
<NetStatusDot tone={isUp ? "ok" : "fail"} />
{iface.status || "?"}
{formatInterfaceStatus(iface.status, t)}
</span>
</div>
@@ -217,11 +254,11 @@ function renderPhysicalInterfaceCardV2(
{speedStr}
{maxSpeedStr && (
<span className="text-[11px] text-muted-foreground/70">
· max {maxSpeedStr}
· {t("network.labels.maxSpeed", { speed: maxSpeedStr })}
</span>
)}
</span>
<span className="capitalize">{iface.duplex || "—"}</span>
<span>{formatDuplex(iface.duplex, t)}</span>
</div>
{/* Separator. */}
@@ -246,7 +283,7 @@ function renderPhysicalInterfaceCardV2(
{bridgesUsing.length > 0 && (
<div className="flex items-baseline justify-between gap-3">
<span className="text-[11px] uppercase tracking-wider text-muted-foreground shrink-0">
Bridge
{t("network.interfaceTypes.bridge")}
</span>
<span className="font-medium text-right truncate font-mono text-xs text-cyan-400">
{bridgesUsing.map((b) => `${b}`).join(" ")}
@@ -260,7 +297,7 @@ function renderPhysicalInterfaceCardV2(
has no previous sample to compute against. */}
<div className="flex items-baseline justify-between gap-3">
<span className="text-[11px] uppercase tracking-wider text-muted-foreground flex items-center gap-1">
<ArrowDown className="h-3 w-3 text-green-500" /> Received
<ArrowDown className="h-3 w-3 text-green-500" /> {t("network.labels.received")}
</span>
<span className="font-medium text-green-500 tabular-nums">
{iface.rx_Bps !== undefined ? formatRate(iface.rx_Bps) : "—"}
@@ -268,7 +305,7 @@ function renderPhysicalInterfaceCardV2(
</div>
<div className="flex items-baseline justify-between gap-3">
<span className="text-[11px] uppercase tracking-wider text-muted-foreground flex items-center gap-1">
<ArrowUp className="h-3 w-3 text-blue-400" /> Sent
<ArrowUp className="h-3 w-3 text-blue-400" /> {t("network.labels.sent")}
</span>
<span className="font-medium text-blue-400 tabular-nums">
{iface.tx_Bps !== undefined ? formatRate(iface.tx_Bps) : "—"}
@@ -278,7 +315,7 @@ function renderPhysicalInterfaceCardV2(
<>
{totalErrors > 0 && (
<div className="flex items-baseline justify-between gap-3">
<span className="text-[11px] uppercase tracking-wider text-muted-foreground">Errors</span>
<span className="text-[11px] uppercase tracking-wider text-muted-foreground">{t("network.labels.errors")}</span>
<span
className={`font-medium flex items-center gap-1.5 ${
netCounterTone(totalErrors) === "ok"
@@ -295,7 +332,7 @@ function renderPhysicalInterfaceCardV2(
)}
{totalDrops > 0 && (
<div className="flex items-baseline justify-between gap-3">
<span className="text-[11px] uppercase tracking-wider text-muted-foreground">Drops</span>
<span className="text-[11px] uppercase tracking-wider text-muted-foreground">{t("network.labels.drops")}</span>
<span
className={`font-medium flex items-center gap-1.5 ${
netCounterTone(totalDrops) === "ok"
@@ -325,7 +362,7 @@ function renderPhysicalInterfaceCardV2(
)}
<span
className="text-blue-400 hover:text-blue-300 transition-colors text-base leading-none shrink-0"
aria-label="View details"
aria-label={t("network.actions.viewDetails")}
>
</span>
@@ -335,32 +372,32 @@ function renderPhysicalInterfaceCardV2(
}
const getInterfaceTypeBadge = (type: string) => {
const getInterfaceTypeBadge = (type: string, t: TFunction) => {
switch (type) {
case "physical":
return { color: "bg-blue-500/10 text-blue-500 border-blue-500/20", label: "Physical" }
return { color: "bg-blue-500/10 text-blue-500 border-blue-500/20", label: t("network.interfaceTypes.physical") }
case "bridge":
return { color: "bg-green-500/10 text-green-500 border-green-500/20", label: "Bridge" }
return { color: "bg-green-500/10 text-green-500 border-green-500/20", label: t("network.interfaceTypes.bridge") }
case "bond":
return { color: "bg-purple-500/10 text-purple-500 border-purple-500/20", label: "Bond" }
return { color: "bg-purple-500/10 text-purple-500 border-purple-500/20", label: t("network.interfaceTypes.bond") }
case "vlan":
return { color: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20", label: "VLAN" }
return { color: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20", label: t("network.interfaceTypes.vlan") }
case "vm_lxc":
return { color: "bg-orange-500/10 text-orange-500 border-orange-500/20", label: "Virtual" }
return { color: "bg-orange-500/10 text-orange-500 border-orange-500/20", label: t("network.interfaceTypes.virtual") }
case "virtual":
return { color: "bg-orange-500/10 text-orange-500 border-orange-500/20", label: "Virtual" }
return { color: "bg-orange-500/10 text-orange-500 border-orange-500/20", label: t("network.interfaceTypes.virtual") }
default:
return { color: "bg-gray-500/10 text-gray-500 border-gray-500/20", label: "Unknown" }
return { color: "bg-gray-500/10 text-gray-500 border-gray-500/20", label: t("common.unknown") }
}
}
const getVMTypeBadge = (vmType: string | undefined) => {
const getVMTypeBadge = (vmType: string | undefined, t: TFunction) => {
if (vmType === "lxc") {
return { color: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20", label: "LXC" }
} else if (vmType === "vm") {
return { color: "bg-purple-500/10 text-purple-500 border-purple-500/20", label: "VM" }
}
return { color: "bg-gray-500/10 text-gray-500 border-gray-500/20", label: "Unknown" }
return { color: "bg-gray-500/10 text-gray-500 border-gray-500/20", label: t("common.unknown") }
}
// Format bytes/sec into the canonical network unit ladder.
@@ -396,8 +433,8 @@ const formatStorage = (bytes: number): string => {
return `${value.toFixed(decimals)} ${sizes[i]}`
}
const formatSpeed = (speed: number): string => {
if (speed === 0) return "N/A"
const formatSpeed = (speed: number, unavailable = "N/A"): string => {
if (speed === 0) return unavailable
if (speed >= 1000) return `${(speed / 1000).toFixed(1)} Gbps`
return `${speed} Mbps`
}
@@ -408,6 +445,7 @@ const fetcher = async (url: string): Promise<NetworkData> => {
export function NetworkMetrics() {
const t = useT()
const {
data: networkData,
error,
@@ -469,8 +507,8 @@ export function NetworkMetrics() {
<div className="h-12 w-12 rounded-full border-2 border-muted"></div>
<div className="absolute inset-0 h-12 w-12 rounded-full border-2 border-transparent border-t-primary animate-spin"></div>
</div>
<div className="text-sm font-medium text-foreground">Loading network data...</div>
<p className="text-xs text-muted-foreground">Scanning interfaces, bridges and traffic</p>
<div className="text-sm font-medium text-foreground">{t("network.loading.title")}</div>
<p className="text-xs text-muted-foreground">{t("network.loading.description")}</p>
</div>
)
}
@@ -483,10 +521,10 @@ export function NetworkMetrics() {
<div className="flex items-center gap-3 text-red-600">
<AlertCircle className="h-6 w-6" />
<div>
<div className="font-semibold text-lg mb-1">Flask Server Not Available</div>
<div className="font-semibold text-lg mb-1">{t("network.errors.serverUnavailableTitle")}</div>
<div className="text-sm">
{error?.message ||
"Unable to connect to the Flask server. Please ensure the server is running and try again."}
t("network.errors.serverUnavailableDescription")}
</div>
</div>
</div>
@@ -514,14 +552,14 @@ export function NetworkMetrics() {
const avgPacketLoss = ((packetLossIn + packetLossOut) / 2).toFixed(2)
// Determine health status
let healthStatus = "Healthy"
let healthStatusKey = "network.status.healthy"
let healthColor = "bg-green-500/10 text-green-500 border-green-500/20"
if (Number.parseFloat(avgPacketLoss) > 5 || totalErrors > 1000) {
healthStatus = "Critical"
healthStatusKey = "network.status.critical"
healthColor = "bg-red-500/10 text-red-500 border-red-500/20"
} else if (Number.parseFloat(avgPacketLoss) >= 1 || totalErrors >= 100) {
healthStatus = "Warning"
healthStatusKey = "network.status.warning"
healthColor = "bg-yellow-500/10 text-yellow-500 border-yellow-500/20"
}
@@ -545,24 +583,24 @@ export function NetworkMetrics() {
const topTraffic = (top.bytes_recv || 0) + (top.bytes_sent || 0)
return ifaceTraffic > topTraffic ? iface : top
}, vmLxcInterfaces[0])
: { name: "No VM/LXC", type: "unknown", bytes_recv: 0, bytes_sent: 0, vm_name: "N/A" }
: { name: t("network.empty.noVmLxc"), type: "unknown", bytes_recv: 0, bytes_sent: 0, vm_name: t("common.notAvailable") }
const topInterfaceTraffic = (topInterface.bytes_recv || 0) + (topInterface.bytes_sent || 0)
const getTimeframeLabel = () => {
switch (timeframe) {
case "hour":
return "1 Hour"
return t("network.timeframes.hour")
case "day":
return "24 Hours"
return t("network.timeframes.day")
case "week":
return "7 Days"
return t("network.timeframes.week")
case "month":
return "30 Days"
return t("network.timeframes.month")
case "year":
return "1 Year"
return t("network.timeframes.year")
default:
return "24 Hours"
return t("network.timeframes.day")
}
}
@@ -571,25 +609,42 @@ export function NetworkMetrics() {
const getTimeframeShortLabel = () => {
switch (timeframe) {
case "hour":
return "Past 1 h"
return t("network.timeframes.short.hour")
case "day":
return "Past 24 h"
return t("network.timeframes.short.day")
case "week":
return "Past 7 d"
return t("network.timeframes.short.week")
case "month":
return "Past 30 d"
return t("network.timeframes.short.month")
case "year":
return "Past 1 y"
return t("network.timeframes.short.year")
default:
return "Past 24 h"
return t("network.timeframes.short.day")
}
}
const hostname = networkData.hostname || "N/A"
const domain = networkData.domain || "N/A"
const getLastTimeframeLabel = (value: "hour" | "day" | "week" | "month" | "year") => {
switch (value) {
case "hour":
return t("network.timeframes.last.hour")
case "day":
return t("network.timeframes.last.day")
case "week":
return t("network.timeframes.last.week")
case "month":
return t("network.timeframes.last.month")
case "year":
return t("network.timeframes.last.year")
default:
return t("network.timeframes.last.day")
}
}
const hostname = networkData.hostname || t("common.notAvailable")
const domain = networkData.domain || t("common.notAvailable")
const dnsServers = networkData.dns_servers || []
const primaryDNS = dnsServers[0] || "N/A"
const secondaryDNS = dnsServers[1] || "N/A"
const primaryDNS = dnsServers[0] || t("common.notAvailable")
const secondaryDNS = dnsServers[1] || t("common.notAvailable")
return (
<div className="space-y-6">
@@ -606,7 +661,7 @@ export function NetworkMetrics() {
<Card className="bg-card border-border">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="flex flex-col gap-0.5 min-w-0">
<CardTitle className="text-sm font-medium text-muted-foreground">Network Traffic</CardTitle>
<CardTitle className="text-sm font-medium text-muted-foreground">{t("network.cards.traffic")}</CardTitle>
<span className="text-[10px] text-muted-foreground/70 font-normal">{getTimeframeShortLabel()}</span>
</div>
<Activity className="h-4 w-4 text-muted-foreground flex-shrink-0" />
@@ -615,13 +670,13 @@ export function NetworkMetrics() {
<div className="grid grid-cols-2 gap-3 mb-3">
<div>
<div className="text-xs font-medium text-muted-foreground mb-1">
<span className="text-green-500"></span> Down
<span className="text-green-500"></span> {t("network.labels.down")}
</div>
<div className="text-xl lg:text-2xl font-bold leading-tight text-green-500">{trafficInFormatted}</div>
</div>
<div>
<div className="text-xs font-medium text-muted-foreground mb-1">
<span className="text-blue-500"></span> Up
<span className="text-blue-500"></span> {t("network.labels.up")}
</div>
<div className="text-xl lg:text-2xl font-bold leading-tight text-blue-500">{trafficOutFormatted}</div>
</div>
@@ -631,8 +686,8 @@ export function NetworkMetrics() {
<div style={{ width: `${upPct}%`, background: '#3b82f6' }}></div>
</div>
<div className="mt-2 flex justify-between text-xs text-muted-foreground">
<span className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-green-500"></span>Down {Math.round(downPct)}%</span>
<span className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-blue-500"></span>Up {Math.round(upPct)}%</span>
<span className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-green-500"></span>{t("network.labels.down")} {Math.round(downPct)}%</span>
<span className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-blue-500"></span>{t("network.labels.up")} {Math.round(upPct)}%</span>
</div>
</CardContent>
</Card>
@@ -642,7 +697,7 @@ export function NetworkMetrics() {
{/* ── Active Interfaces (preview restyle v2: revertido al original con title uppercase) ── */}
<Card className="bg-card border-border">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Active Interfaces</CardTitle>
<CardTitle className="text-sm font-medium text-muted-foreground">{t("network.cards.activeInterfaces")}</CardTitle>
<Network className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
@@ -651,14 +706,16 @@ export function NetworkMetrics() {
</div>
<div className="flex flex-wrap items-center gap-2 mt-2">
<Badge variant="outline" className="bg-blue-500/10 text-blue-500 border-blue-500/20">
Physical: {networkData.physical_active_count ?? 0}/{networkData.physical_total_count ?? 0}
{t("network.interfaceTypes.physical")}: {networkData.physical_active_count ?? 0}/{networkData.physical_total_count ?? 0}
</Badge>
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
Bridges: {networkData.bridge_active_count ?? 0}/{networkData.bridge_total_count ?? 0}
{t("network.interfaceTypes.bridges")}: {networkData.bridge_active_count ?? 0}/{networkData.bridge_total_count ?? 0}
</Badge>
</div>
<p className="text-xs text-muted-foreground mt-2">
{(networkData.physical_total_count ?? 0) + (networkData.bridge_total_count ?? 0)} total interfaces
{t("network.summary.totalInterfaces", {
count: (networkData.physical_total_count ?? 0) + (networkData.bridge_total_count ?? 0),
})}
</p>
</CardContent>
</Card>
@@ -666,8 +723,8 @@ export function NetworkMetrics() {
{/* ── Network Status (preview restyle: packet-loss highlight + 2x2 grid) ── */}
<Card className="bg-card border-border">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Network Status</CardTitle>
<Badge variant="outline" className={`${healthColor}`}>{healthStatus === 'Healthy' ? '✓ ' : ''}{healthStatus}</Badge>
<CardTitle className="text-sm font-medium text-muted-foreground">{t("network.cards.status")}</CardTitle>
<Badge variant="outline" className={`${healthColor}`}>{healthStatusKey === "network.status.healthy" ? "✓ " : ""}{t(healthStatusKey)}</Badge>
</CardHeader>
<CardContent>
{(() => {
@@ -680,13 +737,13 @@ export function NetworkMetrics() {
return (
<div className={`mb-3 text-xl lg:text-2xl font-bold ${lossColor} leading-none`}>
{avgPacketLoss}<span className="text-sm font-normal text-muted-foreground">% </span>
<span className="text-sm font-normal text-muted-foreground">Packet Loss</span>
<span className="text-sm font-normal text-muted-foreground">{t("network.labels.packetLoss")}</span>
</div>
)
})()}
<div className="grid grid-cols-2 gap-x-3 gap-y-3 pt-3 border-t border-border/50 text-sm">
<div className="min-w-0">
<div className="text-muted-foreground">Hostname:</div>
<div className="text-muted-foreground">{t("network.labels.hostname")}:</div>
<div className="font-medium font-mono truncate">{hostname}</div>
</div>
<div className="min-w-0">
@@ -694,12 +751,12 @@ export function NetworkMetrics() {
<div className="font-medium font-mono truncate">{primaryDNS}</div>
</div>
<div className="min-w-0">
<div className="text-muted-foreground">Errors:</div>
<div className="text-muted-foreground">{t("network.labels.errors")}:</div>
<div className="font-medium font-mono">{totalErrors}</div>
</div>
<div className="min-w-0">
<div className="text-muted-foreground">Domain:</div>
<div className="font-medium font-mono truncate">{networkData.domain || '—'}</div>
<div className="text-muted-foreground">{t("network.labels.domain")}:</div>
<div className="font-medium font-mono truncate">{domain}</div>
</div>
</div>
</CardContent>
@@ -711,7 +768,7 @@ export function NetworkMetrics() {
onClick={() => setLatencyModalOpen(true)}
>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Network Latency</CardTitle>
<CardTitle className="text-sm font-medium text-muted-foreground">{t("network.cards.latency")}</CardTitle>
<div className="flex items-center gap-1 text-muted-foreground">
<Timer className="h-4 w-4" />
<ChevronRight className="h-4 w-4 opacity-60" />
@@ -734,9 +791,9 @@ export function NetworkMetrics() {
: "bg-red-500/10 text-red-500 border-red-500/20"
}
>
{(latencyData?.stats?.current ?? 0) < 50 ? "Excellent" :
(latencyData?.stats?.current ?? 0) < 100 ? "Good" :
(latencyData?.stats?.current ?? 0) < 200 ? "Fair" : "Poor"}
{(latencyData?.stats?.current ?? 0) < 50 ? t("network.latency.status.excellent") :
(latencyData?.stats?.current ?? 0) < 100 ? t("network.latency.status.good") :
(latencyData?.stats?.current ?? 0) < 200 ? t("network.latency.status.fair") : t("network.latency.status.poor")}
</Badge>
</div>
{/* Sparkline */}
@@ -765,7 +822,7 @@ export function NetworkMetrics() {
</div>
)}
<p className="text-xs text-muted-foreground mt-1">
Avg: {latencyData?.stats?.avg ?? 0}ms | Max: {latencyData?.stats?.max ?? 0}ms
{t("network.labels.avg")}: {latencyData?.stats?.avg ?? 0}ms | {t("network.labels.max")}: {latencyData?.stats?.max ?? 0}ms
</p>
</CardContent>
</Card>
@@ -778,11 +835,11 @@ export function NetworkMetrics() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="hour">1 Hour</SelectItem>
<SelectItem value="day">24 Hours</SelectItem>
<SelectItem value="week">7 Days</SelectItem>
<SelectItem value="month">30 Days</SelectItem>
<SelectItem value="year">1 Year</SelectItem>
<SelectItem value="hour">{t("network.timeframes.hour")}</SelectItem>
<SelectItem value="day">{t("network.timeframes.day")}</SelectItem>
<SelectItem value="week">{t("network.timeframes.week")}</SelectItem>
<SelectItem value="month">{t("network.timeframes.month")}</SelectItem>
<SelectItem value="year">{t("network.timeframes.year")}</SelectItem>
</SelectContent>
</Select>
</div>
@@ -792,7 +849,7 @@ export function NetworkMetrics() {
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-foreground flex items-center">
<Activity className="h-5 w-5 mr-2" />
Network Traffic
{t("network.cards.traffic")}
</CardTitle>
</CardHeader>
<CardContent>
@@ -904,9 +961,12 @@ export function NetworkMetrics() {
<CardHeader>
<CardTitle className="text-foreground flex items-center">
<Router className="h-5 w-5 mr-2" />
Physical Interfaces
{t("network.sections.physicalInterfaces")}
<Badge variant="outline" className="ml-3 bg-blue-500/10 text-blue-500 border-blue-500/20">
{networkData.physical_active_count ?? 0}/{networkData.physical_total_count ?? 0} Active
{t("network.summary.activeCount", {
active: networkData.physical_active_count ?? 0,
total: networkData.physical_total_count ?? 0,
})}
</Badge>
</CardTitle>
</CardHeader>
@@ -916,7 +976,7 @@ export function NetworkMetrics() {
long interface names won't push others off-screen. */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{networkData.physical_interfaces.map((iface) =>
renderPhysicalInterfaceCardV2(iface, setSelectedInterface),
renderPhysicalInterfaceCardV2(iface, setSelectedInterface, t),
)}
</div>
</CardContent>
@@ -927,16 +987,19 @@ export function NetworkMetrics() {
<CardHeader>
<CardTitle className="text-foreground flex items-center">
<Network className="h-5 w-5 mr-2" />
Bridge Interfaces
{t("network.sections.bridgeInterfaces")}
<Badge variant="outline" className="ml-3 bg-green-500/10 text-green-500 border-green-500/20">
{networkData.bridge_active_count ?? 0}/{networkData.bridge_total_count ?? 0} Active
{t("network.summary.activeCount", {
active: networkData.bridge_active_count ?? 0,
total: networkData.bridge_total_count ?? 0,
})}
</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{networkData.bridge_interfaces.map((interface_, index) => {
const typeBadge = getInterfaceTypeBadge(interface_.type)
const typeBadge = getInterfaceTypeBadge(interface_.type, t)
return (
<div
@@ -971,30 +1034,30 @@ export function NetworkMetrics() {
: "bg-red-500/10 text-red-500 border-red-500/20"
}
>
{interface_.status.toUpperCase()}
{formatInterfaceStatus(interface_.status, t)}
</Badge>
</div>
{/* Second row: Details - Responsive layout */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<div className="text-muted-foreground text-xs">IP Address</div>
<div className="text-muted-foreground text-xs">{t("network.labels.ipAddress")}</div>
<div className="font-medium text-foreground font-mono text-sm truncate">
{interface_.addresses.length > 0 ? interface_.addresses[0].ip : "N/A"}
{interface_.addresses.length > 0 ? interface_.addresses[0].ip : t("common.notAvailable")}
</div>
</div>
<div>
<div className="text-muted-foreground text-xs">Speed</div>
<div className="text-muted-foreground text-xs">{t("network.labels.speed")}</div>
<div className="font-medium text-foreground flex items-center gap-1">
<Zap className="h-3 w-3" />
{formatSpeed(interface_.speed)}
{formatSpeed(interface_.speed, t("common.notAvailable"))}
</div>
</div>
<div>
<div className="text-muted-foreground text-xs">Duplex</div>
<div className="font-medium text-foreground text-xs capitalize">{interface_.duplex}</div>
<div className="text-muted-foreground text-xs">{t("network.labels.duplex")}</div>
<div className="font-medium text-foreground text-xs">{formatDuplex(interface_.duplex, t)}</div>
</div>
<div>
@@ -1025,16 +1088,19 @@ export function NetworkMetrics() {
<CardHeader>
<CardTitle className="text-foreground flex items-center">
<Network className="h-5 w-5 mr-2" />
VM & LXC Network Interfaces
{t("network.sections.vmLxcInterfaces")}
<Badge variant="outline" className="ml-3 bg-orange-500/10 text-orange-500 border-orange-500/20">
{networkData.vm_lxc_active_count ?? 0} / {networkData.vm_lxc_total_count ?? 0} Active
{t("network.summary.activeCount", {
active: networkData.vm_lxc_active_count ?? 0,
total: networkData.vm_lxc_total_count ?? 0,
})}
</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{vmLxcInterfaces.map((interface_, index) => {
const vmTypeBadge = getVMTypeBadge(interface_.vm_type)
const vmTypeBadge = getVMTypeBadge(interface_.vm_type, t)
return (
<div
@@ -1062,7 +1128,7 @@ export function NetworkMetrics() {
: "bg-red-500/10 text-red-500 border-red-500/20"
}
>
{interface_.status.toUpperCase()}
{formatInterfaceStatus(interface_.status, t)}
</Badge>
</div>
@@ -1070,20 +1136,20 @@ export function NetworkMetrics() {
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<div className="text-sm text-muted-foreground">VMID</div>
<div className="font-medium">{interface_.vmid ?? "N/A"}</div>
<div className="font-medium">{interface_.vmid ?? t("common.notAvailable")}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Speed</div>
<div className="text-sm text-muted-foreground">{t("network.labels.speed")}</div>
<div className="font-medium text-foreground flex items-center gap-1">
<Zap className="h-3 w-3" />
{formatSpeed(interface_.speed)}
{formatSpeed(interface_.speed, t("common.notAvailable"))}
</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Duplex</div>
<div className="font-medium text-foreground text-xs capitalize">{interface_.duplex}</div>
<div className="text-sm text-muted-foreground">{t("network.labels.duplex")}</div>
<div className="font-medium text-foreground text-xs">{formatDuplex(interface_.duplex, t)}</div>
</div>
<div>
@@ -1114,10 +1180,10 @@ export function NetworkMetrics() {
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Router className="h-5 w-5" />
{selectedInterface?.name} - Interface Details
{selectedInterface?.name} - {t("network.interfaceDetails.title")}
</DialogTitle>
<DialogDescription>
View detailed information and network traffic statistics for this interface
{t("network.interfaceDetails.description")}
</DialogDescription>
{selectedInterface?.status.toLowerCase() === "up" && selectedInterface?.vm_type !== "vm" && (
<div className="flex justify-end pt-2">
@@ -1126,11 +1192,11 @@ export function NetworkMetrics() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="hour">1 Hour</SelectItem>
<SelectItem value="day">24 Hours</SelectItem>
<SelectItem value="week">7 Days</SelectItem>
<SelectItem value="month">30 Days</SelectItem>
<SelectItem value="year">1 Year</SelectItem>
<SelectItem value="hour">{t("network.timeframes.hour")}</SelectItem>
<SelectItem value="day">{t("network.timeframes.day")}</SelectItem>
<SelectItem value="week">{t("network.timeframes.week")}</SelectItem>
<SelectItem value="month">{t("network.timeframes.month")}</SelectItem>
<SelectItem value="year">{t("network.timeframes.year")}</SelectItem>
</SelectContent>
</Select>
</div>
@@ -1156,21 +1222,21 @@ export function NetworkMetrics() {
<>
{/* Basic Information */}
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Basic Information</h3>
<h3 className="text-sm font-semibold text-muted-foreground mb-3">{t("network.interfaceDetails.basicInformation")}</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-sm text-muted-foreground">Interface Name</div>
<div className="text-sm text-muted-foreground">{t("network.labels.interfaceName")}</div>
<div className="font-medium">{displayInterface.name}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Type</div>
<Badge variant="outline" className={getInterfaceTypeBadge(displayInterface.type).color}>
{getInterfaceTypeBadge(displayInterface.type).label}
<div className="text-sm text-muted-foreground">{t("network.labels.type")}</div>
<Badge variant="outline" className={getInterfaceTypeBadge(displayInterface.type, t).color}>
{getInterfaceTypeBadge(displayInterface.type, t).label}
</Badge>
</div>
{displayInterface.type === "bridge" && displayInterface.bridge_physical_interface && (
<div className="col-span-2">
<div className="text-sm text-muted-foreground">Physical Interface</div>
<div className="text-sm text-muted-foreground">{t("network.labels.physicalInterface")}</div>
<div className="font-medium text-blue-500 text-lg break-all">
{displayInterface.bridge_physical_interface}
</div>
@@ -1180,7 +1246,7 @@ export function NetworkMetrics() {
there never matched. */}
{displayInterface.bridge_bond_slaves && displayInterface.bridge_bond_slaves.length > 0 && (
<div className="mt-2">
<div className="text-sm text-muted-foreground mb-2">Bond Members</div>
<div className="text-sm text-muted-foreground mb-2">{t("network.labels.bondMembers")}</div>
<div className="flex flex-wrap gap-2">
{displayInterface.bridge_bond_slaves.map((slave, idx) => (
<Badge
@@ -1198,19 +1264,19 @@ export function NetworkMetrics() {
)}
{displayInterface.type === "vm_lxc" && displayInterface.vm_name && (
<div className="col-span-2">
<div className="text-sm text-muted-foreground">VM/LXC Name</div>
<div className="text-sm text-muted-foreground">{t("network.labels.vmLxcName")}</div>
<div className="font-medium text-orange-500 text-lg flex items-center gap-2">
{displayInterface.vm_name}
{displayInterface.vm_type && (
<Badge variant="outline" className={getVMTypeBadge(displayInterface.vm_type).color}>
{getVMTypeBadge(displayInterface.vm_type).label}
<Badge variant="outline" className={getVMTypeBadge(displayInterface.vm_type, t).color}>
{getVMTypeBadge(displayInterface.vm_type, t).label}
</Badge>
)}
</div>
</div>
)}
<div>
<div className="text-sm text-muted-foreground">Status</div>
<div className="text-sm text-muted-foreground">{t("network.labels.status")}</div>
<Badge
variant="outline"
className={
@@ -1219,16 +1285,16 @@ export function NetworkMetrics() {
: "bg-red-500/10 text-red-500 border-red-500/20"
}
>
{displayInterface.status.toUpperCase()}
{formatInterfaceStatus(displayInterface.status, t)}
</Badge>
</div>
<div>
<div className="text-sm text-muted-foreground">Speed</div>
<div className="font-medium">{formatSpeed(displayInterface.speed)}</div>
<div className="text-sm text-muted-foreground">{t("network.labels.speed")}</div>
<div className="font-medium">{formatSpeed(displayInterface.speed, t("common.notAvailable"))}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Duplex</div>
<div className="font-medium capitalize">{displayInterface.duplex}</div>
<div className="text-sm text-muted-foreground">{t("network.labels.duplex")}</div>
<div className="font-medium">{formatDuplex(displayInterface.duplex, t)}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">MTU</div>
@@ -1236,7 +1302,7 @@ export function NetworkMetrics() {
</div>
{displayInterface.mac_address && (
<div className="col-span-2">
<div className="text-sm text-muted-foreground">MAC Address</div>
<div className="text-sm text-muted-foreground">{t("network.labels.macAddress")}</div>
<div className="font-medium font-mono">{displayInterface.mac_address}</div>
</div>
)}
@@ -1246,13 +1312,13 @@ export function NetworkMetrics() {
{/* IP Addresses */}
{displayInterface.addresses.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-3">IP Addresses</h3>
<h3 className="text-sm font-semibold text-muted-foreground mb-3">{t("network.interfaceDetails.ipAddresses")}</h3>
<div className="space-y-2">
{displayInterface.addresses.map((addr, idx) => (
<div key={idx} className="flex items-center justify-between p-3 rounded-lg bg-muted/50">
<div>
<div className="font-medium font-mono">{addr.ip}</div>
<div className="text-sm text-muted-foreground">Netmask: {addr.netmask}</div>
<div className="text-sm text-muted-foreground">{t("network.labels.netmask")}: {addr.netmask}</div>
</div>
</div>
))}
@@ -1264,23 +1330,15 @@ export function NetworkMetrics() {
{displayInterface.status.toLowerCase() === "up" && displayInterface.vm_type !== "vm" ? (
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-4">
Network Traffic Statistics (
{modalTimeframe === "hour"
? "Last Hour"
: modalTimeframe === "day"
? "Last 24 Hours"
: modalTimeframe === "week"
? "Last 7 Days"
: modalTimeframe === "month"
? "Last 30 Days"
: "Last Year"}
)
{t("network.interfaceDetails.trafficStatistics", {
timeframe: getLastTimeframeLabel(modalTimeframe),
})}
</h3>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-sm text-muted-foreground">
{networkUnit === "Bits" ? "Bits Received" : "Bytes Received"}
{networkUnit === "Bits" ? t("network.labels.bitsReceived") : t("network.labels.bytesReceived")}
</div>
<div className="font-medium text-green-500 text-lg">
{formatNetworkTraffic(
@@ -1292,7 +1350,7 @@ export function NetworkMetrics() {
</div>
<div>
<div className="text-sm text-muted-foreground">
{networkUnit === "Bits" ? "Bits Sent" : "Bytes Sent"}
{networkUnit === "Bits" ? t("network.labels.bitsSent") : t("network.labels.bytesSent")}
</div>
<div className="font-medium text-blue-500 text-lg">
{formatNetworkTraffic(
@@ -1316,31 +1374,31 @@ export function NetworkMetrics() {
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-border">
<div>
<div className="text-sm text-muted-foreground">Packets Received</div>
<div className="text-sm text-muted-foreground">{t("network.labels.packetsReceived")}</div>
<div className="font-medium">
{displayInterface.packets_recv?.toLocaleString() || "N/A"}
{displayInterface.packets_recv?.toLocaleString() || t("common.notAvailable")}
</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Packets Sent</div>
<div className="text-sm text-muted-foreground">{t("network.labels.packetsSent")}</div>
<div className="font-medium">
{displayInterface.packets_sent?.toLocaleString() || "N/A"}
{displayInterface.packets_sent?.toLocaleString() || t("common.notAvailable")}
</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Errors In</div>
<div className="text-sm text-muted-foreground">{t("network.labels.errorsIn")}</div>
<div className="font-medium text-red-500">{displayInterface.errors_in || 0}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Errors Out</div>
<div className="text-sm text-muted-foreground">{t("network.labels.errorsOut")}</div>
<div className="font-medium text-red-500">{displayInterface.errors_out || 0}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Drops In</div>
<div className="text-sm text-muted-foreground">{t("network.labels.dropsIn")}</div>
<div className="font-medium text-yellow-500">{displayInterface.drops_in || 0}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Drops Out</div>
<div className="text-sm text-muted-foreground">{t("network.labels.dropsOut")}</div>
<div className="font-medium text-yellow-500">{displayInterface.drops_out || 0}</div>
</div>
</div>
@@ -1348,11 +1406,11 @@ export function NetworkMetrics() {
</div>
) : displayInterface.status.toLowerCase() === "up" && displayInterface.vm_type === "vm" ? (
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-4">Traffic since last boot</h3>
<h3 className="text-sm font-semibold text-muted-foreground mb-4">{t("network.interfaceDetails.trafficSinceBoot")}</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-sm text-muted-foreground">
{networkUnit === "Bits" ? "Bits Received" : "Bytes Received"}
{networkUnit === "Bits" ? t("network.labels.bitsReceived") : t("network.labels.bytesReceived")}
</div>
<div className="font-medium text-green-500 text-lg">
{formatNetworkTraffic(displayInterface.bytes_recv || 0, networkUnit)}
@@ -1360,38 +1418,38 @@ export function NetworkMetrics() {
</div>
<div>
<div className="text-sm text-muted-foreground">
{networkUnit === "Bits" ? "Bits Sent" : "Bytes Sent"}
{networkUnit === "Bits" ? t("network.labels.bitsSent") : t("network.labels.bytesSent")}
</div>
<div className="font-medium text-blue-500 text-lg">
{formatNetworkTraffic(displayInterface.bytes_sent || 0, networkUnit)}
</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Packets Received</div>
<div className="text-sm text-muted-foreground">{t("network.labels.packetsReceived")}</div>
<div className="font-medium">
{displayInterface.packets_recv?.toLocaleString() || "N/A"}
{displayInterface.packets_recv?.toLocaleString() || t("common.notAvailable")}
</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Packets Sent</div>
<div className="text-sm text-muted-foreground">{t("network.labels.packetsSent")}</div>
<div className="font-medium">
{displayInterface.packets_sent?.toLocaleString() || "N/A"}
{displayInterface.packets_sent?.toLocaleString() || t("common.notAvailable")}
</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Errors In</div>
<div className="text-sm text-muted-foreground">{t("network.labels.errorsIn")}</div>
<div className="font-medium text-red-500">{displayInterface.errors_in || 0}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Errors Out</div>
<div className="text-sm text-muted-foreground">{t("network.labels.errorsOut")}</div>
<div className="font-medium text-red-500">{displayInterface.errors_out || 0}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Drops In</div>
<div className="text-sm text-muted-foreground">{t("network.labels.dropsIn")}</div>
<div className="font-medium text-yellow-500">{displayInterface.drops_in || 0}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Drops Out</div>
<div className="text-sm text-muted-foreground">{t("network.labels.dropsOut")}</div>
<div className="font-medium text-yellow-500">{displayInterface.drops_out || 0}</div>
</div>
</div>
@@ -1399,9 +1457,9 @@ export function NetworkMetrics() {
) : (
<div className="bg-muted/30 rounded-lg p-6 text-center">
<AlertCircle className="h-12 w-12 text-muted-foreground mx-auto mb-3" />
<h3 className="text-lg font-semibold text-foreground mb-2">Interface Inactive</h3>
<h3 className="text-lg font-semibold text-foreground mb-2">{t("network.interfaceDetails.inactiveTitle")}</h3>
<p className="text-sm text-muted-foreground">
This interface is currently down. Network traffic statistics are not available.
{t("network.interfaceDetails.inactiveDescription")}
</p>
</div>
)}
@@ -1409,12 +1467,12 @@ export function NetworkMetrics() {
{/* Bond Information */}
{displayInterface.type === "bond" && displayInterface.bond_slaves && (
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Bond Configuration</h3>
<h3 className="text-sm font-semibold text-muted-foreground mb-3">{t("network.interfaceDetails.bondConfiguration")}</h3>
<div className="space-y-3">
<div>
<div className="text-sm text-muted-foreground">Bonding Mode</div>
<div className="text-sm text-muted-foreground">{t("network.labels.bondingMode")}</div>
<div className="font-medium">
{displayInterface.bond_mode || "Unknown"}
{displayInterface.bond_mode || t("common.unknown")}
{displayInterface.bond_mode_detail &&
displayInterface.bond_mode_detail !== displayInterface.bond_mode && (
<span className="text-muted-foreground font-normal">
@@ -1427,13 +1485,13 @@ export function NetworkMetrics() {
{displayInterface.bond_active_slave && (
<div>
<div className="text-sm text-muted-foreground">
{displayInterface.bond_supports_failover ? "Active Slave" : "Primary Slave"}
{displayInterface.bond_supports_failover ? t("network.labels.activeSlave") : t("network.labels.primarySlave")}
</div>
<div className="font-medium">{displayInterface.bond_active_slave}</div>
</div>
)}
<div>
<div className="text-sm text-muted-foreground mb-2">Slave Interfaces</div>
<div className="text-sm text-muted-foreground mb-2">{t("network.labels.slaveInterfaces")}</div>
<div className="flex flex-wrap gap-2">
{displayInterface.bond_slaves.map((slave, idx) => {
// Only active-backup has a real standby. In every
@@ -1456,7 +1514,7 @@ export function NetworkMetrics() {
return (
<Badge key={idx} variant="outline" className={tone}>
{slave}
{role && <span className="ml-1 opacity-70">· {role}</span>}
{role && <span className="ml-1 opacity-70">· {t(`network.roles.${role}`)}</span>}
</Badge>
)
})}
@@ -1469,9 +1527,9 @@ export function NetworkMetrics() {
{/* Bridge Information */}
{displayInterface.type === "bridge" && displayInterface.bridge_members && (
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Bridge Configuration</h3>
<h3 className="text-sm font-semibold text-muted-foreground mb-3">{t("network.interfaceDetails.bridgeConfiguration")}</h3>
<div>
<div className="text-sm text-muted-foreground mb-2">Virtual Member Interfaces</div>
<div className="text-sm text-muted-foreground mb-2">{t("network.labels.virtualMemberInterfaces")}</div>
<div className="flex flex-wrap gap-2">
{displayInterface.bridge_members.length > 0 ? (
displayInterface.bridge_members
@@ -1494,7 +1552,7 @@ export function NetworkMetrics() {
</Badge>
))
) : (
<div className="text-sm text-muted-foreground">No virtual members</div>
<div className="text-sm text-muted-foreground">{t("network.empty.noVirtualMembers")}</div>
)}
</div>
</div>
@@ -5,6 +5,7 @@ import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContai
import { Loader2 } from 'lucide-react'
import { fetchApi } from "../lib/api-config"
import { getNetworkUnit } from "../lib/format-network"
import { useT } from "../lib/i18n/provider"
interface NetworkMetricsData {
time: string
@@ -50,6 +51,7 @@ export function NetworkTrafficChart({
refreshInterval = 60000,
networkUnit: networkUnitProp, // Rename prop to avoid conflict
}: NetworkTrafficChartProps) {
const t = useT()
const [data, setData] = useState<NetworkMetricsData[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@@ -114,7 +116,7 @@ export function NetworkTrafficChart({
const result = await fetchApi<any>(apiPath)
if (!result.data || !Array.isArray(result.data)) {
throw new Error("Invalid data format received from server")
throw new Error(t("network.chart.invalidDataFormat"))
}
if (result.data.length === 0) {
@@ -207,7 +209,7 @@ export function NetworkTrafficChart({
}
} catch (err: any) {
console.error("Error fetching network metrics:", err)
setError(err.message || "Error loading metrics")
setError(err.message || t("network.chart.loadError"))
} finally {
setLoading(false)
}
@@ -255,7 +257,7 @@ export function NetworkTrafficChart({
if (error) {
return (
<div className="flex flex-col items-center justify-center h-[300px] gap-2">
<p className="text-muted-foreground text-sm">Network metrics not available yet</p>
<p className="text-muted-foreground text-sm">{t("overview.networkMetricsUnavailable")}</p>
<p className="text-xs text-red-500">{error}</p>
</div>
)
@@ -264,7 +266,7 @@ export function NetworkTrafficChart({
if (data.length === 0) {
return (
<div className="flex items-center justify-center h-[300px]">
<p className="text-muted-foreground text-sm">No network metrics available</p>
<p className="text-muted-foreground text-sm">{t("overview.noNetworkMetrics")}</p>
</div>
)
}
@@ -295,7 +297,7 @@ export function NetworkTrafficChart({
}}
domain={[0, "auto"]}
/>
<Tooltip content={<CustomNetworkTooltip networkUnit={networkUnit} />} /> // Pass networkUnit to tooltip
<Tooltip content={<CustomNetworkTooltip networkUnit={networkUnit} />} />
<Legend verticalAlign="top" height={36} content={renderLegend} />
<Area
type="monotone"
@@ -304,7 +306,7 @@ export function NetworkTrafficChart({
strokeWidth={2}
fill="#10b981"
fillOpacity={0.3}
name="Received"
name={t("overview.receivedShort")}
hide={!visibleLines.netIn}
isAnimationActive={true}
animationDuration={300}
@@ -317,7 +319,7 @@ export function NetworkTrafficChart({
strokeWidth={2}
fill="#3b82f6"
fillOpacity={0.3}
name="Sent"
name={t("overview.sentShort")}
hide={!visibleLines.netOut}
isAnimationActive={true}
animationDuration={300}
+48 -28
View File
@@ -7,12 +7,13 @@ import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContai
import { Loader2, TrendingUp, MemoryStick } from "lucide-react"
import { useIsMobile } from "../hooks/use-mobile"
import { fetchApi } from "@/lib/api-config"
import { useI18n } from "../lib/i18n/provider"
const TIMEFRAME_OPTIONS = [
{ value: "hour", label: "1 Hour" },
{ value: "day", label: "24 Hours" },
{ value: "week", label: "7 Days" },
{ value: "month", label: "30 Days" },
{ value: "hour", labelKey: "overview.timeframes.hour" },
{ value: "day", labelKey: "overview.timeframes.day" },
{ value: "week", labelKey: "overview.timeframes.week" },
{ value: "month", labelKey: "overview.timeframes.month" },
]
interface NodeMetricsData {
@@ -90,9 +91,11 @@ type PeriodStat = { avg: number; max: number; min: number } | null
function ChartStatsHeader({
stats,
suffix = "",
labels,
}: {
stats: PeriodStat
suffix?: string
labels: { avg: string; max: string; min: string }
}) {
if (!stats) return null
const fmt = (n: number) => (n >= 100 ? n.toFixed(0) : n.toFixed(1))
@@ -100,15 +103,15 @@ function ChartStatsHeader({
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 text-sm tabular-nums">
<span>
<span className="font-semibold text-foreground">{fmt(stats.avg)}{suffix}</span>
<span className="ml-1 text-xs uppercase tracking-wide text-muted-foreground">avg</span>
<span className="ml-1 text-xs uppercase tracking-wide text-muted-foreground">{labels.avg}</span>
</span>
<span>
<span className="font-semibold text-foreground">{fmt(stats.max)}{suffix}</span>
<span className="ml-1 text-xs uppercase tracking-wide text-muted-foreground">max</span>
<span className="ml-1 text-xs uppercase tracking-wide text-muted-foreground">{labels.max}</span>
</span>
<span>
<span className="font-semibold text-foreground">{fmt(stats.min)}{suffix}</span>
<span className="ml-1 text-xs uppercase tracking-wide text-muted-foreground">min</span>
<span className="ml-1 text-xs uppercase tracking-wide text-muted-foreground">{labels.min}</span>
</span>
</div>
)
@@ -116,6 +119,7 @@ function ChartStatsHeader({
export function NodeMetricsCharts() {
const { language, t } = useI18n()
const [timeframe, setTimeframe] = useState("day")
const [data, setData] = useState<NodeMetricsData[]>([])
// period_stats from the backend — computed over the raw RRD points
@@ -141,7 +145,7 @@ export function NodeMetricsCharts() {
useEffect(() => {
fetchMetrics()
}, [timeframe])
}, [timeframe, language])
const fetchMetrics = async () => {
setLoading(true)
@@ -153,7 +157,7 @@ export function NodeMetricsCharts() {
if (!result.data || !Array.isArray(result.data)) {
console.error("Invalid data format - data is not an array:", result)
throw new Error("Invalid data format received from server")
throw new Error(t("overview.invalidMetricsData"))
}
if (result.data.length === 0) {
@@ -171,26 +175,26 @@ export function NodeMetricsCharts() {
let timeLabel = ""
if (timeframe === "hour") {
timeLabel = date.toLocaleString("en-US", {
timeLabel = date.toLocaleString(language, {
hour: "2-digit",
minute: "2-digit",
hour12: false,
})
} else if (timeframe === "day") {
timeLabel = date.toLocaleString("en-US", {
timeLabel = date.toLocaleString(language, {
hour: "2-digit",
minute: "2-digit",
hour12: false,
})
} else if (timeframe === "week") {
timeLabel = date.toLocaleString("en-US", {
timeLabel = date.toLocaleString(language, {
month: "short",
day: "numeric",
hour: "2-digit",
hour12: false,
})
} else {
timeLabel = date.toLocaleString("en-US", {
timeLabel = date.toLocaleString(language, {
month: "short",
day: "numeric",
})
@@ -224,7 +228,7 @@ export function NodeMetricsCharts() {
// the user sees actionable text instead of a bare "503".
const body = err?.body
setError({
headline: body?.error || err?.message || "Error loading metrics",
headline: body?.error || err?.message || t("overview.metricsLoadError"),
details: body?.details,
suggestion: body?.suggestion,
})
@@ -311,7 +315,7 @@ export function NodeMetricsCharts() {
{error.suggestion && (
<div className="w-full mt-2">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground mb-1">
Suggested fix on the Proxmox host
{t("overview.suggestedFix")}
</p>
<code className="block text-xs bg-background/60 border border-border rounded px-2 py-1.5 font-mono break-all">
{error.suggestion}
@@ -336,14 +340,14 @@ export function NodeMetricsCharts() {
<Card className="bg-card border-border">
<CardContent className="p-6">
<div className="flex items-center justify-center h-[300px]">
<p className="text-muted-foreground text-sm">No metrics data available</p>
<p className="text-muted-foreground text-sm">{t("overview.noMetricsData")}</p>
</div>
</CardContent>
</Card>
<Card className="bg-card border-border">
<CardContent className="p-6">
<div className="flex items-center justify-center h-[300px]">
<p className="text-muted-foreground text-sm">No metrics data available</p>
<p className="text-muted-foreground text-sm">{t("overview.noMetricsData")}</p>
</div>
</CardContent>
</Card>
@@ -363,7 +367,7 @@ export function NodeMetricsCharts() {
<SelectContent>
{TIMEFRAME_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
{t(option.labelKey)}
</SelectItem>
))}
</SelectContent>
@@ -378,9 +382,17 @@ export function NodeMetricsCharts() {
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<CardTitle className="text-foreground flex items-center">
<TrendingUp className="h-5 w-5 mr-2" />
CPU Usage & Load Average
{t("overview.cpuUsageLoadAverage")}
</CardTitle>
<ChartStatsHeader stats={periodStats.cpu ?? null} suffix="%" />
<ChartStatsHeader
stats={periodStats.cpu ?? null}
suffix="%"
labels={{
avg: t("overview.stats.avg"),
max: t("overview.stats.max"),
min: t("overview.stats.min"),
}}
/>
</div>
</CardHeader>
<CardContent className="px-0 md:px-6">
@@ -414,7 +426,7 @@ export function NodeMetricsCharts() {
className="text-foreground"
tick={{ fill: "currentColor", fontSize: 12 }}
label={
isMobile ? undefined : { value: "Load", angle: 90, position: "insideRight", fill: "currentColor" }
isMobile ? undefined : { value: t("overview.loadAxis"), angle: 90, position: "insideRight", fill: "currentColor" }
}
domain={[0, "dataMax"]}
/>
@@ -428,7 +440,7 @@ export function NodeMetricsCharts() {
strokeWidth={2}
fill="#3b82f6"
fillOpacity={0.3}
name="CPU %"
name={t("overview.cpuPercent")}
hide={!visibleLines.cpu.cpu}
/>
<Area
@@ -439,7 +451,7 @@ export function NodeMetricsCharts() {
strokeWidth={2}
fill="#10b981"
fillOpacity={0.3}
name="Load Avg"
name={t("overview.loadAverage")}
hide={!visibleLines.cpu.load}
/>
</AreaChart>
@@ -453,9 +465,17 @@ export function NodeMetricsCharts() {
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<CardTitle className="text-foreground flex items-center">
<MemoryStick className="h-5 w-5 mr-2" />
Memory Usage
{t("overview.memoryUsage")}
</CardTitle>
<ChartStatsHeader stats={periodStats.memory_used ?? null} suffix=" GB" />
<ChartStatsHeader
stats={periodStats.memory_used ?? null}
suffix=" GB"
labels={{
avg: t("overview.stats.avg"),
max: t("overview.stats.max"),
min: t("overview.stats.min"),
}}
/>
</div>
</CardHeader>
<CardContent className="px-0 pr-2 md:px-6">
@@ -490,7 +510,7 @@ export function NodeMetricsCharts() {
strokeWidth={2}
fill="#3b82f6"
fillOpacity={0.1}
name="Total"
name={t("overview.total")}
hide={!visibleLines.memory.memoryTotal}
/>
<Area
@@ -500,7 +520,7 @@ export function NodeMetricsCharts() {
strokeWidth={2}
fill="#10b981"
fillOpacity={0.3}
name="Used"
name={t("overview.used")}
hide={!visibleLines.memory.memoryUsed}
/>
{/* Only show ZFS ARC if there's data */}
@@ -525,7 +545,7 @@ export function NodeMetricsCharts() {
strokeWidth={2}
fill="#06b6d4"
fillOpacity={0.3}
name="Free"
name={t("overview.free")}
hide={!visibleLines.memory.memoryFree}
/>
)}
File diff suppressed because it is too large Load Diff
+32 -36
View File
@@ -20,11 +20,12 @@ import {
} from "lucide-react"
import Image from "next/image"
import { Checkbox } from "./ui/checkbox"
import { useT } from "../lib/i18n/provider"
interface OnboardingSlide {
id: number
title: string
description: string
titleKey: string
descriptionKey: string
image?: string
icon: React.ReactNode
gradient: string
@@ -33,77 +34,70 @@ interface OnboardingSlide {
const slides: OnboardingSlide[] = [
{
id: 0,
title: "Welcome to ProxMenux Monitor!",
description:
"Your new monitoring tool for Proxmox. Discover all the features that will help you manage and supervise your infrastructure efficiently.",
titleKey: "onboarding.slides.welcome.title",
descriptionKey: "onboarding.slides.welcome.description",
icon: <Sparkles className="h-16 w-16" />,
gradient: "from-blue-500 via-purple-500 to-pink-500",
},
{
id: 1,
title: "System Overview",
description:
"Monitor your server's status in real-time: CPU, memory, temperature, system load and more. Everything in an intuitive and easy-to-understand dashboard.",
titleKey: "onboarding.slides.overview.title",
descriptionKey: "onboarding.slides.overview.description",
image: "/images/onboarding/imagen1.png",
icon: <LayoutDashboard className="h-12 w-12" />,
gradient: "from-blue-500 to-cyan-500",
},
{
id: 2,
title: "Storage Management",
description:
"Visualize the status of all your disks and volumes. Detailed information on capacity, usage, SMART health, temperature and performance of each storage device.",
titleKey: "onboarding.slides.storage.title",
descriptionKey: "onboarding.slides.storage.description",
image: "/images/onboarding/imagen2.png",
icon: <HardDrive className="h-12 w-12" />,
gradient: "from-cyan-500 to-teal-500",
},
{
id: 3,
title: "Network Metrics",
description:
"Monitor network traffic in real-time. Bandwidth statistics, active interfaces, transfer speeds and historical usage graphs.",
titleKey: "onboarding.slides.network.title",
descriptionKey: "onboarding.slides.network.description",
image: "/images/onboarding/imagen3.png",
icon: <Network className="h-12 w-12" />,
gradient: "from-teal-500 to-green-500",
},
{
id: 4,
title: "Virtual Machines & Containers",
description:
"Manage all your VMs and LXC containers from one place. Status, allocated resources, current usage and quick controls for each virtual machine.",
titleKey: "onboarding.slides.virtualMachines.title",
descriptionKey: "onboarding.slides.virtualMachines.description",
image: "/images/onboarding/imagen4.png",
icon: <Box className="h-12 w-12" />,
gradient: "from-green-500 to-emerald-500",
},
{
id: 5,
title: "Hardware Information",
description:
"Complete details of your server hardware: CPU, RAM, GPU, disks, network, UPS and more. Technical specifications, models, serial numbers and status of each component.",
titleKey: "onboarding.slides.hardware.title",
descriptionKey: "onboarding.slides.hardware.description",
image: "/images/onboarding/imagen5.png",
icon: <Cpu className="h-12 w-12" />,
gradient: "from-emerald-500 to-blue-500",
},
{
id: 6,
title: "System Logs",
description:
"Access system logs in real-time. Filter by event type, search for specific errors and keep complete track of your server activity. Download the displayed logs for further analysis.",
titleKey: "onboarding.slides.logs.title",
descriptionKey: "onboarding.slides.logs.description",
image: "/images/onboarding/imagen6.png",
icon: <FileText className="h-12 w-12" />,
gradient: "from-blue-500 to-indigo-500",
},
{
id: 7,
title: "Ready for the Future!",
description:
"ProxMenux Monitor is prepared to receive updates and improvements that will be added gradually, improving the user experience and being able to execute ProxMenux functions from the web panel.",
titleKey: "onboarding.slides.future.title",
descriptionKey: "onboarding.slides.future.description",
icon: <Rocket className="h-16 w-16" />,
gradient: "from-indigo-500 via-purple-500 to-pink-500",
},
]
export function OnboardingCarousel() {
const t = useT()
const [open, setOpen] = useState(false)
const [currentSlide, setCurrentSlide] = useState(0)
const [direction, setDirection] = useState<"next" | "prev">("next")
@@ -155,11 +149,13 @@ export function OnboardingCarousel() {
}
const slide = slides[currentSlide]
const slideTitle = t(slide.titleKey)
const slideDescription = t(slide.descriptionKey)
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-4xl p-0 gap-0 overflow-hidden border-0 bg-transparent">
<DialogTitle className="sr-only">ProxMenux Onboarding</DialogTitle>
<DialogTitle className="sr-only">{t("onboarding.dialogTitle")}</DialogTitle>
<div className="relative bg-card rounded-lg overflow-hidden shadow-2xl">
<Button
variant="ghost"
@@ -181,7 +177,7 @@ export function OnboardingCarousel() {
<div className="relative w-full h-36 md:h-48 flex items-center justify-center px-4">
<Image
src={slide.image || "/placeholder.svg"}
alt={slide.title}
alt={slideTitle}
width={600}
height={400}
className="rounded-lg shadow-2xl object-cover max-h-36 md:max-h-48"
@@ -207,9 +203,9 @@ export function OnboardingCarousel() {
<div className="p-4 md:p-8 space-y-3 md:space-y-6 max-h-[60vh] md:max-h-none overflow-y-auto">
<div className="space-y-2 md:space-y-3">
<h2 className="text-xl md:text-3xl font-bold text-foreground text-balance">{slide.title}</h2>
<h2 className="text-xl md:text-3xl font-bold text-foreground text-balance">{slideTitle}</h2>
<p className="text-sm md:text-lg text-muted-foreground leading-relaxed text-pretty">
{slide.description}
{slideDescription}
</p>
</div>
@@ -223,7 +219,7 @@ export function OnboardingCarousel() {
? "w-8 h-2.5 bg-blue-500 shadow-lg shadow-blue-500/50"
: "w-2.5 h-2.5 bg-muted-foreground/60 hover:bg-muted-foreground/80 border border-muted-foreground/40"
}`}
aria-label={`Go to slide ${index + 1}`}
aria-label={t("onboarding.goToSlide", { number: index + 1 })}
/>
))}
</div>
@@ -236,7 +232,7 @@ export function OnboardingCarousel() {
className="gap-2 w-full sm:w-auto text-sm"
>
<ChevronLeft className="h-4 w-4" />
Previous
{t("onboarding.previous")}
</Button>
<div className="flex gap-2 w-full sm:w-auto">
@@ -247,13 +243,13 @@ export function OnboardingCarousel() {
onClick={handleSkip}
className="flex-1 sm:flex-none bg-transparent text-sm"
>
Skip
{t("onboarding.skip")}
</Button>
<Button
onClick={handleNext}
className="gap-2 bg-blue-500 hover:bg-blue-600 flex-1 sm:flex-none text-sm"
>
Next
{t("onboarding.next")}
<ChevronRight className="h-4 w-4" />
</Button>
</>
@@ -262,7 +258,7 @@ export function OnboardingCarousel() {
onClick={handleNext}
className="gap-2 bg-gradient-to-r from-blue-500 to-purple-500 hover:from-blue-600 hover:to-purple-600 w-full sm:w-auto text-sm"
>
Get Started!
{t("onboarding.getStarted")}
<Sparkles className="h-4 w-4" />
</Button>
)}
@@ -279,7 +275,7 @@ export function OnboardingCarousel() {
htmlFor="dont-show-again"
className="text-xs md:text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer select-none"
>
Don't show this again
{t("onboarding.dontShowAgain")}
</label>
</div>
</div>
+26 -16
View File
@@ -7,6 +7,7 @@ import { ScrollArea } from "./ui/scroll-area"
import { Cpu, MemoryStick, Search } from "lucide-react"
import { fetchApi } from "@/lib/api-config"
import { ProcessInfoModal } from "./process-info-modal"
import { useT } from "@/lib/i18n/provider"
interface ProcessInfo {
pid: number
@@ -61,6 +62,7 @@ const formatRss = (kb: number): string => {
}
export function ProcessDetailModal({ open, onOpenChange, sort }: ProcessDetailModalProps) {
const t = useT()
const [data, setData] = useState<ProcessesResponse | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
@@ -74,7 +76,7 @@ export function ProcessDetailModal({ open, onOpenChange, sort }: ProcessDetailMo
const res = await fetchApi<ProcessesResponse>(`/api/processes?sort=${sort}&limit=${FETCH_LIMIT}`)
setData(res)
} catch (e: any) {
setError(e?.message || "Failed to fetch processes")
setError(e?.message || t("details.processes.loadFailed"))
} finally {
if (!silent) setLoading(false)
}
@@ -110,11 +112,11 @@ export function ProcessDetailModal({ open, onOpenChange, sort }: ProcessDetailMo
const filtered = filter ? allMatches : allMatches.slice(0, DISPLAY_LIMIT)
const Icon = sort === "cpu" ? Cpu : MemoryStick
const title = sort === "cpu" ? "Top processes by CPU" : "Top processes by Memory"
const title = sort === "cpu" ? t("details.processes.topByCpu") : t("details.processes.topByMemory")
const description =
sort === "cpu"
? "Current CPU usage per process, as a fraction of the host's total CPU — same scale as the CPU Usage card above. Refreshes every 3 s while open."
: "Current resident memory per process. Refreshes every 3 s while open."
? t("details.processes.cpuDescription")
: t("details.processes.memoryDescription")
// Accent palette matched to the Overview cards: CPU Usage donut uses
// blue (#3b82f6), Memory cached uses rgba(99,102,241,0.55) — we keep
@@ -160,7 +162,7 @@ export function ProcessDetailModal({ open, onOpenChange, sort }: ProcessDetailMo
<div className="relative mb-2">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Filter by command line, user or PID..."
placeholder={t("details.processes.filterPlaceholder")}
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="pl-8 h-8 text-sm"
@@ -176,16 +178,18 @@ export function ProcessDetailModal({ open, onOpenChange, sort }: ProcessDetailMo
<div
className={`grid items-center gap-x-3 sm:gap-x-6 px-3 py-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground border-b border-border bg-card sticky top-0 z-10 ${gridCols}`}
>
<div className="hidden sm:block">PID</div>
<div className="hidden sm:block truncate">User</div>
<div>Command</div>
<div className={`text-right ${sort === "cpu" ? accent.text : ""}`}>CPU %</div>
<div className={`text-right ${sort === "mem" ? accent.text : ""}`}>{sort === "mem" ? "Memory" : "Mem %"}</div>
<div className="hidden sm:block">{t("details.processes.pid")}</div>
<div className="hidden sm:block truncate">{t("details.processes.user")}</div>
<div>{t("details.processes.command")}</div>
<div className={`text-right ${sort === "cpu" ? accent.text : ""}`}>{t("details.processes.cpuPercent")}</div>
<div className={`text-right ${sort === "mem" ? accent.text : ""}`}>
{sort === "mem" ? t("details.processes.memory") : t("details.processes.memPercent")}
</div>
</div>
{filtered.length === 0 && !loading ? (
<div className="text-center py-8 text-sm text-muted-foreground">
No processes match the filter
{t("details.processes.noMatches")}
</div>
) : (
filtered.map((p) => {
@@ -228,8 +232,8 @@ export function ProcessDetailModal({ open, onOpenChange, sort }: ProcessDetailMo
where avg and now match within sampler
noise. */}
{typeof p.cpu_avg === "number" && p.cpu_avg >= 0.5 && p.cpu_avg > p.cpu * 1.5 && (
<span className="font-mono text-[10px] text-amber-400" title="Average CPU% across this process's lifetime — useful for finding long-running idle baselines">
avg {p.cpu_avg.toFixed(1)}
<span className="font-mono text-[10px] text-amber-400" title={t("details.processes.lifetimeAverageTitle")}>
{t("details.processes.averageShort")} {p.cpu_avg.toFixed(1)}
</span>
)}
<div className="w-full h-1 bg-muted rounded-full overflow-hidden">
@@ -261,9 +265,15 @@ export function ProcessDetailModal({ open, onOpenChange, sort }: ProcessDetailMo
{data?.captured_at && (
<div className="text-[10px] text-muted-foreground text-right mt-1">
Captured {new Date(data.captured_at * 1000).toLocaleTimeString()} · {filter
? `${allMatches.length} match${allMatches.length === 1 ? '' : 'es'} of ${data.processes.length} processes`
: `Top ${filtered.length} of ${data.processes.length} processes`}
{t("details.processes.captured", { time: new Date(data.captured_at * 1000).toLocaleTimeString() })} · {filter
? t(allMatches.length === 1 ? "details.processes.matchCount" : "details.processes.matchesCount", {
count: allMatches.length,
total: data.processes.length,
})
: t("details.processes.topCount", {
shown: filtered.length,
total: data.processes.length,
})}
</div>
)}
</DialogContent>
+47 -45
View File
@@ -5,6 +5,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f
import { ScrollArea } from "./ui/scroll-area"
import { Activity, FileText, HardDrive, Clock, Info } from "lucide-react"
import { fetchApi } from "@/lib/api-config"
import { useI18n } from "../lib/i18n/provider"
interface ProcessDetail {
pid: number
@@ -59,22 +60,24 @@ const formatBytes = (b: number | null | undefined): string => {
// Linux process states from /proc/<pid>/status. The first char of `State:`
// is the canonical letter — the rest of the field is a human label like
// "(running)". We expand the bare letter to something readable.
const stateLabel = (state: string): string => {
const letter = (state || "").trim().charAt(0).toUpperCase()
const stateLabel = (state: string, t: (key: string) => string): string => {
const rawLetter = (state || "").trim().charAt(0)
const letter = rawLetter.toUpperCase()
const map: Record<string, string> = {
R: "Running",
S: "Sleeping",
D: "Disk wait",
Z: "Zombie",
T: "Stopped",
t: "Tracing stop",
X: "Dead",
I: "Idle",
R: "running",
S: "sleeping",
D: "diskWait",
Z: "zombie",
T: rawLetter === "t" ? "tracingStop" : "stopped",
X: "dead",
I: "idle",
}
return map[letter] || state || "—"
const key = map[letter]
return key ? t(`details.processInfo.states.${key}`) : state || "—"
}
export function ProcessInfoModal({ pid, accent, onClose }: ProcessInfoModalProps) {
const { language, t } = useI18n()
const [data, setData] = useState<ProcessDetail | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
@@ -105,7 +108,7 @@ export function ProcessInfoModal({ pid, accent, onClose }: ProcessInfoModalProps
setExited(true)
stopPolling()
} else {
setError(e?.message || "Failed to fetch process")
setError(t("details.processInfo.fetchFailed"))
}
} finally {
if (!silent) setLoading(false)
@@ -136,15 +139,13 @@ export function ProcessInfoModal({ pid, accent, onClose }: ProcessInfoModalProps
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ background: accent.dot }}
/>
<span className="truncate font-mono text-base">{data?.comm || "Process"}</span>
<span className="truncate font-mono text-base">{data?.comm || t("details.processInfo.titleFallback")}</span>
<span className="text-xs text-muted-foreground font-mono flex-shrink-0">PID {pid}</span>
</DialogTitle>
<DialogDescription className="text-xs">
{exited ? (
<>Last snapshot from <span className="font-mono">/proc/{pid}</span> before the process finished.</>
) : (
<>Live snapshot from <span className="font-mono">/proc/{pid}</span>. Auto-refreshes every {REFRESH_MS / 1000} s while open.</>
)}
{exited
? t("details.processInfo.descriptionExited", { pid: pid ?? "" })
: t("details.processInfo.descriptionLive", { pid: pid ?? "", seconds: REFRESH_MS / 1000 })}
</DialogDescription>
</DialogHeader>
@@ -154,9 +155,9 @@ export function ProcessInfoModal({ pid, accent, onClose }: ProcessInfoModalProps
<div className="flex items-start gap-2 px-3 py-2 rounded-md border border-amber-500/30 bg-amber-500/10 text-xs text-amber-300">
<Info className="h-4 w-4 flex-shrink-0 mt-0.5" />
<div>
<div className="font-medium text-amber-200">This process has finished</div>
<div className="font-medium text-amber-200">{t("details.processInfo.finishedTitle")}</div>
<div className="text-amber-300/80 mt-0.5">
It was likely a short-lived helper (a script, a <span className="font-mono">pct exec</span>, or a one-shot command) that completed while the modal was open. The data below is the last snapshot captured before it exited not a stale or broken read.
{t("details.processInfo.finishedDescription")}
</div>
</div>
</div>
@@ -166,44 +167,44 @@ export function ProcessInfoModal({ pid, accent, onClose }: ProcessInfoModalProps
<div className="text-sm text-red-500 py-4">{error}</div>
) : !data ? (
<div className="text-sm text-muted-foreground py-8 text-center">
{loading ? "Loading" : "—"}
{loading ? t("details.processInfo.loading") : "—"}
</div>
) : (
<ScrollArea className={`max-h-[480px] pr-2 ${exited ? "opacity-75" : ""}`}>
<div className="space-y-4">
{/* Overview */}
<Section icon={<Activity className="h-4 w-4 text-blue-400" />} title="Overview">
<Row label="State" value={exited ? "Exited" : stateLabel(data.state)} />
<Row label="Parent" value={data.parent_name ? `${data.parent_name} (PID ${data.ppid})` : `PID ${data.ppid}`} mono />
<Row label="Threads" value={String(data.threads)} mono />
<Row label="Open FDs" value={data.fd_count != null ? String(data.fd_count) : "—"} mono />
<Row label="User" value={`${data.user} (${data.uid})`} mono />
<Row label="Group" value={`${data.group} (${data.gid})`} mono />
<Section icon={<Activity className="h-4 w-4 text-blue-400" />} title={t("details.processInfo.sections.overview")}>
<Row label={t("details.processInfo.labels.state")} value={exited ? t("details.processInfo.states.exited") : stateLabel(data.state, t)} />
<Row label={t("details.processInfo.labels.parent")} value={data.parent_name ? `${data.parent_name} (PID ${data.ppid})` : `PID ${data.ppid}`} mono />
<Row label={t("details.processInfo.labels.threads")} value={String(data.threads)} mono />
<Row label={t("details.processInfo.labels.openFds")} value={data.fd_count != null ? String(data.fd_count) : "—"} mono />
<Row label={t("details.processInfo.labels.user")} value={`${data.user} (${data.uid})`} mono />
<Row label={t("details.processInfo.labels.group")} value={`${data.group} (${data.gid})`} mono />
</Section>
{/* Resources */}
<Section icon={<HardDrive className="h-4 w-4 text-amber-400" />} title="Resources">
<Row label="CPU" value={`${data.cpu.toFixed(1)} %`} mono valueClass={accent.text} />
<Row label="Memory" value={`${data.mem.toFixed(1)} %`} mono valueClass={accent.text} />
<Row label="Resident (RSS)" value={formatKb(data.vm_rss_kb)} mono />
<Row label="Virtual size" value={formatKb(data.vm_size_kb)} mono />
<Row label="Swap" value={formatKb(data.vm_swap_kb)} mono />
<Row label="I/O read" value={formatBytes(data.io_read_bytes)} mono />
<Row label="I/O write" value={formatBytes(data.io_write_bytes)} mono />
<Section icon={<HardDrive className="h-4 w-4 text-amber-400" />} title={t("details.processInfo.sections.resources")}>
<Row label={t("details.processInfo.labels.cpu")} value={`${data.cpu.toFixed(1)} %`} mono valueClass={accent.text} />
<Row label={t("details.processInfo.labels.memory")} value={`${data.mem.toFixed(1)} %`} mono valueClass={accent.text} />
<Row label={t("details.processInfo.labels.residentRss")} value={formatKb(data.vm_rss_kb)} mono />
<Row label={t("details.processInfo.labels.virtualSize")} value={formatKb(data.vm_size_kb)} mono />
<Row label={t("details.processInfo.labels.swap")} value={formatKb(data.vm_swap_kb)} mono />
<Row label={t("details.processInfo.labels.ioRead")} value={formatBytes(data.io_read_bytes)} mono />
<Row label={t("details.processInfo.labels.ioWrite")} value={formatBytes(data.io_write_bytes)} mono />
</Section>
{/* Command */}
<Section icon={<FileText className="h-4 w-4 text-purple-400" />} title="Command">
<Row label="Name" value={data.comm} mono />
<Row label="Command line" value={data.cmdline || data.comm} mono wrap />
<Row label="Executable" value={data.exe || "—"} mono wrap />
<Row label="Working dir" value={data.cwd || "—"} mono wrap />
<Section icon={<FileText className="h-4 w-4 text-purple-400" />} title={t("details.processInfo.sections.command")}>
<Row label={t("details.processInfo.labels.name")} value={data.comm} mono />
<Row label={t("details.processInfo.labels.commandLine")} value={data.cmdline || data.comm} mono wrap />
<Row label={t("details.processInfo.labels.executable")} value={data.exe || "—"} mono wrap />
<Row label={t("details.processInfo.labels.workingDir")} value={data.cwd || "—"} mono wrap />
</Section>
{/* Times */}
<Section icon={<Clock className="h-4 w-4 text-emerald-400" />} title="Lifetime">
<Row label="Started" value={data.start_time || "—"} mono />
<Row label="Running for" value={data.elapsed || "—"} mono />
<Section icon={<Clock className="h-4 w-4 text-emerald-400" />} title={t("details.processInfo.sections.lifetime")}>
<Row label={t("details.processInfo.labels.started")} value={data.start_time || "—"} mono />
<Row label={t("details.processInfo.labels.runningFor")} value={data.elapsed || "—"} mono />
</Section>
</div>
</ScrollArea>
@@ -211,7 +212,8 @@ export function ProcessInfoModal({ pid, accent, onClose }: ProcessInfoModalProps
{data?.captured_at && (
<div className="text-[10px] text-muted-foreground text-right mt-1">
{exited ? "Last seen" : "Captured"} {new Date(data.captured_at * 1000).toLocaleTimeString()}
{exited ? t("details.processInfo.lastSeen") : t("details.processInfo.captured")}{" "}
{new Date(data.captured_at * 1000).toLocaleTimeString(language)}
{error ? ` · ${error}` : ""}
</div>
)}
+26 -28
View File
@@ -19,6 +19,7 @@ import { Button } from "./ui/button"
import { Input } from "./ui/input"
import { Label } from "./ui/label"
import { fetchApi, getApiUrl, getAuthToken } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
interface ProfileData {
success: boolean
@@ -51,6 +52,7 @@ interface ProfileProps {
* the operator hits Edit to start typing.
*/
export function Profile({ onOpenSecurity }: ProfileProps) {
const t = useT()
const [profile, setProfile] = useState<ProfileData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@@ -146,7 +148,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
body: JSON.stringify({ display_name: displayDraft }),
})
if (!data.success) {
setError(data.message || "Failed to save display name")
setError(data.message || t("profilePage.errors.saveDisplayNameFailed"))
return
}
setProfile(data)
@@ -182,7 +184,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
})
const data: ProfileData = await r.json().catch(() => ({ success: false }))
if (!r.ok || !data.success) {
setAvatarError(data.message || `Upload failed (${r.status})`)
setAvatarError(data.message || t("profilePage.errors.uploadFailed", { status: r.status }))
return
}
setProfile(data)
@@ -212,7 +214,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
})
const data: ProfileData = await r.json().catch(() => ({ success: false }))
if (!r.ok || !data.success) {
setAvatarError(data.message || `Delete failed (${r.status})`)
setAvatarError(data.message || t("profilePage.errors.deleteFailed", { status: r.status }))
return
}
setProfile(data)
@@ -232,7 +234,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
<Card>
<CardContent className="p-8 flex items-center justify-center text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Loading profile
{t("profilePage.loading")}
</CardContent>
</Card>
</div>
@@ -247,7 +249,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
<div className="flex items-start gap-2 text-red-500">
<AlertCircle className="h-5 w-5 shrink-0 mt-0.5" />
<div>
<div className="font-medium">Failed to load profile</div>
<div className="font-medium">{t("profilePage.loadFailed")}</div>
<div className="text-xs text-muted-foreground mt-1 break-all">{error}</div>
</div>
</div>
@@ -268,13 +270,13 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="flex items-center gap-2">
<UserIcon className="h-5 w-5 text-cyan-500" />
<CardTitle>User Profile</CardTitle>
<CardTitle>{t("profilePage.title")}</CardTitle>
</div>
<div className="flex items-center gap-2">
{savedDisplay && (
<span className="flex items-center gap-1 text-xs text-green-500">
<Check className="h-3.5 w-3.5" />
Saved
{t("status.saved")}
</span>
)}
{displayEditMode ? (
@@ -286,7 +288,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
disabled={savingDisplay}
className="h-7 text-xs"
>
Cancel
{t("actions.cancel")}
</Button>
<Button
size="sm"
@@ -299,7 +301,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
) : (
<CheckCircle2 className="h-3 w-3 mr-1.5" />
)}
Save
{t("actions.save")}
</Button>
</>
) : (
@@ -310,14 +312,13 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
className="h-7 text-xs"
>
<Settings2 className="h-3 w-3 mr-1.5" />
Edit
{t("actions.edit")}
</Button>
)}
</div>
</div>
<CardDescription>
Personal details rendered in the header avatar menu. None of this is required
the username already covers identity. Display name and avatar are decorative.
{t("profilePage.description")}
</CardDescription>
</CardHeader>
@@ -327,7 +328,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
image they uploaded. `object-cover` keeps the aspect
ratio and crops to fit the circle. */}
<div>
<Label className="text-sm">Avatar</Label>
<Label className="text-sm">{t("profilePage.avatar.label")}</Label>
<div className="flex flex-col sm:flex-row items-start gap-6 mt-3">
<div className="relative shrink-0">
{avatarBlobUrl ? (
@@ -367,7 +368,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
className="justify-start"
>
<Upload className="h-3.5 w-3.5 mr-2" />
{profile?.has_avatar ? "Replace avatar" : "Upload avatar"}
{profile?.has_avatar ? t("profilePage.avatar.replace") : t("profilePage.avatar.upload")}
</Button>
{profile?.has_avatar && (
<Button
@@ -378,12 +379,11 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
className="justify-start text-red-500 hover:text-red-500 hover:bg-red-500/10"
>
<Trash2 className="h-3.5 w-3.5 mr-2" />
Remove avatar
{t("profilePage.avatar.remove")}
</Button>
)}
<p className="text-[11px] text-muted-foreground leading-relaxed max-w-xs">
PNG, JPEG, WebP or GIF. Up to 2 MB. The image isn&apos;t resized
render it square or pre-crop for best results in the header.
{t("profilePage.avatar.hint")}
</p>
</div>
</div>
@@ -397,7 +397,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
{/* ─── Username (read-only) ─── */}
<div>
<Label className="text-sm" htmlFor="profile-username">Username</Label>
<Label className="text-sm" htmlFor="profile-username">{t("profilePage.username.label")}</Label>
<Input
id="profile-username"
value={profile?.username || ""}
@@ -405,28 +405,26 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
className="mt-2 max-w-sm disabled:opacity-100 disabled:cursor-default"
/>
<p className="text-[11px] text-muted-foreground mt-1">
The login name. To change it, disable authentication and reconfigure from
Security.
{t("profilePage.username.help")}
</p>
</div>
{/* ─── Display name (Edit controls live in the card header) ─── */}
<div>
<Label className="text-sm" htmlFor="profile-display">
Display name <span className="text-muted-foreground font-normal">(optional)</span>
{t("profilePage.displayName.label")} <span className="text-muted-foreground font-normal">{t("profilePage.displayName.optional")}</span>
</Label>
<Input
id="profile-display"
value={displayDraft}
onChange={(e) => setDisplayDraft(e.target.value)}
placeholder={profile?.username || "Display name"}
placeholder={profile?.username || t("profilePage.displayName.placeholder")}
maxLength={64}
disabled={!displayEditMode || savingDisplay}
className="mt-2 max-w-sm disabled:opacity-100 disabled:cursor-default"
/>
<p className="text-[11px] text-muted-foreground mt-1">
Shown above the username inside the avatar menu. Leave empty to show the
username itself. Up to 64 characters.
{t("profilePage.displayName.help")}
</p>
{error && displayEditMode && (
<div className="mt-2 text-xs text-red-500 flex items-start gap-1.5">
@@ -443,21 +441,21 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
<CardHeader>
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-orange-500" />
<CardTitle>Account security</CardTitle>
<CardTitle>{t("profilePage.accountSecurity.title")}</CardTitle>
</div>
<CardDescription>
Password, two-factor authentication and API tokens live in the Security panel.
{t("profilePage.accountSecurity.description")}
</CardDescription>
</CardHeader>
<CardContent>
{onOpenSecurity ? (
<Button variant="outline" onClick={onOpenSecurity}>
<Lock className="h-4 w-4 mr-2" />
Open Security settings
{t("profilePage.accountSecurity.openSecurity")}
</Button>
) : (
<p className="text-xs text-muted-foreground">
Open the Security tab from the navigation.
{t("profilePage.accountSecurity.fallback")}
</p>
)}
</CardContent>
+203 -167
View File
@@ -1,6 +1,7 @@
"use client"
import { useState, useEffect, useMemo, useCallback } from "react"
import React, { useState, useEffect, useMemo, useCallback } from "react"
import useSWR from "swr"
import { Badge } from "./ui/badge"
import { Button } from "./ui/button"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs"
@@ -8,6 +9,7 @@ import { SystemOverview } from "./system-overview"
import { StorageOverview } from "./storage-overview"
import { NetworkMetrics } from "./network-metrics"
import { VirtualMachines } from "./virtual-machines"
import { AppsDashboard } from "./apps-dashboard"
import Hardware from "./hardware"
import { SystemLogs } from "./system-logs"
import { Settings } from "./settings"
@@ -32,6 +34,7 @@ import {
HardDrive,
NetworkIcon,
Boxes,
Grid3x3,
Cpu,
ScrollText,
SettingsIcon,
@@ -51,6 +54,9 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "./ui/dropdown-menu"
import { useT } from "../lib/i18n/provider"
import { APP_VERSION } from "../lib/version"
import { useTabOrder, firstActualTab, type TabId } from "../lib/tab-order"
interface SystemStatus {
status: "healthy" | "warning" | "critical"
@@ -79,7 +85,19 @@ interface FlaskSystemInfo {
}
}
// Prefetch on dashboard mount: SWR caches by key across all
// `useSWR` calls, so firing these here means the Apps tab finds the
// data already resolved when it opens. Without this, the tab pays a
// visible roundtrip on first render because VirtualMachines has been
// warming /api/vms since page load but nobody was warming the custom
// links endpoint.
const _dashboardPrefetchFetcher = (url: string) => fetchApi(url)
export function ProxmoxDashboard() {
const t = useT()
useSWR("/api/apps/custom-links", _dashboardPrefetchFetcher, { revalidateOnFocus: false })
useSWR("/api/apps/categories", _dashboardPrefetchFetcher, { revalidateOnFocus: false })
const { order: tabOrder } = useTabOrder()
const [systemStatus, setSystemStatus] = useState<SystemStatus>({
status: "healthy",
uptime: "Loading...",
@@ -92,12 +110,21 @@ export function ProxmoxDashboard() {
const [componentKey, setComponentKey] = useState(0)
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [activeTab, setActiveTab] = useState("overview")
// On first mount, land on whatever the user's custom order says is
// the leading tab. localStorage isn't available during SSR so this
// runs post-hydration; the tiny flash is acceptable and matches the
// pattern next-themes uses for the same reason.
useEffect(() => {
setActiveTab(firstActualTab())
}, [])
const [infoCount, setInfoCount] = useState(0)
const [updateAvailable, setUpdateAvailable] = useState(false)
const [showNavigation, setShowNavigation] = useState(true)
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 +195,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 +212,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 +223,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 +321,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 +389,20 @@ 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 "apps": return t("navigation.apps")
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 +416,13 @@ export function ProxmoxDashboard() {
<div className="container mx-auto">
<div className="flex items-center space-x-2 text-red-500 mb-2">
<XCircle className="h-5 w-5" />
<span className="font-medium">ProxMenux Server Connection Failed</span>
<span className="font-medium">{t("status.connectionFailed")}</span>
</div>
<div className="text-sm text-red-500/80 space-y-1 ml-7">
<p> Check that the monitor.service is running correctly.</p>
<p> The ProxMenux server should start automatically on port 8008</p>
<p>&bull; {t("status.checkService")}</p>
<p>&bull; {t("status.serverPort")}</p>
<p>
Try accessing:{" "}
&bull; {t("status.tryAccessing")}{" "}
<a href={getApiUrl("/api/health")} target="_blank" rel="noopener noreferrer" className="underline">
{getApiUrl("/api/health")}
</a>
@@ -433,11 +461,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 +475,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 +493,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 +507,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 +541,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 +569,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 +579,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 +611,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 +628,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 —
@@ -616,75 +644,62 @@ export function ProxmoxDashboard() {
: "text-muted-foreground hover:text-foreground rounded-sm"
}`
// Data-driven TabsList: iterate over the user's saved
// top-level order. Each slot is either a direct tab or a
// dropdown group (Node/Admin). The internal items of a
// dropdown are never reordered by the user — a grouped
// slot moves as a unit.
const renderDirect = (
value: string,
Icon: React.ComponentType<{ className?: string }>,
label: string,
) => (
<TabsTrigger key={value} value={value} className={triggerActiveClass}>
<Icon className="mr-2 h-4 w-4" />
{label}
</TabsTrigger>
)
const renderDropdown = (
key: string,
items: { value: string; label: string; Icon: React.ComponentType<{ className?: string }> }[],
active: boolean,
TriggerIcon: React.ComponentType<{ className?: string }>,
triggerLabel: string,
) => (
<DropdownMenu key={key}>
<DropdownMenuTrigger className={dropdownBtnClass(active)}>
<TriggerIcon className="mr-2 h-4 w-4" />
{triggerLabel}
<ChevronDown className="ml-1.5 h-3 w-3 opacity-70" />
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="min-w-[180px]">
{items.map(({ value, label, Icon }) => (
<DropdownMenuItem
key={value}
onClick={() => setActiveTab(value)}
className={activeTab === value ? "bg-blue-500/10 text-blue-500" : ""}
>
<Icon className="mr-2 h-4 w-4" />
{label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
const renderTop = (id: TabId) => {
switch (id) {
case "overview": return renderDirect("overview", LayoutDashboard, t("navigation.overview"))
case "apps": return renderDirect("apps", Grid3x3, t("navigation.apps"))
case "vms": return renderDirect("vms", Boxes, t("navigation.virtualMachines"))
case "backup": return renderDirect("backup", DatabaseBackup, t("navigation.backup"))
case "terminal": return renderDirect("terminal", Terminal, t("navigation.terminal"))
case "node": return renderDropdown("node", NODE_ITEMS, isNodeActive, NodeTriggerIcon, NodeTriggerLabel)
case "admin": return renderDropdown("admin", ADMIN_ITEMS, isAdminActive, AdminTriggerIcon, AdminTriggerLabel)
}
}
return (
<TabsList className="hidden lg:grid w-full grid-cols-6 bg-card border border-border">
{/* Direct: Overview */}
<TabsTrigger value="overview" className={triggerActiveClass}>
<LayoutDashboard className="mr-2 h-4 w-4" />
Overview
</TabsTrigger>
{/* Direct: VMs & LXCs first-class because Proxmox IS
a hypervisor; workloads belong at top level. */}
<TabsTrigger value="vms" className={triggerActiveClass}>
<Boxes className="mr-2 h-4 w-4" />
VMs &amp; LXCs
</TabsTrigger>
{/* Dropdown: Node (Storage / Network / Hardware) */}
<DropdownMenu>
<DropdownMenuTrigger className={dropdownBtnClass(isNodeActive)}>
<NodeTriggerIcon className="mr-2 h-4 w-4" />
{NodeTriggerLabel}
<ChevronDown className="ml-1.5 h-3 w-3 opacity-70" />
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="min-w-[180px]">
{NODE_ITEMS.map(({ value, label, Icon }) => (
<DropdownMenuItem
key={value}
onClick={() => setActiveTab(value)}
className={activeTab === value ? "bg-blue-500/10 text-blue-500" : ""}
>
<Icon className="mr-2 h-4 w-4" />
{label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Direct: Backup (today: Host Backup only). When VM/LXC
backup ships this becomes a dropdown. */}
<TabsTrigger value="backup" className={triggerActiveClass}>
<DatabaseBackup className="mr-2 h-4 w-4" />
Backup
</TabsTrigger>
{/* Direct: Terminal */}
<TabsTrigger value="terminal" className={triggerActiveClass}>
<Terminal className="mr-2 h-4 w-4" />
Terminal
</TabsTrigger>
{/* Dropdown: Admin (System Logs / Security / Settings / About) */}
<DropdownMenu>
<DropdownMenuTrigger className={dropdownBtnClass(isAdminActive)}>
<AdminTriggerIcon className="mr-2 h-4 w-4" />
{AdminTriggerLabel}
<ChevronDown className="ml-1.5 h-3 w-3 opacity-70" />
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="min-w-[180px]">
{ADMIN_ITEMS.map(({ value, label, Icon }) => (
<DropdownMenuItem
key={value}
onClick={() => setActiveTab(value)}
className={activeTab === value ? "bg-blue-500/10 text-blue-500" : ""}
>
<Icon className="mr-2 h-4 w-4" />
{label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<TabsList className="hidden lg:grid w-full grid-cols-7 bg-card border border-border">
{tabOrder.map(renderTop)}
</TabsList>
)
})()}
@@ -719,56 +734,52 @@ export function ProxmoxDashboard() {
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
: ""
}`
// Mobile sheet is a flat list (no section headers).
// The desktop layout uses dropdowns to express the
// Node/Admin grouping; here we just enumerate items
// in the same visual order.
// Mobile sheet honours the same user-defined
// top-level order as the desktop TabsList. Grouped
// slots (Node/Admin) expand their child items
// inline right after their position — the group
// still moves as a unit, but children stay grouped.
const btn = (
value: string,
Icon: React.ComponentType<{ className?: string }>,
label: string,
) => (
<Button
key={value}
variant="ghost"
onClick={() => select(value)}
className={itemClass(activeTab === value)}
>
<Icon className="h-5 w-5" />
<span>{label}</span>
</Button>
)
return (
<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>
</Button>
<Button variant="ghost" onClick={() => select("vms")} className={itemClass(activeTab === "vms")}>
<Boxes className="h-5 w-5" />
<span>VMs &amp; LXCs</span>
</Button>
<Button variant="ghost" onClick={() => select("storage")} className={itemClass(activeTab === "storage")}>
<HardDrive className="h-5 w-5" />
<span>Storage</span>
</Button>
<Button variant="ghost" onClick={() => select("network")} className={itemClass(activeTab === "network")}>
<NetworkIcon className="h-5 w-5" />
<span>Network</span>
</Button>
<Button variant="ghost" onClick={() => select("hardware")} className={itemClass(activeTab === "hardware")}>
<Cpu className="h-5 w-5" />
<span>Hardware</span>
</Button>
<Button variant="ghost" onClick={() => select("backup")} className={itemClass(activeTab === "backup")}>
<DatabaseBackup className="h-5 w-5" />
<span>Backup</span>
</Button>
<Button variant="ghost" onClick={() => select("terminal")} className={itemClass(activeTab === "terminal")}>
<Terminal className="h-5 w-5" />
<span>Terminal</span>
</Button>
<Button variant="ghost" onClick={() => select("logs")} className={itemClass(activeTab === "logs")}>
<ScrollText className="h-5 w-5" />
<span>System Logs</span>
</Button>
<Button variant="ghost" onClick={() => select("security")} className={itemClass(activeTab === "security")}>
<ShieldCheck className="h-5 w-5" />
<span>Security</span>
</Button>
<Button variant="ghost" onClick={() => select("settings")} className={itemClass(activeTab === "settings")}>
<SettingsIcon className="h-5 w-5" />
<span>Settings</span>
</Button>
<Button variant="ghost" onClick={() => select("about")} className={itemClass(activeTab === "about")}>
<Info className="h-5 w-5" />
<span>About</span>
</Button>
{tabOrder.map((id): React.ReactNode => {
switch (id) {
case "overview": return btn("overview", LayoutDashboard, t("navigation.overview"))
case "apps": return btn("apps", Grid3x3, t("navigation.apps"))
case "vms": return btn("vms", Boxes, t("navigation.virtualMachines"))
case "backup": return btn("backup", DatabaseBackup, t("navigation.backup"))
case "terminal": return btn("terminal", Terminal, t("navigation.terminal"))
case "node": return (
<React.Fragment key="node">
{btn("storage", HardDrive, t("navigation.storage"))}
{btn("network", NetworkIcon, t("navigation.network"))}
{btn("hardware", Cpu, t("navigation.hardware"))}
</React.Fragment>
)
case "admin": return (
<React.Fragment key="admin">
{btn("logs", ScrollText, t("navigation.systemLogs"))}
{btn("security", ShieldCheck, t("navigation.security"))}
{btn("settings", SettingsIcon, t("navigation.settings"))}
{btn("about", Info, t("navigation.about"))}
</React.Fragment>
)
}
})}
</div>
)
})()}
@@ -779,11 +790,30 @@ export function ProxmoxDashboard() {
</div>
<div className="container mx-auto px-4 md:px-6 py-4 md:py-6">
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4 md:space-y-6">
<TabsContent value="overview" className="space-y-4 md:space-y-6 mt-0">
{/* No `space-y-*` here: only one TabsContent is visible at a
time, but Overview stays force-mounted (hidden) as the
first child, so every OTHER active tab used to inherit an
extra top margin from the space-y utility pushing the
page content further from the nav than on Overview.
Vertical spacing INSIDE each tab lives on its own
TabsContent's `space-y-*`. */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-0">
{/* forceMount so SystemOverview mounts at dashboard load and
never gets torn down when the user visits another tab.
Without this, every return to Overview re-fires ~7 fetches
(system, vms, storage, proxmox-storage, network, node
metrics, network chart) and the user waited for the
cascade to complete each time. With forceMount, the
5 s / 59 s refresh intervals keep the data fresh in the
background reopening the tab is instant. */}
<TabsContent value="overview" forceMount className="space-y-4 md:space-y-6 mt-0 data-[state=inactive]:hidden">
<SystemOverview key={`overview-${componentKey}`} />
</TabsContent>
<TabsContent value="apps" className="space-y-4 md:space-y-6 mt-0">
<AppsDashboard key={`apps-${componentKey}`} />
</TabsContent>
<TabsContent value="storage" className="space-y-4 md:space-y-6 mt-0">
<StorageOverview key={`storage-${componentKey}`} />
</TabsContent>
@@ -792,7 +822,13 @@ export function ProxmoxDashboard() {
<NetworkMetrics key={`network-${componentKey}`} />
</TabsContent>
<TabsContent value="vms" className="space-y-4 md:space-y-6 mt-0">
{/* forceMount so the modal-data prefetcher (inside VirtualMachines)
starts warming caches from the moment the dashboard loads,
not the first time the user clicks the VMs tab. Kept
visually hidden with data-attribute selector when the tab
is inactive mount cost is ~zero (no polling loop that
other components run). */}
<TabsContent value="vms" forceMount className="space-y-4 md:space-y-6 mt-0 data-[state=inactive]:hidden">
<VirtualMachines key={`vms-${componentKey}`} />
</TabsContent>
@@ -836,7 +872,7 @@ export function ProxmoxDashboard() {
</Tabs>
<footer className="mt-8 md:mt-12 pt-4 md:pt-6 border-t border-border text-center text-xs md:text-sm text-muted-foreground">
<p className="font-medium mb-2">ProxMenux Monitor v1.2.4</p>
<p className="font-medium mb-2">ProxMenux Monitor v{APP_VERSION}</p>
<p>
<a
href="https://ko-fi.com/macrimi"
@@ -844,7 +880,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>
+37 -18
View File
@@ -2,6 +2,7 @@
import { useCallback, useEffect, useState } from "react"
import { Plus, Share, X } from "lucide-react"
import { useT } from "../lib/i18n/provider"
// ==========================================================
// PwaInstallPrompt
@@ -58,6 +59,7 @@ function isIOS(): boolean {
}
export function PwaInstallPrompt() {
const t = useT()
const [open, setOpen] = useState(false)
const [platform, setPlatform] = useState<"ios" | "android" | null>(null)
@@ -128,13 +130,30 @@ export function PwaInstallPrompt() {
>
<div className="relative px-5 pt-5">
<div className="mx-auto mb-3 h-1 w-10 rounded-full bg-border" aria-hidden="true" />
{/* Hardened close button the X wasn't reliably closing
the sheet on iOS/Android. Fixes:
* `stopPropagation` so the click never bubbles up to
the backdrop handler (which was seeing the target
and might have been running its own logic against
the same tap on some mobile browsers).
* `z-10` puts it above any sibling absolute layers
(drag handle, headings) in case one silently ate
the tap.
* 40 × 40 hit area comfortable Apple/Google minimum
for a touch target; the 32 × 32 we had was fine on
desktop but easy to miss with a thumb.
* `onPointerDown` as a secondary handler covers the
iOS Safari case where a fast tap on a nested
`<button>` inside a `role="dialog"` can lose the
click event to the parent overlay. */}
<button
type="button"
onClick={handleClose}
aria-label="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"
onClick={(e) => { e.stopPropagation(); handleClose() }}
onPointerDown={(e) => { e.stopPropagation() }}
aria-label={t("actions.close")}
className="absolute right-2 top-2 z-10 flex h-10 w-10 items-center justify-center rounded-full text-muted-foreground hover:bg-muted active:bg-muted transition-colors touch-manipulation"
>
<X className="h-4 w-4" />
<X className="h-5 w-5" />
</button>
<div className="mb-4 flex items-start gap-3.5">
@@ -143,12 +162,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 +179,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 +192,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 +204,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 +222,14 @@ export function PwaInstallPrompt() {
onClick={handleNotNow}
className="rounded-lg py-2.5 text-center text-[13.5px] font-semibold text-muted-foreground hover:bg-muted transition-colors"
>
Not now
{t("pwaInstall.notNow")}
</button>
<button
type="button"
onClick={handleNeverAgain}
className="rounded-lg py-2.5 text-center text-[13.5px] font-semibold text-amber-700 dark:text-amber-500 hover:bg-muted transition-colors"
>
Don&apos;t show again
{t("pwaInstall.neverAgain")}
</button>
</div>
</div>
+121 -13
View File
@@ -3,10 +3,10 @@
import { useState, useEffect } from "react"
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 { X, Sparkles, Thermometer, Activity, HardDrive, Shield, Globe, Cpu, Zap, Sliders, Wrench, RefreshCw, Server, BellOff, Bell, Calendar, DatabaseBackup, Smartphone, Languages } from "lucide-react"
import { Checkbox } from "./ui/checkbox"
const APP_VERSION = "1.2.4" // Sync with AppImage/package.json
import { useT } from "../lib/i18n/provider"
import { APP_VERSION } from "../lib/version"
interface ReleaseNote {
date: string
@@ -18,6 +18,57 @@ interface ReleaseNote {
}
export const CHANGELOG: Record<string, ReleaseNote> = {
"1.2.5": {
date: "September 1, 2026",
changes: {
added: [
"New top-level Apps dashboard — a single launcher for every Web Link across the node. LXC-registered apps and user-defined Custom Web Links share the same grid with category badges, search, sort by name/id/category, and one-click deep-links back to the guest modal (LXC cards land on App, VM cards on Status).",
"Application detection catalog with over 380 tracked workloads, generated live from the community-scripts source across seven detector methods (file, binary, dpkg, apk, Python, Docker exec, Docker label). Primary and fallback detectors cover both new and historical LXC layouts.",
"App tab inside every VM & LXC modal — register the apps installed in a container, capture their weblinks, and get notifications when a new upstream version ships. Cold-start Docker detection now correctly promotes Docker as the parent workload before the daemon finishes booting.",
"Reworked LXC Updates tab — apply OS packages and registered-app updates from a single button, schedule a recurring auto-update job, and cover Docker end to end (Engine + per-image on the same 24-hour rolling cycle as OS packages, with a 'Check now' action for on-demand digest comparison).",
"The Monitor now speaks 8 languages: English, Spanish, German, French, Italian, Portuguese, Swedish and Slovak. Thanks to @vaso73 for building the i18n scaffolding.",
"Navigation order — a new Settings card lets each user reorder the seven top-level tabs by drag and drop. Grouped slots (Node, Admin) move as a single unit; the first slot in the saved order becomes the tab the Monitor opens on. Mouse and touch supported.",
"Custom Web Links — persistent sidecar at /etc/proxmenux/custom_links.json for URLs that don't live inside a registered LXC app. Editor takes name, URL, optional logo, category and optional binding to a specific VM or LXC.",
"NVIDIA — multi-GPU passthrough by exact BDF ownership so a multi-GPU host can pass one card to a VM and keep the other operational on the host or in LXCs. Kernel + branch + GPU-aware version picker (#298).",
"Pushover joins Telegram, Gotify, Discord, Email and Apprise as a native notification channel — user/API key, device and sound selectors, priority 0 for regular messages, optional priority 1 for CRITICAL events. Suggested by @benginx (#308).",
"Host backup/restore continuity — ProxMenux configuration and independent backups survive a full Proxmox restore (#317, reported by @tropicaljoe).",
"Hardware temperature sensor identity — storage temperatures now identify the physical drive (NVMe namespace, model, serial) instead of the generic hwmon label. HDD/SSD classification follows the block device rotational flag (#315, suggested by @Dark-Witcher).",
"Post-install — precise rollback for every registered flow and Debian 13 readiness.",
"First-time visitors on Android and iOS Safari now see an in-app install prompt for adding the Monitor to their home screen as a PWA.",
],
changed: [
"Memory & Swap health check now signals real memory pressure — CRITICAL fires only when swap file is nearly full AND available RAM is genuinely tight (both editable in Settings → Health thresholds). The old swap-only signal was noisy on hosts where Linux proactively swaps out inactive pages.",
"Faster page loads and smoother navigation across the dashboard. Overview opens instantly and the VMs & LXCs page never flashes 'Loading…' between guest modals again.",
"Long backup jobs no longer time out. VM and LXC backups launched from the Monitor now run in the background until they naturally finish, so a 30-minute PBS backup completes the same as a 10-second local one.",
"VMs running the QEMU Guest Agent now report real used / total disk figures on the dashboard, instead of the '0 GB' that PVE returns for guest-managed filesystems.",
"App tab cache stays warm across every action. Successful add / edit / check / dismiss / delete operations write the returned sidecar directly into both backend and shared frontend caches; post-update scans revalidate in the background while the last valid content stays visible.",
"Docker cards on the Apps dashboard resolve their update state per image, not per Docker Engine. The purple update arrow only lights up when that specific image has an upstream update.",
"ZFS ARC sizing under memory pressure and OOM diagnostics reworked (credit: LeidenSpain).",
],
fixed: [
"Fix #309 — Proxmox storage availability: false critical alerts for iSCSI storages with maxdisk=0.",
"HID USB device class no longer reads as 'escondido' (past tense of 'to hide') in Spanish, German, French, Italian and Portuguese — the acronym is now preserved.",
"The Memory & Swap health card is dismissable like every other category (RAM usage and Swap usage sub-checks carry the dismissable flag).",
"Regenerated Proxmox VE Helper-Scripts updaters remain available after an app update (both historical wrappers and the current generated entrypoint are recognised).",
],
},
},
"1.2.4.1-beta": {
date: "August 17, 2026",
changes: {
added: [
"The Monitor now speaks 8 languages: English, Spanish, German, French, Italian, Portuguese, Swedish and Slovak. Huge thanks to @vaso73 for building the i18n scaffolding that made this possible.",
"New App tab inside the VM & LXC modal — especially for LXCs. Register the apps installed in a container, capture their weblinks, and get notifications when a new upstream version ships.",
"Reworked Updates tab for LXCs: apply OS packages and registered-app updates from a single button, and schedule a recurring auto-update job that checks the container's OS and its tracked app on every run.",
"First-time visitors on Android and iOS Safari now see an in-app install prompt with clear steps for adding the Monitor to their home screen as a PWA.",
],
changed: [
"Faster page loads and smoother navigation across the dashboard. Overview opens instantly and the VMs & LXCs page never flashes 'Loading…' between guest modals again.",
"Long backup jobs no longer time out. VM and LXC backups launched from the Monitor now run in the background until they naturally finish, so a 30-minute PBS backup completes the same as a 10-second local one.",
"VMs running the QEMU Guest Agent (qemu-guest-agent) now report real used / total disk figures on the dashboard, instead of the '0 GB' that PVE returns for guest-managed filesystems.",
],
},
},
"1.2.3": {
date: "July 15, 2026",
changes: {
@@ -230,23 +281,78 @@ export const CHANGELOG: Record<string, ReleaseNote> = {
},
}
// Each feature carries an i18n key so translations live in the
// common.json catalogs and the modal renders in the user's chosen
// locale. `text` is the English source of truth — it's what the
// build-i18n-messages workflow feeds to Google Translate for locales
// that haven't been curated by hand.
const CURRENT_VERSION_FEATURES = [
{
icon: <RefreshCw className="h-5 w-5" />,
text: "One-click host update from the Health Monitor — new Update Now button in the System Updates section runs the Proxmox update flow in an in-dashboard terminal, without leaving the browser.",
icon: <Sparkles className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.appsDashboard",
text: "New top-level Apps dashboard — a single launcher for every Web Link across the node. LXC-registered apps and user-defined Custom Web Links share the same grid with category badges, search, and one-click deep-links back to the guest modal.",
},
{
icon: <Sparkles className="h-5 w-5" />,
text: "In-app Install prompt for mobile — first-time visitors on Android and iOS Safari now see a bottom-sheet with clear steps for adding the Monitor to their home screen as a PWA.",
icon: <Cpu className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.lxcAppsUpdates",
text: "App tab inside every LXC modal registers installed apps, captures weblinks and tracks upstream versions. Reworked Updates tab applies OS packages and app updates from a single button; Docker Engine and per-image tracking follow the same 24-hour cycle, with a 'Check now' action on demand.",
},
{
icon: <Zap className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.appCatalog",
text: "New application detection catalog with over 380 tracked workloads, generated live from community-scripts across seven detector methods (file, binary, dpkg, apk, Python, Docker exec, Docker label). Primary and fallback detectors cover both new and historical LXC layouts.",
},
{
icon: <Languages className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.multilingual",
text: "The Monitor now speaks 8 languages: English, Spanish, German, French, Italian, Portuguese, Swedish and Slovak. Huge thanks to @vaso73 for building the i18n scaffolding that made this possible.",
},
{
icon: <Server className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.nvidiaMultiGpu",
text: "NVIDIA driver lifecycle moves to per-BDF ownership so a multi-GPU host can pass one card to a VM and keep the other operational on the host or in LXCs, plus a kernel + branch + GPU-aware version picker (#298).",
},
]
// Turn any "@handle" mention inside a release-notes string into a
// link to that GitHub profile. Applied to every feature bullet so a
// contributor shout-out reads as a real link without needing rich
// i18n formatting. Only matches `@` followed by a valid GitHub
// username (letters/digits/hyphen, no consecutive hyphens, 1-39
// chars) so unrelated punctuation stays untouched.
function linkifyGithubMentions(text: string): (string | JSX.Element)[] {
const parts: (string | JSX.Element)[] = []
const re = /@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?)/g
let cursor = 0
let m: RegExpExecArray | null
let idx = 0
while ((m = re.exec(text)) !== null) {
if (m.index > cursor) parts.push(text.slice(cursor, m.index))
const handle = m[1]
parts.push(
<a
key={`gh-${idx++}`}
href={`https://github.com/${handle}`}
target="_blank"
rel="noopener noreferrer"
className="text-orange-500 hover:text-orange-400 underline underline-offset-2"
>
@{handle}
</a>,
)
cursor = m.index + m[0].length
}
if (cursor < text.length) parts.push(text.slice(cursor))
return parts
}
interface ReleaseNotesModalProps {
open: boolean
onClose: () => void
}
export function ReleaseNotesModal({ open, onClose }: ReleaseNotesModalProps) {
const t = useT()
const [dontShowAgain, setDontShowAgain] = useState(false)
const handleClose = () => {
@@ -259,7 +365,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 +391,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 +405,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">
{linkifyGithubMentions(t(feature.key))}
</p>
</div>
))}
</div>
@@ -312,7 +420,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 +433,7 @@ export function ReleaseNotesModal({ open, onClose }: ReleaseNotesModalProps) {
htmlFor="dont-show-version-again"
className="text-xs md:text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer select-none"
>
Don't show again for this version
{t("releaseNotes.dontShowAgain")}
</label>
</div>
</div>
+77 -67
View File
@@ -40,6 +40,7 @@ import {
Filter,
} from "lucide-react"
import { fetchApi } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
// ── Shape contracts with the backend ──────────────────────────
@@ -121,15 +122,17 @@ const formatIso = (iso: string | null | undefined) => {
}
}
const formatRelative = (iso: string) => {
type Translator = ReturnType<typeof useT>
const formatRelative = (iso: string, t: Translator) => {
try {
const then = new Date(iso).getTime()
const now = Date.now()
const diff = Math.max(0, Math.round((now - then) / 1000))
if (diff < 60) return `${diff}s ago`
if (diff < 3600) return `${Math.round(diff / 60)}m ago`
if (diff < 86400) return `${Math.round(diff / 3600)}h ago`
return `${Math.round(diff / 86400)}d ago`
if (diff < 60) return t("restoreProgress.time.secondsAgo", { count: diff })
if (diff < 3600) return t("restoreProgress.time.minutesAgo", { count: Math.round(diff / 60) })
if (diff < 86400) return t("restoreProgress.time.hoursAgo", { count: Math.round(diff / 3600) })
return t("restoreProgress.time.daysAgo", { count: Math.round(diff / 86400) })
} catch {
return iso
}
@@ -140,40 +143,41 @@ const formatRelative = (iso: string) => {
// "estimating time…". After the run is terminal, "—". The output is
// a full phrase so the caller doesn't have to add suffix words that
// only make sense on some branches.
const computeEta = (state: RestoreState): string => {
const computeEta = (state: RestoreState, t: Translator): string => {
if (state.status !== "running") return "—"
if (!state.steps_done || state.steps_done <= 0) return "estimating time…"
if (!state.steps_done || state.steps_done <= 0) return t("restoreProgress.time.estimating")
const elapsedSec = Math.max(1, Math.round((Date.now() - new Date(state.started_at).getTime()) / 1000))
const perStep = elapsedSec / state.steps_done
const remaining = Math.max(0, state.steps_total - state.steps_done)
const eta = Math.round(perStep * remaining)
if (eta < 60) return `~${eta}s left`
if (eta < 3600) return `~${Math.round(eta / 60)}m left`
return `~${Math.round(eta / 3600)}h left`
if (eta < 60) return t("restoreProgress.time.secondsLeft", { count: eta })
if (eta < 3600) return t("restoreProgress.time.minutesLeft", { count: Math.round(eta / 60) })
return t("restoreProgress.time.hoursLeft", { count: Math.round(eta / 3600) })
}
// ── Small building blocks ─────────────────────────────────────
const StatusBadge: React.FC<{ status: string }> = ({ status }) => {
const t = useT()
if (status === "running")
return (
<Badge className="bg-blue-500/10 border-blue-500/40 text-blue-300 gap-1">
<Loader2 className="h-3 w-3 animate-spin" />
Restore in progress
{t("restoreProgress.status.running")}
</Badge>
)
if (status === "complete")
return (
<Badge className="bg-emerald-500/10 border-emerald-500/40 text-emerald-400 gap-1">
<CheckCircle2 className="h-3 w-3" />
Restore complete
{t("restoreProgress.status.complete")}
</Badge>
)
if (status === "failed")
return (
<Badge className="bg-red-500/10 border-red-500/40 text-red-400 gap-1">
<XCircle className="h-3 w-3" />
Restore failed
{t("restoreProgress.status.failed")}
</Badge>
)
return <Badge variant="outline">{status}</Badge>
@@ -190,6 +194,7 @@ const ComponentStatusIcon: React.FC<{ status: string }> = ({ status }) => {
// ── Log viewer ────────────────────────────────────────────────
const LogViewer: React.FC<{ path: string | null; historyOnly?: boolean }> = ({ path, historyOnly }) => {
const t = useT()
const [filter, setFilter] = useState<"all" | "issues">("all")
const swrKey = path
? `/api/host-backups/restore/log?filter=${filter}&tail=600${historyOnly ? `&path=${encodeURIComponent(path)}` : ""}`
@@ -205,7 +210,7 @@ const LogViewer: React.FC<{ path: string | null; historyOnly?: boolean }> = ({ p
<div className="flex items-center justify-between text-xs">
<div className="flex items-center gap-1 text-muted-foreground">
<FileText className="h-3.5 w-3.5" />
{path ?? "no log yet"}
{path ?? t("restoreProgress.log.noLog")}
</div>
<div className="flex items-center gap-1">
<Button
@@ -215,7 +220,7 @@ const LogViewer: React.FC<{ path: string | null; historyOnly?: boolean }> = ({ p
onClick={() => setFilter("all")}
>
<ArrowDownAZ className="h-3 w-3 mr-1" />
Full
{t("restoreProgress.log.full")}
</Button>
<Button
size="sm"
@@ -224,13 +229,13 @@ const LogViewer: React.FC<{ path: string | null; historyOnly?: boolean }> = ({ p
onClick={() => setFilter("issues")}
>
<Filter className="h-3 w-3 mr-1" />
Issues only
{t("restoreProgress.log.issuesOnly")}
</Button>
</div>
</div>
<ScrollArea className="h-72 rounded-md border border-border bg-black/40">
<pre className="p-3 text-xs text-muted-foreground whitespace-pre-wrap font-mono leading-relaxed">
{isLoading ? "Loading" : (data?.lines?.join("\n") || "(no output)")}
{isLoading ? t("app.loading") : (data?.lines?.join("\n") || t("restoreProgress.log.noOutput"))}
</pre>
</ScrollArea>
</div>
@@ -240,13 +245,14 @@ const LogViewer: React.FC<{ path: string | null; historyOnly?: boolean }> = ({ p
// ── Rollback delta widget ─────────────────────────────────────
const RollbackDelta: React.FC<{ delta: RestoreRollback | undefined }> = ({ delta }) => {
const t = useT()
const vms = delta?.vms_to_remove ?? []
const lxcs = delta?.lxcs_to_remove ?? []
const comps = delta?.components_to_uninstall ?? []
if (!vms.length && !lxcs.length && !comps.length) {
return (
<div className="text-xs text-muted-foreground">
No entries exist on this host that weren't in the restored backup.
{t("restoreProgress.rollback.empty")}
</div>
)
}
@@ -264,7 +270,7 @@ const RollbackDelta: React.FC<{ delta: RestoreRollback | undefined }> = ({ delta
{items.length > 0 && (
<details className="text-xs">
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
Show manual cleanup commands
{t("restoreProgress.rollback.showCleanup")}
</summary>
<pre className="mt-1 p-2 rounded-md bg-black/40 text-xs text-muted-foreground font-mono">
{items.map(cmd).join("\n")}
@@ -277,22 +283,22 @@ const RollbackDelta: React.FC<{ delta: RestoreRollback | undefined }> = ({ delta
return (
<div className="space-y-3">
<div className="text-xs text-muted-foreground">
These entries exist on this host but were NOT in the restored backup. Review before removing.
{t("restoreProgress.rollback.description")}
</div>
<Row
label="VMs created after the backup"
label={t("restoreProgress.rollback.vms")}
items={vms}
cmd={(id) => `qm stop ${id} 2>/dev/null; qm destroy ${id} --purge`}
/>
<Row
label="LXCs created after the backup"
label={t("restoreProgress.rollback.lxcs")}
items={lxcs}
cmd={(id) => `pct stop ${id} 2>/dev/null; pct destroy ${id} --purge`}
/>
<Row
label="Components installed after the backup"
label={t("restoreProgress.rollback.components")}
items={comps}
cmd={(name) => `# uninstall ${name} manually via ProxMenux → Hardware & GPU`}
cmd={(name) => t("restoreProgress.rollback.uninstallComponentCommand", { name })}
/>
</div>
)
@@ -306,6 +312,7 @@ const RestoreDetailModal: React.FC<{
state: RestoreState
historyMode?: boolean
}> = ({ open, onClose, state, historyMode }) => {
const t = useT()
const progressPct = state.steps_total > 0 ? Math.round((state.steps_done / state.steps_total) * 100) : 0
return (
@@ -314,12 +321,12 @@ const RestoreDetailModal: React.FC<{
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<RotateCcw className="h-5 w-5 text-blue-500" />
Post-restore progress
{t("restoreProgress.title")}
<StatusBadge status={state.status} />
</DialogTitle>
<DialogDescription>
Started {formatIso(state.started_at)}
{state.finished_at ? ` · finished ${formatIso(state.finished_at)}` : ""}
{t("restoreProgress.startedAt", { time: formatIso(state.started_at) })}
{state.finished_at ? ` · ${t("restoreProgress.finishedAt", { time: formatIso(state.finished_at) })}` : ""}
{state.summary?.duration ? ` · ${state.summary.duration}` : ""}
</DialogDescription>
</DialogHeader>
@@ -329,8 +336,8 @@ const RestoreDetailModal: React.FC<{
<div className="flex justify-between text-xs text-muted-foreground">
<span>{state.current_step || "—"}</span>
<span>
{state.steps_done}/{state.steps_total} steps
{state.status === "running" && ` · ${computeEta(state)}`}
{t("restoreProgress.steps", { done: state.steps_done, total: state.steps_total })}
{state.status === "running" && ` · ${computeEta(state, t)}`}
</span>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
@@ -347,7 +354,7 @@ const RestoreDetailModal: React.FC<{
<div className="space-y-2">
<div className="text-sm font-medium flex items-center gap-2">
<Cpu className="h-4 w-4" />
Components
{t("restoreProgress.sections.components")}
</div>
<div className="space-y-1.5">
{state.components.map((c) => (
@@ -358,8 +365,8 @@ const RestoreDetailModal: React.FC<{
<div className="flex items-center gap-2">
<ComponentStatusIcon status={c.status} />
<span className="font-medium">{formatComponent(c.name)}</span>
<span className="text-muted-foreground">{c.status}</span>
{c.exit_code && <span className="text-red-400">exit {c.exit_code}</span>}
<span className="text-muted-foreground">{t(`restoreProgress.componentStatus.${c.status}`)}</span>
{c.exit_code && <span className="text-red-400">{t("restoreProgress.exitCode", { code: c.exit_code })}</span>}
</div>
{c.log && <span className="text-muted-foreground font-mono">{c.log}</span>}
</div>
@@ -372,7 +379,7 @@ const RestoreDetailModal: React.FC<{
<div className="space-y-2">
<div className="text-sm font-medium flex items-center gap-2 text-amber-400">
<AlertTriangle className="h-4 w-4" />
Boot sanity warnings
{t("restoreProgress.sections.bootWarnings")}
</div>
<ul className="list-disc list-inside text-xs text-muted-foreground space-y-1">
{state.sanity_warnings.map((w) => (
@@ -385,19 +392,19 @@ const RestoreDetailModal: React.FC<{
{state.data_pools_import && <DataPoolsBlock section={state.data_pools_import} />}
<div className="space-y-2">
<div className="text-sm font-medium">Rollback delta</div>
<div className="text-sm font-medium">{t("restoreProgress.sections.rollbackDelta")}</div>
<RollbackDelta delta={state.rollback_delta} />
</div>
<div className="space-y-2">
<div className="text-sm font-medium">Log</div>
<div className="text-sm font-medium">{t("restoreProgress.sections.log")}</div>
<LogViewer path={state.log_path} historyOnly={historyMode} />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Close
{t("actions.close")}
</Button>
</DialogFooter>
</DialogContent>
@@ -408,6 +415,7 @@ const RestoreDetailModal: React.FC<{
// Rendered inside RestoreDetailModal — one row per outcome category
// (imported / forced / partial skip / missing skip / failed).
const DataPoolsBlock: React.FC<{ section: DataPoolsImport }> = ({ section }) => {
const t = useT()
const total =
section.ok.length +
section.forced.length +
@@ -448,40 +456,40 @@ const DataPoolsBlock: React.FC<{ section: DataPoolsImport }> = ({ section }) =>
}
return (
<div className="space-y-2">
<div className="space-y-2">
<div className="text-sm font-medium flex items-center gap-2">
<Cpu className="h-4 w-4" />
ZFS data pools auto-import
{t("restoreProgress.dataPools.title")}
</div>
<div className="space-y-1.5">
<Row label="Imported" tone="ok" items={section.ok} />
<Row label={t("restoreProgress.dataPools.imported")} tone="ok" items={section.ok} />
<Row
label="Imported (forced, foreign hostid)"
label={t("restoreProgress.dataPools.importedForced")}
tone="info"
items={section.forced}
help="New hostid grabbed onto the pool label — next boot imports clean."
help={t("restoreProgress.dataPools.importedForcedHelp")}
/>
<Row
label="Skipped (some disks missing)"
label={t("restoreProgress.dataPools.skippedPartial")}
tone="warn"
items={section.partial}
help="Some vdev disks weren't found by /dev/disk/by-id. Pool NOT imported to avoid a degraded auto-import. Fix the disks or import manually with zpool import."
help={t("restoreProgress.dataPools.skippedPartialHelp")}
/>
<Row
label="Skipped (no disks present)"
label={t("restoreProgress.dataPools.skippedMissing")}
tone="warn"
items={section.missing}
help="None of the pool's disks are on this host. Move the disks over or import from a different host."
help={t("restoreProgress.dataPools.skippedMissingHelp")}
/>
<Row
label="Import failed"
label={t("restoreProgress.dataPools.importFailed")}
tone="error"
items={section.failed}
help="ZFS rejected the import even with -f. Inspect with `zpool import` and the log below."
help={t("restoreProgress.dataPools.importFailedHelp")}
/>
</div>
{section.log_path && (
<div className="text-xs text-muted-foreground font-mono">Log: {section.log_path}</div>
<div className="text-xs text-muted-foreground font-mono">{t("restoreProgress.dataPools.logPath", { path: section.log_path })}</div>
)}
</div>
)
@@ -490,6 +498,7 @@ const DataPoolsBlock: React.FC<{ section: DataPoolsImport }> = ({ section }) =>
// ── History browser modal ─────────────────────────────────────
const RestoreHistoryModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => {
const t = useT()
const { data } = useSWR<{ entries: HistoryEntry[] }>(open ? "/api/host-backups/restore/history" : null, fetcher)
const [detailFile, setDetailFile] = useState<string | null>(null)
const { data: detailResp } = useSWR<{ state: RestoreState }>(
@@ -504,17 +513,17 @@ const RestoreHistoryModal: React.FC<{ open: boolean; onClose: () => void }> = ({
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<History className="h-5 w-5" />
Past restores
{t("restoreProgress.history.title")}
</DialogTitle>
<DialogDescription>
Restores archived by the post-boot dispatcher. The latest 20 are kept.
{t("restoreProgress.history.description")}
</DialogDescription>
</DialogHeader>
<ScrollArea className="h-96">
<div className="space-y-1.5">
{(data?.entries ?? []).length === 0 ? (
<div className="text-sm text-muted-foreground py-6 text-center">No past restores recorded.</div>
<div className="text-sm text-muted-foreground py-6 text-center">{t("restoreProgress.history.empty")}</div>
) : (
(data?.entries ?? []).map((e) => (
<button
@@ -538,7 +547,7 @@ const RestoreHistoryModal: React.FC<{ open: boolean; onClose: () => void }> = ({
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Close
{t("actions.close")}
</Button>
</DialogFooter>
</DialogContent>
@@ -559,6 +568,7 @@ const RestoreHistoryModal: React.FC<{ open: boolean; onClose: () => void }> = ({
// ── Main inline card ──────────────────────────────────────────
export const RestoreProgressCard: React.FC = () => {
const t = useT()
const { data, mutate } = useSWR<{ state: RestoreState | null }>(
"/api/host-backups/restore/status",
fetcher,
@@ -597,7 +607,7 @@ export const RestoreProgressCard: React.FC = () => {
<div className="flex justify-end">
<Button variant="ghost" size="sm" onClick={() => setHistoryOpen(true)}>
<History className="h-3.5 w-3.5 mr-1" />
Past restores
{t("restoreProgress.history.title")}
</Button>
<RestoreHistoryModal open={historyOpen} onClose={() => setHistoryOpen(false)} />
</div>
@@ -625,12 +635,12 @@ export const RestoreProgressCard: React.FC = () => {
<RotateCcw
className={`h-5 w-5 ${state.status === "running" ? "text-blue-500 animate-spin" : "text-blue-500"}`}
/>
Post-restore progress
{t("restoreProgress.title")}
<StatusBadge status={state.status} />
{hasWarnings && (
<Badge variant="outline" className="text-amber-400 border-amber-500/40 bg-amber-500/10 gap-1">
<AlertTriangle className="h-3 w-3" />
{state.sanity_warnings.length} boot warning{state.sanity_warnings.length === 1 ? "" : "s"}
{t("restoreProgress.badges.bootWarnings", { count: state.sanity_warnings.length })}
</Badge>
)}
{poolCount > 0 && (
@@ -643,22 +653,22 @@ export const RestoreProgressCard: React.FC = () => {
}
>
<Cpu className="h-3 w-3" />
{poolCount} ZFS pool{poolCount === 1 ? "" : "s"}
{poolWarnings > 0 && ` · ${poolWarnings} need attention`}
{t("restoreProgress.badges.zfsPools", { count: poolCount })}
{poolWarnings > 0 && ` · ${t("restoreProgress.badges.needAttention", { count: poolWarnings })}`}
</Badge>
)}
</CardTitle>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => setDetailOpen(true)}>
Details
{t("restoreProgress.actions.details")}
</Button>
<Button size="sm" variant="ghost" onClick={() => setHistoryOpen(true)}>
<History className="h-3.5 w-3.5 mr-1" />
History
{t("restoreProgress.actions.history")}
</Button>
{state.status !== "running" && (
<Button size="sm" onClick={dismiss} disabled={dismissing}>
{dismissing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : "Dismiss"}
{dismissing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : t("restoreProgress.actions.dismiss")}
</Button>
)}
</div>
@@ -668,11 +678,11 @@ export const RestoreProgressCard: React.FC = () => {
<div className="space-y-1">
<div className="flex justify-between text-xs text-muted-foreground">
<span className="truncate">
{state.current_step || "—"} · started {formatRelative(state.started_at)}
{state.current_step || "—"} · {t("restoreProgress.startedRelative", { time: formatRelative(state.started_at, t) })}
</span>
<span>
{state.steps_done}/{state.steps_total} steps
{state.status === "running" && ` · ${computeEta(state)}`}
{t("restoreProgress.steps", { done: state.steps_done, total: state.steps_total })}
{state.status === "running" && ` · ${computeEta(state, t)}`}
{state.summary?.duration && state.status !== "running" && ` · ${state.summary.duration}`}
</span>
</div>
@@ -684,19 +694,19 @@ export const RestoreProgressCard: React.FC = () => {
{state.summary && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">
<div className="rounded-md border border-border bg-muted/30 px-2 py-1.5">
<div className="text-muted-foreground">Guests</div>
<div className="text-muted-foreground">{t("restoreProgress.summary.guests")}</div>
<div className="font-medium">{state.summary.guests}</div>
</div>
<div className="rounded-md border border-border bg-muted/30 px-2 py-1.5">
<div className="text-muted-foreground">Bind-mount stubs</div>
<div className="text-muted-foreground">{t("restoreProgress.summary.bindMountStubs")}</div>
<div className="font-medium">{state.summary.stubs}</div>
</div>
<div className="rounded-md border border-border bg-muted/30 px-2 py-1.5">
<div className="text-muted-foreground">Stale nodes cleaned</div>
<div className="text-muted-foreground">{t("restoreProgress.summary.staleNodesCleaned")}</div>
<div className="font-medium">{state.summary.stale_nodes}</div>
</div>
<div className="rounded-md border border-border bg-muted/30 px-2 py-1.5">
<div className="text-muted-foreground">Components</div>
<div className="text-muted-foreground">{t("restoreProgress.summary.components")}</div>
<div className="font-medium">{state.summary.components}</div>
</div>
</div>
+109 -31
View File
@@ -31,6 +31,7 @@ import {
import "xterm/css/xterm.css"
import { API_PORT } from "@/lib/api-config"
import { getTicketedWsUrl } from "@/lib/terminal-ws"
import { useT } from "../lib/i18n/provider"
interface WebInteraction {
type: "yesno" | "menu" | "msgbox" | "input" | "inputbox"
@@ -49,12 +50,14 @@ interface ScriptTerminalModalProps {
description: string
scriptName?: string
params?: Record<string, string>
completedSuccessfullyMessage?: string
completedWithErrorMessage?: (exitCode: number) => string
// Optional callback fired when the script's WebSocket closes
// (script_runner sends an exit code and then closes). Lets the
// parent auto-dismiss the modal — used by host-backup's Restore
// flow so "Press Enter to close" in the bash script actually
// closes the modal without an extra click. Other callers ignore.
onComplete?: () => void
onComplete?: (exitCode?: number) => void
}
export function ScriptTerminalModal({
@@ -64,8 +67,11 @@ export function ScriptTerminalModal({
title,
description,
params = { EXECUTION_MODE: "web" },
completedSuccessfullyMessage,
completedWithErrorMessage,
onComplete,
}: ScriptTerminalModalProps) {
const t = useT()
const termRef = useRef<any>(null)
const wsRef = useRef<WebSocket | null>(null)
// Mirrors `isOpen` for use inside async closures (initializeTerminal)
@@ -83,6 +89,7 @@ export function ScriptTerminalModal({
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const reconnectAttemptsRef = useRef(0)
const keepAliveIntervalRef = useRef<NodeJS.Timeout | null>(null)
const completionReceivedRef = useRef(false)
const [isMobile, setIsMobile] = useState(false)
const [isTablet, setIsTablet] = useState(false)
@@ -94,6 +101,14 @@ export function ScriptTerminalModal({
const resizeBarRef = useRef<HTMLDivElement>(null)
const modalHeightRef = useRef(600)
const getCompletionMessage = useCallback(
(exitCode: number) =>
exitCode === 0
? (completedSuccessfullyMessage ?? t("scriptTerminal.completedSuccessfully"))
: (completedWithErrorMessage?.(exitCode) ?? t("scriptTerminal.completedWithError", { code: exitCode })),
[completedSuccessfullyMessage, completedWithErrorMessage, t],
)
const terminalContainerRef = useRef<HTMLDivElement>(null)
const paramsRef = useRef(params)
@@ -104,7 +119,7 @@ export function ScriptTerminalModal({
// Same trick for onComplete — we want the latest callback inside
// the ws.onclose handler without re-running the connection effect.
const onCompleteRef = useRef<(() => void) | undefined>(undefined)
const onCompleteRef = useRef<((exitCode?: number) => void) | undefined>(undefined)
useEffect(() => {
onCompleteRef.current = onComplete
}, [onComplete])
@@ -165,6 +180,23 @@ const initMessage = {
if (event.data === '{"type": "pong"}' || event.data === '{"type":"pong"}') {
return
}
// The PTY worker always emits this final line. Treat it as a
// completion fallback because some WebSocket servers tear down the
// connection before the following structured message is flushed.
const exitMatch = typeof event.data === "string"
? event.data.match(/\[Script exited with code (-?\d+)\]/)
: null
if (exitMatch) {
const exitCode = Number(exitMatch[1])
termRef.current?.write(event.data)
completionReceivedRef.current = true
setIsComplete(true)
termRef.current?.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`)
onCompleteRef.current?.(exitCode)
if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete")
return
}
try {
const msg = JSON.parse(event.data)
@@ -187,6 +219,15 @@ const initMessage = {
termRef.current?.writeln(`\x1b[31m${msg.message}\x1b[0m`)
return
}
if (msg.type === "script_complete") {
const exitCode = Number(msg.exit_code ?? 1)
completionReceivedRef.current = true
setIsComplete(true)
termRef.current?.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`)
onCompleteRef.current?.(exitCode)
if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete")
return
}
} catch {}
termRef.current?.write(event.data)
setIsWaitingNextInteraction(false)
@@ -197,6 +238,9 @@ const initMessage = {
ws.onerror = () => {
setConnectionStatus("offline")
if (!completionReceivedRef.current) {
termRef.current?.writeln(`\x1b[31m${t("scriptTerminal.websocketError")}\x1b[0m`)
}
}
ws.onclose = (event) => {
@@ -205,16 +249,19 @@ const initMessage = {
clearInterval(keepAliveIntervalRef.current)
keepAliveIntervalRef.current = null
}
if (completionReceivedRef.current) {
return
}
if (!isComplete && reconnectAttemptsRef.current < 3) {
reconnectTimeoutRef.current = setTimeout(attemptReconnect, 2000)
} else {
setIsComplete(true)
onCompleteRef.current?.()
onCompleteRef.current?.(-1)
}
}
}
}, 1000)
}, [isOpen, isComplete, scriptPath])
}, [isOpen, isComplete, scriptPath, getCompletionMessage, t])
const sendKey = useCallback((key: string) => {
if (!termRef.current) return
@@ -350,6 +397,23 @@ const initMessage = {
if (event.data === '{"type": "pong"}' || event.data === '{"type":"pong"}') {
return
}
// See the reconnect handler above. The exit line is guaranteed to be
// sent with the PTY output and is therefore a robust fallback when a
// final JSON frame is lost during server-side socket teardown.
const exitMatch = typeof event.data === "string"
? event.data.match(/\[Script exited with code (-?\d+)\]/)
: null
if (exitMatch) {
const exitCode = Number(exitMatch[1])
term.write(event.data)
completionReceivedRef.current = true
setIsComplete(true)
term.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`)
onCompleteRef.current?.(exitCode)
if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete")
return
}
try {
const msg = JSON.parse(event.data)
@@ -374,6 +438,15 @@ const initMessage = {
term.writeln(`\x1b[31m${msg.message}\x1b[0m`)
return
}
if (msg.type === "script_complete") {
const exitCode = Number(msg.exit_code ?? 1)
completionReceivedRef.current = true
setIsComplete(true)
term.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`)
onCompleteRef.current?.(exitCode)
if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete")
return
}
} catch {
// Not JSON, es output normal de terminal
}
@@ -388,21 +461,25 @@ const initMessage = {
ws.onerror = (error) => {
setConnectionStatus("offline")
term.writeln("\x1b[31mWebSocket error occurred\x1b[0m")
if (!completionReceivedRef.current) {
term.writeln(`\x1b[31m${t("scriptTerminal.websocketError")}\x1b[0m`)
}
}
ws.onclose = (event) => {
setConnectionStatus("offline")
term.writeln("\x1b[33mConnection closed\x1b[0m")
if (!completionReceivedRef.current) {
term.writeln(`\x1b[33m${t("scriptTerminal.connectionClosed")}\x1b[0m`)
}
if (keepAliveIntervalRef.current) {
clearInterval(keepAliveIntervalRef.current)
keepAliveIntervalRef.current = null
}
if (!isComplete) {
if (!completionReceivedRef.current && !isComplete) {
setIsComplete(true)
onCompleteRef.current?.()
onCompleteRef.current?.(-1)
}
}
@@ -489,6 +566,7 @@ const initMessage = {
sessionIdRef.current = Math.random().toString(36).substring(2, 8)
reconnectAttemptsRef.current = 0
completionReceivedRef.current = false
setIsComplete(false)
setInteractionInput("")
setCurrentInteraction(null)
@@ -712,7 +790,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 +913,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 +955,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 +975,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 +1011,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 +1041,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 +1065,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 +1084,14 @@ const initMessage = {
onClick={() => handleInteractionResponse("ok")}
className="flex-1 bg-blue-600 hover:bg-blue-700 transition-all duration-150"
>
OK
{t("scriptTerminal.ok")}
</Button>
<Button
onClick={() => handleInteractionResponse("cancel")}
variant="outline"
className="flex-1 hover:bg-red-600 hover:text-white hover:border-red-600 transition-all duration-150"
>
Cancel
{t("actions.cancel")}
</Button>
</div>
)}
+152 -133
View File
@@ -20,6 +20,7 @@ import {
ArrowUpCircle,
} from "lucide-react"
import { fetchApi } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
interface NetworkInfo {
interface: string
@@ -77,6 +78,20 @@ interface WizardStep {
}
export function SecureGatewaySetup() {
const t = useT()
const sg = (key: string, params?: Record<string, string | number>) => t(`securityPage.secureGateway.${key}`, params)
const maybeSg = (key: string, fallback?: string) => {
const fullKey = `securityPage.secureGateway.${key}`
const value = t(fullKey)
return value === fullKey ? fallback || "" : value
}
const fieldText = (fieldName: string, part: string, fallback?: string) =>
maybeSg(`schema.${fieldName}.${part}`, fallback)
const optionText = (fieldName: string, value: string, part: string, fallback?: string) =>
maybeSg(`schema.${fieldName}.options.${value}.${part}`, fallback)
const stepText = (step: WizardStep, part: "title" | "description") =>
maybeSg(`steps.${step.id}.${part}`, step[part])
// State
const [loading, setLoading] = useState(true)
const [runtimeAvailable, setRuntimeAvailable] = useState(false)
@@ -207,7 +222,7 @@ export function SecureGatewaySetup() {
}
} catch (err) {
console.error("Failed to load data:", err)
setLoadError(err instanceof Error ? err.message : "Failed to load wizard data")
setLoadError(err instanceof Error ? err.message : sg("errors.loadWizardFailed"))
} finally {
setLoading(false)
}
@@ -265,7 +280,7 @@ export function SecureGatewaySetup() {
method: "POST",
})
if (res?.success) {
setUpdateResultMsg(res.message || "Update applied")
setUpdateResultMsg(res.message || sg("messages.updateApplied"))
// Re-probe with force=true so the panel flips back to "No
// updates available" immediately, bypassing the 24h server
// cache which may still hold the pre-apply "available" entry.
@@ -274,10 +289,10 @@ export function SecureGatewaySetup() {
// refresh that too so the action buttons render the right state.
await loadStatus()
} else {
setUpdateError(res?.message || "Update failed")
setUpdateError(res?.message || sg("errors.updateFailed"))
}
} catch (err) {
setUpdateError(err instanceof Error ? err.message : "Network error during update")
setUpdateError(err instanceof Error ? err.message : sg("errors.networkUpdateFailed"))
} finally {
setUpdateApplying(false)
}
@@ -293,7 +308,7 @@ export function SecureGatewaySetup() {
if (deploying) return
setDeploying(true)
setDeployError("")
setDeployProgress("Preparing deployment...")
setDeployProgress(sg("messages.preparingDeployment"))
try {
// Validate required fields
@@ -302,7 +317,7 @@ export function SecureGatewaySetup() {
for (const fieldName of step.fields) {
const field = configSchema?.[fieldName]
if (field?.required && !config[fieldName]) {
setDeployError(`${field.label} is required`)
setDeployError(sg("errors.fieldRequired", { field: fieldText(fieldName, "label", field.label) }))
setDeploying(false)
return
}
@@ -326,7 +341,7 @@ export function SecureGatewaySetup() {
}
// For "custom", the user has already selected networks manually
setDeployProgress("Creating LXC container...")
setDeployProgress(sg("messages.creatingLxc"))
const result = await fetchApi("/api/oci/deploy", {
method: "POST",
@@ -338,16 +353,16 @@ export function SecureGatewaySetup() {
if (!result.success) {
// Make runtime errors more user-friendly
let errorMsg = result.message || "Deployment failed"
let errorMsg = result.message || sg("errors.deploymentFailed")
if (errorMsg.includes("9.1") || errorMsg.includes("OCI") || errorMsg.includes("not supported")) {
errorMsg = "OCI containers require Proxmox VE 9.1 or later. Please upgrade your Proxmox installation to use this feature."
errorMsg = sg("errors.ociRequiresPve")
}
setDeployError(errorMsg)
setDeploying(false)
return
}
setDeployProgress("Gateway deployed successfully!")
setDeployProgress(sg("messages.gatewayDeployed"))
// Wipe the Tailscale auth_key from React state so it's no longer
// reachable from a future XSS / state-inspection. The key only needs
@@ -376,7 +391,7 @@ export function SecureGatewaySetup() {
}, 2000)
} catch (err: any) {
setDeployError(err.message || "Deployment failed")
setDeployError(err.message || sg("errors.deploymentFailed"))
setDeploying(false)
}
}
@@ -400,7 +415,7 @@ export function SecureGatewaySetup() {
const handleUpdateAuthKey = async () => {
if (!newAuthKey.trim()) {
setUpdateAuthKeyError("Auth Key is required")
setUpdateAuthKeyError(sg("errors.authKeyRequired"))
return
}
@@ -417,7 +432,7 @@ export function SecureGatewaySetup() {
})
if (!result.success) {
setUpdateAuthKeyError(result.message || "Failed to update auth key")
setUpdateAuthKeyError(result.message || sg("errors.updateAuthKeyFailed"))
setUpdateAuthKeyLoading(false)
return
}
@@ -427,7 +442,7 @@ export function SecureGatewaySetup() {
setNewAuthKey("")
await loadStatus()
} catch (err: any) {
setUpdateAuthKeyError(err.message || "Failed to update auth key")
setUpdateAuthKeyError(err.message || sg("errors.updateAuthKeyFailed"))
} finally {
setUpdateAuthKeyLoading(false)
}
@@ -456,10 +471,10 @@ export function SecureGatewaySetup() {
try {
const result = await fetchApi("/api/oci/installed/secure-gateway/logs?lines=100")
if (result.success) {
setLogs(result.logs || "No logs available")
setLogs(result.logs || sg("logs.empty"))
}
} catch (err) {
setLogs("Failed to load logs")
setLogs(sg("logs.failed"))
} finally {
setLogsLoading(false)
}
@@ -476,16 +491,16 @@ export function SecureGatewaySetup() {
// date-only string. Used in the Updates panel — the user wants to know
// "how stale is this number" without seeing the raw 2026-05-09T10:23Z.
const formatLastChecked = (iso?: string): string => {
if (!iso) return "never"
if (!iso) return sg("values.never")
const d = new Date(iso)
if (isNaN(d.getTime())) return "unknown"
if (isNaN(d.getTime())) return t("common.unknown")
const now = Date.now()
const ageMs = now - d.getTime()
const sameDay = new Date(now).toDateString() === d.toDateString()
const yesterday = new Date(now - 86_400_000).toDateString() === d.toDateString()
const time = d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
if (sameDay) return time
if (yesterday) return `yesterday ${time}`
if (yesterday) return sg("values.yesterdayAt", { time })
if (ageMs < 7 * 86_400_000) {
return d.toLocaleDateString([], { weekday: "short" }) + " " + time
}
@@ -495,6 +510,11 @@ export function SecureGatewaySetup() {
const renderField = (fieldName: string) => {
const field = configSchema?.[fieldName]
if (!field) return null
const translatedLabel = fieldText(fieldName, "label", field.label)
const translatedDescription = fieldText(fieldName, "description", field.description)
const translatedPlaceholder = fieldText(fieldName, "placeholder", field.placeholder)
const translatedWarning = fieldText(fieldName, "warning", field.warning)
const translatedHelpText = fieldText(fieldName, "helpText", field.help_text)
// Check depends_on
if (field.depends_on) {
@@ -511,7 +531,7 @@ export function SecureGatewaySetup() {
return (
<div key={fieldName} className="space-y-2">
<Label htmlFor={fieldName} className="text-sm font-medium">
{field.label}
{translatedLabel}
{field.required && <span className="text-red-500 ml-1">*</span>}
</Label>
<div className="relative">
@@ -520,7 +540,7 @@ export function SecureGatewaySetup() {
type={isVisible ? "text" : "password"}
value={config[fieldName] || ""}
onChange={(e) => setConfig({ ...config, [fieldName]: e.target.value })}
placeholder={field.placeholder}
placeholder={translatedPlaceholder}
className="pr-10 bg-background border-border"
/>
<button
@@ -536,7 +556,7 @@ export function SecureGatewaySetup() {
{isVisible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<p className="text-xs text-muted-foreground">{field.description}</p>
<p className="text-xs text-muted-foreground">{translatedDescription}</p>
{field.help_url && (
<a
href={field.help_url}
@@ -544,7 +564,7 @@ export function SecureGatewaySetup() {
rel="noopener noreferrer"
className="text-xs text-cyan-500 hover:text-cyan-400 inline-flex items-center gap-1"
>
{field.help_text || "Learn more"} <ExternalLink className="h-3 w-3" />
{translatedHelpText || sg("learnMore")} <ExternalLink className="h-3 w-3" />
</a>
)}
</div>
@@ -554,7 +574,7 @@ export function SecureGatewaySetup() {
return (
<div key={fieldName} className="space-y-2">
<Label htmlFor={fieldName} className="text-sm font-medium">
{field.label}
{translatedLabel}
{field.required && <span className="text-red-500 ml-1">*</span>}
</Label>
<Input
@@ -562,10 +582,10 @@ export function SecureGatewaySetup() {
type="text"
value={config[fieldName] || ""}
onChange={(e) => setConfig({ ...config, [fieldName]: e.target.value })}
placeholder={field.placeholder}
placeholder={translatedPlaceholder}
className="bg-background border-border"
/>
<p className="text-xs text-muted-foreground">{field.description}</p>
<p className="text-xs text-muted-foreground">{translatedDescription}</p>
</div>
)
@@ -596,7 +616,7 @@ export function SecureGatewaySetup() {
return (
<div key={fieldName} className="space-y-3">
<Label className="text-sm font-medium">
{field.label}
{translatedLabel}
{field.required && <span className="text-red-500 ml-1">*</span>}
</Label>
<div className="space-y-2">
@@ -619,15 +639,15 @@ export function SecureGatewaySetup() {
)}
</div>
<div className="flex-1">
<p className="font-medium text-sm">{opt.label}</p>
<p className="font-medium text-sm">{optionText(fieldName, opt.value, "label", opt.label)}</p>
{opt.description && (
<p className="text-xs text-muted-foreground">{opt.description}</p>
<p className="text-xs text-muted-foreground">{optionText(fieldName, opt.value, "description", opt.description)}</p>
)}
{/* Show selected network for proxmox_network */}
{fieldName === "access_mode" && opt.value === "proxmox_network" && config[fieldName] === "proxmox_network" && (
<p className="text-xs text-cyan-400 mt-1 flex items-center gap-1">
<Network className="h-3 w-3" />
{networks.find((n) => n.recommended)?.subnet || networks[0]?.subnet || "No network detected"}
{networks.find((n) => n.recommended)?.subnet || networks[0]?.subnet || sg("noNetworkDetected")}
</p>
)}
</div>
@@ -642,13 +662,13 @@ export function SecureGatewaySetup() {
return (
<div key={fieldName} className="space-y-3">
<Label className="text-sm font-medium">
{field.label}
{translatedLabel}
</Label>
<p className="text-xs text-muted-foreground">{field.description}</p>
<p className="text-xs text-muted-foreground">{translatedDescription}</p>
<div className="space-y-2 max-h-48 overflow-y-auto">
{networks.length === 0 ? (
<p className="text-sm text-muted-foreground p-3 bg-muted/30 rounded">
No networks detected
{sg("noNetworksDetected")}
</p>
) : (
networks.map((net) => {
@@ -676,7 +696,7 @@ export function SecureGatewaySetup() {
<span className="font-mono text-sm">{net.subnet}</span>
{net.recommended && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-green-500/10 text-green-500">
Recommended
{sg("recommended")}
</span>
)}
</div>
@@ -705,12 +725,12 @@ export function SecureGatewaySetup() {
>
<Checkbox checked={config[fieldName] || false} className="pointer-events-none mt-0.5" />
<div>
<p className="font-medium text-sm">{field.label}</p>
<p className="text-xs text-muted-foreground">{field.description}</p>
<p className="font-medium text-sm">{translatedLabel}</p>
<p className="text-xs text-muted-foreground">{translatedDescription}</p>
{field.warning && config[fieldName] && (
<p className="text-xs text-cyan-400 mt-2 flex items-start gap-1.5 bg-cyan-500/10 p-2 rounded">
<Info className="h-3 w-3 mt-0.5 flex-shrink-0" />
{field.warning}
{translatedWarning}
</p>
)}
</div>
@@ -736,40 +756,40 @@ export function SecureGatewaySetup() {
</div>
</div>
<div className="text-center space-y-2">
<h3 className="text-lg font-semibold">Secure Remote Access</h3>
<h3 className="text-lg font-semibold">{sg("wizard.introTitle")}</h3>
<p className="text-sm text-muted-foreground max-w-md mx-auto">
Deploy a VPN gateway using Tailscale for secure, zero-trust access to your Proxmox infrastructure without opening ports.
{sg("wizard.introDescription")}
</p>
</div>
<div className="bg-muted/30 rounded-lg p-4 space-y-3">
<h4 className="text-sm font-medium">What you{"'"}ll get:</h4>
<h4 className="text-sm font-medium">{sg("wizard.whatYouGet")}</h4>
<ul className="space-y-2 text-sm text-muted-foreground">
<li className="flex items-center gap-2">
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
Access ProxMenux Monitor from anywhere
{sg("wizard.benefitMonitorAnywhere")}
</li>
<li className="flex items-center gap-2">
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
Secure access to Proxmox web UI
{sg("wizard.benefitProxmoxUi")}
</li>
<li className="flex items-center gap-2">
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
Optionally expose VMs and LXC containers
{sg("wizard.benefitVmLxc")}
</li>
<li className="flex items-center gap-2">
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
End-to-end encryption
{sg("wizard.benefitEncryption")}
</li>
<li className="flex items-center gap-2">
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
No port forwarding required
{sg("wizard.benefitNoPorts")}
</li>
</ul>
</div>
<div className="bg-cyan-500/10 border border-cyan-500/20 rounded-lg p-3">
<p className="text-xs text-cyan-400 flex items-start gap-2">
<Info className="h-4 w-4 flex-shrink-0 mt-0.5" />
You{"'"}ll need a free Tailscale account. If you don{"'"}t have one, you can create it at{" "}
{sg("wizard.tailscaleAccountBefore")}{" "}
<a href="https://tailscale.com" target="_blank" rel="noopener noreferrer" className="underline hover:text-cyan-300">
tailscale.com
</a>
@@ -783,17 +803,17 @@ export function SecureGatewaySetup() {
return (
<div className="space-y-6">
<div className="text-center space-y-2">
<h3 className="text-lg font-semibold">Review & Deploy</h3>
<h3 className="text-lg font-semibold">{sg("wizard.reviewDeploy")}</h3>
<p className="text-sm text-muted-foreground">
Review your configuration before deploying the gateway.
{sg("wizard.reviewDescription")}
</p>
</div>
{/* Storage selector */}
{storages.length > 1 && (
<div className="space-y-3">
<Label className="text-sm font-medium">Storage Location</Label>
<p className="text-xs text-muted-foreground">Select where to create the container disk.</p>
<Label className="text-sm font-medium">{sg("wizard.storageLocation")}</Label>
<p className="text-xs text-muted-foreground">{sg("wizard.storageDescription")}</p>
<div className="space-y-2">
{storages.filter(s => s.active && s.enabled).map((storage) => (
<div
@@ -819,12 +839,12 @@ export function SecureGatewaySetup() {
<span className="text-xs text-muted-foreground">({storage.type})</span>
{storage.recommended && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-green-500/10 text-green-500">
Recommended
{sg("recommended")}
</span>
)}
</div>
<p className="text-xs text-muted-foreground">
{(storage.avail / 1024 / 1024 / 1024).toFixed(1)} GB available
{sg("wizard.gbAvailable", { amount: (storage.avail / 1024 / 1024 / 1024).toFixed(1) })}
</p>
</div>
</div>
@@ -835,41 +855,41 @@ export function SecureGatewaySetup() {
)}
<div className="bg-muted/30 rounded-lg p-4 space-y-3">
<h4 className="text-sm font-medium">Configuration Summary</h4>
<h4 className="text-sm font-medium">{sg("wizard.configurationSummary")}</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Hostname:</span>
<span className="text-muted-foreground">{sg("wizard.hostname")}:</span>
<span className="font-mono">{config.hostname || "proxmox-gateway"}</span>
</div>
{storages.length > 1 && (
<div className="flex justify-between">
<span className="text-muted-foreground">Storage:</span>
<span className="text-muted-foreground">{sg("wizard.storage")}:</span>
<span className="font-mono">{config.storage || storages[0]?.name}</span>
</div>
)}
<div className="flex justify-between">
<span className="text-muted-foreground">Access Mode:</span>
<span>{config.access_mode === "host_only" ? "Host Only" : config.access_mode === "proxmox_network" ? "Proxmox Network" : "Custom Networks"}</span>
<span className="text-muted-foreground">{sg("wizard.accessMode")}:</span>
<span>{config.access_mode === "host_only" ? sg("wizard.accessModes.hostOnly") : config.access_mode === "proxmox_network" ? sg("wizard.accessModes.proxmoxNetwork") : sg("wizard.accessModes.customNetworks")}</span>
</div>
{config.access_mode === "host_only" && hostIp && (
<div className="flex justify-between">
<span className="text-muted-foreground">Host Access:</span>
<span className="text-muted-foreground">{sg("wizard.hostAccess")}:</span>
<span className="text-right font-mono text-xs">{hostIp}/32</span>
</div>
)}
{(config.access_mode === "proxmox_network" || config.access_mode === "custom") && config.advertise_routes?.length > 0 && (
<div className="flex justify-between">
<span className="text-muted-foreground">Networks:</span>
<span className="text-muted-foreground">{sg("wizard.networks")}:</span>
<span className="text-right font-mono text-xs">{config.advertise_routes.join(", ")}</span>
</div>
)}
<div className="flex justify-between">
<span className="text-muted-foreground">Exit Node:</span>
<span>{config.exit_node ? "Yes" : "No"}</span>
<span className="text-muted-foreground">{sg("wizard.exitNode")}:</span>
<span>{config.exit_node ? sg("values.yes") : sg("values.no")}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Accept Routes:</span>
<span>{config.accept_routes ? "Yes" : "No"}</span>
<span className="text-muted-foreground">{sg("wizard.acceptRoutes")}:</span>
<span>{config.accept_routes ? sg("values.yes") : sg("values.no")}</span>
</div>
</div>
</div>
@@ -880,12 +900,12 @@ export function SecureGatewaySetup() {
<p className="text-xs text-cyan-400 flex items-start gap-2">
<Info className="h-4 w-4 flex-shrink-0 mt-0.5" />
<span>
<strong>Important:</strong> After deployment, you must approve the subnet route in Tailscale Admin for remote access to work.
{config.exit_node && <span> You{"'"}ll also need to approve the exit node.</span>}
<strong>{sg("wizard.important")}:</strong> {sg("wizard.approvalRequired")}
{config.exit_node && <span> {sg("wizard.exitNodeApprovalRequired")}</span>}
</span>
</p>
<p className="text-xs text-muted-foreground ml-6">
We{"'"}ll show you exactly what to do after the gateway is deployed.
{sg("wizard.showAfterDeploy")}
</p>
</div>
)}
@@ -915,8 +935,8 @@ export function SecureGatewaySetup() {
return (
<div className="space-y-6">
<div className="text-center space-y-2">
<h3 className="text-lg font-semibold">{step.title}</h3>
<p className="text-sm text-muted-foreground">{step.description}</p>
<h3 className="text-lg font-semibold">{stepText(step, "title")}</h3>
<p className="text-sm text-muted-foreground">{stepText(step, "description")}</p>
</div>
<div className="space-y-4">
{step.fields?.map((fieldName) => renderField(fieldName))}
@@ -932,7 +952,7 @@ export function SecureGatewaySetup() {
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-cyan-500" />
<CardTitle className="text-base">Secure Gateway</CardTitle>
<CardTitle className="text-base">{sg("title")}</CardTitle>
</div>
</CardHeader>
<CardContent>
@@ -953,14 +973,14 @@ export function SecureGatewaySetup() {
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-cyan-500" />
<CardTitle className="text-base">Secure Gateway</CardTitle>
<CardTitle className="text-base">{sg("title")}</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="space-y-3 py-2">
<p className="text-sm text-red-500">Could not load setup data: {loadError}</p>
<p className="text-sm text-red-500">{sg("errors.couldNotLoadSetupData")} {loadError}</p>
<Button size="sm" variant="outline" onClick={() => loadInitialData()}>
Retry
{sg("retry")}
</Button>
</div>
</CardContent>
@@ -981,7 +1001,7 @@ export function SecureGatewaySetup() {
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-cyan-500" />
<CardTitle className="text-base">Secure Gateway</CardTitle>
<CardTitle className="text-base">{sg("title")}</CardTitle>
</div>
<div className={`flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium ${
isRunning ? "bg-green-500/10 text-green-500" :
@@ -991,16 +1011,16 @@ export function SecureGatewaySetup() {
{isRunning ? <Wifi className="h-3 w-3" /> :
isStopped ? <Square className="h-3 w-3" /> :
<XCircle className="h-3 w-3" />}
{isRunning ? "Connected" : isStopped ? "Stopped" : "Error"}
{isRunning ? sg("status.connected") : isStopped ? sg("status.stopped") : sg("status.error")}
</div>
</div>
<CardDescription>Tailscale VPN Gateway</CardDescription>
<CardDescription>{sg("installed.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Status info */}
{isRunning && appStatus.uptime_seconds > 0 && (
<div className="text-xs text-muted-foreground">
Uptime: {formatUptime(appStatus.uptime_seconds)}
{sg("installed.uptime")}: {formatUptime(appStatus.uptime_seconds)}
</div>
)}
@@ -1018,7 +1038,7 @@ export function SecureGatewaySetup() {
) : (
<Play className="h-4 w-4 mr-1" />
)}
Start
{sg("actions.start")}
</Button>
)}
{isRunning && (
@@ -1034,7 +1054,7 @@ export function SecureGatewaySetup() {
) : (
<Square className="h-4 w-4 mr-1" />
)}
Stop
{sg("actions.stop")}
</Button>
<Button
size="sm"
@@ -1047,7 +1067,7 @@ export function SecureGatewaySetup() {
) : (
<RotateCw className="h-4 w-4 mr-1" />
)}
Restart
{sg("actions.restart")}
</Button>
</>
)}
@@ -1060,7 +1080,7 @@ export function SecureGatewaySetup() {
}}
>
<FileText className="h-4 w-4 mr-1" />
Logs
{sg("actions.logs")}
</Button>
<Button
size="sm"
@@ -1070,7 +1090,7 @@ export function SecureGatewaySetup() {
disabled={actionLoading !== null}
>
<Trash2 className="h-4 w-4 mr-1" />
Remove
{sg("actions.remove")}
</Button>
</div>
@@ -1083,9 +1103,9 @@ export function SecureGatewaySetup() {
<>
<div className="flex items-center justify-between gap-2">
<div className="text-xs text-muted-foreground">
Last checked: {formatLastChecked(updateInfo.last_checked_iso)} ·{" "}
{sg("updates.lastChecked")}: {formatLastChecked(updateInfo.last_checked_iso)} ·{" "}
<span className="text-purple-400 font-medium">
Tailscale v{updateInfo.latest_version} available
{sg("updates.tailscaleAvailable", { version: updateInfo.latest_version || "" })}
</span>
</div>
</div>
@@ -1101,24 +1121,23 @@ export function SecureGatewaySetup() {
<ArrowUpCircle className="h-4 w-4 mr-1.5" />
)}
{updateApplying
? "Updating"
: `Update to v${updateInfo.latest_version}`}
? sg("updates.updating")
: sg("updates.updateToVersion", { version: updateInfo.latest_version || "" })}
</Button>
{updateInfo.packages && updateInfo.packages.length > 1 && (
<div className="text-[11px] text-muted-foreground">
+{updateInfo.packages.length - 1} other package
{updateInfo.packages.length > 2 ? "s" : ""} pending in the container
{sg("updates.otherPackagesPending", { count: updateInfo.packages.length - 1 })}
</div>
)}
</>
) : (
<div className="text-xs text-muted-foreground">
Last checked: {formatLastChecked(updateInfo.last_checked_iso)}
{sg("updates.lastChecked")}: {formatLastChecked(updateInfo.last_checked_iso)}
{updateInfo.current_version
? ` · Tailscale v${updateInfo.current_version}`
: ""}
{" · "}
<span className="text-green-500/80">No updates available</span>
<span className="text-green-500/80">{sg("updates.noneAvailable")}</span>
</div>
)}
{updateError && (
@@ -1146,7 +1165,7 @@ export function SecureGatewaySetup() {
className="text-xs h-7 px-2"
>
<Key className="h-3 w-3 mr-1" />
Update Auth Key
{sg("authKey.update")}
</Button>
<a
href="https://login.tailscale.com/admin/machines"
@@ -1154,7 +1173,7 @@ export function SecureGatewaySetup() {
rel="noopener noreferrer"
className="text-xs text-cyan-500 hover:text-cyan-400 inline-flex items-center gap-1"
>
Open Tailscale Admin <ExternalLink className="h-3 w-3" />
{sg("tailscale.openAdmin")} <ExternalLink className="h-3 w-3" />
</a>
</div>
</CardContent>
@@ -1164,8 +1183,8 @@ export function SecureGatewaySetup() {
<Dialog open={showLogs} onOpenChange={setShowLogs}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Secure Gateway Logs</DialogTitle>
<DialogDescription>Recent container logs</DialogDescription>
<DialogTitle>{sg("logs.title")}</DialogTitle>
<DialogDescription>{sg("logs.description")}</DialogDescription>
</DialogHeader>
<div className="bg-black/50 rounded-lg p-4 max-h-96 overflow-auto">
{logsLoading ? (
@@ -1174,14 +1193,14 @@ export function SecureGatewaySetup() {
</div>
) : (
<pre className="text-xs font-mono text-green-400 whitespace-pre-wrap">
{logs || "No logs available"}
{logs || sg("logs.empty")}
</pre>
)}
</div>
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={loadLogs}>
<RotateCw className="h-4 w-4 mr-1" />
Refresh
{t("actions.refresh")}
</Button>
</div>
</DialogContent>
@@ -1191,14 +1210,14 @@ export function SecureGatewaySetup() {
<Dialog open={showRemoveConfirm} onOpenChange={setShowRemoveConfirm}>
<DialogContent>
<DialogHeader>
<DialogTitle>Remove Secure Gateway?</DialogTitle>
<DialogTitle>{sg("remove.title")}</DialogTitle>
<DialogDescription>
This will stop and remove the gateway container. Your Tailscale state will be preserved for re-deployment.
{sg("remove.description")}
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setShowRemoveConfirm(false)}>
Cancel
{t("actions.cancel")}
</Button>
<Button
variant="destructive"
@@ -1210,7 +1229,7 @@ export function SecureGatewaySetup() {
) : (
<Trash2 className="h-4 w-4 mr-1" />
)}
Remove
{sg("actions.remove")}
</Button>
</div>
</DialogContent>
@@ -1228,16 +1247,16 @@ export function SecureGatewaySetup() {
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Key className="h-5 w-5 text-cyan-500" />
Update Auth Key
{sg("authKey.update")}
</DialogTitle>
<DialogDescription>
Enter a new Tailscale auth key to re-authenticate the gateway. This is useful if your previous key has expired.
{sg("authKey.description")}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">New Auth Key</label>
<label className="text-sm font-medium">{sg("authKey.newKey")}</label>
<Input
type="password"
value={newAuthKey}
@@ -1246,14 +1265,14 @@ export function SecureGatewaySetup() {
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
Generate a new key at{" "}
{sg("authKey.generateAt")}{" "}
<a
href="https://login.tailscale.com/admin/settings/keys"
target="_blank"
rel="noopener noreferrer"
className="text-cyan-500 hover:text-cyan-400 underline"
>
Tailscale Admin &gt; Settings &gt; Keys
{sg("authKey.adminKeys")}
</a>
</p>
</div>
@@ -1267,7 +1286,7 @@ export function SecureGatewaySetup() {
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setShowUpdateAuthKey(false)}>
Cancel
{t("actions.cancel")}
</Button>
<Button
onClick={handleUpdateAuthKey}
@@ -1279,7 +1298,7 @@ export function SecureGatewaySetup() {
) : (
<Key className="h-4 w-4 mr-2" />
)}
Update Key
{sg("authKey.updateKey")}
</Button>
</div>
</DialogContent>
@@ -1291,10 +1310,10 @@ export function SecureGatewaySetup() {
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-green-500" />
Gateway Deployed Successfully
{sg("postDeploy.title")}
</DialogTitle>
<DialogDescription>
One more step to complete the setup
{sg("postDeploy.description")}
</DialogDescription>
</DialogHeader>
@@ -1302,17 +1321,17 @@ export function SecureGatewaySetup() {
<div className="bg-cyan-500/10 border border-cyan-500/20 rounded-lg p-4">
<p className="text-sm font-medium text-cyan-400 flex items-center gap-2 mb-2">
<Info className="h-4 w-4" />
Next Step: Approve in Tailscale Admin
{sg("postDeploy.nextStep")}
</p>
<p className="text-sm text-muted-foreground mb-3">
You need to approve the following settings in your Tailscale admin console for them to take effect:
{sg("postDeploy.approveDescription")}
</p>
<ul className="space-y-2 text-sm">
{deployedConfig.advertise_routes?.length > 0 && (
<li className="flex items-start gap-2">
<Network className="h-4 w-4 text-cyan-500 mt-0.5 flex-shrink-0" />
<div>
<span className="font-medium">Subnet Routes:</span>
<span className="font-medium">{sg("postDeploy.subnetRoutes")}:</span>
<span className="text-muted-foreground ml-1">
{deployedConfig.advertise_routes.join(", ")}
</span>
@@ -1323,9 +1342,9 @@ export function SecureGatewaySetup() {
<li className="flex items-start gap-2">
<Globe className="h-4 w-4 text-cyan-500 mt-0.5 flex-shrink-0" />
<div>
<span className="font-medium">Exit Node:</span>
<span className="font-medium">{sg("postDeploy.exitNode")}:</span>
<span className="text-muted-foreground ml-1">
Route all internet traffic
{sg("postDeploy.routeAllTraffic")}
</span>
</div>
</li>
@@ -1334,30 +1353,30 @@ export function SecureGatewaySetup() {
</div>
<div className="bg-muted/30 rounded-lg p-4 space-y-2">
<p className="text-sm font-medium">How to approve:</p>
<p className="text-sm font-medium">{sg("postDeploy.howToApprove")}</p>
<ol className="text-sm text-muted-foreground space-y-2 list-decimal list-inside">
<li>Click the button below to open Tailscale Admin</li>
<li>Find <span className="font-mono text-cyan-400">{deployedConfig.hostname || "proxmox-gateway"}</span> in the machines list</li>
<li>Click on it to open machine details</li>
<li>In the <strong>Subnets</strong> section, click <strong>Edit</strong> and enable the route</li>
<li>{sg("postDeploy.stepOpenAdmin")}</li>
<li>{sg("postDeploy.stepFindBefore")} <span className="font-mono text-cyan-400">{deployedConfig.hostname || "proxmox-gateway"}</span> {sg("postDeploy.stepFindAfter")}</li>
<li>{sg("postDeploy.stepOpenDetails")}</li>
<li>{sg("postDeploy.stepSubnetsBefore")} <strong>Subnets</strong> {sg("postDeploy.stepSubnetsMiddle")} <strong>Edit</strong> {sg("postDeploy.stepSubnetsAfter")}</li>
{deployedConfig.exit_node && (
<li>In <strong>Routing Settings</strong>, enable <strong>Exit Node</strong></li>
<li>{sg("postDeploy.stepRoutingBefore")} <strong>Routing Settings</strong>, {sg("postDeploy.stepRoutingMiddle")} <strong>Exit Node</strong></li>
)}
</ol>
</div>
<div className="bg-green-500/10 border border-green-500/20 rounded-lg p-3">
<p className="text-xs text-green-400">
Once approved, you can access your Proxmox host at{" "}
<span className="font-mono">{deployedConfig.advertise_routes?.[0]?.replace("/32", "") || hostIp}:8006</span> (Proxmox UI) or{" "}
<span className="font-mono">{deployedConfig.advertise_routes?.[0]?.replace("/32", "") || hostIp}:8008</span> (ProxMenux Monitor) from any device with Tailscale.
{sg("postDeploy.accessAfterApproval")}{" "}
<span className="font-mono">{deployedConfig.advertise_routes?.[0]?.replace("/32", "") || hostIp}:8006</span> (Proxmox UI) {sg("postDeploy.or")}{" "}
<span className="font-mono">{deployedConfig.advertise_routes?.[0]?.replace("/32", "") || hostIp}:8008</span> (ProxMenux Monitor) {sg("postDeploy.fromAnyDevice")}
</p>
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setShowPostDeployInfo(false)}>
I{"'"}ll do it later
{sg("postDeploy.doLater")}
</Button>
<Button
onClick={() => {
@@ -1366,7 +1385,7 @@ export function SecureGatewaySetup() {
}}
className="bg-cyan-600 hover:bg-cyan-700"
>
Open Tailscale Admin
{sg("tailscale.openAdmin")}
<ExternalLink className="h-4 w-4 ml-2" />
</Button>
</div>
@@ -1383,13 +1402,13 @@ export function SecureGatewaySetup() {
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-cyan-500" />
<CardTitle className="text-base">Secure Gateway</CardTitle>
<CardTitle className="text-base">{sg("title")}</CardTitle>
</div>
<CardDescription>VPN access without opening ports</CardDescription>
<CardDescription>{sg("notInstalled.subtitle")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
Deploy a Tailscale VPN gateway for secure remote access to your Proxmox infrastructure. No port forwarding required.
{sg("notInstalled.description")}
</p>
<Button
@@ -1397,7 +1416,7 @@ export function SecureGatewaySetup() {
className="bg-cyan-600 hover:bg-cyan-700"
>
<ShieldCheck className="h-4 w-4 mr-2" />
Deploy Secure Gateway
{sg("notInstalled.deploy")}
</Button>
</CardContent>
</Card>
@@ -1418,7 +1437,7 @@ export function SecureGatewaySetup() {
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-cyan-500" />
Secure Gateway Setup
{sg("wizard.setupTitle")}
</DialogTitle>
</DialogHeader>
@@ -1465,7 +1484,7 @@ export function SecureGatewaySetup() {
}}
disabled={currentStep === 0 || deploying}
>
Back
{sg("actions.back")}
</Button>
{currentStep < wizardSteps.length - 1 ? (
@@ -1480,7 +1499,7 @@ export function SecureGatewaySetup() {
}}
className="bg-cyan-600 hover:bg-cyan-700"
>
Continue
{sg("actions.continue")}
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
) : (
@@ -1492,12 +1511,12 @@ export function SecureGatewaySetup() {
{deploying ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Deploying...
{sg("actions.deploying")}
</>
) : (
<>
<Play className="h-4 w-4 mr-2" />
Deploy Gateway
{sg("actions.deployGateway")}
</>
)}
</Button>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+11 -8
View File
@@ -1,6 +1,7 @@
"use client"
import { LayoutDashboard, HardDrive, Network, Server, Cpu, FileText, SettingsIcon, Terminal } from "lucide-react"
import { useT } from "../lib/i18n/provider"
const menuItems = [
{ name: "Overview", href: "/", icon: LayoutDashboard },
@@ -14,6 +15,8 @@ const menuItems = [
]
const Sidebar = ({ currentPath, setOpen }) => {
const t = useT()
const handleNavigation = (tabName: string) => {
// Dispatch custom event to change tab in dashboard
const event = new CustomEvent("changeTab", { detail: { tab: tabName } })
@@ -32,7 +35,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
}`}
>
<LayoutDashboard className="h-5 w-5" />
<span>Overview</span>
<span>{t("navigation.overview")}</span>
</button>
<button
@@ -44,7 +47,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
}`}
>
<HardDrive className="h-5 w-5" />
<span>Storage</span>
<span>{t("navigation.storage")}</span>
</button>
<button
@@ -56,7 +59,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
}`}
>
<Network className="h-5 w-5" />
<span>Network</span>
<span>{t("navigation.network")}</span>
</button>
<button
@@ -68,7 +71,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
}`}
>
<Server className="h-5 w-5" />
<span>VMs & LXCs</span>
<span>{t("navigation.virtualMachines")}</span>
</button>
<button
@@ -80,7 +83,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
}`}
>
<Cpu className="h-5 w-5" />
<span>Hardware</span>
<span>{t("navigation.hardware")}</span>
</button>
<button
@@ -92,7 +95,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
}`}
>
<FileText className="h-5 w-5" />
<span>System Logs</span>
<span>{t("navigation.systemLogs")}</span>
</button>
<button
@@ -104,7 +107,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
}`}
>
<Terminal className="h-5 w-5" />
<span>Terminal</span>
<span>{t("navigation.terminal")}</span>
</button>
<button
@@ -116,7 +119,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
}`}
>
<SettingsIcon className="h-5 w-5" />
<span>Settings</span>
<span>{t("navigation.settings")}</span>
</button>
</div>
)
File diff suppressed because it is too large Load Diff
+130 -122
View File
@@ -29,6 +29,7 @@ import {
} from "lucide-react"
import { useState, useEffect, useMemo } from "react"
import { API_PORT, fetchApi, getApiUrl, getAuthToken } from "@/lib/api-config"
import { useT } from "@/lib/i18n/provider"
interface Backup {
volid: string
@@ -88,6 +89,7 @@ interface CombinedLogEntry {
}
export function SystemLogs() {
const t = useT()
const [logs, setLogs] = useState<SystemLog[]>([])
const [backups, setBackups] = useState<Backup[]>([])
const [events, setEvents] = useState<Event[]>([])
@@ -150,7 +152,7 @@ export function SystemLogs() {
setLogsCounts(countsRes)
} catch (err) {
if (cancelled) return
setError("Failed to connect to server")
setError(t("systemLogs.errors.connectFailed"))
} finally {
if (!cancelled) setLoading(false)
}
@@ -177,7 +179,7 @@ export function SystemLogs() {
const data = await fetchApi<{ logs?: SystemLog[] } | SystemLog[]>(apiUrl)
return Array.isArray(data) ? data : data.logs || []
} catch {
setError("Failed to load logs. Please try again.")
setError(t("systemLogs.errors.loadFailed"))
return []
}
}
@@ -203,26 +205,26 @@ export function SystemLogs() {
// Generate log content
const logContent = [
`Proxmox System Logs & Events Export`,
`Generated: ${new Date().toISOString()}`,
`Total Entries: ${filteredCombinedLogs.length.toLocaleString()}`,
t("systemLogs.export.title"),
`${t("systemLogs.fields.generated")}: ${new Date().toISOString()}`,
`${t("systemLogs.cards.totalEntries")}: ${filteredCombinedLogs.length.toLocaleString()}`,
``,
`Filters Applied:`,
`- Date Range: ${dateFilter === "custom" ? `${customDays} days ago` : `${dateFilter} day(s) ago`}`,
`- Level: ${levelFilter === "all" ? "All Levels" : levelFilter}`,
`- Service: ${serviceFilter === "all" ? "All Services" : serviceFilter}`,
`- Search: ${searchTerm || "None"}`,
`${t("systemLogs.export.filtersApplied")}:`,
`- ${t("systemLogs.filters.dateRange")}: ${t("systemLogs.filters.daysAgo", { count: dateFilter === "custom" ? customDays : dateFilter })}`,
`- ${t("systemLogs.fields.level")}: ${levelFilter === "all" ? t("systemLogs.filters.allLevels") : levelLabel(levelFilter)}`,
`- ${t("systemLogs.fields.service")}: ${serviceFilter === "all" ? t("systemLogs.filters.allServices") : serviceFilter}`,
`- ${t("systemLogs.fields.search")}: ${searchTerm || t("systemLogs.fields.none")}`,
``,
`${"=".repeat(80)}`,
``,
...filteredCombinedLogs.map((log) => {
const lines = [
`[${log.timestamp}] ${log.level.toUpperCase()} - ${log.service}${log.isEvent ? " [EVENT]" : ""}`,
`Message: ${log.message}`,
`Source: ${log.source}`,
`[${log.timestamp}] ${levelLabel(log.level)} - ${log.service}${log.isEvent ? ` [${t("systemLogs.badges.event")}]` : ""}`,
`${t("systemLogs.fields.message")}: ${log.message}`,
`${t("systemLogs.fields.source")}: ${log.source}`,
]
if (log.pid) lines.push(`PID: ${log.pid}`)
if (log.hostname) lines.push(`Hostname: ${log.hostname}`)
if (log.hostname) lines.push(`${t("systemLogs.fields.hostname")}: ${log.hostname}`)
lines.push(`${"-".repeat(80)}`)
return lines.join("\n")
}),
@@ -273,13 +275,13 @@ export function SystemLogs() {
// Download the complete task log
const blob = new Blob(
[
`Proxmox Task Log\n`,
`${t("systemLogs.download.taskLog")}\n`,
`================\n\n`,
`UPID: ${upid}\n`,
`Timestamp: ${notification.timestamp}\n`,
`Service: ${notification.service}\n`,
`Source: ${notification.source}\n\n`,
`Complete Task Log:\n`,
`${t("systemLogs.fields.timestamp")}: ${notification.timestamp}\n`,
`${t("systemLogs.fields.service")}: ${notification.service}\n`,
`${t("systemLogs.fields.source")}: ${notification.source}\n\n`,
`${t("systemLogs.download.completeTaskLog")}:\n`,
`${"-".repeat(80)}\n`,
`${taskLog}\n`,
],
@@ -303,13 +305,13 @@ export function SystemLogs() {
// If no UPID or failed to fetch task log, download the notification message
const blob = new Blob(
[
`Notification Details\n`,
`${t("systemLogs.modals.notificationTitle")}\n`,
`==================\n\n`,
`Timestamp: ${notification.timestamp}\n`,
`Type: ${notification.type}\n`,
`Service: ${notification.service}\n`,
`Source: ${notification.source}\n\n`,
`Complete Message:\n`,
`${t("systemLogs.fields.timestamp")}: ${notification.timestamp}\n`,
`${t("systemLogs.fields.type")}: ${notification.type}\n`,
`${t("systemLogs.fields.service")}: ${notification.service}\n`,
`${t("systemLogs.fields.source")}: ${notification.source}\n\n`,
`${t("systemLogs.download.completeMessage")}:\n`,
`${notification.message}\n`,
],
{ type: "text/plain" },
@@ -342,7 +344,7 @@ export function SystemLogs() {
level: event.level,
service: event.type,
message: `${event.type}${event.vmid ? ` (VM/CT ${event.vmid})` : ""} - ${event.status}`,
source: `Node: ${event.node} • User: ${event.user}`,
source: `${t("systemLogs.fields.node")}: ${event.node}${t("systemLogs.fields.user")}: ${event.user}`,
isEvent: true,
eventData: event,
sortTimestamp: new Date(event.starttime).getTime(),
@@ -392,6 +394,12 @@ export function SystemLogs() {
}
}
const levelLabel = (level: string) => {
const key = `systemLogs.levels.${safeToLowerCase(level)}`
const translated = t(key)
return translated === key ? String(level).toUpperCase() : translated
}
const getLevelIcon = (level: string) => {
switch (level) {
case "error":
@@ -551,15 +559,15 @@ export function SystemLogs() {
const getSectionLabel = (section: string) => {
switch (section) {
case "logs":
return "Logs"
return t("systemLogs.tabs.logs")
case "events":
return "Events"
return t("systemLogs.tabs.events")
case "backups":
return "Backups"
return t("systemLogs.tabs.backups")
case "notifications":
return "Notifications"
return t("systemLogs.tabs.notifications")
default:
return "Logs"
return t("systemLogs.tabs.logs")
}
}
@@ -570,8 +578,8 @@ export function SystemLogs() {
<div className="h-12 w-12 rounded-full border-2 border-muted"></div>
<div className="absolute inset-0 h-12 w-12 rounded-full border-2 border-transparent border-t-primary animate-spin"></div>
</div>
<div className="text-sm font-medium text-foreground">Loading logs...</div>
<p className="text-xs text-muted-foreground">Fetching system logs and events</p>
<div className="text-sm font-medium text-foreground">{t("systemLogs.loading.title")}</div>
<p className="text-xs text-muted-foreground">{t("systemLogs.loading.description")}</p>
</div>
)
}
@@ -585,7 +593,7 @@ export function SystemLogs() {
<div className="h-10 w-10 rounded-full border-2 border-muted"></div>
<div className="absolute inset-0 h-10 w-10 rounded-full border-2 border-transparent border-t-primary animate-spin"></div>
</div>
<div className="text-sm font-medium text-foreground">Loading logs...</div>
<div className="text-sm font-medium text-foreground">{t("systemLogs.loading.title")}</div>
</div>
</div>
)}
@@ -594,42 +602,42 @@ export function SystemLogs() {
<div className="grid grid-cols-2 xl:grid-cols-4 gap-4 xl:gap-6">
<Card className="bg-card border-border">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Total Entries</CardTitle>
<CardTitle className="text-sm font-medium text-muted-foreground">{t("systemLogs.cards.totalEntries")}</CardTitle>
<FileText className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-foreground">
{(logsCounts?.total ?? 0).toLocaleString("fr-FR")}
</div>
<p className="text-xs text-muted-foreground mt-2">In selected range</p>
<p className="text-xs text-muted-foreground mt-2">{t("systemLogs.cards.selectedRange")}</p>
</CardContent>
</Card>
<Card className="bg-card border-border">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Errors</CardTitle>
<CardTitle className="text-sm font-medium text-muted-foreground">{t("systemLogs.cards.errors")}</CardTitle>
<XCircle className="h-4 w-4 text-red-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-500">{(logsCounts?.errors ?? 0).toLocaleString("fr-FR")}</div>
<p className="text-xs text-muted-foreground mt-2">Requires attention</p>
<p className="text-xs text-muted-foreground mt-2">{t("systemLogs.cards.requiresAttention")}</p>
</CardContent>
</Card>
<Card className="bg-card border-border">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Warnings</CardTitle>
<CardTitle className="text-sm font-medium text-muted-foreground">{t("systemLogs.cards.warnings")}</CardTitle>
<AlertTriangle className="h-4 w-4 text-yellow-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-yellow-500">{(logsCounts?.warnings ?? 0).toLocaleString("fr-FR")}</div>
<p className="text-xs text-muted-foreground mt-2">Monitor closely</p>
<p className="text-xs text-muted-foreground mt-2">{t("systemLogs.cards.monitorClosely")}</p>
</CardContent>
</Card>
<Card className="bg-card border-border">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Backups</CardTitle>
<CardTitle className="text-sm font-medium text-muted-foreground">{t("systemLogs.cards.backups")}</CardTitle>
<Database className="h-4 w-4 text-blue-500" />
</CardHeader>
<CardContent>
@@ -645,11 +653,11 @@ export function SystemLogs() {
<div className="flex items-center justify-between">
<CardTitle className="text-foreground flex items-center">
<Activity className="h-5 w-5 mr-2" />
System Logs & Events
{t("systemLogs.title")}
</CardTitle>
<Button variant="outline" size="sm" onClick={refreshData} disabled={loading}>
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? "animate-spin" : ""}`} />
Refresh
{t("actions.refresh")}
</Button>
</div>
</CardHeader>
@@ -658,18 +666,18 @@ export function SystemLogs() {
<TabsList className="hidden md:grid w-full grid-cols-3">
<TabsTrigger value="logs" className="data-[state=active]:bg-blue-500 data-[state=active]:text-white">
<Terminal className="h-4 w-4 mr-2" />
Logs
{t("systemLogs.tabs.logs")}
</TabsTrigger>
<TabsTrigger value="backups" className="data-[state=active]:bg-blue-500 data-[state=active]:text-white">
<Database className="h-4 w-4 mr-2" />
Backups
{t("systemLogs.tabs.backups")}
</TabsTrigger>
<TabsTrigger
value="notifications"
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white"
>
<Bell className="h-4 w-4 mr-2" />
Notifications
{t("systemLogs.tabs.notifications")}
</TabsTrigger>
</TabsList>
@@ -691,7 +699,7 @@ export function SystemLogs() {
</SheetTrigger>
<SheetContent side="left" className="w-[280px]">
<SheetHeader>
<SheetTitle>Sections</SheetTitle>
<SheetTitle>{t("systemLogs.sections")}</SheetTitle>
</SheetHeader>
<div className="mt-6 space-y-2">
<Button
@@ -707,7 +715,7 @@ export function SystemLogs() {
}}
>
<Terminal className="h-4 w-4" />
Logs
{t("systemLogs.tabs.logs")}
</Button>
<Button
variant="ghost"
@@ -722,7 +730,7 @@ export function SystemLogs() {
}}
>
<Database className="h-4 w-4" />
Backups
{t("systemLogs.tabs.backups")}
</Button>
<Button
variant="ghost"
@@ -737,7 +745,7 @@ export function SystemLogs() {
}}
>
<Bell className="h-4 w-4" />
Notifications
{t("systemLogs.tabs.notifications")}
</Button>
</div>
</SheetContent>
@@ -751,7 +759,7 @@ export function SystemLogs() {
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search logs & events..."
placeholder={t("systemLogs.filters.searchPlaceholder")}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 bg-background border-border"
@@ -761,22 +769,22 @@ export function SystemLogs() {
<Select value={dateFilter} onValueChange={setDateFilter}>
<SelectTrigger className="w-full sm:w-[180px] bg-background border-border">
<SelectValue placeholder="Time range" />
<SelectValue placeholder={t("systemLogs.filters.timeRange")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 day ago</SelectItem>
<SelectItem value="3">3 days ago</SelectItem>
<SelectItem value="7">1 week ago</SelectItem>
<SelectItem value="14">2 weeks ago</SelectItem>
<SelectItem value="30">1 month ago</SelectItem>
<SelectItem value="custom">Custom days</SelectItem>
<SelectItem value="1">{t("systemLogs.filters.oneDay")}</SelectItem>
<SelectItem value="3">{t("systemLogs.filters.threeDays")}</SelectItem>
<SelectItem value="7">{t("systemLogs.filters.oneWeek")}</SelectItem>
<SelectItem value="14">{t("systemLogs.filters.twoWeeks")}</SelectItem>
<SelectItem value="30">{t("systemLogs.filters.oneMonth")}</SelectItem>
<SelectItem value="custom">{t("systemLogs.filters.customDays")}</SelectItem>
</SelectContent>
</Select>
{dateFilter === "custom" && (
<Input
type="number"
placeholder="Days ago"
placeholder={t("systemLogs.filters.daysAgoPlaceholder")}
value={customDays}
onChange={(e) => setCustomDays(e.target.value)}
className="w-full sm:w-[120px] bg-background border-border"
@@ -786,23 +794,23 @@ export function SystemLogs() {
<Select value={levelFilter} onValueChange={setLevelFilter}>
<SelectTrigger className="w-full sm:w-[180px] bg-background border-border">
<SelectValue placeholder="Filter by level" />
<SelectValue placeholder={t("systemLogs.filters.byLevel")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Levels</SelectItem>
<SelectItem value="error">Error</SelectItem>
<SelectItem value="warning">Warning</SelectItem>
<SelectItem value="info">Info</SelectItem>
<SelectItem value="all">{t("systemLogs.filters.allLevels")}</SelectItem>
<SelectItem value="error">{t("systemLogs.levels.error")}</SelectItem>
<SelectItem value="warning">{t("systemLogs.levels.warning")}</SelectItem>
<SelectItem value="info">{t("systemLogs.levels.info")}</SelectItem>
</SelectContent>
</Select>
<Select value={serviceFilter} onValueChange={setServiceFilter}>
<SelectTrigger className="w-full sm:w-[180px] bg-background border-border">
<SelectValue placeholder="Filter by service" />
<SelectValue placeholder={t("systemLogs.filters.byService")} />
</SelectTrigger>
<SelectContent>
<SelectItem key="service-all" value="all">
All Services
{t("systemLogs.filters.allServices")}
</SelectItem>
{uniqueServices.map((service) => (
<SelectItem key={`service-${service}`} value={service}>
@@ -814,7 +822,7 @@ export function SystemLogs() {
<Button variant="outline" className="border-border bg-transparent" onClick={handleDownloadLogs}>
<Download className="h-4 w-4 mr-2" />
Export Logs
{t("systemLogs.export.button")}
</Button>
</div>
@@ -844,12 +852,12 @@ export function SystemLogs() {
<div className="flex-shrink-0 flex gap-2 flex-wrap">
<Badge variant="outline" className={getLevelColor(log.level)}>
{getLevelIcon(log.level)}
{log.level.toUpperCase()}
{levelLabel(log.level)}
</Badge>
{log.eventData && (
<Badge variant="outline" className="bg-purple-500/10 text-purple-500 border-purple-500/20">
<Activity className="h-3 w-3 mr-1" />
EVENT
{t("systemLogs.badges.event")}
</Badge>
)}
</div>
@@ -866,9 +874,9 @@ export function SystemLogs() {
</div>
<div className="text-xs text-muted-foreground truncate overflow-hidden">
{log.source}
{log.unit && log.unit !== log.service && `Unit: ${log.unit}`}
{log.unit && log.unit !== log.service && `${t("systemLogs.fields.unit")}: ${log.unit}`}
{log.pid && ` • PID: ${log.pid}`}
{log.hostname && `Host: ${log.hostname}`}
{log.hostname && `${t("systemLogs.fields.host")}: ${log.hostname}`}
</div>
</div>
</div>
@@ -878,7 +886,7 @@ export function SystemLogs() {
{displayedLogs.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
<FileText className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>No logs found matching your criteria</p>
<p>{t("systemLogs.empty.logs")}</p>
</div>
)}
@@ -890,7 +898,7 @@ export function SystemLogs() {
className="border-border"
>
<RefreshCw className="h-4 w-4 mr-2" />
Load More ({filteredCombinedLogs.length - displayedLogsCount} remaining)
{t("systemLogs.loadMore", { count: filteredCombinedLogs.length - displayedLogsCount })}
</Button>
</div>
)}
@@ -906,19 +914,19 @@ export function SystemLogs() {
<Card className="bg-card/50 border-border">
<CardContent className="pt-6">
<div className="text-2xl font-bold text-cyan-500">{backupStats.qemu}</div>
<p className="text-xs text-muted-foreground mt-1">VM Backups</p>
<p className="text-xs text-muted-foreground mt-1">{t("systemLogs.backups.vm")}</p>
</CardContent>
</Card>
<Card className="bg-card/50 border-border">
<CardContent className="pt-6">
<div className="text-2xl font-bold text-orange-500">{backupStats.lxc}</div>
<p className="text-xs text-muted-foreground mt-1">LXC Backups</p>
<p className="text-xs text-muted-foreground mt-1">{t("systemLogs.backups.lxc")}</p>
</CardContent>
</Card>
<Card className="bg-card/50 border-border hidden md:block">
<CardContent className="pt-6">
<div className="text-2xl font-bold text-foreground">{formatBytes(backupStats.totalSize)}</div>
<p className="text-xs text-muted-foreground mt-1">Total Size</p>
<p className="text-xs text-muted-foreground mt-1">{t("systemLogs.backups.totalSize")}</p>
</CardContent>
</Card>
</div>
@@ -927,7 +935,7 @@ export function SystemLogs() {
<Card className="bg-card/50 border-border md:hidden">
<CardContent className="pt-6">
<div className="text-2xl font-bold text-foreground">{formatBytes(backupStats.totalSize)}</div>
<p className="text-xs text-muted-foreground mt-1">Total Size</p>
<p className="text-xs text-muted-foreground mt-1">{t("systemLogs.backups.totalSize")}</p>
</CardContent>
</Card>
</div>
@@ -967,7 +975,7 @@ export function SystemLogs() {
{backup.size_human}
</Badge>
</div>
<div className="text-xs text-muted-foreground mb-1 truncate">Storage: {backup.storage}</div>
<div className="text-xs text-muted-foreground mb-1 truncate">{t("systemLogs.fields.storage")}: {backup.storage}</div>
<div className="text-xs text-muted-foreground flex items-center">
<Calendar className="h-3 w-3 mr-1 flex-shrink-0" />
<span className="truncate">{backup.created}</span>
@@ -980,7 +988,7 @@ export function SystemLogs() {
{backups.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
<Database className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>No backups found</p>
<p>{t("systemLogs.empty.backups")}</p>
</div>
)}
</div>
@@ -1006,12 +1014,12 @@ export function SystemLogs() {
>
<div className="flex-shrink-0 flex gap-2 flex-wrap">
<Badge variant="outline" className={getNotificationTypeColor(notification.type)}>
{(notification.type || "unknown").toUpperCase()}
{notification.type ? levelLabel(notification.type) : t("app.unknown")}
</Badge>
<Badge variant="outline" className={getNotificationSourceColor(notification.source)}>
{notification.source === "task-log" && <Activity className="h-3 w-3 mr-1" />}
{notification.source === "journal" && <FileText className="h-3 w-3 mr-1" />}
{(notification.source || "unknown").toUpperCase()}
{notification.source ? notification.source.toUpperCase() : t("app.unknown")}
</Badge>
</div>
@@ -1026,7 +1034,7 @@ export function SystemLogs() {
{notification.message}
</div>
<div className="text-xs text-muted-foreground break-words overflow-hidden">
Service: {notification.service} Source: {notification.source}
{t("systemLogs.fields.service")}: {notification.service} {t("systemLogs.fields.source")}: {notification.source}
</div>
</div>
</div>
@@ -1036,7 +1044,7 @@ export function SystemLogs() {
{notifications.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
<Bell className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>No notifications found</p>
<p>{t("systemLogs.empty.notifications")}</p>
</div>
)}
</div>
@@ -1051,55 +1059,55 @@ export function SystemLogs() {
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
Log Details
{t("systemLogs.modals.logTitle")}
</DialogTitle>
<DialogDescription>Complete information about this log entry</DialogDescription>
<DialogDescription>{t("systemLogs.modals.logDescription")}</DialogDescription>
</DialogHeader>
{selectedLog && (
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<div className="text-sm font-medium text-muted-foreground mb-1">Level</div>
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.level")}</div>
<Badge variant="outline" className={getLevelColor(selectedLog.level)}>
{getLevelIcon(selectedLog.level)}
{selectedLog.level.toUpperCase()}
{levelLabel(selectedLog.level)}
</Badge>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground mb-1">Service</div>
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.service")}</div>
<div className="text-sm text-foreground break-all overflow-hidden">{selectedLog.service}</div>
</div>
<div className="sm:col-span-2">
<div className="text-sm font-medium text-muted-foreground mb-1">Timestamp</div>
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.timestamp")}</div>
<div className="text-sm text-foreground font-mono break-all overflow-hidden">
{selectedLog.timestamp}
</div>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground mb-1">Source</div>
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.source")}</div>
<div className="text-sm text-foreground break-all overflow-hidden">{selectedLog.source}</div>
</div>
{selectedLog.unit && (
<div>
<div className="text-sm font-medium text-muted-foreground mb-1">Systemd Unit</div>
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.systemdUnit")}</div>
<div className="text-sm text-foreground font-mono break-all overflow-hidden">{selectedLog.unit}</div>
</div>
)}
{selectedLog.pid && (
<div>
<div className="text-sm font-medium text-muted-foreground mb-1">Process ID</div>
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.processId")}</div>
<div className="text-sm text-foreground font-mono">{selectedLog.pid}</div>
</div>
)}
{selectedLog.hostname && (
<div className="sm:col-span-2">
<div className="text-sm font-medium text-muted-foreground mb-1">Hostname</div>
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.hostname")}</div>
<div className="text-sm text-foreground break-all overflow-hidden">{selectedLog.hostname}</div>
</div>
)}
</div>
<div>
<div className="text-sm font-medium text-muted-foreground mb-2">Message</div>
<div className="text-sm font-medium text-muted-foreground mb-2">{t("systemLogs.fields.message")}</div>
<div className="p-4 rounded-lg bg-muted/50 border border-border overflow-hidden">
<pre className="text-sm text-foreground whitespace-pre-wrap break-all overflow-hidden">
{selectedLog.message}
@@ -1116,37 +1124,37 @@ export function SystemLogs() {
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Activity className="h-5 w-5" />
Event Details
{t("systemLogs.modals.eventTitle")}
</DialogTitle>
<DialogDescription>Complete information about this event</DialogDescription>
<DialogDescription>{t("systemLogs.modals.eventDescription")}</DialogDescription>
</DialogHeader>
{selectedEvent && (
<div className="space-y-4">
<div className="flex gap-2">
<Badge variant="outline" className={getLevelColor(selectedEvent.level)}>
{getLevelIcon(selectedEvent.level)}
{selectedEvent.level.toUpperCase()}
{levelLabel(selectedEvent.level)}
</Badge>
<Badge variant="outline" className="bg-purple-500/10 text-purple-500 border-purple-500/20">
<Activity className="h-3 w-3 mr-1" />
EVENT
{t("systemLogs.badges.event")}
</Badge>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="sm:col-span-2">
<div className="text-sm font-medium text-muted-foreground mb-1">Message</div>
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.message")}</div>
<div className="text-sm text-foreground break-words">{selectedEvent.status}</div>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground mb-1">Type</div>
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.type")}</div>
<div className="text-sm text-foreground break-words">{selectedEvent.type}</div>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground mb-1">Node</div>
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.node")}</div>
<div className="text-sm text-foreground">{selectedEvent.node}</div>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground mb-1">User</div>
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.user")}</div>
<div className="text-sm text-foreground break-words">{selectedEvent.user}</div>
</div>
{selectedEvent.vmid && (
@@ -1156,15 +1164,15 @@ export function SystemLogs() {
</div>
)}
<div>
<div className="text-sm font-medium text-muted-foreground mb-1">Duration</div>
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.duration")}</div>
<div className="text-sm text-foreground">{selectedEvent.duration}</div>
</div>
<div className="sm:col-span-2">
<div className="text-sm font-medium text-muted-foreground mb-1">Start Time</div>
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.startTime")}</div>
<div className="text-sm text-foreground break-words">{selectedEvent.starttime}</div>
</div>
<div className="sm:col-span-2">
<div className="text-sm font-medium text-muted-foreground mb-1">End Time</div>
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.endTime")}</div>
<div className="text-sm text-foreground break-words">{selectedEvent.endtime}</div>
</div>
</div>
@@ -1186,31 +1194,31 @@ export function SystemLogs() {
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Database className="h-5 w-5" />
Backup Details
{t("systemLogs.modals.backupTitle")}
</DialogTitle>
<DialogDescription>Complete information about this backup</DialogDescription>
<DialogDescription>{t("systemLogs.modals.backupDescription")}</DialogDescription>
</DialogHeader>
{selectedBackup && (
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<div className="text-sm font-medium text-muted-foreground mb-1">Type</div>
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.type")}</div>
<Badge variant="outline" className={getBackupTypeColor(selectedBackup.volid)}>
{getBackupTypeLabel(selectedBackup.volid)}
</Badge>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground mb-1">Storage Type</div>
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.storageType")}</div>
<Badge variant="outline" className={getBackupStorageColor(selectedBackup.volid)}>
{getBackupStorageLabel(selectedBackup.volid)}
</Badge>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground mb-1">Storage</div>
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.storage")}</div>
<div className="text-sm text-foreground break-words">{selectedBackup.storage}</div>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground mb-1">Size</div>
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.size")}</div>
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
{selectedBackup.size_human}
</Badge>
@@ -1222,12 +1230,12 @@ export function SystemLogs() {
</div>
)}
<div className="sm:col-span-2">
<div className="text-sm font-medium text-muted-foreground mb-1">Created</div>
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.created")}</div>
<div className="text-sm text-foreground break-words">{selectedBackup.created}</div>
</div>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground mb-2">Volume ID</div>
<div className="text-sm font-medium text-muted-foreground mb-2">{t("systemLogs.fields.volumeId")}</div>
<div className="p-4 rounded-lg bg-muted/50 border border-border">
<pre className="text-sm text-foreground font-mono whitespace-pre-wrap break-all">
{selectedBackup.volid}
@@ -1244,38 +1252,38 @@ export function SystemLogs() {
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg pr-8">
<Bell className="h-4 w-4 sm:h-5 sm:w-5 flex-shrink-0" />
<span className="truncate">Notification Details</span>
<span className="truncate">{t("systemLogs.modals.notificationTitle")}</span>
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
Complete information about this notification
{t("systemLogs.modals.notificationDescription")}
</DialogDescription>
</DialogHeader>
{selectedNotification && (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 sm:gap-4">
<div>
<div className="text-xs sm:text-sm font-medium text-muted-foreground mb-1.5">Type</div>
<div className="text-xs sm:text-sm font-medium text-muted-foreground mb-1.5">{t("systemLogs.fields.type")}</div>
<Badge variant="outline" className={`${getNotificationTypeColor(selectedNotification.type)} text-xs`}>
{(selectedNotification.type || "unknown").toUpperCase()}
{selectedNotification.type ? levelLabel(selectedNotification.type) : t("app.unknown")}
</Badge>
</div>
<div>
<div className="text-xs sm:text-sm font-medium text-muted-foreground mb-1.5">Timestamp</div>
<div className="text-xs sm:text-sm font-medium text-muted-foreground mb-1.5">{t("systemLogs.fields.timestamp")}</div>
<div className="text-xs sm:text-sm text-foreground font-mono break-all">
{selectedNotification.timestamp}
</div>
</div>
<div>
<div className="text-xs sm:text-sm font-medium text-muted-foreground mb-1.5">Service</div>
<div className="text-xs sm:text-sm font-medium text-muted-foreground mb-1.5">{t("systemLogs.fields.service")}</div>
<div className="text-xs sm:text-sm text-foreground break-words">{selectedNotification.service}</div>
</div>
<div>
<div className="text-xs sm:text-sm font-medium text-muted-foreground mb-1.5">Source</div>
<div className="text-xs sm:text-sm font-medium text-muted-foreground mb-1.5">{t("systemLogs.fields.source")}</div>
<div className="text-xs sm:text-sm text-foreground break-words">{selectedNotification.source}</div>
</div>
</div>
<div>
<div className="text-xs sm:text-sm font-medium text-muted-foreground mb-2">Message</div>
<div className="text-xs sm:text-sm font-medium text-muted-foreground mb-2">{t("systemLogs.fields.message")}</div>
<div className="p-3 sm:p-4 rounded-lg bg-muted/50 border border-border max-h-[180px] sm:max-h-[300px] overflow-y-auto">
<pre className="text-xs sm:text-sm text-foreground whitespace-pre-wrap break-all font-mono">
{selectedNotification.message}
@@ -1289,7 +1297,7 @@ export function SystemLogs() {
className="border-border w-full sm:w-auto text-xs sm:text-sm h-9 sm:h-10"
>
<Download className="h-3 w-3 sm:h-4 sm:w-4 mr-2" />
<span className="truncate">Download Complete Message</span>
<span className="truncate">{t("systemLogs.download.completeMessageButton")}</span>
</Button>
</div>
</div>
+91 -96
View File
@@ -13,6 +13,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ".
import { fetchApi } from "../lib/api-config"
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
import { formatStorage } from "../lib/utils"
import { useT } from "../lib/i18n/provider"
import { Area, AreaChart, ResponsiveContainer } from "recharts"
interface TempDataPoint {
@@ -171,6 +172,7 @@ const getUnitsSettings = (): "Bytes" | "Bits" => {
}
export function SystemOverview() {
const t = useT()
const [systemData, setSystemData] = useState<SystemData | null>(null)
const [vmData, setVmData] = useState<VMData[]>([])
const [storageData, setStorageData] = useState<StorageData | null>(null)
@@ -205,7 +207,7 @@ export function SystemOverview() {
setHasAttemptedLoad(true)
if (!systemResult) {
setError("Flask server not available. Please ensure the server is running.")
setError(t("overview.errors.serverUnavailableDescription"))
return
}
@@ -261,7 +263,7 @@ export function SystemOverview() {
clearInterval(networkInterval)
window.removeEventListener("networkUnitChanged" as any, handleUnitChange)
}
}, [])
}, [t])
if (!hasAttemptedLoad || loadingStates.system) {
return (
@@ -270,8 +272,8 @@ export function SystemOverview() {
<div className="h-12 w-12 rounded-full border-2 border-muted"></div>
<div className="absolute inset-0 h-12 w-12 rounded-full border-2 border-transparent border-t-primary animate-spin"></div>
</div>
<div className="text-sm font-medium text-foreground">Loading system overview...</div>
<p className="text-xs text-muted-foreground">Fetching system status and metrics</p>
<div className="text-sm font-medium text-foreground">{t("overview.loadingTitle")}</div>
<p className="text-xs text-muted-foreground">{t("overview.loadingDescription")}</p>
</div>
)
}
@@ -284,9 +286,9 @@ export function SystemOverview() {
<div className="flex items-center gap-3 text-red-600">
<AlertCircle className="h-6 w-6" />
<div>
<div className="font-semibold text-lg mb-1">Flask Server Not Available</div>
<div className="font-semibold text-lg mb-1">{t("overview.errors.serverUnavailableTitle")}</div>
<div className="text-sm">
{error || "Unable to connect to the Flask server. Please ensure the server is running and try again."}
{error || t("overview.errors.serverUnavailableDescription")}
</div>
</div>
</div>
@@ -305,14 +307,14 @@ export function SystemOverview() {
}
const getTemperatureStatus = (temp: number) => {
if (temp === 0) return { status: "N/A", color: "bg-gray-500/10 text-gray-500 border-gray-500/20" }
if (temp < 60) return { status: "Normal", color: "bg-green-500/10 text-green-500 border-green-500/20" }
if (temp < 75) return { status: "Warm", color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" }
return { status: "Hot", color: "bg-red-500/10 text-red-500 border-red-500/20" }
if (temp === 0) return { status: t("app.notAvailable"), color: "bg-gray-500/10 text-gray-500 border-gray-500/20" }
if (temp < 60) return { status: t("status.normal"), color: "bg-green-500/10 text-green-500 border-green-500/20" }
if (temp < 75) return { status: t("status.warm"), color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" }
return { status: t("status.hot"), color: "bg-red-500/10 text-red-500 border-red-500/20" }
}
const formatUptime = (seconds: number) => {
if (!seconds || seconds === 0) return "Stopped"
if (!seconds || seconds === 0) return t("status.stopped")
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
@@ -322,6 +324,19 @@ export function SystemOverview() {
return `${minutes}m`
}
const formatSystemUptime = (uptime: string) => {
const trimmed = uptime?.trim()
if (!trimmed) return t("app.unknown")
const dayMatch = trimmed.match(/^(\d+)\s+days?,\s*(.+)$/)
if (!dayMatch) return trimmed
const days = Number(dayMatch[1])
const dayKey = days === 1 ? "dayOne" : days >= 2 && days <= 4 ? "dayFew" : "dayMany"
return `${t(`overview.uptimeDuration.${dayKey}`, { count: days })}, ${dayMatch[2]}`
}
const formatBytes = (bytes: number) => {
return (bytes / 1024 ** 3).toFixed(2)
}
@@ -346,40 +361,14 @@ export function SystemOverview() {
const getLoadStatus = (load: number, cores: number) => {
if (load < cores) {
return { status: "Normal", color: "bg-green-500/10 text-green-500 border-green-500/20" }
return { status: t("status.normal"), color: "bg-green-500/10 text-green-500 border-green-500/20" }
} else if (load < cores * 1.5) {
return { status: "Moderate", color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" }
return { status: t("status.moderate"), color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" }
} else {
return { status: "High", color: "bg-red-500/10 text-red-500 border-red-500/20" }
return { status: t("status.high"), color: "bg-red-500/10 text-red-500 border-red-500/20" }
}
}
const systemAlerts = []
if (systemData.available_updates && systemData.available_updates > 0) {
systemAlerts.push({
type: "warning",
message: `${systemData.available_updates} updates available`,
})
}
if (vmStats.stopped > 0) {
systemAlerts.push({
type: "info",
message: `${vmStats.stopped} VM${vmStats.stopped > 1 ? "s" : ""} stopped`,
})
}
if (systemData.temperature > 75) {
systemAlerts.push({
type: "warning",
message: "High temperature detected",
})
}
if (localStorage && localStorage.percent > 90) {
systemAlerts.push({
type: "warning",
message: "System storage almost full",
})
}
const loadStatus = getLoadStatus(systemData.load_average[0], systemData.cpu_cores || 8)
const getTimeframeLabel = (timeframe: string): string => {
@@ -406,10 +395,10 @@ export function SystemOverview() {
<Card
className="bg-card border-border cursor-pointer hover:bg-white/5 transition-colors"
onClick={() => setCpuProcModalOpen(true)}
title="View top processes by CPU"
title={t("overview.topProcessesCpu")}
>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">CPU Usage</CardTitle>
<CardTitle className="text-sm font-medium text-muted-foreground">{t("overview.cpuUsage")}</CardTitle>
<div className="flex items-center gap-1 text-muted-foreground">
<Cpu className="h-4 w-4" />
<ChevronRight className="h-4 w-4 opacity-60" />
@@ -427,7 +416,7 @@ export function SystemOverview() {
<div className="flex-1 space-y-2 min-w-0">
<div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">User</span>
<span className="text-muted-foreground">{t("overview.user")}</span>
<span className="font-medium font-mono whitespace-nowrap">{systemData.cpu_user !== undefined ? `${Math.round(systemData.cpu_user)}%` : '—'}</span>
</div>
<div className="mt-1 h-1.5 bg-muted rounded-full overflow-hidden">
@@ -436,7 +425,7 @@ export function SystemOverview() {
</div>
<div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">System</span>
<span className="text-muted-foreground">{t("overview.system")}</span>
<span className="font-medium font-mono whitespace-nowrap">{systemData.cpu_system !== undefined ? `${Math.round(systemData.cpu_system)}%` : '—'}</span>
</div>
<div className="mt-1 h-1.5 bg-muted rounded-full overflow-hidden">
@@ -444,7 +433,7 @@ export function SystemOverview() {
</div>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Cores</span>
<span className="text-muted-foreground">{t("overview.cores")}</span>
<span className="font-medium font-mono whitespace-nowrap">{systemData.cpu_cores ?? '—'}{systemData.cpu_threads ? `/${systemData.cpu_threads}` : ''}</span>
</div>
</div>
@@ -456,10 +445,10 @@ export function SystemOverview() {
<Card
className="bg-card border-border cursor-pointer hover:bg-white/5 transition-colors"
onClick={() => setMemProcModalOpen(true)}
title="View top processes by memory"
title={t("overview.topProcessesMemory")}
>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Memory</CardTitle>
<CardTitle className="text-sm font-medium text-muted-foreground">{t("overview.memory")}</CardTitle>
<div className="flex items-center gap-1 text-muted-foreground">
<MemoryStick className="h-4 w-4" />
<ChevronRight className="h-4 w-4 opacity-60" />
@@ -477,7 +466,7 @@ export function SystemOverview() {
<div className="flex-1 space-y-2 min-w-0">
<div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Used</span>
<span className="text-muted-foreground">{t("overview.used")}</span>
<span className="font-medium font-mono whitespace-nowrap">{systemData.memory_used.toFixed(1)}</span>
</div>
<div className="mt-1 h-1.5 bg-muted rounded-full overflow-hidden">
@@ -486,7 +475,7 @@ export function SystemOverview() {
</div>
<div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Cached</span>
<span className="text-muted-foreground">{t("overview.cached")}</span>
<span className="font-medium font-mono whitespace-nowrap">{systemData.memory_cached !== undefined ? systemData.memory_cached.toFixed(1) : '—'}</span>
</div>
<div className="mt-1 h-1.5 bg-muted rounded-full overflow-hidden">
@@ -494,7 +483,7 @@ export function SystemOverview() {
</div>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Total</span>
<span className="text-muted-foreground">{t("overview.total")}</span>
<span className="font-medium font-mono whitespace-nowrap">{systemData.memory_total.toFixed(0)} GB</span>
</div>
</div>
@@ -505,7 +494,7 @@ export function SystemOverview() {
{/* ── Active VM & LXC (preview restyle v2: pills mismo tamaño que "X running") ── */}
<Card className="bg-card border-border">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Active VM &amp; LXC</CardTitle>
<CardTitle className="text-sm font-medium text-muted-foreground">{t("overview.activeVmLxc")}</CardTitle>
<Server className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
@@ -521,13 +510,19 @@ export function SystemOverview() {
<span className="text-4xl font-bold leading-none text-foreground">{vmStats.running}</span>
<span className="text-lg font-medium ml-1 text-muted-foreground">/ {vmStats.vms + vmStats.lxc}</span>
</div>
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">{vmStats.running} running</Badge>
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
{t("overview.runningCount", { count: vmStats.running })}
</Badge>
</div>
<div className="mt-3 flex gap-1 flex-wrap">
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">{vmStats.vms} VMs</Badge>
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
{t("overview.vmsCount", { count: vmStats.vms })}
</Badge>
<Badge variant="outline" className="bg-blue-500/10 text-blue-500 border-blue-500/20">{vmStats.lxc} LXC</Badge>
{vmStats.stopped > 0 && (
<Badge variant="outline" className="bg-muted text-muted-foreground border-border">{vmStats.stopped} stopped</Badge>
<Badge variant="outline" className="bg-muted text-muted-foreground border-border">
{t("overview.stoppedCount", { count: vmStats.stopped })}
</Badge>
)}
</div>
</>
@@ -540,7 +535,7 @@ export function SystemOverview() {
onClick={() => systemData.temperature > 0 && setTempModalOpen(true)}
>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Temperature</CardTitle>
<CardTitle className="text-sm font-medium text-muted-foreground">{t("overview.temperature")}</CardTitle>
<div className="flex items-center gap-1 text-muted-foreground">
<Thermometer className="h-4 w-4" />
{systemData.temperature > 0 && (
@@ -551,7 +546,7 @@ export function SystemOverview() {
<CardContent>
<div className="flex items-center justify-between">
<span className="text-xl lg:text-2xl font-bold text-foreground">
{systemData.temperature === 0 ? "N/A" : `${Math.round(systemData.temperature * 10) / 10}°C`}
{systemData.temperature === 0 ? t("app.notAvailable") : `${Math.round(systemData.temperature * 10) / 10}°C`}
</span>
<Badge variant="outline" className={`${tempStatus.color}`}>
{tempStatus.status}
@@ -581,7 +576,7 @@ export function SystemOverview() {
</div>
) : (
<p className="text-xs text-muted-foreground mt-2">
{systemData.temperature === 0 ? "No sensor available" : "Collecting data..."}
{systemData.temperature === 0 ? t("overview.noSensorAvailable") : t("overview.collectingData")}
</p>
)}
</CardContent>
@@ -613,7 +608,7 @@ export function SystemOverview() {
<CardHeader>
<CardTitle className="text-foreground flex items-center">
<HardDrive className="h-5 w-5 mr-2" />
Storage Overview
{t("overview.storageOverview")}
</CardTitle>
</CardHeader>
<CardContent>
@@ -634,7 +629,7 @@ export function SystemOverview() {
return totalCapacity > 0 ? (
<div className="space-y-2 pb-4 border-b-2 border-border">
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-foreground">Total Node Capacity:</span>
<span className="text-sm font-medium text-foreground">{t("overview.totalNodeCapacity")}</span>
<span className="text-lg font-bold text-foreground">
{formatStorage(totalCapacity)}
</span>
@@ -646,13 +641,13 @@ export function SystemOverview() {
<div className="flex justify-between items-center mt-1">
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground">
Used:{" "}
{t("overview.used")}:{" "}
<span className="font-semibold text-foreground">
{formatStorage(totalUsed)}
</span>
</span>
<span className="text-xs text-muted-foreground">
Free:{" "}
{t("overview.free")}:{" "}
<span className="font-semibold text-green-500">
{formatStorage(totalAvailable)}
</span>
@@ -666,28 +661,28 @@ export function SystemOverview() {
<div className="space-y-2 pb-3 border-b border-border">
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Total Capacity:</span>
<span className="text-sm text-muted-foreground">{t("overview.totalCapacity")}</span>
<span className="text-lg font-semibold text-foreground">{storageData.total} TB</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Physical Disks:</span>
<span className="text-sm text-muted-foreground">{t("overview.physicalDisks")}</span>
<span className="text-sm font-semibold text-foreground">
{storageData.disk_count} disk{storageData.disk_count !== 1 ? "s" : ""}
{storageData.disk_count} {storageData.disk_count === 1 ? t("overview.diskSingular") : t("overview.diskPlural")}
</span>
</div>
</div>
{vmLxcStorages && vmLxcStorages.length > 0 ? (
<div className="space-y-2 pb-3 border-b border-border">
<div className="text-xs font-medium text-muted-foreground mb-2">VM/LXC Storage</div>
<div className="text-xs font-medium text-muted-foreground mb-2">{t("overview.vmLxcStorage")}</div>
<div className="flex justify-between items-center">
<span className="text-xs text-muted-foreground">Used:</span>
<span className="text-xs text-muted-foreground">{t("overview.used")}:</span>
<span className="text-sm font-semibold text-foreground">
{formatStorage(vmLxcStorageUsed)}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-xs text-muted-foreground">Available:</span>
<span className="text-xs text-muted-foreground">{t("overview.available")}:</span>
<span className="text-sm font-semibold text-green-500">
{formatStorage(vmLxcStorageAvailable)}
</span>
@@ -702,28 +697,28 @@ export function SystemOverview() {
</div>
{vmLxcStorages.length > 1 && (
<div className="text-xs text-muted-foreground mt-1">
{vmLxcStorages.length} storage volume{vmLxcStorages.length > 1 ? "s" : ""}
{vmLxcStorages.length} {vmLxcStorages.length === 1 ? t("overview.storageVolumeSingular") : t("overview.storageVolumePlural")}
</div>
)}
</div>
) : (
<div className="space-y-2 pb-3 border-b border-border">
<div className="text-xs font-medium text-muted-foreground mb-2">VM/LXC Storage</div>
<div className="text-center py-4 text-muted-foreground text-sm">No VM/LXC storage configured</div>
<div className="text-xs font-medium text-muted-foreground mb-2">{t("overview.vmLxcStorage")}</div>
<div className="text-center py-4 text-muted-foreground text-sm">{t("overview.noVmLxcStorage")}</div>
</div>
)}
{localStorage && (
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground mb-2">Local Storage (System)</div>
<div className="text-xs font-medium text-muted-foreground mb-2">{t("overview.localStorageSystem")}</div>
<div className="flex justify-between items-center">
<span className="text-xs text-muted-foreground">Used:</span>
<span className="text-xs text-muted-foreground">{t("overview.used")}:</span>
<span className="text-sm font-semibold text-foreground">
{formatStorage(localStorage.used)}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-xs text-muted-foreground">Available:</span>
<span className="text-xs text-muted-foreground">{t("overview.available")}:</span>
<span className="text-sm font-semibold text-green-500">
{formatStorage(localStorage.available)}
</span>
@@ -740,7 +735,7 @@ export function SystemOverview() {
)}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">Storage data not available</div>
<div className="text-center py-8 text-muted-foreground">{t("overview.storageDataUnavailable")}</div>
)}
</CardContent>
</Card>
@@ -750,18 +745,18 @@ export function SystemOverview() {
<CardTitle className="text-foreground flex items-center justify-between">
<div className="flex items-center">
<Network className="h-5 w-5 mr-2" />
Network Overview
{t("overview.networkOverview")}
</div>
<Select value={networkTimeframe} onValueChange={setNetworkTimeframe}>
<SelectTrigger className="w-28 h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="hour">1 Hour</SelectItem>
<SelectItem value="day">24 Hours</SelectItem>
<SelectItem value="week">7 Days</SelectItem>
<SelectItem value="month">30 Days</SelectItem>
<SelectItem value="year">1 Year</SelectItem>
<SelectItem value="hour">{t("overview.timeframes.hour")}</SelectItem>
<SelectItem value="day">{t("overview.timeframes.day")}</SelectItem>
<SelectItem value="week">{t("overview.timeframes.week")}</SelectItem>
<SelectItem value="month">{t("overview.timeframes.month")}</SelectItem>
<SelectItem value="year">{t("overview.timeframes.year")}</SelectItem>
</SelectContent>
</Select>
</CardTitle>
@@ -776,7 +771,7 @@ export function SystemOverview() {
) : networkData ? (
<div className="space-y-4">
<div className="flex justify-between items-center pb-3 border-b border-border">
<span className="text-sm text-muted-foreground">Active Interfaces:</span>
<span className="text-sm text-muted-foreground">{t("overview.activeInterfaces")}</span>
<span className="text-lg font-semibold text-foreground">
{(networkData.physical_active_count || 0) + (networkData.bridge_active_count || 0)}
</span>
@@ -818,7 +813,7 @@ export function SystemOverview() {
<div className="pt-2 border-t border-border space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Received:</span>
<span className="text-sm text-muted-foreground">{t("overview.received")}</span>
<span className="text-lg font-semibold text-green-500 flex items-center gap-1">
{" "}
{networkUnit === "Bytes"
@@ -828,7 +823,7 @@ export function SystemOverview() {
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Sent:</span>
<span className="text-sm text-muted-foreground">{t("overview.sent")}</span>
<span className="text-lg font-semibold text-blue-500 flex items-center gap-1">
{" "}
{networkUnit === "Bytes"
@@ -848,7 +843,7 @@ export function SystemOverview() {
</div>
</div>
) : (
<div className="text-center py-8 text-muted-foreground">Network data not available</div>
<div className="text-center py-8 text-muted-foreground">{t("overview.networkDataUnavailable")}</div>
)}
</CardContent>
</Card>
@@ -859,27 +854,27 @@ export function SystemOverview() {
<CardHeader>
<CardTitle className="text-foreground flex items-center">
<Server className="h-5 w-5 mr-2" />
System Information
{t("overview.systemInformation")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex justify-between">
<span className="text-muted-foreground">Uptime:</span>
<span className="text-foreground">{systemData.uptime}</span>
<span className="text-muted-foreground">{t("overview.uptime")}</span>
<span className="text-foreground">{formatSystemUptime(systemData.uptime)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Proxmox Version:</span>
<span className="text-muted-foreground">{t("overview.proxmoxVersion")}</span>
<span className="text-foreground">{systemData.proxmox_version || "N/A"}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Kernel:</span>
<span className="text-muted-foreground">{t("overview.kernel")}</span>
<span className="text-foreground font-mono text-sm">{systemData.kernel_version || "Linux"}</span>
</div>
{systemData.available_updates !== undefined && systemData.available_updates > 0 && (
<div className="flex justify-between">
<span className="text-muted-foreground">Available Updates:</span>
<span className="text-muted-foreground">{t("overview.availableUpdates")}</span>
<Badge variant="outline" className="bg-yellow-500/10 text-yellow-500 border-yellow-500/20">
{systemData.available_updates} packages
{systemData.available_updates} {t("overview.packages")}
</Badge>
</div>
)}
@@ -890,13 +885,13 @@ export function SystemOverview() {
<CardHeader>
<CardTitle className="text-foreground flex items-center">
<Zap className="h-5 w-5 mr-2" />
System Overview
{t("overview.systemOverview")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex justify-between items-center pb-3 border-b border-border">
<div className="flex flex-col">
<span className="text-sm text-muted-foreground">Load Average (1m):</span>
<span className="text-sm text-muted-foreground">{t("overview.loadAverage1m")}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-lg font-semibold text-foreground font-mono">
@@ -909,17 +904,17 @@ export function SystemOverview() {
</div>
<div className="flex justify-between items-center pb-3 border-b border-border">
<span className="text-sm text-muted-foreground">CPU Threads:</span>
<span className="text-sm text-muted-foreground">{t("overview.cpuThreads")}</span>
<span className="text-lg font-semibold text-foreground">{systemData.cpu_threads || "N/A"}</span>
</div>
<div className="flex justify-between items-center pb-3 border-b border-border">
<span className="text-sm text-muted-foreground">Physical Disks:</span>
<span className="text-sm text-muted-foreground">{t("overview.physicalDisks")}</span>
<span className="text-lg font-semibold text-foreground">{storageData?.disk_count || "N/A"}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Network Interfaces:</span>
<span className="text-sm text-muted-foreground">{t("overview.networkInterfaces")}</span>
<span className="text-lg font-semibold text-foreground">
{networkData?.physical_total_count || networkData?.physical_interfaces?.length || "N/A"}
</span>
@@ -8,12 +8,13 @@ import { Thermometer, TrendingDown, TrendingUp, Minus } from "lucide-react"
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts"
import { useIsMobile } from "../hooks/use-mobile"
import { fetchApi } from "@/lib/api-config"
import { useT } from "@/lib/i18n/provider"
const TIMEFRAME_OPTIONS = [
{ value: "hour", label: "1 Hour" },
{ value: "day", label: "24 Hours" },
{ value: "week", label: "7 Days" },
{ value: "month", label: "30 Days" },
{ value: "hour", labelKey: "overview.timeframes.hour" },
{ value: "day", labelKey: "overview.timeframes.day" },
{ value: "week", labelKey: "overview.timeframes.week" },
{ value: "month", labelKey: "overview.timeframes.month" },
]
interface TempHistoryPoint {
@@ -70,6 +71,7 @@ const getStatusInfo = (temp: number) => {
}
export function TemperatureDetailModal({ open, onOpenChange, liveTemperature }: TemperatureDetailModalProps) {
const t = useT()
// Default to 24 h — matches the disk temperature modal and is the
// useful timeframe for spotting trends; the 1-h view rarely tells
// you anything that the live reading doesn't already show.
@@ -138,7 +140,7 @@ export function TemperatureDetailModal({ open, onOpenChange, liveTemperature }:
<div className="flex items-center justify-between pr-6">
<DialogTitle className="text-foreground flex items-center gap-2">
<Thermometer className="h-5 w-5" />
CPU Temperature
{t("details.temperature.title")}
</DialogTitle>
<Select value={timeframe} onValueChange={setTimeframe}>
<SelectTrigger className="w-[130px] bg-card border-border">
@@ -147,7 +149,7 @@ export function TemperatureDetailModal({ open, onOpenChange, liveTemperature }:
<SelectContent>
{TIMEFRAME_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
{t(opt.labelKey)}
</SelectItem>
))}
</SelectContent>
@@ -158,24 +160,24 @@ export function TemperatureDetailModal({ open, onOpenChange, liveTemperature }:
{/* Stats bar */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-3">
<div className={`rounded-lg p-3 text-center ${currentStatus.color}`}>
<div className="text-xs opacity-80 mb-1">Current</div>
<div className="text-xs opacity-80 mb-1">{t("details.temperature.current")}</div>
<div className="text-lg font-bold">{currentTemp}°C</div>
</div>
<div className="bg-muted/50 rounded-lg p-3 text-center">
<div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1">
<TrendingDown className="h-3 w-3" /> Min
<TrendingDown className="h-3 w-3" /> {t("details.temperature.min")}
</div>
<div className="text-lg font-bold text-green-500">{stats.min}°C</div>
</div>
<div className="bg-muted/50 rounded-lg p-3 text-center">
<div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1">
<Minus className="h-3 w-3" /> Avg
<Minus className="h-3 w-3" /> {t("details.temperature.avg")}
</div>
<div className="text-lg font-bold text-foreground">{stats.avg}°C</div>
</div>
<div className="bg-muted/50 rounded-lg p-3 text-center">
<div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1">
<TrendingUp className="h-3 w-3" /> Max
<TrendingUp className="h-3 w-3" /> {t("details.temperature.max")}
</div>
<div className="text-lg font-bold text-red-500">{stats.max}°C</div>
</div>
@@ -194,8 +196,8 @@ export function TemperatureDetailModal({ open, onOpenChange, liveTemperature }:
<div className="h-full flex items-center justify-center text-muted-foreground">
<div className="text-center">
<Thermometer className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>No temperature data available for this period</p>
<p className="text-sm mt-1">Data is collected every 60 seconds</p>
<p>{t("details.temperature.noData")}</p>
<p className="text-sm mt-1">{t("details.temperature.collectionHint")}</p>
</div>
</div>
) : (
@@ -228,7 +230,7 @@ export function TemperatureDetailModal({ open, onOpenChange, liveTemperature }:
<Area
type="monotone"
dataKey="value"
name="Temperature"
name={t("details.temperature.seriesName")}
stroke={chartColor}
strokeWidth={2}
fill="url(#tempGradient)"
+70 -115
View File
@@ -34,6 +34,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f
import { Input } from "@/components/ui/input"
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
import type { CheatSheetResult } from "@/lib/cheat-sheet-result" // Declare CheatSheetResult here
import { useT } from "@/lib/i18n/provider"
type TerminalPanelProps = {
websocketUrl?: string
@@ -78,74 +79,30 @@ function getApiUrl(endpoint?: string): string {
}
const proxmoxCommands = [
{ cmd: "pvesh get /nodes", desc: "List all Proxmox nodes" },
{ cmd: "pvesh get /nodes/{node}/qemu", desc: "List VMs on a node" },
{ cmd: "pvesh get /nodes/{node}/lxc", desc: "List LXC containers on a node" },
{ cmd: "pvesh get /nodes/{node}/storage", desc: "List storage on a node" },
{ cmd: "pvesh get /nodes/{node}/network", desc: "List network interfaces" },
{ cmd: "qm list", desc: "List all QEMU/KVM virtual machines" },
{ cmd: "qm start <vmid>", desc: "Start a virtual machine" },
{ cmd: "qm stop <vmid>", desc: "Stop a virtual machine" },
{ cmd: "qm shutdown <vmid>", desc: "Shutdown a virtual machine gracefully" },
{ cmd: "qm status <vmid>", desc: "Show VM status" },
{ cmd: "qm config <vmid>", desc: "Show VM configuration" },
{ cmd: "qm snapshot <vmid> <snapname>", desc: "Create VM snapshot" },
{ cmd: "pct list", desc: "List all LXC containers" },
{ cmd: "pct start <vmid>", desc: "Start LXC container" },
{ cmd: "pct stop <vmid>", desc: "Stop LXC container" },
{ cmd: "pct enter <vmid>", desc: "Enter LXC container console" },
{ cmd: "pct config <vmid>", desc: "Show container configuration" },
{ cmd: "pvesm status", desc: "Show storage status" },
{ cmd: "pvesm list <storage>", desc: "List storage content" },
{ cmd: "pveperf", desc: "Test Proxmox system performance" },
{ cmd: "pveversion", desc: "Show Proxmox VE version" },
{ cmd: "systemctl status pve-cluster", desc: "Check cluster status" },
{ cmd: "pvecm status", desc: "Show cluster status" },
{ cmd: "pvecm nodes", desc: "List cluster nodes" },
{ cmd: "zpool status", desc: "Show ZFS pool status" },
{ cmd: "zpool list", desc: "List all ZFS pools" },
{ cmd: "zfs list", desc: "List all ZFS datasets" },
{ cmd: "ls -la", desc: "List all files with details" },
{ cmd: "cd /path/to/dir", desc: "Change directory" },
{ cmd: "mkdir dirname", desc: "Create new directory" },
{ cmd: "rm -rf dirname", desc: "Remove directory recursively" },
{ cmd: "cp source dest", desc: "Copy files or directories" },
{ cmd: "mv source dest", desc: "Move or rename files" },
{ cmd: "cat filename", desc: "Display file contents" },
{ cmd: "grep 'pattern' file", desc: "Search for pattern in file" },
{ cmd: "find . -name 'file'", desc: "Find files by name" },
{ cmd: "chmod 755 file", desc: "Change file permissions" },
{ cmd: "chown user:group file", desc: "Change file owner" },
{ cmd: "tar -xzf file.tar.gz", desc: "Extract tar.gz archive" },
{ cmd: "tar -czf archive.tar.gz dir/", desc: "Create tar.gz archive" },
{ cmd: "df -h", desc: "Show disk usage" },
{ cmd: "du -sh *", desc: "Show directory sizes" },
{ cmd: "free -h", desc: "Show memory usage" },
{ cmd: "top", desc: "Show running processes" },
{ cmd: "ps aux | grep process", desc: "Find running process" },
{ cmd: "kill -9 PID", desc: "Force kill process" },
{ cmd: "systemctl status service", desc: "Check service status" },
{ cmd: "systemctl start service", desc: "Start a service" },
{ cmd: "systemctl stop service", desc: "Stop a service" },
{ cmd: "systemctl restart service", desc: "Restart a service" },
{ cmd: "apt update && apt upgrade", desc: "Update Debian/Ubuntu packages" },
{ cmd: "apt install package", desc: "Install package on Debian/Ubuntu" },
{ cmd: "apt remove package", desc: "Remove package" },
{ cmd: "docker ps", desc: "List running containers" },
{ cmd: "docker images", desc: "List Docker images" },
{ cmd: "docker exec -it container bash", desc: "Enter container shell" },
{ cmd: "ip addr show", desc: "Show IP addresses" },
{ cmd: "ping host", desc: "Test network connectivity" },
{ cmd: "curl -I url", desc: "Get HTTP headers" },
{ cmd: "wget url", desc: "Download file from URL" },
{ cmd: "ssh user@host", desc: "Connect via SSH" },
{ cmd: "scp file user@host:/path", desc: "Copy file via SSH" },
{ cmd: "tail -f /var/log/syslog", desc: "Follow log file in real-time" },
{ cmd: "history", desc: "Show command history" },
{ cmd: "clear", desc: "Clear terminal screen" },
"pvesh get /nodes", "pvesh get /nodes/{node}/qemu", "pvesh get /nodes/{node}/lxc",
"pvesh get /nodes/{node}/storage", "pvesh get /nodes/{node}/network", "qm list",
"qm start <vmid>", "qm stop <vmid>", "qm shutdown <vmid>", "qm status <vmid>",
"qm config <vmid>", "qm snapshot <vmid> <snapname>", "pct list", "pct start <vmid>",
"pct stop <vmid>", "pct enter <vmid>", "pct config <vmid>", "pvesm status",
"pvesm list <storage>", "pveperf", "pveversion", "systemctl status pve-cluster",
"pvecm status", "pvecm nodes", "zpool status", "zpool list", "zfs list", "ls -la",
"cd /path/to/dir", "mkdir dirname", "rm -rf dirname", "cp source dest", "mv source dest",
"cat filename", "grep 'pattern' file", "find . -name 'file'", "chmod 755 file",
"chown user:group file", "tar -xzf file.tar.gz", "tar -czf archive.tar.gz dir/", "df -h",
"du -sh *", "free -h", "top", "ps aux | grep process", "kill -9 PID",
"systemctl status service", "systemctl start service", "systemctl stop service",
"systemctl restart service", "apt update && apt upgrade", "apt install package",
"apt remove package", "docker ps", "docker images", "docker exec -it container bash",
"ip addr show", "ping host", "curl -I url", "wget url", "ssh user@host",
"scp file user@host:/path", "tail -f /var/log/syslog", "history", "clear",
]
export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onClose }) => {
const t = useT()
const localizedCommands = proxmoxCommands.map((cmd, index) => ({
cmd,
desc: t(`terminal.commandDescriptions.${index}`),
}))
const [terminals, setTerminals] = useState<TerminalInstance[]>([])
const [activeTerminalId, setActiveTerminalId] = useState<string>("")
const [layout, setLayout] = useState<"single" | "grid">("grid")
@@ -154,7 +111,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
const [terminalHeight, setTerminalHeight] = useState<number>(500) // altura por defecto en px
const [searchModalOpen, setSearchModalOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState("")
const [filteredCommands, setFilteredCommands] = useState<Array<{ cmd: string; desc: string }>>(proxmoxCommands)
const [filteredCommands, setFilteredCommands] = useState<Array<{ cmd: string; desc: string }>>(localizedCommands)
const [isSearching, setIsSearching] = useState(false)
const [searchResults, setSearchResults] = useState<CheatSheetResult[]>([])
const [useOnline, setUseOnline] = useState(true)
@@ -272,7 +229,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
const searchCheatSh = async (query: string) => {
if (!query.trim()) {
setSearchResults([])
setFilteredCommands(proxmoxCommands)
setFilteredCommands(localizedCommands)
return
}
@@ -287,7 +244,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
})
if (!data.success || !data.examples || data.examples.length === 0) {
throw new Error("No examples found")
throw new Error(t("terminal.noExamplesFound"))
}
@@ -300,7 +257,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
setUseOnline(true)
setSearchResults(formattedResults)
} catch (error) {
const filtered = proxmoxCommands.filter(
const filtered = localizedCommands.filter(
(item) =>
item.cmd.toLowerCase().includes(query.toLowerCase()) ||
item.desc.toLowerCase().includes(query.toLowerCase()),
@@ -318,12 +275,12 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
searchCheatSh(searchQuery)
} else {
setSearchResults([])
setFilteredCommands(proxmoxCommands)
setFilteredCommands(localizedCommands)
}
}, 800)
return () => clearTimeout(debounce)
}, [searchQuery])
}, [searchQuery, t])
// Function to reconnect a terminal when connection is lost
// This is called when page visibility changes (user returns from another app)
@@ -332,7 +289,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
if (!terminal || !terminal.term) return
// Show reconnecting message
terminal.term.writeln('\r\n\x1b[33m[INFO] Reconnecting...\x1b[0m')
terminal.term.writeln(`\r\n\x1b[33m[INFO] ${t("terminal.reconnecting")}\x1b[0m`)
const wsUrl = websocketUrl || getWebSocketUrl()
// Append the single-use auth ticket so the backend handshake can validate.
@@ -358,7 +315,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
setTerminals((prev) =>
prev.map((t) => (t.id === terminalId ? { ...t, isConnected: true, ws, pingInterval } : t))
)
terminal.term.writeln('\r\n\x1b[32m[INFO] Reconnected successfully\x1b[0m')
terminal.term.writeln(`\r\n\x1b[32m[INFO] ${t("terminal.reconnected")}\x1b[0m`)
// Sync terminal size
if (terminal.fitAddon) {
@@ -384,7 +341,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
}
ws.onerror = () => {
terminal.term.writeln('\r\n\x1b[31m[ERROR] Reconnection failed\x1b[0m')
terminal.term.writeln(`\r\n\x1b[31m[ERROR] ${t("terminal.reconnectionFailed")}\x1b[0m`)
}
ws.onclose = () => {
@@ -397,7 +354,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
}
return t
}))
terminal.term.writeln('\r\n\x1b[33m[INFO] Connection closed\x1b[0m')
terminal.term.writeln(`\r\n\x1b[33m[INFO] ${t("terminal.connectionClosed")}\x1b[0m`)
}
terminal.term.onData((data: string) => {
@@ -415,7 +372,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
...prev,
{
id: newId,
title: `Terminal ${prev.length + 1}`,
title: t("terminal.terminalTitle", { number: prev.length + 1 }),
term: null,
ws: null,
isConnected: false,
@@ -570,8 +527,8 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
if (ws.readyState !== WebSocket.OPEN) {
connectionTimedOut = true
ws.close()
term.writeln('\x1b[31m[ERROR] Connection timeout. Please check your network and try again.\x1b[0m')
term.writeln('\x1b[33m[TIP] If using VPN, ensure the connection is stable.\x1b[0m')
term.writeln(`\x1b[31m[ERROR] ${t("terminal.connectionTimeout")}\x1b[0m`)
term.writeln(`\x1b[33m[TIP] ${t("terminal.vpnTip")}\x1b[0m`)
}
}, connectionTimeout)
@@ -636,7 +593,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
}))
// Only show error if not already shown by timeout
if (!connectionTimedOut) {
term.writeln("\r\n\x1b[31m[ERROR] WebSocket connection error\x1b[0m")
term.writeln(`\r\n\x1b[31m[ERROR] ${t("terminal.websocketError")}\x1b[0m`)
}
}
@@ -653,7 +610,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
}))
// Only show close message if not already shown by timeout
if (!connectionTimedOut) {
term.writeln("\r\n\x1b[33m[INFO] Connection closed\x1b[0m")
term.writeln(`\r\n\x1b[33m[INFO] ${t("terminal.connectionClosed")}\x1b[0m`)
}
}
@@ -816,9 +773,9 @@ const handleClose = () => {
<Activity className="h-5 w-5 text-blue-500" />
<div
className={`w-2 h-2 rounded-full ${activeTerminal?.isConnected ? "bg-green-500" : "bg-red-500"}`}
title={activeTerminal?.isConnected ? "Connected" : "Disconnected"}
title={activeTerminal?.isConnected ? t("terminal.connected") : t("terminal.disconnected")}
></div>
<span className="text-xs text-zinc-500">{terminals.length} / 4 terminals</span>
<span className="text-xs text-zinc-500">{t("terminal.terminalCount", { count: terminals.length })}</span>
</div>
<div className="flex gap-2">
@@ -829,7 +786,7 @@ const handleClose = () => {
variant="outline"
size="sm"
className={`h-8 px-2 ${layout === "single" ? "bg-blue-500/20 border-blue-500" : ""}`}
title="Vista apilada (filas)"
title={t("terminal.stackedLayout")}
>
<AlignJustify className="h-4 w-4" />
</Button>
@@ -838,7 +795,7 @@ const handleClose = () => {
variant="outline"
size="sm"
className={`h-8 px-2 ${layout === "grid" ? "bg-blue-500/20 border-blue-500" : ""}`}
title="Vista cuadrícula 2x2"
title={t("terminal.gridLayout")}
>
<Grid2X2 className="h-4 w-4" />
</Button>
@@ -852,7 +809,7 @@ const handleClose = () => {
className="h-8 gap-2 bg-green-600/20 hover:bg-green-600/30 border-green-600/50 text-green-400 disabled:opacity-50"
>
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">New</span>
<span className="hidden sm:inline">{t("terminal.new")}</span>
</Button>
<Button
onClick={() => setSearchModalOpen(true)}
@@ -862,7 +819,7 @@ const handleClose = () => {
className="h-8 gap-2 bg-blue-600/20 hover:bg-blue-600/30 border-blue-600/50 text-blue-400 disabled:opacity-50"
>
<Search className="h-4 w-4" />
<span className="hidden sm:inline">Search</span>
<span className="hidden sm:inline">{t("terminal.search")}</span>
</Button>
<Button
onClick={handleClear}
@@ -872,7 +829,7 @@ const handleClose = () => {
className="h-8 gap-2 bg-yellow-600/20 hover:bg-yellow-600/30 border-yellow-600/50 text-yellow-400 disabled:opacity-50"
>
<Trash2 className="h-4 w-4" />
<span className="hidden sm:inline">Clear</span>
<span className="hidden sm:inline">{t("terminal.clear")}</span>
</Button>
<Button
onClick={handleClose}
@@ -881,7 +838,7 @@ const handleClose = () => {
className="h-8 gap-2 bg-red-600/20 hover:bg-red-600/30 border-red-600/50 text-red-400"
>
<X className="h-4 w-4" />
<span className="hidden sm:inline">Close</span>
<span className="hidden sm:inline">{t("actions.close")}</span>
</Button>
</div>
</div>
@@ -1075,29 +1032,29 @@ const handleClose = () => {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="text-xs text-muted-foreground">Control Sequences</DropdownMenuLabel>
<DropdownMenuLabel className="text-xs text-muted-foreground">{t("scriptTerminal.controlSequences")}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => sendSequence("\x03")}>
<span className="font-mono text-xs mr-2">Ctrl+C</span>
<span className="text-muted-foreground text-xs">Cancel/Interrupt</span>
<span className="text-muted-foreground text-xs">{t("scriptTerminal.cancelInterrupt")}</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => sendSequence("\x18")}>
<span className="font-mono text-xs mr-2">Ctrl+X</span>
<span className="text-muted-foreground text-xs">Exit (nano)</span>
<span className="text-muted-foreground text-xs">{t("scriptTerminal.exitNano")}</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => sendSequence("\x12")}>
<span className="font-mono text-xs mr-2">Ctrl+R</span>
<span className="text-muted-foreground text-xs">Search history</span>
<span className="text-muted-foreground text-xs">{t("scriptTerminal.searchHistory")}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-muted-foreground">Clipboard</DropdownMenuLabel>
<DropdownMenuLabel className="text-xs text-muted-foreground">{t("scriptTerminal.clipboard")}</DropdownMenuLabel>
<DropdownMenuItem onSelect={() => { void handleCopy() }}>
<Copy className="h-3.5 w-3.5 mr-2" />
<span className="text-xs">Copy selection</span>
<span className="text-xs">{t("scriptTerminal.copySelection")}</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => { void handlePaste() }}>
<Clipboard className="h-3.5 w-3.5 mr-2" />
<span className="text-xs">Paste</span>
<span className="text-xs">{t("scriptTerminal.paste")}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -1107,22 +1064,22 @@ const handleClose = () => {
<Dialog open={searchModalOpen} onOpenChange={setSearchModalOpen}>
<DialogContent className="max-w-3xl max-h-[85vh] overflow-hidden flex flex-col">
<DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-4 border-b border-zinc-800">
<DialogTitle className="text-xl font-semibold">Search Commands</DialogTitle>
<DialogTitle className="text-xl font-semibold">{t("terminal.searchCommands")}</DialogTitle>
<div className="flex items-center gap-2">
<div
className={`w-2 h-2 rounded-full ${useOnline ? "bg-green-500" : "bg-red-500"}`}
title={useOnline ? "Online - Using cheat.sh API" : "Offline - Using local commands"}
title={useOnline ? t("terminal.onlineSource") : t("terminal.offlineSource")}
/>
</div>
</DialogHeader>
<DialogDescription className="sr-only">Search for Linux and Proxmox commands</DialogDescription>
<DialogDescription className="sr-only">{t("terminal.searchDescription")}</DialogDescription>
<div className="space-y-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
<Input
placeholder="Search commands... (e.g., tar, docker, qm, systemctl)"
placeholder={t("terminal.searchPlaceholder")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 bg-zinc-900 border-zinc-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 text-base"
@@ -1136,7 +1093,7 @@ const handleClose = () => {
{isSearching && (
<div className="text-center py-4 text-zinc-400">
<div className="animate-spin inline-block w-6 h-6 border-2 border-current border-t-transparent rounded-full mb-2" />
<p className="text-sm">Searching cheat.sh...</p>
<p className="text-sm">{t("terminal.searchingCheatSh")}</p>
</div>
)}
@@ -1164,7 +1121,7 @@ const handleClose = () => {
<div className="text-center py-2">
<p className="text-xs text-zinc-500">
<Lightbulb className="inline-block w-3 h-3 mr-1" />
Powered by cheat.sh
{t("terminal.poweredByCheatSh")}
</p>
</div>
</>
@@ -1190,13 +1147,13 @@ const handleClose = () => {
className="shrink-0 h-7 px-2 text-xs"
>
<Send className="h-3 w-3 mr-1" />
Send
{t("terminal.send")}
</Button>
</div>
</div>
))
) : !isSearching && !searchQuery && !useOnline ? (
proxmoxCommands.map((item, index) => (
localizedCommands.map((item, index) => (
<div
key={index}
onClick={() => sendToActiveTerminal(item.cmd)}
@@ -1217,7 +1174,7 @@ const handleClose = () => {
className="shrink-0 h-7 px-2 text-xs"
>
<Send className="h-3 w-3 mr-1" />
Send
{t("terminal.send")}
</Button>
</div>
</div>
@@ -1228,17 +1185,17 @@ const handleClose = () => {
<>
<Search className="w-12 h-12 text-zinc-600 mx-auto" />
<div>
<p className="text-zinc-400 font-medium">No results found for "{searchQuery}"</p>
<p className="text-xs text-zinc-500 mt-1">Try a different command or check your spelling</p>
<p className="text-zinc-400 font-medium">{t("terminal.noResults", { query: searchQuery })}</p>
<p className="text-xs text-zinc-500 mt-1">{t("terminal.tryDifferentSearch")}</p>
</div>
</>
) : (
<>
<Terminal className="w-12 h-12 text-zinc-600 mx-auto" />
<div>
<p className="text-zinc-400 font-medium mb-2">Search for any command</p>
<p className="text-zinc-400 font-medium mb-2">{t("terminal.searchAnyCommand")}</p>
<div className="text-sm text-zinc-500 space-y-1">
<p>Try searching for:</p>
<p>{t("terminal.trySearchingFor")}</p>
<div className="flex flex-wrap justify-center gap-2 mt-2">
{["tar", "grep", "docker", "qm", "systemctl"].map((cmd) => (
<code
@@ -1255,7 +1212,7 @@ const handleClose = () => {
{useOnline && (
<div className="flex items-center justify-center gap-2 text-xs text-zinc-600 mt-4">
<Lightbulb className="w-3 h-3" />
<span>Powered by cheat.sh</span>
<span>{t("terminal.poweredByCheatSh")}</span>
</div>
)}
</>
@@ -1264,13 +1221,11 @@ const handleClose = () => {
) : null}
</div>
<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>
{useOnline && searchResults.length > 0 && (
<div className="pt-2 border-t border-zinc-800 text-xs text-zinc-500 text-right">
<span className="text-zinc-600">{t("terminal.poweredByCheatSh")}</span>
</div>
{useOnline && searchResults.length > 0 && <span className="text-zinc-600">Powered by cheat.sh</span>}
</div>
)}
</div>
</DialogContent>
</Dialog>
+4 -2
View File
@@ -4,8 +4,10 @@ import { useTheme } from "next-themes"
import { useEffect, useState } from "react"
import { Button } from "./ui/button"
import { useT } from "../lib/i18n/provider"
export function ThemeToggle() {
const t = useT()
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
@@ -22,7 +24,7 @@ export function ThemeToggle() {
return (
<Button variant="outline" size="sm" className="border-border bg-transparent w-9 h-9">
<Sun className="h-4 w-4" />
<span className="sr-only">Toggle theme</span>
<span className="sr-only">{t("actions.toggleTheme")}</span>
</Button>
)
}
@@ -31,7 +33,7 @@ export function ThemeToggle() {
<Button variant="outline" size="sm" onClick={handleThemeToggle} className="border-border bg-transparent w-9 h-9">
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
<span className="sr-only">{t("actions.toggleTheme")}</span>
</Button>
)
}
+33 -32
View File
@@ -6,6 +6,7 @@ import { Input } from "./ui/input"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog"
import { AlertCircle, CheckCircle, Copy, Shield, Check } from "lucide-react"
import { getApiUrl } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
interface TwoFactorSetupProps {
open: boolean
@@ -14,6 +15,8 @@ interface TwoFactorSetupProps {
}
export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps) {
const t = useT()
const tf = (key: string) => t(`securityPage.twoFactorSetup.${key}`)
const [step, setStep] = useState(1)
const [qrCode, setQrCode] = useState("")
const [secret, setSecret] = useState("")
@@ -41,7 +44,7 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
const data = await response.json()
if (!response.ok) {
throw new Error(data.message || "Failed to setup 2FA")
throw new Error(data.message || tf("setupFailed"))
}
setQrCode(data.qr_code)
@@ -49,7 +52,7 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
setBackupCodes(data.backup_codes)
setStep(2)
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to setup 2FA")
setError(err instanceof Error ? err.message : tf("setupFailed"))
} finally {
setLoading(false)
}
@@ -57,7 +60,7 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
const handleVerify = async () => {
if (!verificationCode || verificationCode.length !== 6) {
setError("Please enter a 6-digit code")
setError(tf("enterSixDigitCode"))
return
}
@@ -78,12 +81,12 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
const data = await response.json()
if (!response.ok) {
throw new Error(data.message || "Invalid verification code")
throw new Error(data.message || tf("invalidCode"))
}
setStep(3)
} catch (err) {
setError(err instanceof Error ? err.message : "Verification failed")
setError(err instanceof Error ? err.message : tf("verificationFailed"))
} finally {
setLoading(false)
}
@@ -141,7 +144,7 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
// both the Clipboard API and execCommand may be locked down.
if (!ok) {
try {
window.prompt("Copy this value:", text)
window.prompt(tf("copyPrompt"), text)
ok = true
} catch {
// ignore
@@ -183,9 +186,9 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Shield className="h-5 w-5 text-blue-500" />
Setup Two-Factor Authentication
{tf("title")}
</DialogTitle>
<DialogDescription>Add an extra layer of security to your account</DialogDescription>
<DialogDescription>{tf("description")}</DialogDescription>
</DialogHeader>
{error && (
@@ -199,22 +202,21 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
<div className="space-y-4">
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-4">
<p className="text-sm text-blue-500">
Two-factor authentication (2FA) adds an extra layer of security by requiring a code from your
authentication app in addition to your password.
{tf("intro")}
</p>
</div>
<div className="space-y-2">
<h4 className="font-medium">You will need:</h4>
<h4 className="font-medium">{tf("youWillNeed")}</h4>
<ul className="text-sm text-muted-foreground space-y-1 list-disc list-inside">
<li>An authentication app (Google Authenticator, Authy, etc.)</li>
<li>Scan a QR code or enter a key manually</li>
<li>Store backup codes securely</li>
<li>{tf("needApp")}</li>
<li>{tf("needQrOrKey")}</li>
<li>{tf("needBackupCodes")}</li>
</ul>
</div>
<Button onClick={handleSetupStart} className="w-full bg-blue-500 hover:bg-blue-600" disabled={loading}>
{loading ? "Starting..." : "Start Setup"}
{loading ? tf("starting") : tf("startSetup")}
</Button>
</div>
)}
@@ -222,24 +224,24 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
{step === 2 && (
<div className="space-y-4">
<div className="space-y-2">
<h4 className="font-medium">1. Scan the QR code</h4>
<p className="text-sm text-muted-foreground">Open your authentication app and scan this QR code</p>
<h4 className="font-medium">{tf("scanTitle")}</h4>
<p className="text-sm text-muted-foreground">{tf("scanDescription")}</p>
{qrCode && (
<div className="flex justify-center p-4 bg-white rounded-lg">
<img src={qrCode || "/placeholder.svg"} alt="QR Code" width={200} height={200} className="rounded" />
<img src={qrCode || "/placeholder.svg"} alt={tf("qrCodeAlt")} width={200} height={200} className="rounded" />
</div>
)}
</div>
<div className="space-y-2">
<h4 className="font-medium">Or enter the key manually:</h4>
<h4 className="font-medium">{tf("manualKey")}</h4>
<div className="flex gap-2">
<Input value={secret} readOnly className="font-mono text-sm" />
<Button
variant="outline"
size="icon"
onClick={() => copyToClipboard(secret, "secret")}
title="Copy key"
title={tf("copyKey")}
>
{copiedSecret ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
</Button>
@@ -247,8 +249,8 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
</div>
<div className="space-y-2">
<h4 className="font-medium">2. Enter the verification code</h4>
<p className="text-sm text-muted-foreground">Enter the 6-digit code that appears in your app</p>
<h4 className="font-medium">{tf("verifyTitle")}</h4>
<p className="text-sm text-muted-foreground">{tf("verifyDescription")}</p>
<Input
type="text"
placeholder="000000"
@@ -262,10 +264,10 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
<div className="flex gap-2">
<Button onClick={handleVerify} className="flex-1 bg-blue-500 hover:bg-blue-600" disabled={loading}>
{loading ? "Verifying..." : "Verify and Enable"}
{loading ? tf("verifying") : tf("verifyAndEnable")}
</Button>
<Button onClick={handleClose} variant="outline" className="flex-1 bg-transparent" disabled={loading}>
Cancel
{t("actions.cancel")}
</Button>
</div>
</div>
@@ -276,30 +278,29 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
<div className="bg-green-500/10 border border-green-500/20 rounded-lg p-4 flex items-start gap-2">
<CheckCircle className="h-5 w-5 text-green-500 flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-green-500">2FA Enabled Successfully</p>
<p className="font-medium text-green-500">{tf("enabledTitle")}</p>
<p className="text-sm text-green-500 mt-1">
Your account is now protected with two-factor authentication
{tf("enabledDescription")}
</p>
</div>
</div>
<div className="space-y-2">
<h4 className="font-medium text-orange-500">Important: Save your backup codes</h4>
<h4 className="font-medium text-orange-500">{tf("saveCodesTitle")}</h4>
<p className="text-sm text-muted-foreground">
These codes will allow you to access your account if you lose access to your authentication app. Store
them in a safe place.
{tf("saveCodesDescription")}
</p>
<div className="bg-muted/50 rounded-lg p-4 space-y-2">
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-medium">Backup Codes</span>
<span className="text-sm font-medium">{tf("backupCodes")}</span>
<Button variant="outline" size="sm" onClick={() => copyToClipboard(backupCodes.join("\n"), "codes")}>
{copiedCodes ? (
<Check className="h-4 w-4 text-green-500 mr-2" />
) : (
<Copy className="h-4 w-4 mr-2" />
)}
Copy All
{tf("copyAll")}
</Button>
</div>
<div className="grid grid-cols-2 gap-2">
@@ -313,7 +314,7 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
</div>
<Button onClick={handleFinish} className="w-full bg-blue-500 hover:bg-blue-600">
Finish
{tf("finish")}
</Button>
</div>
)}
+27 -22
View File
@@ -4,6 +4,7 @@ import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { useT } from "@/lib/i18n/provider"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
@@ -34,28 +35,32 @@ const DialogContent = React.forwardRef<
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
hideClose?: boolean
}
>(({ className, children, hideClose, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
className,
)}
aria-describedby={props["aria-describedby"] || undefined}
{...props}
>
{children}
{!hideClose && (
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
))
>(({ className, children, hideClose, ...props }, ref) => {
const t = useT()
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
className,
)}
aria-describedby={props["aria-describedby"] || undefined}
{...props}
>
{children}
{!hideClose && (
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">{t("actions.close")}</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
})
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
+1 -1
View File
@@ -16,7 +16,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type,
// 1px blue ring + matching border so a focused input now sits at the
// same visual weight as the colored card selectors used elsewhere
// (Backend picker, etc.).
"flex h-10 w-full rounded-lg border border-input bg-background px-4 py-2 text-sm shadow-sm transition-all file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-blue-500 focus-visible:border-blue-500 disabled:cursor-not-allowed disabled:opacity-50 hover:border-ring/50",
"flex h-10 w-full rounded-lg border border-input bg-background px-4 py-2 text-sm shadow-sm transition-all file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground placeholder:opacity-40 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-blue-500 focus-visible:border-blue-500 disabled:cursor-not-allowed disabled:opacity-50 hover:border-ring/50",
className,
)}
ref={ref}
+17 -12
View File
@@ -5,6 +5,7 @@ import * as DialogPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { useT } from "@/lib/i18n/provider"
import { cn } from "@/lib/utils"
const Sheet = DialogPrimitive.Root
@@ -54,18 +55,22 @@ interface SheetContentProps
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<React.ElementRef<typeof DialogPrimitive.Content>, SheetContentProps>(
({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<DialogPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</SheetPortal>
),
({ side = "right", className, children, ...props }, ref) => {
const t = useT()
return (
<SheetPortal>
<SheetOverlay />
<DialogPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">{t("actions.close")}</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</SheetPortal>
)
},
)
SheetContent.displayName = DialogPrimitive.Content.displayName
+1 -1
View File
@@ -10,7 +10,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-blue-500 focus-visible:border-blue-500 disabled:cursor-not-allowed disabled:opacity-50",
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground placeholder:opacity-40 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-blue-500 focus-visible:border-blue-500 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -67,10 +67,17 @@
"google/gemini-2.5-flash-lite",
"google/gemini-2.5-flash",
"openai/gpt-4o-mini",
"mistralai/mistral-small-3.2-24b-instruct"
"mistralai/mistral-small-3.2-24b-instruct",
"nvidia/nemotron-3-super-120b-a12b:free",
"google/gemma-4-26b-a4b-it:free",
"nvidia/nemotron-nano-12b-v2-vl:free",
"nvidia/nemotron-3-nano-30b-a3b:free",
"poolside/laguna-s-2.1:free",
"nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free",
"openai/gpt-oss-20b:free"
],
"recommended": "meta-llama/llama-3.3-70b-instruct",
"_note": "Verified functionally 2026-07-14 with the OpenRouter API — all 10 curated candidates pass the Spanish-translation notification test. Fastest: llama-4-scout (0.51s), gemini-2.5-flash-lite (1.14s), gemini-2.5-flash (1.94s), llama-3.3-70b-instruct (2.29s), claude-haiku-4.5 (2.71s). Legacy anthropic/claude-3.5-* / google/gemini-flash-1.5 / mistralai/mistral-7b-instruct / mixtral-8x7b-instruct removed (GONE from catalog). Modern replacements added: llama-4-scout (Meta's current gen — dramatically fastest), claude-haiku-4.5 / claude-sonnet-4.6, gemini-2.5-flash / flash-lite, mistral-small-3.2-24b. recommended kept as llama-3.3-70b for capability/latency balance; llama-4-scout is a faster alternative worth considering as recommended after a broader release."
"_note": "Paid tier verified functionally 2026-07-14 with the OpenRouter API — all 10 curated candidates pass the Spanish-translation notification test. Fastest: llama-4-scout (0.51s), gemini-2.5-flash-lite (1.14s), gemini-2.5-flash (1.94s), llama-3.3-70b-instruct (2.29s), claude-haiku-4.5 (2.71s). Free tier verified 2026-08-17 — 7 :free models pass and are appended, ordered by latency: nemotron-3-super-120b-a12b (3.5s), gemma-4-26b-a4b-it (4.1s), nemotron-nano-12b-v2-vl (5.3s), nemotron-3-nano-30b-a3b (5.8s), laguna-s-2.1 (8.3s), nemotron-3-nano-omni-30b-a3b-reasoning (10.7s), gpt-oss-20b (12.2s). Free-tier rate limits (~20 req/min shared across all OpenRouter free users on that model) may cause 429 in high-traffic windows — usable for occasional notification translation, not for high-volume automation. recommended kept as llama-3.3-70b for capability/latency balance; llama-4-scout is a faster alternative worth considering as recommended after a broader release."
},
"ollama": {
+87
View File
@@ -0,0 +1,87 @@
// Deterministic OKLCH colouring for category badges. Shared between
// the Apps dashboard and the LXC App tab so both surfaces show the
// exact same colour for a given category name.
//
// Hue exclusions
// --------------
// Two bands are skipped because their meaning is already reserved by
// the rest of the Monitor and a chip in those hues on the same view
// would be visually confusing:
// * 260319° purple/violet — "update available" (ArrowUpCircle)
// * 34019° red — error / danger signal
// Green and yellow ARE used elsewhere for health status, but only as
// tiny dots in other views — a chip in those hues on an app card
// carries no false meaning, so they stay in the allowed range.
//
// Allowed ranges after the exclusions:
// [20, 260) [320, 340) = 240° + 20° = 260° of usable hues.
import { useEffect, useState } from "react"
export function hueForCategory(text: string): number {
let hash = 5381
for (let i = 0; i < text.length; i++) {
hash = ((hash << 5) + hash + text.charCodeAt(i)) | 0
}
const raw = Math.abs(hash) % 260
if (raw < 240) return 20 + raw // 0-239 → 20-259 (orange..blue)
return 320 + (raw - 240) // 240-259 → 320-339 (pink/magenta)
}
// OKLCH is perceptually uniform — L=0.80 looks equally bright for a
// blue and a yellow. HSL fails this because eyes weight green/yellow
// more, so the same L% renders visually darker for blues.
export function categoryChipStyle(text: string, isLight: boolean): {
backgroundColor: string
color: string
borderColor: string
} {
const h = hueForCategory(text)
if (isLight) {
return {
backgroundColor: `oklch(0.55 0.20 ${h} / 0.14)`,
color: `oklch(0.42 0.19 ${h})`,
borderColor: `oklch(0.55 0.20 ${h} / 0.5)`,
}
}
return {
backgroundColor: `oklch(0.60 0.16 ${h} / 0.18)`,
color: `oklch(0.80 0.16 ${h})`,
borderColor: `oklch(0.60 0.16 ${h} / 0.55)`,
}
}
// Read the effective theme from next-themes' hooks on <html>:
// `class="dark|light"` (Tailwind class strategy) or `data-theme`.
// Falls back to the OS setting when the user hasn't chosen one.
export function readIsLightTheme(): boolean {
if (typeof window === "undefined" || typeof document === "undefined") return false
const el = document.documentElement
if (el.classList.contains("dark")) return false
if (el.classList.contains("light")) return true
const attr = el.getAttribute("data-theme")
if (attr === "light") return true
if (attr === "dark") return false
return window.matchMedia("(prefers-color-scheme: light)").matches
}
// React hook — recomputes when the user toggles theme or the OS pref
// flips. Watches <html>'s attributes (data-theme + class) and the
// system media query. Used by any component that renders category
// chips so they stay legible after a theme change.
export function useIsLightTheme(): boolean {
const [isLight, setIsLight] = useState<boolean>(false)
useEffect(() => {
const update = () => setIsLight(readIsLightTheme())
update()
const mq = window.matchMedia("(prefers-color-scheme: light)")
const observer = new MutationObserver(update)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme", "class"],
})
mq.addEventListener("change", update)
return () => { observer.disconnect(); mq.removeEventListener("change", update) }
}, [])
return isLight
}
+40
View File
@@ -0,0 +1,40 @@
export const LANGUAGE_STORAGE_KEY = "proxmenux-ui-language"
export const DEFAULT_LANGUAGE = "en"
export type LanguageCode = "en" | "es" | "fr" | "de" | "it" | "pt" | "sk" | "sv"
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: "de", englishName: "German", nativeName: "Deutsch", status: "complete" },
{ code: "es", englishName: "Spanish", nativeName: "Español", status: "complete" },
{ code: "fr", englishName: "French", nativeName: "Français", status: "complete" },
{ code: "it", englishName: "Italian", nativeName: "Italiano", status: "complete" },
{ code: "pt", englishName: "Portuguese", nativeName: "Português", status: "complete" },
{ code: "sk", englishName: "Slovak", nativeName: "Slovenčina", status: "complete" },
{ code: "sv", englishName: "Swedish", nativeName: "Svenska", status: "complete" },
]
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
}
+135
View File
@@ -0,0 +1,135 @@
"use client"
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"
import enMessages from "../../messages/en/common.json"
import deMessages from "../../messages/de/common.json"
import esMessages from "../../messages/es/common.json"
import frMessages from "../../messages/fr/common.json"
import itMessages from "../../messages/it/common.json"
import ptMessages from "../../messages/pt/common.json"
import skMessages from "../../messages/sk/common.json"
import svMessages from "../../messages/sv/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,
de: deMessages as MessageTree,
es: esMessages as MessageTree,
fr: frMessages as MessageTree,
it: itMessages as MessageTree,
pt: ptMessages as MessageTree,
sk: skMessages as MessageTree,
sv: svMessages 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 }
+89
View File
@@ -0,0 +1,89 @@
// Shared cross-component cache for the LXC App-tab payload
// (registered sidecar + cached detection suggestions). Used by both
// virtual-machines.tsx (which prefetches on modal open and on hover)
// and lxc-app-panel.tsx (which reads the cache first and only fetches
// if empty). The in-flight promise map dedups concurrent requests: if
// the parent already fired a prefetch, the panel awaits the SAME
// promise instead of duplicating the request against the backend —
// no more racing fetches on tab switch during a slow first visit.
import { fetchApi } from "./api-config"
export type LxcAppsBundle = {
sidecar: any
suggestions: any | null
}
const dataCache = new Map<number, LxcAppsBundle>()
const inFlight = new Map<number, Promise<LxcAppsBundle | null>>()
const cacheRevision = new Map<number, number>()
export function getLxcAppsCached(vmid: number): LxcAppsBundle | undefined {
return dataCache.get(vmid)
}
// Write-through after a successful App/Updates mutation. The API returns the
// complete sidecar, so evicting this entry would throw away newer data and
// make the App tab flash "Loading applications..." on its next mount. Keep
// the already-fetched suggestions unless the caller explicitly replaces them.
export function setLxcAppsCached(
vmid: number,
sidecar: any,
suggestions?: any | null,
): LxcAppsBundle {
cacheRevision.set(vmid, (cacheRevision.get(vmid) || 0) + 1)
const current = dataCache.get(vmid)
const bundle: LxcAppsBundle = {
sidecar,
suggestions: suggestions === undefined
? (current?.suggestions ?? null)
: suggestions,
}
dataCache.set(vmid, bundle)
return bundle
}
export function fetchLxcApps(vmid: number): Promise<LxcAppsBundle | null> {
const existing = inFlight.get(vmid)
if (existing) return existing
const startedRevision = cacheRevision.get(vmid) || 0
const p = Promise.all([
fetchApi(`/api/vms/${vmid}/apps`).catch(() => null) as Promise<any>,
fetchApi(`/api/vms/${vmid}/apps/suggestions`).catch(() => null) as Promise<any>,
])
.then(([sc, sug]) => {
if (!sc) return null
const bundle: LxcAppsBundle = { sidecar: sc, suggestions: sug }
// A successful write may have completed while these GETs were in
// flight. Never let that older response overwrite the mutation result.
if ((cacheRevision.get(vmid) || 0) !== startedRevision) {
return dataCache.get(vmid) ?? null
}
dataCache.set(vmid, bundle)
return bundle
})
.finally(() => {
inFlight.delete(vmid)
})
inFlight.set(vmid, p)
return p
}
export function invalidateLxcApps(vmid: number): void {
cacheRevision.set(vmid, (cacheRevision.get(vmid) || 0) + 1)
dataCache.delete(vmid)
}
// Seed the cache from the bulk modal-cache endpoint. Both registered
// apps and startup detection suggestions are already in memory, so
// opening the App tab never starts a discovery scan.
export function seedLxcAppsCache(
vmid: number,
sidecar: any,
suggestions?: any | null,
): void {
if (!sidecar) return
const existing = dataCache.get(vmid)
if (existing) return // per-panel fetch already ran, don't overwrite
dataCache.set(vmid, { sidecar, suggestions: suggestions ?? null })
}
+79
View File
@@ -0,0 +1,79 @@
// Proxmox VE tag color scheme — 1:1 port of the algorithm in
// proxmoxlib.js (`Proxmox.Utils.stringToRGB` +
// `Proxmox.Utils.getTextContrastClass`). Same input → same color
// as the PVE web UI, so tags render identically in both places.
export type TagColor = {
bg: string // css `background-color`
fg: string // css `color` — auto-picked for contrast (SAPC)
border: string // css `border-color`
}
// Verbatim port of stringToRGB from proxmoxlib.js. The `+ 'prox'`
// suffix, the `<< 5` hash, and the `alpha=0.7 / bg=255` blend
// keep the output in the [76.5, 255] range per channel — that's
// why every PVE tag is a "washed" bright color instead of a raw
// hash-hue.
function stringToRGB(input: string): [number, number, number] {
let hash = 0
if (!input) return [255, 255, 255]
const source = input + "prox"
for (let i = 0; i < source.length; i++) {
// eslint-disable-next-line no-bitwise
hash = source.charCodeAt(i) + ((hash << 5) - hash)
// eslint-disable-next-line no-bitwise
hash = hash & hash
}
const alpha = 0.7
const bg = 255
return [
// eslint-disable-next-line no-bitwise
(hash & 255) * alpha + bg * (1 - alpha),
// eslint-disable-next-line no-bitwise
((hash >> 8) & 255) * alpha + bg * (1 - alpha),
// eslint-disable-next-line no-bitwise
((hash >> 16) & 255) * alpha + bg * (1 - alpha),
]
}
// SAPC-based light/dark text picker — verbatim port of
// getTextContrastClass. Same tag → same text color as PVE.
function getTextContrastClass(rgb: [number, number, number]): "light" | "dark" {
const blkThrs = 0.022
const blkClmp = 1.414
const r = (rgb[0] / 255) ** 2.4
const g = (rgb[1] / 255) ** 2.4
const b = (rgb[2] / 255) ** 2.4
let bg = r * 0.2126729 + g * 0.7151522 + b * 0.072175
bg = bg > blkThrs ? bg : bg + (blkThrs - bg) ** blkClmp
const contrastLight = bg ** 0.65 - 1
const contrastDark = bg ** 0.56 - 0.046134502
return Math.abs(contrastLight) >= Math.abs(contrastDark) ? "light" : "dark"
}
function rgbToCss(rgb: [number, number, number]): string {
return `rgb(${Math.round(rgb[0])}, ${Math.round(rgb[1])}, ${Math.round(rgb[2])})`
}
export function tagToColor(tag: string): TagColor {
const rgb = stringToRGB(tag)
const bg = rgbToCss(rgb)
const fg = getTextContrastClass(rgb) === "light" ? "#ffffff" : "#000000"
return { bg, fg, border: bg }
}
// Split a PVE tags string into an array. PVE separators are ';' and
// ',' (both accepted); whitespace around tokens is stripped and
// empty tokens dropped.
export function parseTags(raw: string | null | undefined): string[] {
if (!raw) return []
return raw
.split(/[;,]/)
.map((t) => t.trim())
.filter(Boolean)
}
// Join back into the canonical PVE format (';' separator).
export function stringifyTags(tags: string[]): string {
return tags.map((t) => t.trim()).filter(Boolean).join(";")
}
+132
View File
@@ -0,0 +1,132 @@
import { useEffect, useState, useCallback } from "react"
// Persistent top-level tab order for the Monitor dashboard.
//
// Only the seven top-level slots are user-orderable; the internal
// items of the Node and Admin dropdowns keep their canonical order —
// grouped items move as a single unit.
export type TabId = "overview" | "apps" | "vms" | "node" | "backup" | "terminal" | "admin"
export const DEFAULT_TAB_ORDER: TabId[] = [
"overview",
"apps",
"vms",
"node",
"backup",
"terminal",
"admin",
]
const STORAGE_KEY = "proxmenux-nav-order"
const CHANGE_EVENT = "proxmenux-nav-order-changed"
function isTabId(v: unknown): v is TabId {
return typeof v === "string" && (DEFAULT_TAB_ORDER as string[]).includes(v)
}
// Read + normalise: unknown ids are dropped, missing ones are
// appended in their default position so a future release adding a
// new tab still surfaces it for users with a stored order.
export function readTabOrder(): TabId[] {
if (typeof window === "undefined") return DEFAULT_TAB_ORDER
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
if (!raw) return DEFAULT_TAB_ORDER
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return DEFAULT_TAB_ORDER
const seen = new Set<TabId>()
const clean: TabId[] = []
for (const item of parsed) {
if (isTabId(item) && !seen.has(item)) {
clean.push(item)
seen.add(item)
}
}
for (const id of DEFAULT_TAB_ORDER) {
if (!seen.has(id)) clean.push(id)
}
return clean
} catch {
return DEFAULT_TAB_ORDER
}
}
export function writeTabOrder(order: TabId[]): void {
if (typeof window === "undefined") return
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(order))
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
} catch {
// Storage full / disabled — the in-memory state still updates.
}
}
// Map a top-level slot id to the concrete `activeTab` value the
// Tabs component uses. Direct tabs pass through; grouped slots
// (Node/Admin) resolve to the first child in the dropdown so the
// dashboard lands on a real tab, not a group header.
const GROUP_FIRST_CHILD: Record<TabId, string> = {
overview: "overview",
apps: "apps",
vms: "vms",
node: "storage",
backup: "backup",
terminal: "terminal",
admin: "logs",
}
export function firstActualTab(order: TabId[] = readTabOrder()): string {
const head = order[0]
return (head && GROUP_FIRST_CHILD[head]) || "overview"
}
export function resetTabOrder(): void {
if (typeof window === "undefined") return
try {
window.localStorage.removeItem(STORAGE_KEY)
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
} catch {
// ignore
}
}
// Hook that keeps every consumer in sync. Firing a custom event on
// write means the Settings card and the top navigation update in the
// same tick without prop-drilling.
export function useTabOrder(): {
order: TabId[]
setOrder: (next: TabId[]) => void
reset: () => void
isCustom: boolean
} {
const [order, setOrderState] = useState<TabId[]>(DEFAULT_TAB_ORDER)
useEffect(() => {
setOrderState(readTabOrder())
const onChange = () => setOrderState(readTabOrder())
window.addEventListener(CHANGE_EVENT, onChange)
window.addEventListener("storage", (e) => {
if (e.key === STORAGE_KEY) onChange()
})
return () => {
window.removeEventListener(CHANGE_EVENT, onChange)
}
}, [])
const setOrder = useCallback((next: TabId[]) => {
writeTabOrder(next)
setOrderState(next)
}, [])
const reset = useCallback(() => {
resetTabOrder()
setOrderState(DEFAULT_TAB_ORDER)
}, [])
const isCustom =
order.length !== DEFAULT_TAB_ORDER.length ||
order.some((id, idx) => id !== DEFAULT_TAB_ORDER[idx])
return { order, setOrder, reset, isCustom }
}
+11
View File
@@ -0,0 +1,11 @@
// Single source of truth for the app version displayed inside the
// Monitor. Every component that renders the version (dashboard footer,
// SMART report footer, release-notes modal…) imports from here so a
// version bump only has to be applied once per file lane:
//
// 1. AppImage/lib/version.ts ← this file
// 2. AppImage/package.json ← npm/Next.js metadata
// 3. beta_version.txt ← bash pipeline (build_appimage.sh)
//
// Keep the three in sync on every bump.
export const APP_VERSION = "1.2.5"
+10
View File
@@ -0,0 +1,10 @@
# Monitor dashboard translations
The ProxMenux Monitor dashboard uses a small client-side i18n layer.
- English (`en`) is the source language and the runtime fallback.
- Eight locales are shipped and fully populated end-to-end: `en`, `de`, `es`, `fr`, `it`, `pt`, `sk`, `sv`. Non-English catalogs started from an automated bootstrap and are being polished as native speakers pass through them.
To improve a translation, edit the values in your locale's `common.json` — keep placeholders such as `{uptime}`, `{vmid}` or `{count}` unchanged, and don't translate brand or product names (`ProxMenux Monitor`, `Proxmox Backup Server`, `Secure Gateway`, `Tailscale`, etc.). Missing keys fall back to English at runtime, so a partial refresh is always safe to merge.
To add a new locale, see [§11 → Adding a new locale](../../CONTRIBUTING.md#adding-a-new-locale) in the Contributing Guide.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ProxMenux-Monitor",
"version": "1.2.2.2-beta",
"version": "1.2.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ProxMenux-Monitor",
"version": "1.2.2.2-beta",
"version": "1.2.5",
"dependencies": {
"@hookform/resolvers": "^3.10.0",
"@radix-ui/react-accordion": "1.2.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ProxMenux-Monitor",
"version": "1.2.4",
"version": "1.2.5",
"description": "Proxmox System Monitoring Dashboard",
"private": true,
"scripts": {
@@ -183,6 +183,12 @@ class OpenAIProvider(AIProvider):
],
}
# Custom OpenAI-compatible endpoints often expose opaque aliases whose
# upstream capabilities are known only to the proxy. Do not infer
# sampling or reasoning parameters from those aliases; let the proxy
# apply model-specific defaults.
if self.base_url:
payload['max_tokens'] = max_tokens
# Reasoning models (o1/o3/o4/gpt-5*, excluding *-chat-latest) use a
# different parameter contract: max_completion_tokens instead of
# max_tokens, and no temperature field. Sending the classic chat
@@ -196,7 +202,7 @@ class OpenAIProvider(AIProvider):
# exactly what this pipeline wants. OpenAI documents 'minimal',
# 'low', 'medium', 'high' — 'minimal' is the right setting for a
# straightforward translate+explain task.
if self._is_reasoning_model(self.model):
elif self._is_reasoning_model(self.model):
payload['max_completion_tokens'] = max_tokens
payload['reasoning_effort'] = 'minimal'
else:
+200 -27
View File
@@ -653,12 +653,13 @@ def setup_auth(username, password):
Set up authentication with username and password
Returns (success: bool, message: str)
"""
# Refuse if auth has already been configured. Without this guard an
# Refuse if real credentials already exist. Without this guard an
# unauthenticated POST to /api/auth/setup would let an attacker overwrite
# the existing admin credentials and take over the account. See audit
# Tier 1 #4.
# the existing admin credentials and take over the account. A declined
# setup is marked configured but deliberately has no credentials, so it
# must remain possible to finish setup later. See audit Tier 1 #4.
existing = load_auth_config()
if existing.get("configured", False):
if existing.get("username") and existing.get("password_hash"):
return False, "Authentication is already configured"
if not username or not password:
@@ -668,7 +669,7 @@ def setup_auth(username, password):
if pw_err:
return False, pw_err
config = {
existing.update({
"enabled": True,
"username": username,
"password_hash": hash_password(password),
@@ -677,9 +678,9 @@ def setup_auth(username, password):
"totp_enabled": False,
"totp_secret": None,
"backup_codes": []
}
})
if save_auth_config(config):
if save_auth_config(existing):
return True, "Authentication configured successfully"
else:
return False, "Failed to save authentication configuration"
@@ -1053,6 +1054,15 @@ PROXMOX_KEY_PATH = "/etc/pve/local/pve-ssl.key"
PROXMOX_CUSTOM_CERT_PATH = "/etc/pve/local/pveproxy-ssl.pem"
PROXMOX_CUSTOM_KEY_PATH = "/etc/pve/local/pveproxy-ssl.key"
_SSL_RUNTIME_LOCK = threading.RLock()
_SSL_RUNTIME_REFRESH_LOCK = threading.Lock()
_SSL_RUNTIME_CONTEXT = None
_SSL_RUNTIME_FINGERPRINT = ""
_SSL_RUNTIME_CERT_PATH = ""
_SSL_RUNTIME_KEY_PATH = ""
_SSL_RUNTIME_SOURCE = "none"
_SSL_RUNTIME_LAST_REFRESH_ERROR = ""
def load_ssl_config():
"""Load SSL configuration from file"""
@@ -1093,6 +1103,15 @@ def save_ssl_config(config):
return False
def _detect_proxmox_certificate_paths():
"""Return the certificate pair currently preferred by Proxmox."""
if os.path.isfile(PROXMOX_CUSTOM_CERT_PATH) and os.path.isfile(PROXMOX_CUSTOM_KEY_PATH):
return PROXMOX_CUSTOM_CERT_PATH, PROXMOX_CUSTOM_KEY_PATH
if os.path.isfile(PROXMOX_CERT_PATH) and os.path.isfile(PROXMOX_KEY_PATH):
return PROXMOX_CERT_PATH, PROXMOX_KEY_PATH
return "", ""
def detect_proxmox_certificates():
"""
Detect available Proxmox certificates.
@@ -1110,11 +1129,10 @@ def detect_proxmox_certificates():
"cert_info": None
}
if os.path.isfile(PROXMOX_CUSTOM_CERT_PATH) and os.path.isfile(PROXMOX_CUSTOM_KEY_PATH):
result["proxmox_cert"] = PROXMOX_CUSTOM_CERT_PATH
result["proxmox_key"] = PROXMOX_CUSTOM_KEY_PATH
result["proxmox_available"] = True
elif os.path.isfile(PROXMOX_CERT_PATH) and os.path.isfile(PROXMOX_KEY_PATH):
cert_path, key_path = _detect_proxmox_certificate_paths()
if cert_path and key_path:
result["proxmox_cert"] = cert_path
result["proxmox_key"] = key_path
result["proxmox_available"] = True
if result["proxmox_available"]:
@@ -1174,26 +1192,181 @@ def validate_certificate_files(cert_path, key_path):
except Exception as e:
return False, f"Error reading certificate files: {str(e)}"
# Verify cert and key match
# Parse the complete chain and verify that the private key matches it.
try:
import subprocess
cert_mod = subprocess.run(
["openssl", "x509", "-noout", "-modulus", "-in", cert_path],
capture_output=True, text=True, timeout=5
)
key_mod = subprocess.run(
["openssl", "rsa", "-noout", "-modulus", "-in", key_path],
capture_output=True, text=True, timeout=5
)
if cert_mod.returncode == 0 and key_mod.returncode == 0:
if cert_mod.stdout.strip() != key_mod.stdout.strip():
return False, "Certificate and key do not match"
except Exception:
pass # Non-critical, proceed anyway
import ssl
test_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
test_context.load_cert_chain(cert_path, key_path)
except Exception as e:
return False, f"Certificate or private key is invalid: {str(e)}"
return True, "Certificate files are valid"
def _certificate_pair_fingerprint(cert_path, key_path):
digest = hashlib.sha256()
for path in (cert_path, key_path):
with open(path, "rb") as source:
for chunk in iter(lambda: source.read(65536), b""):
digest.update(chunk)
return digest.hexdigest()
def _build_server_ssl_context(cert_path, key_path):
import ssl
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.load_cert_chain(cert_path, key_path)
return context
def _record_ssl_refresh_error(error):
"""Log one warning per distinct automatic-refresh failure."""
global _SSL_RUNTIME_LAST_REFRESH_ERROR
message = str(error)
with _SSL_RUNTIME_LOCK:
if message == _SSL_RUNTIME_LAST_REFRESH_ERROR:
return
_SSL_RUNTIME_LAST_REFRESH_ERROR = message
print(
"[ProxMenux] Proxmox TLS certificate refresh skipped; "
f"the active certificate remains unchanged: {message}",
flush=True,
)
def _persist_active_proxmox_certificate_paths(cert_path, key_path):
"""Keep the selected Proxmox pair in sync for the next service start."""
config = load_ssl_config()
if not config.get("enabled") or config.get("source") != "proxmox":
return
if config.get("cert_path") == cert_path and config.get("key_path") == key_path:
return
updated_config = dict(config)
updated_config["cert_path"] = cert_path
updated_config["key_path"] = key_path
if not save_ssl_config(updated_config):
print(
"[ProxMenux] Warning: the renewed Proxmox certificate is active, "
"but its paths could not be saved for the next service start",
flush=True,
)
def _refresh_proxmox_ssl_context_for_handshake():
"""Activate a renewed Proxmox pair just before a TLS handshake.
This deliberately has no timer and does not depend on inotify (pmxcfs can
update /etc/pve without emitting a local event). The small PEM pair is
inspected only when a client starts a new TLS connection. Any missing,
partial or mismatched pair leaves the already-active context untouched.
"""
global _SSL_RUNTIME_LAST_REFRESH_ERROR
with _SSL_RUNTIME_LOCK:
if _SSL_RUNTIME_SOURCE != "proxmox" or _SSL_RUNTIME_CONTEXT is None:
return False
# Several browser connections can arrive together. Only one of them may
# validate/swap a newly written pair; the others reuse its result.
with _SSL_RUNTIME_REFRESH_LOCK:
with _SSL_RUNTIME_LOCK:
if _SSL_RUNTIME_SOURCE != "proxmox" or _SSL_RUNTIME_CONTEXT is None:
return False
active_fingerprint = _SSL_RUNTIME_FINGERPRINT
active_cert_path = _SSL_RUNTIME_CERT_PATH
active_key_path = _SSL_RUNTIME_KEY_PATH
cert_path, key_path = _detect_proxmox_certificate_paths()
if not cert_path or not key_path:
raise RuntimeError("No complete Proxmox certificate/key pair was detected")
candidate_fingerprint = _certificate_pair_fingerprint(cert_path, key_path)
paths_changed = cert_path != active_cert_path or key_path != active_key_path
if candidate_fingerprint == active_fingerprint and not paths_changed:
return False
# reload_server_ssl_context builds and validates the replacement first
# and checks that neither PEM changed while it was being loaded. The
# global context is swapped only after all of those checks succeed.
changed = reload_server_ssl_context(cert_path, key_path)
_persist_active_proxmox_certificate_paths(cert_path, key_path)
with _SSL_RUNTIME_LOCK:
_SSL_RUNTIME_LAST_REFRESH_ERROR = ""
if changed:
print(
f"[ProxMenux] Renewed Proxmox TLS certificate activated from {cert_path}",
flush=True,
)
return changed
def create_reloadable_ssl_context(cert_path, key_path):
"""Create the stable server context used by automatic and manual reloads."""
global _SSL_RUNTIME_CONTEXT
global _SSL_RUNTIME_FINGERPRINT
global _SSL_RUNTIME_CERT_PATH
global _SSL_RUNTIME_KEY_PATH
global _SSL_RUNTIME_SOURCE
global _SSL_RUNTIME_LAST_REFRESH_ERROR
context = _build_server_ssl_context(cert_path, key_path)
fingerprint = _certificate_pair_fingerprint(cert_path, key_path)
config = load_ssl_config()
source = config.get("source", "none") if config.get("enabled") else "none"
def _select_active_context(ssl_socket, _server_name, _initial_context):
try:
_refresh_proxmox_ssl_context_for_handshake()
except Exception as error:
# Never fail a client handshake because Proxmox is between the
# certificate and key writes. The previously validated context
# remains authoritative until a later connection can load both.
_record_ssl_refresh_error(error)
with _SSL_RUNTIME_LOCK:
active_context = _SSL_RUNTIME_CONTEXT
if active_context is not None and ssl_socket.context is not active_context:
ssl_socket.context = active_context
context.sni_callback = _select_active_context
with _SSL_RUNTIME_LOCK:
_SSL_RUNTIME_CONTEXT = context
_SSL_RUNTIME_FINGERPRINT = fingerprint
_SSL_RUNTIME_CERT_PATH = cert_path
_SSL_RUNTIME_KEY_PATH = key_path
_SSL_RUNTIME_SOURCE = source
_SSL_RUNTIME_LAST_REFRESH_ERROR = ""
return context
def reload_server_ssl_context(cert_path, key_path):
"""Validate and activate a new certificate for subsequent TLS handshakes."""
global _SSL_RUNTIME_CONTEXT
global _SSL_RUNTIME_FINGERPRINT
global _SSL_RUNTIME_CERT_PATH
global _SSL_RUNTIME_KEY_PATH
before_fingerprint = _certificate_pair_fingerprint(cert_path, key_path)
replacement = _build_server_ssl_context(cert_path, key_path)
after_fingerprint = _certificate_pair_fingerprint(cert_path, key_path)
if before_fingerprint != after_fingerprint:
raise RuntimeError("Certificate files changed while they were being loaded")
with _SSL_RUNTIME_LOCK:
if _SSL_RUNTIME_CONTEXT is None:
raise RuntimeError("The HTTPS runtime is not initialized")
changed = after_fingerprint != _SSL_RUNTIME_FINGERPRINT
if changed:
_SSL_RUNTIME_CONTEXT = replacement
_SSL_RUNTIME_FINGERPRINT = after_fingerprint
_SSL_RUNTIME_CERT_PATH = cert_path
_SSL_RUNTIME_KEY_PATH = key_path
return changed
def configure_ssl(cert_path, key_path, source="custom"):
"""
Configure SSL with given certificate and key paths.
+9
View File
@@ -124,10 +124,19 @@ cp "$SCRIPT_DIR/post_install_versions.py" "$APP_DIR/usr/bin/" 2>/dev/null || ech
cp "$SCRIPT_DIR/mount_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ mount_monitor.py not found"
cp "$SCRIPT_DIR/lxc_mount_points.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ lxc_mount_points.py not found"
cp "$SCRIPT_DIR/disk_temperature_history.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ disk_temperature_history.py not found"
cp "$SCRIPT_DIR/smartctl_resolver.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ smartctl_resolver.py not found"
cp "$SCRIPT_DIR/health_thresholds.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ health_thresholds.py not found"
cp "$SCRIPT_DIR/managed_installs.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ managed_installs.py not found"
cp "$SCRIPT_DIR/lxc_apps.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ lxc_apps.py not found"
cp "$SCRIPT_DIR/custom_links.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ custom_links.py not found"
cp "$SCRIPT_DIR/recreate_docker_container.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ recreate_docker_container.py not found"
chmod +x "$APP_DIR/usr/bin/recreate_docker_container.py" 2>/dev/null || true
cp "$SCRIPT_DIR/update_docker_engine.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ update_docker_engine.py not found"
chmod +x "$APP_DIR/usr/bin/update_docker_engine.py" 2>/dev/null || true
cp "$APPIMAGE_ROOT/../json/app_tracking_hints.json" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ app_tracking_hints.json not found"
cp "$SCRIPT_DIR/flask_terminal_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_terminal_routes.py not found"
cp "$SCRIPT_DIR/hardware_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ hardware_monitor.py not found"
cp "$SCRIPT_DIR/temperature_sensor_resolver.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ temperature_sensor_resolver.py not found"
cp "$SCRIPT_DIR/proxmox_storage_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ proxmox_storage_monitor.py not found"
cp "$SCRIPT_DIR/flask_script_runner.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_script_runner.py not found"
cp "$SCRIPT_DIR/security_manager.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ security_manager.py not found"
+276
View File
@@ -0,0 +1,276 @@
#!/usr/bin/env python3
"""User-defined web links surfaced in the Apps dashboard alongside
LXC-registered apps. Kept in a single sidecar
(/etc/proxmenux/custom_links.json) because the collection is small,
global, and never bound to a specific guest by ProxMenux itself.
Schema of each entry
--------------------
{
"id": "<uuid4>",
"name": "<display name>", # required
"url": "<http(s) URL>", # required
"logo_url": "<http(s) URL or ''>", # optional
"category": "<free text or ''>", # optional
"binding": { # optional; null when unbound
"vmid": <int>,
"guest_type": "lxc" | "qemu"
},
"created_at": <unix ts>,
"updated_at": <unix ts>
}
Design notes
------------
* One file (not per-VM). Volume is small; unbound links have no natural
home; global lookups are O(N) with N tiny.
* All writes go through `save_all` which does the classic
write-temp+rename dance so a crash mid-save can't corrupt the file.
* Validation is strict at the boundary the frontend can send whatever;
the backend refuses anything malformed. Fields that survive are
exactly the schema above; unknown keys are dropped silently.
"""
from __future__ import annotations
import json
import os
import re
import threading
import time
import uuid
from typing import Any, Optional
_CUSTOM_LINKS_PATH = "/etc/proxmenux/custom_links.json"
_lock = threading.RLock()
# In-memory copy of the full list. Populated on first read (or by
# `warmup()` at Monitor startup) and refreshed only when a write goes
# through this module. The sidecar file is our source of truth; the
# cache exists so `/api/apps/custom-links` doesn't hit disk on every
# request. Reads always return a fresh copy so callers can't mutate
# the cached state by accident.
_cached_entries: Optional[list[dict]] = None
# Same character set / max length as the LXC-app editor uses so users
# don't have to learn two different rulesets.
_NAME_RE = re.compile(r"^[\w\s._+\-()/:,&]{1,80}$", re.UNICODE)
_URL_RE = re.compile(r"^https?://[\w\-._~:/?#\[\]@!$&'()*+,;=%]{1,510}$")
_CATEGORY_RE = re.compile(r"^[\w\s&/,.\-*+()]{1,60}$", re.UNICODE)
_UUID_RE = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
_GUEST_TYPES = frozenset({"lxc", "qemu"})
def _err(msg: str) -> tuple[bool, str]:
return False, msg
# ── Persistence ────────────────────────────────────────────────────
def _read_from_disk() -> list[dict]:
"""Actually parse the sidecar file. A missing/empty/corrupt file
returns [] we never let bad JSON take down the whole Apps
dashboard, the user's other data is fine."""
try:
with open(_CUSTOM_LINKS_PATH, encoding="utf-8") as f:
raw = json.load(f)
except (FileNotFoundError, PermissionError):
return []
except (OSError, ValueError):
return []
if not isinstance(raw, list):
return []
return [entry for entry in raw if isinstance(entry, dict)]
def load_all() -> list[dict]:
"""Return the current list of custom links from the in-memory
cache. First call after a Monitor restart pays one disk read
(~1 ms); every subsequent call is a memory op. Writes go through
`save_all` which also refreshes the cache, so callers never see
stale data.
"""
global _cached_entries
with _lock:
if _cached_entries is None:
_cached_entries = _read_from_disk()
return [dict(entry) for entry in _cached_entries]
def warmup() -> int:
"""Force the cache to populate now. Invoked from Monitor startup
so the very first `/api/apps/custom-links` request is served
straight from memory. Returns the entry count for the log line."""
global _cached_entries
with _lock:
_cached_entries = _read_from_disk()
return len(_cached_entries)
def save_all(entries: list[dict]) -> None:
"""Persist the full list. Write-temp+rename so a crash cannot
leave a half-written JSON on disk. Also refreshes the in-memory
cache so the next `load_all` returns the new state without a
disk read. Caller must have validated every entry this
function trusts its input and writes verbatim.
"""
global _cached_entries
directory = os.path.dirname(_CUSTOM_LINKS_PATH)
os.makedirs(directory, exist_ok=True)
payload = json.dumps(entries, ensure_ascii=False, indent=2)
with _lock:
tmp = f"{_CUSTOM_LINKS_PATH}.tmp.{os.getpid()}"
try:
with open(tmp, "w", encoding="utf-8") as f:
f.write(payload)
f.write("\n")
os.replace(tmp, _CUSTOM_LINKS_PATH)
_cached_entries = [dict(e) for e in entries]
finally:
try:
if os.path.exists(tmp):
os.remove(tmp)
except OSError:
pass
# ── Validation ─────────────────────────────────────────────────────
def _validate_binding(raw: Any) -> tuple[bool, Any]:
"""Accepts either null (unbound) or {vmid, guest_type}. Coerces
vmid to int and guest_type to one of the allowed literals."""
if raw in (None, "", {}):
return True, None
if not isinstance(raw, dict):
return _err("binding must be an object with {vmid, guest_type}")
vmid_raw = raw.get("vmid")
try:
vmid = int(vmid_raw)
except (TypeError, ValueError):
return _err("binding.vmid must be an integer")
if not (0 < vmid <= 999_999_999):
return _err("binding.vmid out of range")
guest_type = (raw.get("guest_type") or "").strip().lower()
if guest_type not in _GUEST_TYPES:
return _err("binding.guest_type must be 'lxc' or 'qemu'")
return True, {"vmid": vmid, "guest_type": guest_type}
def validate_entry(raw: Any, existing_id: Optional[str] = None) -> tuple[bool, Any]:
"""Validate a single link payload from the API layer. Returns
(True, sanitised_dict) or (False, error_string). Fields absent in
the input default to safe values; unknown keys are ignored."""
if not isinstance(raw, dict):
return _err("payload must be a JSON object")
name = (raw.get("name") or "").strip()
if not name:
return _err("name is required")
if not _NAME_RE.match(name):
return _err("name contains invalid characters or exceeds 80 chars")
url = (raw.get("url") or "").strip()
if not url:
return _err("url is required")
if not _URL_RE.match(url):
return _err("url must be an http(s) URL (max 512 chars)")
logo_url = (raw.get("logo_url") or "").strip()
if logo_url and not _URL_RE.match(logo_url):
return _err("logo_url must be an http(s) URL (max 512 chars)")
category = (raw.get("category") or "").strip()
if category and not _CATEGORY_RE.match(category):
return _err("category contains invalid characters or exceeds 60 chars")
ok, binding = _validate_binding(raw.get("binding"))
if not ok:
return _err(binding)
entry_id = existing_id or raw.get("id") or str(uuid.uuid4())
if not _UUID_RE.match(entry_id):
entry_id = str(uuid.uuid4())
now = int(time.time())
return True, {
"id": entry_id,
"name": name,
"url": url,
"logo_url": logo_url,
"category": category,
"binding": binding,
"created_at": int(raw.get("created_at") or now),
"updated_at": now,
}
# ── CRUD helpers used by the Flask endpoints ───────────────────────
def create(payload: dict) -> tuple[bool, Any]:
"""Add a new link. Assigns a fresh UUID and appends to the file."""
ok, entry = validate_entry(payload)
if not ok:
return False, entry
with _lock:
current = load_all()
current.append(entry)
save_all(current)
return True, entry
def update(link_id: str, payload: dict) -> tuple[bool, Any]:
"""Replace one link by id. 404 if the id is unknown."""
if not _UUID_RE.match(link_id or ""):
return _err("invalid link id")
with _lock:
current = load_all()
for i, existing in enumerate(current):
if existing.get("id") == link_id:
merged = dict(existing)
merged.update(payload)
merged["id"] = link_id # id is immutable
merged["created_at"] = existing.get("created_at")
ok, entry = validate_entry(merged, existing_id=link_id)
if not ok:
return False, entry
current[i] = entry
save_all(current)
return True, entry
return _err("link not found")
def delete(link_id: str) -> tuple[bool, Any]:
"""Remove one link by id. Idempotent — deleting an unknown id
returns success so the UI doesn't have to distinguish."""
if not _UUID_RE.match(link_id or ""):
return _err("invalid link id")
with _lock:
current = load_all()
remaining = [e for e in current if e.get("id") != link_id]
if len(remaining) != len(current):
save_all(remaining)
return True, {"deleted": link_id}
def purge_binding_for_vmid(vmid: int) -> int:
"""Clear the `binding` on every link that pointed to a guest that
no longer exists. Called from the guest lifecycle hook when a VM
or CT is destroyed so the dashboard never surfaces a dead ID.
Returns the number of links updated (0 or more)."""
try:
target = int(vmid)
except (TypeError, ValueError):
return 0
changed = 0
with _lock:
current = load_all()
for entry in current:
binding = entry.get("binding") or {}
if isinstance(binding, dict) and binding.get("vmid") == target:
entry["binding"] = None
entry["updated_at"] = int(time.time())
changed += 1
if changed:
save_all(current)
return changed
+10 -50
View File
@@ -12,7 +12,7 @@ don't add another background thread.
Performance three caches keep the steady-state cost flat on big JBODs:
* ``_disk_list_cache`` lsblk + USB filter, refreshed every 5 min.
* ``_disk_list_cache`` physical disk inventory, refreshed every 5 min.
* ``_disk_probe_cache`` remembers which ``smartctl -d <type>``
variant works for each disk so we skip
the 4-attempt fallback chain.
@@ -36,6 +36,8 @@ import time
from concurrent.futures import ThreadPoolExecutor
from typing import Any, Optional
from smartctl_resolver import smartctl_probe_types, smartctl_result_is_standby
# Use the same DB the CPU temperature pipeline writes to so we share
# the WAL file and the periodic vacuum that flask_server already runs.
_DB_DIR = "/usr/local/share/proxmenux"
@@ -55,7 +57,7 @@ _SMARTCTL_TIMEOUT = 5
# On a 24-disk host the naive sampler can spend several seconds per minute
# just iterating smartctl. Three caches keep the steady-state cost flat:
#
# _disk_list_cache — the (lsblk + USB filter) result. Disks don't
# _disk_list_cache — the physical disk inventory. Disks don't
# appear/disappear between samples, so we only
# re-enumerate every _DISK_LIST_TTL seconds.
#
@@ -81,7 +83,7 @@ _MAX_WORKERS = 16 # cap concurrency for huge JBODs
_cache_lock = threading.Lock()
_disk_list_cache: Optional[tuple[float, list[str]]] = None
# Maps disk_name -> probe key: 'auto' | 'nvme' | 'ata' | 'sat'.
# Maps disk_name -> the working smartctl device type.
# Only successful probes get cached.
_disk_probe_cache: dict[str, str] = {}
# Maps disk_name -> consecutive_failures count (cleared on success).
@@ -176,26 +178,8 @@ def init_disk_temperature_db() -> bool:
# Disk enumeration + temperature read
# ---------------------------------------------------------------------------
# Match the modal's filter: USB drives are excluded. The hardware tab
# already hides them in the per-disk list and the user's cluster
# storage doesn't run on USB-attached disks anyway. Including them
# would clutter the history table for thumbdrives plugged in once
# during a recovery session.
def _is_usb_disk(disk_name: str) -> bool:
"""Return True for disks attached over USB. Mirrors the heuristic
in `get_disk_connection_type` in flask_server checks the realpath
of /sys/block/<name> for `usb` in the bus chain."""
try:
link = os.path.realpath(f"/sys/block/{disk_name}")
return "/usb" in link
except OSError:
return False
def _enumerate_target_disks() -> list[str]:
"""Run ``lsblk`` + USB filter. The expensive part is the realpath
walks in ``_is_usb_disk``; both are short-lived but we still amortise
them via the disk-list cache so they only run every few minutes."""
"""Enumerate physical disks for temperature sampling."""
out: list[str] = []
try:
proc = subprocess.run(
@@ -214,8 +198,6 @@ def _enumerate_target_disks() -> list[str]:
# Skip virtual/loop devices that lsblk still reports as type=disk.
if name.startswith("loop") or name.startswith("zd"):
continue
if _is_usb_disk(name):
continue
out.append(name)
except (subprocess.TimeoutExpired, OSError):
pass
@@ -237,22 +219,6 @@ def _list_target_disks() -> list[str]:
return fresh
def _is_disk_usb(disk_name: str) -> bool:
"""True if the disk sits behind a USB bus, checked via the resolved
sysfs device path. USB-NVMe bridges (ASMedia, JMicron, Realtek) and
plain USB-HDDs both report `/sys/block/<disk>/removable = 0`, so the
older removable-flag heuristic missed them and the temperature
poller never tried the snt* driver variants that are the only way
to reach the NVMe controller behind those bridges."""
try:
base = disk_name[5:] if disk_name.startswith('/dev/') else disk_name
real = os.path.realpath(f'/sys/block/{base}')
return any(seg.startswith('usb') and (len(seg) == 3 or seg[3:].isdigit())
for seg in real.split('/'))
except Exception:
return False
def _smartctl_cmd_for(disk_name: str, probe: str) -> list[str]:
"""Build the smartctl invocation for a given probe key.
@@ -293,7 +259,7 @@ def _try_probe(disk_name: str, probe: str) -> Optional[float]:
# the backoff and stop polling that drive forever) — surface it
# as the dedicated _STANDBY sentinel so the caller skips the
# update cleanly.
if proc.returncode == 2:
if smartctl_result_is_standby(proc.returncode, proc.stdout, proc.stderr):
return _STANDBY # type: ignore[return-value]
# smartctl returns non-zero on warnings (bit 0x40 etc.) even when
# JSON is fully populated. Don't gate on returncode — parse the
@@ -369,15 +335,9 @@ def _read_temperature(disk_name: str) -> Optional[float]:
return temp
# Cached probe stopped working — fall through and re-detect.
# Slow path: try every probe and remember the first one that works.
# For USB-attached disks we prepend the three snt* driver variants —
# USB-NVMe bridges (ASMedia / JMicron / Realtek) don't answer the
# plain probes with real SMART; only snt* passes through to the NVMe
# controller so temperature actually comes back. Non-USB disks skip
# them, so this adds zero overhead on internal drives.
probes: tuple[str, ...] = ("auto", "nvme", "ata", "sat")
if _is_disk_usb(disk_name):
probes = ("sntasmedia", "sntjmicron", "sntrealtek") + probes
# Slow path: try the transport-specific order and remember the first
# probe that returns an actual temperature.
probes = smartctl_probe_types(disk_name)
for probe in probes:
if probe == cached_probe:
continue # already tried above
+72
View File
@@ -249,6 +249,78 @@ def ssl_disable():
return jsonify({"success": False, "message": str(e)}), 500
@auth_bp.route('/api/ssl/reload', methods=['POST'])
@require_auth
def ssl_reload():
"""Reload the configured certificate without restarting the Monitor."""
config = auth_manager.load_ssl_config()
if not config.get("enabled"):
return jsonify({
"success": False,
"code": "ssl_not_enabled",
"message": "HTTPS is not enabled",
}), 400
source = config.get("source", "custom")
cert_info = None
if source == "proxmox":
detection = auth_manager.detect_proxmox_certificates()
if not detection.get("proxmox_available"):
return jsonify({
"success": False,
"code": "certificate_unavailable",
"message": "No Proxmox certificate was detected",
}), 404
cert_path = detection.get("proxmox_cert", "")
key_path = detection.get("proxmox_key", "")
cert_info = detection.get("cert_info")
else:
cert_path = config.get("cert_path", "")
key_path = config.get("key_path", "")
valid, validation_message = auth_manager.validate_certificate_files(cert_path, key_path)
if not valid:
return jsonify({
"success": False,
"code": "certificate_invalid",
"message": validation_message,
}), 400
paths_changed = (
cert_path != config.get("cert_path", "") or
key_path != config.get("key_path", "")
)
if paths_changed:
updated_config = dict(config)
updated_config["cert_path"] = cert_path
updated_config["key_path"] = key_path
if not auth_manager.save_ssl_config(updated_config):
return jsonify({
"success": False,
"code": "config_save_failed",
"message": "Failed to save the renewed certificate paths",
}), 500
try:
changed = auth_manager.reload_server_ssl_context(cert_path, key_path)
except Exception as e:
if paths_changed:
auth_manager.save_ssl_config(config)
return jsonify({
"success": False,
"code": "runtime_reload_failed",
"message": str(e),
}), 409
return jsonify({
"success": True,
"changed": changed,
"cert_path": cert_path,
"key_path": key_path,
"cert_info": cert_info,
})
def _refresh_pve_webhook_for_ssl_change():
"""Helper used by both `ssl_configure` and `ssl_disable`.
+1
View File
@@ -454,6 +454,7 @@ def get_remote_storages():
'used': storage.get('used', 0),
'available': storage.get('available', 0),
'percent': storage.get('percent', 0),
'capacity_known': storage.get('capacity_known', storage.get('total', 0) > 0),
'exclude_health': exclusion.get('exclude_health', 0) == 1,
'exclude_notifications': exclusion.get('exclude_notifications', 0) == 1,
'excluded_at': exclusion.get('excluded_at'),
+58 -18
View File
@@ -808,12 +808,21 @@ def send_notification():
if not _validate_severity(severity):
return _bad_request('Invalid severity')
# Accept `title`/`message` either at the root of the payload
# or nested under `data` — the public docs show the nested
# form (`data.message`) as the primary example, so falling
# back to it prevents "empty title/message" custom events
# (issue #297).
payload_body = data.get('data') if isinstance(data.get('data'), dict) else {}
title = data.get('title') or payload_body.get('title') or ''
message = data.get('message') or payload_body.get('message') or ''
result = notification_manager.send_notification(
event_type=event_type,
severity=severity,
title=data.get('title', ''),
message=data.get('message', ''),
data=data.get('data', {}),
title=title,
message=message,
data=payload_body,
source='api'
)
return jsonify(result)
@@ -1212,9 +1221,13 @@ def setup_pve_webhook_core() -> dict:
# `could not decode UTF8 string from base64, key 'X-Webhook-Secret' (500)`
# whenever `token_urlsafe` produced `-` or `_` chars (GH #198).
secret_b64 = base64.b64encode(secret.encode()).decode()
# PVE parses /etc/pve/*.cfg TAB-strict. The endpoint_block above
# indents with `\t`; the priv_block MUST too, or PVE silently
# ignores the `secret` line and never sends `X-Webhook-Secret`,
# so every remote delivery lands as 401 invalid_secret. GH #294.
priv_block = (
f"webhook: {_PVE_ENDPOINT_ID}\n"
f" secret name=X-Webhook-Secret,value={secret_b64}\n"
f"\tsecret name=X-Webhook-Secret,value={secret_b64}\n"
)
if priv_text is not None:
@@ -1234,9 +1247,14 @@ def setup_pve_webhook_core() -> dict:
result['error'] = f'Permission denied writing {_PVE_PRIV_CFG}'
result['fallback_commands'] = _build_webhook_fallback()
return result
except Exception:
pass
except Exception as e:
# Silently swallowing this here would report configured:True
# while PVE has no valid secret — exactly the failure mode of
# GH #294. Surface it so the caller can flag the setup.
result['error'] = f'Failed writing {_PVE_PRIV_CFG}: {e}'
result['fallback_commands'] = _build_webhook_fallback()
return result
result['configured'] = True
result['secret'] = secret
return result
@@ -1489,19 +1507,37 @@ def proxmox_webhook():
if not hmac.compare_digest(configured_secret, request_secret):
return _reject(401, 'invalid_secret', 401)
# Layer 3: Anti-replay timestamp
# Layer 3: Anti-replay timestamp.
# PVE's webhook notification target can only send a static secret
# header + a Handlebars-templated body; it cannot inject a custom
# dynamic header, so `X-ProxMenux-Timestamp` never arrives from a
# PVE-origin delivery (GH #294). Our own PVE endpoint template
# already embeds `"timestamp":"{{ timestamp }}"` (Unix epoch), so
# accept the body value as a fallback when the header is absent.
# The replay cache in Layer 4 still binds every accepted request
# to (timestamp, raw_body), so this widens the source of the
# timestamp without weakening the anti-replay guarantee.
raw_body = request.get_data(as_text=True) or ''
ts_header = request.headers.get('X-ProxMenux-Timestamp', '')
if not ts_header:
ts_value = None
if ts_header:
try:
ts_value = int(ts_header)
except (ValueError, TypeError):
return _reject(401, 'invalid_timestamp', 401)
elif raw_body:
try:
body_ts = json.loads(raw_body).get('timestamp')
if body_ts is not None:
ts_value = int(str(body_ts).strip())
except (ValueError, TypeError, json.JSONDecodeError, AttributeError):
ts_value = None
if ts_value is None:
return _reject(401, 'missing_timestamp', 401)
try:
ts_value = int(ts_header)
except (ValueError, TypeError):
return _reject(401, 'invalid_timestamp', 401)
if abs(time.time() - ts_value) > _TIMESTAMP_MAX_DRIFT:
return _reject(401, 'timestamp_expired', 401)
# Layer 4: Replay cache
raw_body = request.get_data(as_text=True) or ''
signature = hashlib.sha256(f"{ts_value}:{raw_body}".encode(errors='replace')).hexdigest()
if _replay_cache.check_and_record(signature):
return _reject(409, 'replay_detected', 409)
@@ -1548,11 +1584,15 @@ def proxmox_webhook():
return _reject(400, 'missing_title', 400)
if not isinstance(message, str):
message = str(message) if message is not None else ''
# Bound runaway sizes — webhooks shouldn't exceed a few KB of text.
# Keep the full webhook body for downstream parsers. PVE vzdump
# reports can legitimately exceed Telegram's 4096-character delivery
# limit when a job covers many VM/CT guests. Truncating here can cut a
# table row in half before notification_templates._parse_vzdump_message
# sees it, which makes a successful backup look like a failed one.
# Channel-specific senders (for example TelegramChannel._split_message)
# are responsible for splitting the final formatted notification.
if len(title) > 256:
payload['title'] = title[:256]
if len(message) > 4096:
payload['message'] = message[:4096]
# Severity normalisation: accept the canonical set, default to 'info'.
sev = (payload.get('severity') or '').lower()
if sev not in {'info', 'warning', 'critical', 'error', 'notice'}:
@@ -29,6 +29,9 @@ TOOL_METADATA = {
'kernel_panic': {'name': 'Kernel Panic Configuration', 'function': 'configure_kernel_panic', 'version': '1.0'},
'apt_ipv4': {'name': 'APT IPv4 Force', 'function': 'force_apt_ipv4', 'version': '1.0'},
'kexec': {'name': 'kexec for quick reboots', 'function': 'enable_kexec', 'version': '1.0'},
'rpc': {'name': 'RPC / rpcbind Disable', 'function': 'disable_rpc', 'version': '1.0'},
'motd': {'name': 'Custom MOTD Banner', 'function': 'setup_motd', 'version': '1.0'},
'system_utils': {'name': 'System Utilities', 'function': 'install_system_utils', 'version': '1.0'},
'network_optimization': {'name': 'Network Optimizations', 'function': 'apply_network_optimizations', 'version': '1.0'},
'bashrc_custom': {'name': 'Bashrc Customization', 'function': 'customize_bashrc', 'version': '1.0'},
'figurine': {'name': 'Figurine', 'function': 'configure_figurine', 'version': '1.0'},
+76
View File
@@ -5,6 +5,8 @@ ProxMenux Security Routes
Flask blueprint for firewall management and security tool detection.
"""
import ipaddress
from flask import Blueprint, jsonify, request
from jwt_middleware import require_auth
@@ -234,6 +236,80 @@ def fail2ban_jail_config():
return jsonify({"success": False, "message": str(e)}), 500
@security_bp.route('/api/security/fail2ban/trusted-networks', methods=['GET'])
@require_auth
def fail2ban_trusted_networks():
"""List global IP/CIDR addresses excluded from all Fail2Ban jails."""
if not security_manager:
return jsonify({"success": False, "message": "Security manager not available"}), 500
try:
detected_ip = request.remote_addr
try:
parsed_ip = ipaddress.ip_address(detected_ip) if detected_ip else None
if isinstance(parsed_ip, ipaddress.IPv6Address) and parsed_ip.ipv4_mapped:
parsed_ip = parsed_ip.ipv4_mapped
if not parsed_ip or parsed_ip.is_loopback:
detected_ip = None
else:
detected_ip = str(parsed_ip)
except ValueError:
detected_ip = None
return jsonify({
"success": True,
"entries": security_manager.get_fail2ban_trusted_networks(),
"detected_ip": detected_ip,
})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@security_bp.route('/api/security/fail2ban/trusted-networks', methods=['POST'])
@require_auth
def fail2ban_add_trusted_network():
"""Add one global Fail2Ban IP/CIDR exclusion."""
if not security_manager:
return jsonify({"success": False, "message": "Security manager not available"}), 500
try:
data = request.json or {}
success, message, value = security_manager.add_fail2ban_trusted_network(data.get("value", ""))
status = 200 if success else 400
return jsonify({"success": success, "message": message, "value": value}), status
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@security_bp.route('/api/security/fail2ban/trusted-networks', methods=['DELETE'])
@require_auth
def fail2ban_remove_trusted_network():
"""Remove one user-managed global Fail2Ban IP/CIDR exclusion."""
if not security_manager:
return jsonify({"success": False, "message": "Security manager not available"}), 500
try:
data = request.json or {}
success, message = security_manager.remove_fail2ban_trusted_network(data.get("value", ""))
status = 200 if success else 400
return jsonify({"success": success, "message": message}), status
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@security_bp.route('/api/security/fail2ban/trusted-networks', methods=['PUT'])
@require_auth
def fail2ban_update_trusted_network():
"""Replace one user-managed global Fail2Ban IP/CIDR exclusion."""
if not security_manager:
return jsonify({"success": False, "message": "Security manager not available"}), 500
try:
data = request.json or {}
success, message, value = security_manager.update_fail2ban_trusted_network(
data.get("old_value", ""), data.get("new_value", "")
)
status = 200 if success else 400
return jsonify({"success": success, "message": message, "value": value}), status
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@security_bp.route('/api/security/fail2ban/apply-jails', methods=['POST'])
@require_auth
def fail2ban_apply_jails():
File diff suppressed because it is too large Load Diff
+76 -6
View File
@@ -108,6 +108,32 @@ sock = Sock()
# Active terminal sessions
active_sessions = {}
_script_completion_hook = None
_script_completion_hook_lock = threading.Lock()
def set_script_completion_hook(callback):
"""Register the backend hook invoked after a streamed script exits."""
global _script_completion_hook
with _script_completion_hook_lock:
_script_completion_hook = callback
def _run_script_completion_hook(script_path, params, exit_code, duration_seconds):
with _script_completion_hook_lock:
callback = _script_completion_hook
if callback is None:
return
try:
callback(
script_path=script_path,
params=dict(params or {}),
exit_code=int(exit_code),
duration_seconds=max(0, int(duration_seconds)),
)
except Exception as exc:
print(f"[ProxMenux] script completion hook failed: {exc}", flush=True)
@terminal_bp.route('/api/terminal/health', methods=['GET'])
def terminal_health():
"""Health check for terminal service"""
@@ -470,6 +496,7 @@ def script_websocket(ws, session_id):
env['PYTHONUNBUFFERED'] = '1'
env['TERM'] = 'xterm-256color'
script_started_at = time.monotonic()
script_process = subprocess.Popen(
['/bin/bash', script_path],
stdin=slave_fd,
@@ -478,6 +505,15 @@ def script_websocket(ws, session_id):
preexec_fn=os.setsid,
env=env
)
# The child inherited the slave side of the PTY. Keeping the parent's
# duplicate open can prevent the reader from seeing EOF after the script
# exits, which in turn hides the final script_complete message.
try:
os.close(slave_fd)
slave_fd = None
except OSError:
pass
# Set non-blocking mode for master_fd
flags = fcntl.fcntl(master_fd, fcntl.F_GETFL)
@@ -570,9 +606,28 @@ def script_websocket(ws, session_id):
script_process.wait()
exit_code = script_process.returncode if script_process.returncode is not None else 0
threading.Thread(
target=_run_script_completion_hook,
args=(
script_path,
params,
exit_code,
time.monotonic() - script_started_at,
),
daemon=True,
name=f'script-complete-{session_id}',
).start()
try:
ws.send(f'\r\n[Script exited with code {exit_code}]\r\n')
# Send an explicit terminal result before the connection is
# closed. The browser previously saw the worker disappear as a
# generic WebSocket failure even when the script exited with 0.
ws.send(json.dumps({
'type': 'script_complete',
'exit_code': exit_code,
}))
except Exception as e:
pass
@@ -581,10 +636,16 @@ def script_websocket(ws, session_id):
try:
while True:
data = ws.receive(timeout=None)
data = ws.receive(timeout=0.25)
if data is None:
break
if script_process.poll() is not None:
# The output worker owns the final PTY drain and emits
# both `[Script exited with code N]` and
# `script_complete`. Wait briefly for it before cleanup.
output_thread.join(timeout=2.0)
break
continue
try:
msg = json.loads(data)
@@ -625,6 +686,14 @@ def script_websocket(ws, session_id):
break
if script_process.poll() is not None:
# The output worker owns the final PTY drain and emits both
# `[Script exited with code N]` and `script_complete`. A
# resize/ping arriving just after process exit used to make
# this receive loop enter cleanup immediately, closing the
# socket before those final frames were sent. Wait briefly
# for the worker so a normal script exit is delivered before
# teardown.
output_thread.join(timeout=2.0)
break
except Exception as e:
@@ -644,10 +713,11 @@ def script_websocket(ws, session_id):
except:
pass
try:
os.close(slave_fd)
except:
pass
if slave_fd is not None:
try:
os.close(slave_fd)
except:
pass
try:
os.close(web_log_fd)
+289 -146
View File
@@ -19,6 +19,12 @@ from collections import defaultdict
import re
from health_persistence import health_persistence, disk_base_name
from proxmox_known_errors import analyze_oom_event, format_oom_diagnosis
from smartctl_resolver import (
is_usb_disk as resolver_is_usb_disk,
probe_smartctl_json,
smart_json_has_telemetry,
)
try:
from proxmox_storage_monitor import proxmox_storage_monitor
@@ -100,14 +106,6 @@ def _fmt_entity_and_summary(items, singular: str, plural: str, limit: int = _NAM
return title_entity, reason
# USB-NVMe bridges (ASMedia, JMicron, Realtek) answer plain smartctl with
# the *bridge* identity — model shows as "ASMT 2462 NVME" and there is no
# temperature. Only `-d snt*` passes through to the actual NVMe controller
# behind the bridge. For removable disks we try the snt* variants first
# so both identity and health reflect the drive, not the enclosure.
_USB_NVME_DRIVERS = ('sntasmedia', 'sntjmicron', 'sntrealtek')
def _disk_base_for_sysfs(name: str) -> str:
"""Normalize `/dev/sda` / `sda` to just `sda` for `/sys/block/<name>` lookups."""
if name.startswith('/dev/'):
@@ -142,10 +140,13 @@ def _hdd_in_standby(disk_name: str) -> bool:
return False
parked = False
try:
r = subprocess.run(
['smartctl', '-n', 'standby', '-i', f'/dev/{base}'],
capture_output=True, text=True, timeout=5)
parked = r.returncode == 2
result = probe_smartctl_json(
base,
('-n', 'standby', '-i', '-j'),
timeout=5,
require_telemetry=False,
)
parked = bool(result.get('standby'))
except Exception:
parked = False
_standby_cache[base] = (now, parked)
@@ -195,19 +196,8 @@ def _is_disk_removable(disk_name: str) -> bool:
def _is_disk_usb(disk_name: str) -> bool:
"""True if the disk sits behind a USB bus. Reads the resolved sysfs
device path reliable for USB-NVMe bridges and USB-attached HDDs
that report `removable=0` even though they ARE USB (so the older
`_is_disk_removable` heuristic skipped snt* driver probes and left
NVMe-behind-a-bridge disks with the bridge's own chatter cached
forever)."""
try:
base = _disk_base_for_sysfs(disk_name)
real = os.path.realpath(f'/sys/block/{base}')
return any(seg.startswith('usb') and (len(seg) == 3 or seg[3:].isdigit())
for seg in real.split('/'))
except Exception:
return False
"""True when sysfs places the disk behind a USB bus."""
return resolver_is_usb_disk(disk_name)
class HealthMonitor:
"""
@@ -229,7 +219,12 @@ class HealthMonitor:
MEMORY_CRITICAL = 95
MEMORY_DURATION = 300 # 5 minutes sustained (aligned with CPU)
SWAP_WARNING_DURATION = 300
SWAP_CRITICAL_PERCENT = 5
# Swap CRITICAL now requires BOTH: swap file nearly full AND RAM
# genuinely tight. Alerting on just one of them fired constantly on
# healthy Proxmox hosts where the kernel proactively swaps out
# inactive pages while RAM remains plentifully available.
SWAP_HIGH_PERCENT = 80 # % of swap file in use
AVAILABLE_MIN_PERCENT = 15 # % of RAM that must stay available
SWAP_CRITICAL_DURATION = 120
# Storage Thresholds
@@ -445,7 +440,8 @@ class HealthMonitor:
(("cpu", "critical"), "CPU_CRITICAL"),
(("memory", "warning"), "MEMORY_WARNING"),
(("memory", "critical"), "MEMORY_CRITICAL"),
(("memory", "swap_critical"), "SWAP_CRITICAL_PERCENT"),
(("memory", "swap_high"), "SWAP_HIGH_PERCENT"),
(("memory", "available_min"), "AVAILABLE_MIN_PERCENT"),
(("host_storage", "warning"), "STORAGE_WARNING"),
(("host_storage", "critical"), "STORAGE_CRITICAL"),
(("cpu_temperature", "warning"), "TEMP_WARNING"),
@@ -635,12 +631,12 @@ class HealthMonitor:
current_time = time.time()
mem_percent = memory.percent
swap_percent = swap.percent if swap.total > 0 else 0
swap_vs_ram = (swap.used / memory.total * 100) if memory.total > 0 else 0
available_percent = (memory.available / memory.total * 100) if memory.total > 0 else 100
state_key = 'memory_usage'
self.state_history[state_key].append({
'mem_percent': mem_percent,
'swap_percent': swap_percent,
'swap_vs_ram': swap_vs_ram,
'available_percent': available_percent,
'time': current_time
})
# Prune entries older than 10 minutes
@@ -964,6 +960,20 @@ class HealthMonitor:
elif lxc_disk_result.get('status') == 'WARNING':
warning_issues.append(lxc_disk_result.get('reason', 'LXC rootfs filling up'))
# QEMU VM filesystem usage via guest agent — mirrors the LXC
# rootfs check but reads from the guest agent because pvesh
# reports disk=0 for most QEMU storage backends. VMs without
# a responsive agent are silently skipped (no signal ≠ OK).
_t = time.time()
vm_disk_result = self._check_vm_disk_usage()
_perf_log("vm_disk_usage", (time.time() - _t) * 1000)
if vm_disk_result:
details['vm_disk'] = vm_disk_result
if vm_disk_result.get('status') == 'CRITICAL':
critical_issues.append(vm_disk_result.get('reason', 'VM filesystems near full'))
elif vm_disk_result.get('status') == 'WARNING':
warning_issues.append(vm_disk_result.get('reason', 'VM filesystems filling up'))
# Phase 3 capacity checks added on top of the existing storage
# ones. Each is independently configurable via Settings →
# Health Thresholds; defaults are 85/95 to align with the host
@@ -1601,30 +1611,36 @@ class HealthMonitor:
def _check_memory_comprehensive(self) -> Dict[str, Any]:
"""
Check memory including RAM and swap with realistic thresholds.
Only alerts on truly problematic memory situations.
Swap CRITICAL requires the memory-pressure AND-clause: swap is
called out only when the swap file is nearly full AND RAM is
genuinely tight (available memory below the configured floor).
Alerting on swap size alone fires constantly on healthy hosts
where Linux proactively swaps out inactive pages the user
can't act on that signal and it drowns real pressure events.
"""
try:
memory = psutil.virtual_memory()
swap = psutil.swap_memory()
current_time = time.time()
mem_percent = memory.percent
swap_percent = swap.percent if swap.total > 0 else 0
swap_vs_ram = (swap.used / memory.total * 100) if memory.total > 0 else 0
available_percent = (memory.available / memory.total * 100) if memory.total > 0 else 100
state_key = 'memory_usage'
self.state_history[state_key].append({
'mem_percent': mem_percent,
'swap_percent': swap_percent,
'swap_vs_ram': swap_vs_ram,
'available_percent': available_percent,
'time': current_time
})
self.state_history[state_key] = [
entry for entry in self.state_history[state_key]
if current_time - entry['time'] < 600
]
mem_critical_samples = [
entry for entry in self.state_history[state_key]
if entry['mem_percent'] >= 90 and
@@ -1637,10 +1653,15 @@ class HealthMonitor:
current_time - entry['time'] <= self.MEMORY_DURATION
]
# Swap CRITICAL requires BOTH conditions sustained. Older
# samples predating the new `available_percent` field are
# skipped rather than defaulted to a passing value so the
# transition period never manufactures a false positive.
swap_critical = sum(
1 for entry in self.state_history[state_key]
if entry['swap_vs_ram'] > 20 and
current_time - entry['time'] <= self.SWAP_CRITICAL_DURATION
if entry['swap_percent'] > self.SWAP_HIGH_PERCENT
and entry.get('available_percent', 100) < self.AVAILABLE_MIN_PERCENT
and current_time - entry['time'] <= self.SWAP_CRITICAL_DURATION
)
# Require sustained high usage across most of the 300s window.
@@ -1659,7 +1680,8 @@ class HealthMonitor:
reason = f'RAM >90% sustained for {actual_duration}s'
elif swap_critical >= 2:
status = 'CRITICAL'
reason = f'Swap >20% of RAM ({swap_vs_ram:.1f}%)'
reason = (f'Memory pressure: swap {swap_percent:.0f}% used '
f'and only {available_percent:.0f}% RAM available')
elif mem_warning_count >= MEM_WARNING_MIN_SAMPLES:
oldest = min(s['time'] for s in mem_warning_samples)
actual_duration = int(current_time - oldest)
@@ -1668,12 +1690,12 @@ class HealthMonitor:
else:
status = 'OK'
reason = None
ram_avail_gb = round(memory.available / (1024**3), 2)
ram_total_gb = round(memory.total / (1024**3), 2)
swap_used_gb = round(swap.used / (1024**3), 2)
swap_total_gb = round(swap.total / (1024**3), 2)
# Determine per-sub-check status
ram_status = 'CRITICAL' if mem_percent >= 90 and mem_critical_count >= MEM_CRITICAL_MIN_SAMPLES else ('WARNING' if mem_percent >= self.MEMORY_WARNING and mem_warning_count >= MEM_WARNING_MIN_SAMPLES else 'OK')
swap_status = 'CRITICAL' if swap_critical >= 2 else 'OK'
@@ -1687,11 +1709,16 @@ class HealthMonitor:
'checks': {
'ram_usage': {
'status': ram_status,
'detail': 'High RAM usage sustained' if ram_status != 'OK' else 'Normal'
'detail': 'High RAM usage sustained' if ram_status != 'OK' else 'Normal',
'dismissable': True,
},
'swap_usage': {
'status': swap_status,
'detail': 'Excessive swap usage' if swap_status != 'OK' else ('Normal' if swap.total > 0 else 'No swap configured')
'detail': (
'Swap nearly full with RAM tight' if swap_status != 'OK'
else ('Normal' if swap.total > 0 else 'No swap configured')
),
'dismissable': True,
}
}
}
@@ -2043,21 +2070,9 @@ class HealthMonitor:
is_usb = tran == 'USB'
is_nvme = disk_name.startswith('nvme')
# Get serial from smartctl
serial = ''
model = ''
try:
smart_result = subprocess.run(
['smartctl', '-i', '-j', f'/dev/{disk_name}'],
capture_output=True, text=True, timeout=5
)
if smart_result.returncode in (0, 4): # 4 = SMART not available but info OK
import json
smart_data = json.loads(smart_result.stdout)
serial = smart_data.get('serial_number', '')
model = smart_data.get('model_name', '') or smart_data.get('model_family', '')
except Exception:
pass
identity = self._get_disk_identity(disk_name)
serial = identity.get('serial', '')
model = identity.get('model', '')
physical_disks[disk_name] = {
'serial': serial,
@@ -2568,35 +2583,15 @@ class HealthMonitor:
try:
dev_path = f'/dev/{disk_name}' if not disk_name.startswith('/') else disk_name
# USB-attached disks may sit behind an NVMe bridge: try the
# snt* driver variants first so identity reflects the drive
# (Samsung 990 PRO) rather than the enclosure (ASMT 2462 NVME).
# If all snt* fail, fall through to the plain call — that's
# still correct for USB-SATA sticks and non-USB devices.
# USB detection is by sysfs path (`_is_disk_usb`) rather than
# the `removable` flag, since USB-NVMe and USB-HDD both report
# `removable=0` even though they ARE USB.
attempts = []
if _is_disk_usb(disk_name) or _is_disk_removable(disk_name):
for drv in _USB_NVME_DRIVERS:
attempts.append(['smartctl', '-i', '-j', '-d', drv, dev_path])
attempts.append(['smartctl', '-i', '-j', dev_path])
import json as _json
for cmd in attempts:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
if proc.returncode not in (0, 4):
continue
try:
data = _json.loads(proc.stdout)
except Exception:
continue
serial = data.get('serial_number', '')
model = data.get('model_name', '') or data.get('model_family', '')
if serial or model:
result['serial'] = serial
result['model'] = model
break
probe = probe_smartctl_json(
dev_path,
('-i', '-j'),
timeout=5,
require_telemetry=False,
)
data = probe.get('data', {})
result['serial'] = data.get('serial_number', '')
result['model'] = data.get('model_name', '') or data.get('model_family', '')
except Exception:
pass
@@ -2629,49 +2624,24 @@ class HealthMonitor:
try:
dev_path = f'/dev/{disk_name}' if not disk_name.startswith('/') else disk_name
# `-n standby` skips the command (exit code 2, no disk I/O)
# when the drive is parked, preventing the health poller
# from spinning up HDDs that hdparm / hd-idle just put to
# sleep — issue #232. The "UNKNOWN" branch below correctly
# keeps the previous cached result alive on exit code 2.
#
# USB-attached disks may sit behind an NVMe bridge: try snt*
# drivers first so health reflects the actual NVMe controller.
# A bridge that fakes "PASSED" while the drive behind it is
# failing is exactly the false-negative we want to avoid.
# USB detection uses the sysfs path so USB-NVMe bridges (which
# report removable=0) are caught too.
attempts = []
if _is_disk_usb(disk_name) or _is_disk_removable(disk_name):
for drv in _USB_NVME_DRIVERS:
attempts.append(['smartctl', '-n', 'standby', '--health', '-j', '-d', drv, dev_path])
attempts.append(['smartctl', '-n', 'standby', '--health', '-j', dev_path])
probe = probe_smartctl_json(
dev_path,
('-n', 'standby', '-a', '-j'),
timeout=5,
require_telemetry=True,
)
if probe.get('standby'):
return cached['result'] if cached else 'UNKNOWN'
import json as _json
smart_result = None
for cmd in attempts:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
if result.returncode == 2:
# Drive in standby — reuse the previous health state
# if we have one, otherwise report UNKNOWN. Either way,
# don't refresh the cache TTL so we retry on the next
# cycle (a drive can come out of standby at any time).
if cached:
return cached['result']
return 'UNKNOWN'
try:
data = _json.loads(result.stdout)
except Exception:
continue
passed = data.get('smart_status', {}).get('passed', None)
if passed is True:
smart_result = 'PASSED'
break
if passed is False:
smart_result = 'FAILED'
break
# No opinion yet — next attempt (fallthrough to plain).
if smart_result is None:
data = probe.get('data', {})
passed = data.get('smart_status', {}).get('passed')
if _is_disk_usb(disk_name) and not smart_json_has_telemetry(data):
smart_result = 'UNKNOWN'
elif passed is True:
smart_result = 'PASSED'
elif passed is False:
smart_result = 'FAILED'
else:
smart_result = 'UNKNOWN'
# Cache the result with the device fingerprint for hot-swap invalidation
@@ -4081,10 +4051,21 @@ class HealthMonitor:
return reason
# Out of memory
if 'out of memory' in line_lower or 'oom_kill' in line_lower:
m = re.search(r'Killed process\s+\d+\s+\(([^)]+)\)', line)
process = m.group(1) if m else 'unknown'
return f'Out of memory - system killed process "{process}" to free RAM'
if any(token in line_lower for token in (
'out of memory', 'oom_kill', 'oom-kill', 'invoked oom-killer', 'oom_reaper'
)):
victim = re.search(r'Killed process\s+\d+\s+\(([^)]+)\)', line, re.IGNORECASE)
if victim:
return f'Memory pressure - kernel killed process "{victim.group(1)}"'
invoker = re.search(r'(?:kernel:\s*)?([^\s:]+)\s+invoked oom-killer', line, re.IGNORECASE)
if invoker:
return (
f'Memory pressure triggered the OOM killer while "{invoker.group(1)}" '
'requested memory; this process is not necessarily the main consumer'
)
return 'Memory pressure triggered the OOM killer; inspect the complete kernel OOM block'
# Kernel panic
if 'kernel panic' in line_lower:
@@ -4206,6 +4187,9 @@ class HealthMonitor:
if result_recent.returncode == 0:
recent_lines = result_recent.stdout.strip().split('\n')
previous_lines = result_previous.stdout.strip().split('\n') if result_previous.returncode == 0 else []
recent_oom_analysis = analyze_oom_event(result_recent.stdout)
recent_oom_reason = format_oom_diagnosis(recent_oom_analysis)
processed_oom_patterns = set()
recent_patterns = defaultdict(int)
previous_patterns = defaultdict(int)
@@ -4226,7 +4210,22 @@ class HealthMonitor:
continue
# Normalize to a pattern for grouping
pattern = self._normalize_log_pattern(line)
is_oom_line = any(token in line.lower() for token in (
'out of memory', 'oom_kill', 'oom-kill',
'invoked oom-killer', 'oom_reaper'
))
if is_oom_line and recent_oom_analysis:
scope = recent_oom_analysis.get('scope') or 'unknown'
scope_id = recent_oom_analysis.get('ctid') \
or recent_oom_analysis.get('cgroup_path') \
or 'unknown'
victim = recent_oom_analysis.get('victim_process') or 'unknown'
pattern = f'oom_event_{scope}_{scope_id}_{victim}'
if pattern in processed_oom_patterns:
continue
processed_oom_patterns.add(pattern)
else:
pattern = self._normalize_log_pattern(line)
if severity == 'CRITICAL':
pattern_hash = hashlib.md5(pattern.encode()).hexdigest()[:8]
@@ -4267,7 +4266,10 @@ class HealthMonitor:
if severity == 'CRITICAL':
critical_errors_found[pattern] = line
# Build a human-readable reason from the raw log line
enriched_reason = self._enrich_critical_log_reason(line)
if is_oom_line and recent_oom_reason:
enriched_reason = recent_oom_reason
else:
enriched_reason = self._enrich_critical_log_reason(line)
# Append SMART context to the reason if we checked it
if smart_status_for_log == 'PASSED':
@@ -4284,9 +4286,13 @@ class HealthMonitor:
category='logs',
severity=severity,
reason=enriched_reason,
details={'pattern': pattern, 'raw_line': line[:200],
'smart_status': smart_status_for_log,
'dismissable': True}
details={
'pattern': pattern,
'raw_line': line[:200],
'smart_status': smart_status_for_log,
'oom_analysis': recent_oom_analysis if is_oom_line else None,
'dismissable': True,
}
)
# Cross-reference: filesystem errors also belong in the disks category
@@ -4334,14 +4340,8 @@ class HealthMonitor:
try:
obs_serial = None
try:
sm = subprocess.run(
['smartctl', '-i', f'/dev/{base_device}'],
capture_output=True, text=True, timeout=3)
if sm.returncode in (0, 4):
for sline in sm.stdout.split('\n'):
if 'Serial Number' in sline or 'Serial number' in sline:
obs_serial = sline.split(':')[-1].strip()
break
identity = self._get_disk_identity(base_device)
obs_serial = identity.get('serial') or None
except Exception:
pass
health_persistence.record_disk_observation(
@@ -6262,6 +6262,149 @@ class HealthMonitor:
'checks': checks,
}
def _check_vm_disk_usage(self) -> Optional[Dict[str, Any]]:
"""QEMU VM filesystem usage via the guest agent.
Sibling of ``_check_lxc_disk_usage`` that closes the analogous
gap for VMs: ``pvesh cluster resources`` reports ``disk=0`` for
most QEMU storage backends (PVE can't see inside the guest),
so this check asks the guest agent directly for every running
QEMU VM and emits WARNING at 85% / CRITICAL at 95% same
defaults as the LXC counterpart. The aggregated total includes
every persistent filesystem the guest reports as backed by a
block device, PCI-passthrough drives included: the metric is
"how full is the guest", not "how full is the virtual disk
PVE knows about", which is intentionally more useful for
appliances like TrueNAS or a Synology VM.
VMs whose agent is absent, unreachable, times out, or reports
no usable data are skipped no false OK, no false alert.
Reads the pre-computed cache maintained by the daemon refresher
in ``flask_server``; never spawns a subprocess on the check
path, so a slow / dead guest agent can't stretch the health
cycle.
"""
try:
import flask_server # deferred — avoids circular import
resources = flask_server.get_cached_pvesh_cluster_resources_vm() or []
except Exception as e:
print(f"[HealthMonitor] VM disk check failed: {e}")
return None
# Cheap short-circuit: no running QEMU VMs on this node.
if not any(
r.get('type') in ('qemu', 'vm') and r.get('status') == 'running'
for r in resources
):
return None
WARN_PCT, CRIT_PCT = self._read_capacity_thresholds('vm_disk', fb_warn=85, fb_crit=95)
checks: Dict[str, Dict[str, Any]] = {}
critical_vms: list[str] = []
warning_vms: list[str] = []
emitted_keys: set[str] = set()
for r in resources:
if r.get('type') not in ('qemu', 'vm'):
continue
if r.get('status') != 'running':
continue
vmid = r.get('vmid')
if vmid is None:
continue
try:
computed = flask_server.get_cached_vm_disk(vmid)
except Exception:
computed = None
if computed is None:
continue
used, total = computed
if total <= 0:
continue
pct = (used / total) * 100
vmid_str = str(vmid)
name = r.get('name', '') or ''
label = f'VM {vmid_str}' + (f' ({name})' if name else '')
entry: Dict[str, Any] = {
'detail': f'guest filesystems {pct:.1f}% used ({used // (1024**2)} MB / {total // (1024**2)} MB)',
'usage_percent': round(pct, 1),
'disk_bytes': used,
'maxdisk_bytes': total,
'vmid': vmid_str,
'name': name,
}
error_key = f'vm_disk_{vmid_str}'
if pct >= CRIT_PCT:
entry['status'] = 'CRITICAL'
entry['error_key'] = error_key
entry['dismissable'] = True
checks[label] = entry
critical_vms.append(label)
emitted_keys.add(error_key)
health_persistence.record_error(
error_key=error_key,
category='storage',
severity='CRITICAL',
reason=f'{label} filesystems at {pct:.1f}% ({used // (1024**2)} MB / {total // (1024**2)} MB)',
details=entry,
)
elif pct >= WARN_PCT:
entry['status'] = 'WARNING'
entry['error_key'] = error_key
entry['dismissable'] = True
checks[label] = entry
warning_vms.append(label)
emitted_keys.add(error_key)
health_persistence.record_error(
error_key=error_key,
category='storage',
severity='WARNING',
reason=f'{label} filesystems at {pct:.1f}% ({used // (1024**2)} MB / {total // (1024**2)} MB)',
details=entry,
)
else:
entry['status'] = 'OK'
checks[label] = entry
# Clear stale VM disk errors (VM stopped, agent lost, freed up).
for err in (health_persistence.get_active_errors() or []):
ek = err.get('error_key', '')
if not ek.startswith('vm_disk_'):
continue
if ek not in emitted_keys:
health_persistence.clear_error(ek)
if not checks:
return None
if critical_vms:
entity, _ = _fmt_entity_and_summary(critical_vms, 'x', 'x')
return {
'status': 'CRITICAL',
'reason': f'{len(critical_vms)} VM(s) at >{CRIT_PCT}% filesystems: {_fmt_name_list(critical_vms)}',
'entity': entity,
'checks': checks,
}
if warning_vms:
entity, _ = _fmt_entity_and_summary(warning_vms, 'x', 'x')
return {
'status': 'WARNING',
'reason': f'{len(warning_vms)} VM(s) at >{WARN_PCT}% filesystems: {_fmt_name_list(warning_vms)}',
'entity': entity,
'checks': checks,
}
return {
'status': 'OK',
'reason': f'{len(checks)} running VM(s) within safe filesystem usage',
'checks': checks,
}
# ─── Phase 3 capacity checks ─────────────────────────────────────────────
#
# Three sibling methods that all share the same shape:
+6
View File
@@ -924,6 +924,12 @@ class HealthPersistence:
for cat, prefix in [('updates', 'security_updates'), ('updates', 'system_age'),
('updates', 'pending_updates'), ('updates', 'kernel_pve'),
('security', 'security_'),
# `vm_disk_<vmid>` is a storage-category key that WOULD otherwise
# match the `vm_` prefix below and end up mis-tagged under `vms`;
# putting the storage-specific override first keeps the Dismiss
# flow honest (invalidates the storage cache, groups with the
# other capacity events).
('storage', 'vm_disk_'),
('pve_services', 'pve_service_'), ('vms', 'vmct_'), ('vms', 'vm_'), ('vms', 'ct_'),
# ── Storage keys — HealthMonitor emits these under `storage` category
# but they used to fall through to 'general' here because no prefix
+16 -1
View File
@@ -65,7 +65,12 @@ DEFAULTS: dict[str, Any] = {
"memory": {
"warning": {"value": 85, "unit": "%", "min": 1, "max": 100, "step": 1},
"critical": {"value": 95, "unit": "%", "min": 1, "max": 100, "step": 1},
"swap_critical": {"value": 5, "unit": "%", "min": 1, "max": 100, "step": 1},
# Swap CRITICAL requires BOTH to hold: swap_high AND
# available_min. Alerting on swap alone was too noisy on
# Proxmox hosts where Linux proactively swaps inactive pages
# while RAM stays plentifully available.
"swap_high": {"value": 80, "unit": "%", "min": 1, "max": 100, "step": 1},
"available_min": {"value": 15, "unit": "%", "min": 1, "max": 100, "step": 1},
},
"host_storage": {
"warning": {"value": 85, "unit": "%", "min": 1, "max": 100, "step": 1},
@@ -75,6 +80,16 @@ DEFAULTS: dict[str, Any] = {
"warning": {"value": 85, "unit": "%", "min": 1, "max": 100, "step": 1},
"critical": {"value": 95, "unit": "%", "min": 1, "max": 100, "step": 1},
},
"vm_disk": {
# Aggregate guest-filesystem usage for running QEMU VMs, read
# from the guest agent (mirrors `lxc_rootfs` for VMs). Includes
# every persistent filesystem the guest reports on a block
# device, so PCI-passthrough drives and add-on storage count
# towards the threshold — the metric is "how full is the
# guest", not "how full is the disk PVE knows about".
"warning": {"value": 85, "unit": "%", "min": 1, "max": 100, "step": 1},
"critical": {"value": 95, "unit": "%", "min": 1, "max": 100, "step": 1},
},
"cpu_temperature": {
"warning": {"value": 80, "unit": "°C", "min": 30, "max": 120, "step": 1},
"critical": {"value": 90, "unit": "°C", "min": 30, "max": 120, "step": 1},
+13 -2
View File
@@ -4,7 +4,7 @@ Provides decorator to protect Flask routes with JWT authentication
Automatically checks auth status and validates tokens
"""
from flask import request, jsonify
from flask import request, jsonify, g
from functools import wraps
from auth_manager import load_auth_config, verify_token, verify_token_full
@@ -26,9 +26,20 @@ def require_auth(f):
"""
@wraps(f)
def decorated_function(*args, **kwargs):
# Internal calls (background prewarmers, in-process cache
# refresh) bypass auth. Set `g._internal_call = True` inside
# an `app.test_request_context()` block before invoking a
# decorated handler — the flag lives only for that context so
# a real HTTP request can never accidentally inherit it.
try:
if getattr(g, '_internal_call', False):
return f(*args, **kwargs)
except RuntimeError:
pass # No request context yet — treat as normal auth flow.
# Check if authentication is enabled
config = load_auth_config()
# If auth is disabled or declined, allow access
if not config.get("enabled", False) or config.get("declined", False):
return f(*args, **kwargs)
File diff suppressed because it is too large Load Diff
+223 -8
View File
@@ -539,18 +539,233 @@ def _stat_via_host(host_pid: str, ct_target: str,
# ---------------------------------------------------------------------------
def get_lxc_mount_points(vmid: str) -> dict[str, Any]:
"""Top-level entry point used by the Flask route.
def get_lxc_mount_points_static(vmid: str) -> dict[str, Any]:
"""Static half of the mount-points payload — safe to cache
indefinitely because it only reads config and classifies against
PVE's storage inventory.
Returns:
- ``ok`` (bool)
- ``vmid`` (str)
- ``mount_points`` list of configured mp0/mp1/... entries with
source / target / type / origin classification / host source
existence flags. No `df`, no `stat`, no ad-hoc detection.
The runtime enrichment (capacity, health, ad-hoc mounts,
runtime_mounted flag) lives in `get_lxc_mount_points_runtime`
and is fetched fresh on every modal open by the client. That
split lets the backend cache this half indefinitely (with event
invalidation on start/stop) while still giving the user real-
time capacity when they actually look."""
if not re.match(r"^\d+$", vmid):
return {"ok": False, "error": "invalid vmid"}
config_entries = _read_lxc_config(vmid)
pve_storages = _list_pve_storages()
out: list[dict[str, Any]] = []
for entry in config_entries:
source = entry.get("source", "")
target = entry.get("target", "")
cls = _classify(source, pve_storages)
host_src = _host_source_state(source)
out.append({
"mp_index": entry.get("mp_index", ""),
"source": source,
"target": target,
"type": cls["type"],
"origin_storage": cls.get("origin_storage", ""),
"origin_storage_type": cls.get("origin_storage_type", ""),
"origin_label": cls.get("origin_label", source),
"config_options": entry.get("config_options", {}),
"config_flags": entry.get("config_flags", []),
"host_source_exists": host_src["exists"],
"host_source_is_mountpoint": host_src["is_mountpoint"],
})
# Cheap hint so the client can render the Mount Points tab
# immediately for CTs that ONLY have ad-hoc NFS/CIFS mounts done
# from inside the container (nothing in .conf, so `out` is
# empty). Without this hint the tab appears only after the
# runtime endpoint returns 200-500 ms later, pushing the other
# tabs sideways. Reading /proc/<pid>/mounts is a pure file read
# (~1 ms, no subprocess), filter by remote fs family so only
# storage counts — plain bind mounts of /dev/* passthrough
# devices don't inflate the count.
#
# IMPORTANT: exclude runtime targets that match a declared mp.
# When a host mp source is itself a remote share (e.g. mp0 binds
# /mnt/pve/Piblic which is a CIFS mount on the host), the same
# mount surfaces in /proc/<pid>/mounts with an `nfs`/`cifs`
# fstype from the CT's perspective. Without the filter the hint
# double-counted it, so the badge showed mp+1 when the tab really
# only had `mp` cards to render.
ad_hoc_hint_count = 0
running, host_pid = _ct_status(vmid)
if running and host_pid:
try:
config_targets = {
entry.get("target", "")
for entry in config_entries
if entry.get("target")
}
for rt in _read_ct_proc_mounts(host_pid):
if not _REMOTE_FS_RE.match(rt.get("rt_fstype", "")):
continue
if rt.get("rt_target") in config_targets:
continue
ad_hoc_hint_count += 1
except Exception:
pass
return {
"ok": True,
"vmid": vmid,
"mount_points": out,
"ad_hoc_hint_count": ad_hoc_hint_count,
}
def get_lxc_mount_points_runtime(vmid: str) -> dict[str, Any]:
"""Runtime half — always fresh, no cache. Fetched by the client
every time the Mount Points tab is opened so the operator sees
live capacity + reachability, plus any ad-hoc NFS/CIFS mounts
the container itself has made since the last static snapshot.
Returns:
- ``ok`` (bool)
- ``vmid`` (str)
- ``running`` (bool)
- ``mount_points`` list of configured mp0/mp1/... entries
- ``ad_hoc`` list of NFS/CIFS/SMB mounts found inside the running
CT that aren't backed by an mp config line
"""
# Validate vmid format — the value comes from a URL parameter, so
# we keep it strict to avoid path-traversal weirdness.
- ``runtime`` dict keyed by target, containing runtime state
+ capacity per configured mount point
- ``ad_hoc`` list of NFS/CIFS/SMB mounts done inside the CT
that aren't backed by an mp config line
The client merges `runtime[target]` onto the matching card from
the static payload; ad-hoc mounts render as their own cards
under a "Mounted inside container" divider. If the CT is down
or the client had no static payload for a target, the tab still
renders whatever runtime info is available (never blanks)."""
if not re.match(r"^\d+$", vmid):
return {"ok": False, "error": "invalid vmid"}
config_entries = _read_lxc_config(vmid)
pve_storages = _list_pve_storages()
running, host_pid = _ct_status(vmid)
rt_mounts = _read_ct_proc_mounts(host_pid) if running else []
# Same parallelisation as the pre-split path: `df`/`stat` per
# mount point are I/O-bound. Serialised, a CT with 5+ binds
# tripped Caddy's 3s reverse-proxy timeout.
from concurrent.futures import ThreadPoolExecutor
rt_by_target: dict[str, dict[str, Any]] = {m["rt_target"]: m for m in rt_mounts}
runtime_by_target: dict[str, dict[str, Any]] = {}
matched_targets: set[str] = set()
def _gather_one(entry):
src = entry.get("source", "")
tgt = entry.get("target", "")
classification = _classify(src, pve_storages)
capacity = _capacity_for(
src, classification, pve_storages,
config_options=entry.get("config_options", {}),
host_pid=host_pid if running else "",
target=tgt,
)
live_target = bool(running and tgt and tgt in rt_by_target)
health = _stat_via_host(host_pid, tgt) if live_target else None
return entry, capacity, live_target, health
if config_entries:
max_workers = max(2, min(8, len(config_entries)))
with ThreadPoolExecutor(max_workers=max_workers) as pool:
gathered = list(pool.map(_gather_one, config_entries))
else:
gathered = []
for entry, cap, live_target, health in gathered:
target = entry.get("target", "")
rt_item: dict[str, Any] = {**cap}
if live_target:
rt = rt_by_target[target]
rt_item.update({
"runtime_mounted": True,
"runtime_source": rt["rt_source"],
"runtime_fstype": rt["rt_fstype"],
"runtime_options": rt["rt_options"],
"runtime_readonly": rt["rt_readonly"],
"runtime_reachable": health["reachable"],
"runtime_error": health["error"],
})
matched_targets.add(target)
elif running:
rt_item["runtime_mounted"] = False
rt_item["runtime_error"] = "configured but not mounted"
else:
rt_item["runtime_mounted"] = None # CT down
runtime_by_target[target] = rt_item
# Ad-hoc remote mounts inside the running CT — same logic and
# parallelisation as before.
ad_hoc: list[dict[str, Any]] = []
if running:
ad_hoc_candidates = [
rt for rt in rt_mounts
if rt["rt_target"] not in matched_targets
and _REMOTE_FS_RE.match(rt["rt_fstype"])
]
if ad_hoc_candidates:
max_workers = max(2, min(8, len(ad_hoc_candidates)))
with ThreadPoolExecutor(max_workers=max_workers) as pool:
def _gather_adhoc(rt):
h = _stat_via_host(host_pid, rt["rt_target"])
if h.get("reachable"):
cap = _df_via_pct_exec(vmid, rt["rt_target"])
else:
cap = {"total_bytes": None, "used_bytes": None,
"available_bytes": None}
return rt, h, cap
results = list(pool.map(_gather_adhoc, ad_hoc_candidates))
for rt, health, cap in results:
ad_hoc.append({
"mp_index": "",
"source": rt["rt_source"],
"target": rt["rt_target"],
"type": "ad_hoc",
"origin_storage": "",
"origin_storage_type": "",
"origin_label": rt["rt_source"],
"config_options": {},
"config_flags": [],
"total_bytes": cap["total_bytes"],
"used_bytes": cap["used_bytes"],
"available_bytes": cap["available_bytes"],
"runtime_mounted": True,
"runtime_source": rt["rt_source"],
"runtime_fstype": rt["rt_fstype"],
"runtime_options": rt["rt_options"],
"runtime_readonly": rt["rt_readonly"],
"runtime_reachable": health["reachable"],
"runtime_error": health["error"],
})
return {
"ok": True,
"vmid": vmid,
"running": running,
"runtime": runtime_by_target,
"ad_hoc": ad_hoc,
}
def get_lxc_mount_points(vmid: str) -> dict[str, Any]:
"""Legacy combined entry point — kept for backwards compatibility
with any caller that still wants the pre-split shape. New code
should hit the static/runtime pair separately.
Merges the two halves so the returned dict matches what the
single-endpoint route used to return before the split."""
if not re.match(r"^\d+$", vmid):
return {"ok": False, "error": "invalid vmid"}
+613 -198
View File
@@ -167,6 +167,26 @@ def _detect_nvidia_xfree86() -> Optional[dict]:
# libedgetpu1-std from Google's apt repo).
def _coral_pcie_hardware_present() -> bool:
"""True when a Coral PCIe/M.2 device (vendor 0x1ac1, Global Unichip
Corp.) is visible on the PCI bus. Used together with the gasket-dkms
package state to detect orphan installs left behind by the legacy
installer (`scripts/install_coral_pve.sh` before 2026-04) that
installed the DKMS driver unconditionally on USB-only hosts."""
try:
for entry in os.listdir("/sys/bus/pci/devices"):
try:
with open(f"/sys/bus/pci/devices/{entry}/vendor",
"r", encoding="utf-8") as fh:
if fh.read().strip() == "0x1ac1":
return True
except OSError:
continue
except OSError:
pass
return False
def _detect_coral_host() -> list[dict]:
out: list[dict] = []
@@ -180,61 +200,105 @@ def _detect_coral_host() -> list[dict]:
# knows the fork's patch level.
# 2. `dpkg-query gasket-dkms` — the Debian package version, only
# present when the user installed via .deb rather than the
# ProxMenux script.
# ProxMenux script. Package state matters: only `ok installed`
# is trusted as a real version; broken states surface as
# "package present but not usable" so the UI can offer cleanup
# instead of a spurious "update available".
# 3. `dkms status` — the upstream module version registered with
# DKMS, which is always the bare `1.0`. Useful as a "modules
# are present" indicator but doesn't reveal the fork patch
# level, so the update-availability check would always fire a
# false positive against feranick's `1.0-N` tags. Reported on
# .50 after a successful re-install kept showing the update
# notification.
pcie_version: Optional[str] = None
# false positive against feranick's `1.0-N` tags.
#
# Orphan detection: gasket-dkms package present + no PCIe/M.2
# hardware = residue from the legacy installer. `_gasket_orphan`
# is exposed so `install_coral.sh` and the notification pipeline
# can offer cleanup without ever calling it "an update".
pcie_hw_present = _coral_pcie_hardware_present()
marker_version: Optional[str] = None
try:
with open("/var/lib/proxmenux/coral_gasket_version",
"r", encoding="utf-8", errors="replace") as fh:
marker = fh.read().strip()
# Sanity check: the file should hold something that looks
# like a version tag, not an error message or empty line.
if marker and re.match(r"^[A-Za-z0-9._+-]+$", marker):
pcie_version = marker
marker_version = marker
except OSError:
pass
if not pcie_version:
try:
r = subprocess.run(
["dpkg-query", "-W", "-f=${Status}|${Version}", "gasket-dkms"],
capture_output=True, text=True, timeout=3,
)
if r.returncode == 0 and "ok installed" in r.stdout:
pcie_version = r.stdout.split("|", 1)[1].strip()
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
pass
if not pcie_version:
try:
r = subprocess.run(
["dkms", "status"], capture_output=True, text=True, timeout=3,
)
if r.returncode == 0:
for line in r.stdout.splitlines():
if line.startswith("gasket"):
# "gasket, 1.0, ..." or "gasket/1.0, ..."
m = re.match(r"^gasket[, /]([^,\s]+)", line)
if m:
pcie_version = m.group(1)
break
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
pass
if pcie_version:
out.append({
# gasket-dkms package inspection: state + version, kept separate.
dpkg_state: str = "absent" # "healthy" | "broken" | "absent"
dpkg_version: Optional[str] = None
try:
r = subprocess.run(
["dpkg-query", "-W", "-f=${Status}|${Version}", "gasket-dkms"],
capture_output=True, text=True, timeout=3,
)
if r.returncode == 0 and "|" in r.stdout:
status_part, _, version_part = r.stdout.partition("|")
if "ok installed" in status_part:
dpkg_state = "healthy"
dpkg_version = version_part.strip() or None
elif any(tok in status_part for tok in (
"half-configured", "half-installed", "unpacked",
"failed-config", "reinst-required", "trigger",
)):
dpkg_state = "broken"
dpkg_version = version_part.strip() or None
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
pass
# Version resolution: emit only when we can actually trust it, i.e.
# the hardware is present AND either we have a marker file or the
# package is healthy. On broken or orphan states we intentionally
# omit `current_version` so the update comparator never fires a
# false "update available" against feranick's tags.
pcie_version: Optional[str] = None
if pcie_hw_present:
if marker_version:
pcie_version = marker_version
elif dpkg_state == "healthy" and dpkg_version:
pcie_version = dpkg_version
else:
# Fallback to dkms status ONLY when hardware is present and
# no better source exists. Kept for backwards compatibility
# with hosts that lost the marker file after a manual dkms
# rebuild but still have working hardware + working modules.
try:
r = subprocess.run(
["dkms", "status"], capture_output=True, text=True, timeout=3,
)
if r.returncode == 0:
for line in r.stdout.splitlines():
if line.startswith("gasket"):
m = re.match(r"^gasket[, /]([^,\s]+)", line)
if m:
pcie_version = m.group(1)
break
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
pass
is_orphan = (dpkg_state != "absent") and not pcie_hw_present
# Emit the entry whenever we have a trustworthy version OR whenever
# there is package state to surface (broken / orphan). This lets the
# frontend and the notification pipeline see both healthy installs
# and the two remediation cases in the same registry shape.
if pcie_version or dpkg_state != "absent":
entry = {
"id": "coral-host-pcie",
"type": "coral_host",
"name": "Coral TPU Driver (gasket-dkms)",
"current_version": pcie_version,
"menu_label": "GPU & TPU → Coral TPU",
"menu_script": "scripts/gpu_tpu/install_coral.sh",
"_coral_variant": "pcie",
})
"_gasket_pkg_state": dpkg_state,
"_gasket_orphan": is_orphan,
"_gasket_pcie_hardware_present": pcie_hw_present,
}
if pcie_version:
entry["current_version"] = pcie_version
out.append(entry)
# USB — libedgetpu1-std (default) or libedgetpu1-max if the user
# opted into the overclocked runtime. Either one means the USB
@@ -297,10 +361,40 @@ def _detect_oci_apps() -> list[dict]:
# Stash the raw app_id so the checker can find it without
# parsing the prefixed registry id.
"_oci_app_id": app_id,
# Cache the CT vmid so `_detect_lxc_containers` can flag the
# matching LXC row as OCI-managed (avoids the LXC update flow
# competing with the Secure Gateway panel's own updater).
"_vmid": app.get("vmid"),
})
return out
def _get_oci_managed_vmids() -> dict[str, str]:
"""Return {vmid_str: oci_app_id} for every CT under oci_manager.
Used by `_detect_lxc_containers` to route those CTs through the
Secure Gateway update flow instead of the generic apt/apk path
the two share the same `apk upgrade` at the bottom but the OCI
manager also does app-specific hooks (e.g. restarting tailscale
when the package moved) that the generic runner is blind to.
"""
try:
import oci_manager
except Exception:
return {}
try:
installed = oci_manager.list_installed_apps() or []
except Exception:
return {}
mapping: dict[str, str] = {}
for app in installed:
vmid = app.get("vmid")
app_id = app.get("id") or app.get("app_id")
if vmid is None or not app_id:
continue
mapping[str(vmid)] = str(app_id)
return mapping
# ── LXC containers (Phase 1: apt-based update detection) ────────────
#
# Each running Debian/Ubuntu CT becomes a registry entry of type "lxc".
@@ -461,6 +555,289 @@ def _list_pve_lxcs() -> list[dict]:
_SUPPORTED_OS_FAMILIES = ("debian", "ubuntu", "alpine")
# Detectors for the CT origin. `pct config` writes machine-friendly
# keys that reveal how a container was created. The most reliable
# OCI-image indicator across PVE 9.1+ is `lxc.environment.runtime:` —
# it's populated from every Dockerfile ENV (nearly universal) whereas
# `entrypoint:` requires the image to define ENTRYPOINT (CMD-only
# images lack it). We match by prefix, one hit is enough.
_OCI_LXC_MARKERS = (
"lxc.environment.runtime:",
"lxc.init.cwd:",
"lxc.signal.halt:",
)
def _probe_lxc_is_oci(vmid: str) -> bool:
"""Return True if the CT was created from an OCI (Docker) image via
PVE 9.1+'s native ``pct create <vmid> <oci-ref>`` path.
OCI-image containers are IMMUTABLE by design running apt/apk
upgrade inside them contradicts the container model and can break
the image (bootstrap deps, baked-in configs). The correct workflow
is to pull a newer image tag and rebuild. We use this probe to
SUPPRESS the apt/apk detection for these CTs so the UI doesn't
show a misleading "packages pending" badge that would nudge users
toward the anti-pattern.
Reads the CT config file directly (cheaper than `pct config`)
the file lives at /etc/pve/lxc/<vmid>.conf and is always present
on the node hosting the CT.
"""
conf_path = f"/etc/pve/lxc/{vmid}.conf"
try:
with open(conf_path) as f:
for line in f:
stripped = line.lstrip()
for marker in _OCI_LXC_MARKERS:
if stripped.startswith(marker):
return True
except (FileNotFoundError, PermissionError, OSError):
pass
return False
# Cross-reference against the ProxMenux helpers catalogue (generated
# by .github/scripts/generate_helpers_cache.py from the
# community-scripts registry). Each entry carries `updateable: bool`
# — the community-scripts folks know which of their apps ship a
# working updater and which don't (47 out of 733 at last count are
# updateable=false). Without this we'd offer an Apply button on
# every CT with /usr/bin/update, and 6-7% of them would fail hard.
_HELPERS_CACHE_URL = (
"https://raw.githubusercontent.com/MacRimi/ProxMenux/"
"refs/heads/main/json/helpers_cache.json"
)
_HELPERS_CACHE_DISK = "/var/lib/proxmenux/helpers_cache.json"
_HELPERS_CACHE_TTL = 7 * 24 * 3600 # 7 days — the catalogue changes rarely
_HELPERS_CACHE_HTTP_TIMEOUT = 10
_helpers_cache_lock = threading.RLock()
_helpers_cache: Optional[dict] = None
_helpers_cache_ts: float = 0.0
_HELPER_SLUG_VALUE = r"[a-z0-9][a-z0-9._-]*"
_UPDATE_SLUG_RE = re.compile(rf"ct/({_HELPER_SLUG_VALUE})\.sh")
_SCRIPT_SLUG_RE = re.compile(
rf"^\s*(?:export\s+)?SCRIPT_SLUG\s*=\s*"
rf'(?:"({_HELPER_SLUG_VALUE})"|\'({_HELPER_SLUG_VALUE})\'|({_HELPER_SLUG_VALUE}))'
rf"\s*(?:#.*)?$",
re.MULTILINE,
)
_UPDATE_SCRIPT_NAME_RE = re.compile(
rf"^\s*(?:export\s+)?UPDATE_SCRIPT_NAME\s*=\s*"
rf'(?:"({_HELPER_SLUG_VALUE})"|\'({_HELPER_SLUG_VALUE})\'|({_HELPER_SLUG_VALUE}))'
rf"\s*(?:#.*)?$",
re.MULTILINE,
)
_BASE_OS_HELPER_SLUGS = frozenset({
"alpine", "archlinux", "archlinux-vm", "debian", "fedora",
"gentoo", "opensuse", "ubuntu",
})
def _fetch_helpers_cache() -> dict:
"""Return the slug→metadata index for community-scripts apps.
Shape: ``{slug: {"name": str, "updateable": bool}}``. Fetched on
demand from the ProxMenux repo, cached in memory for 7 days and
persisted to :data:`_HELPERS_CACHE_DISK` so a Monitor restart
doesn't refetch. On any network failure returns the last known
good copy never raises, so callers can just ``.get(slug)``.
"""
global _helpers_cache, _helpers_cache_ts
with _helpers_cache_lock:
now = time.time()
if _helpers_cache is not None and (now - _helpers_cache_ts) < _HELPERS_CACHE_TTL:
return _helpers_cache
# In-memory expired or empty — try network first, then disk.
try:
req = urllib.request.Request(
_HELPERS_CACHE_URL,
headers={"User-Agent": "ProxMenux-Monitor"},
)
with urllib.request.urlopen(req, timeout=_HELPERS_CACHE_HTTP_TIMEOUT) as r:
raw = json.loads(r.read().decode("utf-8"))
index: dict = {}
for entry in raw or []:
slug = entry.get("slug")
if not slug:
continue
# `default_port` powers the App tab's port pre-fill
# fallback for apps that don't have a curated
# default_ports entry in app_tracking_hints.json.
# `logo` is the selfh.st/icons URL from the
# community-scripts catalog — fallback for slugs
# whose curated tracking hint doesn't ship one.
index[slug] = {
"name": entry.get("name") or slug,
"updateable": bool(entry.get("updateable")),
"default_port": entry.get("port") or 0,
"logo": entry.get("logo") or "",
# community-scripts taxonomy — powers the Categoría
# dropdown in the Web Link editor and the auto-fill
# on Registrar. Keep only the human-readable labels
# (ignore the parallel `categories` id list).
"category_names": entry.get("category_names") or [],
}
_helpers_cache = index
_helpers_cache_ts = now
try:
os.makedirs(os.path.dirname(_HELPERS_CACHE_DISK), exist_ok=True)
tmp = f"{_HELPERS_CACHE_DISK}.tmp.{os.getpid()}"
with open(tmp, "w") as f:
json.dump({"ts": now, "index": index}, f)
os.replace(tmp, _HELPERS_CACHE_DISK)
except OSError:
# Persistence is best-effort — memory copy is enough.
pass
return index
except Exception:
# Network failed. Fall back to whatever we have in memory,
# then to the on-disk copy from a previous run.
if _helpers_cache is not None:
return _helpers_cache
try:
with open(_HELPERS_CACHE_DISK) as f:
disk = json.load(f)
_helpers_cache = disk.get("index") or {}
_helpers_cache_ts = float(disk.get("ts") or 0)
return _helpers_cache
except (OSError, json.JSONDecodeError):
_helpers_cache = {}
_helpers_cache_ts = now # avoid hammering the retry loop
return _helpers_cache
def _extract_helper_slug_from_update_wrapper(content: str) -> Optional[str]:
"""Extract a static app slug from a Helper-Scripts update wrapper.
Historical wrappers contain a literal ``ct/<slug>.sh`` URL. Current
wrappers are regenerated after successful updates and declare
``SCRIPT_SLUG`` / ``UPDATE_SCRIPT_NAME`` before constructing that URL
with shell variables. Only a plain, tightly constrained assignment is
accepted; the wrapper is never evaluated or sourced.
"""
for pattern in (_SCRIPT_SLUG_RE, _UPDATE_SCRIPT_NAME_RE):
match = pattern.search(content or "")
if match:
return next((value for value in match.groups() if value), None)
match = _UPDATE_SLUG_RE.search(content or "")
return match.group(1) if match else None
def _probe_helper_scripts_slug(vmid: str) -> Optional[str]:
"""Return the Helper-Scripts app slug declared by ``/usr/bin/update``.
Supports both the historical literal URL and the current generated
entrypoint format. Returns None when the file is missing, unreadable,
or contains no safe static application slug.
"""
try:
r = subprocess.run(
[_PCT_BIN, "exec", str(vmid), "--", "cat", "/usr/bin/update"],
capture_output=True, text=True,
timeout=_LXC_OS_PROBE_TIMEOUT_SEC,
)
if r.returncode != 0:
return None
return _extract_helper_slug_from_update_wrapper(r.stdout)
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
return None
# Tags the community-scripts installers stamp on the CT config so we
# can recognise a CT as a helper-scripts install even when /usr/bin/
# update has been deleted or was never created (very old installs).
_HELPER_SCRIPTS_TAGS = frozenset({"proxmox-helper-scripts", "community-scripts"})
def _probe_lxc_tags(vmid: str) -> set:
"""Return the set of tags configured on the CT (from ``pct config``).
Returns empty set on any failure never raises.
"""
try:
r = subprocess.run(
[_PCT_BIN, "config", str(vmid)],
capture_output=True, text=True, timeout=5,
)
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
return set()
if r.returncode != 0:
return set()
for line in r.stdout.splitlines():
if line.startswith("tags:"):
raw = line.split(":", 1)[1].strip()
return {t.strip().lower() for t in raw.split(";") if t.strip()}
return set()
def _normalize_for_fuzzy(s: str) -> str:
"""Lowercase + strip non-alphanumeric, for hostname↔slug matching."""
return "".join(ch for ch in (s or "").lower() if ch.isalnum())
def _guess_helper_slug_from_hostname(hostname: str) -> Optional[str]:
"""Fuzzy-match a CT hostname against community-scripts catalog slugs.
Tried in this order:
1. Exact-normalized match the safest and only unambiguous case
2. Prefix match (hostname is a proper prefix of the slug
e.g. `nginxproxy` `nginxproxymanager`) only accepted when
there is EXACTLY ONE candidate. A hostname like `paperless`
matching all of {paperless-ai, paperless-gpt, paperless-ngx}
returns None: the guess would be wrong more often than right.
3. Contains match same "unique or bust" rule.
Ambiguity None. The user then goes through the catalog picker
or types the app name themselves accurate manual choice beats
silently-wrong auto-suggestion.
"""
norm_host = _normalize_for_fuzzy(hostname)
if not norm_host:
return None
cache = _fetch_helpers_cache() or {}
if not cache:
return None
norm_slugs = {slug: _normalize_for_fuzzy(slug) for slug in cache}
for slug, ns in norm_slugs.items():
if ns == norm_host:
return slug
prefix = [slug for slug, ns in norm_slugs.items() if ns.startswith(norm_host)]
if len(prefix) == 1:
return prefix[0]
if prefix:
return None # ambiguous — refuse to guess
contains = [slug for slug, ns in norm_slugs.items() if norm_host in ns]
if len(contains) == 1:
return contains[0]
return None
def _identify_helper_slug(vmid: str, hostname: str) -> tuple[Optional[str], Optional[str]]:
"""Return ``(slug, evidence_source)`` for a community-scripts CT.
``update_wrapper`` is executable evidence: the slug was extracted
from /usr/bin/update. ``tag_hostname`` is only an identity hint for
old installs and must never enable an update action by itself.
"""
slug = _probe_helper_scripts_slug(vmid)
if slug:
return slug, "update_wrapper"
tags = _probe_lxc_tags(vmid)
if not (tags & _HELPER_SCRIPTS_TAGS):
return None, None
slug = _guess_helper_slug_from_hostname(hostname)
return (slug, "tag_hostname") if slug else (None, None)
def _infer_helper_slug(vmid: str, hostname: str) -> Optional[str]:
"""Backward-compatible identity-only wrapper.
Callers deciding whether an updater may run must use
:func:`_identify_helper_slug` and require ``update_wrapper``.
"""
return _identify_helper_slug(vmid, hostname)[0]
def _probe_lxc_os(vmid: str) -> Optional[str]:
"""Return a normalized family identifier (``debian`` / ``ubuntu`` /
@@ -496,7 +873,7 @@ def _probe_lxc_os(vmid: str) -> Optional[str]:
return None
def _detect_lxc_containers() -> list[dict]:
def _detect_lxc_containers(only_vmid: Optional[int] = None) -> list[dict]:
"""Enumerate running Debian/Ubuntu CTs as registry entries.
OS detection is cached in the registry entry (`_os_family`), so the
@@ -531,22 +908,78 @@ def _detect_lxc_containers() -> list[dict]:
}
cts = _list_pve_lxcs()
# Set of CTs currently managed by oci_manager (Secure Gateway etc).
# Their update path is the OCI app's own updater — we mark them so
# the LXC row in the UI redirects the user there instead of running
# our generic apt/apk flow.
oci_managed = _get_oci_managed_vmids()
out: list[dict] = []
for ct in cts:
if only_vmid is not None and str(ct.get("vmid")) != str(int(only_vmid)):
continue
if ct["status"] != "running":
continue
vmid = ct["vmid"]
cid = f"lxc:{vmid}"
prior = existing_by_id.get(cid) or {}
# OCI-image marker is cached — the CT origin doesn't change
# over its lifetime, and reading the pct config file is cheap
# enough that we don't gain much from skipping the re-probe.
is_oci = _probe_lxc_is_oci(vmid)
# Managed OCI-app membership (Secure Gateway / Tailscale / any
# future ProxMenux-shipped OCI app).
managed_oci_app = oci_managed.get(str(vmid))
# OS family is only meaningful for non-OCI CTs. We still cache
# it for OCI (some images ARE Ubuntu/Debian underneath and
# future features might use it), but we don't require it.
os_family = prior.get("_os_family")
if not os_family:
os_family = _probe_lxc_os(vmid)
if os_family not in _SUPPORTED_OS_FAMILIES:
# Distribution we don't yet have a package-manager
# parser for. Skip silently. The framework marks any
# existing entry as removed_at if it stops appearing
# in the detector output.
if not is_oci and os_family not in _SUPPORTED_OS_FAMILIES:
# Non-OCI, non-supported family — the framework has
# no way to check its updates. Skip silently.
continue
# Helper-scripts updater detection — only meaningful for
# non-OCI, non-managed CTs. Managed OCI apps have their own
# updater; OCI-image CTs almost never carry /usr/bin/update
# since apps are baked into the image at build time.
#
# `_has_app_updater` gates whether the "Apply application
# update" button appears in the modal. It's only True when
# BOTH:
# (a) /usr/bin/update exists AND we can extract the
# community-scripts slug from it, and
# (b) that slug is marked `updateable: true` in the
# helpers_cache — 47/733 entries are false, and running
# their updaters is a known-broken action.
# `_helper_slug` and `_helper_app_name` are surfaced to the UI
# so users see which app they'd be updating (e.g. "Update
# Jellyfin" rather than a generic "Update").
has_app_updater = False
helper_slug: Optional[str] = None
helper_slug_source: Optional[str] = None
helper_app_name: Optional[str] = None
helper_updateable_known = False # True when we found the slug in the cache
if not is_oci and not managed_oci_app:
helper_slug, helper_slug_source = _identify_helper_slug(
vmid, ct.get("name") or ""
)
if helper_slug:
entry = _fetch_helpers_cache().get(helper_slug)
if entry:
helper_updateable_known = True
helper_app_name = entry.get("name") or helper_slug
has_app_updater = bool(
helper_slug_source == "update_wrapper"
and helper_slug not in _BASE_OS_HELPER_SLUGS
and entry.get("updateable")
)
out.append({
"id": cid,
"type": "lxc",
@@ -556,8 +989,13 @@ def _detect_lxc_containers() -> list[dict]:
"menu_script": None,
"_vmid": vmid,
"_os_family": os_family,
# Phase 2 hook: populate `_helper_script_app` here once we
# learn how to read the community-scripts marker.
"_is_oci": is_oci,
"_managed_oci_app": managed_oci_app,
"_has_app_updater": has_app_updater,
"_helper_slug": helper_slug,
"_helper_slug_source": helper_slug_source,
"_helper_app_name": helper_app_name,
"_helper_updateable_known": helper_updateable_known,
})
return out
@@ -583,6 +1021,45 @@ def _normalise_detector_result(result: Any) -> list[dict]:
return []
def _merge_detected_entry(existing: dict, entry: dict, now: str) -> dict:
"""Refresh one registry row from detector evidence without touching peers."""
if existing.get("removed_at"):
existing.pop("removed_at", None)
existing["reactivated_at"] = now
for key in ("name", "current_version", "menu_label", "menu_script"):
if key in entry and entry[key] is not None:
existing[key] = entry[key]
for key, value in entry.items():
if key.startswith("_"):
existing[key] = value
existing["last_seen"] = now
return existing
def _new_detected_entry(entry: dict, now: str) -> dict:
new_entry = {
"id": entry["id"],
"type": entry.get("type", "unknown"),
"name": entry.get("name", entry["id"]),
"current_version": entry.get("current_version"),
"menu_label": entry.get("menu_label"),
"menu_script": entry.get("menu_script"),
"installed_by": "detected",
"first_seen": now,
"last_seen": now,
"update_check": {
"last_check": None,
"available": False,
"latest": None,
"error": None,
},
}
for key, value in entry.items():
if key.startswith("_"):
new_entry[key] = value
return new_entry
def detect_and_register() -> dict:
"""Run every detector, merge results into the registry, persist.
@@ -617,44 +1094,9 @@ def detect_and_register() -> dict:
# 1. Add new + reactivate / refresh existing.
for item_id, entry in discovered.items():
if item_id in index:
existing = items[index[item_id]]
# Reactivate if it was previously removed
if existing.get("removed_at"):
existing.pop("removed_at", None)
existing["reactivated_at"] = now
# Refresh metadata fields that may have evolved
for k in ("name", "current_version", "menu_label", "menu_script"):
if k in entry and entry[k] is not None:
existing[k] = entry[k]
# Preserve internal helpers like `_oci_app_id`
for k, v in entry.items():
if k.startswith("_"):
existing[k] = v
existing["last_seen"] = now
_merge_detected_entry(items[index[item_id]], entry, now)
else:
# Brand new entry
new_entry = {
"id": entry["id"],
"type": entry.get("type", "unknown"),
"name": entry.get("name", entry["id"]),
"current_version": entry.get("current_version"),
"menu_label": entry.get("menu_label"),
"menu_script": entry.get("menu_script"),
"installed_by": "detected",
"first_seen": now,
"last_seen": now,
"update_check": {
"last_check": None,
"available": False,
"latest": None,
"error": None,
},
}
# Carry over internals (`_oci_app_id` etc.)
for k, v in entry.items():
if k.startswith("_"):
new_entry[k] = v
items.append(new_entry)
items.append(_new_detected_entry(entry, now))
# 2. Mark missing items as removed (don't delete — preserve
# history so a reinstall doesn't lose the audit trail).
@@ -712,8 +1154,8 @@ def _check_oci_app(entry: dict) -> dict:
# returns the single newest version, e.g. "580.105.08"
# `https://download.nvidia.com/XFree86/Linux-x86_64/`
# HTML directory listing — we scrape it for per-branch latest
# (so a user on 570.x gets 570.x's latest, not pushed to 580.x
# unless their kernel forces a branch upgrade).
# (so a user on 570.x gets 570.x's latest, without an automatic
# cross-branch upgrade).
#
# Cache TTL is 7 days because NVIDIA's release cadence on each branch
# is roughly monthly. The cache is in-memory only; AppImage restarts
@@ -724,49 +1166,6 @@ _NVIDIA_CACHE_TTL = 7 * 86400
_nvidia_cache: dict[str, Any] = {"versions": [], "fetched_at": 0}
def _nvidia_kernel_compat() -> dict:
"""Python port of `get_kernel_compatibility_info` in the bash
installer. Returns ``{kernel, min_version, recommended_branch,
note}``. Kept identical to the bash matrix so the recommendation
here matches what the installer would do."""
try:
kernel = subprocess.run(
["uname", "-r"], capture_output=True, text=True, timeout=2,
).stdout.strip()
except (OSError, subprocess.TimeoutExpired):
kernel = ""
parts = kernel.split(".") if kernel else []
try:
major = int(parts[0]) if len(parts) >= 1 else 0
minor = int(parts[1]) if len(parts) >= 2 else 0
except (ValueError, TypeError):
major, minor = 0, 0
if major >= 7 or (major == 6 and minor >= 17):
return {
"kernel": kernel,
"min_version": "580.105.08",
"recommended_branch": "580",
"note": (f"Kernel {kernel} requires NVIDIA driver 580.105.08 or "
f"newer (older 580.x builds fail to compile)"),
}
if major >= 6 and minor >= 8:
return {"kernel": kernel, "min_version": "550",
"recommended_branch": "580",
"note": f"Kernel {kernel} works with NVIDIA driver 550.x or newer"}
if major >= 6:
return {"kernel": kernel, "min_version": "535",
"recommended_branch": "550",
"note": f"Kernel {kernel} works with NVIDIA driver 535.x or newer"}
if major == 5 and minor >= 15:
return {"kernel": kernel, "min_version": "470",
"recommended_branch": "535",
"note": f"Kernel {kernel} works with NVIDIA driver 470.x or newer"}
return {"kernel": kernel, "min_version": "450",
"recommended_branch": "470",
"note": "For older kernels, compatibility may vary"}
def _version_tuple(v: str) -> tuple:
"""Convert ``580.105.08`` → ``(580, 105, 8)`` for comparison.
Pads to 3 components so ``580.82`` < ``580.105.08``."""
@@ -809,30 +1208,8 @@ def _fetch_nvidia_versions(force: bool = False) -> list[str]:
return versions
def _is_compat_with_kernel(version: str, kernel_compat: dict) -> bool:
"""Compare ``version`` (e.g. ``580.105.08``) against the kernel
compatibility floor. Mirrors the bash ``is_version_compatible``
helper (full-triple compare when min is dotted, major-only otherwise)."""
min_str = kernel_compat.get("min_version", "0")
if "." in min_str and re.match(r"^\d+\.\d+\.\d+$", min_str):
return _version_tuple(version) >= _version_tuple(min_str)
# Single-major threshold like "535" or "550"
try:
ver_major = int(version.split(".")[0])
min_major = int(min_str)
except (ValueError, TypeError):
return True
return ver_major >= min_major
def _check_nvidia_xfree86(entry: dict) -> dict:
"""Compute the update state for a host NVIDIA driver entry.
Policy (Option C from the design discussion):
1. Same-branch newer version available notify.
2. Current branch no longer compatible with current kernel
notify a branch upgrade with explicit messaging.
"""
"""Report same-branch bugfix upgrades for the installed driver."""
current = entry.get("current_version")
if not current or not re.match(r"^\d+\.\d+(\.\d+)?$", current):
return {"available": False, "latest": None,
@@ -844,48 +1221,23 @@ def _check_nvidia_xfree86(entry: dict) -> dict:
"last_check": _now_iso(),
"error": "could not parse upstream version listing"}
kernel_compat = _nvidia_kernel_compat()
current_branch = current.split(".")[0]
same_branch = [v for v in versions if v.split(".")[0] == current_branch
and _is_compat_with_kernel(v, kernel_compat)]
same_branch = [v for v in versions if v.split(".")[0] == current_branch]
same_branch_latest = same_branch[0] if same_branch else None
notify_branch_upgrade = False
branch_upgrade_target: Optional[str] = None
if not _is_compat_with_kernel(current, kernel_compat):
# Current branch / version no longer works with current kernel.
# Recommend the kernel-recommended branch's latest.
rec_branch = kernel_compat["recommended_branch"]
rec_branch_versions = [v for v in versions
if v.split(".")[0] == rec_branch
and _is_compat_with_kernel(v, kernel_compat)]
if rec_branch_versions:
branch_upgrade_target = rec_branch_versions[0]
notify_branch_upgrade = True
available = False
latest: Optional[str] = None
upgrade_kind = None # "patch" | "branch_upgrade" | None
if notify_branch_upgrade and branch_upgrade_target:
latest = branch_upgrade_target
available = True
upgrade_kind = "branch_upgrade"
elif same_branch_latest and \
_version_tuple(same_branch_latest) > _version_tuple(current):
if same_branch_latest and \
_version_tuple(same_branch_latest) > _version_tuple(current):
latest = same_branch_latest
available = True
upgrade_kind = "patch"
return {
"available": available,
"latest": latest,
"last_check": _now_iso(),
"error": None,
"_upgrade_kind": upgrade_kind,
"_kernel": kernel_compat.get("kernel"),
"_kernel_note": kernel_compat.get("note"),
"_upgrade_kind": "patch" if available else None,
}
@@ -1113,6 +1465,24 @@ def _check_lxc_updates(entry: dict) -> dict:
"last_check": _now_iso(), "error": "no vmid in entry",
}
# OCI-image CTs are immutable by design — apt/apk upgrade inside
# them is the wrong workflow (update = rebuild from a newer image
# tag). Skip the package-manager probe entirely so the UI doesn't
# surface a misleading "N packages pending" badge that would nudge
# users toward the anti-pattern. The Updates modal renders a
# dedicated OCI-container panel using the flag propagated below.
#
# Same treatment for CTs managed by oci_manager (Secure Gateway
# etc.) — those have their own dashboard-driven updater with
# app-specific hooks; running our generic apt/apk in parallel
# would race and could restart the wrong services.
if entry.get("_is_oci") or entry.get("_managed_oci_app"):
return {
"available": False, "latest": None,
"last_check": _now_iso(), "error": None,
"_count": 0, "_security_count": 0, "_packages": [],
}
refresh_diag = _refresh_lxc_pkg_cache_if_stale(vmid, family)
if family in ("debian", "ubuntu"):
@@ -1329,6 +1699,23 @@ _CHECKERS: dict[str, Callable[[dict], dict]] = {
}
def _store_update_result(item: dict, result: dict) -> None:
"""Apply one checker result using the registry's canonical shape."""
item["update_check"] = {
"available": bool(result.get("available")),
"latest": result.get("latest"),
"last_check": result.get("last_check") or _now_iso(),
"error": result.get("error"),
}
if result.get("current") and not item.get("current_version"):
item["current_version"] = result["current"]
for extra_key in ("_packages", "_upgrade_kind", "_kernel",
"_kernel_note", "_count", "_security_count",
"_coral_variant", "_coral_pkg"):
if extra_key in result:
item["update_check"][extra_key] = result[extra_key]
def check_for_updates(force: bool = False) -> list[dict]:
"""Run every type-specific checker over active items, persist
the updated state, return the list of items that have an update
@@ -1363,25 +1750,7 @@ def check_for_updates(force: bool = False) -> list[dict]:
result = {"available": False, "latest": None,
"last_check": _now_iso(), "error": str(e)}
it["update_check"] = {
"available": bool(result.get("available")),
"latest": result.get("latest"),
"last_check": result.get("last_check") or _now_iso(),
"error": result.get("error"),
}
if result.get("current") and not it.get("current_version"):
it["current_version"] = result["current"]
# Per-checker extras carried through into the persisted
# `update_check` blob. Add new keys here when a future
# checker needs to surface fields beyond available/latest.
# `_count` + `_security_count` were missing originally, so
# the LXC checker's counts dropped on the floor and the
# frontend badge couldn't render.
for extra_key in ("_packages", "_upgrade_kind", "_kernel",
"_kernel_note", "_count", "_security_count",
"_coral_variant", "_coral_pkg"):
if extra_key in result:
it["update_check"][extra_key] = result[extra_key]
_store_update_result(it, result)
if it["update_check"]["available"]:
updates_available.append(it)
@@ -1391,3 +1760,49 @@ def check_for_updates(force: bool = False) -> list[dict]:
_write_registry(reg)
return updates_available
def refresh_lxc(vmid: int) -> Optional[dict]:
"""Detect and refresh exactly one running LXC.
This is the lifecycle counterpart of the daily collector. It is called
after a stopped container starts and deliberately leaves every other
guest's registry row untouched.
"""
try:
target_vmid = int(vmid)
except (TypeError, ValueError):
return None
detected = _detect_lxc_containers(only_vmid=target_vmid)
if not detected:
return None
entry = detected[0]
item_id = entry["id"]
now = _now_iso()
with _lock:
reg = _read_registry()
items: list[dict] = list(reg.get("items", []))
target = next((item for item in items if item.get("id") == item_id), None)
if target is None:
target = _new_detected_entry(entry, now)
items.append(target)
else:
_merge_detected_entry(target, entry, now)
try:
result = _check_lxc_updates(target)
except Exception as exc:
result = {
"available": False,
"latest": None,
"last_check": _now_iso(),
"error": str(exc),
}
_store_update_result(target, result)
reg["items"] = items
reg["version"] = _SCHEMA_VERSION
reg["last_targeted_refresh"] = now
_write_registry(reg)
return dict(target)
+134 -3
View File
@@ -1,6 +1,7 @@
"""
ProxMenux Notification Channels
Provides transport adapters for Telegram, Gotify, and Discord.
Provides transport adapters for Telegram, Gotify, Discord, Email, Pushover,
and Apprise.
Each channel implements send() and test() with:
- Retry with exponential backoff (3 attempts)
@@ -12,6 +13,7 @@ Author: MacRimi
import json
import logging
import re
import time
import urllib.request
import urllib.error
@@ -392,6 +394,119 @@ class GotifyChannel(NotificationChannel):
return self._http_request(url, payload, {'Content-Type': 'application/json'})
# ─── Pushover ────────────────────────────────────────────────────
class PushoverChannel(NotificationChannel):
"""Pushover Messages API channel."""
API_URL = 'https://api.pushover.net/1/messages.json'
MAX_TITLE_LENGTH = 250
MAX_MESSAGE_LENGTH = 1024
_CREDENTIAL_RE = re.compile(r'^[A-Za-z0-9]{30}$')
_OPTION_RE = re.compile(r'^[A-Za-z0-9_-]{1,25}$')
def __init__(self, user_key: str, api_token: str, device: str = '',
sound: str = '', critical_priority: str = 'true'):
super().__init__()
self.user_key = (user_key or '').strip()
self.api_token = (api_token or '').strip()
self.device = (device or '').strip()
self.sound = (sound or '').strip()
self.critical_priority = str(critical_priority).lower() == 'true'
def validate_config(self) -> Tuple[bool, str]:
if not self.user_key:
return False, 'Pushover user or group key is required'
if not self.api_token:
return False, 'Pushover application API token is required'
if not self._CREDENTIAL_RE.fullmatch(self.user_key):
return False, 'Invalid Pushover user or group key format'
if not self._CREDENTIAL_RE.fullmatch(self.api_token):
return False, 'Invalid Pushover application API token format'
if self.device and not self._OPTION_RE.fullmatch(self.device):
return False, 'Invalid Pushover device name format'
if self.sound and not self._OPTION_RE.fullmatch(self.sound):
return False, 'Invalid Pushover sound name format'
return True, ''
@staticmethod
def _truncate(value: str, limit: int) -> str:
value = value or ''
if len(value) <= limit:
return value
return value[:limit - 1].rstrip() + ''
@staticmethod
def _response_error(body: str) -> str:
try:
payload = json.loads(body or '{}')
errors = payload.get('errors')
if isinstance(errors, list):
clean = [str(item)[:160] for item in errors if item]
if clean:
return '; '.join(clean)
if isinstance(errors, str) and errors:
return errors[:200]
except (TypeError, ValueError):
pass
return 'Pushover API rejected the request'
def _post_message(self, title: str, message: str,
priority: int) -> Tuple[int, str]:
payload = {
'token': self.api_token,
'user': self.user_key,
'title': self._truncate(title, self.MAX_TITLE_LENGTH),
'message': self._truncate(message, self.MAX_MESSAGE_LENGTH),
'priority': str(priority),
}
if self.device:
payload['device'] = self.device
if self.sound:
payload['sound'] = self.sound
body = urllib.parse.urlencode(payload).encode('utf-8')
status, response_body = self._http_request(
self.API_URL,
body,
{'Content-Type': 'application/x-www-form-urlencoded'},
)
if 200 <= status < 300:
try:
response = json.loads(response_body or '{}')
if response.get('status') == 1:
return status, ''
except (TypeError, ValueError):
pass
return 400, self._response_error(response_body)
return status, self._response_error(response_body)
def send(self, title: str, message: str, severity: str = 'INFO',
data: Optional[Dict] = None) -> Dict[str, Any]:
valid, error = self.validate_config()
if not valid:
return {'success': False, 'error': error, 'channel': 'pushover'}
priority = (
1
if self.critical_priority and str(severity or '').upper() == 'CRITICAL'
else 0
)
result = self._send_with_retry(
lambda: self._post_message(title, message, priority)
)
result['channel'] = 'pushover'
return result
def test(self) -> Tuple[bool, str]:
result = self.send(
'ProxMenux Test',
'Pushover is configured correctly. This is a test message from ProxMenux Monitor.',
'INFO',
)
return result['success'], result.get('error', '')
# ─── Discord ─────────────────────────────────────────────────────
class DiscordChannel(NotificationChannel):
@@ -1189,7 +1304,7 @@ class AppriseChannel(NotificationChannel):
Apprise (https://github.com/caronc/apprise) is a Python library that
normalises a wide catalogue of notification destinations behind a
single URL scheme: `tgram://`, `discord://`, `slack://`, `gotify://`,
`ntfy://`, `matrix://`, `mailto://`, `pushover://`, `signal://`, etc.
`ntfy://`, `matrix://`, `mailto://`, `pover://`, `signal://`, etc.
The operator pastes one URL and ProxMenux delegates the transport.
Requested in issue #207 by @0berkampf. Implemented as a *separate
@@ -1347,6 +1462,13 @@ CHANNEL_TYPES = {
'from_address', 'to_addresses', 'subject_prefix'],
'class': EmailChannel,
},
'pushover': {
'name': 'Pushover',
'config_keys': ['user_key', 'api_token', 'device', 'sound',
'critical_priority'],
'required_keys': ['user_key', 'api_token'],
'class': PushoverChannel,
},
'apprise': {
'name': 'Apprise',
'config_keys': ['url'],
@@ -1359,7 +1481,8 @@ def create_channel(channel_type: str, config: Dict[str, str]) -> Optional[Notifi
"""Create a channel instance from type name and config dict.
Args:
channel_type: 'telegram', 'gotify', 'discord', 'email', or 'apprise'
channel_type: 'telegram', 'gotify', 'discord', 'email', 'pushover',
or 'apprise'
config: Dict with channel-specific keys (see CHANNEL_TYPES)
Returns:
@@ -1383,6 +1506,14 @@ def create_channel(channel_type: str, config: Dict[str, str]) -> Optional[Notifi
)
elif channel_type == 'email':
return EmailChannel(config)
elif channel_type == 'pushover':
return PushoverChannel(
user_key=config.get('user_key', ''),
api_token=config.get('api_token', ''),
device=config.get('device', ''),
sound=config.get('sound', ''),
critical_priority=config.get('critical_priority', 'true'),
)
elif channel_type == 'apprise':
return AppriseChannel(url=config.get('url', ''))
except Exception as e:
+197 -53
View File
@@ -22,9 +22,11 @@ import sqlite3
import subprocess
import threading
from queue import Queue
from typing import Optional, Dict, Any, Tuple
from typing import Optional, Dict, Any, Tuple, Callable
from pathlib import Path
from proxmox_known_errors import analyze_oom_event, format_oom_diagnosis
# ─── Shared State for Cross-Watcher Coordination ──────────────────
@@ -194,6 +196,20 @@ def _hostname() -> str:
return resolved
def _new_post_install_update_versions(
updates: list[dict[str, Any]],
notified_versions: dict[str, set[str]],
) -> dict[str, str]:
"""Return only optimization versions that have never been announced."""
available: dict[str, str] = {}
for update in updates:
key = str(update.get('key', '') or '').strip()
version = str(update.get('available_version', '') or '').strip()
if key and version and version not in notified_versions.get(key, set()):
available[key] = version
return available
def capture_journal_context(keywords: list, lines: int = 30,
since: str = "5 minutes ago") -> str:
"""Capture relevant journal lines for AI context enrichment.
@@ -419,7 +435,9 @@ def is_apt_active_on_host() -> bool:
Sources checked, in order:
1. `/var/run/proxmenux-update-in-progress` created by
`scripts/utilities/proxmox_update.sh` around its full-upgrade
call so ProxMenux-driven updates are always covered.
call, and by `scripts/post_install/update_post_install_function.sh`
around the per-tool re-run wrapper (log2ram, chrony), so any
ProxMenux-driven maintenance is covered.
2. `fuser` on `/var/lib/dpkg/lock-frontend` covers a manual
`apt`/`dpkg`/`apt-get` invocation by the operator, or any
other tool holding the lock.
@@ -489,6 +507,12 @@ class JournalWatcher:
self._recent_events: Dict[str, float] = {}
self._dedup_window = 30 # seconds
# Linux emits an OOM diagnosis as a multi-line kernel block. Buffer it
# until the authoritative `Killed process` line arrives so the alert
# can distinguish a host OOM from a memory-cgroup/LXC limit.
self._oom_lines = []
self._oom_started_at = 0.0
# 24h anti-cascade for disk I/O + filesystem errors. The dict
# key includes a tier suffix (`sdh:warning`, `sdh:critical`)
# so a disk in WARNING cooldown can still escalate to CRITICAL
@@ -830,6 +854,57 @@ class JournalWatcher:
# Only process messages from kernel or systemd (not app-level logs)
if syslog_id and syslog_id not in ('kernel', 'systemd', 'systemd-coredump', ''):
return
now = time.time()
if self._oom_lines and now - self._oom_started_at > 15:
self._oom_lines = []
self._oom_started_at = 0.0
starts_oom_block = bool(re.search(
r'invoked oom-killer|oom-kill:constraint=', msg, re.IGNORECASE
))
ends_oom_block = bool(re.search(
r'(?:Memory cgroup )?Out of memory:\s+Killed process', msg, re.IGNORECASE
))
if starts_oom_block and not self._oom_lines:
self._oom_lines = [msg]
self._oom_started_at = now
return
if self._oom_lines:
self._oom_lines.append(msg)
if len(self._oom_lines) > 500:
self._oom_lines = self._oom_lines[-500:]
if ends_oom_block:
analysis = analyze_oom_event('\n'.join(self._oom_lines))
reason = format_oom_diagnosis(analysis)
if not reason:
reason = f'Out of memory killer activated\n{msg[:300]}'
ctid = analysis.get('ctid') if analysis else ''
victim = analysis.get('victim_process') if analysis else ''
entity_id = f'lxc_{ctid}' if ctid else f'oom_{victim or "unknown"}'
self._emit(
'system_problem',
'CRITICAL',
{
'reason': reason,
'hostname': self._hostname,
'oom_analysis': analysis or {},
},
entity='node',
entity_id=entity_id,
)
self._oom_lines = []
self._oom_started_at = 0.0
return
# `Call Trace:` is part of the buffered OOM evidence, not a second
# independent kernel fault requiring another notification.
if re.search(r'^Call Trace:', msg, re.IGNORECASE):
return
# Filter out normal kernel messages that are NOT problems
_KERNEL_NOISE = [
@@ -1939,8 +2014,16 @@ class TaskWatcher:
'vzmigrate': ('migration_start', 'INFO'),
}
def __init__(self, event_queue: Queue):
def __init__(
self,
event_queue: Queue,
guest_lifecycle_callback: Optional[Callable[[str, str, str], None]] = None,
):
self._queue = event_queue
# Reuse the exact PVE task transition already responsible for
# VM/CT lifecycle notifications. Consumers such as the modal cache
# can subscribe without introducing a second status poller.
self._guest_lifecycle_callback = guest_lifecycle_callback
self._running = False
self._thread: Optional[threading.Thread] = None
# `_hostname` is exposed as a @property below so every read returns
@@ -2250,6 +2333,31 @@ class TaskWatcher:
# Determine entity type from task type
entity = 'ct' if task_type.startswith('vz') else 'vm'
# A completed PVE lifecycle task is the existing source of truth for
# start/stop/restart notifications. Publish the same transition to
# the optional cache listener before notification-only suppression
# (backup/startup aggregation, disabled channels, cooldowns) so cache
# correctness never depends on whether a message is delivered.
lifecycle_actions = {
'qmstart': ('qemu', 'start'),
'qmstop': ('qemu', 'stop'),
'qmshutdown': ('qemu', 'stop'),
'qmreboot': ('qemu', 'reboot'),
'qmreset': ('qemu', 'reboot'),
'vzstart': ('lxc', 'start'),
'vzstop': ('lxc', 'stop'),
'vzshutdown': ('lxc', 'stop'),
'vzreboot': ('lxc', 'reboot'),
}
lifecycle = lifecycle_actions.get(task_type)
if (lifecycle and self._guest_lifecycle_callback
and not is_error and (status == 'OK' or is_warning)):
try:
self._guest_lifecycle_callback(vmid, lifecycle[0], lifecycle[1])
except Exception as exc:
print(f'[TaskWatcher] guest lifecycle callback failed for '
f'{lifecycle[0]} {vmid}: {exc}', flush=True)
# Backup completion/failure and replication events are handled
# EXCLUSIVELY by the PVE webhook, which delivers richer data (full
@@ -2475,11 +2583,9 @@ class PollingCollector:
self._last_ai_model_check = 0
# Sprint 12D: post-install function updates check, on the same
# 24h cooldown as the Proxmox/ProxMenux update checks. Notify
# once per *changed set* of update keys — repeating the same
# notification every 24h forever would be noisy, so we de-dupe
# against the previously-notified set.
# once for each genuinely new available version. The persistent
# history is stored in updates_available.json beside the scan.
self._last_post_install_check = 0
self._notified_post_install_keys: set[str] = set()
# Sprint 14.7: fingerprint (item_id → latest_version) of the
# last managed-installs update notification, across all types
# in the registry. A new notification fires when the
@@ -2772,6 +2878,8 @@ class PollingCollector:
if category == 'storage':
if error_key.startswith('lxc_disk_'):
event_type = 'lxc_disk_low'
elif error_key.startswith('vm_disk_'):
event_type = 'vm_disk_low'
elif error_key.startswith('lxc_mount_'):
event_type = 'lxc_mount_low'
elif error_key.startswith('pve_storage_full_'):
@@ -3409,11 +3517,9 @@ class PollingCollector:
Sprint 12A's detector runs at AppImage startup and writes
``updates_available.json``. This check refreshes the snapshot
every 24h (matching the other update channels), and emits a
single ``post_install_update`` event the first time the *set* of
available updates changes. Repeating the same notification every
24h forever would be noisy, so we de-dupe against the previously
notified set of tool keys: only when a new tool joins the list
(or an existing one disappears) does a fresh notification fire.
single ``post_install_update`` event when a tool exposes an available
version that has never been announced. Applying one item merely
shrinks the pending set and must not produce a second notification.
"""
now = time.time()
if now - self._last_post_install_check < self.UPDATE_CHECK_INTERVAL:
@@ -3429,16 +3535,12 @@ class PollingCollector:
return
if not updates:
# All caught up. Reset so a future bump triggers a fresh
# notification instead of being suppressed by stale state.
self._notified_post_install_keys = set()
return
new_keys = {u.get('key', '') for u in updates if u.get('key')}
if new_keys == self._notified_post_install_keys:
return # already notified about this exact set
self._notified_post_install_keys = new_keys
notified_versions = post_install_versions.load_notified_versions()
new_versions = _new_post_install_update_versions(updates, notified_versions)
if not new_versions:
return
# Pre-format the bullet list here so the template can drop it
# straight in with `{tool_list}` (the renderer is plain
@@ -3473,6 +3575,9 @@ class PollingCollector:
'post_install_update', 'INFO', data,
source='polling', entity='node', entity_id='',
))
for key, version in new_versions.items():
notified_versions.setdefault(key, set()).add(version)
post_install_versions.save_notified_versions(notified_versions)
# ── Managed-installs update check (Sprint 14.7) ─────────────────
@@ -3506,6 +3611,38 @@ class PollingCollector:
print(f"[PollingCollector] managed_installs update run failed: {e}")
return
# Piggy-back on the same 24 h cycle to refresh every
# user-registered app watch. Keeps the header badge accurate
# in the VMs list without needing a dedicated timer. Errors
# are absorbed inside refresh_all_apps — one broken CT never
# blocks the others.
try:
import lxc_apps
lxc_apps.refresh_all_apps(force=False)
# Docker images have an independent lifecycle from both the OS
# packages and the Docker engine. Refresh their read-only
# registry digest inventory on the same daily cadence; this never
# pulls or recreates containers.
# This is the single automatic Docker registry comparison. Force
# the rolling pass itself so a user-triggered check shortly after
# yesterday's cycle cannot postpone the next automatic scan by an
# additional day. Normal UI reads remain cache-only for 24 hours.
lxc_apps.refresh_docker_inventories(force=True)
# After the refresh, emit `app_update_available` for every
# sidecar entry currently flagged with a pending upstream
# release. `check_app(force=False)` short-circuits on a
# fresh `checked_at` and never reaches the emit path, so
# without this call the notification only ever fired on
# the exact tick where a new version was FIRST observed —
# missed forever if the user had the toggle off at that
# moment. `notification_manager` dedups by entity_id
# (vmid + app_id + latest_version) so repeated calls only
# deliver one notification per release.
lxc_apps.emit_all_pending_docker_stacks()
lxc_apps.emit_all_pending_updates()
except Exception as e:
print(f"[PollingCollector] lxc_apps refresh failed: {e}")
# Split LXC updates out of the per-item event stream — they get
# one grouped notification per cycle instead of one per CT, to
# avoid spamming the user when 15 CTs have pending updates the
@@ -3691,21 +3828,13 @@ class PollingCollector:
return 'secure_gateway_update_available', data
if item_type == 'nvidia_xfree86':
kind = update.get('_upgrade_kind')
if kind == 'branch_upgrade':
upgrade_reason = (
"Your current driver branch is no longer compatible with "
f"kernel {update.get('_kernel') or 'this kernel'}. "
"Switch to the recommended branch — the installer will "
"rebuild against the running kernel."
)
else:
upgrade_reason = (
"Same-branch maintenance update with bug/security fixes."
)
upgrade_reason = (
"Same-branch maintenance update with bug/security fixes. "
"The installer validates the selected release by rebuilding "
"its DKMS module against the running kernel."
)
data = {
**common,
'kernel': update.get('_kernel') or '',
'upgrade_reason': upgrade_reason,
}
return 'nvidia_driver_update_available', data
@@ -4060,26 +4189,33 @@ class ProxmoxHookWatcher:
# smartd and other system mail contains verbose boilerplate.
# Extract just the actionable warning/error lines.
if pve_type == 'system-mail' and message:
clean_lines = []
for line in message.split('\n'):
stripped = line.strip()
# Skip boilerplate lines
if not stripped:
continue
if stripped.startswith('This message was generated'):
continue
if stripped.startswith('For details see'):
continue
if stripped.startswith('You can also use'):
continue
if stripped.startswith('The original message'):
continue
if stripped.startswith('Another message will'):
continue
if stripped.startswith('host name:') or stripped.startswith('DNS domain:'):
continue
clean_lines.append(stripped)
data['reason'] = '\n'.join(clean_lines).strip() if clean_lines else message.strip()[:500]
# apt-listchanges is package-maintainer NEWS, not diagnostic
# boilerplate. Preserve it verbatim (including paragraphs and
# signatures) so ProxMenux only attributes the source and never
# silently edits or truncates the upstream notice.
if event_type == 'apt_listchanges':
data['reason'] = message.strip()
else:
clean_lines = []
for line in message.split('\n'):
stripped = line.strip()
# Skip boilerplate lines
if not stripped:
continue
if stripped.startswith('This message was generated'):
continue
if stripped.startswith('For details see'):
continue
if stripped.startswith('You can also use'):
continue
if stripped.startswith('The original message'):
continue
if stripped.startswith('Another message will'):
continue
if stripped.startswith('host name:') or stripped.startswith('DNS domain:'):
continue
clean_lines.append(stripped)
data['reason'] = '\n'.join(clean_lines).strip() if clean_lines else message.strip()[:500]
# Extract VMID and VM name from message for vzdump events
if pve_type == 'vzdump' and message:
@@ -4201,6 +4337,14 @@ class ProxmoxHookWatcher:
msg_lower = (message or '').lower()
title_lower_sm = (title or '').lower()
# apt-listchanges forwards legitimate upstream package NEWS through
# PVE's generic system-mail bucket. Keep it, but classify it as an
# update notice of its own so the template can identify the source
# clearly instead of making package-maintainer prose look like a
# recommendation written by ProxMenux.
if 'apt-listchanges' in title_lower_sm or 'apt-listchanges' in msg_lower[:500]:
return 'apt_listchanges', 'node', ''
# ── Record disk observation regardless of noise filter ──
# Even "noise" events are recorded as observations so the user
# can see them in the Storage UI. We just don't send notifications.
+134 -76
View File
@@ -3,7 +3,8 @@ ProxMenux Notification Manager
Central orchestrator for the notification service.
Connects:
- notification_channels.py (transport: Telegram, Gotify, Discord)
- notification_channels.py (transport: Telegram, Gotify, Discord, Email,
Pushover, Apprise)
- notification_templates.py (message formatting + optional AI)
- notification_events.py (event detection: Journal, Task, Polling watchers)
- health_persistence.py (DB: config storage, notification_history)
@@ -79,6 +80,8 @@ SENSITIVE_KEYS = {
'gotify.token',
'discord.webhook_url',
'email.password',
'pushover.user_key',
'pushover.api_token',
'apprise.url',
'webhook_secret',
}
@@ -398,7 +401,13 @@ GROUP_RATE_LIMITS = {
'backup': {'max_per_minute': 5, 'max_per_hour': 30},
'services': {'max_per_minute': 5, 'max_per_hour': 30},
'health': {'max_per_minute': 3, 'max_per_hour': 20},
'updates': {'max_per_minute': 3, 'max_per_hour': 15},
# Bumped from 3/min-15/hour: startup reset re-fires every update
# event (app_update x N + nvidia + secure_gateway + post_install
# + summary…) in a single burst; a 3/min ceiling silently dropped
# everything past the third. Steady-state update noise is very
# low (one event per upstream release), so a wider window costs
# nothing.
'updates': {'max_per_minute': 15, 'max_per_hour': 60},
'other': {'max_per_minute': 5, 'max_per_hour': 30},
}
@@ -507,6 +516,15 @@ _DEFAULT_AGGREGATION = {'window': 60, 'min_count': 2, 'burst_type': 'burst_gener
# recovery is per-event; collapsing them adds zero information.
_AGGREGATION_EXEMPT_EVENTS = frozenset({
'error_resolved',
# Per-app upstream update. Each event carries a distinct app name,
# version and CT id — collapsing "5 app updates burst" into a
# summary hides exactly the information the user wants (which
# apps, which versions). Startup emit fires all pending updates
# at once, so without this exemption only the first 1-2 land and
# the rest get buffered into a useless summary.
'app_update_available',
'docker_stack_update_available',
'lxc_update_applied',
})
@@ -778,6 +796,7 @@ class NotificationManager:
self._task_watcher: Optional[TaskWatcher] = None
self._polling_collector: Optional[PollingCollector] = None
self._dispatch_thread: Optional[threading.Thread] = None
self._guest_lifecycle_callback = None
# Webhook receiver (no thread, passive)
self._hook_watcher: Optional[ProxmoxHookWatcher] = None
@@ -895,6 +914,40 @@ class NotificationManager:
self._config[key] = value
except Exception as e:
print(f"[NotificationManager] Failed to save setting {key}: {e}")
def _active_ai_model(self, provider_name: str) -> str:
"""Return the model selected for the active provider.
`ai_model` is the legacy global key. Newer settings persist
provider-specific models as `ai_model_<provider>`, and those must win
whenever present so custom endpoints keep their opaque aliases.
"""
return (
self._config.get(f'ai_model_{provider_name}', '')
or self._config.get('ai_model', '')
)
def _build_ai_config(self) -> Dict[str, Any]:
"""Build the shared AI config passed to notification rewriters."""
ai_provider = self._config.get('ai_provider', 'groq')
ai_api_key = self._config.get(f'ai_api_key_{ai_provider}', '') or self._config.get('ai_api_key', '')
return {
'ai_enabled': self._config.get('ai_enabled', 'false'),
'ai_provider': ai_provider,
'ai_api_key': ai_api_key,
'ai_model': self._active_ai_model(ai_provider),
'ai_language': self._config.get('ai_language', 'en'),
'ai_ollama_url': self._config.get('ai_ollama_url', ''),
# `ai_openai_base_url` was previously dropped from this dict and
# the downstream `notification_templates.AIRewriter` read it from
# the dict — meaning a user who configured LiteLLM / Azure as a
# custom base_url passed the "Test AI" check (which DOES pass it)
# but every real notification silently went to api.openai.com.
# Privacy + UX deception bug. Audit Tier 3.2 #1.
'ai_openai_base_url': self._config.get('ai_openai_base_url', ''),
'ai_prompt_mode': self._config.get('ai_prompt_mode', 'default'),
'ai_custom_prompt': self._config.get('ai_custom_prompt', ''),
}
def _rebuild_channels(self):
"""Rebuild channel instances from current config.
@@ -934,6 +987,17 @@ class NotificationManager:
with self._lock:
self._load_config()
return {'success': True, 'channels': list(self._channels.keys())}
def set_guest_lifecycle_callback(self, callback) -> None:
"""Attach a consumer to the existing PVE task lifecycle watcher.
Detection stays in TaskWatcherthe same source that emits VM/CT
start/stop notifications. This setter only lets Flask invalidate and
rebuild its guest caches when that already-detected event completes.
"""
self._guest_lifecycle_callback = callback
if self._task_watcher is not None:
self._task_watcher._guest_lifecycle_callback = callback
# ─── Server Mode (Background) ──────────────────────────────
@@ -970,7 +1034,10 @@ class NotificationManager:
# polling collector keep the managed_installs registry, the
# error history, and the task state up to date.
self._journal_watcher = JournalWatcher(self._event_queue)
self._task_watcher = TaskWatcher(self._event_queue)
self._task_watcher = TaskWatcher(
self._event_queue,
guest_lifecycle_callback=self._guest_lifecycle_callback,
)
self._polling_collector = PollingCollector(self._event_queue)
self._journal_watcher.start()
@@ -1216,26 +1283,7 @@ class NotificationManager:
default_event_enabled = 'true' if template.get('default_enabled', True) else 'false'
# Build AI config once (shared across channels, detail_level varies)
# Use per-provider API key
ai_provider = self._config.get('ai_provider', 'groq')
ai_api_key = self._config.get(f'ai_api_key_{ai_provider}', '') or self._config.get('ai_api_key', '')
ai_config = {
'ai_enabled': self._config.get('ai_enabled', 'false'),
'ai_provider': ai_provider,
'ai_api_key': ai_api_key,
'ai_model': self._config.get('ai_model', ''),
'ai_language': self._config.get('ai_language', 'en'),
'ai_ollama_url': self._config.get('ai_ollama_url', ''),
# `ai_openai_base_url` was previously dropped from this dict and
# the downstream `notification_templates.AIRewriter` read it from
# the dict — meaning a user who configured LiteLLM / Azure as a
# custom base_url passed the "Test AI" check (which DOES pass it)
# but every real notification silently went to api.openai.com.
# Privacy + UX deception bug. Audit Tier 3.2 #1.
'ai_openai_base_url': self._config.get('ai_openai_base_url', ''),
'ai_prompt_mode': self._config.get('ai_prompt_mode', 'default'),
'ai_custom_prompt': self._config.get('ai_custom_prompt', ''),
}
ai_config = self._build_ai_config()
# Get journal context if available (will be enriched per-channel based on detail_level)
raw_journal_context = data.get('_journal_context', '')
@@ -1302,6 +1350,20 @@ class NotificationManager:
# If AI is enabled AND rich_format is on, AI will include emojis directly
# Pass channel_type so AI knows whether to append original (email only)
channel_ai_config = {**ai_config, 'channel_type': ch_name}
# Availability notices are factual inventories, not advice.
# Even when the user enables experimental AI suggestions for
# diagnostic alerts, update announcements must only translate
# and format the versions/actions already supplied by the
# deterministic template.
if event_type in {
'update_available', 'update_summary', 'pve_update',
'proxmenux_update', 'post_install_update', 'apt_listchanges',
'lxc_updates_available', 'secure_gateway_update_available',
'nvidia_driver_update_available',
'coral_driver_update_available', 'app_update_available',
'docker_stack_update_available',
}:
channel_ai_config['ai_allow_suggestions'] = False
# Isolate the AI/enrich block in its own try so a failure
# here (raised from enrich_context_for_ai or any other
@@ -1925,14 +1987,19 @@ class NotificationManager:
# (log_critical_*, disk errors, smart_*, …) — preserves the
# anti-flood guarantee for sources that can burst.
_EVENT_TYPES_RESET_ON_START = (
# Update-status reports
# Update-status reports — re-fire on Monitor restart so the
# user gets a fresh "here's what's pending" as a health check
# that the notification pipeline is alive. Steady-state 24 h
# cooldown resumes after that first post-restart send.
'update_summary',
'proxmenux_update',
'post_install_update',
'pve_update',
'update_available',
'nvidia_driver_update_available',
'coral_driver_update_available',
'secure_gateway_update_available',
'app_update_available',
'docker_stack_update_available',
# Security events that must not be silenced by stale cooldowns
# following a Monitor reinstall (Pedro Rico, 19/05).
'auth_fail',
@@ -2163,26 +2230,8 @@ class NotificationManager:
message = rendered['body']
severity = severity or rendered['severity']
# AI config for enhancement - use per-provider API key
ai_provider = self._config.get('ai_provider', 'groq')
ai_api_key = self._config.get(f'ai_api_key_{ai_provider}', '') or self._config.get('ai_api_key', '')
ai_config = {
'ai_enabled': self._config.get('ai_enabled', 'false'),
'ai_provider': ai_provider,
'ai_api_key': ai_api_key,
'ai_model': self._config.get('ai_model', ''),
'ai_language': self._config.get('ai_language', 'en'),
'ai_ollama_url': self._config.get('ai_ollama_url', ''),
# `ai_openai_base_url` was previously dropped from this dict and
# the downstream `notification_templates.AIRewriter` read it from
# the dict — meaning a user who configured LiteLLM / Azure as a
# custom base_url passed the "Test AI" check (which DOES pass it)
# but every real notification silently went to api.openai.com.
# Privacy + UX deception bug. Audit Tier 3.2 #1.
'ai_openai_base_url': self._config.get('ai_openai_base_url', ''),
'ai_prompt_mode': self._config.get('ai_prompt_mode', 'default'),
'ai_custom_prompt': self._config.get('ai_custom_prompt', ''),
}
# AI config for enhancement
ai_config = self._build_ai_config()
results = {}
channels_sent = []
@@ -2268,26 +2317,9 @@ class NotificationManager:
else:
return {'success': False, 'error': f'Channel {channel_name} not configured'}
# AI config for enhancement - use per-provider API key
ai_provider = self._config.get('ai_provider', 'groq')
ai_api_key = self._config.get(f'ai_api_key_{ai_provider}', '') or self._config.get('ai_api_key', '')
ai_config = {
'ai_enabled': self._config.get('ai_enabled', 'false'),
'ai_provider': ai_provider,
'ai_api_key': ai_api_key,
'ai_model': self._config.get('ai_model', ''),
'ai_language': self._config.get('ai_language', 'en'),
'ai_ollama_url': self._config.get('ai_ollama_url', ''),
# `ai_openai_base_url` was previously dropped from this dict and
# the downstream `notification_templates.AIRewriter` read it from
# the dict — meaning a user who configured LiteLLM / Azure as a
# custom base_url passed the "Test AI" check (which DOES pass it)
# but every real notification silently went to api.openai.com.
# Privacy + UX deception bug. Audit Tier 3.2 #1.
'ai_openai_base_url': self._config.get('ai_openai_base_url', ''),
'ai_prompt_mode': self._config.get('ai_prompt_mode', 'default'),
'ai_custom_prompt': self._config.get('ai_custom_prompt', ''),
}
# AI config for enhancement
ai_config = self._build_ai_config()
ai_provider = ai_config.get('ai_provider', 'groq')
ai_enabled = self._config.get('ai_enabled', 'false')
if isinstance(ai_enabled, str):
@@ -2504,9 +2536,10 @@ class NotificationManager:
channels_info = {}
for ch_type, info in CHANNEL_TYPES.items():
enabled = self._config.get(f'{ch_type}.enabled', 'false') == 'true'
required_keys = info.get('required_keys', info['config_keys'])
configured = all(
bool(self._config.get(f'{ch_type}.{k}', ''))
for k in info['config_keys']
for k in required_keys
)
channels_info[ch_type] = {
'name': info['name'],
@@ -2718,7 +2751,7 @@ class NotificationManager:
'ai_provider': current_provider,
'ai_api_keys': ai_api_keys,
'ai_models': ai_models,
'ai_model': self._config.get('ai_model', ''),
'ai_model': self._active_ai_model(current_provider),
'ai_language': self._config.get('ai_language', 'en'),
'ai_ollama_url': self._config.get('ai_ollama_url', 'http://localhost:11434'),
'ai_openai_base_url': self._config.get('ai_openai_base_url', ''),
@@ -2789,7 +2822,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':
@@ -2893,7 +2926,7 @@ class NotificationManager:
return {'checked': False, 'migrated': False, 'message': 'AI not enabled'}
provider_name = self._config.get('ai_provider', 'groq')
current_model = self._config.get('ai_model', '')
current_model = self._active_ai_model(provider_name)
# Skip Ollama - user manages their own models
if provider_name == 'ollama':
@@ -2927,7 +2960,13 @@ class NotificationManager:
print(f"[NotificationManager] Failed to load verified models: {e}")
from ai_providers import get_provider
provider = get_provider(provider_name, api_key=api_key, model=current_model)
provider_kwargs = {
'api_key': api_key,
'model': current_model,
}
if provider_name == 'openai':
provider_kwargs['base_url'] = self._config.get('ai_openai_base_url', '')
provider = get_provider(provider_name, **provider_kwargs)
if not provider:
return {'checked': False, 'migrated': False, 'message': f'Unknown provider: {provider_name}'}
@@ -2935,8 +2974,24 @@ class NotificationManager:
# Get available models from API
api_models = provider.list_models()
# Combine: use verified models that are also in API (or all verified if API fails)
if api_models and verified_models:
# Combine: official providers intersect the API list with the
# verified catalogue. Custom OpenAI-compatible endpoints are
# authoritative for their own opaque aliases, so do not intersect
# them with ProxMenux's bundled official OpenAI IDs.
openai_custom_endpoint = (
provider_name == 'openai'
and bool(self._config.get('ai_openai_base_url', '').strip())
)
if openai_custom_endpoint:
if not api_models:
return {
'checked': True,
'migrated': False,
'new_model': current_model,
'message': 'Could not retrieve custom endpoint model list'
}
available_models = api_models
elif api_models and verified_models:
available_models = [m for m in verified_models if m in api_models]
elif verified_models:
available_models = verified_models
@@ -2970,13 +3025,16 @@ class NotificationManager:
try:
conn = sqlite3.connect(str(DB_PATH), timeout=10)
cursor = conn.cursor()
cursor.execute('''
INSERT OR REPLACE INTO user_settings (setting_key, setting_value, updated_at)
VALUES (?, ?, ?)
''', (f'{SETTINGS_PREFIX}ai_model', recommended, datetime.now().isoformat()))
now_iso = datetime.now().isoformat()
for model_key in ('ai_model', f'ai_model_{provider_name}'):
cursor.execute('''
INSERT OR REPLACE INTO user_settings (setting_key, setting_value, updated_at)
VALUES (?, ?, ?)
''', (f'{SETTINGS_PREFIX}{model_key}', recommended, now_iso))
conn.commit()
conn.close()
self._config['ai_model'] = recommended
self._config[f'ai_model_{provider_name}'] = recommended
print(f"[NotificationManager] AI model migrated: {old_model} -> {recommended}")
+95 -7
View File
@@ -510,6 +510,43 @@ TEMPLATES = {
'group': 'vm_ct',
'default_enabled': False,
},
'lxc_update_applied': {
'title': '{hostname}: LXC {ct_name} ({vmid}) update {result}',
'body': '{details}',
'label': 'LXC update applied',
'group': 'vm_ct',
'default_enabled': True,
},
'app_update_available': {
'title': '{hostname}: {app_name} update available on CT {vmid}',
'body': (
'{app_name} on CT {vmid} ({ct_name}) has a new version:\n'
' {installed}{latest}'
),
'label': 'App update available',
# Grouped under `updates` (not `vm_ct`) so the user can toggle
# per-app upstream notifications independently from VM/CT
# lifecycle events (start/stop/reboot). Sitting alongside the
# other update templates keeps the Settings UI consistent and
# leaves the group ready for future OCI-image notifications
# that share the same "an upstream release is available"
# semantics.
'group': 'updates',
# Every other update template ships enabled by default; leaving
# this one off meant users who registered apps in the App tab
# never received the notification they explicitly asked for.
'default_enabled': True,
},
'docker_stack_update_available': {
'title': '{hostname}: Docker updates available on CT {vmid}',
'body': (
'Container {ct_name} (CT {vmid}) has {count} Docker update(s):\n'
'{details}'
),
'label': 'Docker updates available',
'group': 'updates',
'default_enabled': True,
},
'vm_start': {
'title': '{hostname}: VM {vmname} ({vmid}) started',
'body': 'Virtual machine {vmname} (ID: {vmid}) is now running.',
@@ -996,6 +1033,17 @@ TEMPLATES = {
# /etc/aliases or removing MAILTO from the cron job. Audit Tier 6
# — `system_mail` toggle no visible en UI / reportado por usuario.
},
'apt_listchanges': {
'title': '{hostname}: {pve_title}',
'body': (
'Upstream package information forwarded by Proxmox VE through '
'apt-listchanges. The following text comes from the package '
'maintainer and is not a ProxMenux recommendation.\n\n{reason}'
),
'label': 'apt-listchanges package notices',
'group': 'updates',
'default_enabled': True,
},
'webhook_test': {
'title': '{hostname}: Webhook test received',
'body': 'PVE webhook connectivity test successful.\n{reason}',
@@ -1055,7 +1103,7 @@ TEMPLATES = {
'Kernel updates: {kernel_count}\n'
'Important packages:\n{important_list}'
),
'label': 'Updates available',
'label': 'Host package updates',
'group': 'updates',
'default_enabled': True,
},
@@ -1069,7 +1117,7 @@ TEMPLATES = {
'update_complete': {
'title': '{hostname}: System update completed',
'body': 'System packages have been successfully updated.\n{details}',
'label': 'Update completed',
'label': 'Host update completed',
'group': 'updates',
'default_enabled': False,
},
@@ -1157,6 +1205,27 @@ TEMPLATES = {
'default_enabled': True,
},
# Aggregate filesystem usage reported by the QEMU guest agent for a
# running VM. Fires when the guest is filling up regardless of
# whether the storage is a virtual disk or a PCI-passthrough drive
# (TrueNAS-style appliances included) — the metric is "how full is
# the guest", not "how full is the disk PVE knows about".
'vm_disk_low': {
'title': '{hostname}: VM {vmid} filesystems at {usage_percent}%',
'body': (
'VM {vmid} ({name}) guest filesystems are at {usage_percent}% '
'({disk_bytes_human} / {maxdisk_bytes_human}).\n\n'
'Reported by the QEMU guest agent. Includes every persistent '
'filesystem the guest mounts on a block device — virtual disks '
'and PCI-passthrough drives alike. Free up space inside the '
'guest or expand the affected storage before writes start to '
'fail.'
),
'label': 'VM filesystems near full',
'group': 'storage',
'default_enabled': True,
},
# ── Phase 3 capacity events (Sprint 14.5) ─────────────────────────
# Three new events that complete the storage-monitoring picture.
# Each fires at the user-configured warning/critical thresholds
@@ -1214,9 +1283,9 @@ TEMPLATES = {
'post_install_update': {
'title': '{hostname}: {count} ProxMenux optimization update(s) available',
'body': (
'{count} optimization update(s) detected on this host.\n\n'
'🛠️ Tools:\n{tool_list}\n\n'
'💡 How to apply:\n'
'{count} ProxMenux optimization update(s) available on this host.\n\n'
'🛠️ Available versions:\n{tool_list}\n\n'
'💡 Apply from:\n'
' • ProxMenux Monitor → Settings → ProxMenux Optimizations\n'
' • Or run the post-install menu (option 2) → "Apply available updates"'
),
@@ -1260,7 +1329,7 @@ TEMPLATES = {
'nvidia_driver_update_available': {
'title': '{hostname}: NVIDIA driver update available — v{latest_version}',
'body': (
'A newer NVIDIA driver compatible with kernel {kernel} is available.\n'
'A newer maintenance release is available for the installed NVIDIA driver branch.\n'
'🔹 Currently installed: v{current_version}\n'
'🟢 Latest available: v{latest_version}\n\n'
'{upgrade_reason}\n\n'
@@ -1675,6 +1744,10 @@ CATEGORY_EMOJI = {
EVENT_EMOJI = {
# VM / CT
'lxc_updates_available': '\U0001F4E6', # \uD83D\uDCE6 package \u2014 pending CT updates
'apt_listchanges': '\U0001F4E6', # package-maintainer NEWS via PVE mail
'lxc_update_applied': '\u2705', # \u2705 check \u2014 update applied
'app_update_available': '\U0001F195', # \ud83c\udd95 NEW \u2014 upstream app release
'docker_stack_update_available': '\U0001F433',
'vm_start': '\u25B6\uFE0F', # play button
'vm_start_warning': '\u26A0\uFE0F', # warning sign - started with warnings
'vm_stop': '\u23F9\uFE0F', # stop button
@@ -1716,6 +1789,7 @@ EVENT_EMOJI = {
'mount_stale': '\U0001F517', # link (broken connection feel)
'mount_readonly': '\U0001F512', # lock
'lxc_disk_low': '\U0001F4BE', # floppy disk (near-full)
'vm_disk_low': '\U0001F4BE', # floppy disk — same shape as LXC counterpart
'lxc_mount_low': '\U0001F4C2', # 📂 folder near-full
'pve_storage_full': '\U0001F4E6', # 📦 package (running out)
'zfs_pool_full': '\U0001F30A', # 🌊 wave (pool is full)
@@ -1954,6 +2028,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',
@@ -2354,7 +2429,20 @@ class AIEnhancer:
if title_match and body_match:
title_content = title_match.group(1).strip()
body_content = body_match.group(1).strip()
# Strip stray `[TITLE]` / `[BODY]` markers the AI may
# have echoed back inside the content itself (issue #297
# "additional note": PVE events arriving in Telegram
# with a literal `[TITLE]` in the title). The parser
# regex above splits on the FIRST occurrence, so any
# extra marker the model dropped into its title/body
# ends up inside the extracted string. Users see the
# markers verbatim in Telegram because they are only
# supposed to be structural separators, never content.
marker_re = re.compile(r'\[\s*(?:TITLE|BODY)\s*\]', re.IGNORECASE)
title_content = marker_re.sub('', title_content).strip()
body_content = marker_re.sub('', body_content).strip()
# Remove any "Original message/text" sections the AI might have added.
# Anchored at start-of-line (`(?:^|\n)\s*`) so legitimate prose
# like "we received the original message earlier" mid-paragraph
+71 -7
View File
@@ -92,6 +92,73 @@ def _read_text(path: Path) -> str:
return ""
def _load_notified_versions_from_disk() -> dict[str, set[str]]:
"""Read the versions already announced for each optimization.
Notification history lives beside the existing update snapshot so no
additional runtime file is introduced. Older snapshots simply have no
``notified_versions`` member and therefore start with an empty history.
"""
try:
payload = json.loads(_read_text(_UPDATES_JSON) or "{}")
except json.JSONDecodeError:
return {}
raw = payload.get("notified_versions", {})
if not isinstance(raw, dict):
return {}
normalized: dict[str, set[str]] = {}
for key, versions in raw.items():
if isinstance(versions, str):
versions = [versions]
if not isinstance(versions, list):
continue
clean = {str(version).strip() for version in versions if str(version).strip()}
if clean:
normalized[str(key)] = clean
return normalized
def _write_persisted_snapshot(
scanned_at: float,
updates: list[dict[str, Any]],
notified_versions: dict[str, set[str]],
) -> None:
"""Atomically persist the update snapshot and notification history."""
payload = {
"scanned_at": scanned_at,
"updates": updates,
"notified_versions": {
key: sorted(versions)
for key, versions in sorted(notified_versions.items())
if versions
},
}
_UPDATES_JSON.parent.mkdir(parents=True, exist_ok=True)
temporary = _UPDATES_JSON.with_suffix(_UPDATES_JSON.suffix + ".tmp")
temporary.write_text(json.dumps(payload, indent=2), encoding="utf-8")
temporary.replace(_UPDATES_JSON)
def load_notified_versions() -> dict[str, set[str]]:
"""Return a defensive copy of optimization versions already announced."""
return {
key: set(versions)
for key, versions in _load_notified_versions_from_disk().items()
}
def save_notified_versions(history: dict[str, set[str]]) -> None:
"""Persist notification history without changing the current scan."""
with _cache_lock:
scanned_at = float(_cache.get("scanned_at", 0.0) or 0.0)
updates = list(_cache.get("updates", []))
try:
_write_persisted_snapshot(scanned_at, updates, history)
except OSError:
# Notification de-duplication remains best-effort on read-only hosts.
pass
# ---------------------------------------------------------------------------
# Bash script parser
# ---------------------------------------------------------------------------
@@ -353,13 +420,10 @@ def scan(persist: bool = True) -> dict[str, Any]:
if persist:
try:
_UPDATES_JSON.parent.mkdir(parents=True, exist_ok=True)
_UPDATES_JSON.write_text(
json.dumps(
{"scanned_at": snapshot["scanned_at"], "updates": updates},
indent=2,
),
encoding="utf-8",
_write_persisted_snapshot(
snapshot["scanned_at"],
updates,
_load_notified_versions_from_disk(),
)
except OSError:
# Writing the on-disk cache is best-effort. If /usr/local
+133 -5
View File
@@ -18,6 +18,121 @@ Each entry includes:
import re
from typing import Optional, Dict, Any, List
def analyze_oom_event(text: str) -> Optional[Dict[str, Any]]:
"""Extract the scope and victim from a complete Linux OOM block.
The process on the ``invoked oom-killer`` line is only the allocation
trigger. The authoritative scope is carried by ``constraint`` and
``oom_memcg``; the actual victim is carried by ``Killed process``.
"""
if not text or not re.search(
r'invoked oom-killer|oom-kill:|memory cgroup out of memory|out of memory: killed process',
text,
re.IGNORECASE,
):
return None
result: Dict[str, Any] = {
'scope': 'unknown',
'constraint': '',
'cgroup_path': '',
'ctid': '',
'invoker': '',
'victim_process': '',
'victim_pid': '',
'memory_usage_kib': None,
'memory_limit_kib': None,
'swap_usage_kib': None,
'swap_limit_kib': None,
}
constraint = re.search(r'constraint=([A-Z0-9_]+)', text, re.IGNORECASE)
if constraint:
result['constraint'] = constraint.group(1).upper()
cgroup = re.search(r'oom_memcg=([^,\s]+)', text, re.IGNORECASE)
if not cgroup:
cgroup = re.search(r'Memory cgroup stats for\s+([^:\s]+)', text, re.IGNORECASE)
if cgroup:
result['cgroup_path'] = cgroup.group(1)
ctid = re.search(r'/lxc/(\d+)\b', result['cgroup_path'] or text, re.IGNORECASE)
if ctid:
result['ctid'] = ctid.group(1)
result['scope'] = 'lxc'
elif result['constraint'] == 'CONSTRAINT_MEMCG' or re.search(
r'memory cgroup out of memory', text, re.IGNORECASE
):
result['scope'] = 'memory_cgroup'
elif result['constraint']:
result['scope'] = 'host'
invoker = re.search(r'\b([A-Za-z0-9_.+/-]+)\s+invoked oom-killer', text, re.IGNORECASE)
if invoker:
result['invoker'] = invoker.group(1)
victim = re.search(r'Killed process\s+(\d+)\s+\(([^)]+)\)', text, re.IGNORECASE)
if victim:
result['victim_pid'] = victim.group(1)
result['victim_process'] = victim.group(2)
memory = re.search(
r'memory:\s+usage\s+(\d+)kB,\s+limit\s+(\d+)kB', text, re.IGNORECASE
)
if memory:
result['memory_usage_kib'] = int(memory.group(1))
result['memory_limit_kib'] = int(memory.group(2))
swap = re.search(
r'swap:\s+usage\s+(\d+)kB,\s+limit\s+(\d+)kB', text, re.IGNORECASE
)
if swap:
result['swap_usage_kib'] = int(swap.group(1))
result['swap_limit_kib'] = int(swap.group(2))
return result
def format_oom_diagnosis(analysis: Optional[Dict[str, Any]]) -> str:
"""Return a concise evidence-based diagnosis for an OOM analysis."""
if not analysis:
return ''
lines: List[str] = []
scope = analysis.get('scope')
ctid = analysis.get('ctid')
if scope == 'lxc' and ctid:
lines.append(f'OOM scope: LXC {ctid} memory cgroup (not a host-wide OOM)')
elif scope == 'memory_cgroup':
path = analysis.get('cgroup_path') or 'unknown cgroup'
lines.append(f'OOM scope: memory cgroup {path} (not a host-wide OOM)')
elif scope == 'host':
lines.append('OOM scope: host/kernel memory scope')
else:
lines.append('OOM scope: not established from the available log lines')
usage = analysis.get('memory_usage_kib')
limit = analysis.get('memory_limit_kib')
if usage is not None and limit is not None:
lines.append(f'Cgroup memory: {usage / 1024:.1f} MiB used of {limit / 1024:.1f} MiB')
swap_usage = analysis.get('swap_usage_kib')
swap_limit = analysis.get('swap_limit_kib')
if swap_usage is not None and swap_limit is not None:
lines.append(f'Cgroup swap: {swap_usage / 1024:.1f} MiB used of {swap_limit / 1024:.1f} MiB')
victim = analysis.get('victim_process')
victim_pid = analysis.get('victim_pid')
if victim:
lines.append(f'Killed process: {victim}' + (f' (PID {victim_pid})' if victim_pid else ''))
invoker = analysis.get('invoker')
if invoker and invoker != victim:
lines.append(f'Allocation trigger: {invoker} (not necessarily the largest consumer)')
return '\n'.join(lines)
# Known error patterns with causes and solutions
PROXMOX_KNOWN_ERRORS: List[Dict[str, Any]] = [
# ==================== SUBSCRIPTION/LICENSE ====================
@@ -169,11 +284,11 @@ PROXMOX_KNOWN_ERRORS: List[Dict[str, Any]] = [
},
{
"pattern": r"out of memory|OOM.*kill|cannot allocate memory|memory.*exhausted",
"cause": "System or VM ran out of memory",
"cause_detailed": "The Linux OOM (Out Of Memory) killer terminated a process to free memory. This indicates memory pressure from overcommitment or memory leaks.",
"cause": "The kernel invoked the OOM killer under memory pressure",
"cause_detailed": "Linux could not satisfy a memory allocation in the relevant host, cgroup, cpuset or NUMA scope. The process named as having invoked the OOM killer only triggered the allocation; it is not necessarily the largest consumer or the process that was killed. The complete OOM block is required to identify the scope, victim and likely cause.",
"severity": "critical",
"solution": "Increase memory allocation or reduce VM memory usage",
"solution_detailed": "1. Check what was killed: dmesg | grep -i oom\n2. Review memory usage: free -h\n3. Check balloon driver status for VMs\n4. Consider adding swap or RAM\n5. Review VM memory allocations for overcommitment",
"solution": "Inspect the complete OOM event and current host/cgroup memory before changing allocations",
"solution_detailed": "1. Read the complete kernel OOM block, including 'Killed process', 'Mem-Info' and task rows\n2. Determine whether it was a host-wide or memory-cgroup OOM\n3. Review free -h, swap, CommitLimit/Committed_AS and active VM/LXC allocations\n4. On ZFS hosts, compare ARC size and c_max with the configured zfs_arc_max\n5. Adjust the confirmed consumer, ARC cap or workload only after identifying the exhausted scope",
"category": "memory"
},
@@ -316,6 +431,10 @@ def get_error_context(text: str, category: Optional[str] = None, detail_level: s
error = find_matching_error(text, category)
if not error:
return None
oom_diagnosis = ''
if error.get('category') == 'memory':
oom_diagnosis = format_oom_diagnosis(analyze_oom_event(text))
# NOTE: we intentionally do NOT emit a "Severity:" line here.
# The catalogue's severity is the *typical* severity of a class
@@ -329,7 +448,10 @@ def get_error_context(text: str, category: Optional[str] = None, detail_level: s
# carried by the notification's own severity field; repeating a
# different value here is noise at best, misinformation at worst.
if detail_level == "minimal":
return f"Known issue: {error['cause']}"
result = f"Known issue: {error['cause']}"
if oom_diagnosis:
result += f"\n{oom_diagnosis}"
return result
elif detail_level == "standard":
lines = [
@@ -339,6 +461,9 @@ def get_error_context(text: str, category: Optional[str] = None, detail_level: s
]
if error.get("url"):
lines.append(f" Docs: {error['url']}")
if oom_diagnosis:
lines.append(" Event analysis:")
lines.extend(f" {line}" for line in oom_diagnosis.splitlines())
return "\n".join(lines)
else: # detailed
@@ -349,6 +474,9 @@ def get_error_context(text: str, category: Optional[str] = None, detail_level: s
]
if error.get("url"):
lines.append(f" Documentation: {error['url']}")
if oom_diagnosis:
lines.append(" Event analysis:")
lines.extend(f" {line}" for line in oom_diagnosis.splitlines())
return "\n".join(lines)
+33 -20
View File
@@ -12,6 +12,33 @@ import time
from typing import Dict, List, Any, Optional
def classify_storage_state(storage_type: str, status: str, total: int) -> Dict[str, Any]:
"""Classify reachability independently from reported capacity."""
normalized_status = str(status or 'unknown').strip().lower()
normalized_type = str(storage_type or 'unknown').strip().lower()
capacity_known = total > 0
if normalized_status != 'available':
return {
'status': 'error',
'status_detail': normalized_status or 'unknown',
'capacity_known': capacity_known,
}
if not capacity_known and normalized_type == 'pbs':
return {
'status': 'namespace_restricted',
'status_detail': 'namespace_restricted',
'capacity_known': False,
}
return {
'status': 'active',
'status_detail': 'available' if capacity_known else 'capacity_unreported',
'capacity_known': capacity_known,
}
class ProxmoxStorageMonitor:
"""Monitor Proxmox storage configuration and status"""
@@ -177,28 +204,13 @@ class ProxmoxStorageMonitor:
'percent': round(percent, 2),
'node': node
}
# Check if storage is available.
#
# "jc-pbs-friendly" mode (Sprint 11.6): a remote PBS where
# the user only has DatastoreAdmin on their own namespace
# reports `status=available` + `total=0` — the storage IS
# reachable, the user just can't list the datastore size.
# Treat that combination as INFO (namespace-restricted)
# instead of CRITICAL so we don't spam the operator with
# "almacenamiento no disponible" every poll. Real outages
# still flag because they come back with `status != available`.
if total == 0 and status.lower() == "available" and storage_type == 'pbs':
storage_info['status'] = 'namespace_restricted'
storage_info['status_detail'] = 'namespace_restricted'
state = classify_storage_state(storage_type, status, total)
storage_info.update(state)
if state['status'] in ('active', 'namespace_restricted'):
available_storages.append(storage_info)
elif total == 0 or status.lower() != "available":
storage_info['status'] = 'error'
storage_info['status_detail'] = 'unavailable' if total == 0 else status
unavailable_storages.append(storage_info)
else:
storage_info['status'] = 'active'
available_storages.append(storage_info)
unavailable_storages.append(storage_info)
# Check for configured storages that are completely missing
for storage_name, storage_config in self.configured_storages.items():
@@ -212,6 +224,7 @@ class ProxmoxStorageMonitor:
'used': 0,
'available': 0,
'percent': 0,
'capacity_known': False,
'node': local_node
})
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env python3
"""Safely recreate one standalone Docker container inside an LXC.
The container's create-time Config/HostConfig is read from Docker's API,
the referenced image is pulled, and a replacement is validated before the
old container is removed. If create/start/validation fails, the original
container name and running state are restored.
Compose-owned containers are deliberately rejected: their declarative
project is the authoritative and safer update path.
"""
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
import time
NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$")
def pct_exec(vmid: int, argv: list[str], *, input_text: str | None = None, timeout: int = 300) -> subprocess.CompletedProcess:
return subprocess.run(
["/usr/sbin/pct", "exec", str(vmid), "--", *argv],
input=input_text,
capture_output=True,
text=True,
timeout=timeout,
)
def checked(vmid: int, argv: list[str], *, input_text: str | None = None, timeout: int = 300) -> str:
result = pct_exec(vmid, argv, input_text=input_text, timeout=timeout)
if result.returncode != 0:
detail = (result.stderr or result.stdout or "command failed").strip()
raise RuntimeError(f"{' '.join(argv[:3])}: {detail}")
return result.stdout or ""
def inspect_one(vmid: int, name: str) -> dict:
payload = json.loads(checked(vmid, ["docker", "inspect", name], timeout=30))
if not isinstance(payload, list) or len(payload) != 1:
raise RuntimeError("docker inspect returned an unexpected response")
return payload[0]
def create_payload(inspect: dict, image: str) -> dict:
config = dict(inspect.get("Config") or {})
config["Image"] = image
host_config = dict(inspect.get("HostConfig") or {})
if host_config.get("AutoRemove"):
raise RuntimeError("containers with AutoRemove cannot be recreated safely")
endpoints: dict[str, dict] = {}
for network_name, endpoint in ((inspect.get("NetworkSettings") or {}).get("Networks") or {}).items():
if not NAME_RE.match(str(network_name)):
continue
# Preserve names/aliases and driver options, but deliberately let
# Docker allocate a fresh IP while the stopped rollback container
# still owns its old endpoint.
target: dict = {}
for key in ("Aliases", "Links", "DriverOpts"):
if endpoint.get(key) is not None:
target[key] = endpoint[key]
endpoints[str(network_name)] = target
return {
**config,
"HostConfig": host_config,
"NetworkingConfig": {"EndpointsConfig": endpoints},
}
def api_create(vmid: int, name: str, payload: dict) -> str:
body = json.dumps(payload, separators=(",", ":"))
result = pct_exec(
vmid,
[
"curl", "--silent", "--show-error", "--fail-with-body",
"--unix-socket", "/var/run/docker.sock",
"-H", "Content-Type: application/json",
"-X", "POST", "--data-binary", "@-",
f"http://localhost/v1.41/containers/create?name={name}",
],
input_text=body,
timeout=60,
)
if result.returncode != 0:
raise RuntimeError((result.stderr or result.stdout or "Docker create API failed").strip())
response = json.loads(result.stdout or "{}")
container_id = str(response.get("Id") or "")
if not container_id:
raise RuntimeError(str(response.get("message") or "Docker create API returned no container id"))
return container_id
def recreate(vmid: int, name: str) -> None:
original = inspect_one(vmid, name)
labels = ((original.get("Config") or {}).get("Labels") or {})
if labels.get("com.docker.compose.project"):
raise RuntimeError("container belongs to Docker Compose; use its project update action")
image = str((original.get("Config") or {}).get("Image") or "").strip()
if not image:
raise RuntimeError("container has no reusable image reference")
was_running = bool((original.get("State") or {}).get("Running"))
backup_name = f"{name}.proxmenux-rollback-{int(time.time())}"
replacement_created = False
print(f"=== Docker protected recreation: CT {vmid} / {name} ===", flush=True)
print(f"Image: {image}", flush=True)
print("Pulling the referenced image…", flush=True)
pull = pct_exec(vmid, ["docker", "pull", image], timeout=1800)
if pull.stdout:
print(pull.stdout.rstrip(), flush=True)
if pull.returncode != 0:
raise RuntimeError((pull.stderr or "docker pull failed").strip())
payload = create_payload(original, image)
try:
if was_running:
print("Stopping the original container…", flush=True)
checked(vmid, ["docker", "stop", "--time", "30", name], timeout=60)
print(f"Keeping rollback container as {backup_name}", flush=True)
checked(vmid, ["docker", "rename", name, backup_name], timeout=30)
print("Creating replacement from the inspected configuration…", flush=True)
api_create(vmid, name, payload)
replacement_created = True
if was_running:
checked(vmid, ["docker", "start", name], timeout=60)
deadline = time.time() + 20
while True:
state = inspect_one(vmid, name).get("State") or {}
if not state.get("Running"):
raise RuntimeError(str(state.get("Error") or "replacement stopped during validation"))
health = ((state.get("Health") or {}).get("Status") or "").lower()
if health == "unhealthy":
raise RuntimeError("replacement healthcheck is unhealthy")
if health != "starting" or time.time() >= deadline:
break
time.sleep(2)
print("Replacement validated; removing rollback container…", flush=True)
checked(vmid, ["docker", "rm", "-f", backup_name], timeout=60)
print("Docker container recreation completed successfully.", flush=True)
except Exception:
print("Recreation failed; restoring the original container…", file=sys.stderr, flush=True)
if replacement_created:
pct_exec(vmid, ["docker", "rm", "-f", name], timeout=60)
pct_exec(vmid, ["docker", "rename", backup_name, name], timeout=30)
if was_running:
pct_exec(vmid, ["docker", "start", name], timeout=60)
raise
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--vmid", required=True, type=int)
parser.add_argument("--container", required=True)
args = parser.parse_args()
if args.vmid <= 0 or not NAME_RE.match(args.container):
parser.error("invalid VMID or container name")
try:
recreate(args.vmid, args.container)
return 0
except Exception as exc:
print(f"ERROR: {exc}", file=sys.stderr, flush=True)
return 1
if __name__ == "__main__":
raise SystemExit(main())
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""Helpers for ProxMenux HTTP security headers."""
from __future__ import annotations
import os
import re
from urllib.parse import urlparse
PRIMARY_FRAME_ANCESTORS_ENV = "PROXMENUX_ALLOWED_FRAME_ANCESTORS"
COMPAT_FRAME_ANCESTORS_ENV = "ALLOWED_FRAME_ANCESTORS"
_FRAME_ANCESTOR_KEYWORDS = {
"self": "'self'",
"'self'": "'self'",
}
_UNSAFE_CSP_CHARS = re.compile(r"[\r\n;]")
_FRAME_ANCESTOR_SEPARATOR = re.compile(r"[\s,]+")
_CSP_PREFIX = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: blob: https:; "
"font-src 'self' data:; "
"connect-src 'self' ws: wss: https:; "
)
_CSP_SUFFIX = "base-uri 'self'; form-action 'self'"
def _split_frame_ancestor_sources(raw_value: str) -> list[str]:
return [
source.strip()
for source in _FRAME_ANCESTOR_SEPARATOR.split(raw_value)
if source.strip()
]
def _normalize_frame_ancestor_source(source: str) -> str | None:
token = source.strip()
lowered = token.lower()
if lowered in _FRAME_ANCESTOR_KEYWORDS:
return _FRAME_ANCESTOR_KEYWORDS[lowered]
if not token or _UNSAFE_CSP_CHARS.search(token):
return None
# Keep the initial support intentionally narrow: exact HTTP(S) origins.
# Broad schemes, wildcards, paths, queries, and credentials are rejected.
if token in {"*", "http:", "https:"}:
return None
parsed = urlparse(token)
if parsed.scheme.lower() not in {"http", "https"} or not parsed.netloc:
return None
if parsed.username or parsed.password:
return None
try:
parsed.port
except ValueError:
return None
if parsed.path not in ("", "/") or parsed.params or parsed.query or parsed.fragment:
return None
if not parsed.hostname or "*" in parsed.hostname:
return None
return f"{parsed.scheme.lower()}://{parsed.netloc.lower()}"
def get_allowed_frame_ancestors(environ: dict[str, str] | None = None) -> list[str]:
"""Return sanitized frame-ancestor CSP sources from environment settings."""
environ = os.environ if environ is None else environ
raw_value = environ.get(PRIMARY_FRAME_ANCESTORS_ENV, "").strip()
if not raw_value:
raw_value = environ.get(COMPAT_FRAME_ANCESTORS_ENV, "").strip()
sources: list[str] = []
seen: set[str] = set()
for raw_source in _split_frame_ancestor_sources(raw_value):
source = _normalize_frame_ancestor_source(raw_source)
if source and source not in seen:
sources.append(source)
seen.add(source)
return sources
def build_content_security_policy(frame_ancestors: list[str] | None = None) -> str:
ancestors_value = " ".join(frame_ancestors or []) or "'none'"
return _CSP_PREFIX + f"frame-ancestors {ancestors_value}; " + _CSP_SUFFIX
def should_emit_x_frame_options(frame_ancestors: list[str] | None = None) -> bool:
return not bool(frame_ancestors)

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