Remove the notification menu from the VM and LXC backup dialog; backups follow the notification settings

This commit is contained in:
MacRimi
2026-10-18 17:43:41 +02:00
parent 700a4de2ee
commit 947b5291e9
11 changed files with 77 additions and 98 deletions
+1 -57
View File
@@ -11,7 +11,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, PlusCircle, Loader2, Database, Shield, Bell, FileText, Settings2, Activity, Package, RefreshCw, EthernetPort, ArrowUpCircle, Info, CheckCircle2, EyeOff, Eye, Trash2, Check, X, AlertTriangle, AlertCircle, Search, Pin, Tag as TagIcon } from 'lucide-react'
import { Server, Play, Square, Cpu, MemoryStick, HardDrive, Network, Power, RotateCcw, StopCircle, Container, ChevronDown, ChevronUp, ChevronRight, Terminal, Archive, Plus, PlusCircle, Loader2, Database, Shield, FileText, Settings2, Activity, Package, RefreshCw, EthernetPort, ArrowUpCircle, Info, CheckCircle2, EyeOff, Eye, Trash2, Check, X, AlertTriangle, AlertCircle, Search, Pin, Tag as TagIcon } from 'lucide-react'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
import { Checkbox } from "./ui/checkbox"
import { Switch } from "./ui/switch"
@@ -963,7 +963,6 @@ export function VirtualMachines() {
const [showBackupModal, setShowBackupModal] = useState(false)
const [backupMode, setBackupMode] = useState<string>("snapshot")
const [backupProtected, setBackupProtected] = useState(false)
const [backupNotification, setBackupNotification] = useState<string>("auto")
const [backupNotes, setBackupNotes] = useState<string>("{{guestname}}")
const [backupPbsChangeMode, setBackupPbsChangeMode] = useState<string>("default")
@@ -1698,7 +1697,6 @@ export function VirtualMachines() {
// Reset modal to defaults
setBackupMode("snapshot")
setBackupProtected(false)
setBackupNotification("auto")
setBackupNotes("{{guestname}}")
setBackupPbsChangeMode("default")
// Auto-select first storage if none selected
@@ -1722,7 +1720,6 @@ export function VirtualMachines() {
mode: backupMode,
compress: "zstd",
protected: backupProtected,
notification: backupNotification,
notes: backupNotes,
pbs_change_detection: backupPbsChangeMode
}),
@@ -1879,40 +1876,6 @@ export function VirtualMachines() {
setTerminalOpen(true)
}
const handleDownloadLogs = async (vmid: number, vmName: string) => {
try {
const data = await fetchApi(`/api/vms/${vmid}/logs`)
// Format logs as plain text
let logText = `=== ${t("vmLxc.logs.header", { name: vmName, vmid })} ===\n`
logText += `${t("vmLxc.logs.node")}: ${data.node}\n`
logText += `${t("vmLxc.logs.type")}: ${data.type}\n`
logText += `${t("vmLxc.logs.totalLines")}: ${data.log_lines}\n`
logText += `${t("vmLxc.logs.generated")}: ${new Date().toISOString()}\n`
logText += `\n${"=".repeat(80)}\n\n`
if (data.logs && Array.isArray(data.logs)) {
data.logs.forEach((log: any) => {
if (typeof log === "object" && log.t) {
logText += `${log.t}\n`
} else if (typeof log === "string") {
logText += `${log}\n`
}
})
}
const blob = new Blob([logText], { type: "text/plain" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = `${vmName}-${vmid}-logs.txt`
a.click()
URL.revokeObjectURL(url)
} catch (error) {
console.error("Error downloading logs:", error)
}
}
const getStatusColor = (status: string) => {
switch (status) {
case "running":
@@ -7852,25 +7815,6 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
</div>
</div>
{/* Notification Row */}
<div className="space-y-2">
<Label className="text-sm flex items-center gap-1.5">
<Bell className="h-3.5 w-3.5" />
{t("vmLxc.backupModal.notification")}
</Label>
<Select value={backupNotification} onValueChange={setBackupNotification}>
<SelectTrigger className="bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{t("vmLxc.backupModal.useGlobalSettings")}</SelectItem>
<SelectItem value="always">{t("vmLxc.backupModal.alwaysNotify")}</SelectItem>
<SelectItem value="failure">{t("vmLxc.backupModal.notifyOnFailure")}</SelectItem>
<SelectItem value="never">{t("vmLxc.backupModal.neverNotify")}</SelectItem>
</SelectContent>
</Select>
</div>
{/* Protected Checkbox */}
<div className="flex items-center space-x-2">
<Checkbox
+5 -5
View File
@@ -2105,7 +2105,7 @@
"webhookUrl": "Webhook-URL",
"enableEmail": "E-Mail aktivieren",
"smtpHost": "SMTP-Host",
"port": "Hafen",
"port": "Port",
"tlsMode": "TLS-Modus",
"noTls": "Keine (Port 25)",
"username": "Benutzername",
@@ -2578,9 +2578,9 @@
"twoFactor": {
"title": "Zwei-Faktor-Authentifizierung",
"hint": "Fügen Sie nach dem Passwort einen Einmalcode aus einer Authentifizierungs-App hinzu.",
"enable": "Aktivieren Sie 2FA",
"enable": "2FA aktivieren",
"enabled": "2FA ist aktiviert.",
"disable": "Deaktivieren Sie 2FA",
"disable": "2FA deaktivieren",
"disableTitle": "Deaktivieren Sie die Zwei-Faktor-Authentifizierung",
"disableDescription": "Bestätigen Sie mit Ihrem Passwort und einem aktuellen 2FA-Code oder Backup-Code.",
"codeOrBackup": "2FA-Code oder Backup-Code",
@@ -2723,7 +2723,7 @@
"interfaceOptional": "Schnittstelle (optional)",
"applyTo": "Gelten",
"commentOptional": "Kommentar (optional)",
"port": "Hafen",
"port": "Port",
"source": "Quelle",
"level": "Ebene",
"interface": "Schnittstelle",
@@ -3015,7 +3015,7 @@
"needQrOrKey": "Ein QR-Code oder manueller Schlüssel aus diesem Setup.",
"needBackupCodes": "Backup-Codes werden an einem sicheren Ort gespeichert.",
"starting": "Beginnt...",
"startSetup": "Starten Sie die Einrichtung",
"startSetup": "Einrichtung starten",
"scanTitle": "Scannen Sie den QR-Code",
"scanDescription": "Öffnen Sie Ihre Authentifizierungs-App und scannen Sie diesen Code.",
"qrCodeAlt": "2FA QR-Code",
+4 -4
View File
@@ -3013,7 +3013,7 @@
"youWillNeed": "Vous aurez besoin de :",
"needApp": "Une application d'authentification telle que Aegis, 1Password, Bitwarden ou Google Authenticator.",
"needQrOrKey": "Un code QR ou une clé manuelle de cette configuration.",
"needBackupCodes": "Codes de sauvegarde enregistrés dans un endroit sûr.",
"needBackupCodes": "Codes de secours enregistrés dans un endroit sûr.",
"starting": "Départ...",
"startSetup": "Démarrer la configuration",
"scanTitle": "Scannez le code QR",
@@ -3026,10 +3026,10 @@
"verifying": "Vérification...",
"verifyAndEnable": "Vérifier et activer",
"enabledTitle": "2FA est activé.",
"enabledDescription": "Enregistrez les codes de sauvegarde avant de fermer cette fenêtre.",
"saveCodesTitle": "Enregistrez vos codes de sauvegarde",
"enabledDescription": "Enregistrez les codes de secours avant de fermer cette fenêtre.",
"saveCodesTitle": "Enregistrez vos codes de secours",
"saveCodesDescription": "Chaque code peut être utilisé une fois si vous perdez l'accès à votre application d'authentification.",
"backupCodes": "Codes de sauvegarde",
"backupCodes": "Codes de secours",
"copyAll": "Copier tout",
"finish": "Finition"
},
+15 -15
View File
@@ -1086,7 +1086,7 @@
"start": "Iniciar",
"shutdown": "Desligar",
"reboot": "Reiniciar",
"forceStop": "Forçar parada",
"forceStop": "Forçar paragem",
"working": "Processando...",
"startPending": "Iniciando...",
"shutdownPending": "Desligando...",
@@ -1094,7 +1094,7 @@
"stopPending": "Parando..."
},
"confirm": {
"forceStopTitle": "Forçar parada",
"forceStopTitle": "Forçar paragem",
"rebootTitle": "Reinício",
"forceStopDescription": "Isso ignora a sequência de desligamento do sistema operacional convidado e pode corromper bancos de dados ou sistemas de arquivos em execução. O convidado é morto imediatamente.",
"rebootDescription": "Isso força uma reinicialização sem esperar que o sistema operacional convidado libere as gravações pendentes. Use um desligamento elegante quando possível.",
@@ -2108,8 +2108,8 @@
"port": "Porta",
"tlsMode": "Modo TLS",
"noTls": "Nenhum (porta 25)",
"username": "Nome de usuário",
"password": "Senha",
"username": "Nome de utilizador",
"password": "Palavra-passe",
"appPassword": "Senha do aplicativo",
"fromAddress": "Do endereço",
"toAddresses": "Para endereços (separados por vírgula)",
@@ -2369,8 +2369,8 @@
},
"login": {
"subtitle": "Faça login para acessar seu painel",
"username": "Nome de usuário",
"password": "Senha",
"username": "Nome de utilizador",
"password": "Palavra-passe",
"usernamePlaceholder": "Digite seu nome de usuário",
"passwordPlaceholder": "Digite sua senha",
"rememberMe": "Lembrar",
@@ -2411,9 +2411,9 @@
"skipFailed": "Falha ao ignorar a autenticação",
"savePreferenceFailed": "Falha ao salvar preferência",
"setupFailed": "Falha ao configurar a autenticação",
"username": "Nome de usuário",
"username": "Nome de utilizador",
"usernamePlaceholder": "Digite o nome de usuário",
"password": "Senha",
"password": "Palavra-passe",
"passwordPlaceholder": "Digite a senha",
"confirmPassword": "Confirme sua senha",
"confirmPasswordPlaceholder": "Confirme sua senha",
@@ -2556,9 +2556,9 @@
"enableHint": "Adicione um nome de usuário e uma senha antes de expor este painel fora da sua rede privada.",
"enable": "Habilitar autenticação",
"setupTitle": "Criar login",
"username": "Nome de usuário",
"username": "Nome de utilizador",
"usernamePlaceholder": "administrador",
"password": "Senha",
"password": "Palavra-passe",
"passwordPlaceholder": "Digite a senha",
"confirmPassword": "Confirme sua senha",
"confirmPasswordPlaceholder": "Digite a senha novamente",
@@ -2578,7 +2578,7 @@
"twoFactor": {
"title": "Autenticação de dois fatores",
"hint": "Adicione um código único de um aplicativo autenticador após a senha.",
"enable": "Habilitar 2FA",
"enable": "Ativar 2FA",
"enabled": "2FA está habilitado.",
"disable": "Desativar 2FA",
"disableTitle": "Desative a autenticação de dois fatores",
@@ -4283,7 +4283,7 @@
"onCalendarExpression": "Expressão OnCalendar",
"ownerLabel": "Proprietário",
"passphrase": "Senha",
"password": "Senha",
"password": "Palavra-passe",
"pathLabel": "Caminho",
"pbsRepository": "Repositório PBS",
"profileLabel": "Perfil",
@@ -4316,7 +4316,7 @@
"timeOfDay": "Hora do dia",
"type": "Tipo",
"used": "Usado",
"username": "Nome de usuário",
"username": "Nome de utilizador",
"vmsLxcs": "VMs/LXCs",
"when": "Quando",
"whenLabel": "Quando",
@@ -5001,7 +5001,7 @@
"hint": "PNG, JPEG, WebP ou GIF. Até 2 MB. Use uma imagem quadrada ou corte-a primeiro para obter o melhor resultado no cabeçalho."
},
"username": {
"label": "Nome de usuário",
"label": "Nome de utilizador",
"help": "Este é o nome de login. Para alterá-lo, desative a autenticação e configure-a novamente em Segurança."
},
"displayName": {
@@ -7234,7 +7234,7 @@
"latency": "Latência",
"event": "Evento",
"sourceIp": "IP de origem",
"username": "Nome de usuário",
"username": "Nome de utilizador",
"service": "Serviço",
"jail": "Jail",
"failures": "Falhas",
+2 -2
View File
@@ -2105,7 +2105,7 @@
"webhookUrl": "Webhook URL",
"enableEmail": "Aktivera e-post",
"smtpHost": "SMTP-värd",
"port": "Hamn",
"port": "Port",
"tlsMode": "TLS-läge",
"noTls": "Ingen (port 25)",
"username": "Användarnamn",
@@ -2723,7 +2723,7 @@
"interfaceOptional": "Gränssnitt (valfritt)",
"applyTo": "Ansök till",
"commentOptional": "Kommentar (valfritt)",
"port": "Hamn",
"port": "Port",
"source": "Källa",
"level": "Nivå",
"interface": "Gränssnitt",
+16 -5
View File
@@ -560,10 +560,12 @@ def totp_setup():
"""Initialize TOTP setup for a user"""
try:
token = request.headers.get('Authorization', '').replace('Bearer ', '')
username = auth_manager.verify_token(token)
username, scope = auth_manager.verify_token_full(token)
if not username:
return jsonify({"success": False, "message": "Unauthorized"}), 401
if scope != 'full_admin':
return jsonify({"success": False, "message": "This action requires a full_admin token"}), 403
success, secret, qr_code, backup_codes, message = auth_manager.setup_totp(username)
@@ -586,10 +588,12 @@ def totp_enable():
"""Enable TOTP after verification"""
try:
token = request.headers.get('Authorization', '').replace('Bearer ', '')
username = auth_manager.verify_token(token)
username, scope = auth_manager.verify_token_full(token)
if not username:
return jsonify({"success": False, "message": "Unauthorized"}), 401
if scope != 'full_admin':
return jsonify({"success": False, "message": "This action requires a full_admin token"}), 403
data = request.json
verification_token = data.get('token')
@@ -612,10 +616,12 @@ def totp_disable():
"""Disable TOTP (requires password confirmation)"""
try:
token = request.headers.get('Authorization', '').replace('Bearer ', '')
username = auth_manager.verify_token(token)
username, scope = auth_manager.verify_token_full(token)
if not username:
return jsonify({"success": False, "message": "Unauthorized"}), 401
if scope != 'full_admin':
return jsonify({"success": False, "message": "This action requires a full_admin token"}), 403
data = request.json or {}
password = data.get('password')
@@ -653,10 +659,12 @@ def generate_api_token():
if not token:
return jsonify({"success": False, "message": "Unauthorized. Please log in first."}), 401
username = auth_manager.verify_token(token)
username, scope = auth_manager.verify_token_full(token)
if not username:
return jsonify({"success": False, "message": "Invalid or expired session. Please log in again."}), 401
if scope != 'full_admin':
return jsonify({"success": False, "message": "This action requires a full_admin token"}), 403
data = request.json
password = data.get('password')
@@ -749,8 +757,11 @@ def revoke_api_token_route(token_id):
return jsonify({"success": False, "message": "Authentication is not configured"}), 400
token = request.headers.get('Authorization', '').replace('Bearer ', '')
if not token or not auth_manager.verify_token(token):
username, scope = auth_manager.verify_token_full(token) if token else (None, None)
if not username:
return jsonify({"success": False, "message": "Unauthorized"}), 401
if scope != 'full_admin':
return jsonify({"success": False, "message": "This action requires a full_admin token"}), 403
success, message = auth_manager.revoke_api_token(token_id)
+18 -4
View File
@@ -4549,6 +4549,8 @@ def get_storage_info():
disk_info['used'] = round(partition_usage.used / (1024**3), 1)
disk_info['available'] = round(partition_usage.free / (1024**3), 1)
disk_info['usage_percent'] = round(partition_usage.percent, 1)
disk_info['total_bytes'] = partition_usage.total
disk_info['used_bytes'] = partition_usage.used
except PermissionError:
continue
@@ -13095,7 +13097,6 @@ def api_create_backup(vmid):
mode = data.get('mode', 'snapshot') # snapshot, suspend, stop
compress = data.get('compress', 'zstd') # none, lzo, gzip, zstd
protected = data.get('protected', False) # True/False
notification = data.get('notification', 'auto') # always, failure, never, auto
notes = data.get('notes', '') # Backup notes/description
pbs_change_detection = data.get('pbs_change_detection', None) # default, legacy, data (for PBS + LXC)
@@ -13614,6 +13615,19 @@ def get_task_log(upid):
traceback.print_exc()
return jsonify({'error': str(e)}), 500
def _installed_proxmenux_version():
"""Installed ProxMenux version; a beta install keeps beta_version.txt."""
for name in ('beta_version.txt', 'version.txt'):
try:
with open(f'/usr/local/share/proxmenux/{name}') as fh:
version = fh.read().strip()
if version:
return version
except OSError:
continue
return ''
@app.route('/api/health', methods=['GET'])
@require_auth
def api_health():
@@ -13621,7 +13635,7 @@ def api_health():
return jsonify({
'status': 'healthy',
'timestamp': datetime.now().isoformat(),
'version': '1.2.2'
'version': _installed_proxmenux_version()
})
# ─── User-configurable health thresholds ─────────────────────────────────────
@@ -15047,11 +15061,11 @@ def api_prometheus():
disk_name = disk.get('name', 'unknown')
metrics.append(f'# HELP proxmox_disk_total_bytes Total disk space in bytes')
metrics.append(f'# TYPE proxmox_disk_total_bytes gauge')
metrics.append(f'proxmox_disk_total_bytes{{node="{node}",disk="{disk_name}"}} {disk.get("total", 0)} {timestamp}')
metrics.append(f'proxmox_disk_total_bytes{{node="{node}",disk="{disk_name}"}} {disk.get("total_bytes", 0)} {timestamp}')
metrics.append(f'# HELP proxmox_disk_used_bytes Used disk space in bytes')
metrics.append(f'# TYPE proxmox_disk_used_bytes gauge')
metrics.append(f'proxmox_disk_used_bytes{{node="{node}",disk="{disk_name}"}} {disk.get("used", 0)} {timestamp}')
metrics.append(f'proxmox_disk_used_bytes{{node="{node}",disk="{disk_name}"}} {disk.get("used_bytes", 0)} {timestamp}')
metrics.append(f'# HELP proxmox_disk_usage_percent Disk usage percentage')
metrics.append(f'# TYPE proxmox_disk_usage_percent gauge')
+3 -3
View File
@@ -1637,7 +1637,7 @@ class HealthMonitor:
mem_critical_samples = [
entry for entry in self.state_history[state_key]
if entry['mem_percent'] >= 90 and
if entry['mem_percent'] >= self.MEMORY_CRITICAL and
current_time - entry['time'] <= self.MEMORY_DURATION
]
@@ -1671,7 +1671,7 @@ class HealthMonitor:
oldest = min(s['time'] for s in mem_critical_samples)
actual_duration = int(current_time - oldest)
status = 'CRITICAL'
reason = f'RAM >90% sustained for {actual_duration}s'
reason = f'RAM >{self.MEMORY_CRITICAL}% sustained for {actual_duration}s'
elif swap_critical >= 2:
status = 'CRITICAL'
reason = (f'Memory pressure: swap {swap_percent:.0f}% used '
@@ -1691,7 +1691,7 @@ class HealthMonitor:
swap_total_gb = round(swap.total / (1024**3), 2)
# Determine per-sub-check status
ram_status = 'CRITICAL' if mem_percent >= 90 and mem_critical_count >= MEM_CRITICAL_MIN_SAMPLES else ('WARNING' if mem_percent >= self.MEMORY_WARNING and mem_warning_count >= MEM_WARNING_MIN_SAMPLES else 'OK')
ram_status = 'CRITICAL' if mem_percent >= self.MEMORY_CRITICAL and mem_critical_count >= MEM_CRITICAL_MIN_SAMPLES else ('WARNING' if mem_percent >= self.MEMORY_WARNING and mem_warning_count >= MEM_WARNING_MIN_SAMPLES else 'OK')
swap_status = 'CRITICAL' if swap_critical >= 2 else 'OK'
result = {
+9 -1
View File
@@ -8,6 +8,8 @@ from flask import request, jsonify, g
from functools import wraps
from auth_manager import load_auth_config, verify_token, verify_token_full
_READ_METHODS = ('GET', 'HEAD', 'OPTIONS')
def require_auth(f):
"""
@@ -64,13 +66,19 @@ def require_auth(f):
token = parts[1]
# Verify token
username = verify_token(token)
username, scope = verify_token_full(token)
if not username:
return jsonify({
"error": "Invalid or expired token",
"message": "Please log in again"
}), 401
# read_only tokens may only read; any method that can change
# state needs full_admin.
if request.method not in _READ_METHODS and scope != 'full_admin':
return jsonify({"error": "Insufficient scope",
"message": f"This action requires a full_admin token (your token: {scope})"}), 403
# Token is valid, allow access
return f(*args, **kwargs)
+3 -1
View File
@@ -3335,6 +3335,7 @@ def _ai_cache_key(title, body, ai_config, detail_level, use_emojis):
str(ai_config.get('ai_provider', '')), '\x1f',
str(ai_config.get('ai_model', '')), '\x1f',
str(ai_config.get('ai_language', '')), '\x1f',
str(ai_config.get('channel_type', '')).lower(), '\x1f',
detail_level, '\x1f', '1' if use_emojis else '0',
]
return _hash_ai_cache.sha256(''.join(parts).encode('utf-8', 'replace')).hexdigest()
@@ -3428,7 +3429,8 @@ def format_with_ai_full(title: str, body: str, severity: str,
channel_type = ai_config.get('channel_type', '').lower()
is_email = channel_type == 'email'
if is_email and detail_level == 'detailed' and body and len(body) > 50:
if (is_email and detail_level == 'detailed' and body and len(body) > 50
and result_body != body):
# Only append if original has substantial content
result_body += "\n\n" + "-" * 40 + "\n"
result_body += "Original message:\n"
@@ -109,7 +109,7 @@
"Regenera <code>initramfs</code> para todos los kernels, ejecuta <code>proxmox-boot-tool refresh</code> según la configuración de arranque y solicita reiniciar."
],
"lxcWarnTitle": "LXC con acceso a NVIDIA",
"lxcWarnBody": "Retirar el controlador del host interrumpe las apps LXC que usan NVIDIA, aunque conserven sus bibliotecas. Programa mantenimiento para Frigate, Plex, Jellyfin, Ollama y otras apps que dependan de la GPU."
"lxcWarnBody": "Eliminar el controlador del host interrumpe las apps LXC que usan NVIDIA, aunque conserven sus bibliotecas. Programa mantenimiento para Frigate, Plex, Jellyfin, Ollama y otras apps que dependan de la GPU."
},
"updates": {
"heading": "Avisos de actualizaciones",