From 45b54a1146327505f2a5730769388475a8b568e7 Mon Sep 17 00:00:00 2001 From: Thomas Kroll Date: Sat, 30 May 2026 14:42:07 +0200 Subject: [PATCH] feat(ui): live CPU/Mem/Net/Disk in the container detail sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The detail sheet read CPU/Memory off the polled Container (c.cpu/c.memMb), which GetDockerContainers hardcodes to 0 — so running containers showed 0%. Feed it the dockerContainerStats overlay instead (reactive: the open sheet recomposes as frames land), and carry netIO/blockIO — already selected by the subscription op but dropped by the mapper — through ContainerLiveStats so the sheet can add Network and Disk I/O blocks. Stopped/no-frame -> "—". ADR-0044. Docker tab rows unchanged. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 5 ++ app/build.gradle.kts | 4 +- .../nova/data/api/GraphQlMapper.kt | 6 +- .../nova/data/model/NetworkThroughput.kt | 9 ++- .../screens/container/ContainerDetailSheet.kt | 50 ++++++++++--- .../nova/ui/screens/main/MainScreen.kt | 3 + .../repository/DockerStatsAccumulationTest.kt | 39 +++++++++- .../ui/screens/main/ContainerStatsJoinTest.kt | 3 +- ...044-container-detail-live-stats-overlay.md | 72 +++++++++++++++++++ 9 files changed, 171 insertions(+), 20 deletions(-) create mode 100644 docs/adr/0044-container-detail-live-stats-overlay.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 11a9434..1e3124c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,11 @@ release version + date and opens a fresh `[Unreleased]`. ## [Unreleased] +## [0.1.40-beta6] - 2026-05-30 + +### Fixed +- The container detail view now shows live CPU, memory, network and disk I/O (previously it showed 0). + ## [0.1.40-beta5] - 2026-05-30 ### Changed diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 39c72c9..2e2c5f8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -16,8 +16,8 @@ android { applicationId = "io.github.nofuturekid.nova" minSdk = 26 targetSdk = 36 - versionCode = 111 - versionName = "0.1.40-beta5" + versionCode = 112 + versionName = "0.1.40-beta6" vectorDrawables { useSupportLibrary = true } } 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 1606344..990099f 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 @@ -416,14 +416,16 @@ private fun GTemperatureUnit.toDomain(): TemperatureUnit = when (this) { // ── Docker container stats subscription ────────────────────────────── /** One frame = one container. Maps to a (id -> stats) pair for the overlay - * accumulation. netIO/blockIO are intentionally dropped (out of scope — - * the overlay shows cpu% + memory only). */ + * accumulation. netIO/blockIO are preformatted "RX / TX" and "read / write" + * strings the detail sheet surfaces verbatim. */ fun DockerContainerStatsSubscription.Data.toContainerLiveStat(): Pair = dockerContainerStats.let { s -> s.id to ContainerLiveStats( cpuPercent = s.cpuPercent, memPercent = s.memPercent, memUsage = s.memUsage, + netIO = s.netIO, + blockIO = s.blockIO, ) } diff --git a/app/src/main/kotlin/io/github/nofuturekid/nova/data/model/NetworkThroughput.kt b/app/src/main/kotlin/io/github/nofuturekid/nova/data/model/NetworkThroughput.kt index 16af8a6..95a54f9 100644 --- a/app/src/main/kotlin/io/github/nofuturekid/nova/data/model/NetworkThroughput.kt +++ b/app/src/main/kotlin/io/github/nofuturekid/nova/data/model/NetworkThroughput.kt @@ -30,11 +30,14 @@ fun selectThroughput(samples: List, primaryIface: String?): Network /** Live per-container resource stats from the `dockerContainerStats` * subscription. [cpuPercent]/[memPercent] are the server's percentages; - * [memUsage] is the server's already-formatted memory string (e.g. - * "1.2GiB / 16GiB"). Accumulated by id into the overlay map — a container - * with no frame yet simply has no entry (rows omit live stats, no zero noise). */ + * [memUsage]/[netIO]/[blockIO] are the server's already-formatted strings + * ("used / limit", "RX / TX", "read / write"). Accumulated by id into the + * overlay map — a container with no frame yet simply has no entry (rows omit + * live stats, no zero noise). */ data class ContainerLiveStats( val cpuPercent: Double, val memPercent: Double, val memUsage: String, + val netIO: String, + val blockIO: String, ) diff --git a/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/container/ContainerDetailSheet.kt b/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/container/ContainerDetailSheet.kt index a952751..21dac60 100644 --- a/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/container/ContainerDetailSheet.kt +++ b/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/container/ContainerDetailSheet.kt @@ -44,6 +44,7 @@ import androidx.compose.ui.platform.LocalContext import android.content.Intent import androidx.core.net.toUri import io.github.nofuturekid.nova.data.model.Container +import io.github.nofuturekid.nova.data.model.ContainerLiveStats import io.github.nofuturekid.nova.data.model.ContainerStatus import io.github.nofuturekid.nova.data.model.hasUpdate import io.github.nofuturekid.nova.ui.components.BtnVariant @@ -73,6 +74,7 @@ private const val LOG_TAIL_INTERVAL_MS = 3_000L fun ContainerDetailSheet( container: Container, serverBaseUrl: String, + liveStats: ContainerLiveStats?, isUpdating: Boolean, onFetchLogs: suspend (id: String) -> List, onRefresh: suspend () -> Unit, @@ -331,7 +333,7 @@ fun ContainerDetailSheet( .verticalScroll(rememberScrollState()), ) { when (tab) { - DetailTab.Info -> InfoTabContent(container) + DetailTab.Info -> InfoTabContent(container, liveStats) DetailTab.Logs -> LogsTabContent(container, logs, logsLoading) DetailTab.Ports -> PortsTabContent(container) DetailTab.Volumes -> VolumesTabContent(container) @@ -344,17 +346,39 @@ fun ContainerDetailSheet( } @Composable -private fun InfoTabContent(c: Container) { +private fun InfoTabContent(c: Container, liveStats: ContainerLiveStats?) { val t = UnraidTheme.colors + // Live per-container stats arrive only via the dockerContainerStats + // subscription overlay (the polled Container carries 0). They're + // meaningless for a stopped container, so gate on Running + a frame. + val live = liveStats?.takeIf { c.status == ContainerStatus.Running } Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { UnraidCard(padding = UnraidTheme.tokens.pad) { - Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) { - StatBlock(label = "CPU", value = "%.1f%%".format(c.cpu), modifier = Modifier.weight(1f)) - StatBlock( - label = "Memory", - value = if (c.memMb > 0) "${c.memMb} MB" else "—", - modifier = Modifier.weight(1f), - ) + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) { + StatBlock( + label = "CPU", + value = live?.let { "%.1f%%".format(it.cpuPercent) } ?: "—", + modifier = Modifier.weight(1f), + ) + StatBlock( + label = "Memory", + value = live?.memUsage ?: "—", + modifier = Modifier.weight(1f), + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) { + StatBlock( + label = "Network", + value = live?.netIO ?: "—", + modifier = Modifier.weight(1f), + ) + StatBlock( + label = "Disk I/O", + value = live?.blockIO ?: "—", + modifier = Modifier.weight(1f), + ) + } } } UnraidCard(padding = UnraidTheme.tokens.pad) { @@ -380,7 +404,13 @@ private fun StatBlock(label: String, value: String, modifier: Modifier = Modifie Text( text = value, color = t.text, - style = MaterialTheme.typography.headlineMedium, + // titleLarge (not headlineMedium): the live memory/net/disk + // values are preformatted "used / limit" pairs that overflow a + // half-width column at headline size; titleLarge keeps them on + // one ellipsized line while staying the row's emphasis weight. + style = MaterialTheme.typography.titleLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } } 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 4321ba0..f5a2b49 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 @@ -341,6 +341,9 @@ fun MainScreen( ContainerDetailSheet( container = shown, serverBaseUrl = baseUrl, + // Reactive overlay: dockerLiveStats is collected above, so the open + // sheet recomposes live as new dockerContainerStats frames arrive. + liveStats = dockerLiveStats[shown.id], isUpdating = shown.id in updatingIds, onFetchLogs = { id -> vm.containerLogs(id) }, onRefresh = { vm.refresh() }, diff --git a/app/src/test/kotlin/io/github/nofuturekid/nova/data/repository/DockerStatsAccumulationTest.kt b/app/src/test/kotlin/io/github/nofuturekid/nova/data/repository/DockerStatsAccumulationTest.kt index 0c871e8..3875906 100644 --- a/app/src/test/kotlin/io/github/nofuturekid/nova/data/repository/DockerStatsAccumulationTest.kt +++ b/app/src/test/kotlin/io/github/nofuturekid/nova/data/repository/DockerStatsAccumulationTest.kt @@ -1,6 +1,8 @@ package io.github.nofuturekid.nova.data.repository +import io.github.nofuturekid.nova.data.api.toContainerLiveStat import io.github.nofuturekid.nova.data.model.ContainerLiveStats +import io.github.nofuturekid.nova.graphql.DockerContainerStatsSubscription import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.toList import kotlinx.coroutines.test.runTest @@ -9,8 +11,41 @@ import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) class DockerStatsAccumulationTest { - private fun stat(cpu: Double, mem: Double, usage: String) = - ContainerLiveStats(cpuPercent = cpu, memPercent = mem, memUsage = usage) + private fun stat(cpu: Double, mem: Double, usage: String) = ContainerLiveStats( + cpuPercent = cpu, + memPercent = mem, + memUsage = usage, + netIO = "", + blockIO = "", + ) + + /** WHY: the subscription op selects netIO + blockIO (since beta2), but the + * mapper used to drop them — so the container detail sheet could never show + * network / disk I/O. This pins that BOTH carry through verbatim (they're + * preformatted "RX / TX" and "read / write" strings); a regression that + * re-drops either fails here. */ + @Test fun mapper_carries_netio_and_blockio_through() { + val data = DockerContainerStatsSubscription.Data( + dockerContainerStats = DockerContainerStatsSubscription.DockerContainerStats( + id = "c1", + cpuPercent = 2.6, + memUsage = "236.1MiB / 2.00GiB", + memPercent = 11.5, + netIO = "2.17GiB / 12.7MiB", + blockIO = "206.5MiB / 0B", + ), + ) + assertEquals( + "c1" to ContainerLiveStats( + cpuPercent = 2.6, + memPercent = 11.5, + memUsage = "236.1MiB / 2.00GiB", + netIO = "2.17GiB / 12.7MiB", + blockIO = "206.5MiB / 0B", + ), + data.toContainerLiveStat(), + ) + } /** WHY: each subscription frame carries ONE container; the overlay must * ACCUMULATE — after b arrives, a is still present. A regression to diff --git a/app/src/test/kotlin/io/github/nofuturekid/nova/ui/screens/main/ContainerStatsJoinTest.kt b/app/src/test/kotlin/io/github/nofuturekid/nova/ui/screens/main/ContainerStatsJoinTest.kt index 8abada3..5b5e8c5 100644 --- a/app/src/test/kotlin/io/github/nofuturekid/nova/ui/screens/main/ContainerStatsJoinTest.kt +++ b/app/src/test/kotlin/io/github/nofuturekid/nova/ui/screens/main/ContainerStatsJoinTest.kt @@ -15,7 +15,8 @@ class ContainerStatsJoinTest { ports = emptyList(), volumes = emptyList(), updateStatus = ContainerUpdateStatus.Unknown, webUiUrl = null, networkIp = null, ) - private fun stat(cpu: Double) = ContainerLiveStats(cpu, cpu, "${cpu}GiB") + private fun stat(cpu: Double) = + ContainerLiveStats(cpu, cpu, "${cpu}GiB", netIO = "", blockIO = "") /** WHY (hard constraint): a container the stats stream hasn't sent a frame * for yet MUST still appear — with null stats — never be dropped. A diff --git a/docs/adr/0044-container-detail-live-stats-overlay.md b/docs/adr/0044-container-detail-live-stats-overlay.md new file mode 100644 index 0000000..0001b13 --- /dev/null +++ b/docs/adr/0044-container-detail-live-stats-overlay.md @@ -0,0 +1,72 @@ +# ADR-0044: Surface live CPU/Mem/Net/Disk in the container detail sheet from the docker stats overlay + +- **Status**: Accepted +- **Date**: 2026-05-30 +- **Tags**: ui, data + +## Context + +ADR-0043 wired the `dockerContainerStats` subscription into a `dockerLiveStats: +Map` overlay and joined it onto the Docker TAB rows +(compact cpu% + memory). The container DETAIL sheet, however, rendered its CPU +and Memory `StatBlock`s straight off the polled `Container` (`c.cpu`, +`c.memMb`) — fields the `GetDockerContainers` query never populates, so they are +hardcoded to 0. On-device (beta5, 2026-05-30) the sheet therefore showed CPU +`0.0%` and Memory `—` for every container, including running ones. + +Separately, the subscription op already selects `netIO` and `blockIO` (since +beta2 — "RX / TX" and "read / write" preformatted strings the server emits), but +`ContainerLiveStats` only carried cpu/mem, so the mapper silently dropped both. +ADR-0043 listed "docker net/block IO rows" as out of scope; this ADR brings them +in for the detail sheet. + +## Decision + +Feed the detail sheet the same `dockerLiveStats` overlay the rows use, and carry +net/disk I/O through to it. + +- `ContainerLiveStats` gains `netIO: String` and `blockIO: String`; the + `toContainerLiveStat` mapper maps the already-selected fields through (no + GraphQL/schema/op change). +- `ContainerDetailSheet` takes a `liveStats: ContainerLiveStats?` param; + `MainScreen` passes `dockerLiveStats[shown.id]` (reactive — the open sheet + recomposes as new frames land). The Info tab's StatBlocks now read CPU/Memory + from `liveStats` (gated on `Running` + a present frame, else `—`) and add two + more — Network (`netIO`) and Disk I/O (`blockIO`) — displaying the server's + preformatted strings verbatim. `c.cpu`/`c.memMb` are no longer referenced in + the sheet (the model fields stay; out of scope here). + +Builds on ADR-0043. + +## Consequences + +**Positive:** the detail sheet shows real live CPU/Mem and, for the first time, +per-container network and disk I/O, all updating in real time off the existing +overlay — no new transport, query, or poll. + +**Negative / trade-offs:** stopped containers (no frame) show `—` across all +four blocks — correct (the stats are meaningless) but a visible blank for an +exited container; the StatBlock value typography drops from `headlineMedium` to +`titleLarge` so the wider "used / limit" / "RX / TX" strings fit one ellipsized +half-width line; the Compose UI stays device-gated (no unit test), consistent +with the other cards. + +**Trigger to revisit:** if a future op exposes per-container stats on the poll +path (then the sheet could degrade without a live frame); if the server's I/O +string format changes such that verbatim display no longer reads cleanly. + +## Alternatives considered + +Keep reading `c.cpu`/`c.memMb` and backfill them in the join (rejected — couples +the polled `Container` to the live transport, the exact coupling ADR-0043's +overlay avoids). Add net/disk as a separate model from cpu/mem (rejected — they +arrive in the same frame; one model is simpler). Format netIO/blockIO +client-side into split RX/TX rows (rejected — the server preformats them and the +Docker tab already shows server strings verbatim; deferred until a real need). + +## References + +- ADR-0043 — metrics/docker subscription rollout (predecessor; the overlay). +- ADR-0042 — network-throughput subscription pilot (the transport substrate). +- ADR-0027 — Tier-3 on-device acceptance gate (the Compose UI's verification). +- Live data: read-only `dockerContainerStats`, real Unraid server, 2026-05-30.