mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-04-25 08:56:21 +00:00
Update scripts
This commit is contained in:
@@ -7,7 +7,7 @@ import { Button } from "./ui/button"
|
||||
import { Input } from "./ui/input"
|
||||
import { Label } from "./ui/label"
|
||||
import { Checkbox } from "./ui/checkbox"
|
||||
import { Lock, User, AlertCircle, Server, Shield } from "lucide-react"
|
||||
import { Lock, User, AlertCircle, Server, Shield, Eye, EyeOff } from "lucide-react"
|
||||
import { getApiUrl } from "../lib/api-config"
|
||||
import Image from "next/image"
|
||||
|
||||
@@ -21,6 +21,7 @@ export function Login({ onLogin }: LoginProps) {
|
||||
const [totpCode, setTotpCode] = useState("")
|
||||
const [requiresTotp, setRequiresTotp] = useState(false)
|
||||
const [rememberMe, setRememberMe] = useState(false)
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
@@ -161,14 +162,27 @@ export function Login({ onLogin }: LoginProps) {
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="login-password"
|
||||
type="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="pl-10 text-base"
|
||||
className="pl-10 pr-10 text-base"
|
||||
disabled={loading}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
disabled={loading}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { HardDrive, Database, AlertTriangle, CheckCircle2, XCircle, Square, Thermometer, Archive, Info, Clock, Usb } from "lucide-react"
|
||||
import { HardDrive, Database, AlertTriangle, CheckCircle2, XCircle, Square, Thermometer, Archive, Info, Clock, Usb, Server, Activity, FileText, Play, Loader2 } from "lucide-react"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
|
||||
interface DiskInfo {
|
||||
@@ -44,6 +45,8 @@ interface DiskInfo {
|
||||
observations_count?: number
|
||||
connection_type?: 'usb' | 'sata' | 'nvme' | 'sas' | 'internal' | 'unknown'
|
||||
removable?: boolean
|
||||
is_system_disk?: boolean
|
||||
system_usage?: string[]
|
||||
}
|
||||
|
||||
interface DiskObservation {
|
||||
@@ -118,6 +121,7 @@ export function StorageOverview() {
|
||||
const [detailsOpen, setDetailsOpen] = useState(false)
|
||||
const [diskObservations, setDiskObservations] = useState<DiskObservation[]>([])
|
||||
const [loadingObservations, setLoadingObservations] = useState(false)
|
||||
const [activeModalTab, setActiveModalTab] = useState<"overview" | "smart">("overview")
|
||||
|
||||
const fetchStorageData = async () => {
|
||||
try {
|
||||
@@ -838,12 +842,18 @@ export function StorageOverview() {
|
||||
>
|
||||
<div className="space-y-2 mb-3">
|
||||
{/* Row 1: Device name and type badge */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<HardDrive className="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
||||
<h3 className="font-semibold">/dev/{disk.name}</h3>
|
||||
<Badge className={getDiskTypeBadge(disk.name, disk.rotation_rate).className}>
|
||||
{getDiskTypeBadge(disk.name, disk.rotation_rate).label}
|
||||
</Badge>
|
||||
{disk.is_system_disk && (
|
||||
<Badge className="bg-orange-500/10 text-orange-500 border-orange-500/20 gap-1">
|
||||
<Server className="h-3 w-3" />
|
||||
System
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Row 2: Model, temperature, and health status */}
|
||||
@@ -930,6 +940,12 @@ export function StorageOverview() {
|
||||
<Badge className={getDiskTypeBadge(disk.name, disk.rotation_rate).className}>
|
||||
{getDiskTypeBadge(disk.name, disk.rotation_rate).label}
|
||||
</Badge>
|
||||
{disk.is_system_disk && (
|
||||
<Badge className="bg-orange-500/10 text-orange-500 border-orange-500/20 gap-1">
|
||||
<Server className="h-3 w-3" />
|
||||
System
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Row 2: Model, temperature, and health status */}
|
||||
@@ -1187,9 +1203,12 @@ export function StorageOverview() {
|
||||
)}
|
||||
|
||||
{/* Disk Details Dialog */}
|
||||
<Dialog open={detailsOpen} onOpenChange={setDetailsOpen}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] sm:max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<Dialog open={detailsOpen} onOpenChange={(open) => {
|
||||
setDetailsOpen(open)
|
||||
if (!open) setActiveModalTab("overview")
|
||||
}}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] sm:max-h-[85vh] overflow-hidden flex flex-col p-0">
|
||||
<DialogHeader className="px-6 pt-6 pb-0">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{selectedDisk?.connection_type === 'usb' ? (
|
||||
<Usb className="h-5 w-5 text-orange-400" />
|
||||
@@ -1200,10 +1219,47 @@ export function StorageOverview() {
|
||||
{selectedDisk?.connection_type === 'usb' && (
|
||||
<Badge className="bg-orange-500/10 text-orange-400 border-orange-500/20 text-[10px] px-1.5">USB</Badge>
|
||||
)}
|
||||
{selectedDisk?.is_system_disk && (
|
||||
<Badge className="bg-orange-500/10 text-orange-500 border-orange-500/20 gap-1">
|
||||
<Server className="h-3 w-3" />
|
||||
System
|
||||
</Badge>
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>Complete SMART information and health status</DialogDescription>
|
||||
<DialogDescription>
|
||||
{selectedDisk?.model !== "Unknown" ? selectedDisk?.model : "Physical disk"} - {selectedDisk?.size_formatted}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{selectedDisk && (
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex border-b border-border px-6">
|
||||
<button
|
||||
onClick={() => setActiveModalTab("overview")}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
||||
activeModalTab === "overview"
|
||||
? "border-blue-500 text-blue-500"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Info className="h-4 w-4" />
|
||||
Overview
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveModalTab("smart")}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
||||
activeModalTab === "smart"
|
||||
? "border-green-500 text-green-500"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Activity className="h-4 w-4" />
|
||||
SMART Test
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 min-h-0">
|
||||
{selectedDisk && activeModalTab === "overview" && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
@@ -1402,6 +1458,745 @@ export function StorageOverview() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SMART Test Tab */}
|
||||
{selectedDisk && activeModalTab === "smart" && (
|
||||
<SmartTestTab disk={selectedDisk} />
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// SMART Test Tab Component
|
||||
interface SmartTestTabProps {
|
||||
disk: DiskInfo
|
||||
}
|
||||
|
||||
interface SmartTestStatus {
|
||||
status: 'idle' | 'running' | 'completed' | 'failed'
|
||||
test_type?: string
|
||||
progress?: number
|
||||
result?: string
|
||||
last_test?: {
|
||||
type: string
|
||||
status: string
|
||||
timestamp: string
|
||||
duration?: string
|
||||
}
|
||||
smart_data?: {
|
||||
device: string
|
||||
model: string
|
||||
serial: string
|
||||
firmware: string
|
||||
smart_status: string
|
||||
temperature: number
|
||||
power_on_hours: number
|
||||
attributes: Array<{
|
||||
id: number
|
||||
name: string
|
||||
value: number
|
||||
worst: number
|
||||
threshold: number
|
||||
raw_value: string
|
||||
status: 'ok' | 'warning' | 'critical'
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
function SmartTestTab({ disk }: SmartTestTabProps) {
|
||||
const [testStatus, setTestStatus] = useState<SmartTestStatus>({ status: 'idle' })
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [runningTest, setRunningTest] = useState<'short' | 'long' | null>(null)
|
||||
const [showReport, setShowReport] = useState(false)
|
||||
const [reportTab, setReportTab] = useState<'overview' | 'attributes' | 'history' | 'recommendations'>('overview')
|
||||
|
||||
// Fetch current SMART status on mount
|
||||
useEffect(() => {
|
||||
fetchSmartStatus()
|
||||
}, [disk.name])
|
||||
|
||||
const fetchSmartStatus = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const data = await fetchApi<SmartTestStatus>(`/api/storage/smart/${disk.name}`)
|
||||
setTestStatus(data)
|
||||
} catch {
|
||||
setTestStatus({ status: 'idle' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const runSmartTest = async (testType: 'short' | 'long') => {
|
||||
try {
|
||||
setRunningTest(testType)
|
||||
await fetchApi(`/api/storage/smart/${disk.name}/test`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ test_type: testType })
|
||||
})
|
||||
// Poll for status updates
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const data = await fetchApi<SmartTestStatus>(`/api/storage/smart/${disk.name}`)
|
||||
setTestStatus(data)
|
||||
if (data.status !== 'running') {
|
||||
clearInterval(pollInterval)
|
||||
setRunningTest(null)
|
||||
}
|
||||
} catch {
|
||||
clearInterval(pollInterval)
|
||||
setRunningTest(null)
|
||||
}
|
||||
}, 5000)
|
||||
} catch {
|
||||
setRunningTest(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-3">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">Loading SMART data...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Quick Actions */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-semibold flex items-center gap-2">
|
||||
<Play className="h-4 w-4" />
|
||||
Run SMART Test
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => runSmartTest('short')}
|
||||
disabled={runningTest !== null}
|
||||
className="gap-2"
|
||||
>
|
||||
{runningTest === 'short' ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Activity className="h-4 w-4" />
|
||||
)}
|
||||
Short Test (~2 min)
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => runSmartTest('long')}
|
||||
disabled={runningTest !== null}
|
||||
className="gap-2"
|
||||
>
|
||||
{runningTest === 'long' ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Activity className="h-4 w-4" />
|
||||
)}
|
||||
Extended Test (background)
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchSmartStatus}
|
||||
disabled={runningTest !== null}
|
||||
className="gap-2"
|
||||
>
|
||||
<Activity className="h-4 w-4" />
|
||||
Refresh Status
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Short test takes ~2 minutes. Extended test runs in the background and can take several hours for large disks.
|
||||
You will receive a notification when the test completes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Test Progress */}
|
||||
{testStatus.status === 'running' && (
|
||||
<div className="border rounded-lg p-4 bg-blue-500/5 border-blue-500/20">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-blue-500" />
|
||||
<div>
|
||||
<p className="font-medium text-blue-500">
|
||||
{testStatus.test_type === 'short' ? 'Short' : 'Extended'} test in progress
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Please wait while the test completes...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{testStatus.progress !== undefined && (
|
||||
<Progress value={testStatus.progress} className="h-2 [&>div]:bg-blue-500" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Last Test Result */}
|
||||
{testStatus.last_test && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-semibold flex items-center gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
Last Test Result
|
||||
</h4>
|
||||
<div className={`border rounded-lg p-4 ${
|
||||
testStatus.last_test.status === 'passed'
|
||||
? 'bg-green-500/5 border-green-500/20'
|
||||
: 'bg-red-500/5 border-red-500/20'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{testStatus.last_test.status === 'passed' ? (
|
||||
<CheckCircle2 className="h-5 w-5 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="h-5 w-5 text-red-500" />
|
||||
)}
|
||||
<span className="font-medium">
|
||||
{testStatus.last_test.type === 'short' ? 'Short' : 'Extended'} Test - {' '}
|
||||
{testStatus.last_test.status === 'passed' ? 'Passed' : 'Failed'}
|
||||
</span>
|
||||
</div>
|
||||
<Badge className={testStatus.last_test.status === 'passed'
|
||||
? 'bg-green-500/10 text-green-500 border-green-500/20'
|
||||
: 'bg-red-500/10 text-red-500 border-red-500/20'
|
||||
}>
|
||||
{testStatus.last_test.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Completed</p>
|
||||
<p className="font-medium">{testStatus.last_test.timestamp}</p>
|
||||
</div>
|
||||
{testStatus.last_test.duration && (
|
||||
<div>
|
||||
<p className="text-muted-foreground">Duration</p>
|
||||
<p className="font-medium">{testStatus.last_test.duration}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SMART Attributes Summary */}
|
||||
{testStatus.smart_data?.attributes && testStatus.smart_data.attributes.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-semibold flex items-center gap-2">
|
||||
<Activity className="h-4 w-4" />
|
||||
SMART Attributes
|
||||
</h4>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div className="grid grid-cols-12 gap-2 p-3 bg-muted/30 text-xs font-medium text-muted-foreground">
|
||||
<div className="col-span-1">ID</div>
|
||||
<div className="col-span-5">Attribute</div>
|
||||
<div className="col-span-2 text-center">Value</div>
|
||||
<div className="col-span-2 text-center">Worst</div>
|
||||
<div className="col-span-2 text-center">Status</div>
|
||||
</div>
|
||||
<div className="divide-y divide-border max-h-[200px] overflow-y-auto">
|
||||
{testStatus.smart_data.attributes.slice(0, 15).map((attr) => (
|
||||
<div key={attr.id} className="grid grid-cols-12 gap-2 p-3 text-sm items-center">
|
||||
<div className="col-span-1 text-muted-foreground">{attr.id}</div>
|
||||
<div className="col-span-5 truncate" title={attr.name}>{attr.name}</div>
|
||||
<div className="col-span-2 text-center font-mono">{attr.value}</div>
|
||||
<div className="col-span-2 text-center font-mono text-muted-foreground">{attr.worst}</div>
|
||||
<div className="col-span-2 text-center">
|
||||
{attr.status === 'ok' ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500 mx-auto" />
|
||||
) : attr.status === 'warning' ? (
|
||||
<AlertTriangle className="h-4 w-4 text-yellow-500 mx-auto" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 text-red-500 mx-auto" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* View Full Report Button */}
|
||||
<div className="pt-4 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full gap-2"
|
||||
onClick={() => setShowReport(true)}
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
View Full SMART Report
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground text-center mt-2">
|
||||
Generate a comprehensive professional report with detailed analysis and recommendations.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Full SMART Report Dialog */}
|
||||
<Dialog open={showReport} onOpenChange={setShowReport}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden flex flex-col p-0">
|
||||
<DialogHeader className="px-6 pt-6 pb-0">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
SMART Health Report: /dev/{disk.name}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Comprehensive analysis of disk health, SMART attributes, and recommendations
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Report Tabs */}
|
||||
<div className="flex border-b border-border px-6 overflow-x-auto">
|
||||
<button
|
||||
onClick={() => setReportTab('overview')}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px whitespace-nowrap ${
|
||||
reportTab === 'overview'
|
||||
? "border-blue-500 text-blue-500"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Info className="h-4 w-4" />
|
||||
Overview
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setReportTab('attributes')}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px whitespace-nowrap ${
|
||||
reportTab === 'attributes'
|
||||
? "border-purple-500 text-purple-500"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Activity className="h-4 w-4" />
|
||||
Attributes
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setReportTab('history')}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px whitespace-nowrap ${
|
||||
reportTab === 'history'
|
||||
? "border-amber-500 text-amber-500"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Clock className="h-4 w-4" />
|
||||
History
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setReportTab('recommendations')}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px whitespace-nowrap ${
|
||||
reportTab === 'recommendations'
|
||||
? "border-green-500 text-green-500"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
Recommendations
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Report Content */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 min-h-0">
|
||||
{/* Overview Tab */}
|
||||
{reportTab === 'overview' && (
|
||||
<div className="space-y-6">
|
||||
{/* Health Score Card */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className={`border rounded-lg p-4 ${
|
||||
testStatus.smart_status === 'passed'
|
||||
? 'bg-green-500/5 border-green-500/20'
|
||||
: testStatus.smart_status === 'failed'
|
||||
? 'bg-red-500/5 border-red-500/20'
|
||||
: 'bg-yellow-500/5 border-yellow-500/20'
|
||||
}`}>
|
||||
<p className="text-sm text-muted-foreground mb-1">Overall Health</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{testStatus.smart_status === 'passed' ? (
|
||||
<CheckCircle2 className="h-6 w-6 text-green-500" />
|
||||
) : testStatus.smart_status === 'failed' ? (
|
||||
<XCircle className="h-6 w-6 text-red-500" />
|
||||
) : (
|
||||
<AlertTriangle className="h-6 w-6 text-yellow-500" />
|
||||
)}
|
||||
<span className="text-xl font-bold capitalize">
|
||||
{testStatus.smart_status || 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg p-4 bg-muted/20">
|
||||
<p className="text-sm text-muted-foreground mb-1">Temperature</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Thermometer className="h-6 w-6 text-blue-500" />
|
||||
<span className="text-xl font-bold">
|
||||
{disk.temperature > 0 ? `${disk.temperature}°C` : 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg p-4 bg-muted/20">
|
||||
<p className="text-sm text-muted-foreground mb-1">Power On Time</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-6 w-6 text-purple-500" />
|
||||
<span className="text-xl font-bold">
|
||||
{disk.power_on_hours ? `${disk.power_on_hours.toLocaleString()}h` : 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Executive Summary */}
|
||||
<div className="border rounded-lg p-4">
|
||||
<h4 className="font-semibold mb-3 flex items-center gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
Executive Summary
|
||||
</h4>
|
||||
<div className="prose prose-sm prose-invert max-w-none">
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
{testStatus.smart_status === 'passed' ? (
|
||||
<>
|
||||
This disk is operating within normal parameters. All SMART attributes are within acceptable thresholds,
|
||||
indicating good health. The disk has been powered on for approximately{' '}
|
||||
<span className="text-foreground font-medium">
|
||||
{disk.power_on_hours ? `${Math.round(disk.power_on_hours / 24)} days` : 'an unknown period'}
|
||||
</span>{' '}
|
||||
and is currently operating at{' '}
|
||||
<span className="text-foreground font-medium">{disk.temperature || 'N/A'}°C</span>.
|
||||
{disk.reallocated_sectors === 0 && disk.pending_sectors === 0
|
||||
? ' No bad sectors have been detected.'
|
||||
: disk.reallocated_sectors && disk.reallocated_sectors > 0
|
||||
? ` ${disk.reallocated_sectors} sectors have been reallocated, which may indicate early signs of wear.`
|
||||
: ''}
|
||||
</>
|
||||
) : testStatus.smart_status === 'failed' ? (
|
||||
<>
|
||||
<span className="text-red-400 font-medium">Warning: This disk has failed SMART health assessment.</span>{' '}
|
||||
One or more critical SMART attributes have exceeded their failure threshold.
|
||||
It is strongly recommended to backup all data immediately and consider replacing this disk.
|
||||
{disk.reallocated_sectors && disk.reallocated_sectors > 0
|
||||
? ` The disk has ${disk.reallocated_sectors} reallocated sectors, indicating physical media degradation.`
|
||||
: ''}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
The disk health status could not be fully determined. Some SMART attributes may be showing warning signs.
|
||||
It is recommended to run a full SMART self-test and monitor the disk closely.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Key Metrics */}
|
||||
<div className="border rounded-lg p-4">
|
||||
<h4 className="font-semibold mb-3">Key Metrics</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Model</p>
|
||||
<p className="font-medium">{disk.model || 'Unknown'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Serial</p>
|
||||
<p className="font-medium font-mono text-xs">{disk.serial?.replace(/\\x[0-9a-fA-F]{2}/g, '') || 'Unknown'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Capacity</p>
|
||||
<p className="font-medium">{disk.size_formatted || 'Unknown'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Power Cycles</p>
|
||||
<p className="font-medium">{disk.power_cycles?.toLocaleString() || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Reallocated Sectors</p>
|
||||
<p className={`font-medium ${disk.reallocated_sectors && disk.reallocated_sectors > 0 ? 'text-yellow-500' : ''}`}>
|
||||
{disk.reallocated_sectors ?? 0}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Pending Sectors</p>
|
||||
<p className={`font-medium ${disk.pending_sectors && disk.pending_sectors > 0 ? 'text-yellow-500' : ''}`}>
|
||||
{disk.pending_sectors ?? 0}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">CRC Errors</p>
|
||||
<p className={`font-medium ${disk.crc_errors && disk.crc_errors > 0 ? 'text-yellow-500' : ''}`}>
|
||||
{disk.crc_errors ?? 0}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Disk Type</p>
|
||||
<p className="font-medium">
|
||||
{disk.name.startsWith('nvme') ? 'NVMe' : !disk.rotation_rate || disk.rotation_rate === 0 ? 'SSD' : 'HDD'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Attributes Tab */}
|
||||
{reportTab === 'attributes' && (
|
||||
<div className="space-y-4">
|
||||
<div className="border rounded-lg p-4 bg-muted/20">
|
||||
<h4 className="font-semibold mb-2">Understanding SMART Attributes</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
SMART (Self-Monitoring, Analysis and Reporting Technology) attributes are sensors built into hard drives and SSDs.
|
||||
Each attribute has a current value, a worst recorded value, and a threshold. When the current value drops below the threshold,
|
||||
the attribute is considered failed. Values typically decrease from 100 (or 200/253 on some drives) as the attribute degrades.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{testStatus.smart_data?.attributes && testStatus.smart_data.attributes.length > 0 ? (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div className="grid grid-cols-12 gap-2 p-3 bg-muted/30 text-xs font-medium text-muted-foreground">
|
||||
<div className="col-span-1">ID</div>
|
||||
<div className="col-span-4">Attribute Name</div>
|
||||
<div className="col-span-2 text-center">Value</div>
|
||||
<div className="col-span-2 text-center">Worst</div>
|
||||
<div className="col-span-2 text-center">Threshold</div>
|
||||
<div className="col-span-1 text-center">Status</div>
|
||||
</div>
|
||||
<div className="divide-y divide-border max-h-[400px] overflow-y-auto">
|
||||
{testStatus.smart_data.attributes.map((attr) => (
|
||||
<div key={attr.id} className={`grid grid-cols-12 gap-2 p-3 text-sm items-center ${
|
||||
attr.status === 'critical' ? 'bg-red-500/5' : attr.status === 'warning' ? 'bg-yellow-500/5' : ''
|
||||
}`}>
|
||||
<div className="col-span-1 text-muted-foreground font-mono">{attr.id}</div>
|
||||
<div className="col-span-4">
|
||||
<p className="truncate font-medium" title={attr.name}>{attr.name.replace(/_/g, ' ')}</p>
|
||||
<p className="text-xs text-muted-foreground">Raw: {attr.raw_value}</p>
|
||||
</div>
|
||||
<div className="col-span-2 text-center font-mono font-medium">{attr.value}</div>
|
||||
<div className="col-span-2 text-center font-mono text-muted-foreground">{attr.worst}</div>
|
||||
<div className="col-span-2 text-center font-mono text-muted-foreground">{attr.threshold}</div>
|
||||
<div className="col-span-1 text-center">
|
||||
{attr.status === 'ok' ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500 mx-auto" />
|
||||
) : attr.status === 'warning' ? (
|
||||
<AlertTriangle className="h-4 w-4 text-yellow-500 mx-auto" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 text-red-500 mx-auto" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Activity className="h-12 w-12 mx-auto mb-3 opacity-50" />
|
||||
<p>No SMART attribute data available.</p>
|
||||
<p className="text-sm mt-1">Run a SMART test to collect attribute data.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* History Tab */}
|
||||
{reportTab === 'history' && (
|
||||
<div className="space-y-4">
|
||||
{testStatus.last_test ? (
|
||||
<div className={`border rounded-lg p-4 ${
|
||||
testStatus.last_test.status === 'passed'
|
||||
? 'bg-green-500/5 border-green-500/20'
|
||||
: 'bg-red-500/5 border-red-500/20'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="font-semibold flex items-center gap-2">
|
||||
{testStatus.last_test.status === 'passed' ? (
|
||||
<CheckCircle2 className="h-5 w-5 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="h-5 w-5 text-red-500" />
|
||||
)}
|
||||
Last Test Result
|
||||
</h4>
|
||||
<Badge className={testStatus.last_test.status === 'passed'
|
||||
? 'bg-green-500/10 text-green-500 border-green-500/20'
|
||||
: 'bg-red-500/10 text-red-500 border-red-500/20'
|
||||
}>
|
||||
{testStatus.last_test.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Test Type</p>
|
||||
<p className="font-medium capitalize">{testStatus.last_test.type}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Completed</p>
|
||||
<p className="font-medium">{testStatus.last_test.timestamp}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Clock className="h-12 w-12 mx-auto mb-3 opacity-50" />
|
||||
<p>No test history available.</p>
|
||||
<p className="text-sm mt-1">Run a SMART self-test to see results here.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border rounded-lg p-4 bg-muted/20">
|
||||
<h4 className="font-semibold mb-2">About Self-Tests</h4>
|
||||
<div className="space-y-2 text-sm text-muted-foreground">
|
||||
<p>
|
||||
<strong className="text-foreground">Short Test (~2 minutes):</strong> Performs a quick check of the disk's
|
||||
basic functionality including read/seek tests on a small portion of the disk surface.
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-foreground">Extended Test (hours):</strong> Performs a comprehensive surface scan
|
||||
of the entire disk. Duration depends on disk size - typically 1-2 hours per TB.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recommendations Tab */}
|
||||
{reportTab === 'recommendations' && (
|
||||
<div className="space-y-4">
|
||||
{/* Status-based recommendations */}
|
||||
{testStatus.smart_status === 'passed' && (
|
||||
<div className="border rounded-lg p-4 bg-green-500/5 border-green-500/20">
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-500 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-semibold text-green-500">Disk is Healthy</h4>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
All SMART attributes are within normal ranges. Continue with regular monitoring.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{testStatus.smart_status === 'failed' && (
|
||||
<div className="border rounded-lg p-4 bg-red-500/5 border-red-500/20">
|
||||
<div className="flex items-start gap-3">
|
||||
<XCircle className="h-5 w-5 text-red-500 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-semibold text-red-500">Critical: Disk Replacement Recommended</h4>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
This disk has failed SMART health assessment. Backup all data immediately and plan for disk replacement.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Conditional recommendations */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-semibold">Recommendations</h4>
|
||||
|
||||
{(disk.reallocated_sectors ?? 0) > 0 && (
|
||||
<div className="border rounded-lg p-3 bg-yellow-500/5 border-yellow-500/20">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-yellow-500 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium">Reallocated Sectors Detected</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{disk.reallocated_sectors} sectors have been reallocated. This indicates the disk has found and
|
||||
remapped bad sectors. Monitor this value - if it increases rapidly, consider replacing the disk.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(disk.pending_sectors ?? 0) > 0 && (
|
||||
<div className="border rounded-lg p-3 bg-yellow-500/5 border-yellow-500/20">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-yellow-500 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium">Pending Sectors Detected</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{disk.pending_sectors} sectors are pending reallocation. These sectors may be unreadable.
|
||||
Run an extended self-test to force reallocation attempts.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{disk.temperature > 55 && (
|
||||
<div className="border rounded-lg p-3 bg-yellow-500/5 border-yellow-500/20">
|
||||
<div className="flex items-start gap-3">
|
||||
<Thermometer className="h-5 w-5 text-yellow-500 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium">Elevated Temperature</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Current temperature ({disk.temperature}°C) is above optimal. Improve airflow or reduce disk activity.
|
||||
Sustained high temperatures can reduce disk lifespan.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(disk.power_on_hours ?? 0) > 35000 && (
|
||||
<div className="border rounded-lg p-3 bg-blue-500/5 border-blue-500/20">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="h-5 w-5 text-blue-500 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium">High Power-On Hours</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
This disk has been running for {Math.round((disk.power_on_hours ?? 0) / 8760)} years.
|
||||
While still operational, consider planning for replacement as disks typically have a 3-5 year lifespan.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* General best practices */}
|
||||
<div className="border rounded-lg p-4 bg-muted/20 mt-6">
|
||||
<h4 className="font-semibold mb-3">Best Practices</h4>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500 mt-0.5 flex-shrink-0" />
|
||||
<span>Run a short SMART test monthly to catch early issues</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500 mt-0.5 flex-shrink-0" />
|
||||
<span>Run an extended test quarterly for comprehensive verification</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500 mt-0.5 flex-shrink-0" />
|
||||
<span>Maintain regular backups - SMART can detect some failures but not all</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500 mt-0.5 flex-shrink-0" />
|
||||
<span>Keep disk temperatures below 50°C for optimal lifespan</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500 mt-0.5 flex-shrink-0" />
|
||||
<span>Replace disks proactively after 4-5 years of heavy use</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Report Footer */}
|
||||
<div className="border-t px-6 py-4 flex justify-between items-center bg-muted/10">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Report generated by ProxMenux Monitor
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={() => window.print()} className="gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
Print Report
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
@@ -1784,6 +1784,270 @@ def is_disk_removable(disk_name):
|
||||
return False
|
||||
|
||||
|
||||
def _is_system_mount(mountpoint):
|
||||
"""Check if mountpoint is a critical system path (matching bash scripts logic)."""
|
||||
system_mounts = ('/', '/boot', '/boot/efi', '/efi', '/usr', '/var', '/etc',
|
||||
'/lib', '/lib64', '/run', '/proc', '/sys')
|
||||
if mountpoint in system_mounts:
|
||||
return True
|
||||
# Also check if it's under these paths
|
||||
for prefix in ('/usr/', '/var/', '/lib/', '/lib64/'):
|
||||
if mountpoint.startswith(prefix):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _get_zfs_root_pool():
|
||||
"""Get the ZFS pool containing the root filesystem, if any (matches bash _get_zfs_root_pool)."""
|
||||
try:
|
||||
result = subprocess.run(['df', '/'], capture_output=True, text=True, timeout=5)
|
||||
if result.returncode == 0:
|
||||
lines = result.stdout.strip().split('\n')
|
||||
if len(lines) >= 2:
|
||||
root_fs = lines[1].split()[0]
|
||||
# A ZFS dataset looks like "rpool/ROOT/pve-1" — not /dev/
|
||||
if not root_fs.startswith('/dev/') and '/' in root_fs:
|
||||
return root_fs.split('/')[0]
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_zfs_entry(entry):
|
||||
"""
|
||||
Resolve a ZFS device entry to a base disk name.
|
||||
Handles: /dev/paths, by-id names, short kernel names (matches bash _resolve_zfs_entry).
|
||||
"""
|
||||
path = None
|
||||
|
||||
try:
|
||||
if entry.startswith('/dev/'):
|
||||
path = os.path.realpath(entry)
|
||||
elif os.path.exists(f'/dev/disk/by-id/{entry}'):
|
||||
path = os.path.realpath(f'/dev/disk/by-id/{entry}')
|
||||
elif os.path.exists(f'/dev/{entry}'):
|
||||
path = os.path.realpath(f'/dev/{entry}')
|
||||
|
||||
if path:
|
||||
# Get parent disk (base disk without partition number)
|
||||
result = subprocess.run(
|
||||
['lsblk', '-no', 'PKNAME', path],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
return result.stdout.strip()
|
||||
# If no parent, path is the base disk itself
|
||||
return os.path.basename(path)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def get_system_disks():
|
||||
"""
|
||||
Detect which physical disks are used by the system (Proxmox).
|
||||
|
||||
Returns a dict mapping disk names to their system usage info:
|
||||
{
|
||||
'sda': {'is_system': True, 'usage': ['root', 'boot']},
|
||||
'nvme0n1': {'is_system': True, 'usage': ['zfs:rpool']},
|
||||
}
|
||||
|
||||
Detects (matching logic from disk-passthrough.sh, format-disk.sh, disk_host.sh):
|
||||
- Root filesystem (/) and critical system mounts (/boot, /usr, /var, etc.)
|
||||
- Boot partition (/boot, /boot/efi)
|
||||
- Active swap partitions
|
||||
- ZFS pools (especially root pool - these disks are critical)
|
||||
- LVM physical volumes (especially 'pve' volume group)
|
||||
- RAID members (mdadm)
|
||||
- Disks with Proxmox partition labels
|
||||
"""
|
||||
system_disks = {}
|
||||
|
||||
def add_usage(disk_name, usage_type):
|
||||
"""Helper to add a usage type to a disk."""
|
||||
if not disk_name:
|
||||
return
|
||||
# Normalize disk name (strip partition numbers to get base disk)
|
||||
base_disk = disk_name
|
||||
if disk_name and disk_name[-1].isdigit():
|
||||
if 'nvme' in disk_name or 'mmcblk' in disk_name:
|
||||
# NVMe/eMMC: nvme0n1p1 -> nvme0n1
|
||||
if 'p' in disk_name:
|
||||
base_disk = disk_name.rsplit('p', 1)[0]
|
||||
else:
|
||||
# SATA/SAS: sda1 -> sda
|
||||
base_disk = disk_name.rstrip('0123456789')
|
||||
|
||||
if base_disk not in system_disks:
|
||||
system_disks[base_disk] = {'is_system': True, 'usage': []}
|
||||
if usage_type not in system_disks[base_disk]['usage']:
|
||||
system_disks[base_disk]['usage'].append(usage_type)
|
||||
|
||||
# Get ZFS root pool first (critical - these disks should never be touched)
|
||||
zfs_root_pool = _get_zfs_root_pool()
|
||||
|
||||
try:
|
||||
# 1. Check mounted filesystems for system-critical mounts
|
||||
with open('/proc/mounts', 'r') as f:
|
||||
for line in f:
|
||||
parts = line.split()
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
device, mountpoint = parts[0], parts[1]
|
||||
|
||||
# Skip non-block devices
|
||||
if not device.startswith('/dev/'):
|
||||
continue
|
||||
|
||||
disk_name = device.replace('/dev/', '').replace('mapper/', '')
|
||||
|
||||
# Identify system mountpoints (expanded from bash _is_system_mount)
|
||||
if _is_system_mount(mountpoint):
|
||||
if mountpoint == '/':
|
||||
add_usage(disk_name, 'root')
|
||||
elif mountpoint.startswith('/boot'):
|
||||
add_usage(disk_name, 'boot')
|
||||
else:
|
||||
add_usage(disk_name, 'system')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
# 2. Check active swap partitions
|
||||
result = subprocess.run(
|
||||
['swapon', '--noheadings', '--raw', '--show=NAME'],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if result.returncode == 0:
|
||||
for line in result.stdout.strip().split('\n'):
|
||||
device = line.strip()
|
||||
if device.startswith('/dev/'):
|
||||
disk_name = device.replace('/dev/', '')
|
||||
add_usage(disk_name, 'swap')
|
||||
except Exception:
|
||||
# Fallback to /proc/swaps
|
||||
try:
|
||||
with open('/proc/swaps', 'r') as f:
|
||||
lines = f.readlines()[1:] # Skip header
|
||||
for line in lines:
|
||||
parts = line.split()
|
||||
if len(parts) >= 1:
|
||||
device = parts[0]
|
||||
if device.startswith('/dev/'):
|
||||
disk_name = device.replace('/dev/', '').replace('mapper/', '')
|
||||
add_usage(disk_name, 'swap')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
# 3. Check ZFS pools using zpool list -v -H (matches bash _build_pool_disks)
|
||||
result = subprocess.run(
|
||||
['zpool', 'list', '-v', '-H'],
|
||||
capture_output=True, text=True, timeout=8
|
||||
)
|
||||
if result.returncode == 0:
|
||||
current_pool = None
|
||||
for line in result.stdout.split('\n'):
|
||||
if not line.strip():
|
||||
continue
|
||||
parts = line.split()
|
||||
if not parts:
|
||||
continue
|
||||
|
||||
# First column is pool name or device
|
||||
entry = parts[0]
|
||||
|
||||
# Skip metadata entries
|
||||
if entry in ('-', 'mirror', 'raidz', 'raidz1', 'raidz2', 'raidz3', 'spare', 'log', 'cache'):
|
||||
continue
|
||||
|
||||
# Check if this is a pool name (pools have no leading whitespace)
|
||||
if not line.startswith('\t') and not line.startswith(' '):
|
||||
current_pool = entry
|
||||
continue
|
||||
|
||||
# This is a device entry - resolve it
|
||||
base_disk = _resolve_zfs_entry(entry)
|
||||
if base_disk:
|
||||
pool_label = current_pool or 'unknown'
|
||||
# Mark root pool specially
|
||||
if zfs_root_pool and pool_label == zfs_root_pool:
|
||||
add_usage(base_disk, f'zfs:{pool_label} (root)')
|
||||
else:
|
||||
add_usage(base_disk, f'zfs:{pool_label}')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
# 4. Check LVM physical volumes
|
||||
result = subprocess.run(
|
||||
['pvs', '--noheadings', '-o', 'pv_name,vg_name'],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if result.returncode == 0:
|
||||
for line in result.stdout.strip().split('\n'):
|
||||
if line.strip():
|
||||
parts = line.split()
|
||||
if len(parts) >= 2:
|
||||
pv_device = parts[0]
|
||||
vg_name = parts[1]
|
||||
if pv_device.startswith('/dev/'):
|
||||
# Resolve to real path
|
||||
try:
|
||||
real_path = os.path.realpath(pv_device)
|
||||
disk_name = os.path.basename(real_path)
|
||||
except Exception:
|
||||
disk_name = pv_device.replace('/dev/', '')
|
||||
|
||||
# Proxmox typically uses 'pve' volume group
|
||||
if 'pve' in vg_name.lower():
|
||||
add_usage(disk_name, f'lvm:{vg_name} (pve)')
|
||||
else:
|
||||
add_usage(disk_name, f'lvm:{vg_name}')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
# 5. Check active RAID arrays
|
||||
if os.path.exists('/proc/mdstat'):
|
||||
with open('/proc/mdstat', 'r') as f:
|
||||
content = f.read()
|
||||
if 'active' in content:
|
||||
# Parse mdstat for active arrays
|
||||
for line in content.split('\n'):
|
||||
if 'active raid' in line:
|
||||
# Extract device names from line like "md0 : active raid1 sda1[0] sdb1[1]"
|
||||
parts = line.split()
|
||||
for part in parts:
|
||||
if '[' in part:
|
||||
dev = part.split('[')[0]
|
||||
add_usage(dev, 'raid')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
# 6. Check for disks with Proxmox/system partition labels
|
||||
result = subprocess.run(
|
||||
['lsblk', '-o', 'NAME,PARTLABEL', '-n', '-l'],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if result.returncode == 0:
|
||||
for line in result.stdout.strip().split('\n'):
|
||||
parts = line.split(None, 1)
|
||||
if len(parts) >= 2:
|
||||
disk_name = parts[0]
|
||||
partlabel = parts[1].lower() if len(parts) > 1 else ''
|
||||
# Proxmox-specific and system partition labels
|
||||
system_labels = ['pve', 'proxmox', 'bios', 'esp', 'efi', 'boot', 'grub']
|
||||
if any(label in partlabel for label in system_labels):
|
||||
add_usage(disk_name, 'system-partition')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return system_disks
|
||||
|
||||
|
||||
def get_storage_info():
|
||||
"""Get storage and disk information"""
|
||||
try:
|
||||
@@ -1802,6 +2066,9 @@ def get_storage_info():
|
||||
physical_disks = {}
|
||||
total_disk_size_bytes = 0
|
||||
|
||||
# Get system disk information (disks used by Proxmox)
|
||||
system_disks = get_system_disks()
|
||||
|
||||
try:
|
||||
# List all block devices
|
||||
result = subprocess.run(['lsblk', '-b', '-d', '-n', '-o', 'NAME,SIZE,TYPE'],
|
||||
@@ -1843,6 +2110,11 @@ def get_storage_info():
|
||||
conn_type = get_disk_connection_type(disk_name)
|
||||
removable = is_disk_removable(disk_name)
|
||||
|
||||
# Check if this disk is used by the system
|
||||
sys_info = system_disks.get(disk_name, {})
|
||||
is_system_disk = sys_info.get('is_system', False)
|
||||
system_usage = sys_info.get('usage', [])
|
||||
|
||||
physical_disks[disk_name] = {
|
||||
'name': disk_name,
|
||||
'size': disk_size_kb, # In KB for formatMemory() in Storage Summary
|
||||
@@ -1866,6 +2138,8 @@ def get_storage_info():
|
||||
'ssd_life_left': smart_data.get('ssd_life_left'),
|
||||
'connection_type': conn_type,
|
||||
'removable': removable,
|
||||
'is_system_disk': is_system_disk,
|
||||
'system_usage': system_usage,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
@@ -6075,6 +6349,322 @@ def api_proxmox_storage():
|
||||
"""Get Proxmox storage information"""
|
||||
return jsonify(get_proxmox_storage())
|
||||
|
||||
|
||||
# ─── SMART Disk Testing API ───────────────────────────────────────────────────
|
||||
|
||||
SMART_DIR = '/usr/local/share/proxmenux/smart'
|
||||
|
||||
def _is_nvme(disk_name):
|
||||
"""Check if disk is NVMe."""
|
||||
return disk_name.startswith('nvme')
|
||||
|
||||
def _get_smart_json_path(disk_name):
|
||||
"""Get path to SMART JSON file for a disk."""
|
||||
return os.path.join(SMART_DIR, f"{disk_name}.json")
|
||||
|
||||
def _ensure_smart_tools():
|
||||
"""Check if SMART tools are installed."""
|
||||
has_smartctl = shutil.which('smartctl') is not None
|
||||
has_nvme = shutil.which('nvme') is not None
|
||||
return {'smartctl': has_smartctl, 'nvme': has_nvme}
|
||||
|
||||
def _parse_smart_attributes(output_lines):
|
||||
"""Parse SMART attributes from smartctl output."""
|
||||
attributes = []
|
||||
in_attrs = False
|
||||
for line in output_lines:
|
||||
if 'ID#' in line and 'ATTRIBUTE_NAME' in line:
|
||||
in_attrs = True
|
||||
continue
|
||||
if in_attrs:
|
||||
if not line.strip():
|
||||
break
|
||||
parts = line.split()
|
||||
if len(parts) >= 10 and parts[0].isdigit():
|
||||
attr_id = int(parts[0])
|
||||
attr_name = parts[1]
|
||||
value = int(parts[3]) if parts[3].isdigit() else 0
|
||||
worst = int(parts[4]) if parts[4].isdigit() else 0
|
||||
threshold = int(parts[5]) if parts[5].isdigit() else 0
|
||||
raw_value = parts[9] if len(parts) > 9 else ''
|
||||
|
||||
# Determine status
|
||||
status = 'ok'
|
||||
if threshold > 0 and value <= threshold:
|
||||
status = 'critical'
|
||||
elif threshold > 0 and value <= threshold + 10:
|
||||
status = 'warning'
|
||||
|
||||
attributes.append({
|
||||
'id': attr_id,
|
||||
'name': attr_name,
|
||||
'value': value,
|
||||
'worst': worst,
|
||||
'threshold': threshold,
|
||||
'raw_value': raw_value,
|
||||
'status': status
|
||||
})
|
||||
return attributes
|
||||
|
||||
@app.route('/api/storage/smart/<disk_name>', methods=['GET'])
|
||||
@require_auth
|
||||
def api_smart_status(disk_name):
|
||||
"""Get SMART status and data for a specific disk."""
|
||||
try:
|
||||
# Validate disk name (security)
|
||||
if not re.match(r'^[a-zA-Z0-9]+$', disk_name):
|
||||
return jsonify({'error': 'Invalid disk name'}), 400
|
||||
|
||||
device = f'/dev/{disk_name}'
|
||||
if not os.path.exists(device):
|
||||
return jsonify({'error': 'Device not found'}), 404
|
||||
|
||||
tools = _ensure_smart_tools()
|
||||
result = {
|
||||
'status': 'idle',
|
||||
'tools_installed': tools
|
||||
}
|
||||
|
||||
# Check if tools are available
|
||||
is_nvme = _is_nvme(disk_name)
|
||||
if is_nvme and not tools['nvme']:
|
||||
result['error'] = 'nvme-cli not installed'
|
||||
return jsonify(result)
|
||||
if not is_nvme and not tools['smartctl']:
|
||||
result['error'] = 'smartmontools not installed'
|
||||
return jsonify(result)
|
||||
|
||||
# Check for existing JSON file (from previous test)
|
||||
json_path = _get_smart_json_path(disk_name)
|
||||
if os.path.exists(json_path):
|
||||
try:
|
||||
with open(json_path, 'r') as f:
|
||||
saved_data = json.load(f)
|
||||
result['saved_data'] = saved_data
|
||||
result['saved_timestamp'] = os.path.getmtime(json_path)
|
||||
except (json.JSONDecodeError, IOError):
|
||||
pass
|
||||
|
||||
# Get current SMART status
|
||||
if is_nvme:
|
||||
# NVMe: Check for running test
|
||||
proc = subprocess.run(
|
||||
['nvme', 'self-test-log', device],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
if 'in progress' in proc.stdout.lower():
|
||||
result['status'] = 'running'
|
||||
result['test_type'] = 'nvme'
|
||||
|
||||
# Get smart-log data
|
||||
proc = subprocess.run(
|
||||
['nvme', 'smart-log', device],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
lines = proc.stdout.strip().split('\n')
|
||||
smart_data = {}
|
||||
for line in lines:
|
||||
if ':' in line:
|
||||
key, value = line.split(':', 1)
|
||||
smart_data[key.strip().lower().replace(' ', '_')] = value.strip()
|
||||
result['smart_data'] = smart_data
|
||||
|
||||
# Check health
|
||||
crit_warn = smart_data.get('critical_warning', '0')
|
||||
result['smart_status'] = 'passed' if crit_warn == '0' else 'warning'
|
||||
else:
|
||||
# SATA/SAS: Check for running test
|
||||
proc = subprocess.run(
|
||||
['smartctl', '-c', device],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
if 'Self-test routine in progress' in proc.stdout or '% of test remaining' in proc.stdout:
|
||||
result['status'] = 'running'
|
||||
# Extract progress percentage
|
||||
match = re.search(r'(\d+)% of test remaining', proc.stdout)
|
||||
if match:
|
||||
result['progress'] = 100 - int(match.group(1))
|
||||
|
||||
# Get SMART health
|
||||
proc = subprocess.run(
|
||||
['smartctl', '-H', device],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
if 'PASSED' in proc.stdout:
|
||||
result['smart_status'] = 'passed'
|
||||
elif 'FAILED' in proc.stdout:
|
||||
result['smart_status'] = 'failed'
|
||||
else:
|
||||
result['smart_status'] = 'unknown'
|
||||
|
||||
# Get SMART attributes
|
||||
proc = subprocess.run(
|
||||
['smartctl', '-A', device],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
attrs = _parse_smart_attributes(proc.stdout.split('\n'))
|
||||
result['smart_data'] = {'attributes': attrs}
|
||||
|
||||
# Get self-test log for last test result
|
||||
proc = subprocess.run(
|
||||
['smartctl', '-l', 'selftest', device],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
lines = proc.stdout.split('\n')
|
||||
for line in lines:
|
||||
if line.startswith('# ') or line.startswith('# '):
|
||||
parts = line.split()
|
||||
if len(parts) >= 5:
|
||||
test_type = 'short' if 'Short' in line else 'long' if 'Extended' in line or 'Long' in line else 'unknown'
|
||||
test_status = 'passed' if 'Completed without error' in line else 'failed'
|
||||
result['last_test'] = {
|
||||
'type': test_type,
|
||||
'status': test_status,
|
||||
'timestamp': ' '.join(parts[-5:-2]) if len(parts) > 5 else 'unknown'
|
||||
}
|
||||
break
|
||||
|
||||
return jsonify(result)
|
||||
except subprocess.TimeoutExpired:
|
||||
return jsonify({'error': 'Command timeout'}), 504
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/storage/smart/<disk_name>/test', methods=['POST'])
|
||||
@require_auth
|
||||
def api_smart_run_test(disk_name):
|
||||
"""Start a SMART self-test on a disk."""
|
||||
try:
|
||||
# Validate disk name (security)
|
||||
if not re.match(r'^[a-zA-Z0-9]+$', disk_name):
|
||||
return jsonify({'error': 'Invalid disk name'}), 400
|
||||
|
||||
device = f'/dev/{disk_name}'
|
||||
if not os.path.exists(device):
|
||||
return jsonify({'error': 'Device not found'}), 404
|
||||
|
||||
data = request.get_json() or {}
|
||||
test_type = data.get('test_type', 'short')
|
||||
|
||||
if test_type not in ('short', 'long'):
|
||||
return jsonify({'error': 'Invalid test type. Use "short" or "long"'}), 400
|
||||
|
||||
tools = _ensure_smart_tools()
|
||||
is_nvme = _is_nvme(disk_name)
|
||||
|
||||
# Ensure SMART directory exists
|
||||
os.makedirs(SMART_DIR, exist_ok=True)
|
||||
json_path = _get_smart_json_path(disk_name)
|
||||
|
||||
if is_nvme:
|
||||
if not tools['nvme']:
|
||||
return jsonify({'error': 'nvme-cli not installed'}), 400
|
||||
|
||||
# NVMe: self-test-code 1=short, 2=long
|
||||
code = 1 if test_type == 'short' else 2
|
||||
proc = subprocess.run(
|
||||
['nvme', 'device-self-test', device, f'--self-test-code={code}'],
|
||||
capture_output=True, text=True, timeout=30
|
||||
)
|
||||
|
||||
if proc.returncode != 0:
|
||||
return jsonify({'error': f'Failed to start test: {proc.stderr}'}), 500
|
||||
|
||||
# For long test, start background monitor
|
||||
if test_type == 'long':
|
||||
subprocess.Popen(
|
||||
f'''
|
||||
while nvme device-self-test {device} --self-test-code=0 2>/dev/null | grep -qi 'in progress'; do
|
||||
sleep 60
|
||||
done
|
||||
nvme smart-log -o json {device} > {json_path} 2>/dev/null
|
||||
''',
|
||||
shell=True, start_new_session=True,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
|
||||
)
|
||||
else:
|
||||
if not tools['smartctl']:
|
||||
return jsonify({'error': 'smartmontools not installed'}), 400
|
||||
|
||||
test_flag = '-t short' if test_type == 'short' else '-t long'
|
||||
proc = subprocess.run(
|
||||
['smartctl'] + test_flag.split() + [device],
|
||||
capture_output=True, text=True, timeout=30
|
||||
)
|
||||
|
||||
if proc.returncode not in (0, 4): # 4 = test started successfully
|
||||
return jsonify({'error': f'Failed to start test: {proc.stderr}'}), 500
|
||||
|
||||
# For long test, start background monitor
|
||||
if test_type == 'long':
|
||||
subprocess.Popen(
|
||||
f'''
|
||||
while smartctl -c {device} 2>/dev/null | grep -qiE 'Self-test routine in progress|[1-9][0-9]?% of test remaining'; do
|
||||
sleep 60
|
||||
done
|
||||
smartctl --json=c {device} > {json_path} 2>/dev/null
|
||||
''',
|
||||
shell=True, start_new_session=True,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'test_type': test_type,
|
||||
'device': device,
|
||||
'message': f'{test_type.capitalize()} test started on {device}'
|
||||
})
|
||||
except subprocess.TimeoutExpired:
|
||||
return jsonify({'error': 'Command timeout'}), 504
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/storage/smart/tools', methods=['GET'])
|
||||
@require_auth
|
||||
def api_smart_tools_status():
|
||||
"""Check if SMART tools are installed."""
|
||||
tools = _ensure_smart_tools()
|
||||
return jsonify(tools)
|
||||
|
||||
|
||||
@app.route('/api/storage/smart/tools/install', methods=['POST'])
|
||||
@require_auth
|
||||
def api_smart_tools_install():
|
||||
"""Install SMART tools (smartmontools and nvme-cli)."""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
packages = data.get('packages', ['smartmontools', 'nvme-cli'])
|
||||
|
||||
results = {}
|
||||
for pkg in packages:
|
||||
if pkg not in ('smartmontools', 'nvme-cli'):
|
||||
results[pkg] = {'success': False, 'error': 'Invalid package name'}
|
||||
continue
|
||||
|
||||
# Update apt cache and install
|
||||
proc = subprocess.run(
|
||||
['apt-get', 'install', '-y', pkg],
|
||||
capture_output=True, text=True, timeout=120
|
||||
)
|
||||
results[pkg] = {
|
||||
'success': proc.returncode == 0,
|
||||
'output': proc.stdout if proc.returncode == 0 else proc.stderr
|
||||
}
|
||||
|
||||
return jsonify(results)
|
||||
except subprocess.TimeoutExpired:
|
||||
return jsonify({'error': 'Installation timeout'}), 504
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
# ─── END SMART API ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@app.route('/api/network', methods=['GET'])
|
||||
@require_auth
|
||||
def api_network():
|
||||
|
||||
@@ -678,6 +678,59 @@ TEMPLATES = {
|
||||
'group': 'storage',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'smart_test_complete': {
|
||||
'title': '{hostname}: SMART test completed — {device}',
|
||||
'body': 'SMART {test_type} test on /dev/{device} has completed.\nResult: {result}\nDuration: {duration}',
|
||||
'label': 'SMART test completed',
|
||||
'group': 'storage',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'smart_test_failed': {
|
||||
'title': '{hostname}: SMART test FAILED — {device}',
|
||||
'body': 'SMART {test_type} test on /dev/{device} has failed.\nResult: {result}\nReason: {reason}',
|
||||
'label': 'SMART test FAILED',
|
||||
'group': 'storage',
|
||||
'default_enabled': True,
|
||||
},
|
||||
|
||||
# ── GPU / PCIe passthrough events ──
|
||||
'gpu_mode_switch': {
|
||||
'title': '{hostname}: GPU mode changed to {new_mode}',
|
||||
'body': (
|
||||
'GPU passthrough mode has been switched.\n'
|
||||
'GPU: {gpu_name} ({gpu_pci})\n'
|
||||
'Previous mode: {old_mode}\n'
|
||||
'New mode: {new_mode}\n'
|
||||
'{details}'
|
||||
),
|
||||
'label': 'GPU mode switched',
|
||||
'group': 'hardware',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'gpu_passthrough_blocked': {
|
||||
'title': '{hostname}: {guest_type} {guest_id} blocked at startup',
|
||||
'body': (
|
||||
'PCIe passthrough guard prevented {guest_type} {guest_id} ({guest_name}) from starting.\n'
|
||||
'Reason: {reason}\n'
|
||||
'{details}'
|
||||
),
|
||||
'label': 'GPU passthrough blocked',
|
||||
'group': 'hardware',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'pci_passthrough_conflict': {
|
||||
'title': '{hostname}: PCIe device conflict detected',
|
||||
'body': (
|
||||
'A PCIe device is assigned to multiple guests.\n'
|
||||
'Device: {device_pci}\n'
|
||||
'Conflicting guests: {guest_list}\n'
|
||||
'Action required: Stop one of the guests or reassign the device.'
|
||||
),
|
||||
'label': 'PCIe device conflict',
|
||||
'group': 'hardware',
|
||||
'default_enabled': True,
|
||||
},
|
||||
|
||||
'load_high': {
|
||||
'title': '{hostname}: High system load — {value}',
|
||||
'body': 'System load average is {value} on {cores} cores.\n{details}',
|
||||
@@ -1203,6 +1256,7 @@ CATEGORY_EMOJI = {
|
||||
'services': '\u2699\uFE0F', # gear
|
||||
'health': '\U0001FA7A', # stethoscope
|
||||
'updates': '\U0001F504', # counterclockwise arrows (update)
|
||||
'hardware': '\U0001F3AE', # video game controller (GPU/PCIe hardware)
|
||||
'other': '\U0001F4E8', # incoming envelope
|
||||
}
|
||||
|
||||
@@ -1275,6 +1329,10 @@ EVENT_EMOJI = {
|
||||
'proxmenux_update': '\U0001F195', # NEW
|
||||
# AI
|
||||
'ai_model_migrated': '\U0001F504', # arrows counterclockwise (refresh/update)
|
||||
# GPU / PCIe
|
||||
'gpu_mode_switch': '\U0001F3AE', # video game controller (represents GPU)
|
||||
'gpu_passthrough_blocked': '\U0001F6AB', # prohibited sign (blocked)
|
||||
'pci_passthrough_conflict': '\u26A0\uFE0F', # warning triangle (conflict)
|
||||
}
|
||||
|
||||
# Decorative field-level icons for body text enrichment
|
||||
|
||||
Reference in New Issue
Block a user