From 366a2e667866890cf4f6f0bfc1f98f39cfece160 Mon Sep 17 00:00:00 2001 From: Thomas Kroll Date: Fri, 13 Nov 2026 17:40:32 +0100 Subject: [PATCH 1/4] test: keep every GraphQL operation within the request limits of Unraid 7.4 Unraid 7.4.0 refuses requests above a cost of 1,000, depth 20, 100 aliases and 5,000 GraphQL tokens (unraid/api request-protection.ts, graphql-armor 3.2.0). A query that grows past them would only show up as a failed screen. GraphqlRequestLimitsTest re-implements the server's formulas (documented with their source in GraphqlDocumentMetrics), measures every operation in app/src/main/graphql as Apollo sends it and fails, naming the operation and the measure, when one passes 70 percent of a limit. Its other tests pin the formulas to the server's own boundaries. HANDOFF.md records the limits, the margin and the result measured on a real Unraid 7.4.0. No app code changes: the largest operation (GetArray) costs 59 of 1,000. Closes #320 Co-Authored-By: Claude Sonnet 5.5 --- HANDOFF.md | 21 + .../nova/data/api/GraphqlDocumentMetrics.kt | 402 ++++++++++++++++++ .../nova/data/api/GraphqlRequestLimitsTest.kt | 166 ++++++++ 3 files changed, 589 insertions(+) create mode 100644 app/src/test/kotlin/io/github/nofuturekid/nova/data/api/GraphqlDocumentMetrics.kt create mode 100644 app/src/test/kotlin/io/github/nofuturekid/nova/data/api/GraphqlRequestLimitsTest.kt 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/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(), + ) + } +} From 3258fbb45fac36809e2a251a3ea1fda8b9253e83 Mon Sep 17 00:00:00 2001 From: Thomas Kroll Date: Fri, 13 Nov 2026 17:37:34 +0100 Subject: [PATCH 2/4] feat: a needs-attention summary on the Overview A card at the top of the Overview lists what needs attention, most urgent first: an array that is not started or is degraded, a parity operation that is running, paused or failed, disks with errors, disks at or above their warning or critical temperature, and unread alert and warning notifications. Tapping a row opens the Array tab or the notifications. The card is hidden when nothing needs attention and while the server is offline. The rules are one pure function in the data layer (attentionItems) that returns items with a severity, a stable key per condition, a title, a detail and a target, so background alerts and a widget can reuse it. It reads only data the app already loads. The array mapper now also keeps how the last parity run ended (the query already asked for it). Closes #319 Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 11 + .../nova/data/api/GraphQlMapper.kt | 10 + .../nofuturekid/nova/data/model/Attention.kt | 138 ++++++++ .../nofuturekid/nova/data/model/Models.kt | 15 + .../nova/ui/screens/main/MainScreen.kt | 9 + .../nova/ui/screens/overview/AttentionCard.kt | 97 ++++++ .../nova/ui/screens/overview/OverviewTab.kt | 29 +- .../nova/data/api/ArrayMappingTest.kt | 39 ++- .../nova/data/model/AttentionItemsTest.kt | 294 ++++++++++++++++++ 9 files changed, 640 insertions(+), 2 deletions(-) create mode 100644 app/src/main/kotlin/io/github/nofuturekid/nova/data/model/Attention.kt create mode 100644 app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/overview/AttentionCard.kt create mode 100644 app/src/test/kotlin/io/github/nofuturekid/nova/data/model/AttentionItemsTest.kt 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/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/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(), + ) +} From f04f97ad28737405955603ed619d3f8cae2efa4b Mon Sep 17 00:00:00 2001 From: Thomas Kroll Date: Fri, 13 Nov 2026 17:32:09 +0100 Subject: [PATCH 3/4] feat: search containers and group them by Compose project The Docker tab already filtered by name and image, but the text was lost on every tab switch, there was no clear button and an empty result showed a blank list. The search text now lives in MainScreen (rememberSaveable), the filter is a pure function that ignores case and surrounding spaces, and a "No matching containers" state appears when nothing matches. New Docker view option "Compose" groups containers by the label com.docker.compose.project; containers without it go into "No Compose project", listed last. GetDockerContainers now also requests the existing DockerContainer.labels field (present since API 4.29, same docker:read permission as the rest of the query). Not built: grouping by the web interface's Docker folders (organizer API); collapsible groups (the existing grouped layout is not collapsible either). Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 7 ++ .../github/nofuturekid/nova/queries.graphql | 2 +- .../github/nofuturekid/nova/schema.graphqls | 3 + .../nova/data/api/GraphQlMapper.kt | 17 +++ .../nova/data/local/SettingsStore.kt | 7 +- .../nofuturekid/nova/data/model/Models.kt | 4 + .../nova/ui/screens/array/ArrayTab.kt | 2 +- .../ui/screens/docker/ContainerListing.kt | 44 +++++++ .../nova/ui/screens/docker/DockerTab.kt | 86 ++++++++++++-- .../nova/ui/screens/main/MainScreen.kt | 6 + .../ui/screens/settings/SettingsScreen.kt | 2 +- .../nofuturekid/nova/ui/screens/vms/VmsTab.kt | 2 +- .../data/api/ComposeProjectMappingTest.kt | 34 ++++++ .../ui/screens/docker/ContainerListingTest.kt | 112 ++++++++++++++++++ .../nova/ui/screenshots/StoreScreenshots.kt | 3 +- 15 files changed, 311 insertions(+), 20 deletions(-) create mode 100644 app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/docker/ContainerListing.kt create mode 100644 app/src/test/kotlin/io/github/nofuturekid/nova/data/api/ComposeProjectMappingTest.kt create mode 100644 app/src/test/kotlin/io/github/nofuturekid/nova/ui/screens/docker/ContainerListingTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index c352d451..44e4df32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,13 @@ release version + date and opens a fresh `[Unreleased]`. 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. +- **Search and Compose grouping on the Docker tab** — the search field now has a + clear button, says so when nothing matches, and keeps what you typed when you + switch tabs or rotate the phone. A new "Compose" option for the Docker view + (Settings, Layout) groups containers by their Docker Compose project, with + containers that don't belong to one listed under "No Compose project". Updates + and the start, restart and stop buttons work in every layout. + ## [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/app/src/main/graphql/io/github/nofuturekid/nova/queries.graphql b/app/src/main/graphql/io/github/nofuturekid/nova/queries.graphql index 6301c8d1..7c3071a7 100644 --- a/app/src/main/graphql/io/github/nofuturekid/nova/queries.graphql +++ b/app/src/main/graphql/io/github/nofuturekid/nova/queries.graphql @@ -82,7 +82,7 @@ query GetDockerContainers { id names image state status autoStart iconUrl isUpdateAvailable isRebuildReady webUiUrl ports { privatePort publicPort type } - mounts networkSettings + mounts networkSettings labels } } } diff --git a/app/src/main/graphql/io/github/nofuturekid/nova/schema.graphqls b/app/src/main/graphql/io/github/nofuturekid/nova/schema.graphqls index 10caf79e..1055e618 100644 --- a/app/src/main/graphql/io/github/nofuturekid/nova/schema.graphqls +++ b/app/src/main/graphql/io/github/nofuturekid/nova/schema.graphqls @@ -389,6 +389,9 @@ type DockerContainer { iconUrl: String mounts: JSON networkSettings: JSON + # Docker labels as a JSON object (null when the server reports none). + # NOVA reads com.docker.compose.project from it to group containers. + labels: JSON # Both null when the server hasn't determined update status yet. isUpdateAvailable: Boolean isRebuildReady: Boolean 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 535d4f8a..e9f5e032 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 @@ -217,6 +217,7 @@ fun GetDockerContainersQuery.Data.toContainers(): List = updateStatus = deriveUpdateStatus(c.isUpdateAvailable, c.isRebuildReady), webUiUrl = c.webUiUrl?.takeIf { it.isNotBlank() }, networkIp = parseContainerNetworkIp(c.networkSettings), + composeProject = parseComposeProject(c.labels), ) } @@ -629,6 +630,22 @@ fun parseContainerNetworkIp(raw: Any?): String? { return null } +/** The Docker label Compose sets on every container it creates. */ +const val COMPOSE_PROJECT_LABEL = "com.docker.compose.project" + +/** + * Read the Compose project out of the container's labels. + * + * Apollo's [JsonAnyAdapter] delivers `labels: JSON` as `Any?` — a + * `Map` of label name to value, or null. Returns the + * trimmed project name, or null when the label is missing, blank or not + * a string, so a container without Compose never gets a made-up project. + */ +fun parseComposeProject(raw: Any?): String? { + val labels = raw as? Map<*, *> ?: return null + return (labels[COMPOSE_PROJECT_LABEL] as? String)?.trim()?.takeIf { it.isNotEmpty() } +} + /** * Parse Docker container mount entries. * diff --git a/app/src/main/kotlin/io/github/nofuturekid/nova/data/local/SettingsStore.kt b/app/src/main/kotlin/io/github/nofuturekid/nova/data/local/SettingsStore.kt index d72c6718..3a9d8450 100644 --- a/app/src/main/kotlin/io/github/nofuturekid/nova/data/local/SettingsStore.kt +++ b/app/src/main/kotlin/io/github/nofuturekid/nova/data/local/SettingsStore.kt @@ -42,8 +42,10 @@ private object Keys { /** Shared layout mode for the Docker / VMs / Array list views. Each * view persists its own choice; "Grouped" is interpreted per view - * (Docker → by status, VMs → by state, Array → by disk type). */ -enum class LayoutMode { List, Grid, Grouped } + * (Docker → by status, VMs → by state, Array → by disk type). + * "Compose" exists for Docker only (grouped by Compose project); the + * VMs and Array settings never offer it and would show it as Grouped. */ +enum class LayoutMode { List, Grid, Grouped, Compose } /** One consistent read of the stored server selection; see [SettingsStore.serverSnapshot]. */ data class ServerSnapshot( @@ -131,6 +133,7 @@ class SettingsStore @Inject constructor( when (it[key]) { "Grid" -> LayoutMode.Grid "Grouped" -> LayoutMode.Grouped + "Compose" -> LayoutMode.Compose else -> LayoutMode.List } } 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 87d4f784..f3953dc6 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 @@ -184,6 +184,10 @@ data class Container( * correct. When non-null, the UI substitutes this for the host * portion of [webUiUrl] before opening the browser. */ val networkIp: String?, + /** Docker Compose project the container belongs to (the label + * `com.docker.compose.project`). Null when the container has no such + * label, or the server did not report labels. */ + val composeProject: String? = null, ) enum class VmState { Running, Paused, Stopped } diff --git a/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/array/ArrayTab.kt b/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/array/ArrayTab.kt index 1b4f3cbe..b3597f4a 100644 --- a/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/array/ArrayTab.kt +++ b/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/array/ArrayTab.kt @@ -309,7 +309,7 @@ private fun ArrayContent( } } } - LayoutMode.Grouped -> { + LayoutMode.Grouped, LayoutMode.Compose -> { val byType = listOf( "Parity" to arr.disks.filter { it.type == DiskType.Parity }, "Data" to arr.disks.filter { it.type == DiskType.Data }, diff --git a/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/docker/ContainerListing.kt b/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/docker/ContainerListing.kt new file mode 100644 index 00000000..372882ae --- /dev/null +++ b/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/docker/ContainerListing.kt @@ -0,0 +1,44 @@ +package io.github.nofuturekid.nova.ui.screens.docker + +import io.github.nofuturekid.nova.data.model.Container + +/** Title of the group that holds containers without a Compose project. */ +const val NO_COMPOSE_PROJECT = "No Compose project" + +/** One group of the Compose layout. [project] is null for the containers without one. */ +data class ComposeGroup(val project: String?, val containers: List) { + val title: String get() = project ?: NO_COMPOSE_PROJECT +} + +/** + * The containers whose name or image contains [query]. + * + * Case does not matter and spaces around the query are ignored, so + * " Plex " finds `plex`. A blank query is "no filter": every container + * comes back, in the order it came in. + */ +fun filterContainers(containers: List, query: String): List { + val q = query.trim() + if (q.isEmpty()) return containers + return containers.filter { + it.name.contains(q, ignoreCase = true) || it.image.contains(q, ignoreCase = true) + } +} + +/** + * Group [containers] by Compose project. + * + * Projects are sorted by name (case-insensitive); the group of containers + * without a project always comes last. Inside a group the containers keep + * the order they came in, so the layout does not reshuffle on every poll. + * Every container lands in exactly one group, none is dropped. + */ +fun groupByComposeProject(containers: List): List = + containers + .groupBy { c -> c.composeProject?.trim()?.takeIf { it.isNotEmpty() } } + .map { (project, members) -> ComposeGroup(project, members) } + .sortedWith( + compareBy { it.project == null } + .thenBy(String.CASE_INSENSITIVE_ORDER) { it.project.orEmpty() } + .thenBy { it.project.orEmpty() }, + ) diff --git a/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/docker/DockerTab.kt b/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/docker/DockerTab.kt index aa0992f1..c83d7652 100644 --- a/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/docker/DockerTab.kt +++ b/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/docker/DockerTab.kt @@ -25,17 +25,17 @@ import androidx.compose.foundation.text.BasicTextField import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -64,6 +64,8 @@ import io.github.nofuturekid.nova.ui.util.memUsed fun DockerTab( state: DomainState>, view: LayoutMode, + query: String, + onQueryChange: (String) -> Unit, onAddServer: () -> Unit, stats: Map = emptyMap(), onOpenContainer: (Container) -> Unit, @@ -80,6 +82,8 @@ fun DockerTab( containers = state.value, serverBaseUrl = state.serverBaseUrl, view = view, + query = query, + onQueryChange = onQueryChange, stats = stats, onOpenContainer = onOpenContainer, onStart = onStart, @@ -95,6 +99,8 @@ private fun DockerContent( containers: List, serverBaseUrl: String, view: LayoutMode, + query: String, + onQueryChange: (String) -> Unit, stats: Map = emptyMap(), onOpenContainer: (Container) -> Unit, onStart: (Container) -> Unit, @@ -102,13 +108,9 @@ private fun DockerContent( onStop: (Container) -> Unit, onUpdateAll: () -> Unit, ) { - var query by remember { mutableStateOf("") } - val filtered = containers.filter { c -> - query.isBlank() || - c.name.contains(query, ignoreCase = true) || - c.image.contains(query, ignoreCase = true) - } + val filtered = filterContainers(containers, query) val rows = MainViewModel.joinContainerStats(filtered, stats) + val noMatch = filtered.isEmpty() && containers.isNotEmpty() val updateCount = containers.count { it.updateStatus.hasUpdate() } val d = UnraidTheme.tokens when (view) { @@ -120,7 +122,8 @@ private fun DockerContent( verticalArrangement = Arrangement.spacedBy(d.gap), ) { if (updateCount > 0) item { UpdateAllBanner(updateCount, onUpdateAll) } - item { SearchBox(query, { query = it }) } + item { SearchBox(query, onQueryChange) } + if (noMatch) item { NoMatches(query, containers.size) } items(rows, key = { it.first.id }) { (c, live) -> ContainerRow(c, serverBaseUrl, live, onOpenContainer, onStart, onRestart, onStop) } @@ -137,7 +140,8 @@ private fun DockerContent( if (updateCount > 0) item(span = { GridItemSpan(maxLineSpan) }) { UpdateAllBanner(updateCount, onUpdateAll) } - item(span = { GridItemSpan(maxLineSpan) }) { SearchBox(query, { query = it }) } + item(span = { GridItemSpan(maxLineSpan) }) { SearchBox(query, onQueryChange) } + if (noMatch) item(span = { GridItemSpan(maxLineSpan) }) { NoMatches(query, containers.size) } gridItems(filtered, key = { it.id }) { c -> ContainerGridTile(c, serverBaseUrl, onOpenContainer) } @@ -154,7 +158,8 @@ private fun DockerContent( verticalArrangement = Arrangement.spacedBy(d.gap), ) { if (updateCount > 0) item { UpdateAllBanner(updateCount, onUpdateAll) } - item { SearchBox(query, { query = it }) } + item { SearchBox(query, onQueryChange) } + if (noMatch) item { NoMatches(query, containers.size) } if (running.isNotEmpty()) { item { SectionLabel("Running · ${running.size}") } items(running, key = { "r-${it.first.id}" }) { (c, live) -> @@ -175,6 +180,29 @@ private fun DockerContent( } } } + LayoutMode.Compose -> { + val liveById = rows.associate { (c, live) -> c.id to live } + val groups = groupByComposeProject(filtered) + LazyColumn( + modifier = Modifier.fillMaxWidth(), + contentPadding = androidx.compose.foundation.layout.PaddingValues( + start = d.screenPad, end = d.screenPad, top = 4.dp, bottom = 24.dp, + ), + verticalArrangement = Arrangement.spacedBy(d.gap), + ) { + if (updateCount > 0) item { UpdateAllBanner(updateCount, onUpdateAll) } + item { SearchBox(query, onQueryChange) } + if (noMatch) item { NoMatches(query, containers.size) } + groups.forEach { group -> + item(key = if (group.project == null) "g-none" else "g-p-${group.project}") { + SectionLabel("${group.title} · ${group.containers.size}") + } + items(group.containers, key = { "c-${it.id}" }) { c -> + ContainerRow(c, serverBaseUrl, liveById[c.id], onOpenContainer, onStart, onRestart, onStop) + } + } + } + } } } @@ -239,6 +267,38 @@ private fun SearchBox(value: String, onChange: (String) -> Unit) { } }, ) + if (value.isNotEmpty()) { + Box( + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .semantics { contentDescription = "Clear search" } + .clickable(role = Role.Button) { onChange("") }, + contentAlignment = Alignment.Center, + ) { + UC.X(16.dp, t.muted) + } + } + } +} + +@Composable +private fun NoMatches(query: String, total: Int) { + val t = UnraidTheme.colors + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text("No matching containers", color = t.text, style = MaterialTheme.typography.titleSmall) + Spacer(Modifier.height(4.dp)) + Text( + text = "Nothing named or based on \u201C${query.trim()}\u201D among $total containers.", + color = t.muted, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + ) } } 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 8791b71c..1c80ebd1 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 @@ -137,6 +137,10 @@ fun MainScreen( openContainer = it vm.setDockerSheetOpen(it != null) } + // The Docker search text lives here, not in DockerTab: the tab leaves the + // composition on every tab switch, and rememberSaveable keeps the text + // through rotation and process restarts too. + var dockerQuery by androidx.compose.runtime.saveable.rememberSaveable { mutableStateOf("") } var openVm by remember { mutableStateOf(null) } var showNotifications by remember { mutableStateOf(false) } var confirm by remember { mutableStateOf(null) } @@ -286,6 +290,8 @@ fun MainScreen( MainTab.Docker -> DockerTab( state = dockerState, view = ui.dockerView, + query = dockerQuery, + onQueryChange = { dockerQuery = it }, onAddServer = onAddServer, stats = dockerLiveStats, onOpenContainer = { setOpenContainer(it) }, diff --git a/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/settings/SettingsScreen.kt index 27445d69..1b8e8419 100644 --- a/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/settings/SettingsScreen.kt @@ -227,7 +227,7 @@ fun SettingsScreen( SettingRow(label = "Docker view") { Segmented( value = ui.dockerView, - options = listOf(LayoutMode.List, LayoutMode.Grid, LayoutMode.Grouped), + options = listOf(LayoutMode.List, LayoutMode.Grid, LayoutMode.Grouped, LayoutMode.Compose), label = { it.name }, onChange = vm::setDockerView, ) diff --git a/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/vms/VmsTab.kt b/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/vms/VmsTab.kt index 7fc067ae..9e0df2c3 100644 --- a/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/vms/VmsTab.kt +++ b/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/vms/VmsTab.kt @@ -89,7 +89,7 @@ fun VmsTab( VmTile(vm, onOpenVm) } } - LayoutMode.Grouped -> { + LayoutMode.Grouped, LayoutMode.Compose -> { val running = vms.filter { it.state == VmState.Running } val paused = vms.filter { it.state == VmState.Paused } val stopped = vms.filter { it.state == VmState.Stopped } diff --git a/app/src/test/kotlin/io/github/nofuturekid/nova/data/api/ComposeProjectMappingTest.kt b/app/src/test/kotlin/io/github/nofuturekid/nova/data/api/ComposeProjectMappingTest.kt new file mode 100644 index 00000000..56347729 --- /dev/null +++ b/app/src/test/kotlin/io/github/nofuturekid/nova/data/api/ComposeProjectMappingTest.kt @@ -0,0 +1,34 @@ +package io.github.nofuturekid.nova.data.api + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** Why: the Compose layout is only as good as the label read behind it. Anything + * that is not a plain project name must become "no project", never a wrong one. */ +class ComposeProjectMappingTest { + @Test fun reads_the_compose_project_label() { + val labels = mapOf("com.docker.compose.project" to "media", "net.unraid.docker.managed" to "dockerman") + assertEquals("media", parseComposeProject(labels)) + } + + @Test fun trims_the_value() { + assertEquals("media", parseComposeProject(mapOf(COMPOSE_PROJECT_LABEL to " media "))) + } + + /** Why: template-based Unraid containers carry labels, just not this one. */ + @Test fun containers_without_the_label_have_no_project() { + assertNull(parseComposeProject(mapOf("net.unraid.docker.managed" to "dockerman"))) + assertNull(parseComposeProject(emptyMap())) + } + + /** Why: servers send null labels for containers without any; other shapes must not crash. */ + @Test fun missing_blank_or_odd_labels_have_no_project() { + assertNull(parseComposeProject(null)) + assertNull(parseComposeProject(mapOf(COMPOSE_PROJECT_LABEL to ""))) + assertNull(parseComposeProject(mapOf(COMPOSE_PROJECT_LABEL to " "))) + assertNull(parseComposeProject(mapOf(COMPOSE_PROJECT_LABEL to 42))) + assertNull(parseComposeProject(listOf("com.docker.compose.project"))) + assertNull(parseComposeProject("com.docker.compose.project=media")) + } +} diff --git a/app/src/test/kotlin/io/github/nofuturekid/nova/ui/screens/docker/ContainerListingTest.kt b/app/src/test/kotlin/io/github/nofuturekid/nova/ui/screens/docker/ContainerListingTest.kt new file mode 100644 index 00000000..de66c6e8 --- /dev/null +++ b/app/src/test/kotlin/io/github/nofuturekid/nova/ui/screens/docker/ContainerListingTest.kt @@ -0,0 +1,112 @@ +package io.github.nofuturekid.nova.ui.screens.docker + +import io.github.nofuturekid.nova.data.model.Container +import io.github.nofuturekid.nova.data.model.ContainerStatus +import io.github.nofuturekid.nova.data.model.ContainerUpdateStatus +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** Why: with dozens of containers the user finds one by typing a few letters, and a + * Compose stack is one thing to manage, not N unrelated rows. A container that + * vanishes from the list, or a stack that jumps around, is worse than no feature. */ +class ContainerListingTest { + private fun c(id: String, image: String = "img", project: String? = null) = Container( + id = id, name = id, image = image, status = ContainerStatus.Running, + autoStart = false, iconColorHex = null, iconUrl = null, cpu = 0.0, memMb = 0, + ports = emptyList(), volumes = emptyList(), + updateStatus = ContainerUpdateStatus.Unknown, webUiUrl = null, networkIp = null, + composeProject = project, + ) + + private val all = listOf( + c("plex", "lscr.io/linuxserver/plex:latest"), + c("Sonarr", "lscr.io/linuxserver/sonarr:latest"), + c("db", "postgres:16"), + ) + + /** Why: users type what they remember. Sometimes that is the name, sometimes + * only the image ("postgres" for a container called `db`). */ + @Test fun search_matches_name_and_image() { + assertEquals(listOf("plex"), filterContainers(all, "plex").map { it.id }) + assertEquals(listOf("db"), filterContainers(all, "postgres").map { it.id }) + assertEquals(listOf("plex", "Sonarr"), filterContainers(all, "linuxserver").map { it.id }) + } + + /** Why: a phone keyboard capitalises the first letter and adds a trailing space + * after autocomplete; neither may make a container disappear. */ + @Test fun search_ignores_case_and_surrounding_spaces() { + assertEquals(listOf("Sonarr"), filterContainers(all, " sonARR ").map { it.id }) + assertEquals(listOf("plex"), filterContainers(all, "PLEX").map { it.id }) + } + + /** Why: clearing the field must give back the full list, in the order it was + * before; a blank query is not a search for "nothing". */ + @Test fun empty_or_blank_query_returns_everything_in_order() { + assertEquals(all, filterContainers(all, "")) + assertEquals(all, filterContainers(all, " ")) + } + + /** Why: the empty-result state in the UI relies on an empty list, not on a crash + * or on the old list coming back. */ + @Test fun a_query_without_a_match_returns_nothing() { + assertEquals(emptyList(), filterContainers(all, "zzz")) + assertEquals(emptyList(), filterContainers(emptyList(), "plex")) + } + + /** Why: the stack list must read the same on every poll, and the odd ones out + * (no Compose) belong at the bottom, not between stacks. */ + @Test fun groups_are_sorted_by_project_with_unlabeled_last() { + val list = listOf( + c("loose-1"), + c("z1", project = "zeta"), + c("a1", project = "Alpha"), + c("b1", project = "beta"), + ) + val groups = groupByComposeProject(list) + assertEquals(listOf("Alpha", "beta", "zeta", NO_COMPOSE_PROJECT), groups.map { it.title }) + assertNull(groups.last().project) + } + + /** Why: the order of the server's list is what every other layout shows; the + * grouping must not reshuffle containers inside a stack. */ + @Test fun containers_keep_their_order_inside_a_group() { + val list = listOf( + c("web", project = "shop"), c("cache", project = "other"), + c("db", project = "shop"), c("worker", project = "shop"), + ) + val shop = groupByComposeProject(list).first { it.project == "shop" } + assertEquals(listOf("web", "db", "worker"), shop.containers.map { it.id }) + } + + /** Why (hard constraint): grouping must never lose a container. Most Unraid + * containers come from templates, not Compose, so they are the majority. */ + @Test fun unlabeled_and_blank_project_containers_are_not_lost() { + val list = listOf(c("a"), c("b", project = ""), c("c", project = " "), c("d", project = "stack")) + val groups = groupByComposeProject(list) + assertEquals(list.map { it.id }.sorted(), groups.flatMap { it.containers }.map { it.id }.sorted()) + assertEquals(listOf("a", "b", "c"), groups.last().containers.map { it.id }) + assertEquals(NO_COMPOSE_PROJECT, groups.last().title) + } + + /** Why: a project that is literally named like the fallback group must not + * swallow the containers that have no project. */ + @Test fun a_project_named_like_the_fallback_stays_a_separate_group() { + val groups = groupByComposeProject(listOf(c("a", project = NO_COMPOSE_PROJECT), c("b"))) + assertEquals(2, groups.size) + assertEquals(listOf("a"), groups.first().containers.map { it.id }) + assertNull(groups.last().project) + } + + @Test fun no_containers_means_no_groups() { + assertEquals(emptyList(), groupByComposeProject(emptyList())) + } + + /** Why: search and grouping are used together; a stack whose members are all + * filtered out must not show as an empty header. */ + @Test fun grouping_a_filtered_list_only_has_stacks_with_matches() { + val list = listOf(c("web", project = "shop"), c("db", project = "shop"), c("grafana", project = "metrics")) + val groups = groupByComposeProject(filterContainers(list, "graf")) + assertEquals(listOf("metrics"), groups.map { it.title }) + } +} diff --git a/app/src/test/kotlin/io/github/nofuturekid/nova/ui/screenshots/StoreScreenshots.kt b/app/src/test/kotlin/io/github/nofuturekid/nova/ui/screenshots/StoreScreenshots.kt index 389bc637..c8fd36ee 100644 --- a/app/src/test/kotlin/io/github/nofuturekid/nova/ui/screenshots/StoreScreenshots.kt +++ b/app/src/test/kotlin/io/github/nofuturekid/nova/ui/screenshots/StoreScreenshots.kt @@ -384,6 +384,7 @@ class StoreScreenshots { DockerTab( state = DomainState.Content(containers, "http://192.0.2.10"), view = LayoutMode.List, + query = "", onQueryChange = {}, onAddServer = {}, stats = stats, onOpenContainer = {}, onStart = {}, onRestart = {}, onStop = {}, onUpdateAll = {}, @@ -416,7 +417,7 @@ class StoreScreenshots { Chrome(MainTab.Docker) { DockerTab( state = DomainState.Content(containers, "http://192.0.2.10"), - view = LayoutMode.List, onAddServer = {}, stats = stats, + view = LayoutMode.List, query = "", onQueryChange = {}, onAddServer = {}, stats = stats, onOpenContainer = {}, onStart = {}, onRestart = {}, onStop = {}, onUpdateAll = {}, ) } From dcfb18364b59c778f146701144b99a0495de78c9 Mon Sep 17 00:00:00 2001 From: Thomas Kroll Date: Fri, 13 Nov 2026 17:40:54 +0100 Subject: [PATCH 4/4] address review findings Correct the labels comment in the schema and the KDoc of the compose project mapping test: upstream sends an empty object for containers without labels, the field is only nullable in the schema type. Co-Authored-By: Claude Sonnet 5.5 --- app/src/main/graphql/io/github/nofuturekid/nova/schema.graphqls | 2 +- .../nofuturekid/nova/data/api/ComposeProjectMappingTest.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/graphql/io/github/nofuturekid/nova/schema.graphqls b/app/src/main/graphql/io/github/nofuturekid/nova/schema.graphqls index 1055e618..df858758 100644 --- a/app/src/main/graphql/io/github/nofuturekid/nova/schema.graphqls +++ b/app/src/main/graphql/io/github/nofuturekid/nova/schema.graphqls @@ -389,7 +389,7 @@ type DockerContainer { iconUrl: String mounts: JSON networkSettings: JSON - # Docker labels as a JSON object (null when the server reports none). + # Docker labels as a JSON object of label name to value (an empty object when the container has none; nullable in the schema). # NOVA reads com.docker.compose.project from it to group containers. labels: JSON # Both null when the server hasn't determined update status yet. diff --git a/app/src/test/kotlin/io/github/nofuturekid/nova/data/api/ComposeProjectMappingTest.kt b/app/src/test/kotlin/io/github/nofuturekid/nova/data/api/ComposeProjectMappingTest.kt index 56347729..7ab9b082 100644 --- a/app/src/test/kotlin/io/github/nofuturekid/nova/data/api/ComposeProjectMappingTest.kt +++ b/app/src/test/kotlin/io/github/nofuturekid/nova/data/api/ComposeProjectMappingTest.kt @@ -22,7 +22,7 @@ class ComposeProjectMappingTest { assertNull(parseComposeProject(emptyMap())) } - /** Why: servers send null labels for containers without any; other shapes must not crash. */ + /** Why: the schema types labels as nullable JSON (current servers send {} for none); null and other shapes must not crash. */ @Test fun missing_blank_or_odd_labels_have_no_project() { assertNull(parseComposeProject(null)) assertNull(parseComposeProject(mapOf(COMPOSE_PROJECT_LABEL to "")))