update storage settings

This commit is contained in:
MacRimi
2026-03-19 19:07:26 +01:00
parent cefeac72fc
commit 876194cdc8
9 changed files with 668 additions and 63 deletions

View File

@@ -1426,13 +1426,34 @@ export function NotificationSettings() {
{config.ai_provider === "ollama" && ( {config.ai_provider === "ollama" && (
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs sm:text-sm text-foreground/80">Ollama URL</Label> <Label className="text-xs sm:text-sm text-foreground/80">Ollama URL</Label>
<Input <div className="flex items-center gap-2">
className="h-9 text-sm font-mono" <Input
placeholder="http://localhost:11434" className="h-9 text-sm font-mono flex-1"
value={config.ai_ollama_url} placeholder="http://localhost:11434"
onChange={e => updateConfig(p => ({ ...p, ai_ollama_url: e.target.value }))} value={config.ai_ollama_url}
disabled={!editMode} onChange={e => updateConfig(p => ({ ...p, ai_ollama_url: e.target.value }))}
/> disabled={!editMode}
/>
<Button
variant="outline"
size="sm"
className="h-9 px-3 shrink-0"
onClick={() => fetchOllamaModels(config.ai_ollama_url)}
disabled={!editMode || loadingOllamaModels || !config.ai_ollama_url}
>
{loadingOllamaModels ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<RefreshCw className="h-4 w-4 mr-1" />
Load
</>
)}
</Button>
</div>
{ollamaModels.length > 0 && (
<p className="text-xs text-green-500">{ollamaModels.length} models found</p>
)}
</div> </div>
)} )}
@@ -1495,38 +1516,28 @@ export function NotificationSettings() {
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs sm:text-sm text-foreground/80">Model</Label> <Label className="text-xs sm:text-sm text-foreground/80">Model</Label>
{config.ai_provider === "ollama" ? ( {config.ai_provider === "ollama" ? (
<div className="flex items-center gap-2"> <Select
<Select value={config.ai_model || ""}
value={config.ai_model || ""} onValueChange={v => updateConfig(p => ({ ...p, ai_model: v }))}
onValueChange={v => updateConfig(p => ({ ...p, ai_model: v }))} disabled={!editMode || loadingOllamaModels || ollamaModels.length === 0}
disabled={!editMode || loadingOllamaModels} >
> <SelectTrigger className="h-9 text-sm font-mono">
<SelectTrigger className="h-9 text-sm font-mono flex-1"> <SelectValue placeholder={ollamaModels.length === 0 ? "Click 'Load' to fetch models" : "Select model"}>
<SelectValue placeholder={loadingOllamaModels ? "Loading models..." : "Select model"}> {config.ai_model || (ollamaModels.length === 0 ? "Click 'Load' to fetch models" : "Select model")}
{config.ai_model || (loadingOllamaModels ? "Loading..." : "Select model")} </SelectValue>
</SelectValue> </SelectTrigger>
</SelectTrigger> <SelectContent>
<SelectContent> {ollamaModels.length > 0 ? (
{ollamaModels.length > 0 ? ( ollamaModels.map(m => (
ollamaModels.map(m => ( <SelectItem key={m} value={m} className="font-mono">{m}</SelectItem>
<SelectItem key={m} value={m} className="font-mono">{m}</SelectItem> ))
)) ) : (
) : ( <SelectItem value="_none" disabled className="text-muted-foreground">
<SelectItem value="_none" disabled className="text-muted-foreground"> No models loaded - click Load button
{loadingOllamaModels ? "Loading models..." : "No models found"} </SelectItem>
</SelectItem> )}
)} </SelectContent>
</SelectContent> </Select>
</Select>
<button
onClick={() => fetchOllamaModels(config.ai_ollama_url)}
disabled={!editMode || loadingOllamaModels}
className="h-9 w-9 flex items-center justify-center rounded-md border border-border hover:bg-muted transition-colors shrink-0 disabled:opacity-50"
title="Refresh models"
>
<RefreshCw className={`h-4 w-4 ${loadingOllamaModels ? 'animate-spin' : ''}`} />
</button>
</div>
) : ( ) : (
<div className="h-9 px-3 flex items-center rounded-md border border-border bg-muted/50 text-sm font-mono text-muted-foreground"> <div className="h-9 px-3 flex items-center rounded-md border border-border bg-muted/50 text-sm font-mono text-muted-foreground">
{AI_PROVIDERS.find(p => p.value === config.ai_provider)?.model || "default"} {AI_PROVIDERS.find(p => p.value === config.ai_provider)?.model || "default"}

View File

@@ -2,9 +2,10 @@
import { useState, useEffect } from "react" import { useState, useEffect } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
import { Wrench, Package, Ruler, HeartPulse, Cpu, MemoryStick, HardDrive, CircleDot, Network, Server, Settings2, FileText, RefreshCw, Shield, AlertTriangle, Info, Loader2, Check } from "lucide-react" import { Wrench, Package, Ruler, HeartPulse, Cpu, MemoryStick, HardDrive, CircleDot, Network, Server, Settings2, FileText, RefreshCw, Shield, AlertTriangle, Info, Loader2, Check, Database, CloudOff } from "lucide-react"
import { NotificationSettings } from "./notification-settings" import { NotificationSettings } from "./notification-settings"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
import { Switch } from "./ui/switch"
import { Input } from "./ui/input" import { Input } from "./ui/input"
import { Badge } from "./ui/badge" import { Badge } from "./ui/badge"
import { getNetworkUnit } from "../lib/format-network" import { getNetworkUnit } from "../lib/format-network"
@@ -47,6 +48,20 @@ interface ProxMenuxTool {
enabled: boolean enabled: boolean
} }
interface RemoteStorage {
name: string
type: string
status: string
total: number
used: number
available: number
percent: number
exclude_health: boolean
exclude_notifications: boolean
excluded_at?: string
reason?: string
}
export function Settings() { export function Settings() {
const [proxmenuxTools, setProxmenuxTools] = useState<ProxMenuxTool[]>([]) const [proxmenuxTools, setProxmenuxTools] = useState<ProxMenuxTool[]>([])
const [loadingTools, setLoadingTools] = useState(true) const [loadingTools, setLoadingTools] = useState(true)
@@ -61,11 +76,17 @@ export function Settings() {
const [savedAllHealth, setSavedAllHealth] = useState(false) const [savedAllHealth, setSavedAllHealth] = useState(false)
const [pendingChanges, setPendingChanges] = useState<Record<string, number>>({}) const [pendingChanges, setPendingChanges] = useState<Record<string, number>>({})
const [customValues, setCustomValues] = useState<Record<string, string>>({}) const [customValues, setCustomValues] = useState<Record<string, string>>({})
// Remote Storage Exclusions
const [remoteStorages, setRemoteStorages] = useState<RemoteStorage[]>([])
const [loadingStorages, setLoadingStorages] = useState(true)
const [savingStorage, setSavingStorage] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
loadProxmenuxTools() loadProxmenuxTools()
getUnitsSettings() getUnitsSettings()
loadHealthSettings() loadHealthSettings()
loadRemoteStorages()
}, []) }, [])
const loadProxmenuxTools = async () => { const loadProxmenuxTools = async () => {
@@ -114,6 +135,53 @@ export function Settings() {
} }
} }
const loadRemoteStorages = async () => {
try {
const data = await fetchApi("/api/health/remote-storages")
if (data.storages) {
setRemoteStorages(data.storages)
}
} catch (err) {
console.error("Failed to load remote storages:", err)
} finally {
setLoadingStorages(false)
}
}
const handleStorageExclusionChange = async (storageName: string, storageType: string, excludeHealth: boolean, excludeNotifications: boolean) => {
setSavingStorage(storageName)
try {
// If both are false, remove the exclusion
if (!excludeHealth && !excludeNotifications) {
await fetchApi(`/api/health/storage-exclusions/${encodeURIComponent(storageName)}`, {
method: "DELETE"
})
} else {
await fetchApi("/api/health/storage-exclusions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
storage_name: storageName,
storage_type: storageType,
exclude_health: excludeHealth,
exclude_notifications: excludeNotifications
})
})
}
// Update local state
setRemoteStorages(prev => prev.map(s =>
s.name === storageName
? { ...s, exclude_health: excludeHealth, exclude_notifications: excludeNotifications }
: s
))
} catch (err) {
console.error("Failed to update storage exclusion:", err)
} finally {
setSavingStorage(null)
}
}
const getSelectValue = (hours: number, key: string): string => { const getSelectValue = (hours: number, key: string): string => {
if (hours === -1) return "-1" if (hours === -1) return "-1"
const preset = SUPPRESSION_OPTIONS.find(o => o.value === String(hours)) const preset = SUPPRESSION_OPTIONS.find(o => o.value === String(hours))
@@ -439,6 +507,120 @@ export function Settings() {
</CardContent> </CardContent>
</Card> </Card>
{/* Remote Storage Exclusions */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Database className="h-5 w-5 text-purple-500" />
<CardTitle>Remote Storage Exclusions</CardTitle>
</div>
<CardDescription>
Exclude remote storages (PBS, NFS, CIFS, etc.) from health monitoring and notifications.
Use this for storages that are intentionally offline or have limited API access.
</CardDescription>
</CardHeader>
<CardContent>
{loadingStorages ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin h-8 w-8 border-4 border-purple-500 border-t-transparent rounded-full" />
</div>
) : remoteStorages.length === 0 ? (
<div className="text-center py-8">
<CloudOff className="h-12 w-12 text-muted-foreground mx-auto mb-3 opacity-50" />
<p className="text-muted-foreground">No remote storages detected</p>
<p className="text-sm text-muted-foreground mt-1">
PBS, NFS, CIFS, and other remote storages will appear here when configured
</p>
</div>
) : (
<div className="space-y-0">
{/* Header */}
<div className="grid grid-cols-[1fr_auto_auto] gap-4 pb-2 mb-1 border-b border-border">
<span className="text-xs font-medium text-muted-foreground">Storage</span>
<span className="text-xs font-medium text-muted-foreground text-center w-20">Health</span>
<span className="text-xs font-medium text-muted-foreground text-center w-20">Alerts</span>
</div>
{/* Storage rows */}
<div className="divide-y divide-border/50">
{remoteStorages.map((storage) => {
const isExcluded = storage.exclude_health || storage.exclude_notifications
const isSaving = savingStorage === storage.name
const isOffline = storage.status === 'error' || storage.total === 0
return (
<div key={storage.name} className="grid grid-cols-[1fr_auto_auto] gap-4 py-3 items-center">
<div className="flex items-center gap-3 min-w-0">
<div className={`w-2 h-2 rounded-full shrink-0 ${
isOffline ? 'bg-red-500' : 'bg-green-500'
}`} />
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">{storage.name}</span>
<Badge variant="outline" className="text-[10px] px-1.5 py-0 shrink-0">
{storage.type}
</Badge>
</div>
{isOffline && (
<p className="text-[11px] text-red-400 mt-0.5">Offline or unavailable</p>
)}
</div>
</div>
<div className="flex items-center justify-center w-20">
{isSaving ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
<Switch
checked={!storage.exclude_health}
onCheckedChange={(checked) => {
handleStorageExclusionChange(
storage.name,
storage.type,
!checked,
storage.exclude_notifications
)
}}
/>
)}
</div>
<div className="flex items-center justify-center w-20">
{isSaving ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
<Switch
checked={!storage.exclude_notifications}
onCheckedChange={(checked) => {
handleStorageExclusionChange(
storage.name,
storage.type,
storage.exclude_health,
!checked
)
}}
/>
)}
</div>
</div>
)
})}
</div>
{/* Info footer */}
<div className="flex items-start gap-2 mt-3 pt-3 border-t border-border">
<Info className="h-3.5 w-3.5 text-purple-400 shrink-0 mt-0.5" />
<p className="text-[11px] text-muted-foreground leading-relaxed">
<strong>Health:</strong> When OFF, the storage won't trigger warnings/critical alerts in the Health Monitor.
<br />
<strong>Alerts:</strong> When OFF, no notifications will be sent for this storage.
</p>
</div>
</div>
)}
</CardContent>
</Card>
{/* Notification Settings */} {/* Notification Settings */}
<NotificationSettings /> <NotificationSettings />

View File

@@ -674,11 +674,20 @@ export function StorageOverview() {
{proxmoxStorage.storage {proxmoxStorage.storage
.filter((storage) => storage && storage.name && storage.used >= 0 && storage.available >= 0) .filter((storage) => storage && storage.name && storage.used >= 0 && storage.available >= 0)
.sort((a, b) => a.name.localeCompare(b.name)) .sort((a, b) => a.name.localeCompare(b.name))
.map((storage) => ( .map((storage) => {
// Check if storage is excluded from monitoring
const isExcluded = storage.excluded === true
const hasError = storage.status === "error" && !isExcluded
return (
<div <div
key={storage.name} key={storage.name}
className={`border rounded-lg p-4 ${ className={`border rounded-lg p-4 ${
storage.status === "error" ? "border-red-500/50 bg-red-500/5" : "" hasError
? "border-red-500/50 bg-red-500/5"
: isExcluded
? "border-purple-500/30 bg-purple-500/5 opacity-75"
: ""
}`} }`}
> >
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
@@ -687,27 +696,40 @@ export function StorageOverview() {
<Database className="h-5 w-5 text-muted-foreground" /> <Database className="h-5 w-5 text-muted-foreground" />
<h3 className="font-semibold text-lg">{storage.name}</h3> <h3 className="font-semibold text-lg">{storage.name}</h3>
<Badge className={getStorageTypeBadge(storage.type)}>{storage.type}</Badge> <Badge className={getStorageTypeBadge(storage.type)}>{storage.type}</Badge>
{isExcluded && (
<Badge className="bg-purple-500/10 text-purple-400 border-purple-500/20 text-[10px]">
excluded
</Badge>
)}
</div> </div>
<div className="flex md:hidden items-center gap-2 flex-1"> <div className="flex md:hidden items-center gap-2 flex-1">
<Database className="h-5 w-5 text-muted-foreground flex-shrink-0" /> <Database className="h-5 w-5 text-muted-foreground flex-shrink-0" />
<Badge className={getStorageTypeBadge(storage.type)}>{storage.type}</Badge> <Badge className={getStorageTypeBadge(storage.type)}>{storage.type}</Badge>
<h3 className="font-semibold text-base flex-1 min-w-0 truncate">{storage.name}</h3> <h3 className="font-semibold text-base flex-1 min-w-0 truncate">{storage.name}</h3>
{getStatusIcon(storage.status)} {isExcluded ? (
<Badge className="bg-purple-500/10 text-purple-400 border-purple-500/20 text-[10px]">
excluded
</Badge>
) : (
getStatusIcon(storage.status)
)}
</div> </div>
{/* Desktop: Badge active + Porcentaje */} {/* Desktop: Badge active + Porcentaje */}
<div className="hidden md:flex items-center gap-2"> <div className="hidden md:flex items-center gap-2">
<Badge <Badge
className={ className={
storage.status === "active" isExcluded
? "bg-green-500/10 text-green-500 border-green-500/20" ? "bg-purple-500/10 text-purple-400 border-purple-500/20"
: storage.status === "error" : storage.status === "active"
? "bg-red-500/10 text-red-500 border-red-500/20" ? "bg-green-500/10 text-green-500 border-green-500/20"
: "bg-gray-500/10 text-gray-500 border-gray-500/20" : storage.status === "error"
? "bg-red-500/10 text-red-500 border-red-500/20"
: "bg-gray-500/10 text-gray-500 border-gray-500/20"
} }
> >
{storage.status} {isExcluded ? "not monitored" : storage.status}
</Badge> </Badge>
<span className="text-sm font-medium">{storage.percent}%</span> <span className="text-sm font-medium">{storage.percent}%</span>
</div> </div>
@@ -750,7 +772,8 @@ export function StorageOverview() {
</div> </div>
</div> </div>
</div> </div>
))} )
})}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -326,3 +326,133 @@ def save_health_settings():
}) })
except Exception as e: except Exception as e:
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500
# ── Remote Storage Exclusions Endpoints ──
@health_bp.route('/api/health/remote-storages', methods=['GET'])
def get_remote_storages():
"""
Get list of all remote storages with their exclusion status.
Remote storages are those that can be offline (PBS, NFS, CIFS, etc.)
"""
try:
from proxmox_storage_monitor import proxmox_storage_monitor
# Get current storage status
storage_status = proxmox_storage_monitor.get_storage_status()
all_storages = storage_status.get('available', []) + storage_status.get('unavailable', [])
# Filter to only remote types
remote_types = health_persistence.REMOTE_STORAGE_TYPES
remote_storages = [s for s in all_storages if s.get('type', '').lower() in remote_types]
# Get current exclusions
exclusions = {e['storage_name']: e for e in health_persistence.get_excluded_storages()}
# Combine info
result = []
for storage in remote_storages:
name = storage.get('name', '')
exclusion = exclusions.get(name, {})
result.append({
'name': name,
'type': storage.get('type', 'unknown'),
'status': storage.get('status', 'unknown'),
'total': storage.get('total', 0),
'used': storage.get('used', 0),
'available': storage.get('available', 0),
'percent': storage.get('percent', 0),
'exclude_health': exclusion.get('exclude_health', 0) == 1,
'exclude_notifications': exclusion.get('exclude_notifications', 0) == 1,
'excluded_at': exclusion.get('excluded_at'),
'reason': exclusion.get('reason')
})
return jsonify({
'storages': result,
'remote_types': list(remote_types)
})
except ImportError:
return jsonify({'error': 'Storage monitor not available', 'storages': []}), 200
except Exception as e:
return jsonify({'error': str(e)}), 500
@health_bp.route('/api/health/storage-exclusions', methods=['GET'])
def get_storage_exclusions():
"""Get all storage exclusions."""
try:
exclusions = health_persistence.get_excluded_storages()
return jsonify({'exclusions': exclusions})
except Exception as e:
return jsonify({'error': str(e)}), 500
@health_bp.route('/api/health/storage-exclusions', methods=['POST'])
def save_storage_exclusion():
"""
Add or update a storage exclusion.
Request body:
{
"storage_name": "pbs-backup",
"storage_type": "pbs",
"exclude_health": true,
"exclude_notifications": true,
"reason": "PBS server is offline daily"
}
"""
try:
data = request.get_json()
if not data or 'storage_name' not in data:
return jsonify({'error': 'storage_name is required'}), 400
storage_name = data['storage_name']
storage_type = data.get('storage_type', 'unknown')
exclude_health = data.get('exclude_health', True)
exclude_notifications = data.get('exclude_notifications', True)
reason = data.get('reason')
# Check if already excluded
existing = health_persistence.get_excluded_storages()
exists = any(e['storage_name'] == storage_name for e in existing)
if exists:
# Update existing
success = health_persistence.update_storage_exclusion(
storage_name, exclude_health, exclude_notifications
)
else:
# Add new
success = health_persistence.exclude_storage(
storage_name, storage_type, exclude_health, exclude_notifications, reason
)
if success:
return jsonify({
'success': True,
'message': f'Storage {storage_name} exclusion saved',
'storage_name': storage_name
})
else:
return jsonify({'error': 'Failed to save exclusion'}), 500
except Exception as e:
return jsonify({'error': str(e)}), 500
@health_bp.route('/api/health/storage-exclusions/<storage_name>', methods=['DELETE'])
def delete_storage_exclusion(storage_name):
"""Remove a storage from the exclusion list."""
try:
success = health_persistence.remove_storage_exclusion(storage_name)
if success:
return jsonify({
'success': True,
'message': f'Storage {storage_name} removed from exclusions'
})
else:
return jsonify({'error': 'Storage not found in exclusions'}), 404
except Exception as e:
return jsonify({'error': str(e)}), 500

View File

@@ -130,9 +130,10 @@ def get_ollama_models():
with urllib.request.urlopen(req, timeout=10) as resp: with urllib.request.urlopen(req, timeout=10) as resp:
result = json.loads(resp.read().decode('utf-8')) result = json.loads(resp.read().decode('utf-8'))
models = [m.get('name', '').split(':')[0] for m in result.get('models', [])] # Keep full model names (including tags like :latest, :3b-instruct-q4_0)
# Remove duplicates and sort models = [m.get('name', '') for m in result.get('models', []) if m.get('name')]
models = sorted(list(set(models))) # Sort alphabetically
models = sorted(models)
return jsonify({ return jsonify({
'success': True, 'success': True,
'models': models, 'models': models,

View File

@@ -2639,6 +2639,24 @@ def get_proxmox_storage():
for unavailable_storage in unavailable_storages: for unavailable_storage in unavailable_storages:
if unavailable_storage['name'] not in existing_storage_names: if unavailable_storage['name'] not in existing_storage_names:
storage_list.append(unavailable_storage) storage_list.append(unavailable_storage)
# Get storage exclusions to mark excluded storages
try:
excluded_health = health_persistence.get_excluded_storage_names('health')
remote_types = health_persistence.REMOTE_STORAGE_TYPES
for storage in storage_list:
storage_name = storage.get('name', '')
storage_type = storage.get('type', '').lower()
# Mark if this is a remote storage type
storage['is_remote'] = storage_type in remote_types
# Mark if excluded from health monitoring
storage['excluded'] = storage_name in excluded_health
except Exception:
# If exclusion check fails, continue without it
pass
return {'storage': storage_list} return {'storage': storage_list}

View File

@@ -4431,6 +4431,8 @@ class HealthMonitor:
Detects unavailable storages configured in PVE. Detects unavailable storages configured in PVE.
Returns CRITICAL if any configured storage is unavailable. Returns CRITICAL if any configured storage is unavailable.
Returns None if the module is not available. Returns None if the module is not available.
Respects storage exclusions: excluded storages are reported as INFO, not CRITICAL.
""" """
if not PROXMOX_STORAGE_AVAILABLE: if not PROXMOX_STORAGE_AVAILABLE:
return None return None
@@ -4443,12 +4445,22 @@ class HealthMonitor:
storage_status = proxmox_storage_monitor.get_storage_status() storage_status = proxmox_storage_monitor.get_storage_status()
unavailable_storages = storage_status.get('unavailable', []) unavailable_storages = storage_status.get('unavailable', [])
if not unavailable_storages: # Get excluded storage names for health monitoring
# All storages are available. We should also clear any previously recorded storage errors. excluded_names = health_persistence.get_excluded_storage_names('health')
# Separate excluded storages from real issues
excluded_unavailable = [s for s in unavailable_storages if s.get('name', '') in excluded_names]
real_unavailable = [s for s in unavailable_storages if s.get('name', '') not in excluded_names]
if not real_unavailable:
# All non-excluded storages are available. Clear any previously recorded storage errors.
active_errors = health_persistence.get_active_errors() active_errors = health_persistence.get_active_errors()
for error in active_errors: for error in active_errors:
if error.get('category') == 'storage' and error.get('error_key', '').startswith('storage_unavailable_'): if error.get('category') == 'storage' and error.get('error_key', '').startswith('storage_unavailable_'):
health_persistence.clear_error(error['error_key']) # Only clear if not an excluded storage
storage_name = error.get('error_key', '').replace('storage_unavailable_', '')
if storage_name not in excluded_names:
health_persistence.clear_error(error['error_key'])
# Build checks from all configured storages for descriptive display # Build checks from all configured storages for descriptive display
available_storages = storage_status.get('available', []) available_storages = storage_status.get('available', [])
@@ -4460,12 +4472,24 @@ class HealthMonitor:
'status': 'OK', 'status': 'OK',
'detail': f'{st_type} storage available' 'detail': f'{st_type} storage available'
} }
# Add excluded unavailable storages as INFO (not CRITICAL)
for st in excluded_unavailable:
st_name = st.get('name', 'unknown')
st_type = st.get('type', 'unknown')
checks[st_name] = {
'status': 'INFO',
'detail': f'{st_type} storage excluded from monitoring',
'excluded': True
}
if not checks: if not checks:
checks['proxmox_storages'] = {'status': 'OK', 'detail': 'All storages available'} checks['proxmox_storages'] = {'status': 'OK', 'detail': 'All storages available'}
return {'status': 'OK', 'checks': checks} return {'status': 'OK', 'checks': checks}
storage_details = {} storage_details = {}
for storage in unavailable_storages: # Only process non-excluded unavailable storages as errors
for storage in real_unavailable:
storage_name = storage['name'] storage_name = storage['name']
error_key = f'storage_unavailable_{storage_name}' error_key = f'storage_unavailable_{storage_name}'
status_detail = storage.get('status_detail', 'unavailable') status_detail = storage.get('status_detail', 'unavailable')
@@ -4508,6 +4532,17 @@ class HealthMonitor:
'detail': st_info.get('reason', 'Unavailable'), 'detail': st_info.get('reason', 'Unavailable'),
'dismissable': False 'dismissable': False
} }
# Add excluded unavailable storages as INFO (not as errors)
for st in excluded_unavailable:
st_name = st.get('name', 'unknown')
st_type = st.get('type', 'unknown')
checks[st_name] = {
'status': 'INFO',
'detail': f'{st_type} storage excluded from monitoring (offline)',
'excluded': True
}
# Also add available storages # Also add available storages
available_list = storage_status.get('available', []) available_list = storage_status.get('available', [])
unavail_names = {s['name'] for s in unavailable_storages} unavail_names = {s['name'] for s in unavailable_storages}
@@ -4518,12 +4553,21 @@ class HealthMonitor:
'detail': f'{st.get("type", "unknown")} storage available' 'detail': f'{st.get("type", "unknown")} storage available'
} }
return { # Determine overall status based on non-excluded issues only
'status': 'CRITICAL', if real_unavailable:
'reason': f'{len(unavailable_storages)} Proxmox storage(s) unavailable', return {
'details': storage_details, 'status': 'CRITICAL',
'checks': checks 'reason': f'{len(real_unavailable)} Proxmox storage(s) unavailable',
} 'details': storage_details,
'checks': checks
}
else:
# Only excluded storages are unavailable - this is OK
return {
'status': 'OK',
'reason': 'All monitored storages available',
'checks': checks
}
except Exception as e: except Exception as e:
print(f"[HealthMonitor] Error checking Proxmox storage: {e}") print(f"[HealthMonitor] Error checking Proxmox storage: {e}")

