This commit is contained in:
2026-05-08 06:54:28 +03:30
parent 428d335d87
commit e9e7446734
35 changed files with 2735 additions and 70 deletions

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
*.iml
.gradle
.gradle-user
/local.properties
/.idea/caches
/.idea/libraries

View File

@@ -4,6 +4,24 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-05-08T02:10:54.105792300Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\meghdad\.android\avd\Pixel_10_Pro.avd" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
<SelectionState runConfigName="MainActivity">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-05-08T02:10:54.105792300Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\meghdad\.android\avd\Pixel_10_Pro.avd" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
</selectionStates>

13
.idea/deviceManager.xml generated Normal file
View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>

1
.idea/gradle.xml generated
View File

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>

1
.idea/misc.xml generated
View File

@@ -1,4 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">

6
.idea/studiobot.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="StudioBotProjectSettings">
<option name="shareContext" value="OptedOut" />
</component>
</project>

View File

@@ -34,6 +34,11 @@ android {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11)
}
}
buildFeatures {
compose = true
}
@@ -42,12 +47,15 @@ android {
dependencies {
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.foundation)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.kotlinx.coroutines.android)
testImplementation(libs.junit)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)

View File

@@ -2,7 +2,12 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name=".NgxMonitorApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
@@ -10,7 +15,8 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.NGXHttpMonitoringClient">
android:theme="@style/Theme.NGXHttpMonitoringClient"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
@@ -22,6 +28,10 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".background.MonitorJobService"
android:exported="false"
android:permission="android.permission.BIND_JOB_SERVICE" />
</application>
</manifest>

View File

@@ -1,47 +1,40 @@
package net.rodakot.ngxhttpmonitoringclient
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import net.rodakot.ngxhttpmonitoringclient.ui.MonitorApp
import net.rodakot.ngxhttpmonitoringclient.ui.theme.NGXHttpMonitoringClientTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
requestNotificationPermission()
setContent {
val controller = remember { MonitorController(applicationContext) }
DisposableEffect(controller) {
onDispose { controller.close() }
}
NGXHttpMonitoringClientTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Greeting(
name = "Android",
modifier = Modifier.padding(innerPadding)
)
}
}
MonitorApp(controller)
}
}
}
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
Text(
text = "Hello $name!",
modifier = modifier
)
}
@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
NGXHttpMonitoringClientTheme {
Greeting("Android")
private fun requestNotificationPermission() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
val granted = ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
if (!granted) {
ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1001)
}
}
}

View File

@@ -0,0 +1,250 @@
package net.rodakot.ngxhttpmonitoringclient
import android.content.Context
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.rodakot.ngxhttpmonitoringclient.data.MonitorRepository
import net.rodakot.ngxhttpmonitoringclient.model.DetailTab
import net.rodakot.ngxhttpmonitoringclient.model.MetricSummary
import net.rodakot.ngxhttpmonitoringclient.model.MonitorUiState
import net.rodakot.ngxhttpmonitoringclient.model.ServerEditorDraft
import net.rodakot.ngxhttpmonitoringclient.model.ServerProfile
import kotlin.coroutines.coroutineContext
class MonitorController(context: Context) {
private val repository = MonitorRepository(context.applicationContext)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val liveJobs = mutableMapOf<String, Job>()
private val _state = MutableStateFlow(MonitorUiState())
val state: StateFlow<MonitorUiState> = _state
init {
reload()
}
fun reload() {
scope.launch(Dispatchers.IO) {
val servers = repository.servers()
val selected = _state.value.selectedServerId?.takeIf { id -> servers.any { it.id == id } }
?: servers.firstOrNull()?.id
val summaries = repository.latestSummaries(servers.map { it.id })
val history = selected?.let { mapOf(it to repository.history(it)) }.orEmpty()
val alerts = repository.alerts()
_state.update {
it.copy(
servers = servers,
selectedServerId = selected,
summaries = summaries,
history = history,
alerts = alerts,
globalMessage = null,
)
}
if (_state.value.liveMonitoring) restartLiveMonitoring(servers)
}
}
fun startLiveMonitoring() {
_state.update { it.copy(liveMonitoring = true) }
restartLiveMonitoring(_state.value.servers)
}
fun stopLiveMonitoring() {
liveJobs.values.forEach { it.cancel() }
liveJobs.clear()
_state.update { it.copy(liveMonitoring = false) }
}
fun close() {
stopLiveMonitoring()
scope.cancel()
}
fun refreshAll() {
scope.launch(Dispatchers.IO) {
_state.value.servers.filter { it.enabled }.forEach { server ->
updateSummary(repository.fetchApiSummary(server, forcePersist = true))
}
reloadAlertsAndSelectedHistory()
}
}
fun refreshSelected() {
val server = selectedServer() ?: return
scope.launch(Dispatchers.IO) {
updateSummary(repository.fetchApiSummary(server, forcePersist = true))
reloadAlertsAndSelectedHistory()
}
}
fun selectServer(serverId: String) {
_state.update { it.copy(selectedServerId = serverId, showDetail = true, selectedTab = DetailTab.Overview) }
scope.launch(Dispatchers.IO) {
val history = repository.history(serverId)
_state.update { it.copy(history = it.history + (serverId to history)) }
}
}
fun showDashboard() {
_state.update { it.copy(showDetail = false) }
}
fun setDetailTab(tab: DetailTab) {
_state.update { it.copy(selectedTab = tab) }
}
fun setQuery(query: String) {
_state.update { it.copy(query = query) }
}
fun setTagFilter(tag: String?) {
_state.update { it.copy(tagFilter = tag) }
}
fun showAddServer() {
_state.update { it.copy(editorDraft = ServerEditorDraft()) }
}
fun showEditSelectedServer() {
val server = selectedServer() ?: return
_state.update { it.copy(editorDraft = repository.draftFor(server)) }
}
fun dismissEditor() {
_state.update { it.copy(editorDraft = null) }
}
fun updateDraft(transform: (ServerEditorDraft) -> ServerEditorDraft) {
_state.update { current ->
val draft = current.editorDraft ?: return@update current
current.copy(editorDraft = transform(draft))
}
}
fun saveEditor() {
val draft = _state.value.editorDraft ?: return
scope.launch(Dispatchers.IO) {
runCatching { repository.saveDraft(draft) }
.onSuccess { server ->
val servers = repository.servers()
val summaries = repository.latestSummaries(servers.map { it.id })
val history = repository.history(server.id)
_state.update {
it.copy(
servers = servers,
summaries = summaries,
selectedServerId = server.id,
showDetail = true,
selectedTab = DetailTab.Overview,
history = it.history + (server.id to history),
editorDraft = null,
globalMessage = "Server saved",
)
}
if (_state.value.liveMonitoring) restartLiveMonitoring(servers)
}
.onFailure { error ->
_state.update { it.copy(editorDraft = draft.copy(testMessage = error.message ?: "Unable to save server")) }
}
}
}
fun testEditorConnection() {
val draft = _state.value.editorDraft ?: return
_state.update { it.copy(editorDraft = draft.copy(isTesting = true, testMessage = null)) }
scope.launch(Dispatchers.IO) {
val result = repository.testConnection(draft)
_state.update { current ->
current.copy(
editorDraft = current.editorDraft?.copy(
isTesting = false,
testMessage = result.getOrElse { it.message ?: "Connection failed" },
),
)
}
}
}
fun deleteSelectedServer() {
val server = selectedServer() ?: return
scope.launch(Dispatchers.IO) {
liveJobs.remove(server.id)?.cancel()
repository.deleteServer(server.id)
val servers = repository.servers()
val selected = servers.firstOrNull()?.id
_state.update {
it.copy(
servers = servers,
selectedServerId = selected,
showDetail = false,
selectedTab = DetailTab.Overview,
summaries = repository.latestSummaries(servers.map { item -> item.id }),
history = selected?.let { id -> mapOf(id to repository.history(id)) }.orEmpty(),
alerts = repository.alerts(),
globalMessage = "Server deleted",
)
}
}
}
private fun restartLiveMonitoring(servers: List<ServerProfile>) {
val enabledIds = servers.filter { it.enabled }.map { it.id }.toSet()
liveJobs.filterKeys { it !in enabledIds }.values.forEach { it.cancel() }
liveJobs.keys.removeAll { it !in enabledIds }
servers.filter { it.enabled && liveJobs[it.id]?.isActive != true }.forEach { server ->
liveJobs[server.id] = scope.launch(Dispatchers.IO) { liveLoop(server) }
}
}
private suspend fun liveLoop(server: ServerProfile) {
var backoffMillis = 2_000L
while (coroutineContext.isActive) {
runCatching {
repository.streamLive(server) { summary ->
backoffMillis = 2_000L
updateSummary(summary)
}
}.onFailure {
updateSummary(repository.fetchApiSummary(server, forcePersist = true))
delay(backoffMillis)
backoffMillis = (backoffMillis * 2).coerceAtMost(60_000L)
}
}
}
private fun updateSummary(summary: MetricSummary) {
_state.update { current ->
current.copy(summaries = current.summaries + (summary.serverId to summary))
}
}
private suspend fun reloadAlertsAndSelectedHistory() {
val selected = _state.value.selectedServerId
val history = selected?.let { repository.history(it) }
val alerts = repository.alerts()
withContext(Dispatchers.Main.immediate) {
_state.update {
it.copy(
alerts = alerts,
history = if (selected != null && history != null) it.history + (selected to history) else it.history,
)
}
}
}
private fun selectedServer(): ServerProfile? {
val selected = _state.value.selectedServerId ?: return null
return _state.value.servers.firstOrNull { it.id == selected }
}
}

