diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ed6632d3..47e63a88 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -7,6 +7,7 @@ on: paths: - "web/**" - "guides/**" + - "scripts/**" - "CHANGELOG.md" workflow_dispatch: @@ -31,15 +32,15 @@ jobs: with: node-version: "20" cache: 'npm' - cache-dependency-path: 'web/package.json' + cache-dependency-path: 'web/package-lock.json' - name: Setup Pages uses: actions/configure-pages@v4 - - name: Install dependencies and generate lock file + - name: Install dependencies run: | cd web - npm install + npm ci - name: Build with Next.js run: | diff --git a/.gitignore b/.gitignore index 19fae05e..94bbb09e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,14 @@ web/out/ web/node_modules/ node_modules/ +# Local-only — accidental pagefind install at project root. +# Pagefind is declared and installed from web/package.json; the +# CI build (.github/workflows/deploy.yml) only runs +# `cd web && npm install`, so a root-level package.json/lock is +# never consumed and just adds noise. Keep them ignored. +/package.json +/package-lock.json + # Logs web/*.log *.log @@ -33,6 +41,15 @@ Thumbs.db /web/.next /web/out +# Build artifacts generated by web's prebuild + build scripts. +# `prebuild` runs `sync:scripts` which rsyncs ../scripts/ into +# public/scripts/. `build` runs pagefind --site out which writes the +# search index into public/pagefind/. Both are regenerated fresh by +# the GitHub Pages CI on every deploy; committing them would just +# bloat the repo and produce constant noise in `git status`. +/web/public/pagefind/ +/web/public/scripts/ + # Cache .cache /web/.cache diff --git a/AppImage/ProxMenux-1.2.2.AppImage b/AppImage/ProxMenux-1.2.2.AppImage new file mode 100755 index 00000000..c96e595a Binary files /dev/null and b/AppImage/ProxMenux-1.2.2.AppImage differ diff --git a/AppImage/ProxMenux-Monitor.AppImage.sha256 b/AppImage/ProxMenux-Monitor.AppImage.sha256 index 48d39a76..21e23405 100644 --- a/AppImage/ProxMenux-Monitor.AppImage.sha256 +++ b/AppImage/ProxMenux-Monitor.AppImage.sha256 @@ -1 +1 @@ -db5bc199adba9c231f344428ac902a0cbf7473778e8a79a4535263599d975449 ProxMenux-1.2.0.AppImage +097e2344675d4b21f1dd18c531c956c299a6507fbc3d0c9695418063581ba2b0 diff --git a/AppImage/app/page.tsx b/AppImage/app/page.tsx index 826117f7..810f2766 100644 --- a/AppImage/app/page.tsx +++ b/AppImage/app/page.tsx @@ -29,21 +29,57 @@ export default function Home() { const response = await fetch(getApiUrl("/api/auth/status"), { headers: token ? { Authorization: `Bearer ${token}` } : {}, }) - + + // 401 here means the token is present but invalid — typically signed + // under a previous jwt_secret (rotated on AppImage upgrade or fresh + // install). If we let this fall into the catch below, the dashboard + // would render and every authenticated component would fire its own + // 401 in parallel, flooding the backend logs and looping reloads. + // Drop the dead token and force the Login screen instead. + if (response.status === 401) { + try { + localStorage.removeItem("proxmenux-auth-token") + } catch { + // private browsing — best-effort + } + setAuthStatus({ + loading: false, + authEnabled: true, + authConfigured: true, + authenticated: false, + }) + return + } + // Check if response is valid JSON before parsing if (!response.ok) { throw new Error(`HTTP ${response.status}`) } - + const contentType = response.headers.get("content-type") if (!contentType || !contentType.includes("application/json")) { throw new Error("Response is not JSON") } - + const data = await response.json() const authenticated = data.auth_enabled ? data.authenticated : true + // Clear the 401 cascade-prevention flag when we successfully end + // up in the authenticated state. The flag is meant to dedupe a + // burst of 401s during a single page load; once we've confirmed + // the user is in, a future 401 (token rotation, restart, etc.) + // should be allowed to reload again. Without this, a stale flag + // can prevent the post-2FA dashboard from recovering from any + // transient 401 and leaves the UI blocked. + if (authenticated) { + try { + sessionStorage.removeItem("proxmenux-auth-401-handled") + } catch { + // private browsing — best-effort + } + } + setAuthStatus({ loading: false, authEnabled: data.auth_enabled, diff --git a/AppImage/components/about.tsx b/AppImage/components/about.tsx new file mode 100644 index 00000000..86f91ef3 --- /dev/null +++ b/AppImage/components/about.tsx @@ -0,0 +1,234 @@ +"use client" + +import Image from "next/image" +import { + Github, + Heart, + BookOpen, + MessageSquare, + Bug, + Sparkles, + Scale, + ExternalLink, +} from "lucide-react" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card" +import { APP_VERSION } from "./release-notes-modal" + +// Issue #191: a dedicated About tab. Centralises project metadata +// (version, license, author) and every external link the project +// already exposes — GitHub, docs, donation. Replaces the lone +// "Support and contribute to the project" footer link with a proper +// information surface that's easy to extend with new social channels +// without re-cluttering the dashboard footer. + +interface LinkRow { + label: string + description: string + href: string + Icon: React.ComponentType<{ className?: string }> + accent?: keyof typeof ACCENT_CLASSES +} + +// Tailwind only emits classes that appear as literal strings in the +// source. A dynamic `bg-${accent}/10` template does not survive the +// purge step, so each accent maps to a fully-spelled class pair below. +const ACCENT_CLASSES = { + gray: "bg-gray-500/10 text-gray-400", + blue: "bg-blue-500/10 text-blue-500", + purple: "bg-purple-500/10 text-purple-400", + red: "bg-red-500/10 text-red-500", + pink: "bg-pink-500/10 text-pink-500", +} as const + +const PROJECT_LINKS: LinkRow[] = [ + { + label: "GitHub repository", + description: "Source code, releases and issue tracker.", + href: "https://github.com/MacRimi/ProxMenux", + Icon: Github, + accent: "gray", + }, + { + label: "Documentation", + description: "Full user guide for ProxMenux and the Monitor.", + href: "https://proxmenux.com", + Icon: BookOpen, + accent: "blue", + }, + { + label: "Discussions", + description: "Ask questions, share custom AI prompts, swap ideas.", + 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.", + href: "https://github.com/MacRimi/ProxMenux/issues", + Icon: Bug, + accent: "red", + }, +] + +const SUPPORT_LINKS: LinkRow[] = [ + { + label: "Support the project on Ko-fi", + description: "ProxMenux is free and open source. Donations cover hosting and dev time.", + href: "https://ko-fi.com/macrimi", + Icon: Heart, + accent: "pink", + }, +] + +function LinkCard({ row }: { row: LinkRow }) { + 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 + // accent-coloured borders or text colour changes — keeps the look + // consistent with the rest of the project. + return ( + + + + +
+
+ {row.label} + +
+

{row.description}