View File

@@ -235,6 +235,22 @@ class HealthPersistence:
cursor.execute('CREATE INDEX IF NOT EXISTS idx_obs_disk ON disk_observations(disk_registry_id)') cursor.execute('CREATE INDEX IF NOT EXISTS idx_obs_disk ON disk_observations(disk_registry_id)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_obs_dismissed ON disk_observations(dismissed)') cursor.execute('CREATE INDEX IF NOT EXISTS idx_obs_dismissed ON disk_observations(dismissed)')
# ── Remote Storage Exclusions System ──
# Allows users to permanently exclude remote storages (PBS, NFS, CIFS, etc.)
# from health monitoring and notifications
cursor.execute('''
CREATE TABLE IF NOT EXISTS excluded_storages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
storage_name TEXT UNIQUE NOT NULL,
storage_type TEXT NOT NULL,
excluded_at TEXT NOT NULL,
exclude_health INTEGER DEFAULT 1,
exclude_notifications INTEGER DEFAULT 1,
reason TEXT
)
''')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_excluded_storage ON excluded_storages(storage_name)')
conn.commit() conn.commit()
conn.close() conn.close()
@@ -1845,5 +1861,172 @@ class HealthPersistence:
return 0 return 0
# ── Remote Storage Exclusions Methods ──
# Types considered "remote" and eligible for exclusion
REMOTE_STORAGE_TYPES = {'pbs', 'nfs', 'cifs', 'glusterfs', 'iscsi', 'iscsidirect', 'cephfs', 'rbd'}
def is_remote_storage_type(self, storage_type: str) -> bool:
"""Check if a storage type is considered remote/external."""
return storage_type.lower() in self.REMOTE_STORAGE_TYPES
def get_excluded_storages(self) -> List[Dict[str, Any]]:
"""Get list of all excluded remote storages."""
try:
with self._db_connection(row_factory=True) as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT storage_name, storage_type, excluded_at,
exclude_health, exclude_notifications, reason
FROM excluded_storages
''')
return [dict(row) for row in cursor.fetchall()]
except Exception as e:
print(f"[HealthPersistence] Error getting excluded storages: {e}")
return []
def is_storage_excluded(self, storage_name: str, check_type: str = 'health') -> bool:
"""
Check if a storage is excluded from monitoring.
Args:
storage_name: Name of the storage
check_type: 'health' or 'notifications'
Returns:
True if storage is excluded for the given check type
"""
try:
with self._db_connection() as conn:
cursor = conn.cursor()
column = 'exclude_health' if check_type == 'health' else 'exclude_notifications'
cursor.execute(f'''
SELECT {column} FROM excluded_storages
WHERE storage_name = ?
''', (storage_name,))
row = cursor.fetchone()
return row is not None and row[0] == 1
except Exception:
return False
def exclude_storage(self, storage_name: str, storage_type: str,
exclude_health: bool = True, exclude_notifications: bool = True,
reason: str = None) -> bool:
"""
Add a storage to the exclusion list.
Args:
storage_name: Name of the storage to exclude
storage_type: Type of storage (pbs, nfs, etc.)
exclude_health: Whether to exclude from health monitoring
exclude_notifications: Whether to exclude from notifications
reason: Optional reason for exclusion
Returns:
True if successfully excluded
"""
try:
now = datetime.now().isoformat()
with self._db_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
INSERT INTO excluded_storages
(storage_name, storage_type, excluded_at, exclude_health, exclude_notifications, reason)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(storage_name) DO UPDATE SET
exclude_health = excluded.exclude_health,
exclude_notifications = excluded.exclude_notifications,
reason = excluded.reason
''', (storage_name, storage_type, now,
1 if exclude_health else 0,
1 if exclude_notifications else 0,
reason))
conn.commit()
return True
except Exception as e:
print(f"[HealthPersistence] Error excluding storage: {e}")
return False
def update_storage_exclusion(self, storage_name: str,
exclude_health: Optional[bool] = None,
exclude_notifications: Optional[bool] = None) -> bool:
"""
Update exclusion settings for a storage.
Args:
storage_name: Name of the storage
exclude_health: New value for health exclusion (None = don't change)
exclude_notifications: New value for notifications exclusion (None = don't change)
Returns:
True if successfully updated
"""
try:
with self._db_connection() as conn:
cursor = conn.cursor()
updates = []
values = []
if exclude_health is not None:
updates.append('exclude_health = ?')
values.append(1 if exclude_health else 0)
if exclude_notifications is not None:
updates.append('exclude_notifications = ?')
values.append(1 if exclude_notifications else 0)
if not updates:
return True
values.append(storage_name)
cursor.execute(f'''
UPDATE excluded_storages
SET {', '.join(updates)}
WHERE storage_name = ?
''', values)
conn.commit()
return cursor.rowcount > 0
except Exception as e:
print(f"[HealthPersistence] Error updating storage exclusion: {e}")
return False
def remove_storage_exclusion(self, storage_name: str) -> bool:
"""Remove a storage from the exclusion list."""
try:
with self._db_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
DELETE FROM excluded_storages WHERE storage_name = ?
''', (storage_name,))
conn.commit()
return cursor.rowcount > 0
except Exception as e:
print(f"[HealthPersistence] Error removing storage exclusion: {e}")
return False
def get_excluded_storage_names(self, check_type: str = 'health') -> set:
"""
Get set of storage names excluded for a specific check type.
Args:
check_type: 'health' or 'notifications'
Returns:
Set of excluded storage names
"""
try:
with self._db_connection() as conn:
cursor = conn.cursor()
column = 'exclude_health' if check_type == 'health' else 'exclude_notifications'
cursor.execute(f'''
SELECT storage_name FROM excluded_storages
WHERE {column} = 1
''')
return {row[0] for row in cursor.fetchall()}
except Exception:
return set()
# Global instance # Global instance
health_persistence = HealthPersistence() health_persistence = HealthPersistence()

View File

@@ -648,6 +648,19 @@ class NotificationManager:
if self._is_backup_running(): if self._is_backup_running():
return return
# Check storage exclusions for storage-related events.
# If the storage is excluded from notifications, suppress the event entirely.
_STORAGE_EVENTS = {'storage_unavailable', 'storage_low_space', 'storage_warning', 'storage_error'}
if event.event_type in _STORAGE_EVENTS:
storage_name = event.data.get('storage_name') or event.data.get('name')
if storage_name:
try:
from health_persistence import health_persistence
if health_persistence.is_storage_excluded(storage_name, 'notifications'):
return # Storage is excluded from notifications, skip silently
except Exception:
pass # Continue if check fails
# Check cooldown # Check cooldown
if not self._check_cooldown(event): if not self._check_cooldown(event):
return return