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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,17 @@ release version + date and opens a fresh `[Unreleased]`.

## [Unreleased]

### Added

- **A "Needs attention" card on the Overview** — when something needs you, a
card at the top lists it, most urgent first: an array that is not started or is
degraded, a parity check that is running, paused or failed, disks with errors,
disks at or above their warning or critical temperature, and unread alert and
warning notifications. Tap a row to open the Array tab or the notifications.
The card is hidden when all is well and while the server is offline. It uses
only data NOVA already loads, and it flags only what it knows: a sleeping disk
or a missing value is never reported as a problem.

## [0.1.47] - 2026-11-13

NOVA no longer installs updates itself. It still tells you when a new version is out and shows the release notes, the file size and its SHA-256; "Download" now lets your browser download the release file, and you install it by opening the downloaded file. The "Update available" notice and Settings now always show the same latest version. The 0.1.47 betas confirmed this on a real phone: the download and the install from the downloaded file work, and Google Play Protect offered a scan of the new file and then rated it as seeming safe. Play Protect can still offer to scan every new file before it installs; that is expected. If you update from 0.1.46 or older, that one last update still goes through the old installer and can show the old warning once more.
Expand Down
21 changes: 21 additions & 0 deletions HANDOFF.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ JSON-scalar decision rationale (incl. why-not-String): [ADR-0007](docs/adr/0007-
- **JSON scalar → kotlin.Any via Apollo's `AnyAdapter`** — the Unraid GraphQL `JSON` scalar serialises as inline JSON values (objects/arrays/primitives), not pre-stringified text. Custom `kotlin.String` adapters crash. We map it to `kotlin.Any` with `com.apollographql.apollo.api.AnyAdapter` (public, built-in). Don't try to write your own with `reader.readAny()` — that's `@ApolloInternal`.
- **Update check** lives under `data/update/`: `UpdateRepository` polls `https://api.github.com/repos/hellwand/nova/releases` (installed versions up to 0.1.42 still carry the old `nofuturekid/nova` name and reach it through GitHub's redirect; no auth, 60 req/h rate limit fine for on-start checks), `UpdateDialog` only sends the browser to the release's APK (or the release page when it has no APK) with `ACTION_VIEW`; the app downloads and installs nothing itself and declares no `REQUEST_INSTALL_PACKAGES` in any flavor (ADR-0046). The check is compiled into both flavors and disabled at runtime when `BuildConfig.HAS_UPDATER` is false (`store`). The Settings screen exposes the "Include pre-releases" toggle + manual "Check now". Banner on MainScreen dismisses per-tag via `dismissedUpdateTag` in DataStore.
- Cleartext HTTP requires `android:usesCleartextTraffic="true"` in the manifest. LAN access against `http://192.168.x.x` needs this. The base-config in `network_security_config.xml` permits cleartext; the GitHub hosts (`api.github.com`, `github.com`, `objects.githubusercontent.com`) are pinned to HTTPS.
- **Unraid 7.4 refuses oversized requests** (cost 1,000, depth 20, 100 aliases, 5,000 tokens). `GraphqlRequestLimitsTest` fails CI when an operation passes 70 % of any limit; numbers and sources under "Unraid 7.4 request limits (#320)" below.
- **CI `test` job (ADR-0036) can transiently fail at Gradle _configuration_** with a plugin-not-resolvable error (e.g. `com.google.devtools.ksp` not found) on a plugin-portal/registry blip — it fetches plugins fresh under PR `cache-read-only`. **Re-run the job; it is not a code defect** (see ADR-0036 "Known consequence — transient plugin-fetch in the `test` job" note; PR #163 2026-05-19).

### What the Unraid API does NOT expose
Expand All @@ -261,6 +262,26 @@ Minimum server API: every operation the app sends, except the two network-speed
- VmDomain has only id/name/state — no vcpu/memGb/gpu fields. UI placeholder zeros (still not exposed).
- Container icons may come back as **root-relative paths** (`/state/plugins/dynamix.docker.manager/images/foo.png`). We prepend the configured server base URL in `ContainerIcon.resolveIconUrl`.

### Unraid 7.4 request limits (#320)

Unraid 7.4.0 refuses GraphQL requests above fixed limits. Source: `api/src/unraid-api/graph/request-protection.ts` and `graph.module.ts` in unraid/api at commit `8e587fe` (2026-11-04; the API version `4.37.11+8e587fe2` that the 7.4.0 test server reports is built from exactly that commit), `@escape.tech/graphql-armor` 3.2.0, `graphql` 16.11.0, Fastify 5.12.5.

| Measure | Server limit | How it is counted | NOVA's own ceiling (70 %) |
| ------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------- |
| Cost | 1,000 | every field costs 1 (object and scalar alike, depth factor 1), fragments flattened (a spread adds 1 + 1 + its fields); a `first`/`last` integer literal multiplies; `limit` does not | 700 |
| Depth | 20 | field nesting; fragments and inline fragments add no level | 14 |
| Aliases | 100 | every aliased field, no allow-list | 70 |
| Tokens | 5,000 | graphql-js tokens of the whole document (HTTP and WebSocket subscribe messages); the `connection_init` message is not GraphQL | 3,500 |
| Request body | 1 MiB | no `bodyLimit` is set, so Fastify's default applies | 734,003 bytes (the test measures the query text only; variables are small) |

Only `__schema` and `__type` (introspection) are blocked; NOVA sends none. A refused request is HTTP 400 with `Syntax Error: Query Cost limit of 1000 exceeded, found N.` (or the alias, depth or token variant).

**Guard:** `GraphqlRequestLimitsTest` (unit test, runs in CI) re-implements these formulas, measures every operation in `app/src/main/graphql` as Apollo sends it (generated documents, `__typename` included) and fails with the operation and the measure when one passes 70 % of a limit. Its own tests pin the formulas to the server's boundaries. If it fails, slim the query down (fewer fields, a second query) rather than raising the margin.

**Measured 2026-11-13 on Unraid 7.4.0 (API 4.37.11):** the largest operation is `GetArray` with cost 59 (6 % of 1,000), depth 4; the deepest is `GetMetrics` with depth 5; most tokens `GetNotificationList` with 78 (2 % of 5,000); at most 2 aliases; largest document 477 bytes. All 15 shipped queries answer HTTP 200 with data. Live control requests (harmless `__typename` and copies of `GetArray` and `GetNotificationList`) were refused at exactly the predicted numbers (cost 1001, 1003 = 17 x 59, 1023 = 31 x 33; 101 aliases; 5,001 tokens) and 1,000 cost / 100 aliases were accepted, so the formulas match the live server. Not measured live: depth 21 (needs a recursive type) and the body limit (no oversized request was sent); both come from the source. The subscriptions were not sent live; their documents use 8 to 20 tokens.

NOVA does not need introspection. The limits come from the server's code on that commit; a later 7.4.x or 7.5 API may change them. Re-read `request-protection.ts` then and update `GraphqlRequestLimits` in the test.

## Secrets

### GitHub Actions secrets (already set on the repo)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import io.github.nofuturekid.nova.data.model.NotifImportance
import io.github.nofuturekid.nova.data.model.NotifType
import io.github.nofuturekid.nova.data.model.Notifications
import io.github.nofuturekid.nova.data.model.ParityCheck
import io.github.nofuturekid.nova.data.model.ParityLastRun
import io.github.nofuturekid.nova.data.model.ParityOutcome
import io.github.nofuturekid.nova.data.model.Plugin
import io.github.nofuturekid.nova.data.model.PluginInstallOperation
import io.github.nofuturekid.nova.data.model.PluginInstallStatus
Expand Down Expand Up @@ -180,6 +182,14 @@ fun GetArrayQuery.Data.toArrayInfo(): ArrayInfo {
)
},
disks = parities + data + caches,
parityLastRun = arrBlock.parityCheckStatus?.takeIf { it.running != true && it.paused != true }?.let { p ->
when (p.status) {
GParityStatus.COMPLETED -> ParityLastRun(ParityOutcome.Completed, p.errors)
GParityStatus.CANCELLED -> ParityLastRun(ParityOutcome.Cancelled, p.errors)
GParityStatus.FAILED -> ParityLastRun(ParityOutcome.Failed, p.errors)
else -> null
}
},
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package io.github.nofuturekid.nova.data.model

/** How urgent an [AttentionItem] is. Declared least to most urgent: the order is the sort key. */
enum class AttentionSeverity { Info, Warning, Critical }

/**
* Where an [AttentionItem] is dealt with. Kept free of UI types so background
* alerts and a widget can reuse it. [Notifications] is the bell's sheet, not a tab.
*/
enum class AttentionTarget { Array, Notifications }

/**
* One thing that needs the user's attention.
*
* [key] names the condition, not the moment: it stays the same while the same
* condition holds, so a later alert can tell "still the same problem" from "a new
* one" and not repeat itself. Changing numbers (progress, error count) therefore
* stay out of the key and live in [detail]. A condition that is worse in kind
* (warm to hot disk) gets its own key on purpose.
*/
data class AttentionItem(
val key: String,
val severity: AttentionSeverity,
val title: String,
val detail: String,
val target: AttentionTarget,
)

/** Everything [attentionItems] reads, all of it data the app already loads. Null means not loaded. */
data class AttentionSnapshot(
val array: ArrayInfo?,
val notifications: Notifications?,
val thresholds: DisplayThresholds?,
)

/**
* The problems visible in [snapshot], most urgent first (equal severity keeps the
* order the rules run in: array, parity, disks, notifications). Empty when all is well.
*
* Pure and free of Android and Compose, so the Overview card, background alerts and
* a home-screen widget can share it.
*
* Only known data raises an item: an array that is not loaded, a disk without a
* temperature, a threshold the server does not report or an error count the API does
* not give are not problems. Callers that know the server is offline pass an empty
* snapshot.
*
* Deliberately not covered: containers that are stopped but have auto-start on. The
* flag stays set when the user stops a container on purpose, and one-shot containers
* exit by design, so it cannot tell "should run" from "was stopped" (see issue 319).
*
* Disk temperatures need a threshold the server reported, per disk or globally; the
* Array tab also falls back to fixed 42/50 degrees, this card does not, because a
* guessed limit would raise alerts the server never configured.
*/
fun attentionItems(snapshot: AttentionSnapshot): List<AttentionItem> {
val items = buildList {
snapshot.array?.let { array ->
addAll(arrayItems(array))
addAll(parityItems(array))
addAll(diskItems(array.disks, snapshot.thresholds))
}
snapshot.notifications?.let { addAll(notificationItems(it)) }
}
// sortedByDescending is stable, so equal severities keep their rule order.
return items.sortedByDescending { it.severity }
}

private fun arrayItems(array: ArrayInfo): List<AttentionItem> = when (array.state) {
ArrayState.Stopped -> listOf(
AttentionItem("array-stopped", AttentionSeverity.Warning, "Array is not started", "Start it on the Array tab.", AttentionTarget.Array),
)
ArrayState.Error -> listOf(
AttentionItem("array-degraded", AttentionSeverity.Critical, "Array is degraded", "A disk is missing, disabled or invalid.", AttentionTarget.Array),
)
// Rebuilding and syncing already shows as the running parity operation; the
// state alone only gets its own row when the API gives no operation.
ArrayState.Parity -> if (array.parity == null) listOf(
AttentionItem("array-rebuilding", AttentionSeverity.Warning, "Array is rebuilding", "Parity is being rebuilt or synced.", AttentionTarget.Array),
) else emptyList()
// Offline here means "not known", never a problem.
ArrayState.Started, ArrayState.Offline -> emptyList()
}

private fun parityItems(array: ArrayInfo): List<AttentionItem> {
val running = array.parity
if (running != null) {
val percent = "${(running.progress * 100).toInt()}%"
return listOf(
if (running.paused) {
AttentionItem("parity-paused", AttentionSeverity.Warning, "Parity operation is paused", "Paused at $percent.", AttentionTarget.Array)
} else {
AttentionItem("parity-running", AttentionSeverity.Info, "Parity operation is running", "$percent done.", AttentionTarget.Array)
},
)
}
val last = array.parityLastRun ?: return emptyList()
val errors = last.errors?.takeIf { it > 0 }
return when {
last.outcome == ParityOutcome.Failed ->
listOf(AttentionItem("parity-failed", AttentionSeverity.Critical, "Last parity check failed", "It did not finish.", AttentionTarget.Array))
// A cancelled check is the user's own doing and never raises an item.
last.outcome == ParityOutcome.Completed && errors != null ->
listOf(AttentionItem("parity-errors", AttentionSeverity.Critical, "Parity check found errors", "$errors ${plural(errors, "error")} in the last check.", AttentionTarget.Array))
else -> emptyList()
}
}

private fun diskItems(disks: List<Disk>, thresholds: DisplayThresholds?): List<AttentionItem> = buildList {
for (disk in disks) {
val id = disk.name.ifBlank { disk.device }
if (id.isBlank()) continue
if (disk.numErrors > 0) {
add(AttentionItem("disk-errors:$id", AttentionSeverity.Critical, "$id has errors", "${disk.numErrors} ${plural(disk.numErrors, "error")} reported.", AttentionTarget.Array))
}
// A spun-down disk has no temperature; isSpinning is the truth source.
if (!disk.isSpinning) continue
val crit = (disk.criticalC ?: thresholds?.diskCritC)?.takeIf { it > 0 }
val warn = (disk.warningC ?: thresholds?.diskWarnC)?.takeIf { it > 0 }
when {
crit != null && disk.tempC >= crit ->
add(AttentionItem("disk-temp-critical:$id", AttentionSeverity.Critical, "$id is too hot", "${disk.tempC} °C, critical from $crit °C.", AttentionTarget.Array))
warn != null && disk.tempC >= warn ->
add(AttentionItem("disk-temp-warning:$id", AttentionSeverity.Warning, "$id is running warm", "${disk.tempC} °C, warning from $warn °C.", AttentionTarget.Array))
}
}
}

private fun notificationItems(n: Notifications): List<AttentionItem> = buildList {
if (n.unreadAlert > 0) {
add(AttentionItem("notifications-alert", AttentionSeverity.Critical, "${n.unreadAlert} unread ${plural(n.unreadAlert, "alert")}", "From the server notifications.", AttentionTarget.Notifications))
}
if (n.unreadWarning > 0) {
add(AttentionItem("notifications-warning", AttentionSeverity.Warning, "${n.unreadWarning} unread ${plural(n.unreadWarning, "warning")}", "From the server notifications.", AttentionTarget.Notifications))
}
}

private fun plural(count: Number, noun: String): String = if (count.toLong() == 1L) noun else "${noun}s"
Original file line number Diff line number Diff line change
Expand Up @@ -130,12 +130,27 @@ data class ParityCheck(
val paused: Boolean,
)

/** How the last parity operation ended, as far as the API reports it. */
enum class ParityOutcome { Completed, Cancelled, Failed }

/**
* The last finished parity operation. [errors] is null while the API does not
* report an error count (today it does not fill it), so null means unknown,
* never zero. Null in [ArrayInfo.parityLastRun] means no finished run is known.
*/
data class ParityLastRun(
val outcome: ParityOutcome,
val errors: Int?,
)

data class ArrayInfo(
val state: ArrayState,
val totalTb: Double,
val usedTb: Double,
val parity: ParityCheck?,
val disks: List<Disk>,
/** Set only while no operation is running; see [ParityLastRun]. */
val parityLastRun: ParityLastRun? = null,
)

enum class ContainerStatus { Running, Paused, Exited }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import kotlinx.coroutines.launch
import io.github.nofuturekid.nova.BuildConfig
import io.github.nofuturekid.nova.data.model.ArrayState
import io.github.nofuturekid.nova.data.model.AttentionTarget
import io.github.nofuturekid.nova.data.model.Container
import io.github.nofuturekid.nova.data.model.ConnectionMode
import io.github.nofuturekid.nova.data.model.NotifType
Expand Down Expand Up @@ -224,6 +225,14 @@ fun MainScreen(
temperature = (temperatureState as? DomainState.Content)?.value,
cpuWarnC = displayThresholds?.cpuWarnC,
cpuCritC = displayThresholds?.cpuCritC,
notifications = (notificationsState as? DomainState.Content)?.value,
displayThresholds = displayThresholds,
onOpenAttention = { target ->
when (target) {
AttentionTarget.Array -> vm.setSelectedTab(MainTab.Array)
AttentionTarget.Notifications -> showNotifications = true
}
},
)
MainTab.Array -> ArrayTab(
state = arrayState,
Expand Down
Loading
Loading