+
+
+ ) +} + +export function About() { + return ( +
+ {/* Hero — logo, name, version, one-line description. */} + + +
+
+ ProxMenux logo +
+
+

+ ProxMenux Monitor +

+

+ A web dashboard and management layer for Proxmox VE — health monitoring, + notifications, terminal, optimization tracker and more, packaged as a single + AppImage. +

+
+ + + v{APP_VERSION} + + {/* Beta versions surface their pre-release notes on the + GitHub Releases page (where each beta is tagged + signed); + stable versions point at the canonical web changelog + which only carries shipped releases. Detection: the + APP_VERSION string carries a "-beta" / "-rc" / + "-alpha" suffix for any non-stable build. */} + {(() => { + const isPrerelease = /-(beta|rc|alpha)/i.test(APP_VERSION) + const href = isPrerelease + ? "https://github.com/MacRimi/ProxMenux/releases" + : "https://proxmenux.com/en/changelog" + const label = isPrerelease ? "Release notes" : "Changelog" + return ( + + {label} + + + ) + })()} +
+
+
+
+
+ + {/* Project links — GitHub, docs, discussions, bug tracker. */} + + + + + Project + + Repository, documentation and community channels. + + +
+ {PROJECT_LINKS.map(row => ( + + ))} +
+
+
+ + {/* Support + License combined — donation link and licensing + info in one card. The previous layout had a separate "Author" + block that has been removed by request. */} + + + + + Support & License + + + ProxMenux is free and open source under the GPL-3.0 license. If it's useful to + you, a one-off contribution helps keep it that way. + + + +
+ {SUPPORT_LINKS.map(row => ( + + ))} + + + + +
+
+ GPL-3.0 license + +
+

+ Free software — see the LICENSE file for the full text. +

+
+
+
+
+
+
+ ) +} diff --git a/AppImage/components/auth-setup.tsx b/AppImage/components/auth-setup.tsx index ed336c8f..1b137709 100644 --- a/AppImage/components/auth-setup.tsx +++ b/AppImage/components/auth-setup.tsx @@ -1,11 +1,11 @@ "use client" -import { useState, useEffect } from "react" +import { useState, useEffect, useRef } from "react" import { Button } from "./ui/button" import { Dialog, DialogContent, DialogTitle } from "./ui/dialog" import { Input } from "./ui/input" import { Label } from "./ui/label" -import { Shield, Lock, User, AlertCircle, Eye, EyeOff } from "lucide-react" +import { Shield, Lock, User, AlertCircle, Eye, EyeOff, Upload, Trash2 } from "lucide-react" import { getApiUrl } from "../lib/api-config" interface AuthSetupProps { @@ -22,6 +22,14 @@ export function AuthSetup({ onComplete }: AuthSetupProps) { const [loading, setLoading] = useState(false) const [showPassword, setShowPassword] = useState(false) const [showConfirmPassword, setShowConfirmPassword] = useState(false) + // Profile (Fase 2 — v1.2.2). Both optional decorations on top of the + // mandatory username + password. Persisted via PUT /api/auth/profile + // and POST /api/auth/profile/avatar after the user lands a successful + // /api/auth/setup so we don't change the setup endpoint's contract. + const [displayName, setDisplayName] = useState("") + const [avatarFile, setAvatarFile] = useState(null) + const [avatarPreviewUrl, setAvatarPreviewUrl] = useState(null) + const fileInputRef = useRef(null) useEffect(() => { const checkOnboardingStatus = async () => { @@ -58,36 +66,44 @@ export function AuthSetup({ onComplete }: AuthSetupProps) { setError("") try { - console.log("[v0] Skipping authentication setup...") const response = await fetch(getApiUrl("/api/auth/skip"), { method: "POST", headers: { "Content-Type": "application/json" }, }) const data = await response.json() - console.log("[v0] Auth skip response:", data) if (!response.ok) { throw new Error(data.error || "Failed to skip authentication") } if (data.auth_declined) { - console.log("[v0] Authentication skipped successfully - APIs should be accessible without token") } - console.log("[v0] Authentication skipped successfully") localStorage.setItem("proxmenux-auth-declined", "true") localStorage.removeItem("proxmenux-auth-token") // Remove any old token setOpen(false) onComplete() } catch (err) { - console.error("[v0] Auth skip error:", err) + console.error("Auth skip error:", err) setError(err instanceof Error ? err.message : "Failed to save preference") } finally { setLoading(false) } } + const handleAvatarPick = () => fileInputRef.current?.click() + + const handleAvatarChange = (file: File | null) => { + // Revoke the previous local preview so we don't leak blob URLs while + // the user picks another file before submitting. + if (avatarPreviewUrl) { + URL.revokeObjectURL(avatarPreviewUrl) + } + setAvatarFile(file) + setAvatarPreviewUrl(file ? URL.createObjectURL(file) : null) + } + const handleSetupAuth = async () => { setError("") @@ -109,7 +125,6 @@ export function AuthSetup({ onComplete }: AuthSetupProps) { setLoading(true) try { - console.log("[v0] Setting up authentication...") const response = await fetch(getApiUrl("/api/auth/setup"), { method: "POST", headers: { "Content-Type": "application/json" }, @@ -120,7 +135,6 @@ export function AuthSetup({ onComplete }: AuthSetupProps) { }) const data = await response.json() - console.log("[v0] Auth setup response:", data) if (!response.ok) { throw new Error(data.error || "Failed to setup authentication") @@ -129,13 +143,67 @@ export function AuthSetup({ onComplete }: AuthSetupProps) { if (data.token) { localStorage.setItem("proxmenux-auth-token", data.token) localStorage.removeItem("proxmenux-auth-declined") - console.log("[v0] Authentication setup successful") + } + + // Profile decorations (Fase 2). Sent as a follow-up to the setup + // call so the /api/auth/setup endpoint stays minimal (username + + // password only) — these calls reuse the existing profile + // endpoints and the JWT we just received. Failures here are + // non-fatal: the user is already authenticated and can finish + // configuring the profile from the /profile page. + const token = data.token + if (token) { + const trimmedDisplayName = displayName.trim() + if (trimmedDisplayName) { + try { + await fetch(getApiUrl("/api/auth/profile"), { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ display_name: trimmedDisplayName }), + }) + } catch (e) { + console.warn("[auth-setup] failed to save display_name:", e) + } + } + if (avatarFile) { + try { + await fetch(getApiUrl("/api/auth/profile/avatar"), { + method: "POST", + headers: { + "Content-Type": avatarFile.type, + Authorization: `Bearer ${token}`, + }, + body: avatarFile, + }) + } catch (e) { + console.warn("[auth-setup] failed to upload avatar:", e) + } + } + } + + // Release the local preview blob now that the file has been + // uploaded (or skipped). The header avatar pulls a fresh copy + // from the backend. + if (avatarPreviewUrl) { + URL.revokeObjectURL(avatarPreviewUrl) + setAvatarPreviewUrl(null) + } + + // Notify the header AvatarMenu (mounted on dashboard load with + // auth_enabled=false) to re-fetch its status + profile so the + // avatar appears immediately after first-time setup instead of + // requiring a page refresh. + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent("proxmenux:profile-changed")) } setOpen(false) onComplete() } catch (err) { - console.error("[v0] Auth setup error:", err) + console.error("Auth setup error:", err) setError(err instanceof Error ? err.message : "Failed to setup authentication") } finally { setLoading(false) @@ -268,6 +336,100 @@ export function AuthSetup({ onComplete }: AuthSetupProps) { + + {/* Optional profile decorations (Fase 2). Visually + separated from the mandatory credential fields by a + divider + a small heading so the operator understands + they can skip everything below and still complete the + setup. Both are saved with follow-up calls after the + setup endpoint returns the JWT. */} +
+

+ Profile · optional +

+ +
+ +
+ + setDisplayName(e.target.value)} + maxLength={64} + className="pl-10 text-base" + disabled={loading} + /> +
+

+ Leave empty to render the username itself. Up to 64 characters. +

+
+ +
+ +
+ {avatarPreviewUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( + + {(displayName || username || "U").trim().charAt(0).toUpperCase() || "U"} + + )} +
+ { + const file = e.target.files?.[0] || null + handleAvatarChange(file) + if (fileInputRef.current) fileInputRef.current.value = "" + }} + /> +
+ + {avatarFile && ( + + )} +
+

+ PNG, JPEG, WebP or GIF · up to 2 MB · pre-crop square for best results. +