View File

@@ -0,0 +1,13 @@
package net.rodakot.ngxhttpmonitoringclient
import android.app.Application
import net.rodakot.ngxhttpmonitoringclient.background.MonitorJobScheduler
import net.rodakot.ngxhttpmonitoringclient.background.MonitorNotifications
class NgxMonitorApplication : Application() {
override fun onCreate() {
super.onCreate()
MonitorNotifications.ensureChannel(this)
MonitorJobScheduler.schedule(this)
}
}

View File

@@ -0,0 +1,21 @@
package net.rodakot.ngxhttpmonitoringclient.background
import android.app.job.JobInfo
import android.app.job.JobScheduler
import android.content.ComponentName
import android.content.Context
import java.util.concurrent.TimeUnit
object MonitorJobScheduler {
private const val JobId = 50901
fun schedule(context: Context) {
val scheduler = context.getSystemService(JobScheduler::class.java)
val componentName = ComponentName(context, MonitorJobService::class.java)
val job = JobInfo.Builder(JobId, componentName)
.setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY)
.setPeriodic(TimeUnit.MINUTES.toMillis(15))
.build()
scheduler.schedule(job)
}
}

View File

@@ -0,0 +1,41 @@
package net.rodakot.ngxhttpmonitoringclient.background
import android.app.job.JobParameters
import android.app.job.JobService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import net.rodakot.ngxhttpmonitoringclient.data.MonitorRepository
class MonitorJobService : JobService() {
private var scope: CoroutineScope? = null
override fun onStartJob(params: JobParameters): Boolean {
val jobScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
scope = jobScope
jobScope.launch {
runChecks()
jobFinished(params, false)
}
return true
}
override fun onStopJob(params: JobParameters): Boolean {
scope?.cancel()
scope = null
return true
}
private fun runChecks() {
val repository = MonitorRepository(applicationContext)
repository.pruneHistory()
repository.servers().filter { it.enabled }.forEach { server ->
val result = repository.refreshServer(server, forcePersist = true)
result.alerts.forEach { alert ->
MonitorNotifications.showAlert(applicationContext, server, alert)
}
}
}
}

View File

@@ -0,0 +1,51 @@
package net.rodakot.ngxhttpmonitoringclient.background
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import net.rodakot.ngxhttpmonitoringclient.model.AlertEvent
import net.rodakot.ngxhttpmonitoringclient.model.AlertSeverity
import net.rodakot.ngxhttpmonitoringclient.model.ServerProfile
object MonitorNotifications {
private const val ChannelId = "ngx_monitor_alerts"
fun ensureChannel(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(NotificationManager::class.java)
val channel = NotificationChannel(
ChannelId,
"Server alerts",
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description = "NGX Monitor alert notifications"
}
manager.createNotificationChannel(channel)
}
fun showAlert(context: Context, server: ServerProfile, alert: AlertEvent) {
ensureChannel(context)
val manager = context.getSystemService(NotificationManager::class.java)
val notification = builder(context)
.setSmallIcon(android.R.drawable.stat_notify_error)
.setContentTitle("${alert.title}: ${server.name}")
.setContentText(alert.message)
.setStyle(Notification.BigTextStyle().bigText(alert.message))
.setWhen(alert.timestampMillis)
.setShowWhen(true)
.setAutoCancel(true)
.setPriority(if (alert.severity == AlertSeverity.Critical) Notification.PRIORITY_HIGH else Notification.PRIORITY_DEFAULT)
.build()
manager.notify((server.id + alert.title).hashCode(), notification)
}
private fun builder(context: Context): Notification.Builder {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Notification.Builder(context, ChannelId)
} else {
Notification.Builder(context)
}
}
}

View File

