mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-05-30 12:04:43 +00:00
Update AppImage 1.2.1.3
This commit is contained in:
@@ -1,11 +1,11 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect, useRef } from "react"
|
||||||
import { Button } from "./ui/button"
|
import { Button } from "./ui/button"
|
||||||
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog"
|
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog"
|
||||||
import { Input } from "./ui/input"
|
import { Input } from "./ui/input"
|
||||||
import { Label } from "./ui/label"
|
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"
|
import { getApiUrl } from "../lib/api-config"
|
||||||
|
|
||||||
interface AuthSetupProps {
|
interface AuthSetupProps {
|
||||||
@@ -22,6 +22,14 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
|||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
const [showConfirmPassword, setShowConfirmPassword] = 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<File | null>(null)
|
||||||
|
const [avatarPreviewUrl, setAvatarPreviewUrl] = useState<string | null>(null)
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkOnboardingStatus = async () => {
|
const checkOnboardingStatus = async () => {
|
||||||
@@ -84,6 +92,18 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 () => {
|
const handleSetupAuth = async () => {
|
||||||
setError("")
|
setError("")
|
||||||
|
|
||||||
@@ -125,6 +145,61 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
|||||||
localStorage.removeItem("proxmenux-auth-declined")
|
localStorage.removeItem("proxmenux-auth-declined")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
setOpen(false)
|
||||||
onComplete()
|
onComplete()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -261,6 +336,100 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 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. */}
|
||||||
|
<div className="pt-3 border-t border-border/60 space-y-4">
|
||||||
|
<p className="text-xs text-muted-foreground uppercase tracking-wider">
|
||||||
|
Profile · optional
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="display-name" className="text-sm">
|
||||||
|
Display name
|
||||||
|
</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"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
maxLength={64}
|
||||||
|
className="pl-10 text-base"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
Leave empty to render the username itself. Up to 64 characters.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm">Avatar</Label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{avatarPreviewUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={avatarPreviewUrl}
|
||||||
|
alt=""
|
||||||
|
className="w-14 h-14 rounded-full object-cover border border-border bg-cyan-500/5 shrink-0"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="w-14 h-14 rounded-full bg-cyan-500/15 text-cyan-600 dark:text-cyan-300 flex items-center justify-center text-xl font-semibold border border-border shrink-0">
|
||||||
|
{(displayName || username || "U").trim().charAt(0).toUpperCase() || "U"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-1.5 min-w-0">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0] || null
|
||||||
|
handleAvatarChange(file)
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = ""
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleAvatarPick}
|
||||||
|
disabled={loading}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
<Upload className="h-3 w-3 mr-1.5" />
|
||||||
|
{avatarFile ? "Change" : "Choose image"}
|
||||||
|
</Button>
|
||||||
|
{avatarFile && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleAvatarChange(null)}
|
||||||
|
disabled={loading}
|
||||||
|
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
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
PNG, JPEG, WebP or GIF · up to 2 MB · pre-crop square for best results.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -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<AuthStatus | null>(null)
|
||||||
|
const [profile, setProfile] = useState<ProfileData | null>(null)
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [avatarBlobUrl, setAvatarBlobUrl] = useState<string | null>(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<AuthStatus>("/api/auth/status")
|
||||||
|
.then(data => {
|
||||||
|
if (cancelled) return
|
||||||
|
setStatus(data)
|
||||||
|
if (data?.auth_enabled && data?.username) {
|
||||||
|
fetchApi<ProfileData>("/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<AuthStatus>("/api/auth/status")
|
||||||
|
.then(s => {
|
||||||
|
if (cancelled) return
|
||||||
|
setStatus(s)
|
||||||
|
if (s?.auth_enabled && s?.username) {
|
||||||
|
fetchApi<ProfileData>("/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
|
||||||
|
// <img src=…> 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 && (
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="fixed inset-0 z-40 bg-black/40 animate-in fade-in-0 duration-150"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||||
|
<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"
|
||||||
|
// 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
|
||||||
|
// dismissing the dropdown without picking anything. Setting
|
||||||
|
// `-webkit-tap-highlight-color` to transparent suppresses
|
||||||
|
// it without affecting keyboard focus visibility (handled
|
||||||
|
// separately by `focus-visible:outline-none` above).
|
||||||
|
style={{ WebkitTapHighlightColor: "transparent" }}
|
||||||
|
>
|
||||||
|
{avatarBlobUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={avatarBlobUrl}
|
||||||
|
alt=""
|
||||||
|
className={`${avatarSize} rounded-full object-cover bg-cyan-500/10`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className={`${avatarSize} rounded-full flex items-center justify-center font-semibold bg-cyan-500/15 text-cyan-600 dark:text-cyan-300`}
|
||||||
|
>
|
||||||
|
{initial}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-72 z-[60]">
|
||||||
|
<DropdownMenuLabel>
|
||||||
|
<div className="flex items-center gap-3 py-1">
|
||||||
|
{avatarBlobUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={avatarBlobUrl}
|
||||||
|
alt=""
|
||||||
|
className="w-20 h-20 rounded-full object-cover bg-cyan-500/10 shrink-0"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="w-20 h-20 rounded-full bg-cyan-500/15 text-cyan-600 dark:text-cyan-300 flex items-center justify-center text-3xl font-semibold shrink-0">
|
||||||
|
{initial}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-base font-semibold truncate">{displayName}</div>
|
||||||
|
{profile?.display_name && (
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{onOpenProfile && (
|
||||||
|
<DropdownMenuItem onClick={onOpenProfile}>
|
||||||
|
<User className="h-4 w-4 mr-2" />
|
||||||
|
View profile
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{onOpenSecurity && (
|
||||||
|
<DropdownMenuItem onClick={onOpenSecurity}>
|
||||||
|
<Shield className="h-4 w-4 mr-2" />
|
||||||
|
Security
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{(onOpenProfile || onOpenSecurity) && <DropdownMenuSeparator />}
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={handleSignOut}
|
||||||
|
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
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -398,23 +398,36 @@ export function HealthThresholds() {
|
|||||||
if (!leaf) return null
|
if (!leaf) return null
|
||||||
const key = pathKey(path)
|
const key = pathKey(path)
|
||||||
const editingValue = pending[key] ?? String(leaf.value)
|
const editingValue = pending[key] ?? String(leaf.value)
|
||||||
// The input border carries the severity colour so the editable field
|
// Visual rules (rebuilt — the original used /40 opacity borders +
|
||||||
// itself shows what kind of threshold this is — no separate badge
|
// a blue ring stacked on top of the colour border, both of which
|
||||||
// duplicating the number, which users mistook for the "real" value.
|
// were nearly invisible in read-only mode and stacked weirdly when
|
||||||
// `swap_critical` and any other `*_critical` leaf falls into the red
|
// a value was customised):
|
||||||
// bucket via the substring check. A blue ring on top of the colour
|
//
|
||||||
// border signals "customised vs recommended" — two independent
|
// • Read-only mode (editMode=false): keep severity colour on the
|
||||||
// signals on the same widget.
|
// border at a higher opacity (/70 instead of /40) and on the
|
||||||
|
// background (/10) so the field is clearly readable, and
|
||||||
|
// restore foreground colour (no `opacity-70` washout). This is
|
||||||
|
// the default state the user sees most of the time — it must
|
||||||
|
// match the visual weight of the rest of the Settings page.
|
||||||
|
// • Edit mode + value matches the recommended default: severity
|
||||||
|
// border + soft severity bg, same as read-only.
|
||||||
|
// • Edit mode + value customised: ONE border in blue, replacing
|
||||||
|
// (not stacking on top of) the severity border. This is the
|
||||||
|
// single signal that "this value differs from recommended".
|
||||||
|
//
|
||||||
|
// `swap_critical` and any other `*_critical` leaf falls into the
|
||||||
|
// red bucket via the substring check.
|
||||||
const last = path[path.length - 1] || ""
|
const last = path[path.length - 1] || ""
|
||||||
const isCritical = last.toLowerCase().includes("critical")
|
const isCritical = last.toLowerCase().includes("critical")
|
||||||
const isWarning = last.toLowerCase().includes("warning")
|
const isWarning = last.toLowerCase().includes("warning")
|
||||||
const severityBorder = isCritical
|
const severityClass = isCritical
|
||||||
? "border-red-500/40 bg-red-500/5 focus-visible:border-red-500"
|
? "border-red-500/70 bg-red-500/10 focus-visible:border-red-500"
|
||||||
: isWarning
|
: isWarning
|
||||||
? "border-amber-500/40 bg-amber-500/5 focus-visible:border-amber-500"
|
? "border-amber-500/70 bg-amber-500/10 focus-visible:border-amber-500"
|
||||||
: ""
|
: "border-input"
|
||||||
const isCustomised = leaf.customised && !(key in pending)
|
const isCustomised = leaf.customised && !(key in pending)
|
||||||
const customisedRing = isCustomised ? "ring-2 ring-blue-500/40" : ""
|
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 = `Recommended: ${leaf.recommended}${leaf.unit}`
|
||||||
return (
|
return (
|
||||||
<div key={key} className="flex items-center justify-between gap-2 py-1.5 px-1">
|
<div key={key} className="flex items-center justify-between gap-2 py-1.5 px-1">
|
||||||
@@ -433,9 +446,9 @@ export function HealthThresholds() {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setPending((p) => ({ ...p, [key]: e.target.value }))
|
setPending((p) => ({ ...p, [key]: e.target.value }))
|
||||||
}
|
}
|
||||||
className={`w-20 h-7 text-xs text-right tabular-nums ${
|
className={`w-20 h-7 text-xs text-right tabular-nums border ${fieldClass} ${
|
||||||
!editMode ? "opacity-70" : ""
|
!editMode ? "disabled:opacity-100 disabled:cursor-default" : ""
|
||||||
} ${severityBorder} ${customisedRing}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
<span className="text-[11px] text-muted-foreground w-6">{leaf.unit}</span>
|
<span className="text-[11px] text-muted-foreground w-6">{leaf.unit}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -104,10 +104,14 @@ export function LxcUpdateDetection() {
|
|||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex items-center gap-2">
|
{/* Title row — flex-wrap so on narrow screens the badge can drop
|
||||||
<Boxes className="h-5 w-5 text-purple-500" />
|
under the title without dragging the icon along with it. The
|
||||||
<CardTitle>LXC Update Detection</CardTitle>
|
icon stays on the same baseline as the title text on every
|
||||||
|
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>
|
||||||
{enabled ? (
|
{enabled ? (
|
||||||
<Badge variant="outline" className="text-[10px] border-green-500/30 text-green-500">
|
<Badge variant="outline" className="text-[10px] border-green-500/30 text-green-500">
|
||||||
Active
|
Active
|
||||||
@@ -118,7 +122,7 @@ export function LxcUpdateDetection() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
{saved && (
|
{saved && (
|
||||||
<span className="flex items-center gap-1 text-xs text-green-500">
|
<span className="flex items-center gap-1 text-xs text-green-500">
|
||||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||||
@@ -172,23 +176,19 @@ export function LxcUpdateDetection() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="space-y-5">
|
<CardContent className="space-y-5">
|
||||||
{/* ── Enable/Disable ── */}
|
{/* ── Enable/Disable ── single-line label + toggle. The description
|
||||||
<div className="flex items-center justify-between py-2 px-1">
|
paragraph was removed because the CardDescription above already
|
||||||
<div className="flex items-center gap-2">
|
covers the behaviour; on mobile that second paragraph forced
|
||||||
|
the icon to top-align and made the toggle wrap awkwardly. */}
|
||||||
|
<div className="flex items-center justify-between gap-3 py-2 px-1">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<Boxes
|
<Boxes
|
||||||
className={`h-4 w-4 ${pending ? "text-purple-500" : "text-muted-foreground"}`}
|
className={`h-4 w-4 shrink-0 ${pending ? "text-purple-500" : "text-muted-foreground"}`}
|
||||||
/>
|
/>
|
||||||
<div>
|
<span className="text-sm font-medium truncate">Enable LXC update detection</span>
|
||||||
<span className="text-sm font-medium">Enable LXC update detection</span>
|
|
||||||
<p className="text-[11px] text-muted-foreground">
|
|
||||||
When OFF, ProxMenux stops scanning your CTs (no <code>pct exec</code> calls), removes existing LXC
|
|
||||||
entries from the managed-installs registry, and hides the related notification toggle. Default is
|
|
||||||
ON.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className={`relative w-10 h-5 rounded-full transition-colors ${
|
className={`relative w-10 h-5 rounded-full transition-colors shrink-0 ${
|
||||||
pending ? "bg-blue-600" : "bg-muted-foreground/20 border border-muted-foreground/40"
|
pending ? "bg-blue-600" : "bg-muted-foreground/20 border border-muted-foreground/40"
|
||||||
} ${!editMode ? "opacity-60 cursor-not-allowed" : "cursor-pointer"}`}
|
} ${!editMode ? "opacity-60 cursor-not-allowed" : "cursor-pointer"}`}
|
||||||
onClick={() => editMode && setPending(p => !p)}
|
onClick={() => editMode && setPending(p => !p)}
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ const EVENT_CATEGORIES = [
|
|||||||
{ key: "other", label: "Other", desc: "Uncategorized notifications" },
|
{ 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 = [
|
const AI_PROVIDERS = [
|
||||||
{
|
{
|
||||||
@@ -262,6 +262,7 @@ const DEFAULT_CONFIG: NotificationConfig = {
|
|||||||
gotify: { enabled: false },
|
gotify: { enabled: false },
|
||||||
discord: { enabled: false },
|
discord: { enabled: false },
|
||||||
email: { enabled: false },
|
email: { enabled: false },
|
||||||
|
apprise: { enabled: false },
|
||||||
},
|
},
|
||||||
event_categories: {
|
event_categories: {
|
||||||
vm_ct: true, backup: true, resources: true, storage: true,
|
vm_ct: true, backup: true, resources: true, storage: true,
|
||||||
@@ -275,6 +276,7 @@ const DEFAULT_CONFIG: NotificationConfig = {
|
|||||||
gotify: { categories: {}, events: {} },
|
gotify: { categories: {}, events: {} },
|
||||||
discord: { categories: {}, events: {} },
|
discord: { categories: {}, events: {} },
|
||||||
email: { categories: {}, events: {} },
|
email: { categories: {}, events: {} },
|
||||||
|
apprise: { categories: {}, events: {} },
|
||||||
},
|
},
|
||||||
ai_enabled: false,
|
ai_enabled: false,
|
||||||
ai_provider: "groq",
|
ai_provider: "groq",
|
||||||
@@ -305,6 +307,7 @@ const DEFAULT_CONFIG: NotificationConfig = {
|
|||||||
gotify: "brief",
|
gotify: "brief",
|
||||||
discord: "brief",
|
discord: "brief",
|
||||||
email: "detailed",
|
email: "detailed",
|
||||||
|
apprise: "brief",
|
||||||
},
|
},
|
||||||
hostname: "",
|
hostname: "",
|
||||||
webhook_secret: "",
|
webhook_secret: "",
|
||||||
@@ -1346,7 +1349,7 @@ export function NotificationSettings() {
|
|||||||
|
|
||||||
<div className="rounded-lg border border-border/50 bg-muted/20 p-3">
|
<div className="rounded-lg border border-border/50 bg-muted/20 p-3">
|
||||||
<Tabs defaultValue="telegram" className="w-full">
|
<Tabs defaultValue="telegram" className="w-full">
|
||||||
<TabsList className="w-full grid grid-cols-4 h-8">
|
<TabsList className="w-full grid grid-cols-5 h-8">
|
||||||
<TabsTrigger value="telegram" className="text-xs data-[state=active]:text-blue-500">
|
<TabsTrigger value="telegram" className="text-xs data-[state=active]:text-blue-500">
|
||||||
Telegram
|
Telegram
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
@@ -1359,6 +1362,9 @@ export function NotificationSettings() {
|
|||||||
<TabsTrigger value="email" className="text-xs data-[state=active]:text-amber-500">
|
<TabsTrigger value="email" className="text-xs data-[state=active]:text-amber-500">
|
||||||
Email
|
Email
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="apprise" className="text-xs data-[state=active]:text-cyan-500">
|
||||||
|
Apprise
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* Telegram */}
|
{/* Telegram */}
|
||||||
@@ -1788,6 +1794,96 @@ export function NotificationSettings() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Apprise — issue #207. Single URL talks to ~80
|
||||||
|
notification services. The operator pastes one
|
||||||
|
`tgram://`, `discord://`, `ntfy://`, `matrix://`,
|
||||||
|
`pushover://` etc. URL and the AppriseChannel
|
||||||
|
backend handles the transport. Mirrors the same
|
||||||
|
Enable toggle + Test button pattern as the other
|
||||||
|
channels. */}
|
||||||
|
<TabsContent value="apprise" className="space-y-3 pt-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label className="text-xs font-medium">Enable Apprise</Label>
|
||||||
|
<a
|
||||||
|
href="https://github.com/caronc/apprise/wiki"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-[10px] text-cyan-500 hover:text-cyan-400 hover:underline"
|
||||||
|
>
|
||||||
|
+URL formats
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className={`relative w-9 h-[18px] rounded-full transition-colors ${
|
||||||
|
config.channels.apprise?.enabled ? "bg-blue-600" : "bg-muted-foreground/20 border border-muted-foreground/40"
|
||||||
|
} ${!editMode ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}`}
|
||||||
|
onClick={() => { if (editMode) updateChannel("apprise", "enabled", !config.channels.apprise?.enabled) }}
|
||||||
|
disabled={!editMode}
|
||||||
|
role="switch"
|
||||||
|
aria-checked={config.channels.apprise?.enabled || false}
|
||||||
|
>
|
||||||
|
<span className={`absolute top-[1px] left-[1px] h-4 w-4 rounded-full bg-white shadow transition-transform ${
|
||||||
|
config.channels.apprise?.enabled ? "translate-x-[18px]" : "translate-x-0"
|
||||||
|
}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{config.channels.apprise?.enabled && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-[11px] text-muted-foreground">Apprise URL</Label>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Input
|
||||||
|
type={showSecrets["apprise_url"] ? "text" : "password"}
|
||||||
|
className={`h-7 text-xs font-mono ${!editMode ? "opacity-50" : ""}`}
|
||||||
|
placeholder="tgram://bottoken/ChatID · ntfy://server/topic · discord://webhook_id/token · matrix://..."
|
||||||
|
value={config.channels.apprise?.url || ""}
|
||||||
|
onChange={e => updateChannel("apprise", "url", e.target.value)}
|
||||||
|
disabled={!editMode}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="h-7 w-7 flex items-center justify-center rounded-md border border-border hover:bg-muted text-muted-foreground"
|
||||||
|
onClick={() => setShowSecrets(s => ({ ...s, apprise_url: !s.apprise_url }))}
|
||||||
|
title={showSecrets["apprise_url"] ? "Hide URL" : "Show URL"}
|
||||||
|
>
|
||||||
|
{showSecrets["apprise_url"] ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground leading-relaxed">
|
||||||
|
A single URL that Apprise routes to the right service. Examples:
|
||||||
|
<code className="text-foreground/80 mx-0.5">tgram://</code>,
|
||||||
|
<code className="text-foreground/80 mx-0.5">discord://</code>,
|
||||||
|
<code className="text-foreground/80 mx-0.5">slack://</code>,
|
||||||
|
<code className="text-foreground/80 mx-0.5">ntfy://</code>,
|
||||||
|
<code className="text-foreground/80 mx-0.5">matrix://</code>,
|
||||||
|
<code className="text-foreground/80 mx-0.5">pushover://</code>,
|
||||||
|
<code className="text-foreground/80 mx-0.5">mailto://</code>… See the
|
||||||
|
{" "}
|
||||||
|
<a
|
||||||
|
href="https://github.com/caronc/apprise/wiki"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-cyan-500 hover:underline"
|
||||||
|
>
|
||||||
|
full list
|
||||||
|
</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end pt-1">
|
||||||
|
<button
|
||||||
|
className="h-7 px-3 text-xs rounded-md bg-cyan-600 hover:bg-cyan-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5"
|
||||||
|
onClick={() => handleTest("apprise")}
|
||||||
|
disabled={testing === "apprise" || !config.channels.apprise?.url}
|
||||||
|
>
|
||||||
|
{testing === "apprise" ? <Loader2 className="h-3 w-3 animate-spin" /> : <TestTube2 className="h-3 w-3" />}
|
||||||
|
Send Test
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{/* Test Result */}
|
{/* Test Result */}
|
||||||
|
|||||||
@@ -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<ProfileData | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(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<string | null>(null)
|
||||||
|
const [avatarBlobUrl, setAvatarBlobUrl] = useState<string | null>(null)
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const loadProfile = async () => {
|
||||||
|
try {
|
||||||
|
const data = await fetchApi<ProfileData>("/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 <img src=…> can't send. Plain
|
||||||
|
// `<img>` 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<ProfileData>("/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<string, string> = {}
|
||||||
|
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<string, string> = {}
|
||||||
|
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 (
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
<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…
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !profile) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<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="text-xs text-muted-foreground mt-1 break-all">{error}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
{/* 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. */}
|
||||||
|
<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>
|
||||||
|
</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
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{displayEditMode ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCancelDisplay}
|
||||||
|
disabled={savingDisplay}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSaveDisplayName}
|
||||||
|
disabled={savingDisplay || !hasDisplayChanges}
|
||||||
|
className="h-7 text-xs bg-blue-600 hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
{savingDisplay ? (
|
||||||
|
<Loader2 className="h-3 w-3 mr-1.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle2 className="h-3 w-3 mr-1.5" />
|
||||||
|
)}
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleEditDisplay}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
<Settings2 className="h-3 w-3 mr-1.5" />
|
||||||
|
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.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-8">
|
||||||
|
{/* ─── 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. */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm">Avatar</Label>
|
||||||
|
<div className="flex flex-col sm:flex-row items-start gap-6 mt-3">
|
||||||
|
<div className="relative shrink-0">
|
||||||
|
{avatarBlobUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={avatarBlobUrl}
|
||||||
|
alt=""
|
||||||
|
className="w-40 h-40 rounded-full object-cover border border-border bg-cyan-500/5"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="w-40 h-40 rounded-full bg-cyan-500/15 text-cyan-600 dark:text-cyan-300 flex items-center justify-center text-6xl font-semibold border border-border">
|
||||||
|
{initial}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{uploadingAvatar && (
|
||||||
|
<div className="absolute inset-0 rounded-full bg-black/50 flex items-center justify-center">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2 min-w-0">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (file) handleAvatarFile(file)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleAvatarPick}
|
||||||
|
disabled={uploadingAvatar}
|
||||||
|
className="justify-start"
|
||||||
|
>
|
||||||
|
<Upload className="h-3.5 w-3.5 mr-2" />
|
||||||
|
{profile?.has_avatar ? "Replace avatar" : "Upload avatar"}
|
||||||
|
</Button>
|
||||||
|
{profile?.has_avatar && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleAvatarDelete}
|
||||||
|
disabled={uploadingAvatar}
|
||||||
|
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
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<p className="text-[11px] text-muted-foreground leading-relaxed max-w-xs">
|
||||||
|
PNG, JPEG, WebP or GIF. Up to 2 MB. The image isn't resized —
|
||||||
|
render it square or pre-crop for best results in the header.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{avatarError && (
|
||||||
|
<div className="mt-3 text-xs text-red-500 flex items-start gap-1.5">
|
||||||
|
<X className="h-3.5 w-3.5 shrink-0 mt-0.5" />
|
||||||
|
<span className="break-all">{avatarError}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ─── Username (read-only) ─── */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm" htmlFor="profile-username">Username</Label>
|
||||||
|
<Input
|
||||||
|
id="profile-username"
|
||||||
|
value={profile?.username || ""}
|
||||||
|
disabled
|
||||||
|
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.
|
||||||
|
</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>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="profile-display"
|
||||||
|
value={displayDraft}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<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.
|
||||||
|
</p>
|
||||||
|
{error && displayEditMode && (
|
||||||
|
<div className="mt-2 text-xs text-red-500 flex items-start gap-1.5">
|
||||||
|
<X className="h-3.5 w-3.5 shrink-0 mt-0.5" />
|
||||||
|
<span className="break-all">{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ─── Account security shortcut ─── */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Shield className="h-5 w-5 text-orange-500" />
|
||||||
|
<CardTitle>Account security</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription>
|
||||||
|
Password, two-factor authentication and API tokens live in the Security panel.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{onOpenSecurity ? (
|
||||||
|
<Button variant="outline" onClick={onOpenSecurity}>
|
||||||
|
<Lock className="h-4 w-4 mr-2" />
|
||||||
|
Open Security settings
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Open the Security tab from the navigation.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,12 +12,14 @@ import Hardware from "./hardware"
|
|||||||
import { SystemLogs } from "./system-logs"
|
import { SystemLogs } from "./system-logs"
|
||||||
import { Settings } from "./settings"
|
import { Settings } from "./settings"
|
||||||
import { Security } from "./security"
|
import { Security } from "./security"
|
||||||
|
import { Profile } from "./profile"
|
||||||
import { About } from "./about"
|
import { About } from "./about"
|
||||||
import { OnboardingCarousel } from "./onboarding-carousel"
|
import { OnboardingCarousel } from "./onboarding-carousel"
|
||||||
import { HealthStatusModal } from "./health-status-modal"
|
import { HealthStatusModal } from "./health-status-modal"
|
||||||
import { ReleaseNotesModal, useVersionCheck } from "./release-notes-modal"
|
import { ReleaseNotesModal, useVersionCheck } from "./release-notes-modal"
|
||||||
import { getApiUrl, fetchApi } from "../lib/api-config"
|
import { getApiUrl, fetchApi } from "../lib/api-config"
|
||||||
import { TerminalPanel } from "./terminal-panel"
|
import { TerminalPanel } from "./terminal-panel"
|
||||||
|
import { AvatarMenu } from "./avatar-menu"
|
||||||
import {
|
import {
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
@@ -368,6 +370,8 @@ export function ProxmoxDashboard() {
|
|||||||
return "Security"
|
return "Security"
|
||||||
case "settings":
|
case "settings":
|
||||||
return "Settings"
|
return "Settings"
|
||||||
|
case "profile":
|
||||||
|
return "Profile"
|
||||||
default:
|
default:
|
||||||
return "Navigation Menu"
|
return "Navigation Menu"
|
||||||
}
|
}
|
||||||
@@ -480,44 +484,74 @@ export function ProxmoxDashboard() {
|
|||||||
<div onClick={(e) => e.stopPropagation()}>
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* User account dropdown — Fase 1 (v1.2.2). Self-hides
|
||||||
|
when auth isn't enabled on this install. */}
|
||||||
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
|
<AvatarMenu
|
||||||
|
size="lg"
|
||||||
|
onOpenProfile={() => setActiveTab("profile")}
|
||||||
|
onOpenSecurity={() => setActiveTab("security")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Actions */}
|
{/* Mobile Actions — variant D approved in demo:
|
||||||
<div className="flex lg:hidden items-start gap-2 pt-2">
|
• Top-right: Refresh + Theme + Avatar (all with border)
|
||||||
<div className="flex flex-col items-end gap-1">
|
• Bottom row (under Node line): badges left-aligned with
|
||||||
<Badge variant="outline" className={`${statusColor} text-xs px-2`}>
|
the Node text column, Uptime right-aligned in the same
|
||||||
{statusIcon}
|
horizontal line. No extra row for Uptime so the
|
||||||
</Badge>
|
header doesn't grow vertically. */}
|
||||||
{systemStatus.status === "healthy" && infoCount > 0 && (
|
<div className="flex lg:hidden items-center gap-1.5 shrink-0">
|
||||||
<Badge variant="outline" className="bg-blue-500/10 text-blue-500 border-blue-500/20 text-xs px-2">
|
|
||||||
<Info className="h-4 w-4" />
|
|
||||||
<span className="ml-1">{infoCount}</span>
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
refreshData()
|
refreshData()
|
||||||
}}
|
}}
|
||||||
disabled={isRefreshing}
|
disabled={isRefreshing}
|
||||||
className="h-8 w-8 p-0 -mt-1"
|
className="h-8 w-8 p-0 border-border/50 bg-transparent hover:bg-secondary"
|
||||||
|
aria-label="Refresh"
|
||||||
>
|
>
|
||||||
<RefreshCw className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
|
<RefreshCw className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div onClick={(e) => e.stopPropagation()} className="-mt-1">
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
|
<AvatarMenu
|
||||||
|
size="lg"
|
||||||
|
onOpenProfile={() => setActiveTab("profile")}
|
||||||
|
onOpenSecurity={() => setActiveTab("security")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Server Info */}
|
{/* Mobile bottom row — badges (left, aligned with the title
|
||||||
<div className="lg:hidden mt-2 flex items-center justify-end text-xs text-muted-foreground">
|
column via pl-[3.25rem] = w-16 logo + space-x-2 gap-ish)
|
||||||
<span className="whitespace-nowrap">Uptime: {systemStatus.uptime || "N/A"}</span>
|
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. */}
|
||||||
|
<div className="lg:hidden mt-2 flex items-center justify-between gap-2 pl-[4.5rem]">
|
||||||
|
<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>
|
||||||
|
</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">
|
||||||
|
<Info className="h-3 w-3" />
|
||||||
|
<span className="ml-1">{infoCount}</span>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
Uptime: {systemStatus.uptime || "N/A"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -804,6 +838,16 @@ export function ProxmoxDashboard() {
|
|||||||
<Security key={`security-${componentKey}`} />
|
<Security key={`security-${componentKey}`} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* 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. */}
|
||||||
|
<TabsContent value="profile" className="space-y-4 md:space-y-6 mt-0">
|
||||||
|
<Profile
|
||||||
|
key={`profile-${componentKey}`}
|
||||||
|
onOpenSecurity={() => setActiveTab("security")}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="settings" className="space-y-4 md:space-y-6 mt-0">
|
<TabsContent value="settings" className="space-y-4 md:space-y-6 mt-0">
|
||||||
<Settings />
|
<Settings />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Input } from "./ui/input"
|
|||||||
import { Label } from "./ui/label"
|
import { Label } from "./ui/label"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
|
||||||
import {
|
import {
|
||||||
Shield, Lock, User, AlertCircle, CheckCircle, Info, LogOut, Key, Copy, Eye, EyeOff,
|
Shield, Lock, User, AlertCircle, CheckCircle, Info, Key, Copy, Eye, EyeOff,
|
||||||
Trash2, RefreshCw, Clock, ShieldCheck, Globe, FileKey, AlertTriangle,
|
Trash2, RefreshCw, Clock, ShieldCheck, Globe, FileKey, AlertTriangle,
|
||||||
Flame, Bug, Search, Download, Power, PowerOff, Plus, Minus, Activity, Settings, Ban,
|
Flame, Bug, Search, Download, Power, PowerOff, Plus, Minus, Activity, Settings, Ban,
|
||||||
FileText, Printer, Play, BarChart3, TriangleAlert, ChevronDown, ArrowDownLeft, ArrowUpRight,
|
FileText, Printer, Play, BarChart3, TriangleAlert, ChevronDown, ArrowDownLeft, ArrowUpRight,
|
||||||
@@ -925,11 +925,8 @@ export function Security() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleLogout = () => {
|
// handleLogout removed: the session-end action lives in the header's
|
||||||
localStorage.removeItem("proxmenux-auth-token")
|
// AvatarMenu now (Fase 1, v1.2.2). See `components/avatar-menu.tsx`.
|
||||||
localStorage.removeItem("proxmenux-auth-setup-complete")
|
|
||||||
window.location.reload()
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadApiTokens = async () => {
|
const loadApiTokens = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -1740,10 +1737,11 @@ ${(report.sections && report.sections.length > 0) ? `
|
|||||||
|
|
||||||
{authEnabled && (
|
{authEnabled && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Button onClick={handleLogout} variant="outline" className="bg-transparent">
|
{/* Logout moved to the header AvatarMenu (Fase 1, v1.2.2)
|
||||||
<LogOut className="h-4 w-4 mr-2" />
|
so the session-end action lives in one consistent place
|
||||||
Logout
|
on every page. The Security panel keeps the actions
|
||||||
</Button>
|
that affect the *account* itself (password, 2FA, disable
|
||||||
|
auth), not the session. */}
|
||||||
|
|
||||||
{!showChangePassword && (
|
{!showChangePassword && (
|
||||||
<Button onClick={() => setShowChangePassword(true)} variant="outline">
|
<Button onClick={() => setShowChangePassword(true)} variant="outline">
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Badge } from "./ui/badge"
|
|||||||
import { Progress } from "./ui/progress"
|
import { Progress } from "./ui/progress"
|
||||||
import { Button } from "./ui/button"
|
import { Button } from "./ui/button"
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "./ui/dialog"
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "./ui/dialog"
|
||||||
import { Server, Play, Square, Cpu, MemoryStick, HardDrive, Network, Power, RotateCcw, StopCircle, Container, ChevronDown, ChevronUp, ChevronRight, Terminal, Archive, Plus, Loader2, Clock, Database, Shield, Bell, FileText, Settings2, Activity, Package } from 'lucide-react'
|
import { Server, Play, Square, Cpu, MemoryStick, HardDrive, Network, Power, RotateCcw, StopCircle, Container, ChevronDown, ChevronUp, ChevronRight, Terminal, Archive, Plus, Loader2, Clock, Database, Shield, Bell, FileText, Settings2, Activity, Package, RefreshCw } from 'lucide-react'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||||
import { Checkbox } from "./ui/checkbox"
|
import { Checkbox } from "./ui/checkbox"
|
||||||
import { Textarea } from "./ui/textarea"
|
import { Textarea } from "./ui/textarea"
|
||||||
@@ -645,7 +645,16 @@ export function VirtualMachines() {
|
|||||||
const [backupPbsChangeMode, setBackupPbsChangeMode] = useState<string>("default")
|
const [backupPbsChangeMode, setBackupPbsChangeMode] = useState<string>("default")
|
||||||
|
|
||||||
// Tab state for modal
|
// Tab state for modal
|
||||||
const [activeModalTab, setActiveModalTab] = useState<"status" | "mounts" | "backups" | "updates">("status")
|
const [activeModalTab, setActiveModalTab] = useState<"status" | "mounts" | "backups" | "updates" | "firewall">("status")
|
||||||
|
|
||||||
|
// Firewall log state — fetched only when the operator opens that tab
|
||||||
|
// so a CT/VM without firewall use doesn't pay the pvesh cost on every
|
||||||
|
// modal open. Issue #14554 from the helper-scripts discussions.
|
||||||
|
interface FirewallLogEntry { n: number; t: string }
|
||||||
|
const [firewallLogs, setFirewallLogs] = useState<FirewallLogEntry[]>([])
|
||||||
|
const [loadingFirewallLog, setLoadingFirewallLog] = useState(false)
|
||||||
|
const [firewallEnabled, setFirewallEnabled] = useState<boolean>(true)
|
||||||
|
const [firewallLogError, setFirewallLogError] = useState<string | null>(null)
|
||||||
// Sprint 13.29: per-LXC mount points lazy-loaded when the user opens
|
// Sprint 13.29: per-LXC mount points lazy-loaded when the user opens
|
||||||
// the LXC modal. We fetch alongside backups (one-shot) so switching
|
// the LXC modal. We fetch alongside backups (one-shot) so switching
|
||||||
// tabs is instantaneous; the cost is small (parses one config file
|
// tabs is instantaneous; the cost is small (parses one config file
|
||||||
@@ -772,6 +781,11 @@ export function VirtualMachines() {
|
|||||||
// so the new modal doesn't briefly flash data from another LXC.
|
// so the new modal doesn't briefly flash data from another LXC.
|
||||||
setMountPoints([])
|
setMountPoints([])
|
||||||
setAdHocMounts([])
|
setAdHocMounts([])
|
||||||
|
// Reset firewall log state — fetched lazily when the user opens
|
||||||
|
// that tab, since most operators won't visit it on every modal open.
|
||||||
|
setFirewallLogs([])
|
||||||
|
setFirewallLogError(null)
|
||||||
|
setFirewallEnabled(true)
|
||||||
|
|
||||||
// Load backups immediately (independent of config)
|
// Load backups immediately (independent of config)
|
||||||
fetchBackupStorages()
|
fetchBackupStorages()
|
||||||
@@ -858,6 +872,33 @@ export function VirtualMachines() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Firewall log fetcher — proxies the PVE per-VM/CT firewall log
|
||||||
|
// endpoint. The backend returns `firewall_enabled: false` when PVE
|
||||||
|
// says the firewall is OFF for that guest; in that case we render
|
||||||
|
// a callout instead of an empty viewer.
|
||||||
|
const fetchFirewallLog = async (vmid: number) => {
|
||||||
|
setLoadingFirewallLog(true)
|
||||||
|
setFirewallLogError(null)
|
||||||
|
try {
|
||||||
|
const response = await fetchApi<{
|
||||||
|
logs?: FirewallLogEntry[]
|
||||||
|
firewall_enabled?: boolean
|
||||||
|
error?: string
|
||||||
|
}>(`/api/vms/${vmid}/firewall/log?limit=500`)
|
||||||
|
setFirewallEnabled(response.firewall_enabled !== false)
|
||||||
|
setFirewallLogs(Array.isArray(response.logs) ? response.logs : [])
|
||||||
|
if (response.error && response.firewall_enabled !== false) {
|
||||||
|
setFirewallLogError(response.error)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setFirewallEnabled(true)
|
||||||
|
setFirewallLogs([])
|
||||||
|
setFirewallLogError(error instanceof Error ? error.message : String(error))
|
||||||
|
} finally {
|
||||||
|
setLoadingFirewallLog(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const openBackupModal = () => {
|
const openBackupModal = () => {
|
||||||
// Reset modal to defaults
|
// Reset modal to defaults
|
||||||
setBackupMode("snapshot")
|
setBackupMode("snapshot")
|
||||||
@@ -1708,62 +1749,127 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
|||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{/* Tab Navigation */}
|
{/* Tab Navigation.
|
||||||
<div className="flex border-b border-border px-6 shrink-0">
|
Mobile UX:
|
||||||
|
• Only the active tab shows its label; the rest
|
||||||
|
collapse to icon-only so 4-5 tabs fit on a phone.
|
||||||
|
• Per-tab padding + gap shrink on narrow viewports
|
||||||
|
(`px-2.5 sm:px-4`, `gap-1.5 sm:gap-2`) so even with
|
||||||
|
two badges showing counts the row doesn't overflow.
|
||||||
|
• Container has `overflow-x-auto` as a safety net —
|
||||||
|
a CT with all tabs active (Mounts + Backups +
|
||||||
|
Updates + Firewall) on a very narrow phone can
|
||||||
|
still horizontally scroll the row instead of
|
||||||
|
clipping the last tab off-screen.
|
||||||
|
• Badges stay visible in both states so the user
|
||||||
|
still sees "9 backups" at a glance even when that
|
||||||
|
tab isn't active. */}
|
||||||
|
<div className="flex border-b border-border px-3 sm:px-6 shrink-0 overflow-x-auto [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveModalTab("status")}
|
onClick={() => setActiveModalTab("status")}
|
||||||
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px whitespace-nowrap ${
|
className={`flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px whitespace-nowrap shrink-0 ${
|
||||||
activeModalTab === "status"
|
activeModalTab === "status"
|
||||||
? "border-cyan-500 text-cyan-500"
|
? "border-cyan-500 text-cyan-500"
|
||||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Activity className="h-4 w-4" />
|
<Activity className="h-4 w-4" />
|
||||||
Status
|
<span className={activeModalTab === "status" ? "" : "hidden sm:inline"}>
|
||||||
|
Status
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{/* Sprint 13.29: Mount Points tab — LXC only, and only
|
{/* Sprint 13.29: Mount Points tab — LXC only, and only
|
||||||
when at least one mp / ad-hoc remote mount exists.
|
when at least one mp / ad-hoc remote mount exists.
|
||||||
A CT without mounts gets no empty tab.
|
A CT without mounts gets no empty tab. */}
|
||||||
Label is "Mounts" (single word) so the trigger
|
|
||||||
fits in one line on mobile next to Status and
|
|
||||||
Backups; "Mount Points" wrapped on narrow viewports. */}
|
|
||||||
{selectedVM?.type === "lxc" && (mountPoints.length > 0 || adHocMounts.length > 0) && (
|
{selectedVM?.type === "lxc" && (mountPoints.length > 0 || adHocMounts.length > 0) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveModalTab("mounts")}
|
onClick={() => setActiveModalTab("mounts")}
|
||||||
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px whitespace-nowrap ${
|
className={`flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px whitespace-nowrap shrink-0 ${
|
||||||
activeModalTab === "mounts"
|
activeModalTab === "mounts"
|
||||||
? "border-blue-500 text-blue-500"
|
? "border-blue-500 text-blue-500"
|
||||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<HardDrive className="h-4 w-4" />
|
<HardDrive className="h-4 w-4" />
|
||||||
Mounts
|
<span className={activeModalTab === "mounts" ? "" : "hidden sm:inline"}>
|
||||||
<Badge variant="secondary" className="text-xs h-5 ml-1">
|
Mounts
|
||||||
|
</span>
|
||||||
|
<Badge variant="secondary" className="text-xs h-5 ml-0.5 sm:ml-1">
|
||||||
{mountPoints.length + adHocMounts.length}
|
{mountPoints.length + adHocMounts.length}
|
||||||
</Badge>
|
</Badge>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveModalTab("backups")}
|
onClick={() => setActiveModalTab("backups")}
|
||||||
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px whitespace-nowrap ${
|
className={`flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px whitespace-nowrap shrink-0 ${
|
||||||
activeModalTab === "backups"
|
activeModalTab === "backups"
|
||||||
? "border-amber-500 text-amber-500"
|
? "border-amber-500 text-amber-500"
|
||||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Archive className="h-4 w-4" />
|
<Archive className="h-4 w-4" />
|
||||||
Backups
|
<span className={activeModalTab === "backups" ? "" : "hidden sm:inline"}>
|
||||||
|
Backups
|
||||||
|
</span>
|
||||||
{vmBackups.length > 0 && (
|
{vmBackups.length > 0 && (
|
||||||
<Badge variant="secondary" className="text-xs h-5 ml-1">{vmBackups.length}</Badge>
|
<Badge variant="secondary" className="text-xs h-5 ml-0.5 sm:ml-1">{vmBackups.length}</Badge>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
{/* Updates is intentionally NOT a tab in the nav — the
|
{/* Updates tab — re-added as a first-class nav entry now
|
||||||
extra tab created a scrolling tab strip on mobile
|
that the mobile UX collapses inactive tabs to
|
||||||
(especially once Mounts + Backups + Updates piled
|
icon-only (so the row no longer overflows on narrow
|
||||||
up) and the swipe affordance was missed. The
|
viewports the way it did before v1.2.1.3). LXC only,
|
||||||
clickable violet badge in the modal header is now
|
rendered only when the managed-installs registry has
|
||||||
the sole entry point; the Updates content panel
|
flagged pending updates for this CT, so a CT with
|
||||||
below still mounts when activeModalTab === 'updates'. */}
|
nothing pending doesn't get an empty tab. The violet
|
||||||
|
badge in the header stays as a complementary entry
|
||||||
|
point — both routes lead to the same `updates` panel
|
||||||
|
below. */}
|
||||||
|
{selectedVM?.type === "lxc" && selectedVM?.update_check?.available && (
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveModalTab("updates")}
|
||||||
|
className={`flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px whitespace-nowrap shrink-0 ${
|
||||||
|
activeModalTab === "updates"
|
||||||
|
? "border-purple-500 text-purple-500"
|
||||||
|
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
<span className={activeModalTab === "updates" ? "" : "hidden sm:inline"}>
|
||||||
|
Updates
|
||||||
|
</span>
|
||||||
|
{typeof selectedVM.update_check?.count === "number" && selectedVM.update_check.count > 0 && (
|
||||||
|
<Badge variant="secondary" className="text-xs h-5 ml-0.5 sm:ml-1">
|
||||||
|
{selectedVM.update_check.count}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Firewall tab — issue #14554 from the helper-scripts
|
||||||
|
discussions ("view individual VM/CT firewall logs").
|
||||||
|
Always rendered for VMs and CTs; if the guest doesn't
|
||||||
|
have firewall enabled in PVE, the panel shows a
|
||||||
|
callout explaining how to turn it on. Log fetched
|
||||||
|
lazily on first click to avoid hitting pvesh on
|
||||||
|
every modal open. */}
|
||||||
|
{selectedVM && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setActiveModalTab("firewall")
|
||||||
|
fetchFirewallLog(selectedVM.vmid)
|
||||||
|
}}
|
||||||
|
className={`flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px whitespace-nowrap shrink-0 ${
|
||||||
|
activeModalTab === "firewall"
|
||||||
|
? "border-orange-500 text-orange-500"
|
||||||
|
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Shield className="h-4 w-4" />
|
||||||
|
<span className={activeModalTab === "firewall" ? "" : "hidden sm:inline"}>
|
||||||
|
Firewall
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-6 py-4 min-h-0">
|
<div className="flex-1 overflow-y-auto px-6 py-4 min-h-0">
|
||||||
@@ -2740,6 +2846,112 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Firewall Logs Tab — issue #14554. Reads the per-VM/CT
|
||||||
|
log filtered by PVE directly (no host-wide log
|
||||||
|
grep). Loading is lazy and triggered by the tab
|
||||||
|
button's onClick. */}
|
||||||
|
{activeModalTab === "firewall" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card className="border border-border bg-card/50">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center justify-between mb-4 gap-2 flex-wrap">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="p-1.5 rounded-md bg-orange-500/10">
|
||||||
|
<Shield className="h-4 w-4 text-orange-500" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-sm font-semibold text-foreground">Firewall Logs</h3>
|
||||||
|
{firewallEnabled && firewallLogs.length > 0 && (
|
||||||
|
<Badge variant="secondary" className="text-xs h-5 ml-1">
|
||||||
|
{firewallLogs.length}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 text-xs gap-1"
|
||||||
|
onClick={() => selectedVM && fetchFirewallLog(selectedVM.vmid)}
|
||||||
|
disabled={loadingFirewallLog}
|
||||||
|
>
|
||||||
|
{loadingFirewallLog ? (
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
<span>Refresh</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-border/50 mb-4" />
|
||||||
|
|
||||||
|
{loadingFirewallLog ? (
|
||||||
|
<div className="flex items-center justify-center py-6 text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||||
|
<span className="text-sm">Loading firewall log…</span>
|
||||||
|
</div>
|
||||||
|
) : !firewallEnabled ? (
|
||||||
|
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 p-4 text-sm">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Shield className="h-4 w-4 text-amber-500 shrink-0 mt-0.5" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="font-medium text-amber-500">
|
||||||
|
Firewall is not enabled for this {selectedVM?.type === "lxc" ? "container" : "VM"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||||
|
Enable it in the Proxmox UI under{" "}
|
||||||
|
<strong>
|
||||||
|
{selectedVM?.type === "lxc" ? "Container" : "VM"} → Firewall → Options
|
||||||
|
</strong>{" "}
|
||||||
|
and add at least one rule with <code>log: info</code> (or higher) so packets start
|
||||||
|
being recorded. New entries will appear here automatically on the next refresh.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : firewallLogError ? (
|
||||||
|
<div className="rounded-md border border-red-500/30 bg-red-500/5 p-4 text-sm">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Shield className="h-4 w-4 text-red-500 shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-red-500 mb-1">Failed to read firewall log</p>
|
||||||
|
<p className="text-xs text-muted-foreground break-all">{firewallLogError}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : firewallLogs.length === 0 ? (
|
||||||
|
<div className="text-center py-6 text-sm text-muted-foreground">
|
||||||
|
No firewall events recorded yet.
|
||||||
|
<div className="text-xs mt-1">
|
||||||
|
Rules with <code>log: info</code> (or higher) will populate this view as packets arrive.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-md border border-border bg-background/50 max-h-[480px] overflow-y-auto">
|
||||||
|
<pre className="text-[11px] font-mono leading-snug whitespace-pre-wrap break-all p-3">
|
||||||
|
{firewallLogs.map((entry, idx) => {
|
||||||
|
const text = entry.t || ""
|
||||||
|
// Light colour-coding by the action keyword
|
||||||
|
// PVE emits in the line itself — purely
|
||||||
|
// visual, parsing stays line-by-line so
|
||||||
|
// a malformed entry still renders fine.
|
||||||
|
let actionClass = "text-foreground/90"
|
||||||
|
if (/\bDROP\b/i.test(text)) actionClass = "text-red-400"
|
||||||
|
else if (/\bREJECT\b/i.test(text)) actionClass = "text-orange-400"
|
||||||
|
else if (/\bACCEPT\b/i.test(text)) actionClass = "text-green-400"
|
||||||
|
return (
|
||||||
|
<div key={`${entry.n}-${idx}`} className={actionClass}>
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-border bg-background px-6 py-4 mt-auto shrink-0">
|
<div className="border-t border-border bg-background px-6 py-4 mt-auto shrink-0">
|
||||||
|
|||||||
@@ -39,6 +39,20 @@ except ImportError:
|
|||||||
# Configuration
|
# Configuration
|
||||||
CONFIG_DIR = Path.home() / ".config" / "proxmenux-monitor"
|
CONFIG_DIR = Path.home() / ".config" / "proxmenux-monitor"
|
||||||
AUTH_CONFIG_FILE = CONFIG_DIR / "auth.json"
|
AUTH_CONFIG_FILE = CONFIG_DIR / "auth.json"
|
||||||
|
|
||||||
|
# User profile — Fase 2 (v1.2.2). Avatar stored as a binary file next
|
||||||
|
# to auth.json so the JSON stays small and the image can be served
|
||||||
|
# unmodified. Display name is kept inside auth.json as an optional
|
||||||
|
# string; empty/missing falls back to the username at render time.
|
||||||
|
AVATAR_FILE = CONFIG_DIR / "avatar.bin"
|
||||||
|
AVATAR_CONTENT_TYPE_FILE = CONFIG_DIR / "avatar.type"
|
||||||
|
AVATAR_MAX_BYTES = 2 * 1024 * 1024 # 2 MB hard cap on uploads
|
||||||
|
AVATAR_ALLOWED_CONTENT_TYPES = {
|
||||||
|
"image/png",
|
||||||
|
"image/jpeg",
|
||||||
|
"image/webp",
|
||||||
|
"image/gif",
|
||||||
|
}
|
||||||
# Sentinel for legacy installs that started under the hardcoded JWT_SECRET.
|
# Sentinel for legacy installs that started under the hardcoded JWT_SECRET.
|
||||||
# The audit (Tier 4 #22) flagged that constant — anyone with access to the
|
# The audit (Tier 4 #22) flagged that constant — anyone with access to the
|
||||||
# public repo could forge JWTs against any deployment. We now generate a
|
# public repo could forge JWTs against any deployment. We now generate a
|
||||||
@@ -97,7 +111,8 @@ def load_auth_config():
|
|||||||
"totp_secret": None,
|
"totp_secret": None,
|
||||||
"backup_codes": [],
|
"backup_codes": [],
|
||||||
"api_tokens": [],
|
"api_tokens": [],
|
||||||
"revoked_tokens": []
|
"revoked_tokens": [],
|
||||||
|
"display_name": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -111,6 +126,7 @@ def load_auth_config():
|
|||||||
config.setdefault("backup_codes", [])
|
config.setdefault("backup_codes", [])
|
||||||
config.setdefault("api_tokens", [])
|
config.setdefault("api_tokens", [])
|
||||||
config.setdefault("revoked_tokens", [])
|
config.setdefault("revoked_tokens", [])
|
||||||
|
config.setdefault("display_name", None)
|
||||||
return config
|
return config
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error loading auth config: {e}")
|
print(f"Error loading auth config: {e}")
|
||||||
@@ -124,7 +140,8 @@ def load_auth_config():
|
|||||||
"totp_secret": None,
|
"totp_secret": None,
|
||||||
"backup_codes": [],
|
"backup_codes": [],
|
||||||
"api_tokens": [],
|
"api_tokens": [],
|
||||||
"revoked_tokens": []
|
"revoked_tokens": [],
|
||||||
|
"display_name": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1280,3 +1297,168 @@ def authenticate(username, password, totp_token=None):
|
|||||||
return True, token, False, "Authentication successful"
|
return True, token, False, "Authentication successful"
|
||||||
else:
|
else:
|
||||||
return False, None, False, "Failed to generate authentication token"
|
return False, None, False, "Failed to generate authentication token"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# User profile (Fase 2, v1.2.2)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# Display name + avatar. Both are optional decorations on top of the
|
||||||
|
# existing username + password. The display name lives inside auth.json
|
||||||
|
# (one extra string field). The avatar is stored as a binary file next
|
||||||
|
# to auth.json so the JSON stays small and the image can be served
|
||||||
|
# without re-encoding.
|
||||||
|
#
|
||||||
|
# No email field — the Monitor doesn't send mail (no password reset, no
|
||||||
|
# confirmation), and the operator-of-PVE-as-root use case never benefits
|
||||||
|
# from one. If OIDC lands in v1.3.0 we'll surface whatever the issuer
|
||||||
|
# claims, but we don't ask the operator for an email manually.
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_profile():
|
||||||
|
"""Return the active user's profile decorations.
|
||||||
|
|
||||||
|
Returns a dict with:
|
||||||
|
{
|
||||||
|
"username": str | None,
|
||||||
|
"display_name": str | None, # may equal username
|
||||||
|
"has_avatar": bool,
|
||||||
|
"avatar_mtime": float | None, # for cache-busting URLs
|
||||||
|
"avatar_content_type": str | None,
|
||||||
|
}
|
||||||
|
Username falls back to None when auth isn't configured/enabled.
|
||||||
|
"""
|
||||||
|
config = load_auth_config()
|
||||||
|
username = config.get("username") if config.get("enabled") else None
|
||||||
|
display_name = config.get("display_name") or None
|
||||||
|
|
||||||
|
has_avatar = AVATAR_FILE.exists() and AVATAR_FILE.stat().st_size > 0
|
||||||
|
avatar_mtime = None
|
||||||
|
avatar_content_type = None
|
||||||
|
if has_avatar:
|
||||||
|
try:
|
||||||
|
avatar_mtime = AVATAR_FILE.stat().st_mtime
|
||||||
|
except OSError:
|
||||||
|
avatar_mtime = None
|
||||||
|
try:
|
||||||
|
if AVATAR_CONTENT_TYPE_FILE.exists():
|
||||||
|
avatar_content_type = AVATAR_CONTENT_TYPE_FILE.read_text().strip() or None
|
||||||
|
except OSError:
|
||||||
|
avatar_content_type = None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"username": username,
|
||||||
|
"display_name": display_name,
|
||||||
|
"has_avatar": has_avatar,
|
||||||
|
"avatar_mtime": avatar_mtime,
|
||||||
|
"avatar_content_type": avatar_content_type,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def set_display_name(display_name):
|
||||||
|
"""Persist (or clear) the user's display name.
|
||||||
|
|
||||||
|
Accepts any string up to 64 chars. An empty / whitespace-only value
|
||||||
|
clears the field — the dropdown then falls back to the raw username
|
||||||
|
when rendering. Returns (success: bool, message: str).
|
||||||
|
"""
|
||||||
|
cleaned = (display_name or "").strip()
|
||||||
|
if len(cleaned) > 64:
|
||||||
|
return False, "Display name must be 64 characters or less"
|
||||||
|
# Disallow control characters — a display name with embedded \n
|
||||||
|
# would break the avatar dropdown layout.
|
||||||
|
if any(ord(ch) < 0x20 for ch in cleaned):
|
||||||
|
return False, "Display name contains control characters"
|
||||||
|
|
||||||
|
config = load_auth_config()
|
||||||
|
config["display_name"] = cleaned or None
|
||||||
|
if not save_auth_config(config):
|
||||||
|
return False, "Failed to save profile"
|
||||||
|
return True, "Display name updated"
|
||||||
|
|
||||||
|
|
||||||
|
def save_avatar(content_bytes, content_type):
|
||||||
|
"""Persist a new avatar image. Best-effort validation:
|
||||||
|
|
||||||
|
• Content-Type must be one of `AVATAR_ALLOWED_CONTENT_TYPES`.
|
||||||
|
• Size must be <= `AVATAR_MAX_BYTES` (2 MB).
|
||||||
|
• Magic-number check — first few bytes must match a supported image
|
||||||
|
format. This blocks a `.png`-renamed `.exe` from being served as
|
||||||
|
an image to other browsers.
|
||||||
|
|
||||||
|
Returns (success: bool, message: str). Does not resize — the
|
||||||
|
frontend always renders the avatar inside a `rounded-full` with
|
||||||
|
`object-cover`, so any aspect ratio displays correctly. Operators
|
||||||
|
who want a smaller file can compress before upload.
|
||||||
|
"""
|
||||||
|
if not isinstance(content_bytes, (bytes, bytearray)) or not content_bytes:
|
||||||
|
return False, "No image data"
|
||||||
|
if len(content_bytes) > AVATAR_MAX_BYTES:
|
||||||
|
return False, f"Image exceeds {AVATAR_MAX_BYTES // (1024 * 1024)} MB limit"
|
||||||
|
if content_type not in AVATAR_ALLOWED_CONTENT_TYPES:
|
||||||
|
return False, f"Unsupported image type: {content_type}"
|
||||||
|
|
||||||
|
# Magic-number sniffing: trust the Content-Type but verify.
|
||||||
|
head = bytes(content_bytes[:12])
|
||||||
|
looks_valid = (
|
||||||
|
head.startswith(b"\x89PNG\r\n\x1a\n") or # PNG
|
||||||
|
head.startswith(b"\xff\xd8\xff") or # JPEG
|
||||||
|
(head[:4] == b"RIFF" and head[8:12] == b"WEBP") or # WebP
|
||||||
|
head.startswith(b"GIF87a") or head.startswith(b"GIF89a") # GIF
|
||||||
|
)
|
||||||
|
if not looks_valid:
|
||||||
|
return False, "Image bytes don't match a supported format"
|
||||||
|
|
||||||
|
try:
|
||||||
|
ensure_config_dir()
|
||||||
|
# Write atomically — tmp + rename so a crashed write never leaves
|
||||||
|
# a half-written avatar file that the GET endpoint would serve as
|
||||||
|
# corrupt bytes.
|
||||||
|
tmp_avatar = AVATAR_FILE.with_suffix(AVATAR_FILE.suffix + ".tmp")
|
||||||
|
with open(tmp_avatar, "wb") as f:
|
||||||
|
f.write(content_bytes)
|
||||||
|
os.replace(tmp_avatar, AVATAR_FILE)
|
||||||
|
AVATAR_CONTENT_TYPE_FILE.write_text(content_type)
|
||||||
|
try:
|
||||||
|
os.chmod(AVATAR_FILE, 0o600)
|
||||||
|
except OSError:
|
||||||
|
# Best-effort permission tighten; not fatal if the FS doesn't
|
||||||
|
# support it (e.g. some bind-mounted scenarios).
|
||||||
|
pass
|
||||||
|
return True, "Avatar saved"
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"Failed to save avatar: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
def delete_avatar():
|
||||||
|
"""Remove the stored avatar file. Returns (success, message). No-op
|
||||||
|
when there's nothing to delete (still returns success)."""
|
||||||
|
try:
|
||||||
|
if AVATAR_FILE.exists():
|
||||||
|
AVATAR_FILE.unlink()
|
||||||
|
if AVATAR_CONTENT_TYPE_FILE.exists():
|
||||||
|
AVATAR_CONTENT_TYPE_FILE.unlink()
|
||||||
|
return True, "Avatar removed"
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"Failed to remove avatar: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_avatar_bytes():
|
||||||
|
"""Return (bytes, content_type) for the stored avatar, or (None, None)
|
||||||
|
if no avatar is set or the file is unreadable. The caller is
|
||||||
|
responsible for the HTTP response; this only handles the I/O."""
|
||||||
|
if not AVATAR_FILE.exists():
|
||||||
|
return None, None
|
||||||
|
try:
|
||||||
|
data = AVATAR_FILE.read_bytes()
|
||||||
|
except OSError:
|
||||||
|
return None, None
|
||||||
|
content_type = "application/octet-stream"
|
||||||
|
try:
|
||||||
|
if AVATAR_CONTENT_TYPE_FILE.exists():
|
||||||
|
ct = AVATAR_CONTENT_TYPE_FILE.read_text().strip()
|
||||||
|
if ct in AVATAR_ALLOWED_CONTENT_TYPES:
|
||||||
|
content_type = ct
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return data, content_type
|
||||||
|
|||||||
@@ -383,6 +383,14 @@ pip3 install --target "$APP_DIR/usr/lib/python3/dist-packages" --upgrade \
|
|||||||
gevent-websocket>=0.10.1 \
|
gevent-websocket>=0.10.1 \
|
||||||
greenlet>=3.0.0
|
greenlet>=3.0.0
|
||||||
|
|
||||||
|
# Phase 3c: Apprise notification hub (issue #207). One library handles
|
||||||
|
# ~80 notification services behind a single URL scheme (`tgram://`,
|
||||||
|
# `discord://`, `ntfy://`, `matrix://`, etc.). Used by the optional
|
||||||
|
# `apprise` channel in notification_channels.py for operators who want
|
||||||
|
# to reach a service we don't support natively.
|
||||||
|
pip3 install --target "$APP_DIR/usr/lib/python3/dist-packages" --upgrade \
|
||||||
|
apprise>=1.7.0
|
||||||
|
|
||||||
cat > "$APP_DIR/usr/lib/python3/dist-packages/cgi.py" << 'PYEOF'
|
cat > "$APP_DIR/usr/lib/python3/dist-packages/cgi.py" << 'PYEOF'
|
||||||
from typing import Tuple, Dict
|
from typing import Tuple, Dict
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -670,3 +670,128 @@ def revoke_api_token_route(token_id):
|
|||||||
return jsonify({"success": False, "message": message}), 400
|
return jsonify({"success": False, "message": message}), 400
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"success": False, "message": str(e)}), 500
|
return jsonify({"success": False, "message": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# User profile endpoints (Fase 2, v1.2.2)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# GET /api/auth/profile → username + display_name + has_avatar
|
||||||
|
# PUT /api/auth/profile → update display_name (body: {display_name})
|
||||||
|
# GET /api/auth/profile/avatar → serve the avatar bytes (image/*)
|
||||||
|
# POST /api/auth/profile/avatar → upload new avatar (multipart 'file')
|
||||||
|
# DELETE /api/auth/profile/avatar → remove the stored avatar
|
||||||
|
#
|
||||||
|
# All four require auth via @require_auth. The avatar GET also requires
|
||||||
|
# auth because the file lives next to the auth state on disk and we
|
||||||
|
# don't want it leaked to arbitrary callers — the avatar URL is meant
|
||||||
|
# to be fetched by an already-authenticated session.
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.route('/api/auth/profile', methods=['GET'])
|
||||||
|
@require_auth
|
||||||
|
def get_profile():
|
||||||
|
"""Return the active user's profile (username + display name + avatar
|
||||||
|
metadata). Falls back to None values when auth isn't configured."""
|
||||||
|
try:
|
||||||
|
profile = auth_manager.get_user_profile()
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
**profile,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"success": False, "message": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.route('/api/auth/profile', methods=['PUT'])
|
||||||
|
@require_auth
|
||||||
|
def update_profile():
|
||||||
|
"""Update display_name. Body: {"display_name": "..."}. Empty string
|
||||||
|
clears it (the dropdown then renders the raw username)."""
|
||||||
|
try:
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
if "display_name" not in data:
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"message": "Missing 'display_name' field",
|
||||||
|
}), 400
|
||||||
|
ok, message = auth_manager.set_display_name(data.get("display_name") or "")
|
||||||
|
if not ok:
|
||||||
|
return jsonify({"success": False, "message": message}), 400
|
||||||
|
# Return the fresh profile so the frontend can update without a
|
||||||
|
# second roundtrip.
|
||||||
|
return jsonify({"success": True, "message": message, **auth_manager.get_user_profile()})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"success": False, "message": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.route('/api/auth/profile/avatar', methods=['GET'])
|
||||||
|
@require_auth
|
||||||
|
def get_avatar():
|
||||||
|
"""Serve the stored avatar bytes. Returns 404 if no avatar set."""
|
||||||
|
try:
|
||||||
|
from flask import Response
|
||||||
|
data, content_type = auth_manager.get_avatar_bytes()
|
||||||
|
if data is None:
|
||||||
|
return jsonify({"success": False, "message": "No avatar set"}), 404
|
||||||
|
return Response(
|
||||||
|
data,
|
||||||
|
mimetype=content_type,
|
||||||
|
headers={
|
||||||
|
# Allow short-window caching keyed by the URL — the
|
||||||
|
# frontend appends `?v=<mtime>` so any update busts the
|
||||||
|
# cache automatically.
|
||||||
|
"Cache-Control": "private, max-age=60",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"success": False, "message": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.route('/api/auth/profile/avatar', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def upload_avatar():
|
||||||
|
"""Upload a new avatar image. Accepts either:
|
||||||
|
• multipart/form-data with a `file` field (preferred), or
|
||||||
|
• a raw image body with Content-Type set to image/png|jpeg|webp|gif.
|
||||||
|
The size cap (2 MB) and the magic-number sniff happen in
|
||||||
|
auth_manager.save_avatar — failures come back as 400 with a
|
||||||
|
human-readable message."""
|
||||||
|
try:
|
||||||
|
content_bytes = None
|
||||||
|
content_type = None
|
||||||
|
|
||||||
|
# Multipart path
|
||||||
|
if request.files:
|
||||||
|
file_storage = request.files.get("file")
|
||||||
|
if file_storage is not None:
|
||||||
|
content_bytes = file_storage.read()
|
||||||
|
content_type = (file_storage.mimetype or "").lower()
|
||||||
|
|
||||||
|
# Raw body fallback
|
||||||
|
if content_bytes is None:
|
||||||
|
content_bytes = request.get_data(cache=False)
|
||||||
|
content_type = (request.headers.get("Content-Type") or "").split(";", 1)[0].strip().lower()
|
||||||
|
|
||||||
|
if not content_bytes:
|
||||||
|
return jsonify({"success": False, "message": "No image data received"}), 400
|
||||||
|
|
||||||
|
ok, message = auth_manager.save_avatar(content_bytes, content_type)
|
||||||
|
if not ok:
|
||||||
|
return jsonify({"success": False, "message": message}), 400
|
||||||
|
return jsonify({"success": True, "message": message, **auth_manager.get_user_profile()})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"success": False, "message": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.route('/api/auth/profile/avatar', methods=['DELETE'])
|
||||||
|
@require_auth
|
||||||
|
def remove_avatar():
|
||||||
|
"""Remove the stored avatar (no-op if none set)."""
|
||||||
|
try:
|
||||||
|
ok, message = auth_manager.delete_avatar()
|
||||||
|
if not ok:
|
||||||
|
return jsonify({"success": False, "message": message}), 400
|
||||||
|
return jsonify({"success": True, "message": message, **auth_manager.get_user_profile()})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"success": False, "message": str(e)}), 500
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ TOOL_METADATA = {
|
|||||||
'figurine': {'name': 'Figurine', 'function': 'configure_figurine', 'version': '1.0'},
|
'figurine': {'name': 'Figurine', 'function': 'configure_figurine', 'version': '1.0'},
|
||||||
'fastfetch': {'name': 'Fastfetch', 'function': 'configure_fastfetch', 'version': '1.0'},
|
'fastfetch': {'name': 'Fastfetch', 'function': 'configure_fastfetch', 'version': '1.0'},
|
||||||
'log2ram': {'name': 'Log2ram (SSD Protection)', 'function': 'configure_log2ram', 'version': '1.0'},
|
'log2ram': {'name': 'Log2ram (SSD Protection)', 'function': 'configure_log2ram', 'version': '1.0'},
|
||||||
|
'zfs_autotrim': {'name': 'ZFS Autotrim', 'function': 'enable_zfs_autotrim', 'version': '1.0'},
|
||||||
'amd_fixes': {'name': 'AMD CPU (Ryzen/EPYC) fixes', 'function': 'apply_amd_fixes', 'version': '1.0'},
|
'amd_fixes': {'name': 'AMD CPU (Ryzen/EPYC) fixes', 'function': 'apply_amd_fixes', 'version': '1.0'},
|
||||||
'persistent_network': {'name': 'Setting persistent network interfaces', 'function': 'setup_persistent_network', 'version': '1.0'},
|
'persistent_network': {'name': 'Setting persistent network interfaces', 'function': 'setup_persistent_network', 'version': '1.0'},
|
||||||
'vfio_iommu': {'name': 'VFIO/IOMMU Passthrough', 'function': 'enable_vfio_iommu', 'version': '1.0'},
|
'vfio_iommu': {'name': 'VFIO/IOMMU Passthrough', 'function': 'enable_vfio_iommu', 'version': '1.0'},
|
||||||
|
|||||||
@@ -11198,6 +11198,105 @@ def api_vm_logs(vmid):
|
|||||||
pass
|
pass
|
||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
@app.route('/api/vms/<int:vmid>/firewall/log', methods=['GET'])
|
||||||
|
@require_auth
|
||||||
|
def api_vm_firewall_log(vmid):
|
||||||
|
"""Per-VM/CT firewall log entries — proxies the official PVE API:
|
||||||
|
`/nodes/<node>/{lxc,qemu}/<vmid>/firewall/log`. Returns the matching
|
||||||
|
lines from `/var/log/pve-firewall.log` already filtered by VMID so
|
||||||
|
the frontend doesn't have to parse the host-wide log itself.
|
||||||
|
|
||||||
|
Implements issue #14554 from the helper-scripts discussions —
|
||||||
|
"view individual VM/CT firewall logs" — without writing any custom
|
||||||
|
log parsing: PVE's API does it natively.
|
||||||
|
|
||||||
|
Query string:
|
||||||
|
* `start` — 0-based offset into the log (default 0).
|
||||||
|
* `limit` — number of lines to return (default 500, cap 5000).
|
||||||
|
|
||||||
|
Response shape mirrors the `/api/vms/<vmid>/logs` endpoint so the
|
||||||
|
frontend log viewer can reuse the same renderer.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
start = max(int(request.args.get('start', 0)), 0)
|
||||||
|
limit = min(max(int(request.args.get('limit', 500)), 1), 5000)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({'error': 'start/limit must be integers'}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
resources = get_cached_pvesh_cluster_resources_vm()
|
||||||
|
if not resources:
|
||||||
|
return jsonify({'error': 'Failed to enumerate cluster VMs'}), 500
|
||||||
|
|
||||||
|
vm_info = next((r for r in resources if r.get('vmid') == vmid), None)
|
||||||
|
if not vm_info:
|
||||||
|
return jsonify({'error': f'VM/LXC {vmid} not found'}), 404
|
||||||
|
|
||||||
|
vm_type = 'lxc' if vm_info.get('type') == 'lxc' else 'qemu'
|
||||||
|
node = vm_info.get('node', 'pve')
|
||||||
|
|
||||||
|
log_result = subprocess.run(
|
||||||
|
[
|
||||||
|
'pvesh', 'get',
|
||||||
|
f'/nodes/{node}/{vm_type}/{vmid}/firewall/log',
|
||||||
|
'--start', str(start),
|
||||||
|
'--limit', str(limit),
|
||||||
|
'--output-format', 'json',
|
||||||
|
],
|
||||||
|
capture_output=True, text=True, timeout=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
if log_result.returncode != 0:
|
||||||
|
stderr = (log_result.stderr or '').strip()
|
||||||
|
# PVE returns this exact wording when the firewall is OFF
|
||||||
|
# for the guest — surface it as a structured flag so the
|
||||||
|
# frontend can render a "firewall disabled" callout instead
|
||||||
|
# of a generic error toast.
|
||||||
|
firewall_disabled = (
|
||||||
|
'firewall' in stderr.lower() and 'disable' in stderr.lower()
|
||||||
|
) or '404' in stderr
|
||||||
|
return jsonify({
|
||||||
|
'vmid': vmid,
|
||||||
|
'name': vm_info.get('name'),
|
||||||
|
'type': vm_type,
|
||||||
|
'node': node,
|
||||||
|
'firewall_enabled': not firewall_disabled,
|
||||||
|
'logs': [],
|
||||||
|
'error': stderr[:300] if stderr else 'pvesh returned non-zero',
|
||||||
|
}), 200 if firewall_disabled else 500
|
||||||
|
|
||||||
|
entries = []
|
||||||
|
try:
|
||||||
|
data = json.loads(log_result.stdout or '[]')
|
||||||
|
if isinstance(data, list):
|
||||||
|
for row in data:
|
||||||
|
if isinstance(row, dict):
|
||||||
|
entries.append({
|
||||||
|
'n': row.get('n'),
|
||||||
|
't': row.get('t', ''),
|
||||||
|
})
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
# Older PVE versions or oddly-shaped output: fall back to
|
||||||
|
# plain text parsing, one entry per line.
|
||||||
|
for i, line in enumerate(log_result.stdout.split('\n')):
|
||||||
|
if line.strip():
|
||||||
|
entries.append({'n': start + i, 't': line})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'vmid': vmid,
|
||||||
|
'name': vm_info.get('name'),
|
||||||
|
'type': vm_type,
|
||||||
|
'node': node,
|
||||||
|
'firewall_enabled': True,
|
||||||
|
'log_lines': len(entries),
|
||||||
|
'logs': entries,
|
||||||
|
})
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return jsonify({'error': 'pvesh timed out reading firewall log'}), 504
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/vms/<int:vmid>/control', methods=['POST'])
|
@app.route('/api/vms/<int:vmid>/control', methods=['POST'])
|
||||||
@require_auth
|
@require_auth
|
||||||
def api_vm_control(vmid):
|
def api_vm_control(vmid):
|
||||||
|
|||||||
@@ -1021,6 +1021,120 @@ class EmailChannel(NotificationChannel):
|
|||||||
return result.get('success', False), result.get('error', '')
|
return result.get('success', False), result.get('error', '')
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Apprise ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class AppriseChannel(NotificationChannel):
|
||||||
|
"""Apprise meta-channel — a single URL talks to ~80 services.
|
||||||
|
|
||||||
|
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.
|
||||||
|
The operator pastes one URL and ProxMenux delegates the transport.
|
||||||
|
|
||||||
|
Requested in issue #207 by @0berkampf. Implemented as a *separate
|
||||||
|
channel type* (not a replacement for the native Telegram / Gotify /
|
||||||
|
Discord / Email channels), so installs that already have a working
|
||||||
|
native channel don't need to migrate — Apprise is opt-in for users
|
||||||
|
who want to reach a service we don't support natively.
|
||||||
|
|
||||||
|
The library is loaded lazily on first send. Older deployments that
|
||||||
|
haven't installed it yet surface a clean validation error instead
|
||||||
|
of crashing the notification manager at import time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, url: str):
|
||||||
|
super().__init__()
|
||||||
|
self.url = (url or '').strip()
|
||||||
|
|
||||||
|
# Lazy import so installs that haven't picked up the new dep yet
|
||||||
|
# don't crash on module load. Each call re-imports cheaply — Python
|
||||||
|
# caches the module reference after the first hit.
|
||||||
|
def _load_apprise(self):
|
||||||
|
try:
|
||||||
|
import apprise # type: ignore
|
||||||
|
return apprise
|
||||||
|
except ImportError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def validate_config(self) -> Tuple[bool, str]:
|
||||||
|
if not self.url:
|
||||||
|
return False, 'Apprise URL is required'
|
||||||
|
apprise = self._load_apprise()
|
||||||
|
if apprise is None:
|
||||||
|
return False, (
|
||||||
|
'apprise library not installed in this deployment. '
|
||||||
|
'Reinstall ProxMenux Monitor or run `pip install apprise` '
|
||||||
|
'inside the AppImage environment.'
|
||||||
|
)
|
||||||
|
# `add(url)` returns True only if Apprise recognised the scheme
|
||||||
|
# — useful as a syntactic validation without sending anything.
|
||||||
|
try:
|
||||||
|
apobj = apprise.Apprise()
|
||||||
|
ok = apobj.add(self.url)
|
||||||
|
if not ok:
|
||||||
|
return False, 'Apprise rejected the URL (unrecognised scheme or bad format)'
|
||||||
|
except Exception as e:
|
||||||
|
return False, f'Apprise rejected the URL: {e}'
|
||||||
|
return True, ''
|
||||||
|
|
||||||
|
def _severity_to_notify_type(self, apprise_mod, severity: str):
|
||||||
|
"""Map ProxMenux severities to Apprise NotifyType constants so
|
||||||
|
services that render severity (e.g. Pushover priority, ntfy
|
||||||
|
priority headers) get the right indicator."""
|
||||||
|
sev = (severity or '').upper()
|
||||||
|
if sev == 'CRITICAL':
|
||||||
|
return apprise_mod.NotifyType.FAILURE
|
||||||
|
if sev == 'WARNING':
|
||||||
|
return apprise_mod.NotifyType.WARNING
|
||||||
|
if sev == 'SUCCESS':
|
||||||
|
return apprise_mod.NotifyType.SUCCESS
|
||||||
|
return apprise_mod.NotifyType.INFO
|
||||||
|
|
||||||
|
def send(self, title: str, message: str, severity: str = 'INFO',
|
||||||
|
data: Optional[Dict] = None) -> Dict[str, Any]:
|
||||||
|
ok, err = self.validate_config()
|
||||||
|
if not ok:
|
||||||
|
return {'success': False, 'error': err, 'channel': 'apprise'}
|
||||||
|
|
||||||
|
# Rate limit (shared with the other channels) before dispatch.
|
||||||
|
def _send_via_apprise() -> Tuple[int, str]:
|
||||||
|
apprise = self._load_apprise()
|
||||||
|
if apprise is None:
|
||||||
|
# Shouldn't happen — validate_config caught it above —
|
||||||
|
# but defend in depth so the retry loop reports cleanly.
|
||||||
|
return 0, 'apprise library not available'
|
||||||
|
try:
|
||||||
|
apobj = apprise.Apprise()
|
||||||
|
apobj.add(self.url)
|
||||||
|
sent = apobj.notify(
|
||||||
|
body=message or '',
|
||||||
|
title=title or '',
|
||||||
|
notify_type=self._severity_to_notify_type(apprise, severity),
|
||||||
|
)
|
||||||
|
# `notify` returns True iff at least one target accepted
|
||||||
|
# the message. False means every URL endpoint rejected
|
||||||
|
# — we don't get a per-URL status code back, hence the
|
||||||
|
# opaque "Apprise rejected the notification".
|
||||||
|
if sent:
|
||||||
|
return 200, ''
|
||||||
|
return 500, 'Apprise rejected the notification (transport failure)'
|
||||||
|
except Exception as e:
|
||||||
|
return 0, str(e)
|
||||||
|
|
||||||
|
result = self._send_with_retry(_send_via_apprise)
|
||||||
|
result['channel'] = 'apprise'
|
||||||
|
return result
|
||||||
|
|
||||||
|
def test(self) -> Tuple[bool, str]:
|
||||||
|
result = self.send(
|
||||||
|
title='ProxMenux Monitor — Test',
|
||||||
|
message='Apprise channel is configured correctly. If you can read this, the URL is valid and the service accepted the notification.',
|
||||||
|
severity='INFO',
|
||||||
|
)
|
||||||
|
return bool(result.get('success')), result.get('error') or ''
|
||||||
|
|
||||||
|
|
||||||
# ─── Channel Factory ─────────────────────────────────────────────
|
# ─── Channel Factory ─────────────────────────────────────────────
|
||||||
|
|
||||||
CHANNEL_TYPES = {
|
CHANNEL_TYPES = {
|
||||||
@@ -1045,6 +1159,11 @@ CHANNEL_TYPES = {
|
|||||||
'from_address', 'to_addresses', 'subject_prefix'],
|
'from_address', 'to_addresses', 'subject_prefix'],
|
||||||
'class': EmailChannel,
|
'class': EmailChannel,
|
||||||
},
|
},
|
||||||
|
'apprise': {
|
||||||
|
'name': 'Apprise',
|
||||||
|
'config_keys': ['url'],
|
||||||
|
'class': AppriseChannel,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1052,7 +1171,7 @@ def create_channel(channel_type: str, config: Dict[str, str]) -> Optional[Notifi
|
|||||||
"""Create a channel instance from type name and config dict.
|
"""Create a channel instance from type name and config dict.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
channel_type: 'telegram', 'gotify', or 'discord'
|
channel_type: 'telegram', 'gotify', 'discord', 'email', or 'apprise'
|
||||||
config: Dict with channel-specific keys (see CHANNEL_TYPES)
|
config: Dict with channel-specific keys (see CHANNEL_TYPES)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -1076,6 +1195,8 @@ def create_channel(channel_type: str, config: Dict[str, str]) -> Optional[Notifi
|
|||||||
)
|
)
|
||||||
elif channel_type == 'email':
|
elif channel_type == 'email':
|
||||||
return EmailChannel(config)
|
return EmailChannel(config)
|
||||||
|
elif channel_type == 'apprise':
|
||||||
|
return AppriseChannel(url=config.get('url', ''))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[NotificationChannels] Failed to create {channel_type}: {e}")
|
print(f"[NotificationChannels] Failed to create {channel_type}: {e}")
|
||||||
return None
|
return None
|
||||||
|
|||||||
Reference in New Issue
Block a user