update storage-overview.tsx

This commit is contained in:
MacRimi
2026-04-13 15:29:22 +02:00
parent 4eea90bd97
commit 98becfd368
2 changed files with 274 additions and 151 deletions

View File

@@ -163,3 +163,15 @@
.xterm-rows { .xterm-rows {
margin: 0 !important; margin: 0 !important;
} }
/* ===================== */
/* Progress Animations */
/* ===================== */
@keyframes indeterminate {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(400%);
}
}

View File

@@ -1436,12 +1436,20 @@ export function StorageOverview() {
</div> </div>
</div> </div>
{/* SMART Test Data Section (from real test JSON) */} {/* SMART Test Data Section (from real test JSON) - Uniform style with Wear & Lifetime */}
{(loadingSmartJson || smartJsonData?.has_data) && ( {(loadingSmartJson || smartJsonData?.has_data) && (
<div className="border-t pt-4"> <div className="border-t pt-4">
<h4 className="font-semibold mb-3 flex items-center gap-2"> <h4 className="font-semibold mb-3 flex items-center gap-2">
<Activity className="h-4 w-4 text-green-400" /> <Activity className="h-4 w-4 text-green-400" />
SMART Test Data {(() => {
// Check if this is SSD without Proxmox wear data - show as "Wear & Lifetime"
const isNvme = selectedDisk.name?.includes('nvme')
const hasProxmoxWear = getWearIndicator(selectedDisk) !== null
if (!isNvme && !hasProxmoxWear && smartJsonData?.has_data) {
return 'Wear & Lifetime'
}
return 'SMART Test Data'
})()}
{smartJsonData?.has_data && ( {smartJsonData?.has_data && (
<Badge className="bg-green-500/10 text-green-400 border-green-500/20 text-[10px] px-1.5"> <Badge className="bg-green-500/10 text-green-400 border-green-500/20 text-[10px] px-1.5">
Real Test Real Test
@@ -1455,33 +1463,18 @@ export function StorageOverview() {
</div> </div>
) : smartJsonData?.has_data && smartJsonData.data ? ( ) : smartJsonData?.has_data && smartJsonData.data ? (
<div className="space-y-3"> <div className="space-y-3">
{/* Last Test Info */} {/* SSD/NVMe Life Estimation from JSON - Uniform style */}
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-muted-foreground">Last Test Date</p>
<p className="font-medium">
{smartJsonData.timestamp
? new Date(smartJsonData.timestamp).toLocaleString()
: 'Unknown'}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Test Type</p>
<p className="font-medium capitalize">{smartJsonData.test_type || 'Unknown'}</p>
</div>
</div>
{/* SSD Life Estimation from JSON (if available) */}
{(() => { {(() => {
const data = smartJsonData.data as Record<string, unknown> const data = smartJsonData.data as Record<string, unknown>
const ataAttrs = data?.ata_smart_attributes as { table?: Array<{ id: number; name: string; value: number; raw?: { value: number } }> } const ataAttrs = data?.ata_smart_attributes as { table?: Array<{ id: number; name: string; value: number; raw?: { value: number } }> }
const table = ataAttrs?.table || [] const table = ataAttrs?.table || []
// Look for wear-related attributes // Look for wear-related attributes for SSD
const wearAttr = table.find(a => const wearAttr = table.find(a =>
a.name?.toLowerCase().includes('wear_leveling') || a.name?.toLowerCase().includes('wear_leveling') ||
a.name?.toLowerCase().includes('media_wearout') || a.name?.toLowerCase().includes('media_wearout') ||
a.name?.toLowerCase().includes('percent_lifetime') || a.name?.toLowerCase().includes('percent_lifetime') ||
a.name?.toLowerCase().includes('ssd_life_left') ||
a.id === 177 || a.id === 231 || a.id === 233 a.id === 177 || a.id === 231 || a.id === 233
) )
@@ -1491,55 +1484,95 @@ export function StorageOverview() {
a.id === 241 a.id === 241
) )
if (wearAttr || lbasAttr) { // Look for power on hours from SMART data
return ( const pohAttr = table.find(a =>
<div className="bg-green-500/5 border border-green-500/20 rounded-lg p-3"> a.name?.toLowerCase().includes('power_on_hours') ||
<p className="text-xs text-green-400 mb-2 font-medium">From Real SMART Test</p> a.id === 9
<div className="grid grid-cols-2 gap-4"> )
{wearAttr && (
<div>
<p className="text-sm text-muted-foreground">{wearAttr.name?.replace(/_/g, ' ')}</p>
<p className={`font-medium ${wearAttr.value < 50 ? 'text-red-400' : wearAttr.value < 80 ? 'text-yellow-400' : 'text-green-400'}`}>
{wearAttr.value}%
</p>
</div>
)}
{lbasAttr && lbasAttr.raw?.value && (
<div>
<p className="text-sm text-muted-foreground">Total Data Written</p>
<p className="font-medium">
{(() => {
const tbWritten = (lbasAttr.raw.value * 512) / (1024 ** 4)
return tbWritten >= 1
? `${tbWritten.toFixed(2)} TB`
: `${(tbWritten * 1024).toFixed(2)} GB`
})()}
</p>
</div>
)}
</div>
</div>
)
}
// For NVMe, check nvme_smart_health_information_log // For NVMe, check nvme_smart_health_information_log
const nvmeHealth = data?.nvme_smart_health_information_log as Record<string, unknown> const nvmeHealth = data?.nvme_smart_health_information_log as Record<string, unknown>
if (nvmeHealth) {
const percentUsed = nvmeHealth.percentage_used as number
const dataUnitsWritten = nvmeHealth.data_units_written as number
const availableSpare = nvmeHealth.available_spare as number
// Calculate data written
let dataWrittenTB = 0
let dataWrittenLabel = ''
if (lbasAttr && lbasAttr.raw?.value) {
dataWrittenTB = (lbasAttr.raw.value * 512) / (1024 ** 4)
dataWrittenLabel = dataWrittenTB >= 1
? `${dataWrittenTB.toFixed(2)} TB`
: `${(dataWrittenTB * 1024).toFixed(2)} GB`
} else if (nvmeHealth?.data_units_written) {
const units = nvmeHealth.data_units_written as number
dataWrittenTB = (units * 512000) / (1024 ** 4)
dataWrittenLabel = dataWrittenTB >= 1
? `${dataWrittenTB.toFixed(2)} TB`
: `${(dataWrittenTB * 1024).toFixed(2)} GB`
}
// Get wear percentage
let wearPercent: number | null = null
let wearLabel = 'Life Remaining'
if (wearAttr) {
wearPercent = wearAttr.value
wearLabel = wearAttr.name?.replace(/_/g, ' ') || 'Life Remaining'
} else if (nvmeHealth?.percentage_used !== undefined) {
wearPercent = 100 - (nvmeHealth.percentage_used as number)
wearLabel = 'Life Remaining'
}
// Calculate estimated life remaining
let estimatedLife = ''
const powerOnHours = pohAttr?.raw?.value || selectedDisk.power_on_hours || 0
if (wearPercent !== null && wearPercent > 0 && wearPercent < 100 && powerOnHours > 0) {
const usedPercent = 100 - wearPercent
if (usedPercent > 0) {
const totalEstimatedHours = powerOnHours / (usedPercent / 100)
const remainingHours = totalEstimatedHours - powerOnHours
const remainingYears = remainingHours / (24 * 365)
if (remainingYears >= 1) {
estimatedLife = `~${remainingYears.toFixed(1)} years`
} else {
const remainingMonths = remainingYears * 12
estimatedLife = `~${remainingMonths.toFixed(0)} months`
}
}
}
// Available spare for NVMe
const availableSpare = nvmeHealth?.available_spare as number | undefined
if (wearPercent !== null || dataWrittenLabel) {
return ( return (
<div className="bg-green-500/5 border border-green-500/20 rounded-lg p-3"> <>
<p className="text-xs text-green-400 mb-2 font-medium">From Real SMART Test (NVMe)</p> {/* Wear Progress Bar - Same style as NVMe */}
<div className="grid grid-cols-2 gap-4"> {wearPercent !== null && (
{percentUsed !== undefined && ( <div>
<div> <div className="flex items-center justify-between mb-2">
<p className="text-sm text-muted-foreground">Percent Used</p> <p className="text-sm text-muted-foreground">{wearLabel}</p>
<p className={`font-medium ${percentUsed > 80 ? 'text-red-400' : percentUsed > 50 ? 'text-yellow-400' : 'text-green-400'}`}> <p className={`font-medium ${wearPercent < 20 ? 'text-red-400' : wearPercent < 50 ? 'text-yellow-400' : 'text-green-400'}`}>
{percentUsed}% {wearPercent}%
</p> </p>
</div> </div>
<Progress
value={wearPercent}
className={`h-2 ${wearPercent < 20 ? '[&>div]:bg-red-500' : wearPercent < 50 ? '[&>div]:bg-yellow-500' : '[&>div]:bg-green-500'}`}
/>
</div>
)}
{/* Stats Grid - Same layout as NVMe Wear & Lifetime */}
<div className="grid grid-cols-2 gap-4">
{estimatedLife && (
<div>
<p className="text-sm text-muted-foreground">Estimated Life Remaining</p>
<p className="font-medium">{estimatedLife}</p>
</div>
)}
{dataWrittenLabel && (
<div>
<p className="text-sm text-muted-foreground">Total Data Written</p>
<p className="font-medium">{dataWrittenLabel}</p>
</div>
)} )}
{availableSpare !== undefined && ( {availableSpare !== undefined && (
<div> <div>
@@ -1549,30 +1582,28 @@ export function StorageOverview() {
</p> </p>
</div> </div>
)} )}
{dataUnitsWritten !== undefined && (
<div>
<p className="text-sm text-muted-foreground">Total Data Written</p>
<p className="font-medium">
{(() => {
const tbWritten = (dataUnitsWritten * 512000) / (1024 ** 4)
return tbWritten >= 1
? `${tbWritten.toFixed(2)} TB`
: `${(tbWritten * 1024).toFixed(2)} GB`
})()}
</p>
</div>
)}
</div> </div>
</div> </>
) )
} }
return null return null
})()} })()}
<p className="text-xs text-muted-foreground"> {/* Last Test Info */}
Run a SMART test in the SMART Test tab for more detailed analysis. <div className="grid grid-cols-2 gap-4 pt-2 border-t border-border/50">
</p> <div>
<p className="text-sm text-muted-foreground">Last Test Date</p>
<p className="font-medium text-sm">
{smartJsonData.timestamp
? new Date(smartJsonData.timestamp).toLocaleString()
: 'Unknown'}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Test Type</p>
<p className="font-medium text-sm capitalize">{smartJsonData.test_type || 'Unknown'}</p>
</div>
</div>
</div> </div>
) : ( ) : (
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
@@ -1700,30 +1731,92 @@ function openSmartReport(disk: DiskInfo, testStatus: SmartTestStatus, smartAttri
// Build attributes table - format differs for NVMe vs SATA // Build attributes table - format differs for NVMe vs SATA
const isNvmeForTable = diskType === 'NVMe' const isNvmeForTable = diskType === 'NVMe'
// Explanations for NVMe metrics
const nvmeExplanations: Record<string, string> = {
'Critical Warning': 'Active alert flags from the NVMe controller. Any non-zero value requires immediate investigation.',
'Temperature': 'Composite temperature. Sustained high temps cause throttling and reduce lifespan.',
'Available Spare': 'Spare NAND blocks remaining. Alert triggers below 5%.',
'Available Spare Threshold': 'Threshold below which spare blocks are considered critical.',
'Percentage Used': "Drive's own estimate of endurance consumed. 100% means rated lifespan has been reached.",
'Percent Used': "Drive's own estimate of endurance consumed. 100% means rated lifespan has been reached.",
'Media Errors': 'Unrecoverable errors involving the NAND flash. Any non-zero value indicates flash cell damage.',
'Unsafe Shutdowns': 'Power losses without proper shutdown. Very high counts can cause firmware corruption.',
'Power Cycles': 'Total on/off cycles. Frequent cycling increases connector and capacitor wear.',
'Power On Hours': 'Total hours the drive has been powered on.',
'Data Units Read': 'Total data read from the drive in 512KB units.',
'Data Units Written': 'Total data written to the drive in 512KB units.',
'Host Read Commands': 'Total read commands processed by the controller.',
'Host Write Commands': 'Total write commands processed by the controller.',
'Controller Busy Time': 'Minutes the controller was busy processing commands.',
'Error Log Entries': 'Number of entries in the error log. Often includes benign self-test artifacts.',
'Warning Temp Time': 'Minutes spent in the warning temperature range. Zero is ideal.',
'Critical Temp Time': 'Minutes spent in the critical temperature range. Should always be zero.',
}
// Explanations for SATA/SSD attributes
const sataExplanations: Record<string, string> = {
'Raw Read Error Rate': 'Raw read errors detected. High values on Seagate drives are often normal (uses proprietary formula).',
'Reallocated Sector Count': 'Bad sectors replaced by spare sectors. Growing count indicates drive degradation.',
'Reallocated Sectors': 'Bad sectors replaced by spare sectors. Growing count indicates drive degradation.',
'Spin Up Time': 'Time needed for platters to reach operating speed (HDD only).',
'Start Stop Count': 'Number of spindle start/stop cycles (HDD only).',
'Power On Hours': 'Total hours the drive has been powered on.',
'Power Cycle Count': 'Total number of complete power on/off cycles.',
'Temperature': 'Current drive temperature. High temps reduce lifespan.',
'Temperature Celsius': 'Current drive temperature in Celsius. High temps reduce lifespan.',
'Current Pending Sector': 'Sectors waiting to be remapped. May resolve or become reallocated.',
'Pending Sectors': 'Sectors waiting to be remapped. May resolve or become reallocated.',
'Offline Uncorrectable': 'Uncorrectable errors found during offline scan. Indicates potential data loss.',
'UDMA CRC Error Count': 'Interface communication errors. Usually caused by cable or connection issues.',
'CRC Errors': 'Interface communication errors. Usually caused by cable or connection issues.',
'Wear Leveling Count': 'SSD wear indicator. Lower values mean more wear.',
'Media Wearout Indicator': 'SSD life remaining estimate. Lower values mean less life remaining.',
'Total LBAs Written': 'Total logical blocks written to the drive.',
'Total LBAs Read': 'Total logical blocks read from the drive.',
'SSD Life Left': 'Estimated remaining lifespan percentage.',
'Percent Lifetime Remain': 'Estimated remaining lifespan percentage.',
}
const getAttrExplanation = (name: string, isNvme: boolean): string => {
const cleanName = name.replace(/_/g, ' ')
if (isNvme) {
return nvmeExplanations[cleanName] || nvmeExplanations[name] || ''
}
return sataExplanations[cleanName] || sataExplanations[name] || ''
}
const attributeRows = smartAttributes.map((attr, i) => { const attributeRows = smartAttributes.map((attr, i) => {
const statusColor = attr.status === 'ok' ? '#16a34a' : attr.status === 'warning' ? '#ca8a04' : '#dc2626' const statusColor = attr.status === 'ok' ? '#16a34a' : attr.status === 'warning' ? '#ca8a04' : '#dc2626'
const statusBg = attr.status === 'ok' ? '#16a34a15' : attr.status === 'warning' ? '#ca8a0415' : '#dc262615' const statusBg = attr.status === 'ok' ? '#16a34a15' : attr.status === 'warning' ? '#ca8a0415' : '#dc262615'
const explanation = getAttrExplanation(attr.name, isNvmeForTable)
if (isNvmeForTable) { if (isNvmeForTable) {
// NVMe format: Metric | Value | Status // NVMe format: Metric | Value | Status (with explanation)
return ` return `
<tr> <tr>
<td class="col-name">${attr.name}</td> <td class="col-name">
<td style="text-align:center;font-family:monospace;">${attr.value}</td> <div style="font-weight:500;">${attr.name}</div>
<td><span class="f-tag" style="background:${statusBg};color:${statusColor}">${attr.status === 'ok' ? 'OK' : attr.status.toUpperCase()}</span></td> ${explanation ? `<div style="font-size:10px;color:#64748b;margin-top:2px;">${explanation}</div>` : ''}
</td>
<td style="text-align:center;font-family:monospace;vertical-align:top;padding-top:12px;">${attr.value}</td>
<td style="vertical-align:top;padding-top:8px;"><span class="f-tag" style="background:${statusBg};color:${statusColor}">${attr.status === 'ok' ? 'OK' : attr.status.toUpperCase()}</span></td>
</tr> </tr>
` `
} else { } else {
// SATA format: ID | Attribute | Val | Worst | Thr | Raw | Status // SATA format: ID | Attribute | Val | Worst | Thr | Raw | Status (with explanation)
return ` return `
<tr> <tr>
<td style="font-weight:600;">${attr.id}</td> <td style="font-weight:600;vertical-align:top;padding-top:12px;">${attr.id}</td>
<td class="col-name">${attr.name.replace(/_/g, ' ')}</td> <td class="col-name">
<td style="text-align:center;">${attr.value}</td> <div style="font-weight:500;">${attr.name.replace(/_/g, ' ')}</div>
<td style="text-align:center;">${attr.worst}</td> ${explanation ? `<div style="font-size:10px;color:#64748b;margin-top:2px;">${explanation}</div>` : ''}
<td class="hide-mobile" style="text-align:center;">${attr.threshold}</td> </td>
<td class="col-raw">${attr.raw_value}</td> <td style="text-align:center;vertical-align:top;padding-top:12px;">${attr.value}</td>
<td><span class="f-tag" style="background:${statusBg};color:${statusColor}">${attr.status === 'ok' ? 'OK' : attr.status.toUpperCase()}</span></td> <td style="text-align:center;vertical-align:top;padding-top:12px;">${attr.worst}</td>
<td class="hide-mobile" style="text-align:center;vertical-align:top;padding-top:12px;">${attr.threshold}</td>
<td class="col-raw" style="vertical-align:top;padding-top:12px;">${attr.raw_value}</td>
<td style="vertical-align:top;padding-top:8px;"><span class="f-tag" style="background:${statusBg};color:${statusColor}">${attr.status === 'ok' ? 'OK' : attr.status.toUpperCase()}</span></td>
</tr> </tr>
` `
} }
@@ -2585,19 +2678,36 @@ function SmartTestTab({ disk, observations = [] }: SmartTestTabProps) {
fetchSmartStatus() fetchSmartStatus()
// Poll for status updates // Poll for status updates
// For disks that don't report progress, we keep polling but show an indeterminate progress bar
let pollCount = 0
const maxPolls = testType === 'short' ? 36 : 720 // 3 min for short, 1 hour for long (at 5s intervals)
const pollInterval = setInterval(async () => { const pollInterval = setInterval(async () => {
pollCount++
try { try {
const data = await fetchApi<SmartTestStatus>(`/api/storage/smart/${disk.name}`) const statusData = await fetchApi<SmartTestStatus>(`/api/storage/smart/${disk.name}`)
setTestStatus(data) setTestStatus(statusData)
if (data.status !== 'running') {
// Only clear runningTest when we get a definitive "not running" status
if (statusData.status !== 'running') {
clearInterval(pollInterval) clearInterval(pollInterval)
setRunningTest(null) setRunningTest(null)
// Refresh SMART JSON data to get new test results
fetchSmartStatus()
} }
} catch { } catch {
// Don't clear on error - keep showing progress
}
// Safety timeout: stop polling after max duration
if (pollCount >= maxPolls) {
clearInterval(pollInterval) clearInterval(pollInterval)
setRunningTest(null) setRunningTest(null)
// Refresh status one more time to get final result
fetchSmartStatus()
} }
}, 5000) }, 5000)
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : 'Failed to start test' const message = err instanceof Error ? err.message : 'Failed to start test'
setTestError(message) setTestError(message)
@@ -2666,79 +2776,80 @@ function SmartTestTab({ disk, observations = [] }: SmartTestTabProps) {
<Play className="h-4 w-4" /> <Play className="h-4 w-4" />
Run SMART Test Run SMART Test
</h4> </h4>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => runSmartTest('short')} onClick={() => runSmartTest('short')}
disabled={runningTest !== null || testStatus.status === 'running'} disabled={runningTest !== null || testStatus.status === 'running'}
className="gap-2 bg-blue-500/10 border-blue-500/30 text-blue-500 hover:bg-blue-500/20 hover:text-blue-400" className="gap-2 bg-blue-500/10 border-blue-500/30 text-blue-500 hover:bg-blue-500/20 hover:text-blue-400"
> >
{runningTest === 'short' || (testStatus.status === 'running' && testStatus.test_type === 'short') ? ( {runningTest === 'short' || (testStatus.status === 'running' && testStatus.test_type === 'short') ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
) : ( ) : (
<Activity className="h-4 w-4" /> <Activity className="h-4 w-4" />
)} )}
Short Test (~2 min) Short Test (~2 min)
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => runSmartTest('long')} onClick={() => runSmartTest('long')}
disabled={runningTest !== null || testStatus.status === 'running'} disabled={runningTest !== null || testStatus.status === 'running'}
className="gap-2 bg-blue-500/10 border-blue-500/30 text-blue-500 hover:bg-blue-500/20 hover:text-blue-400" className="gap-2 bg-blue-500/10 border-blue-500/30 text-blue-500 hover:bg-blue-500/20 hover:text-blue-400"
> >
{runningTest === 'long' || (testStatus.status === 'running' && testStatus.test_type === 'long') ? ( {runningTest === 'long' || (testStatus.status === 'running' && testStatus.test_type === 'long') ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
) : ( ) : (
<Activity className="h-4 w-4" /> <Activity className="h-4 w-4" />
)} )}
Extended Test (background) Extended Test (background)
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={fetchSmartStatus} onClick={fetchSmartStatus}
className="gap-2 bg-blue-500/10 border-blue-500/30 text-blue-500 hover:bg-blue-500/20 hover:text-blue-400" className="gap-2 bg-blue-500/10 border-blue-500/30 text-blue-500 hover:bg-blue-500/20 hover:text-blue-400"
> >
<Activity className="h-4 w-4" /> <Activity className="h-4 w-4" />
Refresh Status Refresh Status
</Button> </Button>
</div> </div>
<p className="text-xs text-muted-foreground"> <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. 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. You will receive a notification when the test completes.
</p> </p>
{/* Error Message */} {/* Error Message */}
{testError && ( {testError && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400"> <div className="flex items-start gap-2 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400">
<AlertTriangle className="h-4 w-4 mt-0.5 flex-shrink-0" /> <AlertTriangle className="h-4 w-4 mt-0.5 flex-shrink-0" />
<div className="flex-1"> <div className="flex-1">
<p className="text-sm font-medium">Failed to start test</p> <p className="text-sm font-medium">Failed to start test</p>
<p className="text-xs opacity-80">{testError}</p> <p className="text-xs opacity-80">{testError}</p>
</div> </div>
</div> </div>
)} )}
</div>
</div> {/* Test Progress - Show when API reports running OR when we just started a test */}
{(testStatus.status === 'running' || runningTest !== null) && (
{/* Test Progress */}
{testStatus.status === 'running' && (
<div className="border rounded-lg p-4 bg-blue-500/5 border-blue-500/20"> <div className="border rounded-lg p-4 bg-blue-500/5 border-blue-500/20">
<div className="flex items-center gap-3 mb-3"> <div className="flex items-center gap-3">
<Loader2 className="h-5 w-5 animate-spin text-blue-500" /> <Loader2 className="h-5 w-5 animate-spin text-blue-500" />
<div> <div className="flex-1">
<p className="font-medium text-blue-500"> <p className="font-medium text-blue-500">
{testStatus.test_type === 'short' ? 'Short' : 'Extended'} test in progress {(runningTest || testStatus.test_type) === 'short' ? 'Short' : 'Extended'} test in progress
</p> </p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Please wait while the test completes... Please wait while the test completes...
</p> </p>
</div> </div>
</div> </div>
{/* Only show progress bar if the disk reports progress percentage */}
{testStatus.progress !== undefined && ( {testStatus.progress !== undefined && (
<Progress value={testStatus.progress} className="h-2 [&>div]:bg-blue-500" /> <Progress value={testStatus.progress} className="h-2 mt-3 [&>div]:bg-blue-500" />
)} )}
</div> </div>
)} )}