Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ android {
applicationId = "io.github.nofuturekid.nova"
minSdk = 26
targetSdk = 36
versionCode = 111
versionName = "0.1.40-beta5"
versionCode = 112
versionName = "0.1.40-beta6"

vectorDrawables { useSupportLibrary = true }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, ContainerLiveStats> =
dockerContainerStats.let { s ->
s.id to ContainerLiveStats(
cpuPercent = s.cpuPercent,
memPercent = s.memPercent,
memUsage = s.memUsage,
netIO = s.netIO,
blockIO = s.blockIO,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,14 @@ fun selectThroughput(samples: List<IfaceSample>, 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,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<io.github.nofuturekid.nova.data.model.LogLine>,
onRefresh: suspend () -> Unit,
Expand Down Expand Up @@ -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)
Expand All @@ -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) {
Expand All @@ -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,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() },
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
72 changes: 72 additions & 0 deletions docs/adr/0044-container-detail-live-stats-overlay.md
Original file line number Diff line number Diff line change
@@ -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<id, ContainerLiveStats>` 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.