From 37228088e8d96fed14ff8f2ab49c529dd7afb81d Mon Sep 17 00:00:00 2001 From: Thomas Kroll Date: Sat, 30 May 2026 17:39:54 +0200 Subject: [PATCH 1/2] feat(temp): global display thresholds for disk/CPU coloring (0.1.40-beta9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unraid stores global temperature thresholds in dynamix display settings (display.hot/max for disks, display.warning/critical for CPU). These were ignored: disk colors used hardcoded 42/50, causing a 52 °C disk with global crit=60 to show Danger. CPU temp card used sensor-derived flags only. Changes: - schema: add `display: InfoDisplay` to Query; vendor `InfoDisplay` type - queries: add `GetDisplay { display { hot max warning critical } }` - Models: add `DisplayThresholds`; replace `Disk.tempLevel()` with `Disk.tempLevel(globalWarnC, globalCritC)` using independent coalescing (per-disk ?: global ?: hardcoded) — removes beta8 "if-either-set" rule - Mapper: `GetDisplayQuery.Data.toDisplayThresholds()` extension - Repository: `displayStream()` at POLL_INFO_MS (60 s) - ViewModel: `displayState` + `displayThresholds` (nullable unwrap) gated on overviewOr(Array) — same pattern as dockerLiveStats - ArrayTab: thread globalDiskWarnC/globalDiskCritC to DiskTile/DiskCard - OverviewTab: CPU card accent prefers cpuWarnC/cpuCritC from display; falls back to sensor flags when display poll not yet loaded - MainScreen: collect displayThresholds, pass to both tabs - Tests: DiskTempLevelTest updated to new signature + coalescing semantics; new DisplayThresholdsMappingTest for mapper correctness - docs: ADR-0045 updated with beta9 precedence rule Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 5 + app/build.gradle.kts | 4 +- .../github/nofuturekid/nova/queries.graphql | 4 + .../github/nofuturekid/nova/schema.graphqls | 13 ++ .../nova/data/api/GraphQlMapper.kt | 21 ++++ .../nofuturekid/nova/data/model/Models.kt | 73 ++++++----- .../nova/data/repository/UnraidRepository.kt | 15 +++ .../nova/ui/screens/array/ArrayTab.kt | 19 +-- .../nova/ui/screens/main/MainScreen.kt | 5 + .../nova/ui/screens/main/MainViewModel.kt | 14 +++ .../nova/ui/screens/overview/OverviewTab.kt | 13 +- .../data/api/DisplayThresholdsMappingTest.kt | 71 +++++++++++ .../nova/data/model/DiskTempLevelTest.kt | 116 +++++++++++++----- docs/adr/0045-array-disk-display.md | 61 ++++++++- 14 files changed, 354 insertions(+), 80 deletions(-) create mode 100644 app/src/test/kotlin/io/github/nofuturekid/nova/data/api/DisplayThresholdsMappingTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cf32cc..350b051 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a1fe7e7..b6e7e9c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 } } diff --git a/app/src/main/graphql/io/github/nofuturekid/nova/queries.graphql b/app/src/main/graphql/io/github/nofuturekid/nova/queries.graphql index 5353d93..de4a2d6 100644 --- a/app/src/main/graphql/io/github/nofuturekid/nova/queries.graphql +++ b/app/src/main/graphql/io/github/nofuturekid/nova/queries.graphql @@ -62,6 +62,10 @@ query GetNetworkThroughput { } } +query GetDisplay { + display { hot max warning critical } +} + query GetArray { array { state diff --git a/app/src/main/graphql/io/github/nofuturekid/nova/schema.graphqls b/app/src/main/graphql/io/github/nofuturekid/nova/schema.graphqls index 8c322f9..a7fe92d 100644 --- a/app/src/main/graphql/io/github/nofuturekid/nova/schema.graphqls +++ b/app/src/main/graphql/io/github/nofuturekid/nova/schema.graphqls @@ -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 ──────────────────────────────────────────────────── diff --git a/app/src/main/kotlin/io/github/nofuturekid/nova/data/api/GraphQlMapper.kt b/app/src/main/kotlin/io/github/nofuturekid/nova/data/api/GraphQlMapper.kt index c65ac09..c65b3f4 100644 --- a/app/src/main/kotlin/io/github/nofuturekid/nova/data/api/GraphQlMapper.kt +++ b/app/src/main/kotlin/io/github/nofuturekid/nova/data/api/GraphQlMapper.kt @@ -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 @@ -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 @@ -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 { diff --git a/app/src/main/kotlin/io/github/nofuturekid/nova/data/model/Models.kt b/app/src/main/kotlin/io/github/nofuturekid/nova/data/model/Models.kt index 410d4bb..d1ac27f 100644 --- a/app/src/main/kotlin/io/github/nofuturekid/nova/data/model/Models.kt +++ b/app/src/main/kotlin/io/github/nofuturekid/nova/data/model/Models.kt @@ -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, @@ -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, diff --git a/app/src/main/kotlin/io/github/nofuturekid/nova/data/repository/UnraidRepository.kt b/app/src/main/kotlin/io/github/nofuturekid/nova/data/repository/UnraidRepository.kt index 5d69867..6071195 100644 --- a/app/src/main/kotlin/io/github/nofuturekid/nova/data/repository/UnraidRepository.kt +++ b/app/src/main/kotlin/io/github/nofuturekid/nova/data/repository/UnraidRepository.kt @@ -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 @@ -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 @@ -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 @@ -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> = + domainStream(intervalMs) { client, baseUrl -> + fetch(client, GetDisplayQuery()) { data -> data.toDisplayThresholds() }.withBaseUrl(baseUrl) + } + fun metricsStream(intervalMs: Long = POLL_METRICS_MS): Flow> { val poll = domainStream(intervalMs) { client, baseUrl -> fetch(client, GetMetricsQuery()) { data -> data.toLiveMetrics() }.withBaseUrl(baseUrl) diff --git a/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/array/ArrayTab.kt b/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/array/ArrayTab.kt index 6b6e89f..2ec3fd6 100644 --- a/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/array/ArrayTab.kt +++ b/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/array/ArrayTab.kt @@ -59,6 +59,8 @@ fun ArrayTab( onPauseParity: () -> Unit, onResumeParity: () -> Unit, onCancelParity: () -> Unit, + globalDiskWarnC: Int? = null, + globalDiskCritC: Int? = null, ) { when (state) { DomainState.Loading -> LoadingState() @@ -67,6 +69,7 @@ fun ArrayTab( is DomainState.Content -> ArrayContent( state.value, view, onStartArray, onStopArray, onStartParity, onPauseParity, onResumeParity, onCancelParity, + globalDiskWarnC, globalDiskCritC, ) } } @@ -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 @@ -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 -> { @@ -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)) @@ -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) } } } @@ -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 @@ -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 diff --git a/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/main/MainScreen.kt b/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/main/MainScreen.kt index f5a2b49..1b78c64 100644 --- a/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/main/MainScreen.kt +++ b/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/main/MainScreen.kt @@ -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 @@ -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, @@ -275,6 +278,8 @@ fun MainScreen( onConfirm = { vm.cancelParityCheck(); confirm = null }, ) }, + globalDiskWarnC = displayThresholds?.diskWarnC, + globalDiskCritC = displayThresholds?.diskCritC, ) MainTab.Docker -> DockerTab( state = dockerState, diff --git a/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/main/MainViewModel.kt b/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/main/MainViewModel.kt index 9657082..fa90e66 100644 --- a/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/main/MainViewModel.kt +++ b/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/main/MainViewModel.kt @@ -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 @@ -249,6 +250,19 @@ class MainViewModel @Inject constructor( val notificationsState: StateFlow> = 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> = + 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 = + 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 diff --git a/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/overview/OverviewTab.kt b/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/overview/OverviewTab.kt index 92608c7..7e35edd 100644 --- a/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/overview/OverviewTab.kt +++ b/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/overview/OverviewTab.kt @@ -72,6 +72,8 @@ fun OverviewTab( onAddServer: () -> Unit, networkThroughput: NetworkThroughput? = null, temperature: Temperature? = null, + cpuWarnC: Int? = null, + cpuCritC: Int? = null, ) { // Any single NoServer → no server picked. if (infoState is DomainState.NoServer || metricsState is DomainState.NoServer || @@ -296,12 +298,15 @@ private fun OverviewContent( } item { val temp = temperature?.takeIf { it.available } - // Accent is CPU-driven: the CPU is the meaningful number, so a - // system-only box never escalates. M3 error role on critical. + // Accent is CPU-driven: prefer global display thresholds (cpuWarnC/cpuCritC) + // from dynamix display settings when available; fall back to the sensor-derived + // status flags (temp.cpuCritical / temp.cpuWarning) when not yet loaded. val cpuColor = when { temp == null -> t.muted - temp.cpuCritical -> t.danger - temp.cpuWarning -> t.warn + cpuCritC != null && temp.cpuC != null && temp.cpuC >= cpuCritC -> t.danger + cpuWarnC != null && temp.cpuC != null && temp.cpuC >= cpuWarnC -> t.warn + cpuCritC == null && cpuWarnC == null && temp.cpuCritical -> t.danger + cpuCritC == null && cpuWarnC == null && temp.cpuWarning -> t.warn else -> t.accent } // System line: a distinct M3-consistent secondary (info/blue) so the diff --git a/app/src/test/kotlin/io/github/nofuturekid/nova/data/api/DisplayThresholdsMappingTest.kt b/app/src/test/kotlin/io/github/nofuturekid/nova/data/api/DisplayThresholdsMappingTest.kt new file mode 100644 index 0000000..20f2b34 --- /dev/null +++ b/app/src/test/kotlin/io/github/nofuturekid/nova/data/api/DisplayThresholdsMappingTest.kt @@ -0,0 +1,71 @@ +package io.github.nofuturekid.nova.data.api + +import io.github.nofuturekid.nova.data.model.DisplayThresholds +import io.github.nofuturekid.nova.graphql.GetDisplayQuery +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * Verifies that [GetDisplayQuery.Data.toDisplayThresholds] maps the server's + * dynamix display fields to [DisplayThresholds] correctly. + * + * WHY: global thresholds prevent false disk-temp alarms when per-disk thresholds + * are null (the common case on most Unraid servers). Mapping correctness is the + * contract between the GraphQL response and the domain model that drives coloring + * in both the Array tab and the Overview CPU card (ADR-0045 beta9 update). + * + * Field mapping: hot → diskWarnC, max → diskCritC, warning → cpuWarnC, critical → cpuCritC. + * Live-verified values 2026-05-30: hot=55, max=60, warning=85, critical=95. + */ +class DisplayThresholdsMappingTest { + + @Test fun all_fields_set_maps_correctly() { + // WHY: verifies the live-server values map to the expected domain fields. + // hot=55 is global disk warn; max=60 is global disk crit; + // warning=85 is CPU warn; critical=95 is CPU crit. + val data = displayData(hot = 55, max = 60, warning = 85, critical = 95) + assertEquals( + DisplayThresholds(diskWarnC = 55, diskCritC = 60, cpuWarnC = 85, cpuCritC = 95), + data.toDisplayThresholds(), + ) + } + + @Test fun null_display_block_yields_all_null() { + // WHY: the server may not expose the display field at all on older Unraid + // versions; the mapper must degrade gracefully to all-null thresholds, + // so callers fall back to hardcoded defaults without crashing. + val data = GetDisplayQuery.Data(display = null) + assertEquals( + DisplayThresholds(diskWarnC = null, diskCritC = null, cpuWarnC = null, cpuCritC = null), + data.toDisplayThresholds(), + ) + } + + @Test fun partial_fields_carry_through_nulls() { + // WHY: some servers may only configure some thresholds; the mapper must + // pass through whatever is present and leave the rest null. + val data = displayData(hot = 50, max = null, warning = null, critical = 90) + val result = data.toDisplayThresholds() + assertEquals(50, result.diskWarnC) + assertNull(result.diskCritC) + assertNull(result.cpuWarnC) + assertEquals(90, result.cpuCritC) + } + + // ── Builder ─────────────────────────────────────────────────────── + + private fun displayData( + hot: Int? = null, + max: Int? = null, + warning: Int? = null, + critical: Int? = null, + ) = GetDisplayQuery.Data( + display = GetDisplayQuery.Display( + hot = hot, + max = max, + warning = warning, + critical = critical, + ), + ) +} diff --git a/app/src/test/kotlin/io/github/nofuturekid/nova/data/model/DiskTempLevelTest.kt b/app/src/test/kotlin/io/github/nofuturekid/nova/data/model/DiskTempLevelTest.kt index e28d068..01bb23a 100644 --- a/app/src/test/kotlin/io/github/nofuturekid/nova/data/model/DiskTempLevelTest.kt +++ b/app/src/test/kotlin/io/github/nofuturekid/nova/data/model/DiskTempLevelTest.kt @@ -11,9 +11,12 @@ import org.junit.Test * - A spun-down disk returns temp=null from the server (coalesced to tempC=0 by * the mapper). Without isSpinning-gating the UI would show "0°" for a sleeping * disk — the primary bug driving ADR-0045. - * - Per-disk warning/critical thresholds from Unraid override the app defaults; - * a tighter threshold (e.g. warningC=40) must fire even when the fallback (42) - * would not. Verified server behaviour 2026-05-30. + * - Global display thresholds (from `display.hot` / `display.max`) prevent false + * alarms: a 52 °C disk with global crit=60 must show Normal, not Danger vs the + * hardcoded 50 °C fallback. This is the beta9 fix (ADR-0045 updated). + * - Per threshold, coalescing is independent: per-disk value if set, else global, + * else hardcoded last-resort (42 warn / 50 crit). The beta8 "if EITHER per-disk + * set, ignore the other" rule is removed. */ class DiskTempLevelTest { @@ -34,68 +37,119 @@ class DiskTempLevelTest { assertEquals(DiskTempLevel.Standby, disk.tempLevel()) } - // ── Fallback (null) thresholds ──────────────────────────────────── + @Test fun spinning_false_is_standby_with_global_thresholds() { + // WHY: global thresholds don't affect the standby branch. + val disk = disk(isSpinning = false, tempC = 80) + assertEquals(DiskTempLevel.Standby, disk.tempLevel(globalWarnC = 55, globalCritC = 60)) + } + + // ── Global display thresholds (the beta9 false-alarm fix) ───────── + + @Test fun global_thresholds_prevent_false_danger_at_52() { + // WHY: the primary beta9 bug fix. A 52 °C disk with global crit=60 was + // wrongly shown as Danger because the hardcoded 50 °C fallback fired. + // With global thresholds threaded in, 52 < 55 (warn) → Normal. + val disk = disk(isSpinning = true, tempC = 52) + assertEquals(DiskTempLevel.Normal, disk.tempLevel(globalWarnC = 55, globalCritC = 60)) + } + + @Test fun global_thresholds_warn_at_56() { + // WHY: 56 >= 55 (global warn) → Warn; below crit (60) → not Danger. + val disk = disk(isSpinning = true, tempC = 56) + assertEquals(DiskTempLevel.Warn, disk.tempLevel(globalWarnC = 55, globalCritC = 60)) + } + + @Test fun global_thresholds_danger_at_61() { + // WHY: 61 >= 60 (global crit) → Danger. + val disk = disk(isSpinning = true, tempC = 61) + assertEquals(DiskTempLevel.Danger, disk.tempLevel(globalWarnC = 55, globalCritC = 60)) + } + + @Test fun global_thresholds_danger_at_exactly_60() { + // WHY: threshold is inclusive (>=). + val disk = disk(isSpinning = true, tempC = 60) + assertEquals(DiskTempLevel.Danger, disk.tempLevel(globalWarnC = 55, globalCritC = 60)) + } + + // ── Hardcoded fallback (no per-disk, no global) ─────────────────── @Test fun spinning_cold_with_null_thresholds_is_normal() { - // WHY: a healthy cool spinning disk with no server-side thresholds configured - // must report Normal — no false alarms. - val disk = disk(isSpinning = true, tempC = 39, warningC = null, criticalC = null) + // WHY: a healthy cool spinning disk with no thresholds configured must + // report Normal — no false alarms. + val disk = disk(isSpinning = true, tempC = 39) + assertEquals(DiskTempLevel.Normal, disk.tempLevel()) + } + + @Test fun spinning_just_below_fallback_warn_is_normal() { + // WHY: 41 < 42 fallback warn threshold → Normal. + val disk = disk(isSpinning = true, tempC = 41) assertEquals(DiskTempLevel.Normal, disk.tempLevel()) } @Test fun spinning_at_fallback_warn_threshold_is_warn() { // WHY: 42 °C is the app's default warning level. At exactly 42 with no - // per-disk thresholds, the result must be Warn, not Normal. - val disk = disk(isSpinning = true, tempC = 42, warningC = null, criticalC = null) + // thresholds configured anywhere, the result must be Warn, not Normal. + val disk = disk(isSpinning = true, tempC = 42) assertEquals(DiskTempLevel.Warn, disk.tempLevel()) } + @Test fun spinning_at_fallback_danger_threshold_is_danger() { + // WHY: 50 °C is the app's default critical level (inclusive). + val disk = disk(isSpinning = true, tempC = 50) + assertEquals(DiskTempLevel.Danger, disk.tempLevel()) + } + @Test fun spinning_above_fallback_danger_threshold_is_danger() { - // WHY: 50 °C is the app's default critical level (50+). The null-threshold - // fallback must still catch a clearly hot disk. - val disk = disk(isSpinning = true, tempC = 52, warningC = null, criticalC = null) + // WHY: null global + null per-disk → fallback 50 crit. 52 > 50 → Danger. + val disk = disk(isSpinning = true, tempC = 52) assertEquals(DiskTempLevel.Danger, disk.tempLevel()) } - @Test fun spinning_just_below_fallback_warn_is_normal() { - val disk = disk(isSpinning = true, tempC = 41, warningC = null, criticalC = null) - assertEquals(DiskTempLevel.Normal, disk.tempLevel()) + // ── Per-disk thresholds + global coalescing ─────────────────────── + + @Test fun per_disk_crit_only_no_warn_uses_global_warn() { + // WHY: per-disk criticalC=50, no per-disk warningC, global(null,null). + // effWarn = null ?: null ?: 42 = 42. 49 >= 42 → Warn. + val disk = disk(isSpinning = true, tempC = 49, criticalC = 50) + assertEquals(DiskTempLevel.Warn, disk.tempLevel(globalWarnC = null, globalCritC = null)) } - @Test fun spinning_at_fallback_danger_threshold_is_danger() { - val disk = disk(isSpinning = true, tempC = 50, warningC = null, criticalC = null) - assertEquals(DiskTempLevel.Danger, disk.tempLevel()) + @Test fun per_disk_crit_only_global_warn_55_normal_at_49() { + // WHY: per-disk criticalC=50, no per-disk warningC, global warn=55. + // effWarn = null ?: 55 = 55. effCrit = 50 ?: null = 50. + // 49 < 50 (effCrit) and 49 < 55 (effWarn) → Normal. + val disk = disk(isSpinning = true, tempC = 49, criticalC = 50) + assertEquals(DiskTempLevel.Normal, disk.tempLevel(globalWarnC = 55, globalCritC = 60)) } - // ── Per-disk server thresholds ──────────────────────────────────── + @Test fun per_disk_warn_wins_over_global() { + // WHY: per-disk warningC=45 is tighter than global warn=55; 46 >= 45 → Warn. + val disk = disk(isSpinning = true, tempC = 46, warningC = 45) + assertEquals(DiskTempLevel.Warn, disk.tempLevel(globalWarnC = 55, globalCritC = 60)) + } @Test fun spinning_at_server_warning_threshold_is_warn() { - // WHY: Unraid lets admins configure tighter disk-level thresholds. When - // warningC=45 and tempC=46 the server threshold fires even though the - // app fallback (42) would also fire — the server value takes precedence - // and the result should be Warn (not Danger, since criticalC=55). + // WHY: warningC=45, criticalC=55; 46 >= 45 → Warn (not Danger). val disk = disk(isSpinning = true, tempC = 46, warningC = 45, criticalC = 55) assertEquals(DiskTempLevel.Warn, disk.tempLevel()) } @Test fun spinning_at_server_critical_threshold_is_danger() { - // WHY: criticalC takes precedence over warningC. + // WHY: criticalC=55 takes precedence over warningC=45. val disk = disk(isSpinning = true, tempC = 55, warningC = 45, criticalC = 55) assertEquals(DiskTempLevel.Danger, disk.tempLevel()) } @Test fun spinning_below_server_warning_threshold_is_normal() { - // WHY: the server threshold is tighter than the fallback; a disk at 41 °C - // with warningC=45 must be Normal (the app default of 42 must not fire - // when a per-disk threshold is present). + // WHY: warningC=45 is tighter than fallback (42); 41 < 45 → Normal. val disk = disk(isSpinning = true, tempC = 41, warningC = 45, criticalC = 55) assertEquals(DiskTempLevel.Normal, disk.tempLevel()) } - @Test fun server_critical_only_no_warning_threshold() { - // WHY: criticalC can be set without warningC; below critical → Normal. - val disk = disk(isSpinning = true, tempC = 49, warningC = null, criticalC = 50) - assertEquals(DiskTempLevel.Normal, disk.tempLevel()) + @Test fun server_critical_only_no_warning_threshold_no_global() { + // WHY: criticalC=50, no per-disk warn, no global → effWarn=42. 49 >= 42 → Warn. + val disk = disk(isSpinning = true, tempC = 49, criticalC = 50) + assertEquals(DiskTempLevel.Warn, disk.tempLevel()) } // ── Builder ─────────────────────────────────────────────────────── diff --git a/docs/adr/0045-array-disk-display.md b/docs/adr/0045-array-disk-display.md index b506c4e..c768bac 100644 --- a/docs/adr/0045-array-disk-display.md +++ b/docs/adr/0045-array-disk-display.md @@ -1,6 +1,6 @@ # ADR-0045: Array disk display — sleeping disks, per-disk thresholds, errors -- **Status**: Accepted +- **Status**: Accepted (updated beta9 — 2026-05-30) - **Date**: 2026-05-30 - **Tags**: ui, array, data @@ -11,6 +11,15 @@ spun-down (sleeping) disk, and `isSpinning = false`. The existing `GetArray` poll did not select `isSpinning`, and the mapper coalesced `null → 0`, so sleeping disks displayed "0°" in the Array tab — a misleading health signal. +**Beta9 update (2026-05-30):** a further bug was discovered: disk temperature +coloring used hardcoded thresholds (42 °C warn / 50 °C crit) even though Unraid +stores global thresholds in its dynamix display settings (`display.hot` / `display.max` +for disks; `display.warning` / `display.critical` for CPU/system). A 52 °C disk +with the user's global crit=60 was wrongly shown as Danger. The Overview CPU +temperature card was similarly unaware of `display.warning` / `display.critical`. +The beta8 "authoritative/no-mixing" rule (if either per-disk threshold is set, +ignore the other) is **removed** in beta9 — see Decision 4 below. + ## Decisions ### 1. Sleeping disks show "Standby" instead of "0°" @@ -40,9 +49,8 @@ The following fields are now selected and forwarded to the domain model: | critical | Int | criticalC | null or ≤ 0 → null (unset) | Per-disk temperature thresholds (warning/critical) take precedence over the -app's global defaults (42 °C warn / 50 °C danger). The precedence logic lives -in the pure `Disk.tempLevel()` extension (`DiskTempLevel` enum) so it can be -unit-tested without Compose. +app's global defaults. The precedence logic lives in the pure `Disk.tempLevel()` +extension (`DiskTempLevel` enum) so it can be unit-tested without Compose. Disk errors (`numErrors > 0`) are shown as a `"N err"` danger pill on both the grid tile and the list row. @@ -58,10 +66,51 @@ switching to the subscription. The array therefore deliberately remains on the 5 s poll for this beta; the subscription path is available if the cadence changes upstream. +### 4. Global display thresholds (beta9 — SUPERSEDES beta8 "authoritative/no-mixing" rule) + +Unraid stores global disk and CPU temperature thresholds in the dynamix display +settings, exposed at `query { display { hot max warning critical } }`. +Live-verified 2026-05-30: hot=55 (disk warn), max=60 (disk crit), +warning=85 (CPU warn), critical=95 (CPU crit). + +The per-disk `ArrayDisk.warning` / `ArrayDisk.critical` fields are null on most +servers (including spinning cache disks). Relying on hardcoded fallbacks (42/50) +while ignoring the user's global config caused false alarms. + +**New threshold precedence (replaces the beta8 "if EITHER set, use only those" +rule):** per threshold, coalesce independently: + +``` +effectiveWarn = disk.warningC ?: globalDiskWarnC ?: 42 +effectiveCrit = disk.criticalC ?: globalDiskCritC ?: 50 +``` + +where `globalDiskWarnC = display.hot`, `globalDiskCritC = display.max`. + +This means per-disk values win when set; global display values fill in for +unset per-disk thresholds; the hardcoded 42/50 are a last resort only when +neither per-disk nor global is available. The beta8 "authoritative/no-mixing" +rule (if either per-disk threshold is set, ignore the fallback for the other) +is **removed** — it caused incorrect results when only one per-disk threshold +was configured. + +A new `GetDisplay` query (`query GetDisplay { display { hot max warning critical } }`) +polls these settings every 60 s via `UnraidRepository.displayStream()`. The +`DisplayThresholds` domain model carries the four nullable ints. The ViewModel +exposes a `displayThresholds: StateFlow` (null until first +poll), gated on overview-or-array visibility. + +The Overview CPU temperature card accent color also switches to use +`display.warning` / `display.critical` when available, falling back to the +sensor-derived `Temperature.cpuWarning` / `Temperature.cpuCritical` flags when +the display poll has not yet returned. + ## Consequences - Sleeping disks no longer show "0°"; they show a moon icon + "Standby". -- Per-disk Unraid thresholds drive temperature colour coding; the app defaults - (42 / 50 °C) remain as fallback for servers that have not configured them. +- Disk temperature coloring uses the user's configured global thresholds + (display.hot/max) with per-disk overrides and a 42/50 last-resort fallback. +- CPU temperature card accent uses display.warning/critical when available. - Disk errors are immediately visible without entering a detail view. - No subscription migration required for the array. +- `unit` field intentionally NOT consumed — no unit conversion in this beta. From 7eedc8c426995ff7442ca0e0619d7407d7b1330d Mon Sep 17 00:00:00 2001 From: Thomas Kroll Date: Sat, 30 May 2026 17:42:34 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix(overview):=20wire=20cpuWarnC/cpuCritC?= =?UTF-8?q?=20through=20OverviewTab=20=E2=86=92=20OverviewContent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OverviewTab received the threshold params but never forwarded them to OverviewContent (call-site was missing the args) and OverviewContent had no matching parameters in its signature, so the threshold logic at lines 306-309 always resolved to the fallback path. Both gaps are fixed. Co-Authored-By: Claude Sonnet 4.6 --- .../nofuturekid/nova/ui/screens/overview/OverviewTab.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/overview/OverviewTab.kt b/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/overview/OverviewTab.kt index 7e35edd..3ac2f43 100644 --- a/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/overview/OverviewTab.kt +++ b/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/overview/OverviewTab.kt @@ -102,7 +102,7 @@ fun OverviewTab( return } - OverviewContent(info, metrics, array, containers, vms, server, networkThroughput, temperature) + OverviewContent(info, metrics, array, containers, vms, server, networkThroughput, temperature, cpuWarnC, cpuCritC) } @Composable @@ -115,6 +115,8 @@ private fun OverviewContent( server: Server?, networkThroughput: NetworkThroughput? = null, temperature: Temperature? = null, + cpuWarnC: Int? = null, + cpuCritC: Int? = null, ) { val t = UnraidTheme.colors