Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ release version + date and opens a fresh `[Unreleased]`.

## [Unreleased]

## [0.1.40-beta9] - 2026-05-30

### Fixed
- Disk and CPU temperature warnings now use your Unraid global temperature thresholds (and per-disk overrides) instead of fixed defaults. A 52 °C disk was previously shown as overheating when the user's configured critical threshold is 60 °C; it now correctly shows as normal.

## [0.1.40-beta8] - 2026-05-30

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ android {
applicationId = "io.github.nofuturekid.nova"
minSdk = 26
targetSdk = 36
versionCode = 114
versionName = "0.1.40-beta8"
versionCode = 115
versionName = "0.1.40-beta9"

vectorDrawables { useSupportLibrary = true }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ query GetNetworkThroughput {
}
}

query GetDisplay {
display { hot max warning critical }
}

query GetArray {
array {
state
Expand Down
13 changes: 13 additions & 0 deletions app/src/main/graphql/io/github/nofuturekid/nova/schema.graphqls
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,19 @@ type Query {
installedUnraidPlugins: [String!]!
plugins: [Plugin!]!
pluginInstallOperations: [PluginInstallOperation!]!
display: InfoDisplay
}

# ── Display (global temperature thresholds) ──────────────────────────
# Subset of the live Unraid `InfoDisplay` type (dynamix display settings).
# hot/max are global disk warn/crit thresholds; warning/critical are CPU/system.
# Verified against live server 2026-05-30: hot=55, max=60, warning=85, critical=95.
# `unit` is intentionally NOT selected — no unit conversion in this beta.
type InfoDisplay {
hot: Int
max: Int
warning: Int
critical: Int
}

# ── Notifications ────────────────────────────────────────────────────
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import io.github.nofuturekid.nova.data.model.ContainerLiveStats
import io.github.nofuturekid.nova.data.model.ContainerStatus
import io.github.nofuturekid.nova.data.model.ContainerUpdateStatus
import io.github.nofuturekid.nova.data.model.Disk
import io.github.nofuturekid.nova.data.model.DisplayThresholds
import io.github.nofuturekid.nova.data.model.DiskStatus
import io.github.nofuturekid.nova.data.model.DiskType
import io.github.nofuturekid.nova.data.model.LiveMetrics
Expand All @@ -30,6 +31,7 @@ import io.github.nofuturekid.nova.data.model.Vm
import io.github.nofuturekid.nova.data.model.VmState
import io.github.nofuturekid.nova.data.model.selectTemperature
import io.github.nofuturekid.nova.graphql.GetArrayQuery
import io.github.nofuturekid.nova.graphql.GetDisplayQuery
import io.github.nofuturekid.nova.graphql.GetDockerContainersQuery
import io.github.nofuturekid.nova.graphql.GetMetricsQuery
import io.github.nofuturekid.nova.graphql.GetNetworkInterfacesQuery
Expand Down Expand Up @@ -87,6 +89,25 @@ fun GetServerInfoQuery.Data.toServerInfo(): ServerInfo {
)
}

// ── Display (global temperature thresholds) ──────────────────────────

/**
* Maps the `display` query response to [DisplayThresholds].
*
* Mapping: hot → diskWarnC, max → diskCritC, warning → cpuWarnC, critical → cpuCritC.
* All nullable — the server may return null for the entire display block.
*
* WHY: global thresholds prevent false disk-temp alarms when per-disk thresholds
* are not configured (the common case). See ADR-0045 beta9 update.
*/
fun GetDisplayQuery.Data.toDisplayThresholds(): DisplayThresholds =
DisplayThresholds(
diskWarnC = display?.hot,
diskCritC = display?.max,
cpuWarnC = display?.warning,
cpuCritC = display?.critical,
)

// ── Metrics ──────────────────────────────────────────────────────────

fun GetMetricsQuery.Data.toLiveMetrics(): LiveMetrics {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,10 @@ enum class DiskStatus { Ok, Error }
* be 0 in that case — **[isSpinning] is the truth source** for whether a
* temperature is meaningful. Never display [tempC] when [isSpinning] is false.
*
* [warningC] / [criticalC] are per-disk thresholds set in Unraid. When EITHER
* is set, those server values are authoritative; the app defaults (42 °C warn /
* 50 °C critical) apply only when BOTH are null. See [tempLevel].
* [warningC] / [criticalC] are per-disk thresholds from Unraid config. Each is
* coalesced independently against the global [DisplayThresholds] and hardcoded
* last-resort values in [tempLevel] — per-disk wins when set, else global, else
* hardcoded (42 °C warn / 50 °C crit).
*/
data class Disk(
val name: String,
Expand All @@ -58,41 +59,53 @@ data class Disk(
enum class DiskTempLevel { Standby, Normal, Warn, Danger }

/**
* Returns the [DiskTempLevel] for a disk.
* Returns the [DiskTempLevel] for a disk, incorporating global display thresholds.
*
* Rules (in priority order):
* 1. Not spinning → [DiskTempLevel.Standby] — temp is meaningless.
* 2. Either [criticalC] or [warningC] is set (server-configured thresholds are
* AUTHORITATIVE): evaluate only those; do NOT inject the app fallback for the
* unset one. Within this branch:
* a. [criticalC] set and [tempC] ≥ it → [DiskTempLevel.Danger].
* b. [warningC] set and [tempC] ≥ it → [DiskTempLevel.Warn].
* c. Otherwise → [DiskTempLevel.Normal].
* 3. BOTH thresholds null (no server config at all) — fall back to app defaults:
* ≥ 50 °C → Danger, ≥ 42 °C → Warn, otherwise Normal.
* 2. Per threshold, COALESCE independently:
* - effectiveCrit = [criticalC] ?: [globalCritC] ?: 50
* - effectiveWarn = [warningC] ?: [globalWarnC] ?: 42
* 3. tempC ≥ effectiveCrit → Danger; tempC ≥ effectiveWarn → Warn; else Normal.
*
* WHY: sleeping disks return temp=null → tempC=0; that MUST NOT render as a
* valid temperature (0°). When the server has configured EITHER threshold, those
* are the operator's intent — mixing in the app 42/50 fallback for the unset
* threshold would silently override that intent (e.g. a criticalC-only disk at
* 49 °C below its critical limit must be Normal, not Warn via the fallback).
* The 42/50 fallback applies ONLY when BOTH warningC and criticalC are null.
* WHY: Unraid stores GLOBAL disk thresholds in the dynamix display settings
* (`display.hot` = disk warn, `display.max` = disk crit). The per-disk
* `warning`/`critical` fields are null on most servers. Ignoring the global
* thresholds caused false alarms: a 52 °C disk with global crit=60 wrongly
* showed Danger against the hardcoded 50 °C fallback (ADR-0045 beta9 update).
*
* The beta8 "authoritative/no-mixing" rule (if EITHER per-disk set, ignore the
* other) is REMOVED. Simple coalescing is correct: per-disk wins when set, else
* global, else last-resort hardcoded value — never silently skip a level.
*
* @param globalWarnC global disk warning threshold from [DisplayThresholds.diskWarnC]; null if unavailable.
* @param globalCritC global disk critical threshold from [DisplayThresholds.diskCritC]; null if unavailable.
*/
fun Disk.tempLevel(): DiskTempLevel = when {
!isSpinning -> DiskTempLevel.Standby
// Server-configured thresholds are authoritative — if EITHER is set, use only
// those; do not inject the app fallback for an unset one.
criticalC != null || warningC != null -> when {
criticalC != null && tempC >= criticalC -> DiskTempLevel.Danger
warningC != null && tempC >= warningC -> DiskTempLevel.Warn
else -> DiskTempLevel.Normal
fun Disk.tempLevel(globalWarnC: Int? = null, globalCritC: Int? = null): DiskTempLevel {
if (!isSpinning) return DiskTempLevel.Standby
val effCrit = criticalC ?: globalCritC ?: 50
val effWarn = warningC ?: globalWarnC ?: 42
return when {
tempC >= effCrit -> DiskTempLevel.Danger
tempC >= effWarn -> DiskTempLevel.Warn
else -> DiskTempLevel.Normal
}
// No per-disk thresholds at all → app defaults.
tempC >= 50 -> DiskTempLevel.Danger
tempC >= 42 -> DiskTempLevel.Warn
else -> DiskTempLevel.Normal
}

/**
* Global temperature thresholds from Unraid's dynamix display settings
* (`query { display { hot max warning critical } }`).
*
* Mapping: hot → [diskWarnC], max → [diskCritC], warning → [cpuWarnC], critical → [cpuCritC].
* All fields nullable — the server may not have them configured.
*/
data class DisplayThresholds(
val diskWarnC: Int?,
val diskCritC: Int?,
val cpuWarnC: Int?,
val cpuCritC: Int?,
)

data class ParityCheck(
val progress: Float, // 0..1
val speedMbps: Double,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import io.github.nofuturekid.nova.data.api.cpuPercentTotal
import io.github.nofuturekid.nova.data.api.toArrayInfo
import io.github.nofuturekid.nova.data.api.toContainerLiveStat
import io.github.nofuturekid.nova.data.api.toContainers
import io.github.nofuturekid.nova.data.api.toDisplayThresholds
import io.github.nofuturekid.nova.data.api.toLiveMetrics
import io.github.nofuturekid.nova.data.api.toMemoryTriple
import io.github.nofuturekid.nova.data.api.toTemperature
Expand All @@ -47,6 +48,7 @@ import io.github.nofuturekid.nova.data.model.ArrayInfo
import io.github.nofuturekid.nova.data.model.ConnectionMode
import io.github.nofuturekid.nova.data.model.Container
import io.github.nofuturekid.nova.data.model.ContainerLiveStats
import io.github.nofuturekid.nova.data.model.DisplayThresholds
import io.github.nofuturekid.nova.data.model.LiveMetrics
import io.github.nofuturekid.nova.data.model.NetworkThroughput
import io.github.nofuturekid.nova.data.model.selectThroughput
Expand All @@ -68,6 +70,7 @@ import io.github.nofuturekid.nova.graphql.ArchiveNotificationMutation
import io.github.nofuturekid.nova.graphql.DeleteArchivedNotificationsMutation
import io.github.nofuturekid.nova.graphql.DeleteNotificationMutation
import io.github.nofuturekid.nova.graphql.FetchContainerLogsQuery
import io.github.nofuturekid.nova.graphql.GetDisplayQuery
import io.github.nofuturekid.nova.graphql.GetNotificationListQuery
import io.github.nofuturekid.nova.graphql.RecalculateNotificationOverviewMutation
import io.github.nofuturekid.nova.graphql.UnreadNotificationMutation
Expand Down Expand Up @@ -437,6 +440,18 @@ class UnraidRepository @Inject constructor(
fetch(client, GetServerInfoQuery()) { data -> data.toServerInfo() }.withBaseUrl(baseUrl)
}

/**
* Polls the global display (temperature threshold) settings.
*
* Display settings change rarely (only when the user edits dynamix config),
* so a 60 s cadence (same as info) is sufficient. Used by both the Overview
* temperature card (CPU thresholds) and the Array tab (disk thresholds).
*/
fun displayStream(intervalMs: Long = POLL_INFO_MS): Flow<DomainState<DisplayThresholds>> =
domainStream(intervalMs) { client, baseUrl ->
fetch(client, GetDisplayQuery()) { data -> data.toDisplayThresholds() }.withBaseUrl(baseUrl)
}

fun metricsStream(intervalMs: Long = POLL_METRICS_MS): Flow<DomainState<LiveMetrics>> {
val poll = domainStream(intervalMs) { client, baseUrl ->
fetch(client, GetMetricsQuery()) { data -> data.toLiveMetrics() }.withBaseUrl(baseUrl)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ fun ArrayTab(
onPauseParity: () -> Unit,
onResumeParity: () -> Unit,
onCancelParity: () -> Unit,
globalDiskWarnC: Int? = null,
globalDiskCritC: Int? = null,
) {
when (state) {
DomainState.Loading -> LoadingState()
Expand All @@ -67,6 +69,7 @@ fun ArrayTab(
is DomainState.Content -> ArrayContent(
state.value, view, onStartArray, onStopArray,
onStartParity, onPauseParity, onResumeParity, onCancelParity,
globalDiskWarnC, globalDiskCritC,
)
}
}
Expand All @@ -81,6 +84,8 @@ private fun ArrayContent(
onPauseParity: () -> Unit,
onResumeParity: () -> Unit,
onCancelParity: () -> Unit,
globalDiskWarnC: Int? = null,
globalDiskCritC: Int? = null,
) {
val t = UnraidTheme.colors
val arrayOn = arr.state != ArrayState.Stopped && arr.state != ArrayState.Offline
Expand Down Expand Up @@ -260,7 +265,7 @@ private fun ArrayContent(
LayoutMode.List -> {
item { SectionLabel("Disks") }
items(arr.disks, key = { it.name }) { disk ->
DiskCard(disk, errored = isErr(disk))
DiskCard(disk, errored = isErr(disk), globalDiskWarnC = globalDiskWarnC, globalDiskCritC = globalDiskCritC)
}
}
LayoutMode.Grid -> {
Expand All @@ -269,7 +274,7 @@ private fun ArrayContent(
Row(horizontalArrangement = Arrangement.spacedBy(d.gap)) {
row.forEach { disk ->
Box(modifier = Modifier.weight(1f)) {
DiskTile(disk, errored = isErr(disk))
DiskTile(disk, errored = isErr(disk), globalDiskWarnC = globalDiskWarnC, globalDiskCritC = globalDiskCritC)
}
}
if (row.size == 1) Spacer(Modifier.weight(1f))
Expand All @@ -286,7 +291,7 @@ private fun ArrayContent(
if (disks.isNotEmpty()) {
item(key = "h-$label") { SectionLabel("$label · ${disks.size}") }
items(disks, key = { "$label-${it.name}" }) { disk ->
DiskCard(disk, errored = isErr(disk))
DiskCard(disk, errored = isErr(disk), globalDiskWarnC = globalDiskWarnC, globalDiskCritC = globalDiskCritC)
}
}
}
Expand All @@ -298,14 +303,14 @@ private fun ArrayContent(
/** Compact disk tile for the Grid layout — type icon, name, temp/standby,
* usage bar (data/cache only). */
@Composable
private fun DiskTile(disk: Disk, errored: Boolean) {
private fun DiskTile(disk: Disk, errored: Boolean, globalDiskWarnC: Int? = null, globalDiskCritC: Int? = null) {
val t = UnraidTheme.colors
val typeColor = when (disk.type) {
DiskType.Parity -> Color(0xFFF59E0B)
DiskType.Cache -> Color(0xFFA78BFA)
DiskType.Data -> t.accent
}
val level = disk.tempLevel()
val level = disk.tempLevel(globalDiskWarnC, globalDiskCritC)
val tempColor = when (level) {
DiskTempLevel.Danger -> t.danger
DiskTempLevel.Warn -> t.warn
Expand Down Expand Up @@ -365,14 +370,14 @@ private fun DiskTile(disk: Disk, errored: Boolean) {
}

@Composable
private fun DiskCard(disk: Disk, errored: Boolean) {
private fun DiskCard(disk: Disk, errored: Boolean, globalDiskWarnC: Int? = null, globalDiskCritC: Int? = null) {
val t = UnraidTheme.colors
val typeColor = when (disk.type) {
DiskType.Parity -> Color(0xFFF59E0B)
DiskType.Cache -> Color(0xFFA78BFA)
DiskType.Data -> t.accent
}
val level = disk.tempLevel()
val level = disk.tempLevel(globalDiskWarnC, globalDiskCritC)
val tempColor = when (level) {
DiskTempLevel.Danger -> t.danger
DiskTempLevel.Warn -> t.warn
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ fun MainScreen(
val dockerState by vm.dockerState.collectAsState()
val vmsState by vm.vmsState.collectAsState()
val notificationsState by vm.notificationsState.collectAsState()
val displayThresholds by vm.displayThresholds.collectAsState()
val scope = rememberCoroutineScope()

// Pause polling when app is backgrounded (ADR-0017). Bound to the
Expand Down Expand Up @@ -228,6 +229,8 @@ fun MainScreen(
onAddServer = onAddServer,
networkThroughput = (networkState as? DomainState.Content)?.value,
temperature = (temperatureState as? DomainState.Content)?.value,
cpuWarnC = displayThresholds?.cpuWarnC,
cpuCritC = displayThresholds?.cpuCritC,
)
MainTab.Array -> ArrayTab(
state = arrayState,
Expand Down Expand Up @@ -275,6 +278,8 @@ fun MainScreen(
onConfirm = { vm.cancelParityCheck(); confirm = null },
)
},
globalDiskWarnC = displayThresholds?.diskWarnC,
globalDiskCritC = displayThresholds?.diskCritC,
)
MainTab.Docker -> DockerTab(
state = dockerState,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import io.github.nofuturekid.nova.data.local.LayoutMode
import io.github.nofuturekid.nova.data.model.AppSettings
import io.github.nofuturekid.nova.data.model.ArrayInfo
import io.github.nofuturekid.nova.data.model.ConnectionMode
import io.github.nofuturekid.nova.data.model.DisplayThresholds
import io.github.nofuturekid.nova.data.model.NotifType
import io.github.nofuturekid.nova.data.model.Container
import io.github.nofuturekid.nova.data.model.ContainerLiveStats
Expand Down Expand Up @@ -249,6 +250,19 @@ class MainViewModel @Inject constructor(
val notificationsState: StateFlow<DomainState<Notifications>> =
gatedStream(_appVisible, unraid.notificationsStream())

// Global display thresholds — needed by Overview (CPU card) and Array tab
// (disk coloring). Gated on overview-or-array so it polls whenever either
// of those tabs is active. Display settings change rarely → 60 s poll.
val displayState: StateFlow<DomainState<DisplayThresholds>> =
gatedStream(overviewOr(MainTab.Array), unraid.displayStream())

/** Unwrapped display thresholds for the UI — null until the first poll
* succeeds. Mirrors the dockerLiveStats pattern. */
val displayThresholds: StateFlow<DisplayThresholds?> =
displayState
.map { (it as? DomainState.Content)?.value }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)

// ── In-flight container updates (ADR-0016) ────────────────────────
//
// The Update mutation runs server-side for 30s–several minutes (image
Expand Down
Loading