Implemented VPN/DNS resilience

This commit is contained in:
2026-05-11 17:04:04 +03:30
parent 7b31e14eeb
commit 91f8eda94a
12 changed files with 557 additions and 54 deletions

View File

@@ -56,6 +56,7 @@ dependencies {
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.okhttp)
testImplementation(libs.junit)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)

View File

@@ -41,12 +41,14 @@ class MonitorController(context: Context) {
val summaries = repository.latestSummaries(servers.map { it.id })
val history = selected?.let { mapOf(it to repository.history(it)) }.orEmpty()
val alerts = repository.alerts()
val routeDiagnostics = repository.routeDiagnostics()
_state.update {
it.copy(
servers = servers,
selectedServerId = selected,
summaries = summaries,
history = history,
routeDiagnostics = routeDiagnostics,
alerts = alerts,
globalMessage = null,
)
@@ -140,10 +142,12 @@ class MonitorController(context: Context) {
val servers = repository.servers()
val summaries = repository.latestSummaries(servers.map { it.id })
val history = repository.history(server.id)
val routeDiagnostics = repository.routeDiagnostics()
_state.update {
it.copy(
servers = servers,
summaries = summaries,
routeDiagnostics = routeDiagnostics,
selectedServerId = server.id,
showDetail = true,
selectedTab = DetailTab.Overview,
@@ -191,6 +195,7 @@ class MonitorController(context: Context) {
selectedTab = DetailTab.Overview,
summaries = repository.latestSummaries(servers.map { item -> item.id }),
history = selected?.let { id -> mapOf(id to repository.history(id)) }.orEmpty(),
routeDiagnostics = repository.routeDiagnostics(),
alerts = repository.alerts(),
globalMessage = "Server deleted",
)
@@ -233,10 +238,12 @@ class MonitorController(context: Context) {
val selected = _state.value.selectedServerId
val history = selected?.let { repository.history(it) }
val alerts = repository.alerts()
val routeDiagnostics = repository.routeDiagnostics()
withContext(Dispatchers.Main.immediate) {
_state.update {
it.copy(
alerts = alerts,
routeDiagnostics = routeDiagnostics,
history = if (selected != null && history != null) it.history + (selected to history) else it.history,
)
}

View File

@@ -10,12 +10,16 @@ import net.rodakot.ngxhttpmonitoringclient.model.AlertOverrides
import net.rodakot.ngxhttpmonitoringclient.model.AlertSeverity
import net.rodakot.ngxhttpmonitoringclient.model.HistoryRetentionDays
import net.rodakot.ngxhttpmonitoringclient.model.MetricSummary
import net.rodakot.ngxhttpmonitoringclient.model.NetworkIssue
import net.rodakot.ngxhttpmonitoringclient.model.RouteDiagnostics
import net.rodakot.ngxhttpmonitoringclient.model.RouteKind
import net.rodakot.ngxhttpmonitoringclient.model.RoutePolicy
import net.rodakot.ngxhttpmonitoringclient.model.ServerProfile
import net.rodakot.ngxhttpmonitoringclient.model.ServerStatus
import org.json.JSONArray
import java.util.concurrent.TimeUnit
class MonitorDatabase(context: Context) : SQLiteOpenHelper(context, "ngx_monitor.db", null, 1) {
class MonitorDatabase(context: Context) : SQLiteOpenHelper(context, "ngx_monitor.db", null, 2) {
override fun onCreate(db: SQLiteDatabase) {
db.execSQL(
"""
@@ -23,6 +27,8 @@ class MonitorDatabase(context: Context) : SQLiteOpenHelper(context, "ngx_monitor
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
base_url TEXT NOT NULL,
fallback_ips_json TEXT NOT NULL,
route_policy TEXT NOT NULL,
tags_json TEXT NOT NULL,
favorite INTEGER NOT NULL,
allow_http INTEGER NOT NULL,
@@ -74,9 +80,16 @@ class MonitorDatabase(context: Context) : SQLiteOpenHelper(context, "ngx_monitor
""".trimIndent(),
)
db.execSQL("CREATE INDEX alerts_server_time ON alerts(server_id, timestamp_millis DESC)")
createRouteDiagnosticsTable(db)
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = Unit
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
if (oldVersion < 2) {
db.execSQL("ALTER TABLE servers ADD COLUMN fallback_ips_json TEXT NOT NULL DEFAULT '[]'")
db.execSQL("ALTER TABLE servers ADD COLUMN route_policy TEXT NOT NULL DEFAULT '${RoutePolicy.AutoFallback.name}'")
createRouteDiagnosticsTable(db)
}
}
@Synchronized
fun listServers(): List<ServerProfile> {
@@ -97,6 +110,7 @@ class MonitorDatabase(context: Context) : SQLiteOpenHelper(context, "ngx_monitor
writableDatabase.delete("servers", "id = ?", arrayOf(serverId))
writableDatabase.delete("samples", "server_id = ?", arrayOf(serverId))
writableDatabase.delete("alerts", "server_id = ?", arrayOf(serverId))
writableDatabase.delete("route_diagnostics", "server_id = ?", arrayOf(serverId))
}
@Synchronized
@@ -190,6 +204,28 @@ class MonitorDatabase(context: Context) : SQLiteOpenHelper(context, "ngx_monitor
return result
}
@Synchronized
fun upsertRouteDiagnostics(diagnostics: RouteDiagnostics) {
writableDatabase.insertWithOnConflict(
"route_diagnostics",
null,
diagnostics.toValues(),
SQLiteDatabase.CONFLICT_REPLACE,
)
}
@Synchronized
fun routeDiagnostics(): Map<String, RouteDiagnostics> {
val result = linkedMapOf<String, RouteDiagnostics>()
readableDatabase.query("route_diagnostics", null, null, null, null, null, "timestamp_millis DESC").use { cursor ->
while (cursor.moveToNext()) {
val diagnostics = cursor.toRouteDiagnostics()
result[diagnostics.serverId] = diagnostics
}
}
return result
}
@Synchronized
fun pruneOldSamples(nowMillis: Long = System.currentTimeMillis()) {
val cutoff = nowMillis - TimeUnit.DAYS.toMillis(HistoryRetentionDays.toLong())
@@ -200,6 +236,8 @@ class MonitorDatabase(context: Context) : SQLiteOpenHelper(context, "ngx_monitor
put("id", id)
put("name", name)
put("base_url", baseUrl)
put("fallback_ips_json", listToJson(fallbackIpAddresses))
put("route_policy", routePolicy.name)
put("tags_json", tagsToJson(tags))
put("favorite", favorite.asInt())
put("allow_http", allowHttp.asInt())
@@ -241,11 +279,25 @@ class MonitorDatabase(context: Context) : SQLiteOpenHelper(context, "ngx_monitor
put("resolved", resolved.asInt())
}
private fun RouteDiagnostics.toValues() = ContentValues().apply {
put("server_id", serverId)
put("timestamp_millis", timestampMillis)
put("active_network", activeNetwork)
put("route_used", routeUsed.name)
put("dns_result", dnsResult)
put("issue", issue.name)
put("vpn_active", vpnActive.asInt())
put("fallback_used", fallbackUsed.asInt())
put("success", success.asInt())
}
private fun Cursor.toServer(): ServerProfile {
return ServerProfile(
id = string("id"),
name = string("name"),
baseUrl = string("base_url"),
fallbackIpAddresses = listFromJson(string("fallback_ips_json")),
routePolicy = runCatching { RoutePolicy.valueOf(string("route_policy")) }.getOrDefault(RoutePolicy.AutoFallback),
tags = tagsFromJson(string("tags_json")),
favorite = int("favorite") == 1,
allowHttp = int("allow_http") == 1,
@@ -294,17 +346,55 @@ class MonitorDatabase(context: Context) : SQLiteOpenHelper(context, "ngx_monitor
)
}
private fun Cursor.toRouteDiagnostics(): RouteDiagnostics {
return RouteDiagnostics(
serverId = string("server_id"),
timestampMillis = long("timestamp_millis"),
activeNetwork = string("active_network"),
routeUsed = runCatching { RouteKind.valueOf(string("route_used")) }.getOrDefault(RouteKind.SystemDns),
dnsResult = string("dns_result"),
issue = runCatching { NetworkIssue.valueOf(string("issue")) }.getOrDefault(NetworkIssue.Unknown),
vpnActive = int("vpn_active") == 1,
fallbackUsed = int("fallback_used") == 1,
success = int("success") == 1,
)
}
private fun tagsToJson(tags: List<String>): String {
return listToJson(tags)
}
private fun listToJson(values: List<String>): String {
val array = JSONArray()
tags.map { it.trim() }.filter { it.isNotBlank() }.distinct().forEach(array::put)
values.map { it.trim() }.filter { it.isNotBlank() }.distinct().forEach(array::put)
return array.toString()
}
private fun tagsFromJson(value: String): List<String> = runCatching {
private fun tagsFromJson(value: String): List<String> = listFromJson(value)
private fun listFromJson(value: String): List<String> = runCatching {
val array = JSONArray(value)
List(array.length()) { index -> array.optString(index) }.filter { it.isNotBlank() }
}.getOrDefault(emptyList())
private fun createRouteDiagnosticsTable(db: SQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS route_diagnostics (
server_id TEXT PRIMARY KEY,
timestamp_millis INTEGER NOT NULL,
active_network TEXT NOT NULL,
route_used TEXT NOT NULL,
dns_result TEXT NOT NULL,
issue TEXT NOT NULL,
vpn_active INTEGER NOT NULL,
fallback_used INTEGER NOT NULL,
success INTEGER NOT NULL
)
""".trimIndent(),
)
}
private fun Boolean.asInt() = if (this) 1 else 0
private fun ContentValues.putNullable(key: String, value: String?) = if (value == null) putNull(key) else put(key, value)

View File

@@ -1,35 +1,70 @@
package net.rodakot.ngxhttpmonitoringclient.data
import android.util.Base64
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import net.rodakot.ngxhttpmonitoringclient.domain.RouteAttemptPlanner
import net.rodakot.ngxhttpmonitoringclient.domain.UrlRules
import net.rodakot.ngxhttpmonitoringclient.model.AuthConfig
import net.rodakot.ngxhttpmonitoringclient.model.NetworkIssue
import net.rodakot.ngxhttpmonitoringclient.model.RouteDiagnostics
import net.rodakot.ngxhttpmonitoringclient.model.RouteKind
import net.rodakot.ngxhttpmonitoringclient.model.ServerProfile
import net.rodakot.ngxhttpmonitoringclient.model.ServerStatus
import okhttp3.Credentials
import okhttp3.Dns
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import java.io.BufferedReader
import java.io.InterruptedIOException
import java.io.IOException
import java.io.InputStreamReader
import java.net.HttpURLConnection
import java.net.URL
import java.net.ConnectException
import java.net.InetAddress
import java.net.NoRouteToHostException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import java.nio.charset.StandardCharsets
import java.util.concurrent.TimeUnit
import javax.net.ssl.SSLException
class MonitorHttpClient {
fun fetchApi(server: ServerProfile, auth: AuthConfig): String {
return fetchText(UrlRules.endpoint(server.baseUrl, "/monitor/api"), auth)
class MonitorHttpClient(context: Context) {
private val connectivityManager = context.getSystemService(ConnectivityManager::class.java)
private val baseClient = OkHttpClient.Builder()
.connectTimeout(8, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.callTimeout(20, TimeUnit.SECONDS)
.build()
fun fetchApi(server: ServerProfile, auth: AuthConfig): RouteResponse {
return fetchText(server, auth, UrlRules.endpoint(server.baseUrl, "/monitor/api"))
}
fun fetchHealth(server: ServerProfile, auth: AuthConfig): String {
return fetchText(UrlRules.endpoint(server.baseUrl, "/monitor/health"), auth)
fun fetchHealth(server: ServerProfile, auth: AuthConfig): RouteResponse {
return fetchText(server, auth, UrlRules.endpoint(server.baseUrl, "/monitor/health"))
}
fun streamLive(server: ServerProfile, auth: AuthConfig, onMetrics: (String) -> Unit) {
val connection = openConnection(UrlRules.endpoint(server.baseUrl, "/monitor/live"), auth).apply {
readTimeout = 0
setRequestProperty("Accept", "text/event-stream")
}
try {
val status = connection.responseCode
if (status !in 200..299) throw MonitorClientException(status, status.toMonitorStatus(), status.messageForStatus())
BufferedReader(InputStreamReader(connection.inputStream, StandardCharsets.UTF_8)).use { reader ->
fun streamLive(
server: ServerProfile,
auth: AuthConfig,
onDiagnostics: (RouteDiagnostics) -> Unit,
onMetrics: (String) -> Unit,
) {
val routed = openRoutedResponse(
server = server,
auth = auth,
url = UrlRules.endpoint(server.baseUrl, "/monitor/live"),
accept = "text/event-stream",
streaming = true,
)
onDiagnostics(routed.diagnostics)
routed.response.use { response ->
if (!response.isSuccessful) {
throw response.toException(routed.diagnostics)
}
BufferedReader(InputStreamReader(response.body!!.byteStream(), StandardCharsets.UTF_8)).use { reader ->
var eventName = "message"
val data = StringBuilder()
while (!Thread.currentThread().isInterrupted) {
@@ -47,41 +82,172 @@ class MonitorHttpClient {
}
}
}
} finally {
connection.disconnect()
}
}
private fun fetchText(url: String, auth: AuthConfig): String {
val connection = openConnection(url, auth)
return try {
val status = connection.responseCode
if (status !in 200..299) throw MonitorClientException(status, status.toMonitorStatus(), status.messageForStatus())
connection.inputStream.bufferedReader(StandardCharsets.UTF_8).use { it.readText() }
} catch (exception: IOException) {
throw MonitorClientException(null, ServerStatus.Offline, exception.message ?: "Network error", exception)
} finally {
connection.disconnect()
private fun fetchText(server: ServerProfile, auth: AuthConfig, url: String): RouteResponse {
val routed = openRoutedResponse(server, auth, url, accept = "application/json", streaming = false)
routed.response.use { response ->
if (!response.isSuccessful) {
throw response.toException(routed.diagnostics)
}
val body = response.body?.string().orEmpty()
return RouteResponse(body, routed.diagnostics)
}
}
private fun openConnection(url: String, auth: AuthConfig): HttpURLConnection {
val connection = URL(url).openConnection() as HttpURLConnection
connection.connectTimeout = 8_000
connection.readTimeout = 15_000
connection.requestMethod = "GET"
connection.setRequestProperty("Accept", "application/json")
private fun openRoutedResponse(
server: ServerProfile,
auth: AuthConfig,
url: String,
accept: String,
streaming: Boolean,
): RoutedResponse {
val state = networkState()
val attempts = RouteAttemptPlanner.attempts(
hasFallbackAliases = server.fallbackIpAddresses.isNotEmpty(),
vpnActive = state.vpnActive,
lanAvailable = state.lanNetwork != null,
)
var lastDiagnostics: RouteDiagnostics? = null
var lastException: IOException? = null
attempts.forEach { kind ->
val diagnostics = diagnosticsFor(server, kind, state)
try {
val response = clientFor(kind, state, server, streaming)
.newCall(request(url, auth, accept))
.execute()
return RoutedResponse(response, diagnostics.copy(success = true, issue = NetworkIssue.None))
} catch (exception: IOException) {
lastDiagnostics = diagnostics.copy(success = false, issue = classify(exception, kind))
lastException = exception
}
}
val diagnostics = lastDiagnostics ?: RouteDiagnostics(
serverId = server.id,
timestampMillis = System.currentTimeMillis(),
activeNetwork = state.activeNetworkLabel,
vpnActive = state.vpnActive,
issue = NetworkIssue.Unknown,
)
throw MonitorClientException(
httpStatus = null,
monitorStatus = ServerStatus.Offline,
message = diagnostics.issue.label,
diagnostics = diagnostics,
cause = lastException,
)
}
private fun clientFor(
kind: RouteKind,
state: NetworkState,
server: ServerProfile,
streaming: Boolean,
): OkHttpClient {
val builder = baseClient.newBuilder()
.readTimeout(if (streaming) 0 else 15, TimeUnit.SECONDS)
.callTimeout(if (streaming) 0 else 20, TimeUnit.SECONDS)
val lanNetwork = state.lanNetwork
val dnsDelegate = when {
kind == RouteKind.LanSystemDns || kind == RouteKind.LanFallbackDns -> lanNetwork?.let(::NetworkDns) ?: Dns.SYSTEM
else -> Dns.SYSTEM
}
if (kind == RouteKind.LanSystemDns || kind == RouteKind.LanFallbackDns) {
lanNetwork?.let { builder.socketFactory(it.socketFactory) }
}
if (kind == RouteKind.FallbackDns || kind == RouteKind.LanFallbackDns) {
builder.dns(FallbackDns(server.hostName(), server.fallbackIpAddresses, dnsDelegate))
} else {
builder.dns(dnsDelegate)
}
return builder.build()
}
private fun request(url: String, auth: AuthConfig, accept: String): Request {
val builder = Request.Builder()
.url(url)
.get()
.header("Accept", accept)
auth.token?.takeIf { it.isNotBlank() }?.let {
connection.setRequestProperty("X-Monitor-Token", it)
builder.header("X-Monitor-Token", it)
}
if (!auth.basicUsername.isNullOrBlank() && !auth.basicPassword.isNullOrBlank()) {
val encoded = Base64.encodeToString(
"${auth.basicUsername}:${auth.basicPassword}".toByteArray(StandardCharsets.UTF_8),
Base64.NO_WRAP,
)
connection.setRequestProperty("Authorization", "Basic $encoded")
builder.header("Authorization", Credentials.basic(auth.basicUsername, auth.basicPassword))
}
return connection
return builder.build()
}
private fun diagnosticsFor(server: ServerProfile, kind: RouteKind, state: NetworkState): RouteDiagnostics {
val fallbackUsed = kind == RouteKind.FallbackDns || kind == RouteKind.LanFallbackDns
val dnsResult = when (kind) {
RouteKind.SystemDns -> "System DNS"
RouteKind.FallbackDns -> server.fallbackIpAddresses.joinToString(", ")
RouteKind.LanSystemDns -> "LAN DNS"
RouteKind.LanFallbackDns -> server.fallbackIpAddresses.joinToString(", ")
}
return RouteDiagnostics(
serverId = server.id,
timestampMillis = System.currentTimeMillis(),
activeNetwork = state.activeNetworkLabel,
routeUsed = kind,
dnsResult = dnsResult.ifBlank { "None" },
issue = if (state.vpnActive) NetworkIssue.VpnActive else NetworkIssue.None,
vpnActive = state.vpnActive,
fallbackUsed = fallbackUsed,
success = false,
)
}
private fun networkState(): NetworkState {
val networks = connectivityManager?.allNetworks.orEmpty()
val activeLabel = connectivityManager?.activeNetwork
?.let { network -> connectivityManager.getNetworkCapabilities(network)?.transportLabel() }
?: "No active network"
val vpnActive = networks.any { network ->
connectivityManager?.getNetworkCapabilities(network)?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true
}
val lanNetwork = networks.firstOrNull { network ->
val capabilities = connectivityManager?.getNetworkCapabilities(network)
capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true ||
capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) == true
}
return NetworkState(activeLabel, vpnActive, lanNetwork)
}
private fun classify(exception: IOException, kind: RouteKind): NetworkIssue {
return when (exception) {
is UnknownHostException -> NetworkIssue.DnsFailure
is SocketTimeoutException -> NetworkIssue.Timeout
is SSLException -> NetworkIssue.TlsFailure
is NoRouteToHostException -> NetworkIssue.LanRouteBlocked
is ConnectException -> if (kind == RouteKind.LanSystemDns || kind == RouteKind.LanFallbackDns) {
NetworkIssue.LanRouteBlocked
} else {
NetworkIssue.Timeout
}
is InterruptedIOException -> NetworkIssue.Timeout
else -> NetworkIssue.Unknown
}
}
private fun Response.toException(diagnostics: RouteDiagnostics): MonitorClientException {
val issue = code.toNetworkIssue()
return MonitorClientException(
httpStatus = code,
monitorStatus = code.toMonitorStatus(),
message = code.messageForStatus(),
diagnostics = diagnostics.copy(success = false, issue = issue),
)
}
private fun Int.toNetworkIssue(): NetworkIssue = when (this) {
401 -> NetworkIssue.AuthFailure
429 -> NetworkIssue.RateLimited
else -> NetworkIssue.ApiFailure
}
private fun Int.toMonitorStatus(): ServerStatus = when (this) {
@@ -99,11 +265,58 @@ class MonitorHttpClient {
429 -> "Monitor rate limit was exceeded"
else -> "HTTP $this from monitor endpoint"
}
private fun NetworkCapabilities.transportLabel(): String {
return buildList {
if (hasTransport(NetworkCapabilities.TRANSPORT_VPN)) add("VPN")
if (hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) add("Wi-Fi")
if (hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) add("Ethernet")
if (hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) add("Cellular")
}.ifEmpty { listOf("Unknown") }.joinToString(" + ")
}
private fun ServerProfile.hostName(): String = java.net.URI(baseUrl).host.orEmpty()
}
data class RouteResponse(
val body: String,
val diagnostics: RouteDiagnostics,
)
private data class RoutedResponse(
val response: Response,
val diagnostics: RouteDiagnostics,
)
private data class NetworkState(
val activeNetworkLabel: String,
val vpnActive: Boolean,
val lanNetwork: Network?,
)
private class FallbackDns(
private val hostName: String,
private val fallbackIps: List<String>,
private val delegate: Dns,
) : Dns {
override fun lookup(hostname: String): List<InetAddress> {
if (hostname.equals(hostName, ignoreCase = true) && fallbackIps.isNotEmpty()) {
return fallbackIps.map { InetAddress.getByName(it) }
}
return delegate.lookup(hostname)
}
}
private class NetworkDns(private val network: Network) : Dns {
override fun lookup(hostname: String): List<InetAddress> {
return network.getAllByName(hostname).toList()
}
}
class MonitorClientException(
val httpStatus: Int?,
val monitorStatus: ServerStatus,
override val message: String,
val diagnostics: RouteDiagnostics,
cause: Throwable? = null,
) : Exception(message, cause)

View File

@@ -18,7 +18,7 @@ import org.json.JSONException
class MonitorRepository(context: Context) {
private val database = MonitorDatabase(context.applicationContext)
private val cipher = CredentialCipher()
private val client = MonitorHttpClient()
private val client = MonitorHttpClient(context.applicationContext)
private val alertCooldownMillis = TimeUnit.MINUTES.toMillis(30)
fun servers(): List<ServerProfile> = database.listServers()
@@ -27,6 +27,8 @@ class MonitorRepository(context: Context) {
fun history(serverId: String): List<MetricSummary> = database.samplesForServer(serverId)
fun routeDiagnostics(): Map<String, net.rodakot.ngxhttpmonitoringclient.model.RouteDiagnostics> = database.routeDiagnostics()
fun alerts(): List<AlertEvent> = database.alerts()
fun draftFor(server: ServerProfile?): ServerEditorDraft {
@@ -35,6 +37,7 @@ class MonitorRepository(context: Context) {
id = server.id,
name = server.name,
baseUrl = server.baseUrl,
fallbackIpAddresses = server.fallbackIpAddresses.joinToString(", "),
tags = server.tags.joinToString(", "),
favorite = server.favorite,
allowHttp = server.allowHttp,
@@ -63,6 +66,7 @@ class MonitorRepository(context: Context) {
id = draft.id ?: UUID.randomUUID().toString(),
name = draft.name.trim().ifBlank { baseUrl },
baseUrl = baseUrl,
fallbackIpAddresses = UrlRules.parseFallbackIps(draft.fallbackIpAddresses),
tags = draft.tags.split(',').map { it.trim() }.filter { it.isNotBlank() }.distinct(),
favorite = draft.favorite,
allowHttp = draft.allowHttp,
@@ -91,8 +95,9 @@ class MonitorRepository(context: Context) {
fun testConnection(draft: ServerEditorDraft): Result<String> = runCatching {
val server = buildServer(draft.copy(id = draft.id ?: "test"))
client.fetchHealth(server, authFor(server))
"Connection works"
val response = client.fetchHealth(server, authFor(server))
database.upsertRouteDiagnostics(response.diagnostics.copy(serverId = server.id))
"Connection works through ${response.diagnostics.routeUsed.label}"
}
fun fetchApiSummary(server: ServerProfile, forcePersist: Boolean = false): MetricSummary {
@@ -101,17 +106,19 @@ class MonitorRepository(context: Context) {
fun refreshServer(server: ServerProfile, forcePersist: Boolean = false): RefreshResult {
return try {
val payload = client.fetchApi(server, authFor(server))
val parsed = MonitorJsonParser.parse(server.id, payload)
val response = client.fetchApi(server, authFor(server))
database.upsertRouteDiagnostics(response.diagnostics)
val parsed = MonitorJsonParser.parse(server.id, response.body)
val events = AlertEvaluator.evaluate(server, parsed)
val status = if (events.isEmpty()) ServerStatus.Online else ServerStatus.Degraded
val summary = parsed.copy(
status = status,
message = if (status == ServerStatus.Online) "Live" else "Threshold breached",
)
persistSample(server, summary, payload, forcePersist)
persistSample(server, summary, response.body, forcePersist)
RefreshResult(summary, recordAlerts(server, summary))
} catch (exception: MonitorClientException) {
database.upsertRouteDiagnostics(exception.diagnostics)
val summary = MetricSummary(
serverId = server.id,
timestampMillis = System.currentTimeMillis(),
@@ -142,7 +149,11 @@ class MonitorRepository(context: Context) {
}
fun streamLive(server: ServerProfile, onSummary: (MetricSummary) -> Unit) {
client.streamLive(server, authFor(server)) { payload ->
client.streamLive(
server = server,
auth = authFor(server),
onDiagnostics = database::upsertRouteDiagnostics,
) { payload ->
val parsed = MonitorJsonParser.parse(server.id, payload)
val events = AlertEvaluator.evaluate(server, parsed)
val status = if (events.isEmpty()) ServerStatus.Online else ServerStatus.Degraded

View File

@@ -0,0 +1,19 @@
package net.rodakot.ngxhttpmonitoringclient.domain
import net.rodakot.ngxhttpmonitoringclient.model.RouteKind
object RouteAttemptPlanner {
fun attempts(
hasFallbackAliases: Boolean,
vpnActive: Boolean,
lanAvailable: Boolean,
): List<RouteKind> {
val result = mutableListOf(RouteKind.SystemDns)
if (hasFallbackAliases) result += RouteKind.FallbackDns
if (vpnActive && lanAvailable) {
result += RouteKind.LanSystemDns
if (hasFallbackAliases) result += RouteKind.LanFallbackDns
}
return result.distinct()
}
}

View File

@@ -1,6 +1,7 @@
package net.rodakot.ngxhttpmonitoringclient.domain
import java.net.URI
import java.net.InetAddress
object UrlRules {
fun normalizeBaseUrl(input: String): String {
@@ -30,4 +31,16 @@ object UrlRules {
val normalizedPath = if (path.startsWith("/")) path else "/$path"
return normalizeBaseUrl(baseUrl) + normalizedPath
}
fun parseFallbackIps(input: String): List<String> {
return input
.split(',', '\n', ' ')
.map { it.trim() }
.filter { it.isNotBlank() }
.distinct()
.onEach { value ->
runCatching { InetAddress.getByName(value) }
.getOrElse { throw IllegalArgumentException("Invalid fallback IP: $value") }
}
}
}

View File

@@ -13,6 +13,8 @@ data class ServerProfile(
val id: String,
val name: String,
val baseUrl: String,
val fallbackIpAddresses: List<String> = emptyList(),
val routePolicy: RoutePolicy = RoutePolicy.AutoFallback,
val tags: List<String> = emptyList(),
val favorite: Boolean = false,
val allowHttp: Boolean = false,
@@ -30,6 +32,49 @@ data class AuthConfig(
val basicPassword: String? = null,
)
enum class RoutePolicy {
AutoFallback,
}
enum class RouteKind(val label: String) {
SystemDns("System DNS"),
FallbackDns("Fallback DNS"),
LanSystemDns("LAN DNS"),
LanFallbackDns("LAN fallback"),
}
enum class NetworkIssue(val label: String) {
None("OK"),
DnsFailure("DNS failure"),
VpnActive("VPN active"),
LanRouteBlocked("LAN route blocked"),
Timeout("Timeout"),
TlsFailure("TLS failure"),
AuthFailure("Authentication failed"),
ApiFailure("API failure"),
RateLimited("Rate limited"),
Unknown("Network error"),
}
data class RouteDiagnostics(
val serverId: String,
val timestampMillis: Long,
val activeNetwork: String = "Unknown",
val routeUsed: RouteKind = RouteKind.SystemDns,
val dnsResult: String = "System DNS",
val issue: NetworkIssue = NetworkIssue.None,
val vpnActive: Boolean = false,
val fallbackUsed: Boolean = false,
val success: Boolean = false,
) {
val summary: String
get() = if (success) {
"Connected through ${routeUsed.label}"
} else {
issue.label
}
}
data class AlertOverrides(
val cpuPercent: Double? = null,
val memoryPercent: Double? = null,
@@ -87,6 +132,7 @@ data class ServerEditorDraft(
val id: String? = null,
val name: String = "",
val baseUrl: String = "",
val fallbackIpAddresses: String = "",
val tags: String = "",
val favorite: Boolean = false,
val allowHttp: Boolean = false,
@@ -118,6 +164,7 @@ data class MonitorUiState(
val servers: List<ServerProfile> = emptyList(),
val summaries: Map<String, MetricSummary> = emptyMap(),
val history: Map<String, List<MetricSummary>> = emptyMap(),
val routeDiagnostics: Map<String, RouteDiagnostics> = emptyMap(),
val alerts: List<AlertEvent> = emptyList(),
val selectedServerId: String? = null,
val showDetail: Boolean = false,

View File

@@ -71,6 +71,8 @@ import net.rodakot.ngxhttpmonitoringclient.model.DefaultMemoryThreshold
import net.rodakot.ngxhttpmonitoringclient.model.DetailTab
import net.rodakot.ngxhttpmonitoringclient.model.MetricSummary
import net.rodakot.ngxhttpmonitoringclient.model.MonitorUiState
import net.rodakot.ngxhttpmonitoringclient.model.NetworkIssue
import net.rodakot.ngxhttpmonitoringclient.model.RouteDiagnostics
import net.rodakot.ngxhttpmonitoringclient.model.ServerEditorDraft
import net.rodakot.ngxhttpmonitoringclient.model.ServerProfile
import net.rodakot.ngxhttpmonitoringclient.model.ServerStatus
@@ -204,6 +206,7 @@ private fun FleetCommandDeck(
ServerCommandRow(
server = server,
summary = state.summaries[server.id],
diagnostics = state.routeDiagnostics[server.id],
onClick = { controller.selectServer(server.id) },
)
}
@@ -346,7 +349,12 @@ private fun CommandFilters(
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun ServerCommandRow(server: ServerProfile, summary: MetricSummary?, onClick: () -> Unit) {
private fun ServerCommandRow(
server: ServerProfile,
summary: MetricSummary?,
diagnostics: RouteDiagnostics?,
onClick: () -> Unit,
) {
val status = summary?.status ?: ServerStatus.Unknown
val health = healthScore(summary)
Surface(
@@ -393,6 +401,7 @@ private fun ServerCommandRow(server: ServerProfile, summary: MetricSummary?, onC
LabelCapsule(statusLabel(status).uppercase(), colorForStatus(status))
if (!server.enabled) LabelCapsule("OFF", StatusUnknown)
if (server.allowHttp) LabelCapsule("HTTP", StatusInsecure)
RouteBadges(diagnostics)
server.tags.take(3).forEach { LabelCapsule(it.uppercase(), MaterialTheme.colorScheme.secondary) }
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
@@ -423,6 +432,9 @@ private fun ServerCockpit(
item {
CockpitHero(server, summary, controller)
}
item {
RoutePanel(state.routeDiagnostics[server.id])
}
item {
CockpitTabs(selected = state.selectedTab, onSelect = controller::setDetailTab)
}
@@ -477,6 +489,25 @@ private fun CockpitHero(server: ServerProfile, summary: MetricSummary?, controll
}
}
@Composable
private fun RoutePanel(diagnostics: RouteDiagnostics?) {
DataPanel("Route") {
if (diagnostics == null) {
Text("No route check has completed yet.", color = MaterialTheme.colorScheme.onSurfaceVariant)
} else {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
RuleRow("Network", diagnostics.activeNetwork)
RuleRow("Route", diagnostics.routeUsed.label)
RuleRow("DNS", diagnostics.dnsResult)
RuleRow("VPN", if (diagnostics.vpnActive) "Active" else "Inactive")
RuleRow("Fallback", if (diagnostics.fallbackUsed) "Used" else "Not used")
RuleRow("Last result", diagnostics.summary)
RuleRow("Checked", diagnostics.timestampMillis.formatTime())
}
}
}
}
@Composable
private fun CockpitTabs(selected: DetailTab, onSelect: (DetailTab) -> Unit) {
Row(
@@ -563,6 +594,7 @@ private fun SettingsCockpit(server: ServerProfile, controller: MonitorController
RuleRow("Disk limit", "${server.alertOverrides.diskPercent ?: DefaultDiskThreshold}%")
RuleRow("p95 latency", "${(server.alertOverrides.latencyP95Millis ?: DefaultLatencyP95ThresholdMs).toInt()} ms")
RuleRow("5xx limit", "${server.alertOverrides.errors5xx ?: DefaultErrors5xxThreshold}")
RuleRow("Fallback IPs", server.fallbackIpAddresses.ifEmpty { listOf("None") }.joinToString(", "))
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Button(onClick = controller::showEditSelectedServer, shape = RoundedCornerShape(8.dp)) { Text("Edit") }
OutlinedButton(onClick = controller::deleteSelectedServer, shape = RoundedCornerShape(8.dp)) { Text("Delete") }
@@ -665,6 +697,16 @@ private fun LabelCapsule(text: String, color: Color) {
}
}
@Composable
private fun RouteBadges(diagnostics: RouteDiagnostics?) {
if (diagnostics == null) return
if (diagnostics.vpnActive) LabelCapsule("VPN", StatusWarning)
if (diagnostics.fallbackUsed) LabelCapsule("LAN FALLBACK", StatusOnline)
if (!diagnostics.success && diagnostics.issue != NetworkIssue.None) {
LabelCapsule(diagnostics.issue.label.uppercase(), routeIssueColor(diagnostics.issue))
}
}
@Composable
private fun HealthBadge(score: Int, status: ServerStatus) {
val color = colorForStatus(status)
@@ -834,6 +876,7 @@ private fun ServerEditorDialog(
}
item { EditorField("Display name", draft.name) { value -> onChange { it.copy(name = value) } } }
item { EditorField("Base URL", draft.baseUrl) { value -> onChange { it.copy(baseUrl = value) } } }
item { EditorField("Fallback LAN IPs", draft.fallbackIpAddresses) { value -> onChange { it.copy(fallbackIpAddresses = value) } } }
item { EditorField("Tags", draft.tags) { value -> onChange { it.copy(tags = value) } } }
item {
FlowRow(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -1005,6 +1048,19 @@ private fun colorForPercent(value: Double?): Color = when {
else -> StatusOnline
}
private fun routeIssueColor(issue: NetworkIssue): Color = when (issue) {
NetworkIssue.None -> StatusOnline
NetworkIssue.DnsFailure -> StatusInsecure
NetworkIssue.VpnActive -> StatusWarning
NetworkIssue.LanRouteBlocked -> StatusWarning
NetworkIssue.Timeout -> StatusWarning
NetworkIssue.TlsFailure -> StatusCritical
NetworkIssue.AuthFailure -> StatusCritical
NetworkIssue.ApiFailure -> StatusCritical
NetworkIssue.RateLimited -> StatusWarning
NetworkIssue.Unknown -> StatusUnknown
}
private fun Double?.formatPercent(): String = this?.let { "${it.toInt()}%" } ?: "--"
private fun Double?.formatMillis(): String = this?.let { "${it.toInt()} ms" } ?: "--"
private fun Double?.formatNumber(): String = this?.let { if (it >= 10) it.toInt().toString() else "%.1f".format(it) } ?: "--"

View File

@@ -0,0 +1,36 @@
package net.rodakot.ngxhttpmonitoringclient.domain
import net.rodakot.ngxhttpmonitoringclient.model.RouteKind
import org.junit.Assert.assertEquals
import org.junit.Test
class RouteAttemptPlannerTest {
@Test
fun attempts_usesSystemDnsWhenNoFallbacksOrVpn() {
assertEquals(
listOf(RouteKind.SystemDns),
RouteAttemptPlanner.attempts(hasFallbackAliases = false, vpnActive = false, lanAvailable = false),
)
}
@Test
fun attempts_addsFallbackDnsWhenAliasesExist() {
assertEquals(
listOf(RouteKind.SystemDns, RouteKind.FallbackDns),
RouteAttemptPlanner.attempts(hasFallbackAliases = true, vpnActive = false, lanAvailable = false),
)
}
@Test
fun attempts_addsLanRoutesWhenVpnAndLanAreAvailable() {
assertEquals(
listOf(
RouteKind.SystemDns,
RouteKind.FallbackDns,
RouteKind.LanSystemDns,
RouteKind.LanFallbackDns,
),
RouteAttemptPlanner.attempts(hasFallbackAliases = true, vpnActive = true, lanAvailable = true),
)
}
}

View File

@@ -24,4 +24,12 @@ class UrlRulesTest {
UrlRules.endpoint("https://monitor.example.com/", "/monitor/api"),
)
}
@Test
fun parseFallbackIps_acceptsCommaAndWhitespaceSeparatedValues() {
assertEquals(
listOf("192.168.1.20", "10.0.0.5"),
UrlRules.parseFallbackIps("192.168.1.20, 10.0.0.5\n192.168.1.20"),
)
}
}

View File

@@ -9,6 +9,7 @@ lifecycleRuntimeKtx = "2.10.0"
activityCompose = "1.13.0"
kotlin = "2.2.10"
composeBom = "2026.02.01"
okhttp = "4.12.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -28,6 +29,7 @@ androidx-compose-material3 = { group = "androidx.compose.material3", name = "mat
androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" }
androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycleRuntimeKtx" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }