{t("settings.title")}
{t("settings.description")}
{/* Interface Language Settings */}
{t("settings.interfaceLanguage.title")}
{t("settings.interfaceLanguage.description")}
{t("settings.interfaceLanguage.label")}
{/* Only render the note when the current locale actually has
one. Human-curated locales (en/es/sv/sk) leave the value
empty, so the visible line disappears entirely; the
auto-translated locales (de/fr/it/pt) surface the
Google Translate disclaimer. */}
{t("settings.interfaceLanguage.fallbackNote") && (
{t("settings.interfaceLanguage.fallbackNote")}
)}
{/* Navigation Tab Order — user-orderable top-level tabs */}
{/* Network Units Settings */}
{t("settings.networkUnits.title")}
{!loadingUnitSettings && (
{savedUnit && (
{t("status.saved")}
)}
{unitsEditMode ? (
<>
>
) : (
)}
)}
{t("settings.networkUnits.description")}
{loadingUnitSettings ? (
) : (
{t("settings.networkUnits.label")}
)}
{/* Health Monitor Settings */}
{t("settings.healthMonitor.title")}
{!loadingHealth && (
{savedAllHealth && (
{t("status.saved")}
)}
{healthEditMode ? (
<>
>
) : (
)}
)}
{t("settings.healthMonitor.description")}
{loadingHealth ? (
) : (
{/* Header */}
{t("settings.healthMonitor.category")}
{t("settings.healthMonitor.suppressionDuration")}
{/* Per-category rows */}
{suppressionCategories.map((cat) => {
const IconComp = CATEGORY_ICONS[cat.icon] || HeartPulse
const effectiveHours = getEffectiveHours(cat)
const isCustomMode = effectiveHours === -2 || (cat.key in customValues)
const isPermanent = effectiveHours === -1
const isLong = effectiveHours >= 720 && effectiveHours !== -1 && effectiveHours !== -2
const hasChanged = cat.key in pendingChanges && pendingChanges[cat.key] !== cat.hours
const selectVal = isCustomMode ? "custom" : getSelectValue(effectiveHours, cat.key)
const catLabel = tFallback(`settings.healthMonitor.categories.${healthCategoryKey(cat)}`, cat.label)
return (
{catLabel}
{hasChanged && healthEditMode && (
)}
{isCustomMode && healthEditMode ? (
setCustomValues(prev => ({ ...prev, [cat.key]: e.target.value }))}
placeholder={t("settings.healthMonitor.hours")}
/>
h
) : (
)}
{/* Notice for Permanent */}
{isPermanent && healthEditMode && (
{t("settings.healthMonitor.permanentNotice", { category: catLabel })}
{cat.category === "temperature" && (
{t("settings.healthMonitor.temperatureSafetyNotice")}
)}
)}
{/* Notice for long duration (> 1 month) */}
{isLong && healthEditMode && (
{t("settings.healthMonitor.longSuppressionNotice")}
)}
)
})}
{/* Info footer */}
{t("settings.healthMonitor.footerNote")}
{/* Active Suppressions subsection.
Lives inside the Health Monitor card (no separator).
Surfaces every currently-dismissed alert (time-limited
and permanent) with a Re-enable button gated by Edit
mode. Permanent dismisses chosen from the dashboard
"Dismiss → Permanently" dropdown can only be reverted
here, so this is the audit log + un-dismiss UI for
them. Time-limited dismisses (24h, 7d) are listed for
visibility and can also be force-revived from here. */}
{t("settings.healthMonitor.activeSuppressions")}
{t("settings.healthMonitor.activeSuppressionsDescription")}{" "}
{t("actions.edit")}.
{loadingSuppressions ? (
) : activeSuppressions.length === 0 ? (
{t("settings.healthMonitor.noActiveSuppressions")}
) : (
{activeSuppressions.map((s) => {
const remaining = s.suppression_remaining_hours
const remainingLabel = s.permanent
? t("settings.healthMonitor.permanent")
: remaining === undefined || remaining === null
? t("status.active")
: remaining >= 24
? t("settings.healthMonitor.daysRemaining", { count: Math.round(remaining / 24) })
: t("settings.healthMonitor.hoursRemaining", { count: Math.max(0, Math.round(remaining)) })
const dismissedAtLabel = s.acknowledged_at
? new Date(s.acknowledged_at).toLocaleString()
: ""
const isQueued = pendingReEnables.has(s.error_key)
return (
{s.permanent ? (
{t("settings.healthMonitor.permanent")}
) : (
{remainingLabel}
)}
{tFallback(`settings.healthMonitor.errorNames.${s.error_key}`, normalizeErrorKey(s.error_key))}
{t("settings.healthMonitor.labels.category")}: {s.category ? tFallback(`settings.healthMonitor.categories.${s.category}`, s.category) : "—"}
{s.severity && {t("settings.healthMonitor.labels.severity")}: {tFallback(`status.${s.severity.toLowerCase()}`, s.severity)}}
{dismissedAtLabel && {t("settings.healthMonitor.labels.dismissed")}: {dismissedAtLabel}}
)
})}
)}
)}
{/* Remote Storage Exclusions */}
{t("settings.remoteStorage.title")}
{!loadingStorages && remoteStorages.length > 0 && (
{savedStorages && (
{t("status.saved")}
)}
{storagesEditMode ? (
<>
>
) : (
)}
)}
{t("settings.remoteStorage.description")}
{loadingStorages ? (
) : remoteStorages.length === 0 ? (
{t("settings.remoteStorage.emptyTitle")}
{t("settings.remoteStorage.emptyDescription")}
) : (
{/* Header */}
{t("settings.remoteStorage.storage")}
{t("settings.common.health")}
{t("settings.common.alerts")}
{/* Storage rows - scrollable container */}
{remoteStorages.map((storage) => {
const isExcluded = storage.exclude_health || storage.exclude_notifications
const isSaving = savingStorage === storage.name
const isNamespaceRestricted = storage.status === 'namespace_restricted'
const isOffline = !isNamespaceRestricted && storage.status !== 'active'
const capacityKnown = storage.capacity_known ?? storage.total > 0
return (
{storage.name}
{storage.type}
{isOffline && (
{t("settings.remoteStorage.offline")}
)}
{isNamespaceRestricted && (
{t("settings.remoteStorage.namespaceRestricted")}
)}
{!isOffline && !isNamespaceRestricted && !capacityKnown && (
{t("storage.capacityNotReported")}
)}
{(() => {
// Resolve the visible checked state from the pending
// map first (unsaved local change) or fall back to
// the storage's persisted value.
const p = pendingStorages.get(storage.name)
const excludeHealth = p ? p.exclude_health : storage.exclude_health
const excludeNotif = p ? p.exclude_notifications : storage.exclude_notifications
return (
<>
{isSaving ? (
) : (
{
setPendingStorages(m => {
const next = new Map(m)
next.set(storage.name, { exclude_health: !checked, exclude_notifications: excludeNotif })
return next
})
}}
className={`data-[state=checked]:bg-blue-600 data-[state=unchecked]:bg-input border border-border ${!storagesEditMode ? "opacity-60" : ""}`}
/>
)}
{isSaving ? (
) : (
{
setPendingStorages(m => {
const next = new Map(m)
next.set(storage.name, { exclude_health: excludeHealth, exclude_notifications: !checked })
return next
})
}}
className={`data-[state=checked]:bg-blue-600 data-[state=unchecked]:bg-input border border-border ${!storagesEditMode ? "opacity-60" : ""}`}
/>
)}
>
)
})()}
)
})}
{/* Info footer */}
{t("settings.common.health")}: {t("settings.remoteStorage.healthHelp")}
{t("settings.common.alerts")}: {t("settings.remoteStorage.alertsHelp")}
)}
{/* Network Interface Exclusions */}
{t("settings.networkInterfaces.title")}
{!loadingInterfaces && networkInterfaces.length > 0 && (
{savedInterfaces && (
{t("status.saved")}
)}
{interfacesEditMode ? (
<>
>
) : (
)}
)}
{t("settings.networkInterfaces.description")}
{loadingInterfaces ? (
) : networkInterfaces.length === 0 ? (
{t("settings.networkInterfaces.emptyTitle")}
) : (
{/* Header */}
{t("settings.networkInterfaces.interface")}
{t("settings.common.health")}
{t("settings.common.alerts")}
{/* Interface rows - scrollable container */}
{networkInterfaces.map((iface) => {
const isExcluded = iface.exclude_health || iface.exclude_notifications
const isSaving = savingInterface === iface.name
const isDown = !iface.is_up
return (
{iface.name}
{interfaceTypeLabel(iface.type)}
{isDown && !isExcluded && (
{t("settings.networkInterfaces.down")}
)}
{isExcluded && (
{t("settings.networkInterfaces.excluded")}
)}
{iface.ip_address || t("settings.networkInterfaces.noIp")} {iface.speed > 0 ? `- ${iface.speed} Mbps` : ''}
{(() => {
const p = pendingInterfaces.get(iface.name)
const excludeHealth = p ? p.exclude_health : iface.exclude_health
const excludeNotif = p ? p.exclude_notifications : iface.exclude_notifications
return (
<>
{/* Health toggle */}
{isSaving ? (
) : (
{
setPendingInterfaces(m => {
const next = new Map(m)
next.set(iface.name, { exclude_health: !checked, exclude_notifications: excludeNotif })
return next
})
}}
className={`data-[state=checked]:bg-blue-600 data-[state=unchecked]:bg-input border border-border ${!interfacesEditMode ? "opacity-60" : ""}`}
/>
)}
{/* Notifications toggle */}
{isSaving ? (
) : (
{
setPendingInterfaces(m => {
const next = new Map(m)
next.set(iface.name, { exclude_health: excludeHealth, exclude_notifications: !checked })
return next
})
}}
className={`data-[state=checked]:bg-blue-600 data-[state=unchecked]:bg-input border border-border ${!interfacesEditMode ? "opacity-60" : ""}`}
/>
)}
>
)
})()}
)
})}
{/* Info footer */}
{t("settings.common.health")}: {t("settings.networkInterfaces.healthHelp")}
{t("settings.common.alerts")}: {t("settings.networkInterfaces.alertsHelp")}
)}
{/* Health Monitor Thresholds — placed above Notifications because the
values configured here drive what triggers the notifications below. */}
{/* LXC Update Detection — gates the per-CT apt/apk scan. When OFF,
the matching toggle in NotificationSettings is hidden (the
preference is preserved in the DB and reappears when detection
is re-enabled). */}
{/* GitHub API — optional authentication for app upstream checks. */}
{t("settings.githubApi.title")}
{!githubTokenLoading && (
{githubTokenSaved && (
{t("status.saved")}
)}
{githubTokenEditMode ? (
<>
>
) : (
)}
)}
{t("settings.githubApi.description")}
{githubTokenLoading ? (
) : githubTokenEditMode ? (
{githubTokenConfigured && (
)}
) : (
{githubTokenConfigured ? t("settings.githubApi.configured") : t("settings.githubApi.notConfigured")}
)}
{githubTokenError && (
)}
{/* Notification Settings */}
{/* Issue #195: snippets storage selector. Only renders when more
than one storage advertises content=snippets — on a typical
standalone host with just `local` there's nothing to choose,
so showing an empty selector would be noise. */}
{snippetsCandidates.length > 1 && (
{t("settings.snippets.title")}
{t("settings.snippets.description")}{" "}
local
{" "}{t("settings.snippets.localNote")}
{snippetsSaving && (
{t("status.saving")}
)}
{t("settings.snippets.footer")}
)}
{/* ProxMenux Optimizations */}
{t("settings.optimizations.title")}
{t("settings.optimizations.description")}
{loadingTools ? (
) : proxmenuxTools.length === 0 ? (
{t("settings.optimizations.emptyTitle")}
{t("settings.optimizations.emptyDescription")}
) : (
{t("settings.optimizations.installedTools")}
{t("settings.optimizations.activeCount", { count: proxmenuxTools.length })}
{/* Sprint 12B: count badge that doubles as the trigger
for the multi-select update modal. Only shown when
at least one tool has an available update. */}
{updatesAvailableCount > 0 && (
)}
{proxmenuxTools.map((tool) => {
const clickable = !!tool.has_source
const isDeprecated = !!tool.deprecated
// Sprint 12B: the card turns purple-tinted when an
// update is available — replaces the normal muted
// styling so the user sees at a glance which tools
// need attention. Click on the body still opens the
// source viewer; the small ArrowUpCircle on the right
// is the dedicated update trigger.
const hasUpdate = !!tool.has_update
const baseClasses = hasUpdate
? 'border-purple-500/40 bg-purple-500/10 hover:bg-purple-500/20 hover:border-purple-500/60'
: 'bg-muted/50 border-border hover:bg-muted hover:border-orange-500/40'
return (
viewToolSource(tool) : undefined}
className={`flex items-center justify-between gap-2 p-3 rounded-lg border transition-colors ${baseClasses} ${clickable ? 'cursor-pointer' : ''}`}
title={clickable ? (isDeprecated ? t("settings.optimizations.legacySourceTitle") : t("settings.optimizations.sourceTitle")) : undefined}
>
{tool.name}
{isDeprecated && (
{t("settings.optimizations.legacy")}
)}
{hasUpdate ? (
<>
v{tool.version || '1.0'} → v{tool.available_version || '?'}
>
) : (
v{tool.version || '1.0'}
)}
)
})}
)}
{/* Code Viewer Modal */}
{codeModal.open && (
setCodeModal(prev => ({ ...prev, open: false }))}>
e.stopPropagation()}
>
{/* Header */}
{codeModal.toolName}
{codeModal.deprecated && (
{t("settings.optimizations.legacy")}
)}
{codeModal.functionName && {codeModal.functionName}()}
{codeModal.script && — {codeModal.script}}
{/* Sprint 12B v2: when an update is pending the user
sees `v1.0 → v1.1` so the source viewer matches
the badge in the card. When no update, just the
single installed version. */}
{codeModal.version && codeModal.availableVersion && codeModal.availableVersion !== codeModal.version ? (
v{codeModal.version} → v{codeModal.availableVersion}
) : codeModal.version ? (
v{codeModal.version}
) : null}
{codeModal.source && (
)}
{/* Body */}
{codeModal.loading ? (
) : codeModal.error ? (
) : (
${highlightBash(codeModal.source)}` }}
/>
)}
)}
{/* Sprint 12B: multi-select Update modal — opened from the
"X updates" badge in the Optimizations card header. The user
ticks the tools they want to update, hits Update Selected,
and the wrapper script runs them all in one terminal session. */}
{updateModalOpen && (
setUpdateModalOpen(false)}>
e.stopPropagation()}
>
{t("settings.optimizations.availableUpdates")}
{t("settings.optimizations.availableUpdatesDescription", { count: updatesAvailableCount })}
{/* Sprint 12B v2: every row is selectable. Legacy bool
entries (no recorded source) default to the auto flow
on update — the previous "pick source first" path
required an extra click for what is in practice always
the same answer. */}
{proxmenuxTools.filter(t => t.has_update).map(tool => {
const isSelected = selectedUpdates.has(tool.key)
return (
)
})}
{t("settings.optimizations.selectedCount", { selected: selectedUpdates.size, total: updatesAvailableCount })}
)}
{/* Sprint 12B: terminal that runs the update_post_install_function.sh
wrapper. The wrapper sources the chosen flow script and invokes
one or many functions in sequence (FUNCTIONS_BATCH). On close
we refresh the tools list so the new versions show up. */}
{updateTerminal?.open && (
)}
)
}