fix(monitor): localize dynamic labels and generated status text

Localize runtime labels for SMART/NVMe details, backup target badges, firewall interface types, settings units, and generated health status copy while keeping the English and Slovak catalogs in key parity.
This commit is contained in:
Codex
2026-08-04 17:02:03 +02:00
parent 39e7a7d2db
commit d62fc29581
6 changed files with 143 additions and 60 deletions
@@ -334,6 +334,8 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
if (match) return t("healthStatus.details.gatewayLatency", { latency: match[1] }) if (match) return t("healthStatus.details.gatewayLatency", { latency: match[1] })
match = value.match(/^(\d+) failed login attempts in 24h$/) match = value.match(/^(\d+) failed login attempts in 24h$/)
if (match) return t("healthStatus.details.failedLogins", { count: match[1] }) if (match) return t("healthStatus.details.failedLogins", { count: match[1] })
match = value.match(/^(\d+) IP\(s\) currently banned by Fail2Ban \(jails: (.+)\)$/)
if (match) return t("healthStatus.details.fail2banBannedIps", { count: match[1], jails: match[2] })
match = value.match(/^Uptime (\d+) days?$/) match = value.match(/^Uptime (\d+) days?$/)
if (match) return t("healthStatus.details.uptimeDays", { count: match[1] }) if (match) return t("healthStatus.details.uptimeDays", { count: match[1] })
match = value.match(/^(\d+) package\(s\) pending$/) match = value.match(/^(\d+) package\(s\) pending$/)
+28 -11
View File
@@ -51,7 +51,7 @@ import { fetchApi, getApiUrl } from "../lib/api-config"
import { fetchTerminalTicket } from "../lib/terminal-ws" import { fetchTerminalTicket } from "../lib/terminal-ws"
import { formatStorage, formatBytes } from "../lib/utils" import { formatStorage, formatBytes } from "../lib/utils"
import { getStorageUsageColor } from "../lib/storage-usage-color" import { getStorageUsageColor } from "../lib/storage-usage-color"
import { useT } from "../lib/i18n/provider" import { useI18n, useT } from "../lib/i18n/provider"
type TFunction = (key: string, params?: Record<string, string | number>) => string type TFunction = (key: string, params?: Record<string, string | number>) => string
@@ -380,6 +380,23 @@ const methodBadgeCls = (m: string | undefined): string => {
} }
} }
const localizedBackendLabel = (source: string, t: (key: string) => string): string =>
source === "pbs" ? "PBS" : source === "borg" ? "Borg" : t("backup.backends.local")
const formatCalendarPreview = (value: string, language: string, t: (key: string) => string): string => {
if (language !== "sk") return value
const match = value.match(/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+(\d{4})-(\d{2})-(\d{2})\s+(.+)$/i)
if (!match) return value
const [, weekday, year, month, day, rest] = match
return `${t(`backup.weekdays.short.${weekday.toLowerCase()}`)} ${Number(day)}. ${Number(month)}. ${year} ${rest}`
}
const formatCalendarDistance = (value: string, language: string): string => {
if (language !== "sk") return value
const distance = value.replace(/\s+left$/i, "").replace(/\bdays?\b/gi, "d")
return `zostáva ${distance}`
}
const formatRunAt = (iso: string | null) => { const formatRunAt = (iso: string | null) => {
if (!iso) return null if (!iso) return null
try { try {
@@ -1314,7 +1331,7 @@ export function HostBackup() {
</div> </div>
<div className="text-[11px] text-muted-foreground mt-0.5 flex items-center gap-3 flex-wrap"> <div className="text-[11px] text-muted-foreground mt-0.5 flex items-center gap-3 flex-wrap">
<span className={`uppercase tracking-wide text-[10px] px-1.5 py-0.5 rounded border ${sourceBadgeCls}`}> <span className={`uppercase tracking-wide text-[10px] px-1.5 py-0.5 rounded border ${sourceBadgeCls}`}>
{u.source} {localizedBackendLabel(u.source, t)}
</span> </span>
{u.remote?.encrypted && ( {u.remote?.encrypted && (
<span <span
@@ -1981,7 +1998,7 @@ function InspectModal({
? "text-fuchsia-400 border-fuchsia-500/40 bg-fuchsia-500/10" ? "text-fuchsia-400 border-fuchsia-500/40 bg-fuchsia-500/10"
: "text-blue-400 border-blue-500/40 bg-blue-500/10" : "text-blue-400 border-blue-500/40 bg-blue-500/10"
}`}> }`}>
{archive.source} {localizedBackendLabel(archive.source, t)}
</Badge> </Badge>
)} )}
{remoteArc?.encrypted && ( {remoteArc?.encrypted && (
@@ -2016,9 +2033,9 @@ function InspectModal({
<div><span className="text-muted-foreground">{t("backup.fields.sizeLabel")}</span> {formatBytes(localArc.size_bytes)}</div> <div><span className="text-muted-foreground">{t("backup.fields.sizeLabel")}</span> {formatBytes(localArc.size_bytes)}</div>
<div className="sm:col-span-2"><span className="text-muted-foreground">{t("backup.fields.pathLabel")}</span> <code className="font-mono break-all">{localArc.path}</code></div> <div className="sm:col-span-2"><span className="text-muted-foreground">{t("backup.fields.pathLabel")}</span> <code className="font-mono break-all">{localArc.path}</code></div>
{localArc.job_id && <div><span className="text-muted-foreground">{t("backup.fields.jobIdLabel")}</span> <code className="font-mono">{localArc.job_id}</code></div>} {localArc.job_id && <div><span className="text-muted-foreground">{t("backup.fields.jobIdLabel")}</span> <code className="font-mono">{localArc.job_id}</code></div>}
{localArc.profile && <div><span className="text-muted-foreground">{t("backup.fields.profileLabel")}</span> <code className="font-mono">{localArc.profile}</code></div>} {localArc.profile && <div><span className="text-muted-foreground">{t("backup.fields.profileLabel")}</span> <code className="font-mono">{localArc.profile === "default" ? t("backup.profile.default") : localArc.profile === "custom" ? t("backup.profile.custom") : localArc.profile}</code></div>}
{localArc.source_hostname && <div><span className="text-muted-foreground">{t("backup.fields.sourceHostLabel")}</span> <code className="font-mono">{localArc.source_hostname}</code></div>} {localArc.source_hostname && <div><span className="text-muted-foreground">{t("backup.fields.sourceHostLabel")}</span> <code className="font-mono">{localArc.source_hostname}</code></div>}
<div><span className="text-muted-foreground">{t("backup.fields.detectedViaLabel")}</span> <code className="font-mono text-[10px]">{localArc.detected_via}</code></div> <div><span className="text-muted-foreground">{t("backup.fields.detectedViaLabel")}</span> <code className="font-mono text-[10px]">{localArc.detected_via === "sidecar" ? t("backup.archives.companionFile") : localArc.detected_via}</code></div>
</> </>
) : null} ) : null}
</div> </div>
@@ -2779,7 +2796,7 @@ function CreateJobDialog({
onCreated: () => void onCreated: () => void
editingJobId?: string | null editingJobId?: string | null
}) { }) {
const t = useT() const { t, language } = useI18n()
const isEdit = !!editingJobId const isEdit = !!editingJobId
const [step, setStep] = useState<1 | 2 | 3 | 4 | 5>(1) const [step, setStep] = useState<1 | 2 | 3 | 4 | 5>(1)
const [jobId, setJobId] = useState("") const [jobId, setJobId] = useState("")
@@ -3343,7 +3360,7 @@ function CreateJobDialog({
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
{isEdit {isEdit
? t("backup.jobs.jobNameLocked") ? t("backup.jobs.jobNameLocked")
: <>{t("backup.jobs.jobNameHelpBefore")} <code className="font-mono">_</code> {t("backup.jobs.jobNameHelpAnd")} <code className="font-mono">-</code> {t("backup.jobs.jobNameHelpAfter")}</>} : <>{t("backup.jobs.jobNameHelpBefore")} <code className="font-mono">_</code> {t("backup.jobs.jobNameHelpAnd")} <code className="font-mono">-</code>. {t("backup.jobs.jobNameHelpAfter")}</>}
</p> </p>
{!idValid && jobId.length > 0 && !isEdit && ( {!idValid && jobId.length > 0 && !isEdit && (
<p className="text-xs text-red-500 mt-1">{t("backup.jobs.invalidJobName")}</p> <p className="text-xs text-red-500 mt-1">{t("backup.jobs.invalidJobName")}</p>
@@ -3654,9 +3671,9 @@ function CreateJobDialog({
{calendarPreview.next_elapse && ( {calendarPreview.next_elapse && (
<div className="flex items-baseline gap-2"> <div className="flex items-baseline gap-2">
<span className="text-muted-foreground">{t("backup.jobs.nextRunLabel")}</span> <span className="text-muted-foreground">{t("backup.jobs.nextRunLabel")}</span>
<span className="text-emerald-400">{calendarPreview.next_elapse}</span> <span className="text-emerald-400">{formatCalendarPreview(calendarPreview.next_elapse, language, t)}</span>
{calendarPreview.from_now && ( {calendarPreview.from_now && (
<span className="text-muted-foreground">({calendarPreview.from_now})</span> <span className="text-muted-foreground">({formatCalendarDistance(calendarPreview.from_now, language)})</span>
)} )}
</div> </div>
)} )}
@@ -5774,7 +5791,7 @@ function DestinationRow({
<Icon className={`h-5 w-5 flex-shrink-0 ${iconColor} mt-0.5`} /> <Icon className={`h-5 w-5 flex-shrink-0 ${iconColor} mt-0.5`} />
<h3 className="font-mono font-semibold text-sm break-all">{headline}</h3> <h3 className="font-mono font-semibold text-sm break-all">{headline}</h3>
<Badge variant="outline" className={`text-[10px] uppercase tracking-wide ${accent}`}> <Badge variant="outline" className={`text-[10px] uppercase tracking-wide ${accent}`}>
{item.kind} {localizedBackendLabel(item.kind, t)}
</Badge> </Badge>
{item.kind === "local" && item.source === "default" && ( {item.kind === "local" && item.source === "default" && (
<Badge variant="outline" className="text-[10px] uppercase tracking-wide border-border text-muted-foreground"> <Badge variant="outline" className="text-[10px] uppercase tracking-wide border-border text-muted-foreground">
@@ -5802,7 +5819,7 @@ function DestinationRow({
</Badge> </Badge>
) : ( ) : (
<Badge variant="outline" className="text-[10px] uppercase tracking-wide border-amber-500/40 text-amber-400 bg-amber-500/5"> <Badge variant="outline" className="text-[10px] uppercase tracking-wide border-amber-500/40 text-amber-400 bg-amber-500/5">
local {t("backup.backends.local")}
</Badge> </Badge>
)} )}
{/* Encryption indicator icon-only chip for encrypted {/* Encryption indicator icon-only chip for encrypted
+6 -2
View File
@@ -68,6 +68,10 @@ function validatePasswordStrength(pw: string, t: (key: string) => string): strin
export function Security() { export function Security() {
const { language, t } = useI18n() const { language, t } = useI18n()
const st = (key: string, params?: Record<string, string | number>) => t(`securityPage.${key}`, params) const st = (key: string, params?: Record<string, string | number>) => t(`securityPage.${key}`, params)
const interfaceTypeLabel = (type: string) =>
["physical", "bridge", "bond", "vlan", "virtual"].includes(type)
? t(`network.interfaceTypes.${type}`)
: type
const authErrorText = (message: unknown, fallbackKey: string) => { const authErrorText = (message: unknown, fallbackKey: string) => {
const raw = typeof message === "string" ? message : "" const raw = typeof message === "string" ? message : ""
const normalized = raw.toLowerCase() const normalized = raw.toLowerCase()
@@ -2927,7 +2931,7 @@ ${(report.sections && report.sections.length > 0) ? `
<option value="">{st("firewall.anyInterface")}</option> <option value="">{st("firewall.anyInterface")}</option>
{networkInterfaces.map((iface) => ( {networkInterfaces.map((iface) => (
<option key={iface.name} value={iface.name}> <option key={iface.name} value={iface.name}>
{iface.name} ({iface.type}{iface.status === "up" ? `, ${st("values.up")}` : `, ${st("values.down")}`}) {iface.name} ({interfaceTypeLabel(iface.type)}{iface.status === "up" ? `, ${st("values.up")}` : `, ${st("values.down")}`})
</option> </option>
))} ))}
</select> </select>
@@ -3113,7 +3117,7 @@ ${(report.sections && report.sections.length > 0) ? `
<option value="">{st("firewall.any")}</option> <option value="">{st("firewall.any")}</option>
{networkInterfaces.map((iface) => ( {networkInterfaces.map((iface) => (
<option key={iface.name} value={iface.name}> <option key={iface.name} value={iface.name}>
{iface.name} ({iface.type}) {iface.name} ({interfaceTypeLabel(iface.type)})
</option> </option>
))} ))}
</select> </select>
+8 -6
View File
@@ -313,6 +313,8 @@ export function Settings() {
const translated = t(key) const translated = t(key)
return translated === key ? fallback : translated return translated === key ? fallback : translated
} }
const interfaceTypeLabel = (type: string) =>
tFallback(`network.interfaceTypes.${type.toLowerCase()}`, type)
const [proxmenuxTools, setProxmenuxTools] = useState<ProxMenuxTool[]>([]) const [proxmenuxTools, setProxmenuxTools] = useState<ProxMenuxTool[]>([])
const [updatesAvailableCount, setUpdatesAvailableCount] = useState(0) const [updatesAvailableCount, setUpdatesAvailableCount] = useState(0)
const [loadingTools, setLoadingTools] = useState(true) const [loadingTools, setLoadingTools] = useState(true)
@@ -1005,8 +1007,8 @@ export function Settings() {
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="Bytes">Bytes</SelectItem> <SelectItem value="Bytes">{t("settings.networkUnits.bytes")}</SelectItem>
<SelectItem value="Bits">Bits</SelectItem> <SelectItem value="Bits">{t("settings.networkUnits.bits")}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -1263,7 +1265,7 @@ export function Settings() {
</div> </div>
<div className="text-sm text-muted-foreground flex flex-wrap gap-x-3 gap-y-0.5 mt-0.5"> <div className="text-sm text-muted-foreground flex flex-wrap gap-x-3 gap-y-0.5 mt-0.5">
<span>{t("settings.healthMonitor.labels.category")}: <span className="font-medium text-foreground/80">{s.category ? tFallback(`settings.healthMonitor.categories.${s.category}`, s.category) : "—"}</span></span> <span>{t("settings.healthMonitor.labels.category")}: <span className="font-medium text-foreground/80">{s.category ? tFallback(`settings.healthMonitor.categories.${s.category}`, s.category) : "—"}</span></span>
{s.severity && <span>{t("settings.healthMonitor.labels.severity")}: <span className="font-medium text-foreground/80">{tFallback(`status.${s.severity}`, s.severity)}</span></span>} {s.severity && <span>{t("settings.healthMonitor.labels.severity")}: <span className="font-medium text-foreground/80">{tFallback(`status.${s.severity.toLowerCase()}`, s.severity)}</span></span>}
{dismissedAtLabel && <span>{t("settings.healthMonitor.labels.dismissed")}: {dismissedAtLabel}</span>} {dismissedAtLabel && <span>{t("settings.healthMonitor.labels.dismissed")}: {dismissedAtLabel}</span>}
</div> </div>
</div> </div>
@@ -1467,7 +1469,7 @@ export function Settings() {
{iface.name} {iface.name}
</span> </span>
<Badge variant="outline" className="text-[10px] px-1.5 py-0"> <Badge variant="outline" className="text-[10px] px-1.5 py-0">
{iface.type} {interfaceTypeLabel(iface.type)}
</Badge> </Badge>
{isDown && !isExcluded && ( {isDown && !isExcluded && (
<Badge variant="destructive" className="text-[10px] px-1.5 py-0"> <Badge variant="destructive" className="text-[10px] px-1.5 py-0">
@@ -1569,9 +1571,9 @@ export function Settings() {
<CardTitle>{t("settings.snippets.title")}</CardTitle> <CardTitle>{t("settings.snippets.title")}</CardTitle>
</div> </div>
<CardDescription> <CardDescription>
{t("settings.snippets.description")} {t("settings.snippets.description")}{" "}
<code className="mx-1">local</code> <code className="mx-1">local</code>
{t("settings.snippets.localNote")} {" "}{t("settings.snippets.localNote")}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
+53 -35
View File
@@ -20,6 +20,45 @@ import {
type DiskTempMap, type DiskTempMap,
} from "../lib/health-thresholds" } from "../lib/health-thresholds"
// Raw smartctl names are shared by the compact SMART tab and the full
// report. Keep one canonical mapping so both views use the same labels
// and explanations instead of drifting into separate translations.
const NVME_SMART_ATTRIBUTE_KEYS: Record<string, string> = {
"Critical Warning": "criticalWarning",
"Temperature": "nvmeTemperature",
"Temperature Sensor 1": "temperatureSensor1",
"Temperature Sensor 2": "temperatureSensor2",
"Temperature Sensor 3": "temperatureSensor3",
"Available Spare": "availableSpare",
"Available Spare Threshold": "availableSpareThreshold",
"Percentage Used": "percentageUsed",
"Percent Used": "percentageUsed",
"Endurance Group Warning": "enduranceGroupWarning",
"Media Errors": "mediaErrors",
"Media and Data Integrity Errors": "mediaIntegrityErrors",
"Unsafe Shutdowns": "unsafeShutdowns",
"Power Cycles": "nvmePowerCycles",
"Power On Hours": "nvmePowerOnHours",
"Data Units Read": "dataUnitsRead",
"Data Units Written": "dataUnitsWritten",
"Host Read Commands": "hostReadCommands",
"Host Write Commands": "hostWriteCommands",
"Controller Busy Time": "controllerBusyTime",
"Error Log Entries": "errorLogEntries",
"Error Information Log Entries": "errorLogEntries",
"Warning Temp Time": "warningTempTime",
"Critical Temp Time": "criticalTempTime",
"Warning Composite Temperature Time": "warningCompositeTemperatureTime",
"Critical Composite Temperature Time": "criticalCompositeTemperatureTime",
"Thermal Management T1 Trans Count": "thermalManagementT1TransCount",
"Thermal Management T2 Trans Count": "thermalManagementT2TransCount",
"Thermal Management T1 Total Time": "thermalManagementT1TotalTime",
"Thermal Management T2 Total Time": "thermalManagementT2TotalTime",
}
const getNvmeSmartAttributeKey = (name: string): string | undefined =>
NVME_SMART_ATTRIBUTE_KEYS[name.replace(/_/g, " ")] || NVME_SMART_ATTRIBUTE_KEYS[name]
interface DiskInfo { interface DiskInfo {
name: string name: string
size?: number // Changed from string to number (KB) for formatMemory() size?: number // Changed from string to number (KB) for formatMemory()
@@ -2359,38 +2398,6 @@ 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'
const nvmeExplanationKeys: Record<string, string> = {
'Critical Warning': 'criticalWarning',
'Temperature': 'nvmeTemperature',
'Temperature Sensor 1': 'temperatureSensor1',
'Temperature Sensor 2': 'temperatureSensor2',
'Temperature Sensor 3': 'temperatureSensor3',
'Available Spare': 'availableSpare',
'Available Spare Threshold': 'availableSpareThreshold',
'Percentage Used': 'percentageUsed',
'Percent Used': 'percentageUsed',
'Media Errors': 'mediaErrors',
'Media and Data Integrity Errors': 'mediaIntegrityErrors',
'Unsafe Shutdowns': 'unsafeShutdowns',
'Power Cycles': 'nvmePowerCycles',
'Power On Hours': 'nvmePowerOnHours',
'Data Units Read': 'dataUnitsRead',
'Data Units Written': 'dataUnitsWritten',
'Host Read Commands': 'hostReadCommands',
'Host Write Commands': 'hostWriteCommands',
'Controller Busy Time': 'controllerBusyTime',
'Error Log Entries': 'errorLogEntries',
'Error Information Log Entries': 'errorLogEntries',
'Warning Temp Time': 'warningTempTime',
'Critical Temp Time': 'criticalTempTime',
'Warning Composite Temperature Time': 'warningCompositeTemperatureTime',
'Critical Composite Temperature Time': 'criticalCompositeTemperatureTime',
'Thermal Management T1 Trans Count': 'thermalManagementT1TransCount',
'Thermal Management T2 Trans Count': 'thermalManagementT2TransCount',
'Thermal Management T1 Total Time': 'thermalManagementT1TotalTime',
'Thermal Management T2 Total Time': 'thermalManagementT2TotalTime',
}
const sataExplanationKeys: Record<string, string> = { const sataExplanationKeys: Record<string, string> = {
'Raw Read Error Rate': 'rawReadErrorRate', 'Raw Read Error Rate': 'rawReadErrorRate',
'Write Error Rate': 'writeErrorRate', 'Write Error Rate': 'writeErrorRate',
@@ -2518,7 +2525,7 @@ function openSmartReport(disk: DiskInfo, testStatus: SmartTestStatus, smartAttri
const cleanName = name.replace(/_/g, ' ') const cleanName = name.replace(/_/g, ' ')
const keyPrefix = 'storage.smartReport.attributeExplanations.' const keyPrefix = 'storage.smartReport.attributeExplanations.'
if (diskKind === 'NVMe') { if (diskKind === 'NVMe') {
const key = nvmeExplanationKeys[cleanName] || nvmeExplanationKeys[name] const key = getNvmeSmartAttributeKey(cleanName)
return key ? t(`${keyPrefix}${key}`) : '' return key ? t(`${keyPrefix}${key}`) : ''
} }
if (diskKind === 'SAS') { if (diskKind === 'SAS') {
@@ -2529,6 +2536,12 @@ function openSmartReport(disk: DiskInfo, testStatus: SmartTestStatus, smartAttri
return key ? t(`${keyPrefix}${key}`) : '' return key ? t(`${keyPrefix}${key}`) : ''
} }
const getAttrLabel = (name: string, diskKind: string): string => {
if (diskKind !== 'NVMe') return name.replace(/_/g, ' ')
const key = getNvmeSmartAttributeKey(name)
return key ? t(`storage.smartReport.attributeLabels.${key}`) : name.replace(/_/g, ' ')
}
const attrStatusText = (status?: string) => { const attrStatusText = (status?: string) => {
const s = (status || '').toLowerCase() const s = (status || '').toLowerCase()
if (s === 'ok') return tSmart("statusValues.ok") if (s === 'ok') return tSmart("statusValues.ok")
@@ -2615,7 +2628,7 @@ function openSmartReport(disk: DiskInfo, testStatus: SmartTestStatus, smartAttri
const displayValue = isSasDisk ? attr.raw_value : attr.value const displayValue = isSasDisk ? attr.raw_value : attr.value
return ` return `
<tr> <tr>
<td class="col-name" style="font-weight:500;${explanation ? 'border-bottom:none;padding-bottom:2px;' : ''}">${attr.name}</td> <td class="col-name" style="font-weight:500;${explanation ? 'border-bottom:none;padding-bottom:2px;' : ''}">${getAttrLabel(attr.name, diskType)}</td>
<td style="text-align:center;font-family:monospace;${explanation ? 'border-bottom:none;' : ''}">${displayValue}</td> <td style="text-align:center;font-family:monospace;${explanation ? 'border-bottom:none;' : ''}">${displayValue}</td>
<td style="${explanation ? 'border-bottom:none;' : ''}"><span class="f-tag" style="background:${statusBg};color:${statusColor}">${attrStatusText(attr.status)}</span></td> <td style="${explanation ? 'border-bottom:none;' : ''}"><span class="f-tag" style="background:${statusBg};color:${statusColor}">${attrStatusText(attr.status)}</span></td>
</tr> </tr>
@@ -3813,6 +3826,11 @@ function SmartTestTab({ disk, observations = [], lastTestDate }: SmartTestTabPro
// Extract SMART attributes from testStatus for the report // Extract SMART attributes from testStatus for the report
const smartAttributes = testStatus.smart_data?.attributes || [] const smartAttributes = testStatus.smart_data?.attributes || []
const smartAttributeLabel = (name: string): string => {
if (!disk.name.startsWith("nvme")) return name.replace(/_/g, " ")
const key = getNvmeSmartAttributeKey(name)
return key ? t(`storage.smartReport.attributeLabels.${key}`) : name.replace(/_/g, " ")
}
const fetchSmartStatus = async () => { const fetchSmartStatus = async () => {
try { try {
@@ -4138,7 +4156,7 @@ function SmartTestTab({ disk, observations = [], lastTestDate }: SmartTestTabPro
{testStatus.smart_data.attributes.slice(0, 15).map((attr) => ( {testStatus.smart_data.attributes.slice(0, 15).map((attr) => (
<div key={attr.id} className={`grid ${(isNvme || testStatus.smart_data?.is_sas) ? 'grid-cols-10' : 'grid-cols-12'} gap-2 p-3 text-sm items-center`}> <div key={attr.id} className={`grid ${(isNvme || testStatus.smart_data?.is_sas) ? 'grid-cols-10' : 'grid-cols-12'} gap-2 p-3 text-sm items-center`}>
{!isNvme && !testStatus.smart_data?.is_sas && <div className="col-span-1 text-muted-foreground">{attr.id}</div>} {!isNvme && !testStatus.smart_data?.is_sas && <div className="col-span-1 text-muted-foreground">{attr.id}</div>}
<div className={`${(isNvme || testStatus.smart_data?.is_sas) ? 'col-span-5' : 'col-span-5'} truncate`} title={attr.name}>{attr.name}</div> <div className={`${(isNvme || testStatus.smart_data?.is_sas) ? 'col-span-5' : 'col-span-5'} truncate`} title={smartAttributeLabel(attr.name)}>{smartAttributeLabel(attr.name)}</div>
<div className={`${(isNvme || testStatus.smart_data?.is_sas) ? 'col-span-3' : 'col-span-2'} text-center font-mono`}>{testStatus.smart_data?.is_sas ? attr.raw_value : attr.value}</div> <div className={`${(isNvme || testStatus.smart_data?.is_sas) ? 'col-span-3' : 'col-span-2'} text-center font-mono`}>{testStatus.smart_data?.is_sas ? attr.raw_value : attr.value}</div>
{!isNvme && !testStatus.smart_data?.is_sas && <div className="col-span-2 text-center font-mono text-muted-foreground">{attr.worst}</div>} {!isNvme && !testStatus.smart_data?.is_sas && <div className="col-span-2 text-center font-mono text-muted-foreground">{attr.worst}</div>}
<div className="col-span-2 text-center"> <div className="col-span-2 text-center">
+46 -6
View File
@@ -541,6 +541,36 @@
"footer": { "footer": {
"generatedBy": "Report generated by ProxMenux Monitor" "generatedBy": "Report generated by ProxMenux Monitor"
}, },
"attributeLabels": {
"criticalWarning": "Critical warning",
"nvmeTemperature": "Temperature",
"temperatureSensor1": "Temperature sensor 1",
"temperatureSensor2": "Temperature sensor 2",
"temperatureSensor3": "Temperature sensor 3",
"availableSpare": "Available spare",
"availableSpareThreshold": "Available spare threshold",
"percentageUsed": "Percentage used",
"enduranceGroupWarning": "Endurance group warning",
"mediaErrors": "Media errors",
"mediaIntegrityErrors": "Media and data integrity errors",
"unsafeShutdowns": "Unsafe shutdowns",
"nvmePowerCycles": "Power cycles",
"nvmePowerOnHours": "Power-on hours",
"dataUnitsRead": "Data units read",
"dataUnitsWritten": "Data units written",
"hostReadCommands": "Host read commands",
"hostWriteCommands": "Host write commands",
"controllerBusyTime": "Controller busy time",
"errorLogEntries": "Error log entries",
"warningTempTime": "Warning temperature time",
"criticalTempTime": "Critical temperature time",
"warningCompositeTemperatureTime": "Warning composite temperature time",
"criticalCompositeTemperatureTime": "Critical composite temperature time",
"thermalManagementT1TransCount": "Thermal management T1 transitions",
"thermalManagementT2TransCount": "Thermal management T2 transitions",
"thermalManagementT1TotalTime": "Thermal management T1 total time",
"thermalManagementT2TotalTime": "Thermal management T2 total time"
},
"attributeExplanations": { "attributeExplanations": {
"criticalWarning": "Active alert flags from the NVMe controller. Any non-zero value requires immediate investigation.", "criticalWarning": "Active alert flags from the NVMe controller. Any non-zero value requires immediate investigation.",
"nvmeTemperature": "Composite temperature reported by the controller. Sustained high temperatures cause thermal throttling and reduce NAND lifespan.", "nvmeTemperature": "Composite temperature reported by the controller. Sustained high temperatures cause thermal throttling and reduce NAND lifespan.",
@@ -1119,7 +1149,9 @@
"networkUnits": { "networkUnits": {
"title": "Network Units", "title": "Network Units",
"description": "Change how network traffic is displayed", "description": "Change how network traffic is displayed",
"label": "Network Unit Display" "label": "Network Unit Display",
"bytes": "Bytes",
"bits": "Bits"
}, },
"healthMonitor": { "healthMonitor": {
"title": "Health Monitor", "title": "Health Monitor",
@@ -1856,26 +1888,32 @@
"counts": { "counts": {
"tests": { "tests": {
"one": "{count} test", "one": "{count} test",
"few": "{count} tests",
"many": "{count} tests" "many": "{count} tests"
}, },
"warnings": { "warnings": {
"one": "{count} warning", "one": "{count} warning",
"few": "{count} warnings",
"many": "{count} warnings" "many": "{count} warnings"
}, },
"suggestions": { "suggestions": {
"one": "{count} suggestion", "one": "{count} suggestion",
"few": "{count} suggestions",
"many": "{count} suggestions" "many": "{count} suggestions"
}, },
"testsExecuted": { "testsExecuted": {
"one": "{count} test was executed.", "one": "{count} test was executed.",
"few": "{count} tests were executed.",
"many": "{count} tests were executed." "many": "{count} tests were executed."
}, },
"actionableWarnings": { "actionableWarnings": {
"one": "{count} actionable warning", "one": "{count} actionable warning",
"few": "{count} actionable warnings",
"many": "{count} actionable warnings" "many": "{count} actionable warnings"
}, },
"actionableSuggestions": { "actionableSuggestions": {
"one": "{count} actionable suggestion", "one": "{count} actionable suggestion",
"few": "{count} actionable suggestions",
"many": "{count} actionable suggestions" "many": "{count} actionable suggestions"
} }
}, },
@@ -2319,7 +2357,8 @@
"bridges": "Bridges", "bridges": "Bridges",
"bond": "Bond", "bond": "Bond",
"vlan": "VLAN", "vlan": "VLAN",
"virtual": "Virtual" "virtual": "Virtual",
"other": "Other"
}, },
"labels": { "labels": {
"activeSlave": "Active slave", "activeSlave": "Active slave",
@@ -2805,8 +2844,9 @@
"deleteLocalTitle": "Delete local archive", "deleteLocalTitle": "Delete local archive",
"deletePbsDescription": "This removes the PBS snapshot from the datastore.", "deletePbsDescription": "This removes the PBS snapshot from the datastore.",
"deletePbsTitle": "Delete PBS snapshot", "deletePbsTitle": "Delete PBS snapshot",
"descriptionAfter": "known backups", "companionFile": "companion file",
"descriptionBefore": "Browse and restore", "descriptionAfter": "",
"descriptionBefore": "Browse and restore backups found in",
"downloadTitle": "Download this backup", "downloadTitle": "Download this backup",
"emptyAfter": "backups yet.", "emptyAfter": "backups yet.",
"emptyBefore": "No", "emptyBefore": "No",
@@ -3113,7 +3153,7 @@
"invalidJobName": "Use only letters, numbers, dashes and underscores.", "invalidJobName": "Use only letters, numbers, dashes and underscores.",
"jobNameHelpAfter": "Keep it short and readable.", "jobNameHelpAfter": "Keep it short and readable.",
"jobNameHelpAnd": "and", "jobNameHelpAnd": "and",
"jobNameHelpBefore": "Used in timer names, logs", "jobNameHelpBefore": "Used in timer names and logs. Allowed characters also include",
"jobNameLocked": "Job name is locked after creation.", "jobNameLocked": "Job name is locked after creation.",
"lastRun": "Last run", "lastRun": "Last run",
"lastRunLabel": "Last run", "lastRunLabel": "Last run",
@@ -3517,7 +3557,7 @@
"active": "Active", "up": "UP", "kernelUpToDate": "Kernel/PVE is up to date", "proxmoxUpToDate": "Proxmox VE is up to date", "active": "Active", "up": "UP", "kernelUpToDate": "Kernel/PVE is up to date", "proxmoxUpToDate": "Proxmox VE is up to date",
"noSecurityUpdates": "No security updates pending", "noContainerErrors": "No container startup errors", "noOomEvents": "No OOM events detected", "noSecurityUpdates": "No security updates pending", "noContainerErrors": "No container startup errors", "noOomEvents": "No OOM events detected",
"noQmpTimeouts": "No QMP timeouts detected", "noVmFailures": "No VM startup failures", "dismissedByUser": "Dismissed by user", "noQmpTimeouts": "No QMP timeouts detected", "noVmFailures": "No VM startup failures", "dismissedByUser": "Dismissed by user",
"gatewayLatency": "Latency to gateway: {latency} ms", "failedLogins": "{count} failed login attempts in 24h", "uptimeDays": "Uptime: {count} days", "gatewayLatency": "Latency to gateway: {latency} ms", "failedLogins": "{count} failed login attempts in 24h", "fail2banBannedIps": "Fail2Ban is currently blocking {count} IP addresses (jails: {jails})", "uptimeDays": "Uptime: {count} days",
"pendingPackages": "{count} packages pending", "updatedDaysAgo": "Last updated {count} days ago", "storageAvailable": "{type} storage is available", "pendingPackages": "{count} packages pending", "updatedDaysAgo": "Last updated {count} days ago", "storageAvailable": "{type} storage is available",
"mountReachable": "{type} mount is reachable", "rootfsUsed": "rootfs usage: {percent}% ({size})", "runningCtsSafe": "{count} running CTs have safe rootfs usage", "mountReachable": "{type} mount is reachable", "rootfsUsed": "rootfs usage: {percent}% ({size})", "runningCtsSafe": "{count} running CTs have safe rootfs usage",
"pveStorageSafe": "{count} PVE block storage targets have safe usage", "remoteMountsHealthy": "{count} remote mounts are healthy" "pveStorageSafe": "{count} PVE block storage targets have safe usage", "remoteMountsHealthy": "{count} remote mounts are healthy"