Implemented VPN/DNS resilience
This commit is contained in:
@@ -56,6 +56,7 @@ dependencies {
|
|||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
implementation(libs.androidx.lifecycle.viewmodel.ktx)
|
implementation(libs.androidx.lifecycle.viewmodel.ktx)
|
||||||
implementation(libs.kotlinx.coroutines.android)
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
|
implementation(libs.okhttp)
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||||
|
|||||||
@@ -41,12 +41,14 @@ class MonitorController(context: Context) {
|
|||||||
val summaries = repository.latestSummaries(servers.map { it.id })
|
val summaries = repository.latestSummaries(servers.map { it.id })
|
||||||
val history = selected?.let { mapOf(it to repository.history(it)) }.orEmpty()
|
val history = selected?.let { mapOf(it to repository.history(it)) }.orEmpty()
|
||||||
val alerts = repository.alerts()
|
val alerts = repository.alerts()
|
||||||
|
val routeDiagnostics = repository.routeDiagnostics()
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
servers = servers,
|
servers = servers,
|
||||||
selectedServerId = selected,
|
selectedServerId = selected,
|
||||||
summaries = summaries,
|
summaries = summaries,
|
||||||
history = history,
|
history = history,
|
||||||
|
routeDiagnostics = routeDiagnostics,
|
||||||
alerts = alerts,
|
alerts = alerts,
|
||||||
globalMessage = null,
|
globalMessage = null,
|
||||||
)
|
)
|
||||||
@@ -140,10 +142,12 @@ class MonitorController(context: Context) {
|
|||||||
val servers = repository.servers()
|
val servers = repository.servers()
|
||||||
val summaries = repository.latestSummaries(servers.map { it.id })
|
val summaries = repository.latestSummaries(servers.map { it.id })
|
||||||
val history = repository.history(server.id)
|
val history = repository.history(server.id)
|
||||||
|
val routeDiagnostics = repository.routeDiagnostics()
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
servers = servers,
|
servers = servers,
|
||||||
summaries = summaries,
|
summaries = summaries,
|
||||||
|
routeDiagnostics = routeDiagnostics,
|
||||||
selectedServerId = server.id,
|
selectedServerId = server.id,
|
||||||
showDetail = true,
|
showDetail = true,
|
||||||
selectedTab = DetailTab.Overview,
|
selectedTab = DetailTab.Overview,
|
||||||
@@ -191,6 +195,7 @@ class MonitorController(context: Context) {
|
|||||||
selectedTab = DetailTab.Overview,
|
selectedTab = DetailTab.Overview,
|
||||||
summaries = repository.latestSummaries(servers.map { item -> item.id }),
|
summaries = repository.latestSummaries(servers.map { item -> item.id }),
|
||||||
history = selected?.let { id -> mapOf(id to repository.history(id)) }.orEmpty(),
|
history = selected?.let { id -> mapOf(id to repository.history(id)) }.orEmpty(),
|
||||||
|
routeDiagnostics = repository.routeDiagnostics(),
|
||||||
alerts = repository.alerts(),
|
alerts = repository.alerts(),
|
||||||
globalMessage = "Server deleted",
|
globalMessage = "Server deleted",
|
||||||
)
|
)
|
||||||
@@ -233,10 +238,12 @@ class MonitorController(context: Context) {
|
|||||||
val selected = _state.value.selectedServerId
|
val selected = _state.value.selectedServerId
|
||||||
val history = selected?.let { repository.history(it) }
|
val history = selected?.let { repository.history(it) }
|
||||||
val alerts = repository.alerts()
|
val alerts = repository.alerts()
|
||||||
|
val routeDiagnostics = repository.routeDiagnostics()
|
||||||
withContext(Dispatchers.Main.immediate) {
|
withContext(Dispatchers.Main.immediate) {
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
alerts = alerts,
|
alerts = alerts,
|
||||||
|
routeDiagnostics = routeDiagnostics,
|
||||||
history = if (selected != null && history != null) it.history + (selected to history) else it.history,
|
history = if (selected != null && history != null) it.history + (selected to history) else it.history,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,12 +10,16 @@ import net.rodakot.ngxhttpmonitoringclient.model.AlertOverrides
|
|||||||
import net.rodakot.ngxhttpmonitoringclient.model.AlertSeverity
|
import net.rodakot.ngxhttpmonitoringclient.model.AlertSeverity
|
||||||
import net.rodakot.ngxhttpmonitoringclient.model.HistoryRetentionDays
|
import net.rodakot.ngxhttpmonitoringclient.model.HistoryRetentionDays
|
||||||
import net.rodakot.ngxhttpmonitoringclient.model.MetricSummary
|
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.ServerProfile
|
||||||
import net.rodakot.ngxhttpmonitoringclient.model.ServerStatus
|
import net.rodakot.ngxhttpmonitoringclient.model.ServerStatus
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import java.util.concurrent.TimeUnit
|
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) {
|
override fun onCreate(db: SQLiteDatabase) {
|
||||||
db.execSQL(
|
db.execSQL(
|
||||||
"""
|
"""
|
||||||
@@ -23,6 +27,8 @@ class MonitorDatabase(context: Context) : SQLiteOpenHelper(context, "ngx_monitor
|
|||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
base_url TEXT NOT NULL,
|
base_url TEXT NOT NULL,
|
||||||
|
fallback_ips_json TEXT NOT NULL,
|
||||||
|
route_policy TEXT NOT NULL,
|
||||||
tags_json TEXT NOT NULL,
|
tags_json TEXT NOT NULL,
|
||||||
favorite INTEGER NOT NULL,
|
favorite INTEGER NOT NULL,
|
||||||
allow_http INTEGER NOT NULL,
|
allow_http INTEGER NOT NULL,
|
||||||
@@ -74,9 +80,16 @@ class MonitorDatabase(context: Context) : SQLiteOpenHelper(context, "ngx_monitor
|
|||||||
""".trimIndent(),
|
""".trimIndent(),
|
||||||
)
|
)
|
||||||
db.execSQL("CREATE INDEX alerts_server_time ON alerts(server_id, timestamp_millis DESC)")
|
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
|
@Synchronized
|
||||||
fun listServers(): List<ServerProfile> {
|
fun listServers(): List<ServerProfile> {
|
||||||
@@ -97,6 +110,7 @@ class MonitorDatabase(context: Context) : SQLiteOpenHelper(context, "ngx_monitor
|
|||||||
writableDatabase.delete("servers", "id = ?", arrayOf(serverId))
|
writableDatabase.delete("servers", "id = ?", arrayOf(serverId))
|
||||||
writableDatabase.delete("samples", "server_id = ?", arrayOf(serverId))
|
writableDatabase.delete("samples", "server_id = ?", arrayOf(serverId))
|
||||||
writableDatabase.delete("alerts", "server_id = ?", arrayOf(serverId))
|
writableDatabase.delete("alerts", "server_id = ?", arrayOf(serverId))
|
||||||
|
writableDatabase.delete("route_diagnostics", "server_id = ?", arrayOf(serverId))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
@@ -190,6 +204,28 @@ class MonitorDatabase(context: Context) : SQLiteOpenHelper(context, "ngx_monitor
|
|||||||
return result
|
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
|
@Synchronized
|
||||||
fun pruneOldSamples(nowMillis: Long = System.currentTimeMillis()) {
|
fun pruneOldSamples(nowMillis: Long = System.currentTimeMillis()) {
|
||||||
val cutoff = nowMillis - TimeUnit.DAYS.toMillis(HistoryRetentionDays.toLong())
|
val cutoff = nowMillis - TimeUnit.DAYS.toMillis(HistoryRetentionDays.toLong())
|
||||||
@@ -200,6 +236,8 @@ class MonitorDatabase(context: Context) : SQLiteOpenHelper(context, "ngx_monitor
|
|||||||
put("id", id)
|
put("id", id)
|
||||||
put("name", name)
|
put("name", name)
|
||||||
put("base_url", baseUrl)
|
put("base_url", baseUrl)
|
||||||
|
put("fallback_ips_json", listToJson(fallbackIpAddresses))
|
||||||
|
put("route_policy", routePolicy.name)
|
||||||
put("tags_json", tagsToJson(tags))
|
put("tags_json", tagsToJson(tags))
|
||||||
put("favorite", favorite.asInt())
|
put("favorite", favorite.asInt())
|
||||||
put("allow_http", allowHttp.asInt())
|
put("allow_http", allowHttp.asInt())
|
||||||
@@ -241,11 +279,25 @@ class MonitorDatabase(context: Context) : SQLiteOpenHelper(context, "ngx_monitor
|
|||||||
put("resolved", resolved.asInt())
|
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 {
|
private fun Cursor.toServer(): ServerProfile {
|
||||||
return ServerProfile(
|
return ServerProfile(
|
||||||
id = string("id"),
|
id = string("id"),
|
||||||
name = string("name"),
|
name = string("name"),
|
||||||
baseUrl = string("base_url"),
|
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")),
|
tags = tagsFromJson(string("tags_json")),
|
||||||
favorite = int("favorite") == 1,
|
favorite = int("favorite") == 1,
|
||||||
allowHttp = int("allow_http") == 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 {
|
private fun tagsToJson(tags: List<String>): String {
|
||||||
|
return listToJson(tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun listToJson(values: List<String>): String {
|
||||||
val array = JSONArray()
|
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()
|
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)
|
val array = JSONArray(value)
|
||||||
List(array.length()) { index -> array.optString(index) }.filter { it.isNotBlank() }
|
List(array.length()) { index -> array.optString(index) }.filter { it.isNotBlank() }
|
||||||
}.getOrDefault(emptyList())
|
}.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 Boolean.asInt() = if (this) 1 else 0
|
||||||
|
|
||||||
private fun ContentValues.putNullable(key: String, value: String?) = if (value == null) putNull(key) else put(key, value)
|
private fun ContentValues.putNullable(key: String, value: String?) = if (value == null) putNull(key) else put(key, value)
|
||||||
|
|||||||
@@ -1,35 +1,70 @@
|
|||||||
package net.rodakot.ngxhttpmonitoringclient.data
|
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.domain.UrlRules
|
||||||
import net.rodakot.ngxhttpmonitoringclient.model.AuthConfig
|
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.ServerProfile
|
||||||
import net.rodakot.ngxhttpmonitoringclient.model.ServerStatus
|
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.BufferedReader
|
||||||
|
import java.io.InterruptedIOException
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.InputStreamReader
|
import java.io.InputStreamReader
|
||||||
import java.net.HttpURLConnection
|
import java.net.ConnectException
|
||||||
import java.net.URL
|
import java.net.InetAddress
|
||||||
|
import java.net.NoRouteToHostException
|
||||||
|
import java.net.SocketTimeoutException
|
||||||
|
import java.net.UnknownHostException
|
||||||
import java.nio.charset.StandardCharsets
|
import java.nio.charset.StandardCharsets
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import javax.net.ssl.SSLException
|
||||||
|
|
||||||
class MonitorHttpClient {
|
class MonitorHttpClient(context: Context) {
|
||||||
fun fetchApi(server: ServerProfile, auth: AuthConfig): String {
|
private val connectivityManager = context.getSystemService(ConnectivityManager::class.java)
|
||||||
return fetchText(UrlRules.endpoint(server.baseUrl, "/monitor/api"), auth)
|
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 {
|
fun fetchHealth(server: ServerProfile, auth: AuthConfig): RouteResponse {
|
||||||
return fetchText(UrlRules.endpoint(server.baseUrl, "/monitor/health"), auth)
|
return fetchText(server, auth, UrlRules.endpoint(server.baseUrl, "/monitor/health"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun streamLive(server: ServerProfile, auth: AuthConfig, onMetrics: (String) -> Unit) {
|
fun streamLive(
|
||||||
val connection = openConnection(UrlRules.endpoint(server.baseUrl, "/monitor/live"), auth).apply {
|
server: ServerProfile,
|
||||||
readTimeout = 0
|
auth: AuthConfig,
|
||||||
setRequestProperty("Accept", "text/event-stream")
|
onDiagnostics: (RouteDiagnostics) -> Unit,
|
||||||
}
|
onMetrics: (String) -> Unit,
|
||||||
try {
|
) {
|
||||||
val status = connection.responseCode
|
val routed = openRoutedResponse(
|
||||||
if (status !in 200..299) throw MonitorClientException(status, status.toMonitorStatus(), status.messageForStatus())
|
server = server,
|
||||||
BufferedReader(InputStreamReader(connection.inputStream, StandardCharsets.UTF_8)).use { reader ->
|
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"
|
var eventName = "message"
|
||||||
val data = StringBuilder()
|
val data = StringBuilder()
|
||||||
while (!Thread.currentThread().isInterrupted) {
|
while (!Thread.currentThread().isInterrupted) {
|
||||||
@@ -47,41 +82,172 @@ class MonitorHttpClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
connection.disconnect()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun fetchText(url: String, auth: AuthConfig): String {
|
private fun fetchText(server: ServerProfile, auth: AuthConfig, url: String): RouteResponse {
|
||||||
val connection = openConnection(url, auth)
|
val routed = openRoutedResponse(server, auth, url, accept = "application/json", streaming = false)
|
||||||
return try {
|
routed.response.use { response ->
|
||||||
val status = connection.responseCode
|
if (!response.isSuccessful) {
|
||||||
if (status !in 200..299) throw MonitorClientException(status, status.toMonitorStatus(), status.messageForStatus())
|
throw response.toException(routed.diagnostics)
|
||||||
connection.inputStream.bufferedReader(StandardCharsets.UTF_8).use { it.readText() }
|
}
|
||||||
} catch (exception: IOException) {
|
val body = response.body?.string().orEmpty()
|
||||||
throw MonitorClientException(null, ServerStatus.Offline, exception.message ?: "Network error", exception)
|
return RouteResponse(body, routed.diagnostics)
|
||||||
} finally {
|
|
||||||
connection.disconnect()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun openConnection(url: String, auth: AuthConfig): HttpURLConnection {
|
private fun openRoutedResponse(
|
||||||
val connection = URL(url).openConnection() as HttpURLConnection
|
server: ServerProfile,
|
||||||
connection.connectTimeout = 8_000
|
auth: AuthConfig,
|
||||||
connection.readTimeout = 15_000
|
url: String,
|
||||||
connection.requestMethod = "GET"
|
accept: String,
|
||||||
connection.setRequestProperty("Accept", "application/json")
|
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 {
|
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()) {
|
if (!auth.basicUsername.isNullOrBlank() && !auth.basicPassword.isNullOrBlank()) {
|
||||||
val encoded = Base64.encodeToString(
|
builder.header("Authorization", Credentials.basic(auth.basicUsername, auth.basicPassword))
|
||||||
"${auth.basicUsername}:${auth.basicPassword}".toByteArray(StandardCharsets.UTF_8),
|
|
||||||
Base64.NO_WRAP,
|
|
||||||
)
|
|
||||||
connection.setRequestProperty("Authorization", "Basic $encoded")
|
|
||||||
}
|
}
|
||||||
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) {
|
private fun Int.toMonitorStatus(): ServerStatus = when (this) {
|
||||||
@@ -99,11 +265,58 @@ class MonitorHttpClient {
|
|||||||
429 -> "Monitor rate limit was exceeded"
|
429 -> "Monitor rate limit was exceeded"
|
||||||
else -> "HTTP $this from monitor endpoint"
|
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(
|
class MonitorClientException(
|
||||||
val httpStatus: Int?,
|
val httpStatus: Int?,
|
||||||
val monitorStatus: ServerStatus,
|
val monitorStatus: ServerStatus,
|
||||||
override val message: String,
|
override val message: String,
|
||||||
|
val diagnostics: RouteDiagnostics,
|
||||||
cause: Throwable? = null,
|
cause: Throwable? = null,
|
||||||
) : Exception(message, cause)
|
) : Exception(message, cause)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import org.json.JSONException
|
|||||||
class MonitorRepository(context: Context) {
|
class MonitorRepository(context: Context) {
|
||||||
private val database = MonitorDatabase(context.applicationContext)
|
private val database = MonitorDatabase(context.applicationContext)
|
||||||
private val cipher = CredentialCipher()
|
private val cipher = CredentialCipher()
|
||||||
private val client = MonitorHttpClient()
|
private val client = MonitorHttpClient(context.applicationContext)
|
||||||
private val alertCooldownMillis = TimeUnit.MINUTES.toMillis(30)
|
private val alertCooldownMillis = TimeUnit.MINUTES.toMillis(30)
|
||||||
|
|
||||||
fun servers(): List<ServerProfile> = database.listServers()
|
fun servers(): List<ServerProfile> = database.listServers()
|
||||||
@@ -27,6 +27,8 @@ class MonitorRepository(context: Context) {
|
|||||||
|
|
||||||
fun history(serverId: String): List<MetricSummary> = database.samplesForServer(serverId)
|
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 alerts(): List<AlertEvent> = database.alerts()
|
||||||
|
|
||||||
fun draftFor(server: ServerProfile?): ServerEditorDraft {
|
fun draftFor(server: ServerProfile?): ServerEditorDraft {
|
||||||
@@ -35,6 +37,7 @@ class MonitorRepository(context: Context) {
|
|||||||
id = server.id,
|
id = server.id,
|
||||||
name = server.name,
|
name = server.name,
|
||||||
baseUrl = server.baseUrl,
|
baseUrl = server.baseUrl,
|
||||||
|
fallbackIpAddresses = server.fallbackIpAddresses.joinToString(", "),
|
||||||
tags = server.tags.joinToString(", "),
|
tags = server.tags.joinToString(", "),
|
||||||
favorite = server.favorite,
|
favorite = server.favorite,
|
||||||
allowHttp = server.allowHttp,
|
allowHttp = server.allowHttp,
|
||||||
@@ -63,6 +66,7 @@ class MonitorRepository(context: Context) {
|
|||||||
id = draft.id ?: UUID.randomUUID().toString(),
|
id = draft.id ?: UUID.randomUUID().toString(),
|
||||||
name = draft.name.trim().ifBlank { baseUrl },
|
name = draft.name.trim().ifBlank { baseUrl },
|
||||||
baseUrl = baseUrl,
|
baseUrl = baseUrl,
|
||||||
|
fallbackIpAddresses = UrlRules.parseFallbackIps(draft.fallbackIpAddresses),
|
||||||
tags = draft.tags.split(',').map { it.trim() }.filter { it.isNotBlank() }.distinct(),
|
tags = draft.tags.split(',').map { it.trim() }.filter { it.isNotBlank() }.distinct(),
|
||||||
favorite = draft.favorite,
|
favorite = draft.favorite,
|
||||||
allowHttp = draft.allowHttp,
|
allowHttp = draft.allowHttp,
|
||||||
@@ -91,8 +95,9 @@ class MonitorRepository(context: Context) {
|
|||||||
|
|
||||||
fun testConnection(draft: ServerEditorDraft): Result<String> = runCatching {
|
fun testConnection(draft: ServerEditorDraft): Result<String> = runCatching {
|
||||||
val server = buildServer(draft.copy(id = draft.id ?: "test"))
|
val server = buildServer(draft.copy(id = draft.id ?: "test"))
|
||||||
client.fetchHealth(server, authFor(server))
|
val response = client.fetchHealth(server, authFor(server))
|
||||||
"Connection works"
|
database.upsertRouteDiagnostics(response.diagnostics.copy(serverId = server.id))
|
||||||
|
"Connection works through ${response.diagnostics.routeUsed.label}"
|
||||||
}
|
}
|
||||||
|
|
||||||
fun fetchApiSummary(server: ServerProfile, forcePersist: Boolean = false): MetricSummary {
|
fun fetchApiSummary(server: ServerProfile, forcePersist: Boolean = false): MetricSummary {
|
||||||
@@ -101,17 +106,19 @@ class MonitorRepository(context: Context) {
|
|||||||
|
|
||||||
fun refreshServer(server: ServerProfile, forcePersist: Boolean = false): RefreshResult {
|
fun refreshServer(server: ServerProfile, forcePersist: Boolean = false): RefreshResult {
|
||||||
return try {
|
return try {
|
||||||
val payload = client.fetchApi(server, authFor(server))
|
val response = client.fetchApi(server, authFor(server))
|
||||||
val parsed = MonitorJsonParser.parse(server.id, payload)
|
database.upsertRouteDiagnostics(response.diagnostics)
|
||||||
|
val parsed = MonitorJsonParser.parse(server.id, response.body)
|
||||||
val events = AlertEvaluator.evaluate(server, parsed)
|
val events = AlertEvaluator.evaluate(server, parsed)
|
||||||
val status = if (events.isEmpty()) ServerStatus.Online else ServerStatus.Degraded
|
val status = if (events.isEmpty()) ServerStatus.Online else ServerStatus.Degraded
|
||||||
val summary = parsed.copy(
|
val summary = parsed.copy(
|
||||||
status = status,
|
status = status,
|
||||||
message = if (status == ServerStatus.Online) "Live" else "Threshold breached",
|
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))
|
RefreshResult(summary, recordAlerts(server, summary))
|
||||||
} catch (exception: MonitorClientException) {
|
} catch (exception: MonitorClientException) {
|
||||||
|
database.upsertRouteDiagnostics(exception.diagnostics)
|
||||||
val summary = MetricSummary(
|
val summary = MetricSummary(
|
||||||
serverId = server.id,
|
serverId = server.id,
|
||||||
timestampMillis = System.currentTimeMillis(),
|
timestampMillis = System.currentTimeMillis(),
|
||||||
@@ -142,7 +149,11 @@ class MonitorRepository(context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun streamLive(server: ServerProfile, onSummary: (MetricSummary) -> Unit) {
|
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 parsed = MonitorJsonParser.parse(server.id, payload)
|
||||||
val events = AlertEvaluator.evaluate(server, parsed)
|
val events = AlertEvaluator.evaluate(server, parsed)
|
||||||
val status = if (events.isEmpty()) ServerStatus.Online else ServerStatus.Degraded
|
val status = if (events.isEmpty()) ServerStatus.Online else ServerStatus.Degraded
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package net.rodakot.ngxhttpmonitoringclient.domain
|
package net.rodakot.ngxhttpmonitoringclient.domain
|
||||||
|
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
|
import java.net.InetAddress
|
||||||
|
|
||||||
object UrlRules {
|
object UrlRules {
|
||||||
fun normalizeBaseUrl(input: String): String {
|
fun normalizeBaseUrl(input: String): String {
|
||||||
@@ -30,4 +31,16 @@ object UrlRules {
|
|||||||
val normalizedPath = if (path.startsWith("/")) path else "/$path"
|
val normalizedPath = if (path.startsWith("/")) path else "/$path"
|
||||||
return normalizeBaseUrl(baseUrl) + normalizedPath
|
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") }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ data class ServerProfile(
|
|||||||
val id: String,
|
val id: String,
|
||||||
val name: String,
|
val name: String,
|
||||||
val baseUrl: String,
|
val baseUrl: String,
|
||||||
|
val fallbackIpAddresses: List<String> = emptyList(),
|
||||||
|
val routePolicy: RoutePolicy = RoutePolicy.AutoFallback,
|
||||||
val tags: List<String> = emptyList(),
|
val tags: List<String> = emptyList(),
|
||||||
val favorite: Boolean = false,
|
val favorite: Boolean = false,
|
||||||
val allowHttp: Boolean = false,
|
val allowHttp: Boolean = false,
|
||||||
@@ -30,6 +32,49 @@ data class AuthConfig(
|
|||||||
val basicPassword: String? = null,
|
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(
|
data class AlertOverrides(
|
||||||
val cpuPercent: Double? = null,
|
val cpuPercent: Double? = null,
|
||||||
val memoryPercent: Double? = null,
|
val memoryPercent: Double? = null,
|
||||||
@@ -87,6 +132,7 @@ data class ServerEditorDraft(
|
|||||||
val id: String? = null,
|
val id: String? = null,
|
||||||
val name: String = "",
|
val name: String = "",
|
||||||
val baseUrl: String = "",
|
val baseUrl: String = "",
|
||||||
|
val fallbackIpAddresses: String = "",
|
||||||
val tags: String = "",
|
val tags: String = "",
|
||||||
val favorite: Boolean = false,
|
val favorite: Boolean = false,
|
||||||
val allowHttp: Boolean = false,
|
val allowHttp: Boolean = false,
|
||||||
@@ -118,6 +164,7 @@ data class MonitorUiState(
|
|||||||
val servers: List<ServerProfile> = emptyList(),
|
val servers: List<ServerProfile> = emptyList(),
|
||||||
val summaries: Map<String, MetricSummary> = emptyMap(),
|
val summaries: Map<String, MetricSummary> = emptyMap(),
|
||||||
val history: Map<String, List<MetricSummary>> = emptyMap(),
|
val history: Map<String, List<MetricSummary>> = emptyMap(),
|
||||||
|
val routeDiagnostics: Map<String, RouteDiagnostics> = emptyMap(),
|
||||||
val alerts: List<AlertEvent> = emptyList(),
|
val alerts: List<AlertEvent> = emptyList(),
|
||||||
val selectedServerId: String? = null,
|
val selectedServerId: String? = null,
|
||||||
val showDetail: Boolean = false,
|
val showDetail: Boolean = false,
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ import net.rodakot.ngxhttpmonitoringclient.model.DefaultMemoryThreshold
|
|||||||
import net.rodakot.ngxhttpmonitoringclient.model.DetailTab
|
import net.rodakot.ngxhttpmonitoringclient.model.DetailTab
|
||||||
import net.rodakot.ngxhttpmonitoringclient.model.MetricSummary
|
import net.rodakot.ngxhttpmonitoringclient.model.MetricSummary
|
||||||
import net.rodakot.ngxhttpmonitoringclient.model.MonitorUiState
|
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.ServerEditorDraft
|
||||||
import net.rodakot.ngxhttpmonitoringclient.model.ServerProfile
|
import net.rodakot.ngxhttpmonitoringclient.model.ServerProfile
|
||||||
import net.rodakot.ngxhttpmonitoringclient.model.ServerStatus
|
import net.rodakot.ngxhttpmonitoringclient.model.ServerStatus
|
||||||
@@ -204,6 +206,7 @@ private fun FleetCommandDeck(
|
|||||||
ServerCommandRow(
|
ServerCommandRow(
|
||||||
server = server,
|
server = server,
|
||||||
summary = state.summaries[server.id],
|
summary = state.summaries[server.id],
|
||||||
|
diagnostics = state.routeDiagnostics[server.id],
|
||||||
onClick = { controller.selectServer(server.id) },
|
onClick = { controller.selectServer(server.id) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -346,7 +349,12 @@ private fun CommandFilters(
|
|||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@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 status = summary?.status ?: ServerStatus.Unknown
|
||||||
val health = healthScore(summary)
|
val health = healthScore(summary)
|
||||||
Surface(
|
Surface(
|
||||||
@@ -393,6 +401,7 @@ private fun ServerCommandRow(server: ServerProfile, summary: MetricSummary?, onC
|
|||||||
LabelCapsule(statusLabel(status).uppercase(), colorForStatus(status))
|
LabelCapsule(statusLabel(status).uppercase(), colorForStatus(status))
|
||||||
if (!server.enabled) LabelCapsule("OFF", StatusUnknown)
|
if (!server.enabled) LabelCapsule("OFF", StatusUnknown)
|
||||||
if (server.allowHttp) LabelCapsule("HTTP", StatusInsecure)
|
if (server.allowHttp) LabelCapsule("HTTP", StatusInsecure)
|
||||||
|
RouteBadges(diagnostics)
|
||||||
server.tags.take(3).forEach { LabelCapsule(it.uppercase(), MaterialTheme.colorScheme.secondary) }
|
server.tags.take(3).forEach { LabelCapsule(it.uppercase(), MaterialTheme.colorScheme.secondary) }
|
||||||
}
|
}
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
@@ -423,6 +432,9 @@ private fun ServerCockpit(
|
|||||||
item {
|
item {
|
||||||
CockpitHero(server, summary, controller)
|
CockpitHero(server, summary, controller)
|
||||||
}
|
}
|
||||||
|
item {
|
||||||
|
RoutePanel(state.routeDiagnostics[server.id])
|
||||||
|
}
|
||||||
item {
|
item {
|
||||||
CockpitTabs(selected = state.selectedTab, onSelect = controller::setDetailTab)
|
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
|
@Composable
|
||||||
private fun CockpitTabs(selected: DetailTab, onSelect: (DetailTab) -> Unit) {
|
private fun CockpitTabs(selected: DetailTab, onSelect: (DetailTab) -> Unit) {
|
||||||
Row(
|
Row(
|
||||||
@@ -563,6 +594,7 @@ private fun SettingsCockpit(server: ServerProfile, controller: MonitorController
|
|||||||
RuleRow("Disk limit", "${server.alertOverrides.diskPercent ?: DefaultDiskThreshold}%")
|
RuleRow("Disk limit", "${server.alertOverrides.diskPercent ?: DefaultDiskThreshold}%")
|
||||||
RuleRow("p95 latency", "${(server.alertOverrides.latencyP95Millis ?: DefaultLatencyP95ThresholdMs).toInt()} ms")
|
RuleRow("p95 latency", "${(server.alertOverrides.latencyP95Millis ?: DefaultLatencyP95ThresholdMs).toInt()} ms")
|
||||||
RuleRow("5xx limit", "${server.alertOverrides.errors5xx ?: DefaultErrors5xxThreshold}")
|
RuleRow("5xx limit", "${server.alertOverrides.errors5xx ?: DefaultErrors5xxThreshold}")
|
||||||
|
RuleRow("Fallback IPs", server.fallbackIpAddresses.ifEmpty { listOf("None") }.joinToString(", "))
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
Button(onClick = controller::showEditSelectedServer, shape = RoundedCornerShape(8.dp)) { Text("Edit") }
|
Button(onClick = controller::showEditSelectedServer, shape = RoundedCornerShape(8.dp)) { Text("Edit") }
|
||||||
OutlinedButton(onClick = controller::deleteSelectedServer, shape = RoundedCornerShape(8.dp)) { Text("Delete") }
|
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
|
@Composable
|
||||||
private fun HealthBadge(score: Int, status: ServerStatus) {
|
private fun HealthBadge(score: Int, status: ServerStatus) {
|
||||||
val color = colorForStatus(status)
|
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("Display name", draft.name) { value -> onChange { it.copy(name = value) } } }
|
||||||
item { EditorField("Base URL", draft.baseUrl) { value -> onChange { it.copy(baseUrl = 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 { EditorField("Tags", draft.tags) { value -> onChange { it.copy(tags = value) } } }
|
||||||
item {
|
item {
|
||||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
@@ -1005,6 +1048,19 @@ private fun colorForPercent(value: Double?): Color = when {
|
|||||||
else -> StatusOnline
|
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?.formatPercent(): String = this?.let { "${it.toInt()}%" } ?: "--"
|
||||||
private fun Double?.formatMillis(): String = this?.let { "${it.toInt()} ms" } ?: "--"
|
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) } ?: "--"
|
private fun Double?.formatNumber(): String = this?.let { if (it >= 10) it.toInt().toString() else "%.1f".format(it) } ?: "--"
|
||||||
|
|||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,4 +24,12 @@ class UrlRulesTest {
|
|||||||
UrlRules.endpoint("https://monitor.example.com/", "/monitor/api"),
|
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"),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ lifecycleRuntimeKtx = "2.10.0"
|
|||||||
activityCompose = "1.13.0"
|
activityCompose = "1.13.0"
|
||||||
kotlin = "2.2.10"
|
kotlin = "2.2.10"
|
||||||
composeBom = "2026.02.01"
|
composeBom = "2026.02.01"
|
||||||
|
okhttp = "4.12.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
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-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" }
|
||||||
androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycleRuntimeKtx" }
|
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" }
|
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]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|||||||
Reference in New Issue
Block a user