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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,6 @@ captures/

# Claude Code: share project settings, keep personal overrides local
.claude/settings.local.json

# Superpowers brainstorm artifacts
.superpowers/
19 changes: 19 additions & 0 deletions app/src/main/graphql/io/github/nofuturekid/nova/schema.graphqls
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ scalar URL
schema {
query: Query
mutation: Mutation
subscription: Subscription
}

type Query {
Expand Down Expand Up @@ -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
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
subscription SystemMetricsNetwork {
systemMetricsNetwork {
interfaces {
iface
rxBytesPerSec
txBytesPerSec
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.).
Expand All @@ -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() {
Expand Down Expand Up @@ -68,6 +77,10 @@ class ApolloClientFactory @Inject constructor() {
*/
private val clients = ConcurrentHashMap<String, ApolloClient>()

/** WS subscription clients, keyed on (endpoint, apiKey, trust). Same
* lifecycle ownership as [clients] — factory owns, callers don't close. */
private val wsClients = ConcurrentHashMap<String, ApolloClient>()

/** 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)
Expand All @@ -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,
Expand All @@ -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)
Expand All @@ -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 }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -277,6 +279,17 @@ fun GetNetworkInterfacesQuery.Data.toNetworkInterfaces(): List<NetworkInterface>
}
}

// ── Network throughput subscription ──────────────────────────────────

fun SystemMetricsNetworkSubscription.Data.toIfaceSamples(): List<IfaceSample> =
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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<IfaceSample>, 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
}
Loading
Loading