diff --git a/CHANGELOG.md b/CHANGELOG.md index 313db90e..c352d451 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/HANDOFF.md b/HANDOFF.md index 9c9eb0a0..650f5296 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -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 @@ -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) 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 49242f03..535d4f8a 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 @@ -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 @@ -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 + } + }, ) } diff --git a/app/src/main/kotlin/io/github/nofuturekid/nova/data/model/Attention.kt b/app/src/main/kotlin/io/github/nofuturekid/nova/data/model/Attention.kt new file mode 100644 index 00000000..cbd91423 --- /dev/null +++ b/app/src/main/kotlin/io/github/nofuturekid/nova/data/model/Attention.kt @@ -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 { + 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 = 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 { + 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, thresholds: DisplayThresholds?): List = 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 = 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" 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 15e81119..87d4f784 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 @@ -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, + /** Set only while no operation is running; see [ParityLastRun]. */ + val parityLastRun: ParityLastRun? = null, ) enum class ContainerStatus { Running, Paused, Exited } 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 874d9473..8791b71c 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 @@ -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 @@ -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, diff --git a/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/overview/AttentionCard.kt b/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/overview/AttentionCard.kt new file mode 100644 index 00000000..fc298aec --- /dev/null +++ b/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/overview/AttentionCard.kt @@ -0,0 +1,97 @@ +package io.github.nofuturekid.nova.ui.screens.overview + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import io.github.nofuturekid.nova.data.model.AttentionItem +import io.github.nofuturekid.nova.data.model.AttentionSeverity +import io.github.nofuturekid.nova.data.model.AttentionTarget +import io.github.nofuturekid.nova.ui.components.Pill +import io.github.nofuturekid.nova.ui.components.Tone +import io.github.nofuturekid.nova.ui.components.UC +import io.github.nofuturekid.nova.ui.components.UnraidCard +import io.github.nofuturekid.nova.ui.theme.UnraidTheme + +private fun AttentionSeverity.tone(): Tone = when (this) { + AttentionSeverity.Critical -> Tone.Danger + AttentionSeverity.Warning -> Tone.Warn + AttentionSeverity.Info -> Tone.Info +} + +private fun AttentionSeverity.label(): String = when (this) { + AttentionSeverity.Critical -> "Critical" + AttentionSeverity.Warning -> "Warning" + AttentionSeverity.Info -> "Info" +} + +private fun AttentionTarget.openLabel(): String = when (this) { + AttentionTarget.Array -> "Open the Array tab" + AttentionTarget.Notifications -> "Open notifications" +} + +/** + * Lists what needs the user's attention, one tappable row per item. Draws + * nothing for an empty list, so a healthy or offline server shows no card. + * The severity is spelled out in a pill as well as coloured, so it does not + * rely on colour alone; each row reads as one unit to a screen reader. + */ +@Composable +fun AttentionCard( + items: List, + onOpen: (AttentionTarget) -> Unit, +) { + if (items.isEmpty()) return + val t = UnraidTheme.colors + UnraidCard(padding = UnraidTheme.tokens.pad) { + Column { + Row( + modifier = Modifier.semantics { heading() }, + verticalAlignment = Alignment.CenterVertically, + ) { + UC.Alert(18.dp, t.muted) + Spacer(Modifier.width(8.dp)) + Text("Needs attention", color = t.muted, style = MaterialTheme.typography.labelLarge) + } + items.forEachIndexed { index, item -> + if (index > 0) HorizontalDivider(color = t.border) + AttentionRow(item, onClick = { onOpen(item.target) }) + } + } + } +} + +@Composable +private fun AttentionRow(item: AttentionItem, onClick: () -> Unit) { + val t = UnraidTheme.colors + val tone = item.severity.tone() + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick, role = Role.Button, onClickLabel = item.target.openLabel()) + .padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text(item.title, color = t.text, style = MaterialTheme.typography.bodyMedium) + Text(item.detail, color = t.muted, style = MaterialTheme.typography.bodySmall) + } + Pill(item.severity.label(), tone = tone, dot = true) + UC.ChevR(18.dp, t.muted) + } +} 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 3ac2a96c..91bdf9ed 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 @@ -27,10 +27,16 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import io.github.nofuturekid.nova.data.model.ArrayInfo import io.github.nofuturekid.nova.data.model.ArrayState +import io.github.nofuturekid.nova.data.model.AttentionItem +import io.github.nofuturekid.nova.data.model.AttentionSnapshot +import io.github.nofuturekid.nova.data.model.AttentionTarget +import io.github.nofuturekid.nova.data.model.attentionItems import io.github.nofuturekid.nova.data.model.Container import io.github.nofuturekid.nova.data.model.ContainerStatus +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.Notifications import io.github.nofuturekid.nova.data.model.Server import io.github.nofuturekid.nova.data.model.ServerInfo import io.github.nofuturekid.nova.data.model.Temperature @@ -75,6 +81,9 @@ fun OverviewTab( temperature: Temperature? = null, cpuWarnC: Int? = null, cpuCritC: Int? = null, + notifications: Notifications? = null, + displayThresholds: DisplayThresholds? = null, + onOpenAttention: (AttentionTarget) -> Unit = {}, ) { // Any single NoServer → no server picked. if (infoState is DomainState.NoServer || metricsState is DomainState.NoServer || @@ -103,7 +112,18 @@ fun OverviewTab( return } - OverviewContent(info, metrics, array, containers, vms, server, networkThroughput, temperature, cpuWarnC, cpuCritC) + // Same rule as the top bar's Offline pill: an Error on any of these streams + // means the server is not reachable, so there is nothing trustworthy to flag. + val offline = listOf(infoState, arrayState, dockerState, vmsState).any { it is DomainState.Error } + val attention = remember(array, notifications, displayThresholds, offline) { + if (offline) emptyList() + else attentionItems(AttentionSnapshot(array, notifications, displayThresholds)) + } + + OverviewContent( + info, metrics, array, containers, vms, server, networkThroughput, temperature, cpuWarnC, cpuCritC, + attention, onOpenAttention, + ) } @Composable @@ -118,6 +138,8 @@ private fun OverviewContent( temperature: Temperature? = null, cpuWarnC: Int? = null, cpuCritC: Int? = null, + attention: List = emptyList(), + onOpenAttention: (AttentionTarget) -> Unit = {}, ) { val t = UnraidTheme.colors @@ -186,6 +208,11 @@ private fun OverviewContent( ), verticalArrangement = Arrangement.spacedBy(d.gap), ) { + // Absent when empty: LazyColumn leaves no gap for an item that draws nothing. + if (attention.isNotEmpty()) { + item { AttentionCard(attention, onOpenAttention) } + } + item { UnraidCard(padding = UnraidTheme.tokens.padHero) { Column { 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 index e2d82d32..b0b4d3e1 100644 --- 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 @@ -2,6 +2,7 @@ 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.data.model.ParityOutcome import io.github.nofuturekid.nova.graphql.GetArrayQuery import io.github.nofuturekid.nova.graphql.type.ArrayDiskStatus import io.github.nofuturekid.nova.graphql.type.ArrayDiskType @@ -224,6 +225,41 @@ class ArrayMappingTest { assertNull(arrayData(parityStatus = parityStatus(status = GParityStatus.UNKNOWN__)).toArrayInfo().parity) } + // ── Last parity run (needs-attention card, issue #319) ──────────── + // + // WHY: the card must tell a failed or error-ridden finished check from a + // calm one, but only from what the API says. An error count the API does not + // fill stays null (unknown), never 0, and a never-run array has no last run. + + @Test fun finished_parity_statuses_map_to_the_last_run_outcome() { + val expected = mapOf( + GParityStatus.COMPLETED to ParityOutcome.Completed, + GParityStatus.CANCELLED to ParityOutcome.Cancelled, + GParityStatus.FAILED to ParityOutcome.Failed, + ) + for ((status, outcome) in expected) { + val last = arrayData(parityStatus = parityStatus(status = status)).toArrayInfo().parityLastRun + assertEquals("$status", outcome, last?.outcome) + } + } + + @Test fun last_run_keeps_an_unreported_error_count_unknown() { + val last = arrayData(parityStatus = parityStatus(status = GParityStatus.COMPLETED)).toArrayInfo().parityLastRun + assertNull("an error count the API does not fill must stay unknown, not 0", last!!.errors) + } + + @Test fun last_run_carries_a_reported_error_count() { + val last = arrayData(parityStatus = parityStatus(status = GParityStatus.COMPLETED, errors = 3)).toArrayInfo().parityLastRun + assertEquals(3, last!!.errors) + } + + @Test fun never_run_unknown_and_running_have_no_last_run() { + for (s in listOf(GParityStatus.NEVER_RUN, GParityStatus.UNKNOWN__, GParityStatus.RUNNING, GParityStatus.PAUSED)) { + assertNull("$s", arrayData(parityStatus = parityStatus(status = s)).toArrayInfo().parityLastRun) + } + assertNull(arrayData(parityStatus = null).toArrayInfo().parityLastRun) + } + // ── Builders ───────────────────────────────────────────────────── private fun arrayData( @@ -250,11 +286,12 @@ class ArrayMappingTest { speed: String? = null, running: Boolean? = null, paused: Boolean? = null, + errors: Int? = null, ) = GetArrayQuery.ParityCheckStatus( status = status, progress = progress, speed = speed, - errors = null, + errors = errors, running = running, paused = paused, ) diff --git a/app/src/test/kotlin/io/github/nofuturekid/nova/data/api/GraphqlDocumentMetrics.kt b/app/src/test/kotlin/io/github/nofuturekid/nova/data/api/GraphqlDocumentMetrics.kt new file mode 100644 index 00000000..5b7b0fa7 --- /dev/null +++ b/app/src/test/kotlin/io/github/nofuturekid/nova/data/api/GraphqlDocumentMetrics.kt @@ -0,0 +1,402 @@ +package io.github.nofuturekid.nova.data.api + +import kotlin.math.max + +/** + * Test-only measuring tool for GraphQL documents: the same numbers the Unraid + * API (7.4+) checks before it runs a request (#320). + * + * Source of the limits and of the formulas: unraid/api, + * `api/src/unraid-api/graph/request-protection.ts` (commit 8e587fe, 2026-11-04), + * which configures `@escape.tech/graphql-armor` 3.2.0 and the `graphql` 16.11.0 + * parser. The formulas below are re-implemented from the plugins' published + * code, not from memory: + * + * - cost: `@escape.tech/graphql-armor-cost-limit` 2.4.3 (`CostLimitVisitor.computeComplexity`) + * - depth: `@escape.tech/graphql-armor-max-depth` 2.4.2 (`MaxDepthVisitor.countDepth`) + * - aliases: `@escape.tech/graphql-armor-max-aliases` 2.6.2 (`MaxAliasesVisitor.countAliases`) + * - tokens: `graphql` 16.11.0 `Parser.advanceLexer` (`parseOptions.maxTokens`) + * + * The one deliberate simplification: the plugins memoise fragment results in a + * per-validation map; for documents that pass GraphQL's own "no fragment + * cycles" rule that memo returns exactly what a fresh walk returns, so it is + * left out here. Everything else (flattened fragments, no introspection + * special case, `first`/`last` multiplier) follows the server's configuration. + * + * The cost needs no schema knowledge: with object cost 1, scalar cost 1 and + * depth factor 1 the plugin does not look at types, only at whether a field has + * a selection set. The only argument it reads is an integer literal `first` or + * `last` (a multiplier); NOVA uses neither (`limit: 200` is not one), so the + * number reported here is exact, not an upper bound. A variable `first`/`last` + * would count as multiplier 1 on the server too. + */ +internal object GraphqlRequestLimits { + // request-protection.ts: costLimit { maxCost: 1_000, objectCost: 1, scalarCost: 1, + // depthCostFactor: 1, flattenFragments: true, ignoreIntrospection: false } + const val MAX_COST = 1_000 + const val OBJECT_COST = 1L + const val SCALAR_COST = 1L + const val DEPTH_COST_FACTOR = 1L + + // request-protection.ts: maxDepth { n: 20, flattenFragments: true, ignoreIntrospection: false } + const val MAX_DEPTH = 20 + + // request-protection.ts: maxAliases { n: 100, allowList: [] } + const val MAX_ALIASES = 100 + + // request-protection.ts: maxGraphqlTokens = 5_000 (graph.module.ts parseOptions for HTTP, + // createGraphqlWsOptions.onSubscribe for WebSocket subscribe messages). + const val MAX_TOKENS = 5_000 + + // No bodyLimit is set anywhere in the API (main.ts builds `new FastifyAdapter({ trustProxy })`), + // so Fastify 5's default of 1 MiB applies to the JSON request body. + const val MAX_BODY_BYTES = 1_048_576 + + /** NOVA's own safety margin: no shipped operation may use more than this share of any limit. */ + const val MARGIN = 0.7 +} + +internal enum class TokenKind { PUNCT, NAME, INT, FLOAT, STRING } + +internal data class GqlToken(val kind: TokenKind, val text: String) + +internal sealed interface Selection + +internal data class FieldSelection( + val alias: String?, + val name: String, + /** The `first`/`last` multiplier of the cost plugin; 1 when absent or not an integer literal. */ + val multiplier: Int, + /** null for a leaf field (no selection set). */ + val selections: List?, +) : Selection + +internal data class InlineFragmentSelection(val selections: List) : Selection + +internal data class FragmentSpreadSelection(val name: String) : Selection + +internal data class OperationDefinition(val kind: String, val name: String?, val selections: List) + +internal data class FragmentDefinition(val name: String, val selections: List) + +internal class ParsedDocument( + val operations: List, + val fragments: Map, + val tokenCount: Int, +) + +/** Hand-written lexer: the Apollo compiler is a Gradle-plugin dependency and not on the test classpath. */ +internal fun tokenizeGraphql(src: String): List { + val out = mutableListOf() + var i = 0 + while (i < src.length) { + val c = src[i] + when { + c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == ',' || c == '' -> i++ + c == '#' -> while (i < src.length && src[i] != '\n' && src[i] != '\r') i++ + c == '.' -> { + require(src.startsWith("...", i)) { "Unexpected '.' at $i" } + out += GqlToken(TokenKind.PUNCT, "...") + i += 3 + } + c in "!$&():=@[]{|}" -> { + out += GqlToken(TokenKind.PUNCT, c.toString()) + i++ + } + c == '"' -> { + val start = i + if (src.startsWith("\"\"\"", i)) { + i += 3 + while (!src.startsWith("\"\"\"", i) || src[i - 1] == '\\') { + i++ + require(i < src.length) { "Unterminated block string at $start" } + } + i += 3 + } else { + i++ + while (src[i] != '"') { + i += if (src[i] == '\\') 2 else 1 + require(i < src.length) { "Unterminated string at $start" } + } + i++ + } + out += GqlToken(TokenKind.STRING, src.substring(start, i)) + } + c == '-' || c.isDigit() -> { + val start = i + i++ + while (i < src.length && (src[i].isDigit() || src[i] in ".eE+-")) i++ + val text = src.substring(start, i) + val isFloat = text.any { it == '.' || it == 'e' || it == 'E' } + out += GqlToken(if (isFloat) TokenKind.FLOAT else TokenKind.INT, text) + } + c == '_' || c in 'a'..'z' || c in 'A'..'Z' -> { + val start = i + while (i < src.length && (src[i] == '_' || src[i].isLetterOrDigit())) i++ + out += GqlToken(TokenKind.NAME, src.substring(start, i)) + } + else -> error("Unexpected character '$c' at $i") + } + } + return out +} + +/** The graphql-js token count: every token except comments, whitespace, commas and the end marker. */ +internal fun countGraphqlTokens(src: String): Int = tokenizeGraphql(src).size + +/** Parses operations and fragments; type-system definitions are not supported (NOVA ships none in documents). */ +internal fun parseGraphqlDocument(src: String): ParsedDocument { + val parser = GqlParser(tokenizeGraphql(src)) + return parser.parseDocument() +} + +private class GqlParser(private val tokens: List) { + private var pos = 0 + + private fun peek(): GqlToken? = tokens.getOrNull(pos) + private fun isPunct(p: String) = peek()?.let { it.kind == TokenKind.PUNCT && it.text == p } == true + private fun isName(n: String? = null) = peek()?.let { it.kind == TokenKind.NAME && (n == null || it.text == n) } == true + + private fun next(): GqlToken = tokens.getOrNull(pos++) ?: error("Unexpected end of document") + + private fun expectPunct(p: String) { + val t = next() + require(t.kind == TokenKind.PUNCT && t.text == p) { "Expected '$p' but found '${t.text}' (token ${pos - 1})" } + } + + private fun expectName(): String { + val t = next() + require(t.kind == TokenKind.NAME) { "Expected a name but found '${t.text}' (token ${pos - 1})" } + return t.text + } + + fun parseDocument(): ParsedDocument { + val operations = mutableListOf() + val fragments = linkedMapOf() + while (peek() != null) { + when { + isPunct("{") -> operations += OperationDefinition("query", null, parseSelectionSet()) + isName("fragment") -> { + next() + val name = expectName() + require(isName("on")) { "Fragment $name: expected 'on'" } + next() + expectName() + skipDirectives() + fragments[name] = FragmentDefinition(name, parseSelectionSet()) + } + isName("query") || isName("mutation") || isName("subscription") -> { + val kind = next().text + val name = if (isName()) next().text else null + if (isPunct("(")) skipVariableDefinitions() + skipDirectives() + operations += OperationDefinition(kind, name, parseSelectionSet()) + } + else -> error("Unexpected token '${peek()?.text}' at the top level (token $pos)") + } + } + return ParsedDocument(operations, fragments, tokens.size) + } + + private fun skipVariableDefinitions() { + expectPunct("(") + while (!isPunct(")")) { + expectPunct("$") + expectName() + expectPunct(":") + skipType() + if (isPunct("=")) { + next() + parseValue() + } + skipDirectives() + } + expectPunct(")") + } + + private fun skipType() { + if (isPunct("[")) { + next() + skipType() + expectPunct("]") + } else { + expectName() + } + if (isPunct("!")) next() + } + + private fun skipDirectives() { + while (isPunct("@")) { + next() + expectName() + if (isPunct("(")) parseArguments() + } + } + + private fun parseSelectionSet(): List { + expectPunct("{") + val out = mutableListOf() + while (!isPunct("}")) out += parseSelection() + expectPunct("}") + return out + } + + private fun parseSelection(): Selection { + if (isPunct("...")) { + next() + if (isName() && !isName("on")) { + val name = next().text + skipDirectives() + return FragmentSpreadSelection(name) + } + if (isName("on")) { + next() + expectName() + } + skipDirectives() + return InlineFragmentSelection(parseSelectionSet()) + } + var name = expectName() + var alias: String? = null + if (isPunct(":")) { + next() + alias = name + name = expectName() + } + val multiplier = if (isPunct("(")) parseArguments() else 1 + skipDirectives() + val selections = if (isPunct("{")) parseSelectionSet() else null + return FieldSelection(alias, name, multiplier, selections) + } + + /** Returns the cost plugin's multiplier: the first `first`/`last` argument decides, an integer literal counts. */ + private fun parseArguments(): Int { + expectPunct("(") + var multiplier = 1 + var decided = false + while (!isPunct(")")) { + val name = expectName() + expectPunct(":") + val value = parseValue() + if (!decided && (name == "first" || name == "last")) { + decided = true + if (value.kind == TokenKind.INT) multiplier = max(value.text.toIntOrNull() ?: 1, 1) + } + } + expectPunct(")") + return multiplier + } + + private fun parseValue(): GqlToken { + val t = next() + when { + t.kind == TokenKind.PUNCT && t.text == "$" -> expectName() + t.kind == TokenKind.PUNCT && t.text == "[" -> { + while (!isPunct("]")) parseValue() + expectPunct("]") + } + t.kind == TokenKind.PUNCT && t.text == "{" -> { + while (!isPunct("}")) { + expectName() + expectPunct(":") + parseValue() + } + expectPunct("}") + } + t.kind == TokenKind.PUNCT -> error("Unexpected '${t.text}' in a value") + } + return t + } +} + +internal data class OperationMetrics( + val name: String, + val kind: String, + val tokens: Int, + val depth: Int, + val aliases: Int, + val cost: Long, + val bodyBytes: Int, +) + +/** Measures every operation of [src] by the server's formulas; fragments of the same document are resolved. */ +internal fun measureGraphqlDocument(src: String): List { + val doc = parseGraphqlDocument(src) + val bytes = src.toByteArray(Charsets.UTF_8).size + return doc.operations.map { op -> + OperationMetrics( + name = op.name ?: "(anonymous)", + kind = op.kind, + tokens = doc.tokenCount, + depth = depthOf(op.selections, 0, doc.fragments), + aliases = aliasesOf(op.selections, doc.fragments), + cost = op.selections.sumOf { rootCost(it, doc.fragments) }, + bodyBytes = bytes, + ) + } +} + +// graphql-armor-cost-limit: computeComplexity for the operation's own children (no depth factor). +private fun rootCost(sel: Selection, fragments: Map): Long = when (sel) { + is FieldSelection -> fieldCost(sel, fragments) + is InlineFragmentSelection -> GraphqlRequestLimits.OBJECT_COST + childTerms(sel.selections, fragments) + is FragmentSpreadSelection -> spreadCost(sel, fragments) +} + +private fun fieldCost(f: FieldSelection, fragments: Map): Long { + val children = f.selections ?: return GraphqlRequestLimits.SCALAR_COST + return (GraphqlRequestLimits.OBJECT_COST + childTerms(children, fragments)) * f.multiplier +} + +// flattenFragments: fields below a selection set are weighted by the depth factor, fragments are added as they are. +private fun childTerms(children: List, fragments: Map): Long = + children.sumOf { child -> + when (child) { + is FieldSelection -> GraphqlRequestLimits.DEPTH_COST_FACTOR * fieldCost(child, fragments) + is InlineFragmentSelection -> + GraphqlRequestLimits.OBJECT_COST + childTerms(child.selections, fragments) + is FragmentSpreadSelection -> spreadCost(child, fragments) + } + } + +private fun spreadCost(s: FragmentSpreadSelection, fragments: Map): Long { + val def = fragments[s.name] ?: error("Fragment ${s.name} is spread but not defined in the document") + return GraphqlRequestLimits.SCALAR_COST + GraphqlRequestLimits.OBJECT_COST + childTerms(def.selections, fragments) +} + +// graphql-armor-max-depth, flattenFragments: a field sits one level below its parent; inline +// fragments and spreads add no level of their own. +private fun depthOf(children: List, parentDepth: Int, fragments: Map): Int { + var depth = parentDepth + for (child in children) { + depth = max( + depth, + when (child) { + is FieldSelection -> { + val level = parentDepth + 1 + child.selections?.let { depthOf(it, level, fragments) } ?: level + } + is InlineFragmentSelection -> depthOf(child.selections, parentDepth, fragments) + is FragmentSpreadSelection -> { + val def = fragments[child.name] ?: error("Fragment ${child.name} is spread but not defined") + parentDepth + depthOf(def.selections, 0, fragments) + } + }, + ) + } + return depth +} + +// graphql-armor-max-aliases with allowList []: every aliased field counts, also inside fragments. +private fun aliasesOf(children: List, fragments: Map): Int = + children.sumOf { child -> + when (child) { + is FieldSelection -> + (if (child.alias != null) 1 else 0) + (child.selections?.let { aliasesOf(it, fragments) } ?: 0) + is InlineFragmentSelection -> aliasesOf(child.selections, fragments) + is FragmentSpreadSelection -> { + val def = fragments[child.name] ?: error("Fragment ${child.name} is spread but not defined") + aliasesOf(def.selections, fragments) + } + } + } diff --git a/app/src/test/kotlin/io/github/nofuturekid/nova/data/api/GraphqlRequestLimitsTest.kt b/app/src/test/kotlin/io/github/nofuturekid/nova/data/api/GraphqlRequestLimitsTest.kt new file mode 100644 index 00000000..7a3f9756 --- /dev/null +++ b/app/src/test/kotlin/io/github/nofuturekid/nova/data/api/GraphqlRequestLimitsTest.kt @@ -0,0 +1,166 @@ +package io.github.nofuturekid.nova.data.api + +import java.io.File +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * #320 — Unraid 7.4 refuses requests above fixed limits (cost 1,000, depth 20, + * 100 aliases, 5,000 GraphQL tokens, 1 MiB body; see [GraphqlRequestLimits]). + * The refusal reaches the user as a failed screen, long after the query that + * grew too large was merged. This test measures every operation NOVA ships by + * the server's own formulas and fails in CI when one passes [GraphqlRequestLimits.MARGIN] + * of any limit, so the query is slimmed down while it is still a pull request. + */ +class GraphqlRequestLimitsTest { + private val margin = GraphqlRequestLimits.MARGIN + + private data class Measure(val label: String, val value: Long, val limit: Int) { + val threshold: Long get() = (limit * GraphqlRequestLimits.MARGIN).toLong() + } + + private fun measures(m: OperationMetrics) = listOf( + Measure("cost", m.cost, GraphqlRequestLimits.MAX_COST), + Measure("depth", m.depth.toLong(), GraphqlRequestLimits.MAX_DEPTH), + Measure("aliases", m.aliases.toLong(), GraphqlRequestLimits.MAX_ALIASES), + Measure("tokens", m.tokens.toLong(), GraphqlRequestLimits.MAX_TOKENS), + Measure("request document bytes", m.bodyBytes.toLong(), GraphqlRequestLimits.MAX_BODY_BYTES), + ) + + private fun violations(all: List): List = all.flatMap { m -> + measures(m).filter { it.value > it.threshold }.map { + "${m.kind} ${m.name}: ${it.label} ${it.value} exceeds ${it.threshold} " + + "(${(margin * 100).toInt()}% of the Unraid 7.4 limit ${it.limit})" + } + } + + // ── the formula must be the server's formula ───────────────────────── + + private fun one(src: String) = measureGraphqlDocument(src).single() + + @Test fun cost_countsEveryFieldOnce_andRejectsAboveTheLimitLikeTheServer() { + // WHY: upstream request-protection.spec.ts accepts the limit and rejects limit+1; if our + // numbers drifted by one the margin check would give false comfort. + assertEquals(1_000L, one("{ ${"value ".repeat(1_000)} }").cost) + assertEquals(1_001L, one("{ ${"value ".repeat(1_001)} }").cost) + } + + @Test fun cost_objectFieldIsOnePlusItsChildren() { + assertEquals(1L + 3L, one("{ a { x y z } }").cost) + assertEquals(2L + 2L, one("{ a { x } b { y } }").cost) // two object fields, two leaves + } + + @Test fun cost_fragmentSpreadCostsSpreadPlusFragmentObjectPlusFields() { + // flattenFragments: spread (scalar cost 1) + fragment definition (object cost 1) + its fields. + assertEquals(1L + 1L + 2L, one("{ ...F } fragment F on Q { x y }").cost) + } + + @Test fun cost_firstAndLastIntegerLiteralsMultiply_otherLimitArgumentsDoNot() { + assertEquals((1L + 1L) * 50, one("{ a(first: 50) { x } }").cost) + // WHY: NOVA's GetNotificationList passes `limit: 200`; the server does not multiply by it. + assertEquals(1L + 1L, one("{ a(limit: 200) { x } }").cost) + // A variable is not an IntValue, so the server uses multiplier 1. + assertEquals(1L + 1L, one("query(\$n: Int) { a(first: \$n) { x } }").cost) + } + + @Test fun depth_matchesServerBoundary() { + fun nested(levels: Int) = "{ ${"child { ".repeat(levels)}value${" }".repeat(levels)} }" + assertEquals(20, one(nested(19)).depth) // 19 objects + the leaf = depth 20: still allowed + assertEquals(21, one(nested(20)).depth) // upstream: "rejects deeply nested fields" + } + + @Test fun depth_fragmentsAndInlineFragmentsAddNoLevel() { + assertEquals(2, one("{ a { ...F } } fragment F on A { b }").depth) + assertEquals(2, one("{ a { ... on A { b } } }").depth) + // upstream: "does not count named fragments as field depth" + val chain = (0..20).joinToString(" ") { i -> + "fragment F$i on Query { ${if (i == 20) "value" else "...F${i + 1}"} }" + } + assertEquals(1, one("{ ...F0 } $chain").depth) + } + + @Test fun aliases_countedAtTheLimitBoundaryAndInsideFragments() { + fun aliased(n: Int) = "{ ${(0 until n).joinToString(" ") { "a$it: value" }} }" + assertEquals(100, one(aliased(100)).aliases) + assertEquals(101, one(aliased(101)).aliases) + assertEquals(2, one("{ ...F ...F } fragment F on Q { x: value }").aliases) + // allowList is [] on the server: even an aliased __typename counts. + assertEquals(1, one("{ t: __typename }").aliases) + } + + @Test fun tokens_countPunctuationAndVariablesButNotCommentsOrCommas() { + // query Q ( $ id : ID ! ) { a ( x : 1 ) } = 17 tokens in graphql-js. + assertEquals(17, one("# comment\nquery Q(\$id: ID!) { a(x: 1) }").tokens) + assertEquals(one("{ a b c }").tokens, one("{ a, b, c }").tokens) + assertEquals(8, one("{ a(x: \"s, {}\") }").tokens) // { a ( x : "..." ) } - the string is one token + } + + @Test fun aTooLargeQueryIsCaught() { + // WHY: proves the gate can fail - 800 fields cost 800, above the 70 % margin (700) + // but below the server limit (1,000), which is exactly the case it exists for. + val big = one("query Big { ${"value ".repeat(800)} }") + val found = violations(listOf(big)) + assertTrue(found.toString(), found.any { it.contains("Big") && it.contains("cost 800") }) + } + + // ── what NOVA ships ────────────────────────────────────────────────── + + private fun graphqlDir(): File = + listOf(File("src/main/graphql"), File("app/src/main/graphql")).firstOrNull { it.isDirectory } + ?: error("app/src/main/graphql not found from ${File(".").absolutePath}") + + /** The document exactly as Apollo sends it: generated code, so added `__typename`s are included. */ + private fun wireDocument(kind: String, name: String): String { + val suffix = when (kind) { + "query" -> "Query" + "mutation" -> "Mutation" + else -> "Subscription" + } + val cls = Class.forName("io.github.nofuturekid.nova.graphql.$name$suffix") + // Apollo 5 generates `companion object { val OPERATION_DOCUMENT: String get() = "..." }`. + val companion = cls.getField("Companion").get(null) + return companion.javaClass.getMethod("getOPERATION_DOCUMENT").invoke(companion) as String + } + + @Test fun everyShippedOperation_staysWithinTheMarginOfTheUnraidRequestLimits() { + val files = graphqlDir().walkTopDown().filter { it.isFile && it.extension == "graphql" }.sortedBy { it.path }.toList() + assertTrue("No .graphql documents found", files.isNotEmpty()) + + // Discovery from the source documents (so a new operation cannot be forgotten), numbers + // from the generated wire document (so what the server sees is what is measured). + val declared = files.flatMap { parseGraphqlDocument(it.readText()).operations } + assertTrue("No operations found", declared.isNotEmpty()) + val measured = declared.map { op -> + val name = requireNotNull(op.name) { "Anonymous ${op.kind} in the shipped documents" } + measureGraphqlDocument(wireDocument(op.kind, name)).single() + } + + val table = buildString { + appendLine() + appendLine("GraphQL request limits (Unraid 7.4; margin ${(margin * 100).toInt()}%)") + appendLine("%-34s %-13s %7s %6s %7s %6s %6s".format("operation", "kind", "cost", "depth", "aliases", "tokens", "bytes")) + appendLine( + "%-34s %-13s %7s %6s %7s %6s %6s".format( + "limit (threshold)", "", + "${GraphqlRequestLimits.MAX_COST}(${(GraphqlRequestLimits.MAX_COST * margin).toInt()})", + "${GraphqlRequestLimits.MAX_DEPTH}(${(GraphqlRequestLimits.MAX_DEPTH * margin).toInt()})", + "${GraphqlRequestLimits.MAX_ALIASES}(${(GraphqlRequestLimits.MAX_ALIASES * margin).toInt()})", + "${GraphqlRequestLimits.MAX_TOKENS}(${(GraphqlRequestLimits.MAX_TOKENS * margin).toInt()})", + "1MiB", + ), + ) + measured.forEach { + appendLine("%-34s %-13s %7d %6d %7d %6d %6d".format(it.name, it.kind, it.cost, it.depth, it.aliases, it.tokens, it.bodyBytes)) + } + } + println(table) + + val found = violations(measured) + assertTrue( + "Operations over the safety margin of the Unraid 7.4 request limits " + + "(slim the query down before the server starts refusing it):\n" + found.joinToString("\n") + "\n" + table, + found.isEmpty(), + ) + } +} diff --git a/app/src/test/kotlin/io/github/nofuturekid/nova/data/model/AttentionItemsTest.kt b/app/src/test/kotlin/io/github/nofuturekid/nova/data/model/AttentionItemsTest.kt new file mode 100644 index 00000000..4be7bfd1 --- /dev/null +++ b/app/src/test/kotlin/io/github/nofuturekid/nova/data/model/AttentionItemsTest.kt @@ -0,0 +1,294 @@ +package io.github.nofuturekid.nova.data.model + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Tests for [attentionItems], the one rule set behind the Overview card and later + * the background alerts and the widget. + * + * WHY these cases matter: + * - A healthy server must yield an empty list, or the card would cry wolf and + * users would stop reading it. + * - Unknown is not a problem: an array that is not loaded, a spun-down disk, a + * threshold the server does not report or an error count the API does not fill + * must never raise anything. + * - Keys name the condition, not the moment, so a later alert can avoid repeating + * itself while the same problem lasts, yet still fire when it gets worse in kind. + * - The order is by urgency, so the most important row is always on top. + */ +class AttentionItemsTest { + + // ── Healthy and unknown ─────────────────────────────────────────── + + @Test fun healthy_server_has_nothing_to_flag() { + val snap = snapshot( + array = array(disks = listOf(disk("disk1", tempC = 35), disk("parity", tempC = 33))), + notifications = notifications(), + thresholds = DisplayThresholds(45, 55, 70, 90), + ) + assertEquals(emptyList(), attentionItems(snap)) + } + + @Test fun nothing_loaded_has_nothing_to_flag() { + assertEquals(emptyList(), attentionItems(snapshot())) + } + + @Test fun array_state_offline_means_unknown_not_a_problem() { + // WHY: the mapper uses Offline for "no array data"; it must not alarm. + assertEquals(emptyList(), attentionItems(snapshot(array = array(state = ArrayState.Offline)))) + } + + // ── Array state ─────────────────────────────────────────────────── + + @Test fun stopped_array_is_a_warning() { + val item = attentionItems(snapshot(array = array(state = ArrayState.Stopped))).single() + assertEquals("array-stopped", item.key) + assertEquals(AttentionSeverity.Warning, item.severity) + assertEquals(AttentionTarget.Array, item.target) + } + + @Test fun degraded_array_is_critical() { + val item = attentionItems(snapshot(array = array(state = ArrayState.Error))).single() + assertEquals("array-degraded", item.key) + assertEquals(AttentionSeverity.Critical, item.severity) + } + + @Test fun rebuilding_state_without_an_operation_is_flagged_once() { + val item = attentionItems(snapshot(array = array(state = ArrayState.Parity))).single() + assertEquals("array-rebuilding", item.key) + } + + @Test fun rebuilding_state_with_a_running_operation_shows_only_the_operation() { + // WHY: the state and the operation describe the same thing; two rows would double the noise. + val keys = attentionItems(snapshot(array = array(state = ArrayState.Parity, parity = ParityCheck(0.3f, 100.0, false)))).map { it.key } + assertEquals(listOf("parity-running"), keys) + } + + // ── Parity ──────────────────────────────────────────────────────── + + @Test fun running_parity_operation_is_info_and_keeps_its_key_as_progress_moves() { + // WHY: progress changes every poll; the key must not, or a later alert would repeat. + val early = attentionItems(snapshot(array = array(parity = ParityCheck(0.10f, 100.0, false)))).single() + val late = attentionItems(snapshot(array = array(parity = ParityCheck(0.85f, 100.0, false)))).single() + assertEquals(AttentionSeverity.Info, early.severity) + assertEquals(early.key, late.key) + assertEquals("10% done.", early.detail) + assertEquals("85% done.", late.detail) + } + + @Test fun paused_parity_operation_is_a_warning_with_its_own_key() { + val item = attentionItems(snapshot(array = array(parity = ParityCheck(0.5f, 0.0, true)))).single() + assertEquals("parity-paused", item.key) + assertEquals(AttentionSeverity.Warning, item.severity) + } + + @Test fun failed_last_check_is_critical() { + val item = attentionItems(snapshot(array = array(lastRun = ParityLastRun(ParityOutcome.Failed, null)))).single() + assertEquals("parity-failed", item.key) + assertEquals(AttentionSeverity.Critical, item.severity) + } + + @Test fun completed_check_with_errors_is_critical() { + val item = attentionItems(snapshot(array = array(lastRun = ParityLastRun(ParityOutcome.Completed, 3)))).single() + assertEquals("parity-errors", item.key) + assertEquals(AttentionSeverity.Critical, item.severity) + assertEquals("3 errors in the last check.", item.detail) + } + + @Test fun completed_check_with_one_error_uses_the_singular() { + val item = attentionItems(snapshot(array = array(lastRun = ParityLastRun(ParityOutcome.Completed, 1)))).single() + assertEquals("1 error in the last check.", item.detail) + } + + @Test fun completed_check_without_errors_or_with_unknown_errors_is_calm() { + // WHY: the API does not fill the error count today; unknown must never read as "errors". + assertEquals(emptyList(), attentionItems(snapshot(array = array(lastRun = ParityLastRun(ParityOutcome.Completed, 0))))) + assertEquals(emptyList(), attentionItems(snapshot(array = array(lastRun = ParityLastRun(ParityOutcome.Completed, null))))) + } + + @Test fun cancelled_check_is_the_users_own_doing() { + assertEquals(emptyList(), attentionItems(snapshot(array = array(lastRun = ParityLastRun(ParityOutcome.Cancelled, null))))) + assertEquals(emptyList(), attentionItems(snapshot(array = array(lastRun = ParityLastRun(ParityOutcome.Cancelled, 5))))) + } + + // ── Disk errors ─────────────────────────────────────────────────── + + @Test fun disk_with_errors_is_critical_and_keyed_per_disk() { + val items = attentionItems(snapshot(array = array(disks = listOf(disk("disk1", numErrors = 12), disk("disk2", numErrors = 1), disk("disk3"))))) + assertEquals(listOf("disk-errors:disk1", "disk-errors:disk2"), items.map { it.key }) + assertTrue(items.all { it.severity == AttentionSeverity.Critical }) + assertEquals("12 errors reported.", items[0].detail) + assertEquals("1 error reported.", items[1].detail) + } + + @Test fun error_count_changes_do_not_change_the_key() { + val a = attentionItems(snapshot(array = array(disks = listOf(disk("disk1", numErrors = 2))))).single() + val b = attentionItems(snapshot(array = array(disks = listOf(disk("disk1", numErrors = 50))))).single() + assertEquals(a.key, b.key) + } + + @Test fun disk_without_a_name_is_identified_by_its_device() { + val item = attentionItems(snapshot(array = array(disks = listOf(disk(name = "", device = "sdb", numErrors = 1))))).single() + assertEquals("disk-errors:sdb", item.key) + } + + // ── Disk temperature ────────────────────────────────────────────── + + @Test fun disk_at_the_global_warning_threshold_is_a_warning() { + val snap = snapshot(array = array(disks = listOf(disk("disk1", tempC = 45))), thresholds = DisplayThresholds(45, 55, null, null)) + val item = attentionItems(snap).single() + assertEquals("disk-temp-warning:disk1", item.key) + assertEquals(AttentionSeverity.Warning, item.severity) + assertEquals("45 °C, warning from 45 °C.", item.detail) + } + + @Test fun disk_at_the_global_critical_threshold_is_critical() { + val snap = snapshot(array = array(disks = listOf(disk("disk1", tempC = 55))), thresholds = DisplayThresholds(45, 55, null, null)) + val item = attentionItems(snap).single() + assertEquals("disk-temp-critical:disk1", item.key) + assertEquals(AttentionSeverity.Critical, item.severity) + } + + @Test fun warm_to_hot_is_a_new_condition_with_a_new_key() { + // WHY: a disk that goes from warm to critical is worse in kind and should be able to alert again. + val thresholds = DisplayThresholds(45, 55, null, null) + val warm = attentionItems(snapshot(array = array(disks = listOf(disk("disk1", tempC = 47))), thresholds = thresholds)).single() + val hot = attentionItems(snapshot(array = array(disks = listOf(disk("disk1", tempC = 58))), thresholds = thresholds)).single() + assertTrue(warm.key != hot.key) + } + + @Test fun temperature_inside_the_same_level_keeps_its_key() { + val thresholds = DisplayThresholds(45, 55, null, null) + val a = attentionItems(snapshot(array = array(disks = listOf(disk("disk1", tempC = 46))), thresholds = thresholds)).single() + val b = attentionItems(snapshot(array = array(disks = listOf(disk("disk1", tempC = 49))), thresholds = thresholds)).single() + assertEquals(a.key, b.key) + } + + @Test fun per_disk_threshold_beats_the_global_one() { + // WHY: a disk the user gave its own limit must follow it, in both directions. + val thresholds = DisplayThresholds(45, 55, null, null) + val lenient = disk("disk1", tempC = 50, warningC = 60, criticalC = 70) + val strict = disk("disk2", tempC = 40, warningC = 38, criticalC = 70) + val keys = attentionItems(snapshot(array = array(disks = listOf(lenient, strict)), thresholds = thresholds)).map { it.key } + assertEquals(listOf("disk-temp-warning:disk2"), keys) + } + + @Test fun thresholds_are_coalesced_independently() { + // WHY: only a critical limit per disk, warning from the global setting. + val snap = snapshot(array = array(disks = listOf(disk("disk1", tempC = 48, criticalC = 60))), thresholds = DisplayThresholds(45, 55, null, null)) + assertEquals("disk-temp-warning:disk1", attentionItems(snap).single().key) + } + + @Test fun no_reported_threshold_means_no_temperature_alert() { + // WHY: the Array tab falls back to 42/50 degrees, but this card never guesses a limit + // the server did not configure; a hot disk without any threshold is not raised. + val hot = array(disks = listOf(disk("disk1", tempC = 80))) + assertEquals(emptyList(), attentionItems(snapshot(array = hot, thresholds = null))) + assertEquals(emptyList(), attentionItems(snapshot(array = hot, thresholds = DisplayThresholds(null, null, null, null)))) + assertEquals(emptyList(), attentionItems(snapshot(array = hot, thresholds = DisplayThresholds(0, 0, null, null)))) + } + + @Test fun only_a_critical_limit_known_still_raises_critical_but_not_warning() { + val thresholds = DisplayThresholds(null, 55, null, null) + assertEquals(emptyList(), attentionItems(snapshot(array = array(disks = listOf(disk("d", tempC = 50))), thresholds = thresholds))) + assertEquals("disk-temp-critical:d", attentionItems(snapshot(array = array(disks = listOf(disk("d", tempC = 56))), thresholds = thresholds)).single().key) + } + + @Test fun spun_down_disk_is_never_a_temperature_problem() { + // WHY: a sleeping disk reports no temperature; the mapper stores 0, and isSpinning is the truth. + val snap = snapshot( + array = array(disks = listOf(disk("disk1", tempC = 80, isSpinning = false))), + thresholds = DisplayThresholds(45, 55, null, null), + ) + assertEquals(emptyList(), attentionItems(snap)) + } + + @Test fun spun_down_disk_with_errors_still_counts_for_errors() { + val snap = snapshot(array = array(disks = listOf(disk("disk1", isSpinning = false, numErrors = 4)))) + assertEquals(listOf("disk-errors:disk1"), attentionItems(snap).map { it.key }) + } + + // ── Notifications ───────────────────────────────────────────────── + + @Test fun unread_alerts_and_warnings_are_counted_and_open_the_notifications() { + val items = attentionItems(snapshot(notifications = notifications(warning = 1, alert = 2))) + assertEquals(listOf("notifications-alert", "notifications-warning"), items.map { it.key }) + assertEquals(listOf("2 unread alerts", "1 unread warning"), items.map { it.title }) + assertTrue(items.all { it.target == AttentionTarget.Notifications }) + } + + @Test fun info_notifications_are_not_a_problem() { + assertEquals(emptyList(), attentionItems(snapshot(notifications = notifications(info = 9)))) + } + + @Test fun notification_count_changes_do_not_change_the_key() { + val a = attentionItems(snapshot(notifications = notifications(alert = 1))).single() + val b = attentionItems(snapshot(notifications = notifications(alert = 7))).single() + assertEquals(a.key, b.key) + } + + // ── Ordering and identity ───────────────────────────────────────── + + @Test fun items_are_ordered_by_severity_then_by_rule_order() { + val snap = snapshot( + array = array( + state = ArrayState.Stopped, // warning + parity = ParityCheck(0.2f, 90.0, false), // info + disks = listOf(disk("disk1", numErrors = 2)), // critical + ), + notifications = notifications(warning = 1, alert = 1), // warning, critical + ) + val items = attentionItems(snap) + assertEquals( + listOf("disk-errors:disk1", "notifications-alert", "array-stopped", "notifications-warning", "parity-running"), + items.map { it.key }, + ) + assertEquals(items.sortedByDescending { it.severity }.map { it.key }, items.map { it.key }) + } + + @Test fun keys_are_unique_and_stable_across_evaluations() { + val snap = snapshot( + array = array(state = ArrayState.Error, disks = listOf(disk("disk1", numErrors = 1), disk("disk2", numErrors = 1))), + notifications = notifications(alert = 1, warning = 1), + ) + val first = attentionItems(snap).map { it.key } + assertEquals("a repeat evaluation of the same data must give the same keys", first, attentionItems(snap).map { it.key }) + assertEquals("keys identify items, so they must be unique", first.size, first.toSet().size) + } + + // ── Builders ────────────────────────────────────────────────────── + + private fun snapshot( + array: ArrayInfo? = null, + notifications: Notifications? = null, + thresholds: DisplayThresholds? = null, + ) = AttentionSnapshot(array, notifications, thresholds) + + private fun array( + state: ArrayState = ArrayState.Started, + parity: ParityCheck? = null, + lastRun: ParityLastRun? = null, + disks: List = emptyList(), + ) = ArrayInfo(state, totalTb = 8.0, usedTb = 2.0, parity = parity, disks = disks, parityLastRun = lastRun) + + private fun disk( + name: String, + tempC: Int = 35, + isSpinning: Boolean = true, + numErrors: Long = 0L, + warningC: Int? = null, + criticalC: Int? = null, + device: String = "sd${name.lastOrNull() ?: 'x'}", + ) = Disk( + name = name, device = device, type = DiskType.Data, sizeTb = 4.0, usedTb = 1.0, + tempC = tempC, status = DiskStatus.Ok, model = "", isSpinning = isSpinning, rotational = true, + numErrors = numErrors, warningC = warningC, criticalC = criticalC, + ) + + private fun notifications(warning: Int = 0, alert: Int = 0, info: Int = 0) = Notifications( + unreadWarning = warning, unreadAlert = alert, unreadInfo = info, + unreadTotal = warning + alert + info, items = emptyList(), + ) +}