@@ -0,0 +1,59 @@
package net.rodakot.ngxhttpmonitoringclient.data
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import java.nio.charset.StandardCharsets
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
class CredentialCipher {
private val keyAlias = "ngx_monitor_credentials"
private val keyStore: KeyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
fun encrypt(value: String?): String? {
val plain = value?.trim().orEmpty()
if (plain.isBlank()) return null
val cipher = Cipher.getInstance(Transformation)
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
val encrypted = cipher.doFinal(plain.toByteArray(StandardCharsets.UTF_8))
val iv = Base64.encodeToString(cipher.iv, Base64.NO_WRAP)
val body = Base64.encodeToString(encrypted, Base64.NO_WRAP)
return "$iv:$body"
}
fun decrypt(value: String?): String? {
if (value.isNullOrBlank()) return null
val parts = value.split(':', limit = 2)
if (parts.size != 2) return null
return runCatching {
val cipher = Cipher.getInstance(Transformation)
val iv = Base64.decode(parts[0], Base64.NO_WRAP)
val body = Base64.decode(parts[1], Base64.NO_WRAP)
cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), GCMParameterSpec(128, iv))
String(cipher.doFinal(body), StandardCharsets.UTF_8)
}.getOrNull()
}
private fun getOrCreateKey(): SecretKey {
(keyStore.getEntry(keyAlias, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey }
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
val spec = KeyGenParameterSpec.Builder(
keyAlias,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setRandomizedEncryptionRequired(true)
.build()
generator.init(spec)
return generator.generateKey()
}
private companion object {
const val Transformation = "AES/GCM/NoPadding"
}
}

View File

@@ -0,0 +1,329 @@
package net.rodakot.ngxhttpmonitoringclient.data
import android.content.ContentValues
import android.content.Context
import android.database.Cursor
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import net.rodakot.ngxhttpmonitoringclient.model.AlertEvent
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.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) {
override fun onCreate(db: SQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE servers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
base_url TEXT NOT NULL,
tags_json TEXT NOT NULL,
favorite INTEGER NOT NULL,
allow_http INTEGER NOT NULL,
enabled INTEGER NOT NULL,
token_cipher TEXT,
basic_user_cipher TEXT,
basic_password_cipher TEXT,
cpu_override REAL,
memory_override REAL,
disk_override REAL,
latency_override REAL,
errors5xx_override INTEGER,
updated_at INTEGER NOT NULL
)
""".trimIndent(),
)
db.execSQL(
"""
CREATE TABLE samples (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id TEXT NOT NULL,
timestamp_millis INTEGER NOT NULL,
status TEXT NOT NULL,
message TEXT NOT NULL,
cpu_percent REAL,
memory_percent REAL,
disk_percent REAL,
request_rate REAL,
latency_p95_millis REAL,
errors_4xx INTEGER,
errors_5xx INTEGER,
active_connections INTEGER,
raw_json TEXT
)
""".trimIndent(),
)
db.execSQL("CREATE INDEX samples_server_time ON samples(server_id, timestamp_millis DESC)")
db.execSQL(
"""
CREATE TABLE alerts (
id TEXT PRIMARY KEY,
server_id TEXT NOT NULL,
timestamp_millis INTEGER NOT NULL,
severity TEXT NOT NULL,
title TEXT NOT NULL,
message TEXT NOT NULL,
resolved INTEGER NOT NULL
)
""".trimIndent(),
)
db.execSQL("CREATE INDEX alerts_server_time ON alerts(server_id, timestamp_millis DESC)")
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = Unit
@Synchronized
fun listServers(): List<ServerProfile> {
val result = mutableListOf<ServerProfile>()
readableDatabase.query("servers", null, null, null, null, null, "favorite DESC, name COLLATE NOCASE ASC").use { cursor ->
while (cursor.moveToNext()) result += cursor.toServer()
}
return result
}
@Synchronized
fun upsertServer(server: ServerProfile) {
writableDatabase.insertWithOnConflict("servers", null, server.toValues(), SQLiteDatabase.CONFLICT_REPLACE)
}
@Synchronized
fun deleteServer(serverId: String) {
writableDatabase.delete("servers", "id = ?", arrayOf(serverId))
writableDatabase.delete("samples", "server_id = ?", arrayOf(serverId))
writableDatabase.delete("alerts", "server_id = ?", arrayOf(serverId))
}
@Synchronized
fun insertSample(summary: MetricSummary) {
writableDatabase.insert("samples", null, summary.toValues())
}
@Synchronized
fun latestSummaries(serverIds: List<String>): Map<String, MetricSummary> {
val result = linkedMapOf<String, MetricSummary>()
serverIds.forEach { serverId ->
readableDatabase.query(
"samples",
null,
"server_id = ?",
arrayOf(serverId),
null,
null,
"timestamp_millis DESC",
"1",
).use { cursor ->
if (cursor.moveToFirst()) result[serverId] = cursor.toSummary()
}
}
return result
}
@Synchronized
fun samplesForServer(serverId: String, limit: Int = 360): List<MetricSummary> {
val result = mutableListOf<MetricSummary>()
val cutoff = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(HistoryRetentionDays.toLong())
readableDatabase.query(
"samples",
null,
"server_id = ? AND timestamp_millis >= ?",
arrayOf(serverId, cutoff.toString()),
null,
null,
"timestamp_millis DESC",
limit.toString(),
).use { cursor ->
while (cursor.moveToNext()) result += cursor.toSummary()
}
return result.asReversed()
}
@Synchronized
fun lastSampleTimestamp(serverId: String, rawOnly: Boolean): Long? {
val selection = if (rawOnly) "server_id = ? AND raw_json IS NOT NULL" else "server_id = ?"
readableDatabase.query(
"samples",
arrayOf("timestamp_millis"),
selection,
arrayOf(serverId),
null,
null,
"timestamp_millis DESC",
"1",
).use { cursor ->
return if (cursor.moveToFirst()) cursor.getLong(0) else null
}
}
@Synchronized
fun insertAlert(alert: AlertEvent) {
writableDatabase.insertWithOnConflict("alerts", null, alert.toValues(), SQLiteDatabase.CONFLICT_IGNORE)
}
@Synchronized
fun hasRecentAlert(serverId: String, title: String, afterMillis: Long): Boolean {
readableDatabase.query(
"alerts",
arrayOf("id"),
"server_id = ? AND title = ? AND timestamp_millis >= ?",
arrayOf(serverId, title, afterMillis.toString()),
null,
null,
"timestamp_millis DESC",
"1",
).use { cursor ->
return cursor.moveToFirst()
}
}
@Synchronized
fun alerts(limit: Int = 200): List<AlertEvent> {
val result = mutableListOf<AlertEvent>()
readableDatabase.query("alerts", null, null, null, null, null, "timestamp_millis DESC", limit.toString()).use { cursor ->
while (cursor.moveToNext()) result += cursor.toAlert()
}
return result
}
@Synchronized
fun pruneOldSamples(nowMillis: Long = System.currentTimeMillis()) {
val cutoff = nowMillis - TimeUnit.DAYS.toMillis(HistoryRetentionDays.toLong())
writableDatabase.delete("samples", "timestamp_millis < ?", arrayOf(cutoff.toString()))
}
private fun ServerProfile.toValues() = ContentValues().apply {
put("id", id)
put("name", name)
put("base_url", baseUrl)
put("tags_json", tagsToJson(tags))
put("favorite", favorite.asInt())
put("allow_http", allowHttp.asInt())
put("enabled", enabled.asInt())
putNullable("token_cipher", tokenCipherText)
putNullable("basic_user_cipher", basicUserCipherText)
putNullable("basic_password_cipher", basicPasswordCipherText)
putNullable("cpu_override", alertOverrides.cpuPercent)
putNullable("memory_override", alertOverrides.memoryPercent)
putNullable("disk_override", alertOverrides.diskPercent)
putNullable("latency_override", alertOverrides.latencyP95Millis)
putNullable("errors5xx_override", alertOverrides.errors5xx)
put("updated_at", updatedAtMillis)
}
private fun MetricSummary.toValues() = ContentValues().apply {
put("server_id", serverId)
put("timestamp_millis", timestampMillis)
put("status", status.name)
put("message", message)
putNullable("cpu_percent", cpuPercent)
putNullable("memory_percent", memoryPercent)
putNullable("disk_percent", diskPercent)
putNullable("request_rate", requestRate)
putNullable("latency_p95_millis", latencyP95Millis)
putNullable("errors_4xx", errors4xx)
putNullable("errors_5xx", errors5xx)
putNullable("active_connections", activeConnections)
putNullable("raw_json", rawJson)
}
private fun AlertEvent.toValues() = ContentValues().apply {
put("id", id)
put("server_id", serverId)
put("timestamp_millis", timestampMillis)
put("severity", severity.name)
put("title", title)
put("message", message)
put("resolved", resolved.asInt())
}
private fun Cursor.toServer(): ServerProfile {
return ServerProfile(
id = string("id"),
name = string("name"),
baseUrl = string("base_url"),
tags = tagsFromJson(string("tags_json")),
favorite = int("favorite") == 1,
allowHttp = int("allow_http") == 1,
enabled = int("enabled") == 1,
tokenCipherText = nullableString("token_cipher"),
basicUserCipherText = nullableString("basic_user_cipher"),
basicPasswordCipherText = nullableString("basic_password_cipher"),
alertOverrides = AlertOverrides(
cpuPercent = nullableDouble("cpu_override"),
memoryPercent = nullableDouble("memory_override"),
diskPercent = nullableDouble("disk_override"),
latencyP95Millis = nullableDouble("latency_override"),
errors5xx = nullableInt("errors5xx_override"),
),
updatedAtMillis = long("updated_at"),
)
}
private fun Cursor.toSummary(): MetricSummary {
return MetricSummary(
serverId = string("server_id"),
timestampMillis = long("timestamp_millis"),
status = runCatching { ServerStatus.valueOf(string("status")) }.getOrDefault(ServerStatus.Unknown),
message = string("message"),
cpuPercent = nullableDouble("cpu_percent"),
memoryPercent = nullableDouble("memory_percent"),
diskPercent = nullableDouble("disk_percent"),
requestRate = nullableDouble("request_rate"),
latencyP95Millis = nullableDouble("latency_p95_millis"),
errors4xx = nullableInt("errors_4xx"),
errors5xx = nullableInt("errors_5xx"),
activeConnections = nullableInt("active_connections"),
rawJson = nullableString("raw_json"),
)
}
private fun Cursor.toAlert(): AlertEvent {
return AlertEvent(
id = string("id"),
serverId = string("server_id"),
timestampMillis = long("timestamp_millis"),
severity = runCatching { AlertSeverity.valueOf(string("severity")) }.getOrDefault(AlertSeverity.Warning),
title = string("title"),
message = string("message"),
resolved = int("resolved") == 1,
)
}
private fun tagsToJson(tags: List<String>): String {
val array = JSONArray()
tags.map { it.trim() }.filter { it.isNotBlank() }.distinct().forEach(array::put)
return array.toString()
}
private fun tagsFromJson(value: String): List<String> = runCatching {
val array = JSONArray(value)
List(array.length()) { index -> array.optString(index) }.filter { it.isNotBlank() }
}.getOrDefault(emptyList())
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: Double?) = if (value == null) putNull(key) else put(key, value)
private fun ContentValues.putNullable(key: String, value: Int?) = if (value == null) putNull(key) else put(key, value)
private fun Cursor.string(column: String): String = getString(getColumnIndexOrThrow(column))
private fun Cursor.long(column: String): Long = getLong(getColumnIndexOrThrow(column))
private fun Cursor.int(column: String): Int = getInt(getColumnIndexOrThrow(column))
private fun Cursor.nullableString(column: String): String? {
val index = getColumnIndexOrThrow(column)
return if (isNull(index)) null else getString(index)
}
private fun Cursor.nullableDouble(column: String): Double? {
val index = getColumnIndexOrThrow(column)
return if (isNull(index)) null else getDouble(index)
}
private fun Cursor.nullableInt(column: String): Int? {
val index = getColumnIndexOrThrow(column)
return if (isNull(index)) null else getInt(index)
}
}

View File

@@ -0,0 +1,109 @@
package net.rodakot.ngxhttpmonitoringclient.data
import android.util.Base64
import net.rodakot.ngxhttpmonitoringclient.domain.UrlRules
import net.rodakot.ngxhttpmonitoringclient.model.AuthConfig
import net.rodakot.ngxhttpmonitoringclient.model.ServerProfile
import net.rodakot.ngxhttpmonitoringclient.model.ServerStatus
import java.io.BufferedReader
import java.io.IOException
import java.io.InputStreamReader
import java.net.HttpURLConnection
import java.net.URL
import java.nio.charset.StandardCharsets
class MonitorHttpClient {
fun fetchApi(server: ServerProfile, auth: AuthConfig): String {
return fetchText(UrlRules.endpoint(server.baseUrl, "/monitor/api"), auth)
}
fun fetchHealth(server: ServerProfile, auth: AuthConfig): String {
return fetchText(UrlRules.endpoint(server.baseUrl, "/monitor/health"), auth)
}
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 ->
var eventName = "message"
val data = StringBuilder()
while (!Thread.currentThread().isInterrupted) {
val line = reader.readLine() ?: break
when {
line.isBlank() -> {
if (data.isNotBlank() && (eventName == "metrics" || eventName == "message")) {
onMetrics(data.toString().trim())
}
eventName = "message"
data.clear()
}
line.startsWith("event:") -> eventName = line.substringAfter("event:").trim()
line.startsWith("data:") -> data.appendLine(line.substringAfter("data:").trim())
}
}
}
} 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 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")
auth.token?.takeIf { it.isNotBlank() }?.let {
connection.setRequestProperty("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")
}
return connection
}
private fun Int.toMonitorStatus(): ServerStatus = when (this) {
401 -> ServerStatus.AuthFailed
403 -> ServerStatus.Forbidden
404 -> ServerStatus.Missing
429 -> ServerStatus.RateLimited
else -> ServerStatus.Error
}
private fun Int.messageForStatus(): String = when (this) {
401 -> "Basic Auth or monitor token is missing or wrong"
403 -> "Client IP is blocked by monitor access rules"
404 -> "Monitor API/SSE endpoint is not enabled at this location"
429 -> "Monitor rate limit was exceeded"
else -> "HTTP $this from monitor endpoint"
}
}
class MonitorClientException(
val httpStatus: Int?,
val monitorStatus: ServerStatus,
override val message: String,
cause: Throwable? = null,
) : Exception(message, cause)

View File

@@ -0,0 +1,91 @@
package net.rodakot.ngxhttpmonitoringclient.data
import net.rodakot.ngxhttpmonitoringclient.model.MetricSummary
import net.rodakot.ngxhttpmonitoringclient.model.ServerStatus
import org.json.JSONArray
import org.json.JSONObject
object MonitorJsonParser {
fun parse(serverId: String, json: String, timestampMillis: Long = System.currentTimeMillis()): MetricSummary {
val root = JSONObject(json)
val system = root.obj("system")
val nginx = root.obj("nginx")
val requests = root.obj("requests")
val nginxRequests = nginx?.obj("requests")
val connections = root.obj("connections") ?: nginx?.obj("connections")
val cpu = system?.obj("cpu")?.number("usage")
?: system?.number("cpu_usage")
?: system?.number("cpu")
val memory = system?.obj("memory")?.number("used_pct")
?: system?.obj("memory")?.number("used_percent")
?: system?.number("memory_used_pct")
val disk = parseDisk(root.obj("disk"))
val requestRate = requests?.number("requests_per_sec")
?: nginxRequests?.number("requests_per_sec")
?: nginx?.number("requests_per_sec")
val latency = requests?.obj("latency")?.number("p95")
?: requests?.obj("latency")?.number("p99")
?: requests?.number("latency_p95")
val status = requests?.obj("status") ?: requests?.obj("statuses")
return MetricSummary(
serverId = serverId,
timestampMillis = timestampMillis,
status = ServerStatus.Online,
message = "Live",
cpuPercent = normalizePercent(cpu),
memoryPercent = normalizePercent(memory),
diskPercent = normalizePercent(disk),
requestRate = requestRate,
latencyP95Millis = latency,
errors4xx = status?.int("4xx"),
errors5xx = status?.int("5xx"),
activeConnections = connections?.int("active"),
rawJson = json,
)
}
private fun parseDisk(disk: JSONObject?): Double? {
if (disk == null) return null
disk.number("used_pct")?.let { return it }
disk.number("used_percent")?.let { return it }
val filesystems = disk.array("filesystems") ?: disk.array("mounts") ?: return null
var max: Double? = null
for (index in 0 until filesystems.length()) {
val item = filesystems.optJSONObject(index) ?: continue
val usage = item.number("used_pct") ?: item.number("used_percent") ?: item.number("usage")
if (usage != null && (max == null || usage > max!!)) max = usage
}
return max
}
private fun normalizePercent(value: Double?): Double? {
if (value == null) return null
return if (value in 0.0..1.0) value * 100.0 else value
}
private fun JSONObject.obj(name: String): JSONObject? = optJSONObject(name)
private fun JSONObject.array(name: String): JSONArray? = optJSONArray(name)
private fun JSONObject.number(name: String): Double? {
if (!has(name) || isNull(name)) return null
val value = opt(name)
return when (value) {
is Number -> value.toDouble()
is String -> value.toDoubleOrNull()
else -> null
}
}
private fun JSONObject.int(name: String): Int? {
if (!has(name) || isNull(name)) return null
val value = opt(name)
return when (value) {
is Number -> value.toInt()
is String -> value.toIntOrNull()
else -> null
}
}
}

View File

@@ -0,0 +1,187 @@
package net.rodakot.ngxhttpmonitoringclient.data
import android.content.Context
import net.rodakot.ngxhttpmonitoringclient.domain.AlertEvaluator
import net.rodakot.ngxhttpmonitoringclient.domain.HistorySamplingPolicy
import net.rodakot.ngxhttpmonitoringclient.domain.UrlRules
import net.rodakot.ngxhttpmonitoringclient.model.AlertEvent
import net.rodakot.ngxhttpmonitoringclient.model.AlertOverrides
import net.rodakot.ngxhttpmonitoringclient.model.AuthConfig
import net.rodakot.ngxhttpmonitoringclient.model.MetricSummary
import net.rodakot.ngxhttpmonitoringclient.model.ServerEditorDraft
import net.rodakot.ngxhttpmonitoringclient.model.ServerProfile
import net.rodakot.ngxhttpmonitoringclient.model.ServerStatus
import java.util.UUID
import java.util.concurrent.TimeUnit
import org.json.JSONException
class MonitorRepository(context: Context) {
private val database = MonitorDatabase(context.applicationContext)
private val cipher = CredentialCipher()
private val client = MonitorHttpClient()
private val alertCooldownMillis = TimeUnit.MINUTES.toMillis(30)
fun servers(): List<ServerProfile> = database.listServers()
fun latestSummaries(serverIds: List<String>): Map<String, MetricSummary> = database.latestSummaries(serverIds)
fun history(serverId: String): List<MetricSummary> = database.samplesForServer(serverId)
fun alerts(): List<AlertEvent> = database.alerts()
fun draftFor(server: ServerProfile?): ServerEditorDraft {
if (server == null) return ServerEditorDraft()
return ServerEditorDraft(
id = server.id,
name = server.name,
baseUrl = server.baseUrl,
tags = server.tags.joinToString(", "),
favorite = server.favorite,
allowHttp = server.allowHttp,
enabled = server.enabled,
token = cipher.decrypt(server.tokenCipherText).orEmpty(),
basicUsername = cipher.decrypt(server.basicUserCipherText).orEmpty(),
basicPassword = cipher.decrypt(server.basicPasswordCipherText).orEmpty(),
cpuThreshold = server.alertOverrides.cpuPercent?.trimmed().orEmpty(),
memoryThreshold = server.alertOverrides.memoryPercent?.trimmed().orEmpty(),
diskThreshold = server.alertOverrides.diskPercent?.trimmed().orEmpty(),
latencyThreshold = server.alertOverrides.latencyP95Millis?.trimmed().orEmpty(),
errors5xxThreshold = server.alertOverrides.errors5xx?.toString().orEmpty(),
)
}
fun saveDraft(draft: ServerEditorDraft): ServerProfile {
val server = buildServer(draft)
database.upsertServer(server)
return server
}
private fun buildServer(draft: ServerEditorDraft): ServerProfile {
val baseUrl = UrlRules.normalizeBaseUrl(draft.baseUrl)
UrlRules.validateHttpPolicy(baseUrl, draft.allowHttp)
return ServerProfile(
id = draft.id ?: UUID.randomUUID().toString(),
name = draft.name.trim().ifBlank { baseUrl },
baseUrl = baseUrl,
tags = draft.tags.split(',').map { it.trim() }.filter { it.isNotBlank() }.distinct(),
favorite = draft.favorite,
allowHttp = draft.allowHttp,
enabled = draft.enabled,
tokenCipherText = cipher.encrypt(draft.token),
basicUserCipherText = cipher.encrypt(draft.basicUsername),
basicPasswordCipherText = cipher.encrypt(draft.basicPassword),
alertOverrides = AlertOverrides(
cpuPercent = draft.cpuThreshold.toDoubleOrNull(),
memoryPercent = draft.memoryThreshold.toDoubleOrNull(),
diskPercent = draft.diskThreshold.toDoubleOrNull(),
latencyP95Millis = draft.latencyThreshold.toDoubleOrNull(),
errors5xx = draft.errors5xxThreshold.toIntOrNull(),
),
updatedAtMillis = System.currentTimeMillis(),
)
}
fun deleteServer(serverId: String) = database.deleteServer(serverId)
fun authFor(server: ServerProfile): AuthConfig = AuthConfig(
token = cipher.decrypt(server.tokenCipherText),
basicUsername = cipher.decrypt(server.basicUserCipherText),
basicPassword = cipher.decrypt(server.basicPasswordCipherText),
)
fun testConnection(draft: ServerEditorDraft): Result<String> = runCatching {
val server = buildServer(draft.copy(id = draft.id ?: "test"))
client.fetchHealth(server, authFor(server))
"Connection works"
}
fun fetchApiSummary(server: ServerProfile, forcePersist: Boolean = false): MetricSummary {
return refreshServer(server, forcePersist).summary
}
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 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)
RefreshResult(summary, recordAlerts(server, summary))
} catch (exception: MonitorClientException) {
val summary = MetricSummary(
serverId = server.id,
timestampMillis = System.currentTimeMillis(),
status = exception.monitorStatus,
message = exception.message,
)
persistSample(server, summary, null, forcePersist = true)
RefreshResult(summary, recordAlerts(server, summary))
} catch (exception: JSONException) {
val summary = MetricSummary(
serverId = server.id,
timestampMillis = System.currentTimeMillis(),
status = ServerStatus.Error,
message = "Malformed monitor JSON",
)
persistSample(server, summary, null, forcePersist = true)
RefreshResult(summary, recordAlerts(server, summary))
} catch (exception: Exception) {
val summary = MetricSummary(
serverId = server.id,
timestampMillis = System.currentTimeMillis(),
status = ServerStatus.Offline,
message = exception.message ?: "Unable to reach server",
)
persistSample(server, summary, null, forcePersist = true)
RefreshResult(summary, recordAlerts(server, summary))
}
}
fun streamLive(server: ServerProfile, onSummary: (MetricSummary) -> Unit) {
client.streamLive(server, authFor(server)) { payload ->
val parsed = MonitorJsonParser.parse(server.id, payload)
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 = false)
recordAlerts(server, summary)
onSummary(summary)
}
}
fun recordAlerts(server: ServerProfile, summary: MetricSummary): List<AlertEvent> {
val events = AlertEvaluator.evaluate(server, summary)
val cutoff = System.currentTimeMillis() - alertCooldownMillis
val fresh = events.filterNot { database.hasRecentAlert(server.id, it.title, cutoff) }
fresh.forEach(database::insertAlert)
return fresh
}
fun pruneHistory() = database.pruneOldSamples()
private fun persistSample(server: ServerProfile, summary: MetricSummary, rawJson: String?, forcePersist: Boolean) {
val now = summary.timestampMillis
val lastSummary = database.lastSampleTimestamp(server.id, rawOnly = false)
val shouldStoreSummary = HistorySamplingPolicy.shouldStoreSummary(now, lastSummary, forcePersist)
if (!shouldStoreSummary) return
val lastRaw = database.lastSampleTimestamp(server.id, rawOnly = true)
val shouldStoreRaw = HistorySamplingPolicy.shouldStoreRaw(now, lastRaw, rawJson != null, forcePersist)
database.insertSample(summary.copy(rawJson = if (shouldStoreRaw) rawJson else null))
database.pruneOldSamples(now)
}
private fun Double.trimmed(): String = if (this % 1.0 == 0.0) toInt().toString() else toString()
}
data class RefreshResult(
val summary: MetricSummary,
val alerts: List<AlertEvent>,
)

View File

@@ -0,0 +1,85 @@
package net.rodakot.ngxhttpmonitoringclient.domain
import net.rodakot.ngxhttpmonitoringclient.model.AlertEvent
import net.rodakot.ngxhttpmonitoringclient.model.AlertSeverity
import net.rodakot.ngxhttpmonitoringclient.model.DefaultCpuThreshold
import net.rodakot.ngxhttpmonitoringclient.model.DefaultDiskThreshold
import net.rodakot.ngxhttpmonitoringclient.model.DefaultErrors5xxThreshold
import net.rodakot.ngxhttpmonitoringclient.model.DefaultLatencyP95ThresholdMs
import net.rodakot.ngxhttpmonitoringclient.model.DefaultMemoryThreshold
import net.rodakot.ngxhttpmonitoringclient.model.MetricSummary
import net.rodakot.ngxhttpmonitoringclient.model.ServerProfile
import net.rodakot.ngxhttpmonitoringclient.model.ServerStatus
import java.util.UUID
object AlertEvaluator {
fun evaluate(server: ServerProfile, summary: MetricSummary): List<AlertEvent> {
val events = mutableListOf<AlertEvent>()
val now = summary.timestampMillis
when (summary.status) {
ServerStatus.AuthFailed -> events += event(server, now, AlertSeverity.Critical, "Authentication failed", summary.message)
ServerStatus.Forbidden -> events += event(server, now, AlertSeverity.Critical, "Access denied", summary.message)
ServerStatus.Missing -> events += event(server, now, AlertSeverity.Critical, "Monitor endpoint missing", summary.message)
ServerStatus.RateLimited -> events += event(server, now, AlertSeverity.Warning, "Rate limited", summary.message)
ServerStatus.Offline -> events += event(server, now, AlertSeverity.Critical, "Server unreachable", summary.message)
ServerStatus.Error -> events += event(server, now, AlertSeverity.Warning, "Monitor error", summary.message)
else -> Unit
}
val cpuLimit = server.alertOverrides.cpuPercent ?: DefaultCpuThreshold
if ((summary.cpuPercent ?: 0.0) >= cpuLimit) {
events += event(server, now, AlertSeverity.Warning, "High CPU", "CPU is ${summary.cpuPercent.formatPercent()} (limit ${cpuLimit.formatPercent()})")
}
val memoryLimit = server.alertOverrides.memoryPercent ?: DefaultMemoryThreshold
if ((summary.memoryPercent ?: 0.0) >= memoryLimit) {
events += event(server, now, AlertSeverity.Warning, "High memory", "Memory is ${summary.memoryPercent.formatPercent()} (limit ${memoryLimit.formatPercent()})")
}
val diskLimit = server.alertOverrides.diskPercent ?: DefaultDiskThreshold
if ((summary.diskPercent ?: 0.0) >= diskLimit) {
events += event(server, now, AlertSeverity.Critical, "Disk pressure", "Disk usage is ${summary.diskPercent.formatPercent()} (limit ${diskLimit.formatPercent()})")
}
val latencyLimit = server.alertOverrides.latencyP95Millis ?: DefaultLatencyP95ThresholdMs
if ((summary.latencyP95Millis ?: 0.0) >= latencyLimit) {
events += event(server, now, AlertSeverity.Warning, "High latency", "p95 latency is ${summary.latencyP95Millis?.toInt()} ms (limit ${latencyLimit.toInt()} ms)")
}
val errorsLimit = server.alertOverrides.errors5xx ?: DefaultErrors5xxThreshold
if ((summary.errors5xx ?: 0) >= errorsLimit) {
events += event(server, now, AlertSeverity.Critical, "5xx spike", "${summary.errors5xx} server errors observed (limit $errorsLimit)")
}
return events.distinctBy { it.title }
}
fun statusFor(summary: MetricSummary, server: ServerProfile): ServerStatus {
if (summary.status != ServerStatus.Online && summary.status != ServerStatus.Unknown) return summary.status
return if (evaluate(server, summary).any { it.severity == AlertSeverity.Critical }) {
ServerStatus.Degraded
} else {
ServerStatus.Online
}
}
private fun event(
server: ServerProfile,
now: Long,
severity: AlertSeverity,
title: String,
message: String,
) = AlertEvent(
id = UUID.randomUUID().toString(),
serverId = server.id,
timestampMillis = now,
severity = severity,
title = title,
message = message,
)
private fun Double?.formatPercent(): String = when (this) {
null -> "unknown"
else -> "${toInt()}%"
}
}

View File

@@ -0,0 +1,15 @@
package net.rodakot.ngxhttpmonitoringclient.domain
import net.rodakot.ngxhttpmonitoringclient.model.RawSnapshotIntervalMillis
import net.rodakot.ngxhttpmonitoringclient.model.SummarySampleIntervalMillis
object HistorySamplingPolicy {
fun shouldStoreSummary(nowMillis: Long, lastSummaryMillis: Long?, force: Boolean): Boolean {
return force || lastSummaryMillis == null || nowMillis - lastSummaryMillis >= SummarySampleIntervalMillis
}
fun shouldStoreRaw(nowMillis: Long, lastRawMillis: Long?, hasRawPayload: Boolean, force: Boolean): Boolean {
if (!hasRawPayload) return false
return force || lastRawMillis == null || nowMillis - lastRawMillis >= RawSnapshotIntervalMillis
}
}

View File

@@ -0,0 +1,33 @@
package net.rodakot.ngxhttpmonitoringclient.domain
import java.net.URI
object UrlRules {
fun normalizeBaseUrl(input: String): String {
val trimmed = input.trim().trimEnd('/')
if (trimmed.isBlank()) {
throw IllegalArgumentException("Base URL is required")
}
val uri = URI(trimmed)
val scheme = uri.scheme?.lowercase()
if (scheme != "https" && scheme != "http") {
throw IllegalArgumentException("Use an http or https URL")
}
if (uri.host.isNullOrBlank()) {
throw IllegalArgumentException("URL must include a host")
}
return trimmed
}
fun validateHttpPolicy(baseUrl: String, allowHttp: Boolean) {
val scheme = URI(baseUrl).scheme?.lowercase()
if (scheme == "http" && !allowHttp) {
throw IllegalArgumentException("HTTP must be explicitly enabled for this server")
}
}
fun endpoint(baseUrl: String, path: String): String {
val normalizedPath = if (path.startsWith("/")) path else "/$path"
return normalizeBaseUrl(baseUrl) + normalizedPath
}
}

View File

@@ -0,0 +1,130 @@
package net.rodakot.ngxhttpmonitoringclient.model
const val DefaultCpuThreshold = 85.0
const val DefaultMemoryThreshold = 90.0
const val DefaultDiskThreshold = 90.0
const val DefaultLatencyP95ThresholdMs = 1_000.0
const val DefaultErrors5xxThreshold = 5
const val HistoryRetentionDays = 30
const val SummarySampleIntervalMillis = 60_000L
const val RawSnapshotIntervalMillis = 15 * 60_000L
data class ServerProfile(
val id: String,
val name: String,
val baseUrl: String,
val tags: List<String> = emptyList(),
val favorite: Boolean = false,
val allowHttp: Boolean = false,
val enabled: Boolean = true,
val tokenCipherText: String? = null,
val basicUserCipherText: String? = null,
val basicPasswordCipherText: String? = null,
val alertOverrides: AlertOverrides = AlertOverrides(),
val updatedAtMillis: Long = System.currentTimeMillis(),
)
data class AuthConfig(
val token: String? = null,
val basicUsername: String? = null,
val basicPassword: String? = null,
)
data class AlertOverrides(
val cpuPercent: Double? = null,
val memoryPercent: Double? = null,
val diskPercent: Double? = null,
val latencyP95Millis: Double? = null,
val errors5xx: Int? = null,
)
enum class ServerStatus {
Unknown,
Online,
Degraded,
Offline,
AuthFailed,
Forbidden,
Missing,
RateLimited,
Insecure,
Error,
}
enum class AlertSeverity {
Info,
Warning,
Critical,
}
data class MetricSummary(
val serverId: String,
val timestampMillis: Long,
val status: ServerStatus = ServerStatus.Unknown,
val message: String = "Waiting for data",
val cpuPercent: Double? = null,
val memoryPercent: Double? = null,
val diskPercent: Double? = null,
val requestRate: Double? = null,
val latencyP95Millis: Double? = null,
val errors4xx: Int? = null,
val errors5xx: Int? = null,
val activeConnections: Int? = null,
val rawJson: String? = null,
)
data class AlertEvent(
val id: String,
val serverId: String,
val timestampMillis: Long,
val severity: AlertSeverity,
val title: String,
val message: String,
val resolved: Boolean = false,
)
data class ServerEditorDraft(
val id: String? = null,
val name: String = "",
val baseUrl: String = "",
val tags: String = "",
val favorite: Boolean = false,
val allowHttp: Boolean = false,
val enabled: Boolean = true,
val token: String = "",
val basicUsername: String = "",
val basicPassword: String = "",
val cpuThreshold: String = "",
val memoryThreshold: String = "",
val diskThreshold: String = "",
val latencyThreshold: String = "",
val errors5xxThreshold: String = "",
val testMessage: String? = null,
val isTesting: Boolean = false,
)
enum class DetailTab(val label: String) {
Overview("Overview"),
System("System"),
Nginx("Nginx"),
Requests("Requests"),
Disk("Disk"),
History("History"),
Alerts("Alerts"),
Settings("Settings"),
}
data class MonitorUiState(
val servers: List<ServerProfile> = emptyList(),
val summaries: Map<String, MetricSummary> = emptyMap(),
val history: Map<String, List<MetricSummary>> = emptyMap(),
val alerts: List<AlertEvent> = emptyList(),
val selectedServerId: String? = null,
val showDetail: Boolean = false,
val selectedTab: DetailTab = DetailTab.Overview,
val query: String = "",
val tagFilter: String? = null,
val editorDraft: ServerEditorDraft? = null,
val globalMessage: String? = null,
val liveMonitoring: Boolean = false,
)

File diff suppressed because it is too large Load Diff

View File

@@ -2,10 +2,14 @@ package net.rodakot.ngxhttpmonitoringclient.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val CommandBlack = Color(0xFF0E1116)
val CommandPanel = Color(0xFF171C23)
val CommandPanelRaised = Color(0xFF202733)
val CommandText = Color(0xFFEAF0F7)
val CommandMuted = Color(0xFF9AA6B6)
val CommandLight = Color(0xFFF5F7FA)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
val SignalGreen = Color(0xFF35D38B)
val SignalCyan = Color(0xFF4CC9F0)
val SignalAmber = Color(0xFFFFB84D)
val SignalRed = Color(0xFFFF5A52)

View File

@@ -1,6 +1,5 @@
package net.rodakot.ngxhttpmonitoringclient.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
@@ -9,18 +8,37 @@ import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
primary = SignalGreen,
secondary = SignalCyan,
tertiary = SignalAmber,
error = SignalRed,
background = CommandBlack,
surface = CommandPanel,
surfaceVariant = Color(0xFF2A3340),
surfaceContainer = CommandPanelRaised,
onBackground = CommandText,
onSurface = CommandText,
onSurfaceVariant = CommandMuted,
outline = Color(0xFF3A4555)
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
primary = Color(0xFF08764D),
secondary = Color(0xFF146B8D),
tertiary = Color(0xFF8A5A00),
error = Color(0xFFC73532),
background = CommandLight,
surface = Color.White,
surfaceVariant = Color(0xFFE0E7EF),
surfaceContainer = Color(0xFFEAF0F6),
onBackground = Color(0xFF111820),
onSurface = Color(0xFF111820),
onSurfaceVariant = Color(0xFF566273),
outline = Color(0xFFC6D0DC)
/* Other default colors to override
background = Color(0xFFFFFBFE),
@@ -35,9 +53,9 @@ private val LightColorScheme = lightColorScheme(
@Composable
fun NGXHttpMonitoringClientTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
darkTheme: Boolean = true,
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
dynamicColor: Boolean = false,
content: @Composable () -> Unit
) {
val colorScheme = when {

View File

@@ -13,7 +13,21 @@ val Typography = Typography(
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
letterSpacing = 0.sp
),
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 11.sp,
lineHeight = 14.sp,
letterSpacing = 0.sp
)
/* Other default text styles to override
titleLarge = TextStyle(

View File

@@ -1,3 +1,3 @@
<resources>
<string name="app_name">NGX http Monitoring Client</string>
<string name="app_name">NGX Monitor</string>
</resources>

View File

@@ -1,17 +0,0 @@
package net.rodakot.ngxhttpmonitoringclient
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

View File

@@ -0,0 +1,68 @@
package net.rodakot.ngxhttpmonitoringclient.domain
import net.rodakot.ngxhttpmonitoringclient.model.AlertOverrides
import net.rodakot.ngxhttpmonitoringclient.model.AlertSeverity
import net.rodakot.ngxhttpmonitoringclient.model.MetricSummary
import net.rodakot.ngxhttpmonitoringclient.model.ServerProfile
import net.rodakot.ngxhttpmonitoringclient.model.ServerStatus
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class AlertEvaluatorTest {
private val server = ServerProfile(
id = "server-1",
name = "edge-1",
baseUrl = "https://edge-1.example.com",
)
@Test
fun evaluate_returnsNoAlertsForHealthySummary() {
val alerts = AlertEvaluator.evaluate(
server,
MetricSummary(
serverId = server.id,
timestampMillis = 1L,
status = ServerStatus.Online,
cpuPercent = 20.0,
memoryPercent = 40.0,
diskPercent = 50.0,
latencyP95Millis = 80.0,
errors5xx = 0,
),
)
assertTrue(alerts.isEmpty())
}
@Test
fun evaluate_usesPerServerOverrides() {
val alerts = AlertEvaluator.evaluate(
server.copy(alertOverrides = AlertOverrides(cpuPercent = 50.0)),
MetricSummary(
serverId = server.id,
timestampMillis = 1L,
status = ServerStatus.Online,
cpuPercent = 51.0,
),
)
assertEquals(listOf("High CPU"), alerts.map { it.title })
}
@Test
fun evaluate_marksAuthFailureCritical() {
val alerts = AlertEvaluator.evaluate(
server,
MetricSummary(
serverId = server.id,
timestampMillis = 1L,
status = ServerStatus.AuthFailed,
message = "Token rejected",
),
)
assertEquals(AlertSeverity.Critical, alerts.single().severity)
assertEquals("Authentication failed", alerts.single().title)
}
}

View File

@@ -0,0 +1,22 @@
package net.rodakot.ngxhttpmonitoringclient.domain
import net.rodakot.ngxhttpmonitoringclient.model.RawSnapshotIntervalMillis
import net.rodakot.ngxhttpmonitoringclient.model.SummarySampleIntervalMillis
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class HistorySamplingPolicyTest {
@Test
fun shouldStoreSummary_everyMinuteByDefault() {
assertFalse(HistorySamplingPolicy.shouldStoreSummary(10_000L, 0L, force = false))
assertTrue(HistorySamplingPolicy.shouldStoreSummary(SummarySampleIntervalMillis, 0L, force = false))
}
@Test
fun shouldStoreRaw_everyFifteenMinutesWhenPayloadExists() {
assertFalse(HistorySamplingPolicy.shouldStoreRaw(10_000L, 0L, hasRawPayload = true, force = false))
assertTrue(HistorySamplingPolicy.shouldStoreRaw(RawSnapshotIntervalMillis, 0L, hasRawPayload = true, force = false))
assertFalse(HistorySamplingPolicy.shouldStoreRaw(RawSnapshotIntervalMillis, 0L, hasRawPayload = false, force = true))
}
}

View File

@@ -0,0 +1,27 @@
package net.rodakot.ngxhttpmonitoringclient.domain
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Test
class UrlRulesTest {
@Test
fun normalizeBaseUrl_trimsTrailingSlash() {
assertEquals("https://monitor.example.com", UrlRules.normalizeBaseUrl(" https://monitor.example.com/ "))
}
@Test
fun validateHttpPolicy_rejectsHttpWithoutOptIn() {
assertThrows(IllegalArgumentException::class.java) {
UrlRules.validateHttpPolicy("http://10.0.0.10:8080", allowHttp = false)
}
}
@Test
fun endpoint_appendsMonitorPath() {
assertEquals(
"https://monitor.example.com/monitor/api",
UrlRules.endpoint("https://monitor.example.com/", "/monitor/api"),
)
}
}

View File

@@ -1,6 +1,7 @@
[versions]
agp = "9.2.1"
coreKtx = "1.18.0"
coroutines = "1.9.0"
junit = "4.13.2"
junitVersion = "1.3.0"
espressoCore = "3.7.0"
@@ -24,8 +25,11 @@ androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "u
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
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" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }

44
proposed-plan.md Normal file
View File

@@ -0,0 +1,44 @@
# NGX HTTP Monitoring Client App Plan
## Summary
Build a polished Jetpack Compose operations app for monitoring 20+ Nginx monitoring-module servers. The app is local-first, stores encrypted per-server credentials, uses foreground SSE streams for all enabled servers while
the app is open, and uses Android-friendly periodic background checks for alerts.
## Key Changes
- Add app architecture with Compose, Navigation, ViewModels, Room, WorkManager, OkHttp SSE, kotlinx serialization, and encrypted credential storage.
- Create core models: ServerProfile, AuthConfig, MonitorSnapshot, MetricSummary, AlertRule, AlertEvent, and ServerTag.
- Support /monitor/api, /monitor/health, and /monitor/live; send Basic Auth and X-Monitor-Token when configured.
- Add first-run and server setup flow: name, base URL, tags, favorite flag, token, optional Basic Auth, opt-in HTTP toggle, and test connection.
- Store derived metric summaries every 1 minute and full raw JSON snapshots every 15 minutes, retained for 30 days.
## App Experience
- Dashboard: fleet health counts, search, tags, favorites, critical-first sorting, stale/offline indicators, and compact server cards with CPU, memory, disk, request rate, latency, 4xx/5xx, active connections, and last
update.
- Server detail: live overview plus tabs for System, Nginx, Requests, Network, Disk, Processes, Upstreams, History, Alerts, and Settings.
- Alerts: ship global defaults with per-server overrides for unreachable/stale server, CPU/memory/disk pressure, high 5xx, high latency, and auth/API failures.
- Background monitoring: WorkManager periodic checks using /monitor/health and lightweight API fetches; local notifications fire only on state changes or sustained rule breaches.
- Security UX: HTTPS by default; HTTP allowed only per server with a visible warning. Never log tokens, Basic Auth, or URLs containing secrets.
## Failure Handling
- Treat missing JSON fields as unavailable collectors, not crashes.
- Show clear states for 401, 403, 404, 429, timeout, TLS failure, malformed JSON, and stale SSE.
- Foreground SSE opens one stream per enabled server; if a stream fails repeatedly, fall back to staggered JSON polling for that server and show degraded-live status.
- Apply retry backoff and avoid notification spam with cooldowns and alert state tracking.
## Test Plan
- Unit tests for JSON parsing with missing collectors, auth header creation, alert evaluation, retention pruning, URL validation, and HTTP opt-in rules.
- Repository tests with fake monitor responses for /monitor/api, /monitor/health, and SSE events.
- Compose UI tests for empty state, add server, dashboard filtering/sorting, server detail tabs, and alert rule editing.
- Background-worker tests for periodic checks, notification deduping, and failed-auth handling.
## Assumptions
- No cloud backend or account sync in v1; all data stays on the phone.
- Android minSdk remains 24.
- “SSE everywhere” means all enabled servers while the app is foregrounded; background monitoring uses periodic checks for Android reliability.
- Long history means balanced local retention, not every SSE payload forever.

1
resume Normal file
View File

@@ -0,0 +1 @@
codex resume 019e0523-11be-7293-8a05-2738ee75a23d