diff --git a/.gitignore b/.gitignore index 3658951..a39c0d8 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ captures/ # Claude Code: share project settings, keep personal overrides local .claude/settings.local.json + +# Superpowers brainstorm artifacts +.superpowers/ 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 91a2a78..11c284f 100644 --- a/app/src/main/graphql/io/github/nofuturekid/nova/schema.graphqls +++ b/app/src/main/graphql/io/github/nofuturekid/nova/schema.graphqls @@ -25,6 +25,7 @@ scalar URL schema { query: Query mutation: Mutation + subscription: Subscription } type Query { @@ -441,3 +442,21 @@ type ParityCheckMutations { resume: JSON! cancel: JSON! } + +# ── Subscriptions ───────────────────────────────────────────────────── +# Live-verified 2026-05-29 against real Unraid server. See ADR-0042. + +type Subscription { + systemMetricsNetwork: NetworkUtilization! +} + +type NetworkUtilization { + id: PrefixedID! + interfaces: [NetworkInterfaceUtilization!]! +} + +type NetworkInterfaceUtilization { + iface: String + rxBytesPerSec: Float + txBytesPerSec: Float +} diff --git a/app/src/main/graphql/io/github/nofuturekid/nova/subscriptions.graphql b/app/src/main/graphql/io/github/nofuturekid/nova/subscriptions.graphql new file mode 100644 index 0000000..a079d60 --- /dev/null +++ b/app/src/main/graphql/io/github/nofuturekid/nova/subscriptions.graphql @@ -0,0 +1,9 @@ +subscription SystemMetricsNetwork { + systemMetricsNetwork { + interfaces { + iface + rxBytesPerSec + txBytesPerSec + } + } +} diff --git a/app/src/main/kotlin/io/github/nofuturekid/nova/data/api/ApolloClientFactory.kt b/app/src/main/kotlin/io/github/nofuturekid/nova/data/api/ApolloClientFactory.kt index 3525d44..bf2f47e 100644 --- a/app/src/main/kotlin/io/github/nofuturekid/nova/data/api/ApolloClientFactory.kt +++ b/app/src/main/kotlin/io/github/nofuturekid/nova/data/api/ApolloClientFactory.kt @@ -2,6 +2,9 @@ package io.github.nofuturekid.nova.data.api import com.apollographql.apollo.ApolloClient import com.apollographql.apollo.network.okHttpClient +import com.apollographql.apollo.network.ws.DefaultWebSocketEngine +import com.apollographql.apollo.network.ws.GraphQLWsProtocol +import com.apollographql.apollo.network.ws.WebSocketNetworkTransport import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import java.util.concurrent.ConcurrentHashMap @@ -10,7 +13,7 @@ import javax.inject.Inject import javax.inject.Singleton /** - * Builds per-server [ApolloClient] instances. Two variants: + * Builds per-server [ApolloClient] instances. Three variants: * * - [build] — short timeouts (8 s connect, 15 s read/write). Used for * polling queries and short-lived mutations (start/stop/pause/etc.). @@ -23,6 +26,12 @@ import javax.inject.Singleton * container recreate, which can legitimately take several minutes. * Per ADR-0016 these get their own client so the polling timeout * doesn't have to be relaxed globally. + * + * - [buildWs] — WebSocket-only client for GraphQL subscriptions. Reuses + * the existing self-signed TLS pin (ADR-0041) via [applyTrust]. Uses + * `graphql-ws` protocol with `x-api-key` in the connection payload. + * See ADR-0042. Lifecycle ownership is identical to the HTTP variants: + * the factory owns these; callers MUST NOT close them (ADR-0028). */ @Singleton class ApolloClientFactory @Inject constructor() { @@ -68,6 +77,10 @@ class ApolloClientFactory @Inject constructor() { */ private val clients = ConcurrentHashMap() + /** WS subscription clients, keyed on (endpoint, apiKey, trust). Same + * lifecycle ownership as [clients] — factory owns, callers don't close. */ + private val wsClients = ConcurrentHashMap() + /** Build a per-server client. URL must already be the base address (no trailing /graphql). */ fun build(baseUrl: String, apiKey: String, trust: TlsTrust = TlsTrust.Default): ApolloClient = cached("short", baseHttpClient, baseUrl, apiKey, trust) @@ -78,6 +91,48 @@ class ApolloClientFactory @Inject constructor() { fun buildLongRunning(baseUrl: String, apiKey: String, trust: TlsTrust = TlsTrust.Default): ApolloClient = cached("long", longRunningHttpClient, baseUrl, apiKey, trust) + /** + * Build a WebSocket-backed [ApolloClient] for GraphQL subscriptions. + * + * The HTTP endpoint ([baseUrl]/graphql) is set as `serverUrl` so that + * `apolloClient.query(...)` would still work; in practice only + * `apolloClient.subscription(...)` is called on WS clients. The WS URL + * is derived by replacing the scheme (`https→wss`, `http→ws`). + * + * The OkHttpClient used for the WS engine is built with [applyTrust] so + * the existing self-signed-cert pin (ADR-0041) is honoured for local + * `wss://` endpoints — same trust path as the HTTP polling client. + * + * Authentication is sent in the `graphql-ws` connection-init payload + * (`x-api-key`), matching the server's auth guard that reads connection + * params as headers. See ADR-0042. + */ + fun buildWs(baseUrl: String, apiKey: String, trust: TlsTrust = TlsTrust.Default): ApolloClient = + wsClients.computeIfAbsent(wsKey(baseUrl, apiKey, trust)) { + val httpEndpoint = baseUrl.trimEnd('/') + "/graphql" + val wsEndpoint = httpEndpoint.replaceFirst("https://", "wss://") + .replaceFirst("http://", "ws://") + val wsOkHttp = baseHttpClient.newBuilder().applyTrust(trust).build() + ApolloClient.Builder() + .serverUrl(httpEndpoint) + .subscriptionNetworkTransport( + WebSocketNetworkTransport.Builder() + .serverUrl(wsEndpoint) + .protocol(GraphQLWsProtocol.Factory( + connectionPayload = { mapOf("x-api-key" to apiKey) } + )) + .webSocketEngine(DefaultWebSocketEngine(wsOkHttp)) + .build() + ) + .build() + } + + private fun wsKey(baseUrl: String, apiKey: String, trust: TlsTrust): String { + val trustKey = trustKey(trust) + val endpoint = baseUrl.trimEnd('/') + "/graphql" + return "ws|$endpoint|$apiKey|$trustKey" + } + private fun cached( variant: String, base: OkHttpClient, @@ -90,31 +145,19 @@ class ApolloClientFactory @Inject constructor() { // same endpoint must not collide, and changing the pin must yield a fresh // client (so the new fingerprint snapshot takes effect). Old entries are // bounded and harmless (ADR-0028 lifecycle ownership). - val trustKey = when (trust) { - TlsTrust.Default -> "d" - is TlsTrust.PinnedSelfSigned -> "p:${trust.pinnedSha256 ?: "none"}:${trust.acceptFirstUse}" - } - return clients.computeIfAbsent("$variant|$endpoint|$apiKey|$trustKey") { + return clients.computeIfAbsent("$variant|$endpoint|$apiKey|${trustKey(trust)}") { buildOn(base, endpoint, apiKey, trust) } } + private fun trustKey(trust: TlsTrust): String = when (trust) { + TlsTrust.Default -> "d" + is TlsTrust.PinnedSelfSigned -> "p:${trust.pinnedSha256 ?: "none"}:${trust.acceptFirstUse}" + } + private fun buildOn(base: OkHttpClient, endpoint: String, apiKey: String, trust: TlsTrust): ApolloClient { val keyed = base.newBuilder() - .apply { - if (trust is TlsTrust.PinnedSelfSigned) { - val tm = LocalPinningTrustManager( - default = systemDefaultTrustManager(), - pinnedSha256 = trust.pinnedSha256, - acceptFirstUse = trust.acceptFirstUse, - ) - sslSocketFactory(sslContextFor(tm).socketFactory, tm) - // Pin is the anchor; this client only ever talks to one server's - // local endpoint (see ADR-0041). Self-signed LAN certs rarely - // match the host, so hostname matching is intentionally bypassed. - hostnameVerifier { _, _ -> true } - } - } + .applyTrust(trust) .addInterceptor { chain -> val req = chain.request().newBuilder() .addHeader("x-api-key", apiKey) @@ -128,4 +171,28 @@ class ApolloClientFactory @Inject constructor() { .okHttpClient(keyed) .build() } + + /** + * Apply TLS trust configuration to an [OkHttpClient.Builder]. + * + * For [TlsTrust.PinnedSelfSigned] endpoints (ADR-0041), installs the + * [LocalPinningTrustManager] and bypasses hostname verification (self-signed + * LAN certs rarely match the host). For [TlsTrust.Default] this is a no-op. + * Shared between the HTTP ([buildOn]) and WS ([buildWs]) paths so the exact + * same pin logic applies to both transports — no drift possible. + */ + private fun OkHttpClient.Builder.applyTrust(trust: TlsTrust): OkHttpClient.Builder = apply { + if (trust is TlsTrust.PinnedSelfSigned) { + val tm = LocalPinningTrustManager( + default = systemDefaultTrustManager(), + pinnedSha256 = trust.pinnedSha256, + acceptFirstUse = trust.acceptFirstUse, + ) + sslSocketFactory(sslContextFor(tm).socketFactory, tm) + // Pin is the anchor; this client only ever talks to one server's + // local endpoint (see ADR-0041). Self-signed LAN certs rarely + // match the host, so hostname matching is intentionally bypassed. + hostnameVerifier { _, _ -> 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 d3b45e2..76c45f1 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 @@ -9,6 +9,7 @@ import io.github.nofuturekid.nova.data.model.Disk import io.github.nofuturekid.nova.data.model.DiskStatus import io.github.nofuturekid.nova.data.model.DiskType import io.github.nofuturekid.nova.data.model.LiveMetrics +import io.github.nofuturekid.nova.data.model.IfaceSample import io.github.nofuturekid.nova.data.model.NetworkInterface import io.github.nofuturekid.nova.data.model.NotifImportance import io.github.nofuturekid.nova.data.model.NotifType @@ -31,6 +32,7 @@ import io.github.nofuturekid.nova.graphql.GetPluginOperationsQuery import io.github.nofuturekid.nova.graphql.GetPluginsQuery import io.github.nofuturekid.nova.graphql.GetServerInfoQuery import io.github.nofuturekid.nova.graphql.GetVmsQuery +import io.github.nofuturekid.nova.graphql.SystemMetricsNetworkSubscription import io.github.nofuturekid.nova.graphql.fragment.NotificationFields import io.github.nofuturekid.nova.graphql.type.ArrayDiskStatus import io.github.nofuturekid.nova.graphql.type.ArrayDiskType @@ -277,6 +279,17 @@ fun GetNetworkInterfacesQuery.Data.toNetworkInterfaces(): List } } +// ── Network throughput subscription ────────────────────────────────── + +fun SystemMetricsNetworkSubscription.Data.toIfaceSamples(): List = + systemMetricsNetwork.interfaces.map { + IfaceSample( + iface = it.iface, + rxBytesPerSec = it.rxBytesPerSec ?: 0.0, + txBytesPerSec = it.txBytesPerSec ?: 0.0, + ) + } + private fun GPluginInstallStatus.toDomain(): PluginInstallStatus = when (this) { GPluginInstallStatus.FAILED -> PluginInstallStatus.Failed GPluginInstallStatus.QUEUED -> PluginInstallStatus.Queued 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 new file mode 100644 index 0000000..50e8fb8 --- /dev/null +++ b/app/src/main/kotlin/io/github/nofuturekid/nova/data/model/NetworkThroughput.kt @@ -0,0 +1,29 @@ +package io.github.nofuturekid.nova.data.model + +/** Live per-second throughput for one interface. */ +data class NetworkThroughput( + val rxBytesPerSec: Double, + val txBytesPerSec: Double, +) { + companion object { val ZERO = NetworkThroughput(0.0, 0.0) } +} + +/** Plain per-interface sample — the pure seam the Apollo mapper feeds, so + * interface-selection logic unit-tests without generated types. */ +data class IfaceSample( + val iface: String?, + val rxBytesPerSec: Double, + val txBytesPerSec: Double, +) + +/** + * Pick the throughput to display from a frame's interfaces. + * Preference: the named management interface → first non-`lo` that is + * actively moving bytes → first non-`lo` → ZERO. `lo` is never chosen. + */ +fun selectThroughput(samples: List, primaryIface: String?): NetworkThroughput { + val chosen = samples.firstOrNull { it.iface == primaryIface && it.iface != "lo" } + ?: samples.firstOrNull { it.iface != "lo" && (it.rxBytesPerSec + it.txBytesPerSec) > 0.0 } + ?: samples.firstOrNull { it.iface != "lo" } + return chosen?.let { NetworkThroughput(it.rxBytesPerSec, it.txBytesPerSec) } ?: NetworkThroughput.ZERO +} diff --git a/app/src/main/kotlin/io/github/nofuturekid/nova/data/repository/UnraidRepository.kt b/app/src/main/kotlin/io/github/nofuturekid/nova/data/repository/UnraidRepository.kt index 479241e..3d90389 100644 --- a/app/src/main/kotlin/io/github/nofuturekid/nova/data/repository/UnraidRepository.kt +++ b/app/src/main/kotlin/io/github/nofuturekid/nova/data/repository/UnraidRepository.kt @@ -2,6 +2,7 @@ package io.github.nofuturekid.nova.data.repository import com.apollographql.apollo.ApolloClient import com.apollographql.apollo.api.Query +import com.apollographql.apollo.api.Subscription import com.apollographql.apollo.exception.ApolloException import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -29,10 +30,13 @@ import io.github.nofuturekid.nova.data.api.toPluginOperations import io.github.nofuturekid.nova.data.api.toPlugins import io.github.nofuturekid.nova.data.api.toServerInfo import io.github.nofuturekid.nova.data.api.toVms +import io.github.nofuturekid.nova.data.api.toIfaceSamples import io.github.nofuturekid.nova.data.model.ArrayInfo import io.github.nofuturekid.nova.data.model.ConnectionMode import io.github.nofuturekid.nova.data.model.Container import io.github.nofuturekid.nova.data.model.LiveMetrics +import io.github.nofuturekid.nova.data.model.NetworkThroughput +import io.github.nofuturekid.nova.data.model.selectThroughput import io.github.nofuturekid.nova.data.model.LogLine import io.github.nofuturekid.nova.data.model.NetworkInterface import io.github.nofuturekid.nova.data.model.Notifications @@ -40,6 +44,7 @@ import io.github.nofuturekid.nova.data.model.Plugin import io.github.nofuturekid.nova.data.model.PluginInstallOperation import io.github.nofuturekid.nova.data.model.ServerInfo import io.github.nofuturekid.nova.data.model.Vm +import io.github.nofuturekid.nova.graphql.SystemMetricsNetworkSubscription import io.github.nofuturekid.nova.graphql.ArchiveAllNotificationsMutation import io.github.nofuturekid.nova.graphql.ArchiveNotificationMutation import io.github.nofuturekid.nova.graphql.DeleteArchivedNotificationsMutation @@ -195,6 +200,25 @@ class UnraidRepository @Inject constructor( runCatching { recalculate(c) } nudge() } + + /** + * Test-only seam for the subscription substrate's per-sample → [DomainState] + * mapping. Drives the same mapping logic (frame → Content) that the production + * [subscriptionStream] applies, without requiring Apollo types or a live WS + * connection. Mirrors the [pollDomainForTest] convention (same-module `internal`). + * + * The test supplies a scripted list of frames (e.g. [IfaceSample] lists) and a + * mapping function (e.g. [selectThroughput]); this seam emits exactly one + * [DomainState.Content] per frame, tagged with [baseUrl], and then completes. + */ + internal fun subscriptionStreamForTest( + baseUrl: String, + frames: List, + map: (S) -> T, + ): kotlinx.coroutines.flow.Flow> = + kotlinx.coroutines.flow.flow { + for (f in frames) emit(DomainState.Content(map(f), baseUrl)) + } } /** @@ -324,17 +348,7 @@ class UnraidRepository @Inject constructor( return@flow } if (active.apiKey.isBlank()) { - // ADR-0035: a stored-but-undecryptable key is NOT the - // same as "no key". Don't flatten it to "missing" — - // tell the user it can no longer be decrypted so they - // re-enter it via the existing Add/Edit server flow. - val msg = if (active.keyState is ApiKeyResult.Undecryptable) { - "Saved API key can no longer be decrypted — " + - "re-enter it for ${active.server.name}" - } else { - "Missing API key for ${active.server.name}" - } - emit(DomainState.Error(msg)) + emit(DomainState.Error(blankKeyMessage(active))) return@flow } val url = activeUrl(active) @@ -356,6 +370,84 @@ class UnraidRepository @Inject constructor( } } + /** + * Generic WebSocket subscription stream. + * + * Mirrors [domainStream]: keyed on `servers.activeWithKey`, emits `NoServer` + * when no active server and `Error` when the API key is blank/undecryptable + * (same messages via [blankKeyMessage]). For a valid server, collects + * `apolloFactory.buildWs(url, key, trust).subscription(op).toFlow()` and + * maps each response to `DomainState.Content(map(data), url)`. + * + * Cold: the WS socket opens when the flow is collected and closes when + * collection is cancelled (gate flip / leaving Overview / app background). + * Apollo's WS transport auto-reconnects and re-sends the connection payload + * on drops; a sustained failure surfaces `DomainState.Error`. There is no + * poll fallback for this consumer — on WS failure the card shows unavailable. + */ + private fun subscriptionStream( + op: Subscription, + map: (D) -> T, + ): Flow> = servers.activeWithKey + .distinctUntilChanged() + .flatMapLatest { active -> + flow { + if (active == null) { emit(DomainState.NoServer); return@flow } + if (active.apiKey.isBlank()) { + emit(DomainState.Error(blankKeyMessage(active))); return@flow + } + val url = activeUrl(active) + val client = apolloFactory.buildWs(url, active.apiKey, trustFor(active, url)) + try { + client.subscription(op).toFlow().collect { resp -> + val d = resp.data + if (d != null) { + emit(DomainState.Content(map(d), url)) + } else if (resp.exception != null) { + emit(DomainState.Error(resp.exception!!.message ?: "Live connection error")) + } + } + } catch (e: Throwable) { + if (e is kotlinx.coroutines.CancellationException) throw e + emit(DomainState.Error(e.message ?: "Live connection error")) + } + } + } + + /** + * Live network throughput from the `systemMetricsNetwork` subscription. + * + * Resolves the primary NIC name once (tolerate failure — [selectThroughput] + * has a fallback for null), then opens a subscription stream and applies + * [selectThroughput] to each frame. The WS socket lives only while the + * flow is collected (gated by the caller via [gatedStream] in the ViewModel). + */ + fun networkThroughputStream(): Flow> { + val primaryFlow = flow { emit(runCatching { resolvePrimaryIface() }.getOrNull()) } + return primaryFlow.flatMapLatest { primary -> + subscriptionStream(SystemMetricsNetworkSubscription()) { data -> + selectThroughput(data.toIfaceSamples(), primary) + } + } + } + + private suspend fun resolvePrimaryIface(): String? = + activeClient()?.query(GetNetworkInterfacesQuery())?.execute() + ?.data?.info?.primaryNetwork?.name + + /** Blank/undecryptable API key error message (ADR-0035). Shared between + * [domainStream] and [subscriptionStream] — single definition, no drift. */ + private fun blankKeyMessage(active: ActiveServer): String = + // ADR-0035: a stored-but-undecryptable key is NOT the same as "no key". + // Don't flatten it to "missing" — tell the user it can no longer be + // decrypted so they re-enter it via the existing Add/Edit server flow. + if (active.keyState is ApiKeyResult.Undecryptable) { + "Saved API key can no longer be decrypted — " + + "re-enter it for ${active.server.name}" + } else { + "Missing API key for ${active.server.name}" + } + /** Single Apollo query → DomainState wrapper. The base URL is filled in * by [withBaseUrl] after the call so we keep [fetch] generic. */ private suspend fun fetch( 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 2122906..767f74a 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 @@ -105,6 +105,7 @@ fun MainScreen( val tab by vm.selectedTab.collectAsState() val infoState by vm.infoState.collectAsState() val metricsState by vm.metricsState.collectAsState() + val networkState by vm.networkThroughputState.collectAsState() val arrayState by vm.arrayState.collectAsState() val dockerState by vm.dockerState.collectAsState() val vmsState by vm.vmsState.collectAsState() @@ -223,6 +224,7 @@ fun MainScreen( vmsState = vmsState, server = ui.activeServer, onAddServer = onAddServer, + networkThroughput = (networkState as? DomainState.Content)?.value, ) MainTab.Array -> ArrayTab( state = arrayState, diff --git a/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/main/MainViewModel.kt b/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/main/MainViewModel.kt index 15b1a5c..b504037 100644 --- a/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/main/MainViewModel.kt +++ b/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/main/MainViewModel.kt @@ -32,6 +32,7 @@ import io.github.nofuturekid.nova.data.model.NotifType import io.github.nofuturekid.nova.data.model.Container import io.github.nofuturekid.nova.data.model.InstallState 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 @@ -214,6 +215,9 @@ class MainViewModel @Inject constructor( val metricsState: StateFlow> = gatedStream(overviewOnly(), unraid.metricsStream()) + val networkThroughputState: StateFlow> = + gatedStream(overviewOnly(), unraid.networkThroughputStream()) + val arrayState: StateFlow> = gatedStream(overviewOr(MainTab.Array), unraid.arrayStream()) 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 bd49749..616a8da 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 @@ -30,6 +30,7 @@ import io.github.nofuturekid.nova.data.model.ArrayState import io.github.nofuturekid.nova.data.model.Container import io.github.nofuturekid.nova.data.model.ContainerStatus import io.github.nofuturekid.nova.data.model.LiveMetrics +import io.github.nofuturekid.nova.data.model.NetworkThroughput import io.github.nofuturekid.nova.data.model.Server import io.github.nofuturekid.nova.data.model.ServerInfo import io.github.nofuturekid.nova.data.model.Vm @@ -46,6 +47,7 @@ import io.github.nofuturekid.nova.ui.components.UnraidProgress import io.github.nofuturekid.nova.ui.screens.ErrorState import io.github.nofuturekid.nova.ui.screens.LoadingState import io.github.nofuturekid.nova.ui.screens.NoServerState +import io.github.nofuturekid.nova.ui.util.formatBytesPerSec import io.github.nofuturekid.nova.ui.theme.JetBrainsMono import io.github.nofuturekid.nova.ui.theme.UnraidAlpha import io.github.nofuturekid.nova.ui.theme.UnraidTheme @@ -66,6 +68,7 @@ fun OverviewTab( vmsState: DomainState>, server: Server?, onAddServer: () -> Unit, + networkThroughput: NetworkThroughput? = null, ) { // Any single NoServer → no server picked. if (infoState is DomainState.NoServer || metricsState is DomainState.NoServer || @@ -94,7 +97,7 @@ fun OverviewTab( return } - OverviewContent(info, metrics, array, containers, vms, server) + OverviewContent(info, metrics, array, containers, vms, server, networkThroughput) } @Composable @@ -105,6 +108,7 @@ private fun OverviewContent( containers: List?, vms: List?, server: Server?, + networkThroughput: NetworkThroughput? = null, ) { val t = UnraidTheme.colors @@ -132,7 +136,11 @@ private fun OverviewContent( cpuSeries.value = cpuSeries.value.drop(1) + cpuPercent.toFloat() val pct = if (memTotalGb > 0) ((memUsedGb / memTotalGb) * 100).toFloat() else 0f ramSeries.value = ramSeries.value.drop(1) + pct - netSeries.value = netSeries.value.drop(1) + 0f + } + LaunchedEffect(networkThroughput) { + val total = ((networkThroughput?.rxBytesPerSec ?: 0.0) + + (networkThroughput?.txBytesPerSec ?: 0.0)).toFloat() + netSeries.value = netSeries.value.drop(1) + total } val arrTotalTb = array?.totalTb ?: 0.0 @@ -262,8 +270,12 @@ private fun OverviewContent( iconColor = t.muted, icon = { UC.Network(18.dp, t.muted) }, label = "Network", - value = "%.1f Mbps".format(0.0), - sub = "↓ 0.0 · ↑ 0.0 Mbps", + value = networkThroughput + ?.let { formatBytesPerSec(it.rxBytesPerSec + it.txBytesPerSec) } + ?: "—", + sub = networkThroughput + ?.let { "↓ ${formatBytesPerSec(it.rxBytesPerSec)} · ↑ ${formatBytesPerSec(it.txBytesPerSec)}" } + ?: "live unavailable", series = netSeries.value, seriesColor = Color(0xFFA78BFA), max = null, diff --git a/app/src/main/kotlin/io/github/nofuturekid/nova/ui/util/ByteFormat.kt b/app/src/main/kotlin/io/github/nofuturekid/nova/ui/util/ByteFormat.kt new file mode 100644 index 0000000..464e59c --- /dev/null +++ b/app/src/main/kotlin/io/github/nofuturekid/nova/ui/util/ByteFormat.kt @@ -0,0 +1,12 @@ +package io.github.nofuturekid.nova.ui.util + +/** Adaptive decimal throughput formatter: B/s · KB/s · MB/s · GB/s. */ +fun formatBytesPerSec(bytesPerSec: Double): String { + val b = bytesPerSec.coerceAtLeast(0.0) + return when { + b < 1_000.0 -> "%.0f B/s".format(b) + b < 1_000_000.0 -> "%.1f KB/s".format(b / 1_000.0) + b < 1_000_000_000.0 -> "%.1f MB/s".format(b / 1_000_000.0) + else -> "%.1f GB/s".format(b / 1_000_000_000.0) + } +} diff --git a/app/src/test/kotlin/io/github/nofuturekid/nova/data/model/NetworkThroughputTest.kt b/app/src/test/kotlin/io/github/nofuturekid/nova/data/model/NetworkThroughputTest.kt new file mode 100644 index 0000000..fd1dac4 --- /dev/null +++ b/app/src/test/kotlin/io/github/nofuturekid/nova/data/model/NetworkThroughputTest.kt @@ -0,0 +1,25 @@ +package io.github.nofuturekid.nova.data.model + +import org.junit.Assert.assertEquals +import org.junit.Test + +class NetworkThroughputTest { + private val lo = IfaceSample("lo", 5.0, 5.0) + private val eth0 = IfaceSample("eth0", 3300.0, 2800.0) + private val eth1 = IfaceSample("eth1", 0.0, 0.0) + + @Test fun picks_named_primary() = + assertEquals(NetworkThroughput(3300.0, 2800.0), selectThroughput(listOf(lo, eth0, eth1), "eth0")) + + @Test fun never_picks_loopback_even_if_named() = + assertEquals(NetworkThroughput(0.0, 0.0), selectThroughput(listOf(lo), "lo")) + + @Test fun fallback_to_active_non_lo_when_name_absent() = + assertEquals(NetworkThroughput(3300.0, 2800.0), selectThroughput(listOf(lo, eth1, eth0), null)) + + @Test fun fallback_to_first_non_lo_when_all_idle() = + assertEquals(NetworkThroughput(0.0, 0.0), selectThroughput(listOf(lo, eth1), null)) + + @Test fun empty_is_zero() = + assertEquals(NetworkThroughput.ZERO, selectThroughput(emptyList(), "eth0")) +} diff --git a/app/src/test/kotlin/io/github/nofuturekid/nova/data/repository/SubscriptionMappingTest.kt b/app/src/test/kotlin/io/github/nofuturekid/nova/data/repository/SubscriptionMappingTest.kt new file mode 100644 index 0000000..f469d46 --- /dev/null +++ b/app/src/test/kotlin/io/github/nofuturekid/nova/data/repository/SubscriptionMappingTest.kt @@ -0,0 +1,30 @@ +package io.github.nofuturekid.nova.data.repository + +import io.github.nofuturekid.nova.data.model.IfaceSample +import io.github.nofuturekid.nova.data.model.NetworkThroughput +import io.github.nofuturekid.nova.data.model.selectThroughput +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class SubscriptionMappingTest { + @Test fun maps_each_frame_to_content() = runTest { + val frames = listOf( + listOf(IfaceSample("eth0", 100.0, 50.0)), + listOf(IfaceSample("eth0", 200.0, 60.0)), + ) + val out = UnraidRepository.subscriptionStreamForTest("https://x", frames) { + selectThroughput(it, "eth0") + }.toList() + assertEquals( + listOf( + DomainState.Content(NetworkThroughput(100.0, 50.0), "https://x"), + DomainState.Content(NetworkThroughput(200.0, 60.0), "https://x"), + ), + out, + ) + } +} diff --git a/app/src/test/kotlin/io/github/nofuturekid/nova/ui/util/ByteFormatTest.kt b/app/src/test/kotlin/io/github/nofuturekid/nova/ui/util/ByteFormatTest.kt new file mode 100644 index 0000000..9426698 --- /dev/null +++ b/app/src/test/kotlin/io/github/nofuturekid/nova/ui/util/ByteFormatTest.kt @@ -0,0 +1,14 @@ +package io.github.nofuturekid.nova.ui.util + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ByteFormatTest { + @Test fun zero_is_bytes() = assertEquals("0 B/s", formatBytesPerSec(0.0)) + @Test fun just_under_kilo_is_bytes() = assertEquals("999 B/s", formatBytesPerSec(999.0)) + @Test fun kilo_boundary() = assertEquals("1.0 KB/s", formatBytesPerSec(1_000.0)) + @Test fun low_kb_is_honest() = assertEquals("3.3 KB/s", formatBytesPerSec(3_300.0)) + @Test fun megabytes() = assertEquals("12.0 MB/s", formatBytesPerSec(12_000_000.0)) + @Test fun gigabytes() = assertEquals("1.5 GB/s", formatBytesPerSec(1_500_000_000.0)) + @Test fun negative_clamped_to_zero() = assertEquals("0 B/s", formatBytesPerSec(-5.0)) +} diff --git a/docs/superpowers/plans/2026-05-29-live-network-graph.md b/docs/superpowers/plans/2026-05-29-live-network-graph.md new file mode 100644 index 0000000..8198c80 --- /dev/null +++ b/docs/superpowers/plans/2026-05-29-live-network-graph.md @@ -0,0 +1,542 @@ +# Live Network Graph Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Feed live network throughput (`systemMetricsNetwork` GraphQL subscription) into the existing Overview "Network" card via a reusable WebSocket subscription substrate, shipped beta-first. + +**Architecture:** Add a WS-capable Apollo client (`buildWs`) that reuses the existing self-signed TLS pin (ADR-0041); a generic `subscriptionStream` in `UnraidRepository` mirroring `domainStream` (cold, gated, `DomainState`); a `networkThroughputStream()` consumer that picks the management interface; wire it into `MainViewModel` (gated like `metricsState`) and the already-present Overview Network `StatCard`. Selection/format logic is factored into **pure seams** so it unit-tests without generated Apollo types. + +**Tech Stack:** Kotlin, Jetpack Compose (M3), Apollo Kotlin 5.0.0 (`apollo-runtime` — WS built in, no new dep), Hilt, kotlinx-coroutines(-test), JUnit. + +**Spec:** `docs/superpowers/specs/2026-05-29-live-network-graph-design.md` + +--- + +## ⚠️ Verification model (READ FIRST) + +**There is NO local Android toolchain. `./gradlew` cannot run on the host.** Per project convention CI is the only build/test authority (don't compile locally, don't send subagents to compile). Therefore: + +- "Run the test" steps do **not** execute locally. Pure-logic tests are written test-first and **verified in CI** at the checkpoints (Task 8 / final push), via the `test` job (`./gradlew :app:testDirectDebugUnitTest`). +- Apollo codegen + compile + lint are verified by the `build` job (`assembleDirectDebug`/`assembleRelease` depend on `generateApolloSources`). +- Optional local pre-check: `./scripts/local-ci.sh` (pinned Docker container) runs codegen+lint+assemble (NOT unit tests). Use it to catch codegen/compile breakage before pushing if desired; it is not a substitute for the CI `test` job. +- **Commit frequently** (each task ends in a commit) so CI runs on each push and failures localise to one task. + +All tasks land on branch `feat/live-network-graph` (already created; the spec commit `fa0d2ae` is its first commit). + +--- + +## Task 1: GraphQL schema subset + subscription operation + +**Files:** +- Modify: `app/src/main/graphql/io/github/nofuturekid/nova/schema.graphqls` +- Create: `app/src/main/graphql/io/github/nofuturekid/nova/subscriptions.graphql` + +- [ ] **Step 1: Verify exact upstream shape.** Before editing, confirm the field nullability/wrapping against the authoritative upstream `unraid/api` `generated-schema.graphql` for `Subscription.systemMetricsNetwork`, `NetworkUtilization`, `NetworkInterfaceUtilization`. (Live-verified 2026-05-29 that the fields deliver; this step pins exact SDL, avoiding the `GetNotificationList` vendored-subset-divergence trap.) + +- [ ] **Step 2: Add the `Subscription` type + payload types to `schema.graphqls`** (append near the other top-level types): + +```graphql +type Subscription { + systemMetricsNetwork: NetworkUtilization! +} + +type NetworkUtilization { + id: PrefixedID! + interfaces: [NetworkInterfaceUtilization!]! +} + +type NetworkInterfaceUtilization { + iface: String + rxBytesPerSec: Float + txBytesPerSec: Float +} +``` + +- [ ] **Step 3: Create `subscriptions.graphql`** (read-only; do NOT touch `mutations.graphql` — mutations stay maintainer-only): + +```graphql +subscription SystemMetricsNetwork { + systemMetricsNetwork { + interfaces { + iface + rxBytesPerSec + txBytesPerSec + } + } +} +``` + +- [ ] **Step 4: Commit.** + +```bash +git add app/src/main/graphql/io/github/nofuturekid/nova/schema.graphqls \ + app/src/main/graphql/io/github/nofuturekid/nova/subscriptions.graphql +git commit -m "feat(graphql): add systemMetricsNetwork subscription schema + op" +``` + +CI/codegen verification happens at Task 8 (Apollo generates `SystemMetricsNetworkSubscription` from these files). + +--- + +## Task 2: `NetworkThroughput` model + pure throughput-selection + byte formatter + +This task is **pure Kotlin** (no Apollo types) → fully unit-testable in the `test` job. It contains the two pieces of logic that matter: which interface to read, and how to format a rate. + +**Files:** +- Modify: `app/src/main/kotlin/io/github/nofuturekid/nova/data/model/Models.kt` +- Create: `app/src/main/kotlin/io/github/nofuturekid/nova/ui/util/ByteFormat.kt` +- Create: `app/src/main/kotlin/io/github/nofuturekid/nova/data/model/NetworkThroughput.kt` +- Test: `app/src/test/kotlin/io/github/nofuturekid/nova/ui/util/ByteFormatTest.kt` +- Test: `app/src/test/kotlin/io/github/nofuturekid/nova/data/model/NetworkThroughputTest.kt` + +- [ ] **Step 1: Write the failing formatter test** (`ByteFormatTest.kt`): + +```kotlin +package io.github.nofuturekid.nova.ui.util + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ByteFormatTest { + @Test fun zero_is_bytes() = assertEquals("0 B/s", formatBytesPerSec(0.0)) + @Test fun just_under_kilo_is_bytes() = assertEquals("999 B/s", formatBytesPerSec(999.0)) + @Test fun kilo_boundary() = assertEquals("1.0 KB/s", formatBytesPerSec(1_000.0)) + @Test fun low_kb_is_honest() = assertEquals("3.3 KB/s", formatBytesPerSec(3_300.0)) + @Test fun megabytes() = assertEquals("12.0 MB/s", formatBytesPerSec(12_000_000.0)) + @Test fun gigabytes() = assertEquals("1.5 GB/s", formatBytesPerSec(1_500_000_000.0)) + @Test fun negative_clamped_to_zero() = assertEquals("0 B/s", formatBytesPerSec(-5.0)) +} +``` + +- [ ] **Step 2: Implement the formatter** (`ByteFormat.kt`) — decimal (1000-based) to match networking convention, matching the codebase's locale-default `"%.1f".format(...)` style: + +```kotlin +package io.github.nofuturekid.nova.ui.util + +/** Adaptive decimal throughput formatter: B/s · KB/s · MB/s · GB/s. */ +fun formatBytesPerSec(bytesPerSec: Double): String { + val b = bytesPerSec.coerceAtLeast(0.0) + return when { + b < 1_000.0 -> "%.0f B/s".format(b) + b < 1_000_000.0 -> "%.1f KB/s".format(b / 1_000.0) + b < 1_000_000_000.0 -> "%.1f MB/s".format(b / 1_000_000.0) + else -> "%.1f GB/s".format(b / 1_000_000_000.0) + } +} +``` + +- [ ] **Step 3: Add the model** (`NetworkThroughput.kt`): + +```kotlin +package io.github.nofuturekid.nova.data.model + +/** Live per-second throughput for one interface. */ +data class NetworkThroughput( + val rxBytesPerSec: Double, + val txBytesPerSec: Double, +) { + companion object { val ZERO = NetworkThroughput(0.0, 0.0) } +} + +/** Plain per-interface sample — the pure seam the Apollo mapper feeds, so + * interface-selection logic unit-tests without generated types. */ +data class IfaceSample( + val iface: String?, + val rxBytesPerSec: Double, + val txBytesPerSec: Double, +) + +/** + * Pick the throughput to display from a frame's interfaces. + * Preference: the named management interface → first non-`lo` that is + * actively moving bytes → first non-`lo` → ZERO. `lo` is never chosen. + */ +fun selectThroughput(samples: List, primaryIface: String?): NetworkThroughput { + val chosen = samples.firstOrNull { it.iface == primaryIface && it.iface != "lo" } + ?: samples.firstOrNull { it.iface != "lo" && (it.rxBytesPerSec + it.txBytesPerSec) > 0.0 } + ?: samples.firstOrNull { it.iface != "lo" } + return chosen?.let { NetworkThroughput(it.rxBytesPerSec, it.txBytesPerSec) } ?: NetworkThroughput.ZERO +} +``` + +> Note: `IfaceSample`/`selectThroughput` live in `Models.kt`-adjacent `NetworkThroughput.kt`; do NOT modify `Models.kt` unless a shared import requires it (the `Modify Models.kt` entry above is a fallback only). + +- [ ] **Step 4: Write the failing selection test** (`NetworkThroughputTest.kt`): + +```kotlin +package io.github.nofuturekid.nova.data.model + +import org.junit.Assert.assertEquals +import org.junit.Test + +class NetworkThroughputTest { + private val lo = IfaceSample("lo", 5.0, 5.0) + private val eth0 = IfaceSample("eth0", 3300.0, 2800.0) + private val eth1 = IfaceSample("eth1", 0.0, 0.0) + + @Test fun picks_named_primary() = + assertEquals(NetworkThroughput(3300.0, 2800.0), selectThroughput(listOf(lo, eth0, eth1), "eth0")) + + @Test fun never_picks_loopback_even_if_named() = + assertEquals(NetworkThroughput(0.0, 0.0), selectThroughput(listOf(lo), "lo")) + + @Test fun fallback_to_active_non_lo_when_name_absent() = + assertEquals(NetworkThroughput(3300.0, 2800.0), selectThroughput(listOf(lo, eth1, eth0), null)) + + @Test fun fallback_to_first_non_lo_when_all_idle() = + assertEquals(NetworkThroughput(0.0, 0.0), selectThroughput(listOf(lo, eth1), null)) + + @Test fun empty_is_zero() = + assertEquals(NetworkThroughput.ZERO, selectThroughput(emptyList(), "eth0")) +} +``` + +- [ ] **Step 5: Commit.** + +```bash +git add app/src/main/kotlin/io/github/nofuturekid/nova/ui/util/ByteFormat.kt \ + app/src/main/kotlin/io/github/nofuturekid/nova/data/model/NetworkThroughput.kt \ + app/src/test/kotlin/io/github/nofuturekid/nova/ui/util/ByteFormatTest.kt \ + app/src/test/kotlin/io/github/nofuturekid/nova/data/model/NetworkThroughputTest.kt +git commit -m "feat: NetworkThroughput model, pure interface-selection, byte formatter + tests" +``` + +--- + +## Task 3: Apollo mapper — generated frame → `IfaceSample` list + +Thin adapter over the generated `SystemMetricsNetworkSubscription.Data`; selection itself is the tested `selectThroughput`. Requires Task 1's codegen, so it is compile-verified in CI only. + +**Files:** +- Modify: `app/src/main/kotlin/io/github/nofuturekid/nova/data/api/GraphQlMapper.kt` + +- [ ] **Step 1: Add the mapper** (adjust the generated property path if Step-1 introspection differs): + +```kotlin +import io.github.nofuturekid.nova.graphql.SystemMetricsNetworkSubscription +import io.github.nofuturekid.nova.data.model.IfaceSample + +fun SystemMetricsNetworkSubscription.Data.toIfaceSamples(): List = + systemMetricsNetwork.interfaces.map { + IfaceSample( + iface = it.iface, + rxBytesPerSec = it.rxBytesPerSec ?: 0.0, + txBytesPerSec = it.txBytesPerSec ?: 0.0, + ) + } +``` + +- [ ] **Step 2: Commit.** + +```bash +git add app/src/main/kotlin/io/github/nofuturekid/nova/data/api/GraphQlMapper.kt +git commit -m "feat(mapper): SystemMetricsNetwork frame -> IfaceSample list" +``` + +--- + +## Task 4: WS transport — `ApolloClientFactory.buildWs` + +**Files:** +- Modify: `app/src/main/kotlin/io/github/nofuturekid/nova/data/api/ApolloClientFactory.kt` + +- [ ] **Step 1: Confirm the Apollo 5.0.0 WS API** via Context7 (`mcp__claude_ai_Context7__query-docs`, library "apollo kotlin") — exact names for `WebSocketNetworkTransport`, `GraphQLWsProtocol.Factory(connectionPayload = …)`, and how to supply an OkHttp-backed `WebSocketEngine`/`DefaultWebSocketEngine`. Do not guess the API surface. + +- [ ] **Step 2: Extract the TLS-apply logic** so HTTP and WS share it. Refactor the existing `buildOn`'s OkHttp builder block (the `if (trust is TlsTrust.PinnedSelfSigned) { … sslSocketFactory … hostnameVerifier … }`) into a private helper `private fun OkHttpClient.Builder.applyTrust(trust: TlsTrust): OkHttpClient.Builder` and call it from both `buildOn` and the new WS path. (Surgical; preserves existing HTTP behaviour exactly.) + +- [ ] **Step 3: Add `buildWs`** (shape — finalise names against Step 1): + +```kotlin +fun buildWs(baseUrl: String, apiKey: String, trust: TlsTrust = TlsTrust.Default): ApolloClient = + wsClients.computeIfAbsent(wsKey(baseUrl, apiKey, trust)) { + val httpEndpoint = baseUrl.trimEnd('/') + "/graphql" + val wsEndpoint = httpEndpoint.replaceFirst("http", "ws") // https->wss, http->ws + val ws = baseHttpClient.newBuilder().applyTrust(trust).build() + ApolloClient.Builder() + .serverUrl(httpEndpoint) + .subscriptionNetworkTransport( + WebSocketNetworkTransport.Builder() + .serverUrl(wsEndpoint) + .protocol(GraphQLWsProtocol.Factory(connectionPayload = { mapOf("x-api-key" to apiKey) })) + .webSocketEngine(DefaultWebSocketEngine(ws)) + .build() + ) + .build() + } +``` + +Add a `private val wsClients = ConcurrentHashMap()` and a `wsKey(...)` mirroring the existing `cached(...)` key (variant `"ws"`). Lifecycle ownership note same as ADR-0028 (factory owns; callers don't close). + +- [ ] **Step 4: Commit.** + +```bash +git add app/src/main/kotlin/io/github/nofuturekid/nova/data/api/ApolloClientFactory.kt +git commit -m "feat(api): buildWs WebSocket Apollo client reusing self-signed TLS pin" +``` + +--- + +## Task 5: Subscription substrate + network consumer + test seam + +**Files:** +- Modify: `app/src/main/kotlin/io/github/nofuturekid/nova/data/repository/UnraidRepository.kt` +- Test: `app/src/test/kotlin/io/github/nofuturekid/nova/data/repository/SubscriptionMappingTest.kt` + +- [ ] **Step 1: Write the failing pure-seam test** (`SubscriptionMappingTest.kt`) — pins the substrate's per-sample → `DomainState` mapping + the network fallback, driven by a scripted flow (mirrors `pollDomainForTest`/`TransientErrorToleranceTest`): + +```kotlin +package io.github.nofuturekid.nova.data.repository + +import io.github.nofuturekid.nova.data.model.IfaceSample +import io.github.nofuturekid.nova.data.model.NetworkThroughput +import io.github.nofuturekid.nova.data.model.selectThroughput +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class SubscriptionMappingTest { + @Test fun maps_each_frame_to_content() = runTest { + val frames = listOf( + listOf(IfaceSample("eth0", 100.0, 50.0)), + listOf(IfaceSample("eth0", 200.0, 60.0)), + ) + val out = UnraidRepository.subscriptionStreamForTest("https://x", frames) { + selectThroughput(it, "eth0") + }.toList() + assertEquals( + listOf( + DomainState.Content(NetworkThroughput(100.0, 50.0), "https://x"), + DomainState.Content(NetworkThroughput(200.0, 60.0), "https://x"), + ), + out, + ) + } +} +``` + +- [ ] **Step 2: Add the `internal` test seam to `UnraidRepository.Companion`** (pure; no Apollo/WS): + +```kotlin +internal fun subscriptionStreamForTest( + baseUrl: String, + frames: List, + map: (S) -> T, +): kotlinx.coroutines.flow.Flow> = + kotlinx.coroutines.flow.flow { + for (f in frames) emit(DomainState.Content(map(f), baseUrl)) + } +``` + +- [ ] **Step 3: Add the production `subscriptionStream`** (mirrors `domainStream`: keyed on `servers.activeWithKey`, `flatMapLatest`, `NoServer`/blank-key `Error` cases identical to `domainStream`; collects `apolloFactory.buildWs(url, key, trust).subscription(op).toFlow()`, maps `data` → `DomainState.Content(map(data)).withBaseUrl(url)`; cold so the socket closes on cancel; on error emit `DomainState.Error`). Reuse `trustFor(active, url)`. No fallback poll for this consumer. + +```kotlin +private fun subscriptionStream( + op: com.apollographql.apollo.api.Subscription, + map: (D) -> T, +): Flow> = servers.activeWithKey + .distinctUntilChanged() + .flatMapLatest { active -> + flow { + if (active == null) { emit(DomainState.NoServer); return@flow } + if (active.apiKey.isBlank()) { + emit(DomainState.Error(blankKeyMessage(active))); return@flow + } + val url = activeUrl(active) + val client = apolloFactory.buildWs(url, active.apiKey, trustFor(active, url)) + try { + client.subscription(op).toFlow().collect { resp -> + val d = resp.data + if (d != null) emit(DomainState.Content(map(d), url)) + else if (resp.exception != null) emit(DomainState.Error(resp.exception!!.message ?: "Live connection error")) + } + } catch (e: Throwable) { + if (e is kotlinx.coroutines.CancellationException) throw e + emit(DomainState.Error(e.message ?: "Live connection error")) + } + } + } +``` + +(Extract the existing blank-key message construction from `domainStream` into a private `blankKeyMessage(active)` helper and reuse in both — DRY, no behaviour change.) + +- [ ] **Step 4: Add the network consumer:** + +```kotlin +fun networkThroughputStream(): Flow> { + // Resolve the primary NIC name once; tolerate failure (fallback handles null). + val primaryFlow = flow { emit(runCatching { resolvePrimaryIface() }.getOrNull()) } + return primaryFlow.flatMapLatest { primary -> + subscriptionStream(SystemMetricsNetworkSubscription()) { data -> + selectThroughput(data.toIfaceSamples(), primary) + } + } +} + +private suspend fun resolvePrimaryIface(): String? = + activeClient()?.query(GetNetworkInterfacesQuery())?.execute() + ?.data?.info?.primaryNetwork?.name +``` + +- [ ] **Step 5: Commit.** + +```bash +git add app/src/main/kotlin/io/github/nofuturekid/nova/data/repository/UnraidRepository.kt \ + app/src/test/kotlin/io/github/nofuturekid/nova/data/repository/SubscriptionMappingTest.kt +git commit -m "feat(repo): generic subscriptionStream + networkThroughputStream + test seam" +``` + +--- + +## Task 6: MainViewModel — gated network state + +**Files:** +- Modify: `app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/main/MainViewModel.kt` + +- [ ] **Step 1: Add the gated state** next to `metricsState` (line ~214), gated identically so the WS socket lives only while Overview is foreground: + +```kotlin +val networkThroughputState: StateFlow> = + gatedStream(overviewOnly(), unraid.networkThroughputStream()) +``` + +Add the import `io.github.nofuturekid.nova.data.model.NetworkThroughput`. + +- [ ] **Step 2: Commit.** + +```bash +git add app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/main/MainViewModel.kt +git commit -m "feat(vm): expose gated networkThroughputState (overview-only)" +``` + +--- + +## Task 7: UI wiring — feed the existing Network card + +**Files:** +- Modify: `app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/main/MainScreen.kt` +- Modify: `app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/overview/OverviewTab.kt` + +- [ ] **Step 1: Collect + pass the state in `MainScreen`.** Next to `val metricsState by vm.metricsState.collectAsState()` (line 107) add `val networkState by vm.networkThroughputState.collectAsState()`. In the `MainTab.Overview -> OverviewTab(...)` call (line ~218) add `networkThroughput = (networkState as? DomainState.Content)?.value`. + +- [ ] **Step 2: Thread the param** through `OverviewTab(...)` into `OverviewContent(...)`: add parameter `networkThroughput: NetworkThroughput? = null` (import the model). + +- [ ] **Step 3: Drive the sparkline from the WS sample.** In `OverviewContent`, replace the hardcoded net tick. The existing `LaunchedEffect(metrics)` (lines 131-136) appends `0f` to `netSeries` — REMOVE that one line (`netSeries.value = netSeries.value.drop(1) + 0f`) and add a separate effect keyed on the live sample so it advances at the subscription's ~1 Hz, not the metrics cadence: + +```kotlin +LaunchedEffect(networkThroughput) { + val total = ((networkThroughput?.rxBytesPerSec ?: 0.0) + + (networkThroughput?.txBytesPerSec ?: 0.0)).toFloat() + netSeries.value = netSeries.value.drop(1) + total +} +``` + +- [ ] **Step 4: Feed the card value/sub** (lines 260-271). Replace the hardcoded `value`/`sub`: + +```kotlin +StatCard( + iconColor = t.muted, + icon = { UC.Network(18.dp, t.muted) }, + label = "Network", + value = networkThroughput + ?.let { formatBytesPerSec(it.rxBytesPerSec + it.txBytesPerSec) } + ?: "—", + sub = networkThroughput + ?.let { "↓ ${formatBytesPerSec(it.rxBytesPerSec)} · ↑ ${formatBytesPerSec(it.txBytesPerSec)}" } + ?: "live unavailable", + series = netSeries.value, + seriesColor = Color(0xFFA78BFA), + max = null, +) +``` + +Add `import io.github.nofuturekid.nova.ui.util.formatBytesPerSec`. + +- [ ] **Step 5: Commit.** + +```bash +git add app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/main/MainScreen.kt \ + app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/overview/OverviewTab.kt +git commit -m "feat(ui): live throughput in the Overview Network card + sparkline" +``` + +--- + +## Task 8: CI checkpoint — push and verify green + +- [ ] **Step 1: Push the branch.** + +```bash +git push -u origin feat/live-network-graph +``` + +- [ ] **Step 2: Watch CI** (the only build/test authority): + +```bash +gh run watch --exit-status # or: gh pr checks +``` + +Expected: `build` job green (Apollo codegen for `SystemMetricsNetworkSubscription` succeeds, `assembleDirectDebug`/`assembleRelease` + lint pass) AND `test` job green (`ByteFormatTest`, `NetworkThroughputTest`, `SubscriptionMappingTest` pass). + +- [ ] **Step 3: If red, fix forward** (systematic-debugging): read the failing job log, correct the offending task's code, commit, push, re-watch. Common suspects: Apollo generated property path in Task 3 (adjust to match `schema.graphqls`), Apollo 5.0 WS builder API names in Task 4 (re-check Context7). + +--- + +## Task 9: ADR-0042 + supersede ADR-0026 + +**Files:** +- Create: `docs/adr/0042-graphql-subscription-network-pilot.md` (use `/adr-new` or copy `docs/adr/template.md`) +- Modify: `docs/adr/0026-graphql-subscriptions-hybrid.md` (Status line) + +- [ ] **Step 1: Write ADR-0042** "GraphQL subscription transport — network throughput pilot": Status `Proposed`; Context = ADR-0026 revisit (network field now exists upstream; agent-drivable test env), live evidence (rates deliver ~1 Hz). Decision = adopt the WS subscription transport scoped to network throughput only, reusing the self-signed pin (ADR-0041), gated `overviewOnly()`, **no poll fallback** (none exists), cold-flow socket lifecycle. Consequences/trade-offs; explicitly out of scope: metrics/docker/array. Note the `graphql-smoke` skill is HTTP-only (WS validated via python client / on-device). + +- [ ] **Step 2: Update ADR-0026 Status** to: `Superseded by ADR-0042 for network throughput (network pilot); metrics/docker/array still pending.` + +- [ ] **Step 3: Commit.** + +```bash +git add docs/adr/0042-graphql-subscription-network-pilot.md docs/adr/0026-graphql-subscriptions-hybrid.md +git commit -m "docs(adr-0042): adopt WS subscription transport for network throughput pilot" +``` + +--- + +## Task 10: Version bump + CHANGELOG (beta-first) + +**Files:** +- Modify: `app/build.gradle.kts` +- Modify: `CHANGELOG.md` + +- [ ] **Step 1: Bump** in `app/build.gradle.kts` `defaultConfig`: `versionCode = 108` (was 107), `versionName = "0.1.40-beta1"` (was "0.1.39"). + +- [ ] **Step 2: CHANGELOG** (ADR-0031, plain language) — under `## [Unreleased]` add to `### Added`: + +```markdown +- The Network card on the dashboard now shows live upload/download speed in real time. +``` + +- [ ] **Step 3: Commit.** + +```bash +git add app/build.gradle.kts CHANGELOG.md +git commit -m "release: 0.1.40-beta1 — live network throughput card (versionCode 108)" +``` + +--- + +## Task 11: PR, CI, beta tag — DELEGATE to `release-engineer` + +Per CLAUDE.md Rule 14 the release tail is delegated to the `release-engineer` subagent. It opens the PR, watches CI, squash-merges on green, and tags `v0.1.40-beta1` (beta tagging is autonomous; **stable promotion stays the maintainer's on-device gate** — ADR-0027). The `0.1.40-beta1` APK ships for on-device acceptance: confirm the Network card shows live rates on the real server, then the WS-transport balloon is validated. + +- [ ] **Step 1:** Dispatch `release-engineer` with the branch `feat/live-network-graph`, target `0.1.40-beta1`. +- [ ] **Step 2:** Maintainer on-device acceptance (human-only). Do NOT promote to stable. + +--- + +## Self-review notes (for the executor) + +- **Pure seams** (`selectThroughput`, `formatBytesPerSec`, `subscriptionStreamForTest`) are the only unit-tested logic — deliberate, because generated Apollo types and the live WS can't run in the `test` job. Mapper/transport are CI-compile-verified + on-device-verified. +- **Type consistency:** `NetworkThroughput(rxBytesPerSec, txBytesPerSec)`, `IfaceSample(iface, rxBytesPerSec, txBytesPerSec)`, `selectThroughput(samples, primaryIface)`, `formatBytesPerSec(Double)`, `buildWs(baseUrl, apiKey, trust)`, `networkThroughputStream()`, `networkThroughputState` — used identically across tasks. +- **Apollo API caveat:** Task 4 Step 1 and Task 3 Step 1 require verifying generated property paths + Apollo 5.0 WS builder names before finalising — flagged as the two real unknowns. diff --git a/docs/superpowers/specs/2026-05-29-live-network-graph-design.md b/docs/superpowers/specs/2026-05-29-live-network-graph-design.md new file mode 100644 index 0000000..13e64bf --- /dev/null +++ b/docs/superpowers/specs/2026-05-29-live-network-graph-design.md @@ -0,0 +1,214 @@ +# Live Network Graph — Design Spec + +- **Date**: 2026-05-29 +- **Status**: Approved (brainstorm) — pending implementation plan +- **Feature**: Feed live network throughput into the existing Overview "Network" card via a GraphQL subscription. +- **Framing**: A scoped **test balloon** that re-validates the WebSocket subscription transport on real hardware before metrics/docker subscriptions follow. Ships beta-first. + +## 1. Background & motivation + +The Overview dashboard already renders a **Network `StatCard`** (same component as +CPU/Memory) with a sparkline series `netSeries` — but every input is hardcoded +(`OverviewTab.kt`): + +- `value = "%.1f Mbps".format(0.0)` +- `sub = "↓ 0.0 · ↑ 0.0 Mbps"` +- `netSeries` appends `0f` each tick (line 135), initialised to `List(40){0f}` (line 127) + +So the card is fully scaffolded and shipping today showing zeros. The feature is to +**give it real data**, not to build new UI. + +The data source is the Unraid GraphQL **subscription** `systemMetricsNetwork`, which +pushes per-interface throughput ~1×/sec over a WebSocket. This was **live-verified on +2026-05-29** against the real server (`wss://192.168.11.2/graphql`, subprotocol +`graphql-transport-ws`, auth via `x-api-key`): `NetworkInterfaceUtilization` delivers +`iface`, `rxBytes`, `txBytes`, `rxBytesPerSec`, `txBytesPerSec` for `lo`/`eth0`–`eth3`, +with live rates (e.g. eth0 ≈ 3.3 KB/s rx, 2.8 KB/s tx). + +### Prior art — ADR-0026 + +ADR-0026 piloted a *generic* subscription substrate (E0 WS transport + E1 notifications) +in 0.1.29-beta9…beta13, then **reverted** it. The deciding factor was **iterability**: each +hypothesis cost a multi-beta user-relay loop without a test environment the agent could +drive directly (ADR-0027). Its 2026-05-17 amendment declared **network throughput +"impossible regardless of transport"** because the API exposed no such field. + +Two things changed that make this revisit valid: + +1. **The API added network throughput.** `systemMetricsNetwork` now exists and delivers + (live-verified 2026-05-29) — the 2026-05-17 "impossible" claim is stale. +2. **The agent can now drive the real server directly** (read-only WS smoke via a local + python client), collapsing the iteration cost that caused the revert. + +This is therefore an explicit ADR-0026 revisit trigger ("the API gains server-push"). + +## 2. Decisions locked during brainstorming + +| Decision | Choice | Rationale | +|---|---|---| +| Placement | **Existing Overview Network card** (no new screen/card) | The card already exists; only wiring is missing. | +| Interface scope | **Management interface only** | Resolve via `GetNetworkInterfaces.primaryNetwork.name`; avoids bond/bridge double-counting. | +| Units | **Adaptive bytes** (B/s · KB/s · MB/s · GB/s) | Honest at low rates; "Mbps" rounds 3.3 KB/s to "0.0". | +| Architecture | **Approach 2 — reusable subscription substrate**, network as first consumer | Maintainer's choice (over the minimal-only recommendation). | +| Scope this beta | **Substrate + network consumer only** | Metrics/docker/array stay polling; follow in later betas. Keeps the balloon small. | +| Release | **Beta-first** (`0.1.40-beta1`) | New transport touching how the app talks to the server ⇒ ADR-0006 mandates beta-first. | + +## 3. Architecture & data flow + +### 3.1 WS transport — `ApolloClientFactory.buildWs(baseUrl, apiKey, trust)` + +A WebSocket-capable `ApolloClient`, alongside the existing HTTP clients, cached per +`(endpoint, key, trust)` exactly like `build`/`buildLongRunning`: + +- `WebSocketNetworkTransport` → `wss:///graphql`, `GraphQLWsProtocol`, + `connectionPayload = { "x-api-key": apiKey }` (same credential as HTTP). +- **Reuses the existing self-signed TLS plumbing.** The WS engine is fed an OkHttpClient + built with the same `TlsTrust`/pinning logic as the private `buildOn(...)` + (`LocalPinningTrustManager`, `sslSocketFactory`, hostname bypass for pinned self-signed — + ADR-0041). No new trust path; the WS reuses the pin the HTTP poll already established. +- Apollo 5's `apollo-runtime` already provides this transport (OkHttp-backed) — **no new + dependency**. + +> **Primary integration risk.** Wiring the custom `sslSocketFactory` into Apollo's WS +> engine for the self-signed local case is the fiddliest part. If the cert is not yet +> trusted, the existing HTTP poll surfaces the cert prompt (ADR-0041) first; the WS only +> ever connects with an already-pinned cert. The implementation plan must verify the WS +> handshake honours the pin on-device. + +### 3.2 Generic `subscriptionStream` — `UnraidRepository` + +Mirrors the existing `domainStream`: + +- Keyed on `servers.activeWithKey` → `distinctUntilChanged` → `flatMapLatest`. +- Emits `DomainState`: `NoServer` when no active server; `Error` when the key is blank / + undecryptable (same messages as `domainStream`); otherwise collects + `buildWs(url, key, trust).subscription(op).toFlow()` and maps each response to + `DomainState.Content(map(data)).withBaseUrl(url)`. +- **Cold** ⇒ the WS socket opens when the flow is collected and closes when collection is + cancelled (gate flip / leaving Overview / app background). +- **Reconnect:** Apollo's WS transport auto-reconnects and re-sends `connectionPayload`; + brief drops keep re-showing the last `Content` (analogous to `pollDomain`'s transient + tolerance). A sustained failure surfaces `Error`. +- **Optional fallback-poll hook** preserved from the ADR-0026 contract — **network passes + none** (no query path for throughput exists), so on sustained WS failure it yields + `Error` → the card shows "unavailable". + +### 3.3 Consumer — `UnraidRepository.networkThroughputStream()` + +- Resolves the primary NIC name once per connection via the existing + `GetNetworkInterfaces` query (`primaryNetwork.name`). +- Subscribes `SystemMetricsNetwork`, maps each frame to the matching interface's + `rxBytesPerSec`/`txBytesPerSec` → `NetworkThroughput(rx, tx)`. +- **Fallback if** `primaryNetwork.name` is null or not present in the frame: the first + non-`lo` interface that is reporting. + +### 3.4 UI wiring + +- **`MainViewModel`**: `val networkThroughputState = gatedStream(overviewOnly(), unraid.networkThroughputStream())` + — gated **identically to `metricsState`**, so the WS socket lives only while Overview is + the foreground tab. `gatedStream` already provides `WhileSubscribed(5s)` caching and the + `resetIfForeignServer` server-identity guard; the subscription `Content` is tagged with + `serverBaseUrl` via `withBaseUrl` so the guard works unchanged. +- **`OverviewTab`** (plumbed through `MainScreen` like the other domain states): + - Network card `value` = `formatBytesPerSec(rx + tx)`; `sub` = `"↓ ${formatBytesPerSec(rx)} · ↑ ${formatBytesPerSec(tx)}"`. + - `netSeries` advanced by a **new `LaunchedEffect(networkThroughput)`** on each WS sample + (~1 Hz) with value `rx + tx`. CPU/RAM series keep keying on `metrics`. `max = null` + already auto-scales the sparkline. + - On "unavailable": card shows `—`, sub "live unavailable", and the sparkline holds its + last value (no synthetic 0 spike, no error storm, no crash). + +### 3.5 Lifecycle summary + +``` +Overview visible & app foreground ──▶ gate open ──▶ collect networkThroughputStream + ──▶ buildWs() opens WSS ──▶ SystemMetricsNetwork frames (~1 Hz) ──▶ card + sparkline +Leave Overview / background app ──▶ gate closed ──▶ flow cancelled ──▶ WSS closed +``` + +## 4. Schema, model, operation + +**`schema.graphqls`** — add (each verified against upstream `generated-schema.graphql` +before use; fields already live-verified): + +```graphql +type Subscription { systemMetricsNetwork: NetworkUtilization! } +type NetworkUtilization { id: PrefixedID! interfaces: [NetworkInterfaceUtilization!]! } +type NetworkInterfaceUtilization { iface: String rxBytesPerSec: Float txBytesPerSec: Float } +``` + +> Exact nullability/wrapping must be copied from upstream, not guessed — the +> `GetNotificationList` trap (vendored subset diverged from live) is the cautionary +> precedent. + +**New `subscriptions.graphql`** (read-only; `mutations.graphql` untouched → +maintainer-only mutation convention preserved): + +```graphql +subscription SystemMetricsNetwork { + systemMetricsNetwork { interfaces { iface rxBytesPerSec txBytesPerSec } } +} +``` + +**`Models.kt`**: `data class NetworkThroughput(val rxBytesPerSec: Double, val txBytesPerSec: Double)`. + +**Mapper** (`GraphQlMapper.kt`): `toNetworkThroughput(primaryIface: String?): NetworkThroughput`. + +## 5. Formatting + +Pure helper `formatBytesPerSec(bytesPerSec: Double): String`: + +- Adaptive: `B/s` → `KB/s` → `MB/s` → `GB/s`, **decimal (1000-based)**, 1 decimal place. +- Examples: `0 → "0 B/s"`, `3300 → "3.3 KB/s"`, `12_000_000 → "12.0 MB/s"`. + +## 6. Testing (Rule 9 — encode intent, not just behaviour) + +Pure unit tests (no live WS needed): + +- `formatBytesPerSec`: unit boundaries (999→B, 1000→KB, …), rounding, zero. +- `toNetworkThroughput`: primary-iface match; fallback to first non-`lo` when name + null/absent; `lo` never chosen as primary fallback. +- `DomainState` mapping for the subscription path (Content/Error transitions). +- `subscriptionStreamForTest` internal seam (mirrors `pollDomainForTest`) — drives the + mapping + fallback decision with a caller-scripted flow so the *production* logic is + pinned, not a copy. + +Live WS delivery is **the maintainer's on-device acceptance gate** — the whole point of the +balloon. Already proven to deliver via the read-only WS smoke (python `websockets` client). +CI remains the build/test authority (no local Android toolchain). + +## 7. ADR + +New **ADR-0042 "GraphQL subscription transport — network throughput pilot"**: + +- Supersedes ADR-0026's deprecation for this scoped slice. +- Records: the live evidence (rates deliver, ~1 Hz, near-instant first event); the + **no-fallback** decision for network; the `overviewOnly()` lifecycle gating; the + self-signed-pin reuse; that the `graphql-smoke` skill is HTTP-only and cannot cover + subscriptions (WS validation = python client / on-device). +- Explicitly keeps **metrics, docker stats, array** out of scope for this beta. +- ADR-0026 status updated → "Superseded by ADR-0042 (network pilot); other domains still + pending." + +## 8. Release + +- **Beta-first** (ADR-0006): ships as `0.1.40-beta1`, `versionCode` 107 → 108. Never + direct-to-stable; stable promotion stays the maintainer's on-device gate (ADR-0027). +- **CHANGELOG** (ADR-0031, plain language, under `## [Unreleased]` → renamed by the + version-bump PR): *"The Network card on the dashboard now shows live upload/download + speed in real time."* + +## 9. Out of scope + +- Metrics (CPU/Mem/Temp), `dockerContainerStats`, `arraySubscription` — later betas / + separate specs. +- Per-interface UI, historical persistence, the static Network-interfaces screen + (unchanged). +- Mutations over WS (none; maintainer-only convention unaffected). + +## 10. Execution (CLAUDE.md Rule 14) + +Once this spec is approved, the entire execution tail is delegated to sub-agents — code +fan-out (transport, substrate, consumer, model/mapper, formatter, UI wiring, tests), +schema, ADR-0042, version bump, CHANGELOG, branch/commit/PR, CI watch, beta tag — with the +`release-engineer` subagent owning the release tail. The maintainer's stable-promotion gate +is the only human-only step. Main thread stays overwatch/verify.