+
+
+
+
diff --git a/AppImage/components/avatar-menu.tsx b/AppImage/components/avatar-menu.tsx new file mode 100644 index 00000000..7efdffe5 --- /dev/null +++ b/AppImage/components/avatar-menu.tsx @@ -0,0 +1,281 @@ +"use client" + +import { useEffect, useState } from "react" +import { User, Shield, LogOut } from "lucide-react" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "./ui/dropdown-menu" +import { fetchApi, getApiUrl, getAuthToken } from "../lib/api-config" + +interface AuthStatus { + auth_enabled?: boolean + username?: string | null +} + +interface ProfileData { + success: boolean + username?: string | null + display_name?: string | null + has_avatar?: boolean + avatar_mtime?: number | null +} + +interface AvatarMenuProps { + /** Size of the avatar circle in the header trigger. */ + size?: "md" | "lg" + /** + * Callback used by the Security menu item. The Monitor renders its + * Settings/Security panels inside the same dashboard route, not on + * a separate URL, so navigation is handled by the parent that knows + * how to switch tabs. Optional — when omitted the menu item is hidden. + */ + onOpenSecurity?: () => void + /** + * Callback for "View profile". Same rationale: the parent decides how + * to route there (modal, page, tab switch). Until Fase 2 lands the + * caller typically passes an alert/toast that the page is coming. + */ + onOpenProfile?: () => void +} + +/** + * AvatarMenu — user/account dropdown for the header. + * + * Self-fetches the current auth status to derive the username and the + * initial that fills the avatar circle. Stays silent (renders nothing) + * when authentication is disabled on this install — no point showing + * an account menu for a "Sign out" that doesn't apply. + * + * Sign out clears the token from localStorage and reloads, mirroring + * the existing `handleLogout` in `security.tsx`. That keeps a single + * source of truth for the logout flow until Fase 2 introduces a + * proper /api/auth/logout that revokes the JWT server-side too. + */ +export function AvatarMenu({ size = "lg", onOpenSecurity, onOpenProfile }: AvatarMenuProps) { + // 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 + // renders the moment auth status loaded → React error #310 ("rendered + // more hooks than during the previous render"). All `useState` and + // `useEffect` calls now live above any early return; the null branch + // is at the very end after the hooks. + const [status, setStatus] = useState(null) + const [profile, setProfile] = useState(null) + const [open, setOpen] = useState(false) + const [avatarBlobUrl, setAvatarBlobUrl] = useState(null) + + // Load both auth_status (to decide whether to render at all) and the + // profile (to render display_name + avatar). Profile is fetched only + // when auth is enabled — saves one roundtrip on installs without + // auth where the menu won't show anyway. + useEffect(() => { + let cancelled = false + fetchApi("/api/auth/status") + .then(data => { + if (cancelled) return + setStatus(data) + if (data?.auth_enabled && data?.username) { + fetchApi("/api/auth/profile") + .then(p => { + if (!cancelled) setProfile(p) + }) + .catch(() => { + // Profile fetch is best-effort. Falls back to username + initials. + }) + } + }) + .catch(() => { + if (!cancelled) setStatus(null) + }) + // Reload status + profile when the user updates the profile from + // the /profile page OR completes first-time auth setup. Refreshing + // status is what flips the menu visible after setup (when the + // initial mount saw auth_enabled=false); refreshing profile is + // what makes a new avatar/display name appear without a full + // browser refresh. + const handler = () => { + fetchApi("/api/auth/status") + .then(s => { + if (cancelled) return + setStatus(s) + if (s?.auth_enabled && s?.username) { + fetchApi("/api/auth/profile") + .then(p => { + if (!cancelled) setProfile(p) + }) + .catch(() => {}) + } + }) + .catch(() => {}) + } + if (typeof window !== "undefined") { + window.addEventListener("proxmenux:profile-changed", handler) + } + return () => { + cancelled = true + if (typeof window !== "undefined") { + window.removeEventListener("proxmenux:profile-changed", handler) + } + } + }, []) + + // Avatar fetch — the endpoint requires the Bearer header, which + // can't send, so we fetch as a blob and convert it to a + // local object URL for rendering. The blob URL is revoked on cleanup + // and on every refetch to avoid leaking memory. + useEffect(() => { + let cancelled = false + let currentBlobUrl: string | null = null + if (profile?.has_avatar) { + const token = getAuthToken() + const url = `${getApiUrl("/api/auth/profile/avatar")}?v=${profile.avatar_mtime || ""}` + fetch(url, { headers: token ? { Authorization: `Bearer ${token}` } : {} }) + .then(r => (r.ok ? r.blob() : null)) + .then(blob => { + if (cancelled || !blob) return + currentBlobUrl = URL.createObjectURL(blob) + setAvatarBlobUrl(currentBlobUrl) + }) + .catch(() => { + if (!cancelled) setAvatarBlobUrl(null) + }) + } else { + setAvatarBlobUrl(null) + } + return () => { + cancelled = true + if (currentBlobUrl) URL.revokeObjectURL(currentBlobUrl) + } + }, [profile?.has_avatar, profile?.avatar_mtime]) + + // ── Hooks finished. Safe to early-return now. ── + // Hide the avatar entirely when auth isn't enabled on this install — + // there's no user identity to surface and no Sign out to offer. + if (!status?.auth_enabled || !status?.username) return null + + const username = status.username + const displayName = profile?.display_name || username + const initial = displayName.trim().charAt(0).toUpperCase() || "U" + + const handleSignOut = () => { + try { + localStorage.removeItem("proxmenux-auth-token") + localStorage.removeItem("proxmenux-auth-setup-complete") + } catch { + // localStorage may be unavailable (private mode); fall through. + } + window.location.reload() + } + + // Avatar size in the header trigger. The trigger has no chevron now — + // removing it freed enough horizontal space to bump the avatar a + // notch up (40 → 44 / 32 → 36) without nudging the Refresh / Theme + // buttons sitting to its left. + const avatarSize = size === "lg" ? "w-11 h-11 text-lg" : "w-9 h-9 text-sm" + + return ( + <> + {/* Backdrop overlay — dim only (no blur). Mounted while the + dropdown is open. `bg-black/40` dims the page enough to focus + attention on the dropdown without distorting the content + behind, which testers found annoying when full backdrop blur + was used (especially on wider desktop viewports). `z-40` + places it above the dashboard content but below the dropdown + portal (`DropdownMenuContent` lands on z-[60]) and below the + header (which stays on z-50 so the avatar trigger remains + clickable). Clicking the backdrop closes the menu — the + explicit `onClick` mirrors Radix's outside-click handler. */} + {open && ( + + )} + + + ) +} diff --git a/AppImage/components/login.tsx b/AppImage/components/login.tsx index f027a7ad..534fddcd 100644 --- a/AppImage/components/login.tsx +++ b/AppImage/components/login.tsx @@ -26,6 +26,21 @@ export function Login({ onLogin }: LoginProps) { const [loading, setLoading] = useState(false) useEffect(() => { + // The Login screen is, by construction, the recovery path from any + // 401 cascade (the api-config wrapper redirects here when an + // expired/invalid JWT is detected). Clear the cascade-prevention + // flag on mount so a successful login can subsequently fire a fresh + // reload if a NEW 401 ever occurs. Without this clear, any 401 set + // earlier in the session sticks around forever and the next 401 + // (e.g. mid-2FA, or right after a successful login if the token was + // briefly stale) is silently swallowed by the de-dup — the user + // sees a blank/stuck dashboard. + try { + sessionStorage.removeItem("proxmenux-auth-401-handled") + } catch { + // private browsing — best-effort + } + const savedUsername = localStorage.getItem("proxmenux-saved-username") const savedPassword = localStorage.getItem("proxmenux-saved-password") @@ -76,6 +91,11 @@ export function Login({ onLogin }: LoginProps) { } localStorage.setItem("proxmenux-auth-token", data.token) + try { + sessionStorage.removeItem("proxmenux-auth-401-handled") + } catch { + // ignore + } if (rememberMe) { localStorage.setItem("proxmenux-saved-username", username) @@ -251,7 +271,7 @@ export function Login({ onLogin }: LoginProps) {
-

ProxMenux Monitor v1.2.0

+

ProxMenux Monitor v1.2.2

) diff --git a/AppImage/components/lxc-terminal-modal.tsx b/AppImage/components/lxc-terminal-modal.tsx index 7c2c387f..bbfcbb96 100644 --- a/AppImage/components/lxc-terminal-modal.tsx +++ b/AppImage/components/lxc-terminal-modal.tsx @@ -19,7 +19,10 @@ import { Terminal, Trash2, X, + Copy, + Clipboard, } from "lucide-react" +import { copyTerminalSelection, pasteFromClipboard } from "@/lib/terminal-clipboard" import { DropdownMenu, DropdownMenuContent, @@ -33,6 +36,7 @@ import { Input } from "@/components/ui/input" import { Dialog as SearchDialog, DialogContent as SearchDialogContent, DialogTitle as SearchDialogTitle } from "@/components/ui/dialog" import "xterm/css/xterm.css" import { API_PORT, fetchApi } from "@/lib/api-config" +import { getTicketedWsUrl } from "@/lib/terminal-ws" interface LxcTerminalModalProps { open: boolean @@ -161,9 +165,16 @@ export function LxcTerminalModal({ useEffect(() => { if (!isOpen) return + // `cancelled` short-circuits the async init if the modal closes + // before the dynamic xterm import resolves. Without this, we'd + // construct a Terminal instance, attach it to a now-stale ref, and + // open a WebSocket that nobody listens to. Audit Tier 6 — useEffect + // con `import("xterm")` sin cancelación. + let cancelled = false + // Small delay to ensure Dialog content is rendered const initTimeout = setTimeout(() => { - if (!terminalContainerRef.current) return + if (cancelled || !terminalContainerRef.current) return initTerminal() }, 100) @@ -172,12 +183,13 @@ export function LxcTerminalModal({ import("xterm").then((mod) => mod.Terminal), import("xterm-addon-fit").then((mod) => mod.FitAddon), ]) + if (cancelled) return const fontSize = window.innerWidth < 768 ? 12 : 16 const term = new TerminalClass({ rendererType: "dom", - fontFamily: '"Courier", "Courier New", "Liberation Mono", "DejaVu Sans Mono", monospace', + fontFamily: '"MesloLGS NF", "FiraCode Nerd Font", "JetBrainsMono Nerd Font", "Hack Nerd Font", "Symbols Nerd Font", "Courier", "Courier New", "Liberation Mono", "DejaVu Sans Mono", monospace', fontSize: fontSize, lineHeight: 1, cursorBlink: true, @@ -221,9 +233,11 @@ export function LxcTerminalModal({ termRef.current = term fitAddonRef.current = fitAddon - // Connect WebSocket to host terminal + // Connect WebSocket to host terminal. We append a single-use ticket + // (`?ticket=...`) which the backend consumes on handshake — see + // lib/terminal-ws.ts and AppImage/scripts/flask_terminal_routes.py. const wsUrl = getWebSocketUrl() - const ws = new WebSocket(wsUrl) + const ws = new WebSocket(await getTicketedWsUrl(wsUrl)) wsRef.current = ws // Reset state for new connection @@ -252,11 +266,22 @@ export function LxcTerminalModal({ rows: term.rows, })) - // Auto-execute pct enter after connection is ready + // Auto-execute pct enter after connection is ready. + // The string is sent verbatim to the bash PTY, so a non-numeric + // `vmid` would land as shell input (e.g. `pct enter ; rm -rf /`). + // The prop is typed `number` but JSON / URL query injections can + // sneak strings in; validate as a defensive redundancy. Audit + // residual #lxc-terminal-vmid-injection. setTimeout(() => { - if (ws.readyState === WebSocket.OPEN) { - ws.send(`pct enter ${vmid}\r`) + if (ws.readyState !== WebSocket.OPEN) return + // Coerce + verify: must be a positive integer that round-trips + // 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') + return } + ws.send(`pct enter ${id}\r`) }, 300) } @@ -302,13 +327,17 @@ export function LxcTerminalModal({ if (pctEnterMatch) { const afterPctEnter = cleanBuffer.substring(cleanBuffer.indexOf(pctEnterMatch[0]) + pctEnterMatch[0].length) - // Extract the host name from the prompt BEFORE pct enter (e.g., "root@amd") - const hostPromptMatch = cleanBuffer.match(/@([a-zA-Z0-9_-]+).*pct enter/) + // Extract the host name from the prompt BEFORE pct enter (e.g., "root@amd"). + // Charset widened to accept dotted FQDNs (`proxmox.lan`) and unicode + // letters/numbers (host names like `próxmox` or non-Latin scripts). + // The previous `[a-zA-Z0-9_-]` truncated the hostname and the + // "are we inside the LXC?" comparison then misfired. + const hostPromptMatch = cleanBuffer.match(/@([\p{L}\p{N}._-]+).*pct enter/u) const hostName = hostPromptMatch ? hostPromptMatch[1] : null - + // Look for a new prompt after pct enter that ends with # or $ // This works for both bash (user@host:~#) and ash/Alpine ([user@host /]#) - const promptMatch = afterPctEnter.match(/[@\[]([a-zA-Z0-9_-]+)[^\r\n]*[#$]\s*$/) + const promptMatch = afterPctEnter.match(/[@\[]([\p{L}\p{N}._-]+)[^\r\n]*[#$]\s*$/u) if (promptMatch) { const lxcHostname = promptMatch[1] @@ -354,6 +383,7 @@ export function LxcTerminalModal({ } return () => { + cancelled = true clearTimeout(initTimeout) if (pingIntervalRef.current) { clearInterval(pingIntervalRef.current) @@ -435,6 +465,14 @@ export function LxcTerminalModal({ const sendEnter = useCallback(() => sendKey("\r"), [sendKey]) const sendCtrlC = useCallback(() => sendKey("\x03"), [sendKey]) // Ctrl+C + // Mobile clipboard helpers — see lib/terminal-clipboard.ts for the rationale. + const handleCopy = useCallback(async () => { + await copyTerminalSelection(termRef.current) + }, []) + const handlePaste = useCallback(async () => { + await pasteFromClipboard(sendKey) + }, [sendKey]) + // Search effect - debounced search with cheat.sh useEffect(() => { const searchCheatSh = async (query: string) => { @@ -634,7 +672,7 @@ export function LxcTerminalModal({ - + Control Sequences sendKey("\x03")}> @@ -649,6 +687,16 @@ export function LxcTerminalModal({ Ctrl+R Search history + + Clipboard + { void handleCopy() }}> + + Copy selection + + { void handlePaste() }}> + + Paste + diff --git a/AppImage/components/lxc-update-detection.tsx b/AppImage/components/lxc-update-detection.tsx new file mode 100644 index 00000000..6551a4a0 --- /dev/null +++ b/AppImage/components/lxc-update-detection.tsx @@ -0,0 +1,227 @@ +"use client" + +import { useEffect, useState } from "react" +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" + +interface DetectionResponse { + success: boolean + enabled?: boolean + message?: string + purged?: number +} + +export function LxcUpdateDetection() { + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [enabled, setEnabled] = useState(true) + const [pending, setPending] = useState(true) + const [editMode, setEditMode] = useState(false) + const [error, setError] = useState(null) + const [saved, setSaved] = useState(false) + const [lastPurged, setLastPurged] = useState(null) + + useEffect(() => { + let cancelled = false + fetchApi("/api/lxc-updates/detection") + .then(data => { + if (cancelled) return + if (data.success && typeof data.enabled === "boolean") { + setEnabled(data.enabled) + setPending(data.enabled) + } else { + setError(data.message || "Failed to load setting") + } + }) + .catch(e => { + if (!cancelled) setError(String(e)) + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + return () => { + cancelled = true + } + }, []) + + const hasChanges = pending !== enabled + + function handleEdit() { + setEditMode(true) + setError(null) + setSaved(false) + setLastPurged(null) + } + + function handleCancel() { + setPending(enabled) + setEditMode(false) + setError(null) + setLastPurged(null) + } + + async function handleSave() { + if (!hasChanges) { + setEditMode(false) + return + } + setSaving(true) + setError(null) + setSaved(false) + setLastPurged(null) + try { + const data = await fetchApi("/api/lxc-updates/detection", { + method: "POST", + body: JSON.stringify({ enabled: pending }), + }) + if (!data.success) { + setError(data.message || "Failed to save setting") + return + } + setEnabled(pending) + setEditMode(false) + setSaved(true) + setTimeout(() => setSaved(false), 3000) + if (!pending && typeof data.purged === "number" && data.purged > 0) { + setLastPurged(data.purged) + } + // Notify the Notifications section so it hides/shows the + // lxc_updates_available toggle in real time. + if (typeof window !== "undefined") { + window.dispatchEvent( + new CustomEvent("proxmenux:lxc-detection-changed", { detail: { enabled: pending } }), + ) + } + } catch (e) { + setError(String(e)) + } finally { + setSaving(false) + } + } + + return ( + + +
+ {/* Title row — flex-wrap so on narrow screens the badge can drop + under the title without dragging the icon along with it. The + icon stays on the same baseline as the title text on every + breakpoint thanks to `items-center` + leading-tight title. */} +
+ + LXC Update Detection + {enabled ? ( + + Active + + ) : ( + + Disabled + + )} +
+
+ {saved && ( + + + Saved + + )} + {error && !editMode && ( + + Save failed: {error} + + )} + {editMode ? ( + <> + + + + ) : ( + + )} +
+
+ + Periodically check running Debian/Ubuntu/Alpine LXC containers for pending package updates + (apt list --upgradable / apk list -u) and surface them on the dashboard. The + corresponding notification toggle in Notifications → Services appears only while detection + is enabled. + +
+ + + {/* ── 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 + the icon to top-align and made the toggle wrap awkwardly. */} +
+
+ + Enable LXC update detection +
+ +
+ + {lastPurged !== null && lastPurged > 0 && ( +
+ +

+ {lastPurged} LXC entries removed from the registry. Re-enabling detection will repopulate them on the + next scan cycle. +

+
+ )} + + {error && editMode && ( +
+ +

{error}

+
+ )} +
+
+ ) +} diff --git a/AppImage/components/network-card.tsx b/AppImage/components/network-card.tsx index bb658a21..aaf640b4 100644 --- a/AppImage/components/network-card.tsx +++ b/AppImage/components/network-card.tsx @@ -109,7 +109,7 @@ export function NetworkCard({ interface_, timeframe, onClick }: NetworkCardProps }) } } catch (error) { - console.error("[v0] Failed to fetch traffic data for card:", error) + console.error("Failed to fetch traffic data for card:", error) setTrafficData({ received: 0, sent: 0 }) } } diff --git a/AppImage/components/network-traffic-chart.tsx b/AppImage/components/network-traffic-chart.tsx index 456339f7..c96b9649 100644 --- a/AppImage/components/network-traffic-chart.tsx +++ b/AppImage/components/network-traffic-chart.tsx @@ -110,7 +110,6 @@ export function NetworkTrafficChart({ ? `/api/network/${interfaceName}/metrics?timeframe=${timeframe}` : `/api/node/metrics?timeframe=${timeframe}` - console.log("[v0] Fetching network metrics from:", apiPath) const result = await fetchApi(apiPath) @@ -207,7 +206,7 @@ export function NetworkTrafficChart({ setIsInitialLoad(false) } } catch (err: any) { - console.error("[v0] Error fetching network metrics:", err) + console.error("Error fetching network metrics:", err) setError(err.message || "Error loading metrics") } finally { setLoading(false) diff --git a/AppImage/components/node-metrics-charts.tsx b/AppImage/components/node-metrics-charts.tsx index 79bd77cc..5f356223 100644 --- a/AppImage/components/node-metrics-charts.tsx +++ b/AppImage/components/node-metrics-charts.tsx @@ -83,41 +83,30 @@ export function NodeMetricsCharts() { const hasMemoryFree = data.some(d => d.memoryFree > 0) useEffect(() => { - console.log("[v0] NodeMetricsCharts component mounted") fetchMetrics() }, [timeframe]) const fetchMetrics = async () => { - console.log("[v0] fetchMetrics called with timeframe:", timeframe) setLoading(true) setError(null) try { const result = await fetchApi(`/api/node/metrics?timeframe=${timeframe}`) - console.log("[v0] Node metrics result:", result) - console.log("[v0] Result keys:", Object.keys(result)) - console.log("[v0] Data array length:", result.data?.length || 0) if (!result.data || !Array.isArray(result.data)) { - console.error("[v0] Invalid data format - data is not an array:", result) + console.error("Invalid data format - data is not an array:", result) throw new Error("Invalid data format received from server") } if (result.data.length === 0) { - console.warn("[v0] No data points received") + console.warn("No data points received") setData([]) setLoading(false) return } - console.log("[v0] First data point sample:", result.data[0]) - console.log("[v0] First data point loadavg field:", result.data[0]?.loadavg) - console.log("[v0] loadavg type:", typeof result.data[0]?.loadavg) - console.log("[v0] loadavg is array:", Array.isArray(result.data[0]?.loadavg)) if (result.data[0]?.loadavg) { - console.log("[v0] loadavg length:", result.data[0].loadavg.length) - console.log("[v0] loadavg[0]:", result.data[0].loadavg[0]) } const transformedData = result.data.map((item: any) => { @@ -170,12 +159,11 @@ export function NodeMetricsCharts() { setData(transformedData) } catch (err: any) { - console.error("[v0] Error fetching node metrics:", err) - console.error("[v0] Error message:", err.message) - console.error("[v0] Error stack:", err.stack) + console.error("Error fetching node metrics:", err) + console.error("Error message:", err.message) + console.error("Error stack:", err.stack) setError(err.message || "Error loading metrics") } finally { - console.log("[v0] fetchMetrics finally block - setting loading to false") setLoading(false) } } @@ -220,10 +208,8 @@ export function NodeMetricsCharts() { ) } - console.log("[v0] Render state - loading:", loading, "error:", error, "data length:", data.length) if (loading) { - console.log("[v0] Rendering loading state") return (
@@ -245,7 +231,6 @@ export function NodeMetricsCharts() { } if (error) { - console.log("[v0] Rendering error state:", error) return (
@@ -269,7 +254,6 @@ export function NodeMetricsCharts() { } if (data.length === 0) { - console.log("[v0] Rendering no data state") return (
@@ -290,7 +274,6 @@ export function NodeMetricsCharts() { ) } - console.log("[v0] Rendering charts with", data.length, "data points") return (
diff --git a/AppImage/components/notification-settings.tsx b/AppImage/components/notification-settings.tsx index c750b4bc..02816833 100644 --- a/AppImage/components/notification-settings.tsx +++ b/AppImage/components/notification-settings.tsx @@ -16,7 +16,8 @@ import { AlertTriangle, Info, Settings2, Zap, Eye, EyeOff, Trash2, ChevronDown, ChevronUp, ChevronRight, TestTube2, Mail, Webhook, Copy, Server, Shield, ExternalLink, RefreshCw, Download, Upload, - Cloud, Brain, Globe, MessageSquareText, Sparkles, Pencil, Save, RotateCcw, Lightbulb + Cloud, Brain, Globe, MessageSquareText, Sparkles, Pencil, Save, RotateCcw, Lightbulb, + Moon, Newspaper } from "lucide-react" interface ChannelConfig { @@ -37,6 +38,13 @@ interface ChannelConfig { from_address?: string to_addresses?: string subject_prefix?: string + // Quiet hours: skip below-CRITICAL events between [start, end) local time + quiet_enabled?: boolean + quiet_start?: string // "HH:MM" + quiet_end?: string // "HH:MM" + // Daily digest: buffer INFO events and ship one summary at digest_time + digest_enabled?: boolean + digest_time?: string // "HH:MM" } interface EventTypeInfo { @@ -97,6 +105,44 @@ interface HistoryEntry { error_message: string | null } +// Validation helpers for webhook/URL fields. The server still does the +// authoritative validation (see notification_manager.validate_config). These +// are defense-in-depth + immediate UX feedback so users notice typos / pasted +// internal endpoints before they hit Save. +const DISCORD_WEBHOOK_RE = /^https:\/\/(discord(app)?\.com|ptb\.discord\.com|canary\.discord\.com)\/api\/webhooks\/\d+\/[\w-]+$/ + +function validateDiscordWebhook(url: string): { error?: string } { + if (!url) return {} + if (!DISCORD_WEBHOOK_RE.test(url.trim())) { + return { error: "Must be a Discord webhook URL (https://discord.com/api/webhooks//)" } + } + return {} +} + +function validateGotifyUrl(url: string): { error?: string; warning?: string } { + if (!url) return {} + let parsed: URL + try { + parsed = new URL(url.trim()) + } catch { + return { error: "Not a valid URL" } + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return { error: `Unsupported scheme "${parsed.protocol}" — only http(s) is allowed` } + } + // Block the obvious SSRF target: the local PVE API. RFC1918 ranges remain + // allowed since self-hosted Gotify on a LAN is a normal deployment. + const host = parsed.hostname.toLowerCase() + const port = parsed.port + if ((host === "localhost" || host === "127.0.0.1" || host === "::1") && (port === "8006" || port === "8007")) { + return { error: "Cannot point at the local PVE API (localhost:8006/8007)" } + } + if (host === "169.254.169.254") { + return { error: "Link-local metadata IP is not a valid Gotify endpoint" } + } + return {} +} + const EVENT_CATEGORIES = [ { key: "vm_ct", label: "VM / CT", desc: "Start, stop, crash, migration" }, { key: "backup", label: "Backups", desc: "Backup start, complete, fail" }, @@ -111,7 +157,7 @@ const EVENT_CATEGORIES = [ { key: "other", label: "Other", desc: "Uncategorized notifications" }, ] -const CHANNEL_TYPES = ["telegram", "gotify", "discord", "email"] as const +const CHANNEL_TYPES = ["telegram", "gotify", "discord", "email", "apprise"] as const const AI_PROVIDERS = [ { @@ -216,6 +262,7 @@ const DEFAULT_CONFIG: NotificationConfig = { gotify: { enabled: false }, discord: { enabled: false }, email: { enabled: false }, + apprise: { enabled: false }, }, event_categories: { vm_ct: true, backup: true, resources: true, storage: true, @@ -229,6 +276,7 @@ const DEFAULT_CONFIG: NotificationConfig = { gotify: { categories: {}, events: {} }, discord: { categories: {}, events: {} }, email: { categories: {}, events: {} }, + apprise: { categories: {}, events: {} }, }, ai_enabled: false, ai_provider: "groq", @@ -259,6 +307,7 @@ const DEFAULT_CONFIG: NotificationConfig = { gotify: "brief", discord: "brief", email: "detailed", + apprise: "brief", }, hostname: "", webhook_secret: "", @@ -276,6 +325,11 @@ export function NotificationSettings() { const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [saved, setSaved] = useState(false) + // Save errors used to be silently swallowed — the user thought their + // tokens / API keys were persisted when in fact the POST had failed. + // Surface the failure as a banner so the user can retry. Audit residual + // #notification-settings-handleSave-silent-fail. + const [saveError, setSaveError] = useState(null) const [testing, setTesting] = useState(null) const [testResult, setTestResult] = useState<{ channel: string; success: boolean; message: string } | null>(null) const [showHistory, setShowHistory] = useState(false) @@ -300,6 +354,12 @@ export function NotificationSettings() { error: string }>({ status: "idle", fallback_commands: [], error: "" }) const [systemHostname, setSystemHostname] = useState("") + // Mirrors the dedicated toggle from Settings → LXC Update Detection. + // When false, the per-event toggle for `lxc_updates_available` is hidden + // from every channel's category list (its DB preference is preserved). + // Updated on mount via fetch and on the fly via a CustomEvent dispatched + // by when the user flips the switch. + const [lxcDetectionEnabled, setLxcDetectionEnabled] = useState(true) // Load system hostname for display name placeholder const loadSystemHostname = useCallback(async () => { @@ -382,6 +442,43 @@ export function NotificationSettings() { loadSystemHostname() }, [loadConfig, loadStatus, loadSystemHostname]) + // Track the LXC update-detection toggle so we can conditionally hide + // the `lxc_updates_available` per-event toggle inside every channel's + // category list. Fetched once on mount; live updates ride on a custom + // event dispatched by whenever the user flips + // the switch upstream. + useEffect(() => { + let cancelled = false + fetchApi<{ success: boolean; enabled?: boolean }>("/api/lxc-updates/detection") + .then(data => { + if (cancelled) return + if (data.success && typeof data.enabled === "boolean") { + setLxcDetectionEnabled(data.enabled) + } + }) + .catch(() => { + // Default-true on fetch failure — matches the backend default and + // avoids hiding a notification toggle the user might rely on if + // the settings endpoint is transiently unreachable. + }) + + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail + if (detail && typeof detail.enabled === "boolean") { + setLxcDetectionEnabled(detail.enabled) + } + } + if (typeof window !== "undefined") { + window.addEventListener("proxmenux:lxc-detection-changed", handler) + } + return () => { + cancelled = true + if (typeof window !== "undefined") { + window.removeEventListener("proxmenux:lxc-detection-changed", handler) + } + } + }, []) + useEffect(() => { if (showHistory) loadHistory() }, [showHistory, loadHistory]) @@ -411,6 +508,163 @@ export function NotificationSettings() { })) } + const formatHHMM = (raw: string | undefined, fallback: string): string => { + const v = (raw || fallback).match(/^(\d{1,2}):(\d{2})$/) + if (!v) return fallback + const hh = String(Math.min(23, Math.max(0, parseInt(v[1], 10)))).padStart(2, "0") + const mm = String(Math.min(59, Math.max(0, parseInt(v[2], 10)))).padStart(2, "0") + return `${hh}:${mm}` + } + + const inQuietWindow = (start: string, end: string): boolean => { + if (start === end) return false + const now = new Date() + const cur = now.getHours() * 60 + now.getMinutes() + const [sh, sm] = start.split(":").map((x) => parseInt(x, 10)) + const [eh, em] = end.split(":").map((x) => parseInt(x, 10)) + const s = sh * 60 + sm + const e = eh * 60 + em + return s < e ? cur >= s && cur < e : cur >= s || cur < e + } + + const renderQuietHours = (chName: string) => { + const ch = config.channels[chName as keyof typeof config.channels] as ChannelConfig | undefined + const enabled = !!ch?.quiet_enabled + const start = formatHHMM(ch?.quiet_start, "22:00") + const end = formatHHMM(ch?.quiet_end, "06:00") + const sameTime = start === end + const live = enabled && !sameTime && inQuietWindow(start, end) + return ( +
+
+
+ +

+ During this window only CRITICAL events reach this channel. +

+
+ +
+ {enabled && ( + <> + {/* Inline label + intrinsic-width inputs. The previous + `grid-cols-2 + full-width inputs` rendered weirdly on + iOS Safari (the native time picker centered "22:00" + inside a 200-px box with huge empty margins). flex + + w-24/w-28 keeps the input tight to the HH:MM text on + every viewport and the touch target stays comfortable. */} +
+
+ + updateChannel(chName, "quiet_start", e.target.value)} + disabled={!editMode} + className="h-9 w-28 text-sm font-mono" + /> +
+
+ + updateChannel(chName, "quiet_end", e.target.value)} + disabled={!editMode} + className="h-9 w-28 text-sm font-mono" + /> +
+
+

+ {sameTime + ? "Set a different start and end time to activate." + : live + ? `Active right now — only CRITICAL events pass until ${end}.` + : `Inactive right now — will start at ${start}.`} +

+ + )} +
+ ) + } + + const renderDailyDigest = (chName: string) => { + const ch = config.channels[chName as keyof typeof config.channels] as ChannelConfig | undefined + const enabled = !!ch?.digest_enabled + const time = formatHHMM(ch?.digest_time, "09:00") + let nextLabel = "" + if (enabled) { + const now = new Date() + const cur = now.getHours() * 60 + now.getMinutes() + const [hh, mm] = time.split(":").map((x) => parseInt(x, 10)) + const target = hh * 60 + mm + const minsAway = target > cur ? target - cur : 24 * 60 - cur + target + const h = Math.floor(minsAway / 60) + const m = minsAway % 60 + nextLabel = `Next digest in ${h}h ${m}m (at ${time}).` + } + return ( +
+
+
+ +

+ All INFO events (backups OK, updates available, etc.) accumulate during the day and arrive once at this time as a single summary. CRITICAL and WARNING are never delayed. +

+
+ +
+ {enabled && ( + <> +
+ + updateChannel(chName, "digest_time", e.target.value)} + disabled={!editMode} + className="h-9 w-28 text-sm font-mono" + /> +
+

{nextLabel}

+ + )} +
+ ) + } + /** Reusable 10+1 category block rendered inside each channel tab. */ const renderChannelCategories = (chName: string) => { const overrides = config.channel_overrides?.[chName] || { categories: {}, events: {} } @@ -426,7 +680,16 @@ export function NotificationSettings() { {EVENT_CATEGORIES.filter(cat => cat.key !== "other").map(cat => { const isEnabled = overrides.categories[cat.key] ?? true const isExpanded = expandedCategories.has(`${chName}.${cat.key}`) - const eventsForGroup = evtByGroup[cat.key] || [] + // Hide the LXC update toggle when the user has disabled the + // dedicated detection setting upstream. The backend still + // returns the event type in the catalog (so its stored + // preference survives), but we filter it out of every + // channel's UI list so the operator never sees a notification + // toggle whose underlying scan is paused. + const rawEventsForGroup = evtByGroup[cat.key] || [] + const eventsForGroup = lxcDetectionEnabled + ? rawEventsForGroup + : rawEventsForGroup.filter(e => e.type !== "lxc_updates_available") const enabledCount = eventsForGroup.filter( e => (overrides.events?.[e.type] ?? e.default_enabled) ).length @@ -621,11 +884,12 @@ export function NotificationSettings() { const handleSave = async () => { setSaving(true) + setSaveError(null) try { // If notifications are being disabled, clean up PVE webhook first const wasEnabled = originalConfig.enabled const isNowDisabled = !config.enabled - + if (wasEnabled && isNowDisabled) { try { await fetchApi("/api/notifications/proxmox/cleanup-webhook", { method: "POST" }) @@ -633,7 +897,7 @@ export function NotificationSettings() { // Non-fatal: webhook cleanup failed but we still save settings } } - + const payload = flattenConfig(config) await fetchApi("/api/notifications/settings", { method: "POST", @@ -647,6 +911,8 @@ export function NotificationSettings() { loadStatus() } catch (err) { console.error("Failed to save notification settings:", err) + const msg = err instanceof Error ? err.message : "Failed to save notification settings" + setSaveError(msg) } finally { setSaving(false) } @@ -977,6 +1243,14 @@ export function NotificationSettings() { Saved )} + {saveError && ( + + Save failed: {saveError} + + )} {editMode ? ( <>
{renderChannelCategories("telegram")} + {renderQuietHours("telegram")} + {renderDailyDigest("telegram")} {/* Send Test */}
@@ -1266,6 +1551,8 @@ export function NotificationSettings() {
{renderChannelCategories("gotify")} + {renderQuietHours("gotify")} + {renderDailyDigest("gotify")} {/* Send Test */}
+ {(() => { + const v = validateDiscordWebhook(config.channels.discord?.webhook_url || "") + return v.error ?

{v.error}

: null + })()}
{/* Message format */}
@@ -1342,6 +1633,8 @@ export function NotificationSettings() {
{renderChannelCategories("discord")} + {renderQuietHours("discord")} + {renderDailyDigest("discord")} {/* Send Test */}
{renderChannelCategories("email")} + {renderQuietHours("email")} + {renderDailyDigest("email")} {/* Send Test */}
+
+ {config.channels.apprise?.enabled && ( + <> +
+ +
+ updateChannel("apprise", "url", e.target.value)} + disabled={!editMode} + /> + +
+ {/* The examples row was overflowing on mobile because + every `` token is atomic — the whole line + would scroll horizontally on narrow viewports. + `break-all` on the wrapper lets the layout break + mid-token if the viewport is really tight; on + wider screens the natural commas/spaces still + control wrapping. */} +

+ A single URL that Apprise routes to the right service. Examples: + tgram://, + discord://, + slack://, + ntfy://, + matrix://, + pushover://, + mailto://… See the + {" "} + + full list + . +

+
+ {renderChannelCategories("apprise")} + {renderQuietHours("apprise")} + {renderDailyDigest("apprise")} +
+ +
+ + )} + {/* Test Result */} @@ -1542,14 +1937,23 @@ export function NotificationSettings() {
diff --git a/AppImage/components/profile.tsx b/AppImage/components/profile.tsx new file mode 100644 index 00000000..1d637f4f --- /dev/null +++ b/AppImage/components/profile.tsx @@ -0,0 +1,467 @@ +"use client" + +import { useEffect, useRef, useState } from "react" +import { + User as UserIcon, + Upload, + Trash2, + Loader2, + Check, + AlertCircle, + Shield, + Lock, + X, + Settings2, + CheckCircle2, +} from "lucide-react" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card" +import { Button } from "./ui/button" +import { Input } from "./ui/input" +import { Label } from "./ui/label" +import { fetchApi, getApiUrl, getAuthToken } from "../lib/api-config" + +interface ProfileData { + success: boolean + username?: string | null + display_name?: string | null + has_avatar?: boolean + avatar_mtime?: number | null + avatar_content_type?: string | null + message?: string +} + +interface ProfileProps { + /** Optional navigation hook so the page can link to Security for + * password / 2FA changes without redirecting through a URL. */ + onOpenSecurity?: () => void +} + +/** + * Profile page (Fase 2, v1.2.2). + * + * Lets the operator edit their **display name** and upload / remove + * their **avatar**. Username is read-only (changing it requires + * disabling and reconfiguring auth from Security). Password / 2FA + * are intentionally not editable from this page — those live in + * Security to keep the "account security" surface in one place. + * + * Layout: centered, two cards (Profile + Account security shortcut). + * Display name uses the same Edit / Save / Cancel pattern as the + * Health Thresholds / Notifications panels — read-only by default, + * the operator hits Edit to start typing. + */ +export function Profile({ onOpenSecurity }: ProfileProps) { + const [profile, setProfile] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + // Display name: read-only by default, editable after pressing Edit. + // Mirrors the editMode pattern used in HealthThresholds / Notifications + // so the operator never types into a field that isn't ready to be saved. + const [displayEditMode, setDisplayEditMode] = useState(false) + const [displayDraft, setDisplayDraft] = useState("") + const [savingDisplay, setSavingDisplay] = useState(false) + const [savedDisplay, setSavedDisplay] = useState(false) + + // Avatar state. + const [uploadingAvatar, setUploadingAvatar] = useState(false) + const [avatarError, setAvatarError] = useState(null) + const [avatarBlobUrl, setAvatarBlobUrl] = useState(null) + const fileInputRef = useRef(null) + + const loadProfile = async () => { + try { + const data = await fetchApi("/api/auth/profile") + setProfile(data) + setDisplayDraft(data.display_name || "") + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setLoading(false) + } + } + + useEffect(() => { + loadProfile() + }, []) + + // Avatar fetch. Same blob-URL pattern as in AvatarMenu — the endpoint + // requires the Bearer header, which can't send. Plain + // `` would render a broken image icon (the bug the user reported). + useEffect(() => { + let cancelled = false + let currentBlobUrl: string | null = null + if (profile?.has_avatar) { + const token = getAuthToken() + const url = `${getApiUrl("/api/auth/profile/avatar")}?v=${profile.avatar_mtime || ""}` + fetch(url, { headers: token ? { Authorization: `Bearer ${token}` } : {} }) + .then(r => (r.ok ? r.blob() : null)) + .then(blob => { + if (cancelled || !blob) return + currentBlobUrl = URL.createObjectURL(blob) + setAvatarBlobUrl(currentBlobUrl) + }) + .catch(() => { + if (!cancelled) setAvatarBlobUrl(null) + }) + } else { + setAvatarBlobUrl(null) + } + return () => { + cancelled = true + if (currentBlobUrl) URL.revokeObjectURL(currentBlobUrl) + } + }, [profile?.has_avatar, profile?.avatar_mtime]) + + const initial = (profile?.display_name || profile?.username || "U") + .trim() + .charAt(0) + .toUpperCase() + + const hasDisplayChanges = displayDraft !== (profile?.display_name || "") + + const handleEditDisplay = () => { + setDisplayEditMode(true) + setSavedDisplay(false) + setError(null) + } + + const handleCancelDisplay = () => { + setDisplayDraft(profile?.display_name || "") + setDisplayEditMode(false) + setError(null) + } + + const handleSaveDisplayName = async () => { + if (!hasDisplayChanges) { + setDisplayEditMode(false) + return + } + setSavingDisplay(true) + setError(null) + setSavedDisplay(false) + try { + const data = await fetchApi("/api/auth/profile", { + method: "PUT", + body: JSON.stringify({ display_name: displayDraft }), + }) + if (!data.success) { + setError(data.message || "Failed to save display name") + return + } + setProfile(data) + setDisplayEditMode(false) + setSavedDisplay(true) + setTimeout(() => setSavedDisplay(false), 2500) + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent("proxmenux:profile-changed")) + } + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setSavingDisplay(false) + } + } + + const handleAvatarPick = () => fileInputRef.current?.click() + + const handleAvatarFile = async (file: File) => { + setUploadingAvatar(true) + setAvatarError(null) + try { + const token = getAuthToken() + const headers: Record = {} + if (token) headers["Authorization"] = `Bearer ${token}` + // Raw upload (Content-Type = the image's own MIME) — simpler than + // multipart and the backend handles both. + headers["Content-Type"] = file.type + const r = await fetch(getApiUrl("/api/auth/profile/avatar"), { + method: "POST", + headers, + body: file, + }) + const data: ProfileData = await r.json().catch(() => ({ success: false })) + if (!r.ok || !data.success) { + setAvatarError(data.message || `Upload failed (${r.status})`) + return + } + setProfile(data) + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent("proxmenux:profile-changed")) + } + } catch (e) { + setAvatarError(e instanceof Error ? e.message : String(e)) + } finally { + setUploadingAvatar(false) + // Reset the input so picking the same file twice in a row still + // fires the change event. + if (fileInputRef.current) fileInputRef.current.value = "" + } + } + + const handleAvatarDelete = async () => { + setUploadingAvatar(true) + setAvatarError(null) + try { + const token = getAuthToken() + const headers: Record = {} + if (token) headers["Authorization"] = `Bearer ${token}` + const r = await fetch(getApiUrl("/api/auth/profile/avatar"), { + method: "DELETE", + headers, + }) + const data: ProfileData = await r.json().catch(() => ({ success: false })) + if (!r.ok || !data.success) { + setAvatarError(data.message || `Delete failed (${r.status})`) + return + } + setProfile(data) + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent("proxmenux:profile-changed")) + } + } catch (e) { + setAvatarError(e instanceof Error ? e.message : String(e)) + } finally { + setUploadingAvatar(false) + } + } + + if (loading) { + return ( +
+ + + + Loading profile… + + +
+ ) + } + + if (error && !profile) { + return ( +
+ + +
+ +
+
Failed to load profile
+
{error}
+
+
+
+
+
+ ) + } + + return ( +
+ + + {/* Edit / Save / Cancel sit in the card header — same pattern + as Health Thresholds and Notifications. Avatar actions + (upload / remove) stay independent of editMode because + they're explicit one-shot actions, not field edits. */} +
+
+ + User Profile +
+
+ {savedDisplay && ( + + + Saved + + )} + {displayEditMode ? ( + <> + + + + ) : ( + + )} +
+
+ + Personal details rendered in the header avatar menu. None of this is required — + the username already covers identity. Display name and avatar are decorative. + +
+ + + {/* ─── Avatar section ────────────────────────────────────── + Big preview (160×160) so the operator can see the actual + image they uploaded. `object-cover` keeps the aspect + ratio and crops to fit the circle. */} +
+ +
+
+ {avatarBlobUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( + + {initial} + + )} + {uploadingAvatar && ( +
+ +
+ )} +
+
+ { + const file = e.target.files?.[0] + if (file) handleAvatarFile(file) + }} + /> + + {profile?.has_avatar && ( + + )} +

+ PNG, JPEG, WebP or GIF. Up to 2 MB. The image isn't resized — + render it square or pre-crop for best results in the header. +

+
+
+ {avatarError && ( +
+ + {avatarError} +
+ )} +
+ + {/* ─── Username (read-only) ─── */} +
+ + +

+ The login name. To change it, disable authentication and reconfigure from + Security. +

+
+ + {/* ─── Display name (Edit controls live in the card header) ─── */} +
+ + setDisplayDraft(e.target.value)} + placeholder={profile?.username || "Display name"} + maxLength={64} + disabled={!displayEditMode || savingDisplay} + className="mt-2 max-w-sm disabled:opacity-100 disabled:cursor-default" + /> +

+ Shown above the username inside the avatar menu. Leave empty to show the + username itself. Up to 64 characters. +

+ {error && displayEditMode && ( +
+ + {error} +
+ )} +
+
+
+ + {/* ─── Account security shortcut ─── */} + + +
+ + Account security +
+ + Password, two-factor authentication and API tokens live in the Security panel. + +
+ + {onOpenSecurity ? ( + + ) : ( +

+ Open the Security tab from the navigation. +

+ )} +
+
+
+ ) +} diff --git a/AppImage/components/proxmox-dashboard.tsx b/AppImage/components/proxmox-dashboard.tsx index d309a5dc..a864ea98 100644 --- a/AppImage/components/proxmox-dashboard.tsx +++ b/AppImage/components/proxmox-dashboard.tsx @@ -12,11 +12,14 @@ import Hardware from "./hardware" import { SystemLogs } from "./system-logs" import { Settings } from "./settings" import { Security } from "./security" +import { Profile } from "./profile" +import { About } from "./about" import { OnboardingCarousel } from "./onboarding-carousel" import { HealthStatusModal } from "./health-status-modal" import { ReleaseNotesModal, useVersionCheck } from "./release-notes-modal" import { getApiUrl, fetchApi } from "../lib/api-config" import { TerminalPanel } from "./terminal-panel" +import { AvatarMenu } from "./avatar-menu" import { RefreshCw, AlertTriangle, @@ -367,6 +370,8 @@ export function ProxmoxDashboard() { return "Security" case "settings": return "Settings" + case "profile": + return "Profile" default: return "Navigation Menu" } @@ -479,44 +484,74 @@ export function ProxmoxDashboard() {
e.stopPropagation()}>
+ + {/* User account dropdown — Fase 1 (v1.2.2). Self-hides + when auth isn't enabled on this install. */} +
e.stopPropagation()}> + setActiveTab("profile")} + onOpenSecurity={() => setActiveTab("security")} + /> +
- {/* Mobile Actions */} -
-
- - {statusIcon} - - {systemStatus.status === "healthy" && infoCount > 0 && ( - - - {infoCount} - - )} -
- + {/* Mobile Actions — variant D approved in demo: + • Top-right: Refresh + Theme + Avatar (all with border) + • Bottom row (under Node line): badges left-aligned with + the Node text column, Uptime right-aligned in the same + horizontal line. No extra row for Uptime so the + header doesn't grow vertically. */} +
-
e.stopPropagation()} className="-mt-1"> +
e.stopPropagation()}>
+ +
e.stopPropagation()}> + setActiveTab("profile")} + onOpenSecurity={() => setActiveTab("security")} + /> +
- {/* Mobile Server Info */} -
- Uptime: {systemStatus.uptime || "N/A"} + {/* Mobile bottom row — badges (left, aligned with the title + column via pl-[3.25rem] = w-16 logo + space-x-2 gap-ish) + and Uptime (right). The pl matches the mobile logo width + + the parent flex gap so the badges sit visually under + "Node: amd", not flush against the screen edge. */} +
+
+ + {statusIcon} + {systemStatus.status} + + {systemStatus.status === "healthy" && infoCount > 0 && ( + + + {infoCount} + + )} +
+ + Uptime: {systemStatus.uptime || "N/A"} +
@@ -530,7 +565,10 @@ export function ProxmoxDashboard() { >
- + {/* Issue #191: 10 tabs after adding About. The grid wraps via + Tabs primitives so the extra column doesn't push the + triggers off-screen on common laptop widths. */} + Settings + + About + @@ -738,6 +782,21 @@ export function ProxmoxDashboard() { Settings +
@@ -779,13 +838,27 @@ export function ProxmoxDashboard() { + {/* Profile tab — not surfaced in the top tabs nav. The only + entry point is the avatar dropdown in the header (View + profile). v1.2.2 Fase 2. */} + + setActiveTab("security")} + /> + + + + + +
@@ -844,12 +885,19 @@ const initMessage = { > {currentInteraction.title}
-

").replace(/\n/g, "
"), - }} - /> + {/* + Render the interaction message as plain text. The message + comes through the WebSocket from a script running as root — + a script bug or compromised author could embed `