diff --git a/CHANGELOG.md b/CHANGELOG.md index fe425f8..2cf32cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,15 @@ release version + date and opens a fresh `[Unreleased]`. ## [Unreleased] +## [0.1.40-beta8] - 2026-05-30 + +### Fixed +- Sleeping (spun-down) array disks now show a moon icon and "Standby" instead of "0°". The server returns null temperature for a spun-down disk; the previous release coalesced that to 0 and displayed it as a real temperature reading. + +### Added +- Array disk cards now show per-disk temperature thresholds from Unraid (warning/critical) when configured, falling back to the previous hardcoded defaults (42 °C / 50 °C) when not set. +- Disk errors (I/O error count) are now shown as a danger pill directly on each disk card in both grid and list layouts. + ## [0.1.40-beta7] - 2026-05-30 ### Changed diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bc21b00..a1fe7e7 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 = 113 - versionName = "0.1.40-beta7" + versionCode = 114 + versionName = "0.1.40-beta8" 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 19a9c76..5353d93 100644 --- a/app/src/main/graphql/io/github/nofuturekid/nova/queries.graphql +++ b/app/src/main/graphql/io/github/nofuturekid/nova/queries.graphql @@ -66,9 +66,9 @@ query GetArray { array { state capacity { kilobytes { total used free } } - parities { id name device size status temp type } - disks { id name device size status temp fsSize fsFree fsUsed type } - caches { id name device size status temp fsSize fsFree fsUsed type } + parities { id name device size status temp type isSpinning rotational numErrors warning critical } + disks { id name device size status temp fsSize fsFree fsUsed type isSpinning rotational numErrors warning critical } + caches { id name device size status temp fsSize fsFree fsUsed type isSpinning rotational numErrors warning critical } parityCheckStatus { progress speed errors running paused } } } 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 509e709..8c322f9 100644 --- a/app/src/main/graphql/io/github/nofuturekid/nova/schema.graphqls +++ b/app/src/main/graphql/io/github/nofuturekid/nova/schema.graphqls @@ -315,6 +315,11 @@ type ArrayDisk { fsFree: BigInt fsUsed: BigInt type: ArrayDiskType! + isSpinning: Boolean + rotational: Boolean + numErrors: BigInt + warning: Int + critical: Int } type ParityCheck { 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 990099f..c65ac09 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 @@ -129,13 +129,16 @@ fun GetArrayQuery.Data.toArrayInfo(): ArrayInfo { val totalTb = cap.total.toLongOrNull()?.kbToTb() ?: 0.0 val usedTb = cap.used.toLongOrNull()?.kbToTb() ?: 0.0 val parities = arrBlock.parities.map { - mapDisk(it.name, it.device, it.size, null, it.status, it.temp, it.type) + mapDisk(it.name, it.device, it.size, null, it.status, it.temp, it.type, + it.isSpinning, it.rotational, it.numErrors, it.warning, it.critical) } val data = arrBlock.disks.map { - mapDisk(it.name, it.device, it.size, it.fsUsed, it.status, it.temp, it.type) + mapDisk(it.name, it.device, it.size, it.fsUsed, it.status, it.temp, it.type, + it.isSpinning, it.rotational, it.numErrors, it.warning, it.critical) } val caches = arrBlock.caches.map { - mapDisk(it.name, it.device, it.size, it.fsUsed, it.status, it.temp, it.type) + mapDisk(it.name, it.device, it.size, it.fsUsed, it.status, it.temp, it.type, + it.isSpinning, it.rotational, it.numErrors, it.warning, it.critical) } return ArrayInfo( state = arrBlock.state.toDomain(), @@ -448,6 +451,11 @@ private fun mapDisk( status: ArrayDiskStatus?, temp: Int?, type: ArrayDiskType, + isSpinning: Boolean?, + rotational: Boolean?, + numErrors: Long?, + warningC: Int?, + criticalC: Int?, ): Disk { val sizeTb = (sizeKb ?: 0L).kbToTb() val usedTb = (usedKb ?: 0L).kbToTb() @@ -457,9 +465,16 @@ private fun mapDisk( type = type.toDomain(), sizeTb = sizeTb, usedTb = usedTb, + // temp is null when the disk is spun down; coalesce to 0. + // isSpinning is the truth source — check it before displaying tempC. tempC = temp ?: 0, status = status?.toDomain() ?: DiskStatus.Ok, model = "", + isSpinning = isSpinning ?: false, + rotational = rotational ?: false, + numErrors = numErrors ?: 0L, + warningC = warningC?.takeIf { it > 0 }, + criticalC = criticalC?.takeIf { it > 0 }, ) } 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 6645f58..410d4bb 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 @@ -21,17 +21,78 @@ enum class ArrayState { Started, Stopped, Parity, Error, Offline } enum class DiskType { Parity, Data, Cache } enum class DiskStatus { Ok, Error } +/** + * Domain model for a single array/parity/cache disk. + * + * [tempC] is the raw temperature in Celsius coalesced from the server value. + * When the disk is spun down the server returns `temp = null`; [tempC] will + * 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]. + */ data class Disk( val name: String, val device: String, val type: DiskType, val sizeTb: Double, val usedTb: Double, + /** Raw temp in Celsius. 0 when spun down (server returns null). Use [isSpinning] before reading. */ val tempC: Int, val status: DiskStatus, val model: String, + val isSpinning: Boolean, + val rotational: Boolean, + val numErrors: Long, + /** Per-disk warning threshold in °C from Unraid config; null = not configured by server. */ + val warningC: Int?, + /** Per-disk critical threshold in °C from Unraid config; null = not configured by server. */ + val criticalC: Int?, ) +/** Logical temperature severity level — purely a function of the disk state. + * Colour mapping is the UI layer's responsibility; this enum stays in the model + * so the logic is unit-testable without Compose on the classpath. */ +enum class DiskTempLevel { Standby, Normal, Warn, Danger } + +/** + * Returns the [DiskTempLevel] for a disk. + * + * 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. + * + * 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. + */ +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 + } + // No per-disk thresholds at all → app defaults. + tempC >= 50 -> DiskTempLevel.Danger + tempC >= 42 -> DiskTempLevel.Warn + else -> DiskTempLevel.Normal +} + data class ParityCheck( val progress: Float, // 0..1 val speedMbps: Double, diff --git a/app/src/main/kotlin/io/github/nofuturekid/nova/ui/components/UnraidIcons.kt b/app/src/main/kotlin/io/github/nofuturekid/nova/ui/components/UnraidIcons.kt index 9151bb8..c795abc 100644 --- a/app/src/main/kotlin/io/github/nofuturekid/nova/ui/components/UnraidIcons.kt +++ b/app/src/main/kotlin/io/github/nofuturekid/nova/ui/components/UnraidIcons.kt @@ -35,6 +35,7 @@ import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.Stop import androidx.compose.material.icons.outlined.Storage import androidx.compose.material.icons.outlined.Terminal +import androidx.compose.material.icons.outlined.Bedtime import androidx.compose.material.icons.outlined.Thermostat import androidx.compose.material.icons.outlined.ViewInAr import androidx.compose.material.icons.outlined.Visibility @@ -85,6 +86,8 @@ object UC { // claim was re-tested via local-ci and no longer holds. @Composable fun Bell( size: Dp = 20.dp, tint: Color = Color.Unspecified) = Ico(Icons.Outlined.Notifications, size, tint) @Composable fun Thermo( size: Dp = 20.dp, tint: Color = Color.Unspecified) = Ico(Icons.Outlined.Thermostat, size, tint) + /** Disk standby (spun-down) indicator — moon icon. */ + @Composable fun Standby( size: Dp = 20.dp, tint: Color = Color.Unspecified) = Ico(Icons.Outlined.Bedtime, size, tint) @Composable fun Power( size: Dp = 20.dp, tint: Color = Color.Unspecified) = Ico(Icons.Outlined.PowerSettingsNew, size, tint) @Composable fun Link( size: Dp = 20.dp, tint: Color = Color.Unspecified) = Ico(Icons.Outlined.Link, size, tint) @Composable fun Eye( size: Dp = 20.dp, tint: Color = Color.Unspecified) = Ico(Icons.Outlined.Visibility, size, tint) 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 c6f03c0..6b6e89f 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 @@ -28,7 +28,9 @@ import io.github.nofuturekid.nova.data.local.LayoutMode import io.github.nofuturekid.nova.data.model.ArrayInfo import io.github.nofuturekid.nova.data.model.ArrayState import io.github.nofuturekid.nova.data.model.Disk +import io.github.nofuturekid.nova.data.model.DiskTempLevel import io.github.nofuturekid.nova.data.model.DiskType +import io.github.nofuturekid.nova.data.model.tempLevel import io.github.nofuturekid.nova.data.repository.DomainState import io.github.nofuturekid.nova.ui.components.BtnVariant import io.github.nofuturekid.nova.ui.components.Pill @@ -293,7 +295,7 @@ private fun ArrayContent( } } -/** Compact disk tile for the Grid layout — type icon, name, temp, +/** 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) { @@ -303,10 +305,11 @@ private fun DiskTile(disk: Disk, errored: Boolean) { DiskType.Cache -> Color(0xFFA78BFA) DiskType.Data -> t.accent } - val tempColor = when { - disk.tempC >= 50 -> t.danger - disk.tempC >= 42 -> t.warn - else -> t.muted + val level = disk.tempLevel() + val tempColor = when (level) { + DiskTempLevel.Danger -> t.danger + DiskTempLevel.Warn -> t.warn + else -> t.muted } val pct = if (disk.sizeTb > 0) (disk.usedTb / disk.sizeTb).toFloat() else 0f UnraidCard(padding = UnraidTheme.tokens.padTight) { @@ -327,13 +330,25 @@ private fun DiskTile(disk: Disk, errored: Boolean) { maxLines = 1, overflow = TextOverflow.Ellipsis, ) - Text( - "${disk.tempC}°", - color = tempColor, - style = MaterialTheme.typography.labelSmall.copy(fontFamily = JetBrainsMono), - ) + if (level == DiskTempLevel.Standby) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp)) { + UC.Standby(11.dp, t.muted) + Text( + "Standby", + color = t.muted, + style = MaterialTheme.typography.labelSmall, + ) + } + } else { + Text( + "${disk.tempC}°", + color = tempColor, + style = MaterialTheme.typography.labelSmall.copy(fontFamily = JetBrainsMono), + ) + } } - if (errored) Pill("ERR", tone = Tone.Danger, dot = true) + if (disk.numErrors > 0) Pill("${disk.numErrors} err", tone = Tone.Danger, dot = true) + else if (errored) Pill("ERR", tone = Tone.Danger, dot = true) } if (disk.type != DiskType.Parity) { Spacer(Modifier.height(8.dp)) @@ -357,10 +372,11 @@ private fun DiskCard(disk: Disk, errored: Boolean) { DiskType.Cache -> Color(0xFFA78BFA) DiskType.Data -> t.accent } - val tempColor = when { - disk.tempC >= 50 -> t.danger - disk.tempC >= 42 -> t.warn - else -> t.muted + val level = disk.tempLevel() + val tempColor = when (level) { + DiskTempLevel.Danger -> t.danger + DiskTempLevel.Warn -> t.warn + else -> t.muted } val pct = if (disk.sizeTb > 0) (disk.usedTb / disk.sizeTb).toFloat() else 0f UnraidCard(padding = UnraidTheme.tokens.pad) { @@ -376,7 +392,8 @@ private fun DiskCard(disk: Disk, errored: Boolean) { Column(modifier = Modifier.weight(1f)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Text(disk.name, color = t.text, style = MaterialTheme.typography.titleSmall) - if (errored) Pill("ERR", tone = Tone.Danger, dot = true) + if (disk.numErrors > 0) Pill("${disk.numErrors} err", tone = Tone.Danger, dot = true) + else if (errored) Pill("ERR", tone = Tone.Danger, dot = true) } Text( text = "${disk.device} · ${disk.model}", @@ -386,13 +403,24 @@ private fun DiskCard(disk: Disk, errored: Boolean) { overflow = TextOverflow.Ellipsis, ) } - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { - UC.Thermo(13.dp, tempColor) - Text( - text = "${disk.tempC}°", - color = tempColor, - style = MaterialTheme.typography.labelLarge.copy(fontFamily = JetBrainsMono), - ) + if (level == DiskTempLevel.Standby) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + UC.Standby(13.dp, t.muted) + Text( + text = "Standby", + color = t.muted, + style = MaterialTheme.typography.labelLarge, + ) + } + } else { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + UC.Thermo(13.dp, tempColor) + Text( + text = "${disk.tempC}°", + color = tempColor, + style = MaterialTheme.typography.labelLarge.copy(fontFamily = JetBrainsMono), + ) + } } } if (disk.type != DiskType.Parity) { diff --git a/app/src/test/kotlin/io/github/nofuturekid/nova/data/api/ArrayMappingTest.kt b/app/src/test/kotlin/io/github/nofuturekid/nova/data/api/ArrayMappingTest.kt new file mode 100644 index 0000000..8c850e2 --- /dev/null +++ b/app/src/test/kotlin/io/github/nofuturekid/nova/data/api/ArrayMappingTest.kt @@ -0,0 +1,195 @@ +package io.github.nofuturekid.nova.data.api + +import io.github.nofuturekid.nova.data.model.DiskStatus +import io.github.nofuturekid.nova.data.model.DiskType +import io.github.nofuturekid.nova.graphql.GetArrayQuery +import io.github.nofuturekid.nova.graphql.type.ArrayDiskStatus +import io.github.nofuturekid.nova.graphql.type.ArrayDiskType +import io.github.nofuturekid.nova.graphql.type.ArrayState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Verifies that [GetArrayQuery.Data.toArrayInfo] carries the enriched disk + * fields introduced in v0.1.40-beta8 (ADR-0045). + * + * WHY: the root bug was temp=null/0 displayed as "0°" for a sleeping disk. + * These tests pin that: + * 1. isSpinning=false + temp=null → tempC=0 AND isSpinning=false in the domain + * model (so the UI can gate display correctly). + * 2. numErrors is carried through unchanged (disk errors must surface). + * 3. Per-disk warning/critical thresholds are carried through (null stays null + * when unset; positive values are mapped; zero/negative → null / "unset"). + */ +class ArrayMappingTest { + + // ── Sleeping disk ───────────────────────────────────────────────── + + @Test fun sleeping_disk_has_isSpinning_false_and_tempC_zero() { + // WHY: the primary 0.1.40-beta8 bug: a spun-down disk sends temp=null. + // The mapper coalesces null→0 for tempC. isSpinning=false MUST be + // faithfully forwarded so the UI can show "Standby" rather than "0°". + val data = arrayData( + disks = listOf(disk(name = "disk1", temp = null, isSpinning = false)), + ) + val info = data.toArrayInfo() + val d = info.disks.first { it.type == DiskType.Data } + assertFalse("sleeping disk must have isSpinning=false", d.isSpinning) + assertEquals("temp=null must coalesce to 0", 0, d.tempC) + } + + @Test fun spinning_disk_with_temp_carries_through() { + // WHY: normal spinning disk — temp must not be dropped. + val data = arrayData( + disks = listOf(disk(name = "disk2", temp = 38, isSpinning = true)), + ) + val info = data.toArrayInfo() + val d = info.disks.first { it.type == DiskType.Data } + assertTrue(d.isSpinning) + assertEquals(38, d.tempC) + } + + // ── numErrors ───────────────────────────────────────────────────── + + @Test fun num_errors_carried_through() { + // WHY: disk errors must be surfaced — they are a critical health signal. + val data = arrayData( + disks = listOf(disk(name = "disk3", numErrors = 3L)), + ) + val info = data.toArrayInfo() + val d = info.disks.first { it.type == DiskType.Data } + assertEquals(3L, d.numErrors) + } + + @Test fun null_num_errors_defaults_to_zero() { + val data = arrayData(disks = listOf(disk(name = "disk4", numErrors = null))) + val d = data.toArrayInfo().disks.first { it.type == DiskType.Data } + assertEquals(0L, d.numErrors) + } + + // ── Per-disk thresholds ─────────────────────────────────────────── + + @Test fun positive_warning_and_critical_carried_through() { + // WHY: when Unraid has per-disk thresholds set, the mapper must forward + // them so the UI can use server values instead of app defaults. + val data = arrayData( + disks = listOf(disk(name = "disk5", warningC = 45, criticalC = 55)), + ) + val d = data.toArrayInfo().disks.first { it.type == DiskType.Data } + assertEquals(45, d.warningC) + assertEquals(55, d.criticalC) + } + + @Test fun null_thresholds_stay_null() { + // WHY: null thresholds mean "unset" — the UI falls back to app defaults. + val data = arrayData( + disks = listOf(disk(name = "disk6", warningC = null, criticalC = null)), + ) + val d = data.toArrayInfo().disks.first { it.type == DiskType.Data } + assertNull(d.warningC) + assertNull(d.criticalC) + } + + @Test fun zero_thresholds_treated_as_unset_null() { + // WHY: Unraid sometimes returns 0 for an unset threshold; <=0 must + // be treated as "not configured" so the UI falls back to app defaults, + // not fires a threshold at 0 °C. + val data = arrayData( + disks = listOf(disk(name = "disk7", warningC = 0, criticalC = 0)), + ) + val d = data.toArrayInfo().disks.first { it.type == DiskType.Data } + assertNull("warningC=0 must be treated as unset", d.warningC) + assertNull("criticalC=0 must be treated as unset", d.criticalC) + } + + // ── Parity disk (no fsUsed) ─────────────────────────────────────── + + @Test fun parity_sleeping_disk_isSpinning_false() { + // WHY: parity disks can also spin down; the mapper passes usedKb=null + // for parities (no filesystem) but must still forward isSpinning. + val data = arrayData( + parities = listOf(parity(name = "parity1", temp = null, isSpinning = false)), + ) + val p = data.toArrayInfo().disks.first { it.type == DiskType.Parity } + assertFalse(p.isSpinning) + assertEquals(0, p.tempC) + } + + // ── Status ──────────────────────────────────────────────────────── + + @Test fun errored_status_maps_to_disk_error() { + val data = arrayData( + disks = listOf(disk(name = "disk8", status = ArrayDiskStatus.DISK_INVALID)), + ) + val d = data.toArrayInfo().disks.first { it.type == DiskType.Data } + assertEquals(DiskStatus.Error, d.status) + } + + // ── Builders ───────────────────────────────────────────────────── + + private fun arrayData( + disks: List = emptyList(), + parities: List = emptyList(), + caches: List = emptyList(), + ) = GetArrayQuery.Data( + array = GetArrayQuery.Array( + state = ArrayState.STARTED, + capacity = GetArrayQuery.Capacity( + kilobytes = GetArrayQuery.Kilobytes(total = "8000000000", used = "2000000000", free = "6000000000"), + ), + parities = parities, + disks = disks, + caches = caches, + parityCheckStatus = null, + ), + ) + + private fun disk( + name: String, + temp: Int? = 35, + isSpinning: Boolean? = true, + rotational: Boolean? = true, + numErrors: Long? = null, + warningC: Int? = null, + criticalC: Int? = null, + status: ArrayDiskStatus? = ArrayDiskStatus.DISK_OK, + ) = GetArrayQuery.Disk( + id = name, + name = name, + device = "sd${name.last()}", + size = 4_000_000_000L, + status = status, + temp = temp, + fsSize = 4_000_000_000L, + fsFree = 2_000_000_000L, + fsUsed = 2_000_000_000L, + type = ArrayDiskType.DATA, + isSpinning = isSpinning, + rotational = rotational, + numErrors = numErrors, + warning = warningC, + critical = criticalC, + ) + + private fun parity( + name: String, + temp: Int? = 35, + isSpinning: Boolean? = true, + ) = GetArrayQuery.Parity( + id = name, + name = name, + device = "sd${name.last()}", + size = 8_000_000_000L, + status = ArrayDiskStatus.DISK_OK, + temp = temp, + type = ArrayDiskType.PARITY, + isSpinning = isSpinning, + rotational = true, + numErrors = null, + warning = null, + critical = null, + ) +} 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 new file mode 100644 index 0000000..e28d068 --- /dev/null +++ b/app/src/test/kotlin/io/github/nofuturekid/nova/data/model/DiskTempLevelTest.kt @@ -0,0 +1,123 @@ +package io.github.nofuturekid.nova.data.model + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Tests for [Disk.tempLevel] — the pure helper that maps disk state to a + * [DiskTempLevel] without Compose on the classpath. + * + * WHY these cases matter: + * - 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. + */ +class DiskTempLevelTest { + + // ── Standby / sleep branch ──────────────────────────────────────── + + @Test fun spinning_false_is_standby_regardless_of_temp() { + // WHY: server returns temp=null when spun down → tempC=0 after coalesce. + // isSpinning=false is the ONLY truth source — a sleeping disk must NEVER + // render "0°". + val disk = disk(isSpinning = false, tempC = 0) + assertEquals(DiskTempLevel.Standby, disk.tempLevel()) + } + + @Test fun spinning_false_is_standby_even_with_nonzero_temp() { + // WHY: defensive — if the server ever returns a temp alongside isSpinning=false, + // the sleep state still wins. + val disk = disk(isSpinning = false, tempC = 55) + assertEquals(DiskTempLevel.Standby, disk.tempLevel()) + } + + // ── Fallback (null) thresholds ──────────────────────────────────── + + @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) + 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) + assertEquals(DiskTempLevel.Warn, 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) + 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()) + } + + @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()) + } + + // ── Per-disk server thresholds ──────────────────────────────────── + + @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). + 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. + 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). + 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()) + } + + // ── Builder ─────────────────────────────────────────────────────── + + private fun disk( + isSpinning: Boolean, + tempC: Int, + warningC: Int? = null, + criticalC: Int? = null, + ) = Disk( + name = "disk1", + device = "sda", + type = DiskType.Data, + sizeTb = 4.0, + usedTb = 2.0, + tempC = tempC, + status = DiskStatus.Ok, + model = "", + isSpinning = isSpinning, + rotational = true, + numErrors = 0L, + warningC = warningC, + criticalC = criticalC, + ) +} diff --git a/docs/adr/0045-array-disk-display.md b/docs/adr/0045-array-disk-display.md new file mode 100644 index 0000000..b506c4e --- /dev/null +++ b/docs/adr/0045-array-disk-display.md @@ -0,0 +1,67 @@ +# ADR-0045: Array disk display — sleeping disks, per-disk thresholds, errors + +- **Status**: Accepted +- **Date**: 2026-05-30 +- **Tags**: ui, array, data + +## Context + +Live-verified 2026-05-30: the Unraid server returns `temp = null` for a +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. + +## Decisions + +### 1. Sleeping disks show "Standby" instead of "0°" + +The `GetArray` poll now selects `isSpinning` (and `rotational`, `numErrors`, +`warning`, `critical`) for all disk groups (parities, disks, caches). +The domain `Disk` model carries `isSpinning: Boolean` as the authoritative +truth source for whether `tempC` is meaningful. When `isSpinning = false`, +both the grid tile and the list row replace the thermometer + temperature with +a moon icon (`UC.Standby` → `Icons.Outlined.Bedtime`) and the label "Standby" +(grid: icon + label if space, else icon only; list: icon + "Standby"). + +`tempC` is kept as `Int` (not `Int?`) to avoid a wider refactor — the +existing rendering code and tests only need to consult `isSpinning` before +using the value. KDoc on `Disk.tempC` documents this contract. + +### 2. Richer disk fields surfaced over the existing 5 s poll + +The following fields are now selected and forwarded to the domain model: + +| Field | Type | Domain field | Null treatment | +|-------------|---------|---------------|-------------------------------------| +| isSpinning | Boolean | isSpinning | null → false | +| rotational | Boolean | rotational | null → false | +| numErrors | BigInt | numErrors | null → 0L | +| warning | Int | warningC | null or ≤ 0 → null (unset) | +| 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. + +Disk errors (`numErrors > 0`) are shown as a `"N err"` danger pill on both +the grid tile and the list row. + +### 3. The array deliberately stays on the 5 s poll — NOT the subscription + +`arraySubscription` was live-verified 2026-05-30 and works correctly. However, +measured event frequency is ~10 s (one heartbeat per second from the server +results in a practical update cadence of 10 s). The existing `GetArray` poll +runs every 5 s and is **faster**. Furthermore, the poll is foreground-gated +(paused when the app is backgrounded), so there is no battery benefit to +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. + +## 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 errors are immediately visible without entering a detail view. +- No subscription migration required for the array.