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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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 = 113
versionName = "0.1.40-beta7"
versionCode = 114
versionName = "0.1.40-beta8"

vectorDrawables { useSupportLibrary = true }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,11 @@ type ArrayDisk {
fsFree: BigInt
fsUsed: BigInt
type: ArrayDiskType!
isSpinning: Boolean
rotational: Boolean
numErrors: BigInt
warning: Int
critical: Int
}

type ParityCheck {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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()
Expand All @@ -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 },
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand All @@ -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))
Expand All @@ -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) {
Expand All @@ -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}",
Expand All @@ -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) {
Expand Down
Loading