mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-05-31 12:34:48 +00:00
Update AppImage 1.2.1.3
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { Button } from "./ui/button"
|
||||
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog"
|
||||
import { Input } from "./ui/input"
|
||||
import { Label } from "./ui/label"
|
||||
import { Shield, Lock, User, AlertCircle, Eye, EyeOff } from "lucide-react"
|
||||
import { Shield, Lock, User, AlertCircle, Eye, EyeOff, Upload, Trash2 } from "lucide-react"
|
||||
import { getApiUrl } from "../lib/api-config"
|
||||
|
||||
interface AuthSetupProps {
|
||||
@@ -22,6 +22,14 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
|
||||
// Profile (Fase 2 — v1.2.2). Both optional decorations on top of the
|
||||
// mandatory username + password. Persisted via PUT /api/auth/profile
|
||||
// and POST /api/auth/profile/avatar after the user lands a successful
|
||||
// /api/auth/setup so we don't change the setup endpoint's contract.
|
||||
const [displayName, setDisplayName] = useState("")
|
||||
const [avatarFile, setAvatarFile] = useState<File | null>(null)
|
||||
const [avatarPreviewUrl, setAvatarPreviewUrl] = useState<string | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
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 () => {
|
||||
setError("")
|
||||
|
||||
@@ -125,6 +145,61 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
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)
|
||||
onComplete()
|
||||
} catch (err) {
|
||||
@@ -261,6 +336,100 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
</Button>
|
||||
</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 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
|
||||
const key = pathKey(path)
|
||||
const editingValue = pending[key] ?? String(leaf.value)
|
||||
// The input border carries the severity colour so the editable field
|
||||
// itself shows what kind of threshold this is — no separate badge
|
||||
// duplicating the number, which users mistook for the "real" value.
|
||||
// `swap_critical` and any other `*_critical` leaf falls into the red
|
||||
// bucket via the substring check. A blue ring on top of the colour
|
||||
// border signals "customised vs recommended" — two independent
|
||||
// signals on the same widget.
|
||||
// Visual rules (rebuilt — the original used /40 opacity borders +
|
||||
// a blue ring stacked on top of the colour border, both of which
|
||||
// were nearly invisible in read-only mode and stacked weirdly when
|
||||
// a value was customised):
|
||||
//
|
||||
// • Read-only mode (editMode=false): keep severity colour on the
|
||||
// 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 isCritical = last.toLowerCase().includes("critical")
|
||||
const isWarning = last.toLowerCase().includes("warning")
|
||||
const severityBorder = isCritical
|
||||
? "border-red-500/40 bg-red-500/5 focus-visible:border-red-500"
|
||||
const severityClass = isCritical
|
||||
? "border-red-500/70 bg-red-500/10 focus-visible:border-red-500"
|
||||
: 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 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}`
|
||||
return (
|
||||
<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) =>
|
||||
setPending((p) => ({ ...p, [key]: e.target.value }))
|
||||
}
|
||||
className={`w-20 h-7 text-xs text-right tabular-nums ${
|
||||
!editMode ? "opacity-70" : ""
|
||||
} ${severityBorder} ${customisedRing}`}
|
||||
className={`w-20 h-7 text-xs text-right tabular-nums border ${fieldClass} ${
|
||||
!editMode ? "disabled:opacity-100 disabled:cursor-default" : ""
|
||||
}`}
|
||||
/>
|
||||
<span className="text-[11px] text-muted-foreground w-6">{leaf.unit}</span>
|
||||
</div>
|
||||
|
||||
@@ -104,10 +104,14 @@ export function LxcUpdateDetection() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Boxes className="h-5 w-5 text-purple-500" />
|
||||
<CardTitle>LXC Update Detection</CardTitle>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
{/* Title row — flex-wrap so on narrow screens the badge can drop
|
||||
under the title without dragging the icon along with it. The
|
||||
icon stays on the same baseline as the title text on every
|
||||
breakpoint thanks to `items-center` + leading-tight title. */}
|
||||
<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 ? (
|
||||
<Badge variant="outline" className="text-[10px] border-green-500/30 text-green-500">
|
||||
Active
|
||||
@@ -118,7 +122,7 @@ export function LxcUpdateDetection() {
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{saved && (
|
||||
<span className="flex items-center gap-1 text-xs text-green-500">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
@@ -172,23 +176,19 @@ export function LxcUpdateDetection() {
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-5">
|
||||
{/* ── Enable/Disable ── */}
|
||||
<div className="flex items-center justify-between py-2 px-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* ── Enable/Disable ── single-line label + toggle. The description
|
||||
paragraph was removed because the CardDescription above already
|
||||
covers the behaviour; on mobile that second paragraph forced
|
||||
the icon to top-align and made the toggle wrap awkwardly. */}
|
||||
<div className="flex items-center justify-between gap-3 py-2 px-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<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">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>
|
||||
<span className="text-sm font-medium truncate">Enable LXC update detection</span>
|
||||
</div>
|
||||
<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"
|
||||
} ${!editMode ? "opacity-60 cursor-not-allowed" : "cursor-pointer"}`}
|
||||
onClick={() => editMode && setPending(p => !p)}
|
||||
|
||||
@@ -157,7 +157,7 @@ const EVENT_CATEGORIES = [
|
||||
{ key: "other", label: "Other", desc: "Uncategorized notifications" },
|
||||
]
|
||||
|
||||
const CHANNEL_TYPES = ["telegram", "gotify", "discord", "email"] as const
|
||||
const CHANNEL_TYPES = ["telegram", "gotify", "discord", "email", "apprise"] as const
|
||||
|
||||
const AI_PROVIDERS = [
|
||||
{
|
||||
@@ -262,6 +262,7 @@ const DEFAULT_CONFIG: NotificationConfig = {
|
||||
gotify: { enabled: false },
|
||||
discord: { enabled: false },
|
||||
email: { enabled: false },
|
||||
apprise: { enabled: false },
|
||||
},
|
||||
event_categories: {
|
||||
vm_ct: true, backup: true, resources: true, storage: true,
|
||||
@@ -275,6 +276,7 @@ const DEFAULT_CONFIG: NotificationConfig = {
|
||||
gotify: { categories: {}, events: {} },
|
||||
discord: { categories: {}, events: {} },
|
||||
email: { categories: {}, events: {} },
|
||||
apprise: { categories: {}, events: {} },
|
||||
},
|
||||
ai_enabled: false,
|
||||
ai_provider: "groq",
|
||||
@@ -305,6 +307,7 @@ const DEFAULT_CONFIG: NotificationConfig = {
|
||||
gotify: "brief",
|
||||
discord: "brief",
|
||||
email: "detailed",
|
||||
apprise: "brief",
|
||||
},
|
||||
hostname: "",
|
||||
webhook_secret: "",
|
||||
@@ -1346,7 +1349,7 @@ export function NotificationSettings() {
|
||||
|
||||
<div className="rounded-lg border border-border/50 bg-muted/20 p-3">
|
||||
<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">
|
||||
Telegram
|
||||
</TabsTrigger>
|
||||
@@ -1359,6 +1362,9 @@ export function NotificationSettings() {
|
||||
<TabsTrigger value="email" className="text-xs data-[state=active]:text-amber-500">
|
||||
Email
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="apprise" className="text-xs data-[state=active]:text-cyan-500">
|
||||
Apprise
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Telegram */}
|
||||
@@ -1788,6 +1794,96 @@ export function NotificationSettings() {
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* 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 { Settings } from "./settings"
|
||||
import { Security } from "./security"
|
||||
import { Profile } from "./profile"
|
||||
import { About } from "./about"
|
||||
import { OnboardingCarousel } from "./onboarding-carousel"
|
||||
import { HealthStatusModal } from "./health-status-modal"
|
||||
import { ReleaseNotesModal, useVersionCheck } from "./release-notes-modal"
|
||||
import { getApiUrl, fetchApi } from "../lib/api-config"
|
||||
import { TerminalPanel } from "./terminal-panel"
|
||||
import { AvatarMenu } from "./avatar-menu"
|
||||
import {
|
||||
RefreshCw,
|
||||
AlertTriangle,
|
||||
@@ -368,6 +370,8 @@ export function ProxmoxDashboard() {
|
||||
return "Security"
|
||||
case "settings":
|
||||
return "Settings"
|
||||
case "profile":
|
||||
return "Profile"
|
||||
default:
|
||||
return "Navigation Menu"
|
||||
}
|
||||
@@ -480,44 +484,74 @@ export function ProxmoxDashboard() {
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<ThemeToggle />
|
||||
</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>
|
||||
|
||||
{/* Mobile Actions */}
|
||||
<div className="flex lg:hidden items-start gap-2 pt-2">
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<Badge variant="outline" className={`${statusColor} text-xs px-2`}>
|
||||
{statusIcon}
|
||||
</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-4 w-4" />
|
||||
<span className="ml-1">{infoCount}</span>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile Actions — variant D approved in demo:
|
||||
• Top-right: Refresh + Theme + Avatar (all with border)
|
||||
• Bottom row (under Node line): badges left-aligned with
|
||||
the Node text column, Uptime right-aligned in the same
|
||||
horizontal line. No extra row for Uptime so the
|
||||
header doesn't grow vertically. */}
|
||||
<div className="flex lg:hidden items-center gap-1.5 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
refreshData()
|
||||
}}
|
||||
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" : ""}`} />
|
||||
</Button>
|
||||
|
||||
<div onClick={(e) => e.stopPropagation()} className="-mt-1">
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<AvatarMenu
|
||||
size="lg"
|
||||
onOpenProfile={() => setActiveTab("profile")}
|
||||
onOpenSecurity={() => setActiveTab("security")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Server Info */}
|
||||
<div className="lg:hidden mt-2 flex items-center justify-end text-xs text-muted-foreground">
|
||||
<span className="whitespace-nowrap">Uptime: {systemStatus.uptime || "N/A"}</span>
|
||||
{/* Mobile bottom row — badges (left, aligned with the title
|
||||
column via pl-[3.25rem] = w-16 logo + space-x-2 gap-ish)
|
||||
and Uptime (right). The pl matches the mobile logo width
|
||||
+ the parent flex gap so the badges sit visually under
|
||||
"Node: amd", not flush against the screen edge. */}
|
||||
<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>
|
||||
</header>
|
||||
@@ -804,6 +838,16 @@ export function ProxmoxDashboard() {
|
||||
<Security key={`security-${componentKey}`} />
|
||||
</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">
|
||||
<Settings />
|
||||
</TabsContent>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Input } from "./ui/input"
|
||||
import { Label } from "./ui/label"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
|
||||
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,
|
||||
Flame, Bug, Search, Download, Power, PowerOff, Plus, Minus, Activity, Settings, Ban,
|
||||
FileText, Printer, Play, BarChart3, TriangleAlert, ChevronDown, ArrowDownLeft, ArrowUpRight,
|
||||
@@ -925,11 +925,8 @@ export function Security() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem("proxmenux-auth-token")
|
||||
localStorage.removeItem("proxmenux-auth-setup-complete")
|
||||
window.location.reload()
|
||||
}
|
||||
// handleLogout removed: the session-end action lives in the header's
|
||||
// AvatarMenu now (Fase 1, v1.2.2). See `components/avatar-menu.tsx`.
|
||||
|
||||
const loadApiTokens = async () => {
|
||||
try {
|
||||
@@ -1740,10 +1737,11 @@ ${(report.sections && report.sections.length > 0) ? `
|
||||
|
||||
{authEnabled && (
|
||||
<div className="space-y-3">
|
||||
<Button onClick={handleLogout} variant="outline" className="bg-transparent">
|
||||
<LogOut className="h-4 w-4 mr-2" />
|
||||
Logout
|
||||
</Button>
|
||||
{/* Logout moved to the header AvatarMenu (Fase 1, v1.2.2)
|
||||
so the session-end action lives in one consistent place
|
||||
on every page. The Security panel keeps the actions
|
||||
that affect the *account* itself (password, 2FA, disable
|
||||
auth), not the session. */}
|
||||
|
||||
{!showChangePassword && (
|
||||
<Button onClick={() => setShowChangePassword(true)} variant="outline">
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Badge } from "./ui/badge"
|
||||
import { Progress } from "./ui/progress"
|
||||
import { Button } from "./ui/button"
|
||||
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 { Checkbox } from "./ui/checkbox"
|
||||
import { Textarea } from "./ui/textarea"
|
||||
@@ -645,7 +645,16 @@ export function VirtualMachines() {
|
||||
const [backupPbsChangeMode, setBackupPbsChangeMode] = useState<string>("default")
|
||||
|
||||
// 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
|
||||
// the LXC modal. We fetch alongside backups (one-shot) so switching
|
||||
// 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.
|
||||
setMountPoints([])
|
||||
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)
|
||||
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 = () => {
|
||||
// Reset modal to defaults
|
||||
setBackupMode("snapshot")
|
||||
@@ -1708,62 +1749,127 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex border-b border-border px-6 shrink-0">
|
||||
{/* Tab Navigation.
|
||||
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
|
||||
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"
|
||||
? "border-cyan-500 text-cyan-500"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Activity className="h-4 w-4" />
|
||||
Status
|
||||
<span className={activeModalTab === "status" ? "" : "hidden sm:inline"}>
|
||||
Status
|
||||
</span>
|
||||
</button>
|
||||
{/* Sprint 13.29: Mount Points tab — LXC only, and only
|
||||
when at least one mp / ad-hoc remote mount exists.
|
||||
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. */}
|
||||
A CT without mounts gets no empty tab. */}
|
||||
{selectedVM?.type === "lxc" && (mountPoints.length > 0 || adHocMounts.length > 0) && (
|
||||
<button
|
||||
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"
|
||||
? "border-blue-500 text-blue-500"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<HardDrive className="h-4 w-4" />
|
||||
Mounts
|
||||
<Badge variant="secondary" className="text-xs h-5 ml-1">
|
||||
<span className={activeModalTab === "mounts" ? "" : "hidden sm:inline"}>
|
||||
Mounts
|
||||
</span>
|
||||
<Badge variant="secondary" className="text-xs h-5 ml-0.5 sm:ml-1">
|
||||
{mountPoints.length + adHocMounts.length}
|
||||
</Badge>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
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"
|
||||
? "border-amber-500 text-amber-500"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Archive className="h-4 w-4" />
|
||||
Backups
|
||||
<span className={activeModalTab === "backups" ? "" : "hidden sm:inline"}>
|
||||
Backups
|
||||
</span>
|
||||
{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>
|
||||
{/* Updates is intentionally NOT a tab in the nav — the
|
||||
extra tab created a scrolling tab strip on mobile
|
||||
(especially once Mounts + Backups + Updates piled
|
||||
up) and the swipe affordance was missed. The
|
||||
clickable violet badge in the modal header is now
|
||||
the sole entry point; the Updates content panel
|
||||
below still mounts when activeModalTab === 'updates'. */}
|
||||
{/* Updates tab — re-added as a first-class nav entry now
|
||||
that the mobile UX collapses inactive tabs to
|
||||
icon-only (so the row no longer overflows on narrow
|
||||
viewports the way it did before v1.2.1.3). LXC only,
|
||||
rendered only when the managed-installs registry has
|
||||
flagged pending updates for this CT, so a CT with
|
||||
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 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>
|
||||
</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 className="border-t border-border bg-background px-6 py-4 mt-auto shrink-0">
|
||||
|
||||
@@ -39,6 +39,20 @@ except ImportError:
|
||||
# Configuration
|
||||
CONFIG_DIR = Path.home() / ".config" / "proxmenux-monitor"
|
||||
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.
|
||||
# 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
|
||||
@@ -97,7 +111,8 @@ def load_auth_config():
|
||||
"totp_secret": None,
|
||||
"backup_codes": [],
|
||||
"api_tokens": [],
|
||||
"revoked_tokens": []
|
||||
"revoked_tokens": [],
|
||||
"display_name": None,
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -111,6 +126,7 @@ def load_auth_config():
|
||||
config.setdefault("backup_codes", [])
|
||||
config.setdefault("api_tokens", [])
|
||||
config.setdefault("revoked_tokens", [])
|
||||
config.setdefault("display_name", None)
|
||||
return config
|
||||
except Exception as e:
|
||||
print(f"Error loading auth config: {e}")
|
||||
@@ -124,7 +140,8 @@ def load_auth_config():
|
||||
"totp_secret": None,
|
||||
"backup_codes": [],
|
||||
"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"
|
||||
else:
|
||||
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 \
|
||||
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'
|
||||
from typing import Tuple, Dict
|
||||
try:
|
||||
|
||||
@@ -670,3 +670,128 @@ def revoke_api_token_route(token_id):
|
||||
return jsonify({"success": False, "message": message}), 400
|
||||
except Exception as e:
|
||||
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'},
|
||||
'fastfetch': {'name': 'Fastfetch', 'function': 'configure_fastfetch', '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'},
|
||||
'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'},
|
||||
|
||||
@@ -11198,6 +11198,105 @@ def api_vm_logs(vmid):
|
||||
pass
|
||||
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'])
|
||||
@require_auth
|
||||
def api_vm_control(vmid):
|
||||
|
||||
@@ -1021,6 +1021,120 @@ class EmailChannel(NotificationChannel):
|
||||
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_TYPES = {
|
||||
@@ -1045,16 +1159,21 @@ CHANNEL_TYPES = {
|
||||
'from_address', 'to_addresses', 'subject_prefix'],
|
||||
'class': EmailChannel,
|
||||
},
|
||||
'apprise': {
|
||||
'name': 'Apprise',
|
||||
'config_keys': ['url'],
|
||||
'class': AppriseChannel,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def create_channel(channel_type: str, config: Dict[str, str]) -> Optional[NotificationChannel]:
|
||||
"""Create a channel instance from type name and config dict.
|
||||
|
||||
|
||||
Args:
|
||||
channel_type: 'telegram', 'gotify', or 'discord'
|
||||
channel_type: 'telegram', 'gotify', 'discord', 'email', or 'apprise'
|
||||
config: Dict with channel-specific keys (see CHANNEL_TYPES)
|
||||
|
||||
|
||||
Returns:
|
||||
Channel instance or None if creation fails
|
||||
"""
|
||||
@@ -1076,6 +1195,8 @@ def create_channel(channel_type: str, config: Dict[str, str]) -> Optional[Notifi
|
||||
)
|
||||
elif channel_type == 'email':
|
||||
return EmailChannel(config)
|
||||
elif channel_type == 'apprise':
|
||||
return AppriseChannel(url=config.get('url', ''))
|
||||
except Exception as e:
|
||||
print(f"[NotificationChannels] Failed to create {channel_type}: {e}")
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user