diff --git a/.gitignore b/.gitignore
index aa724b7..d79b3d4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
*.iml
.gradle
+.gradle-user
/local.properties
/.idea/caches
/.idea/libraries
diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml
index ca16a99..b74c6b0 100644
--- a/.idea/deploymentTargetSelector.xml
+++ b/.idea/deploymentTargetSelector.xml
@@ -4,6 +4,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml
new file mode 100644
index 0000000..91f9558
--- /dev/null
+++ b/.idea/deviceManager.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index cdbc250..02c4aa5 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -1,5 +1,6 @@
+
diff --git a/.idea/misc.xml b/.idea/misc.xml
index a4f09e2..1a1bf72 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -1,4 +1,3 @@
-
diff --git a/.idea/studiobot.xml b/.idea/studiobot.xml
new file mode 100644
index 0000000..9298202
--- /dev/null
+++ b/.idea/studiobot.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index b93211a..50129a1 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -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)
@@ -55,4 +63,4 @@ dependencies {
androidTestImplementation(libs.androidx.junit)
debugImplementation(libs.androidx.compose.ui.test.manifest)
debugImplementation(libs.androidx.compose.ui.tooling)
-}
\ No newline at end of file
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index c72ed3f..3dbaf6f 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,7 +2,12 @@
+
+
+
+
+ android:theme="@style/Theme.NGXHttpMonitoringClient"
+ android:usesCleartextTraffic="true">
+
-
\ No newline at end of file
+
diff --git a/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/MainActivity.kt b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/MainActivity.kt
index 60f8ae2..4f306b7 100644
--- a/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/MainActivity.kt
+++ b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/MainActivity.kt
@@ -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)
+ }
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/MonitorController.kt b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/MonitorController.kt
new file mode 100644
index 0000000..152c061
--- /dev/null
+++ b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/MonitorController.kt
@@ -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()
+ private val _state = MutableStateFlow(MonitorUiState())
+
+ val state: StateFlow = _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) {
+ 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 }
+ }
+}
diff --git a/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/NgxMonitorApplication.kt b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/NgxMonitorApplication.kt
new file mode 100644
index 0000000..1a247b0
--- /dev/null
+++ b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/NgxMonitorApplication.kt
@@ -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)
+ }
+}
diff --git a/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/background/MonitorJobScheduler.kt b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/background/MonitorJobScheduler.kt
new file mode 100644
index 0000000..778373a
--- /dev/null
+++ b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/background/MonitorJobScheduler.kt
@@ -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)
+ }
+}
diff --git a/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/background/MonitorJobService.kt b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/background/MonitorJobService.kt
new file mode 100644
index 0000000..c6c1476
--- /dev/null
+++ b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/background/MonitorJobService.kt
@@ -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)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/background/MonitorNotifications.kt b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/background/MonitorNotifications.kt
new file mode 100644
index 0000000..20aa4d6
--- /dev/null
+++ b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/background/MonitorNotifications.kt
@@ -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)
+ }
+ }
+}
diff --git a/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/data/CredentialCipher.kt b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/data/CredentialCipher.kt
new file mode 100644
index 0000000..3222579
--- /dev/null
+++ b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/data/CredentialCipher.kt
@@ -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"
+ }
+}
diff --git a/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/data/MonitorDatabase.kt b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/data/MonitorDatabase.kt
new file mode 100644
index 0000000..6104db8
--- /dev/null
+++ b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/data/MonitorDatabase.kt
@@ -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 {
+ val result = mutableListOf()
+ 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): Map {
+ val result = linkedMapOf()
+ 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 {
+ val result = mutableListOf()
+ 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 {
+ val result = mutableListOf()
+ 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 {
+ val array = JSONArray()
+ tags.map { it.trim() }.filter { it.isNotBlank() }.distinct().forEach(array::put)
+ return array.toString()
+ }
+
+ private fun tagsFromJson(value: String): List = 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)
+ }
+}
diff --git a/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/data/MonitorHttpClient.kt b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/data/MonitorHttpClient.kt
new file mode 100644
index 0000000..0522a57
--- /dev/null
+++ b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/data/MonitorHttpClient.kt
@@ -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)
diff --git a/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/data/MonitorJsonParser.kt b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/data/MonitorJsonParser.kt
new file mode 100644
index 0000000..6b98645
--- /dev/null
+++ b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/data/MonitorJsonParser.kt
@@ -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
+ }
+ }
+}
diff --git a/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/data/MonitorRepository.kt b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/data/MonitorRepository.kt
new file mode 100644
index 0000000..980f625
--- /dev/null
+++ b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/data/MonitorRepository.kt
@@ -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 = database.listServers()
+
+ fun latestSummaries(serverIds: List): Map = database.latestSummaries(serverIds)
+
+ fun history(serverId: String): List = database.samplesForServer(serverId)
+
+ fun alerts(): List = 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 = 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 {
+ 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,
+)
diff --git a/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/domain/AlertEvaluator.kt b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/domain/AlertEvaluator.kt
new file mode 100644
index 0000000..23afc8a
--- /dev/null
+++ b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/domain/AlertEvaluator.kt
@@ -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 {
+ val events = mutableListOf()
+ 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()}%"
+ }
+}
diff --git a/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/domain/HistorySamplingPolicy.kt b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/domain/HistorySamplingPolicy.kt
new file mode 100644
index 0000000..cc7e786
--- /dev/null
+++ b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/domain/HistorySamplingPolicy.kt
@@ -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
+ }
+}
diff --git a/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/domain/UrlRules.kt b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/domain/UrlRules.kt
new file mode 100644
index 0000000..ea9a6ef
--- /dev/null
+++ b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/domain/UrlRules.kt
@@ -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
+ }
+}
diff --git a/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/model/MonitorModels.kt b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/model/MonitorModels.kt
new file mode 100644
index 0000000..be2f94c
--- /dev/null
+++ b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/model/MonitorModels.kt
@@ -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 = 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 = emptyList(),
+ val summaries: Map = emptyMap(),
+ val history: Map> = emptyMap(),
+ val alerts: List = 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,
+)
diff --git a/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/ui/MonitorApp.kt b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/ui/MonitorApp.kt
new file mode 100644
index 0000000..a8f6e58
--- /dev/null
+++ b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/ui/MonitorApp.kt
@@ -0,0 +1,1017 @@
+package net.rodakot.ngxhttpmonitoringclient.ui
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.Button
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import net.rodakot.ngxhttpmonitoringclient.MonitorController
+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.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 net.rodakot.ngxhttpmonitoringclient.model.ServerStatus
+import java.text.DateFormat
+import java.util.Date
+import kotlin.math.max
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun MonitorApp(controller: MonitorController) {
+ val state by controller.state.collectAsState()
+ val selected = state.servers.firstOrNull { it.id == state.selectedServerId }
+
+ DisposableEffect(controller) {
+ controller.startLiveMonitoring()
+ onDispose { controller.stopLiveMonitoring() }
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = {
+ Column {
+ Text(
+ text = if (state.showDetail && selected != null) selected.name else "Command Deck",
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ fontWeight = FontWeight.Bold,
+ )
+ Text(
+ text = if (state.showDetail && selected != null) selected.baseUrl else "NGX fleet monitor",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ },
+ navigationIcon = {
+ if (state.showDetail) {
+ TextButton(onClick = controller::showDashboard) { Text("Back") }
+ }
+ },
+ actions = {
+ TextButton(onClick = controller::refreshAll) { Text("Sync") }
+ LiveSwitch(
+ live = state.liveMonitoring,
+ onClick = {
+ if (state.liveMonitoring) controller.stopLiveMonitoring() else controller.startLiveMonitoring()
+ },
+ )
+ Spacer(Modifier.width(10.dp))
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.background,
+ titleContentColor = MaterialTheme.colorScheme.onBackground,
+ ),
+ )
+ },
+ containerColor = MaterialTheme.colorScheme.background,
+ ) { padding ->
+ if (state.showDetail && selected != null) {
+ ServerCockpit(
+ state = state,
+ server = selected,
+ controller = controller,
+ modifier = Modifier.padding(padding),
+ )
+ } else {
+ FleetCommandDeck(
+ state = state,
+ controller = controller,
+ modifier = Modifier.padding(padding),
+ )
+ }
+ }
+
+ state.editorDraft?.let { draft ->
+ ServerEditorDialog(
+ draft = draft,
+ onDismiss = controller::dismissEditor,
+ onSave = controller::saveEditor,
+ onTest = controller::testEditorConnection,
+ onChange = controller::updateDraft,
+ )
+ }
+}
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+private fun FleetCommandDeck(
+ state: MonitorUiState,
+ controller: MonitorController,
+ modifier: Modifier = Modifier,
+) {
+ val filtered = filteredServers(state)
+ val tags = state.servers.flatMap { it.tags }.distinct().sorted()
+
+ LazyColumn(
+ modifier = modifier.fillMaxSize(),
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ item {
+ FleetHero(
+ state = state,
+ onAdd = controller::showAddServer,
+ )
+ }
+ item {
+ CommandFilters(
+ query = state.query,
+ tags = tags,
+ selectedTag = state.tagFilter,
+ onQuery = controller::setQuery,
+ onTag = controller::setTagFilter,
+ )
+ }
+ item {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text("Priority Queue", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f))
+ Text("${filtered.size} visible", color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodySmall)
+ }
+ }
+ if (state.servers.isEmpty()) {
+ item { EmptyFleet(onAdd = controller::showAddServer) }
+ } else if (filtered.isEmpty()) {
+ item { EmptyPanel("No servers match the current command filters.") }
+ } else {
+ items(filtered, key = { it.id }) { server ->
+ ServerCommandRow(
+ server = server,
+ summary = state.summaries[server.id],
+ onClick = { controller.selectServer(server.id) },
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun FleetHero(state: MonitorUiState, onAdd: () -> Unit) {
+ val counters = fleetCounters(state)
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(8.dp),
+ color = MaterialTheme.colorScheme.surface,
+ tonalElevation = 2.dp,
+ ) {
+ BoxWithConstraints(Modifier.padding(18.dp)) {
+ val compact = maxWidth < 420.dp
+ if (compact) {
+ Column(verticalArrangement = Arrangement.spacedBy(18.dp)) {
+ HeroCopy(state, counters, onAdd)
+ Row(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.CenterVertically) {
+ FleetGauge(counters, Modifier.size(142.dp))
+ CounterStack(counters, Modifier.weight(1f))
+ }
+ }
+ } else {
+ Row(horizontalArrangement = Arrangement.spacedBy(18.dp), verticalAlignment = Alignment.CenterVertically) {
+ HeroCopy(state, counters, onAdd, Modifier.weight(1f))
+ FleetGauge(counters, Modifier.size(154.dp))
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun HeroCopy(state: MonitorUiState, counters: FleetCounters, onAdd: () -> Unit, modifier: Modifier = Modifier) {
+ Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(14.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text("NOC Overview", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f))
+ Button(onClick = onAdd, shape = RoundedCornerShape(8.dp)) { Text("Add") }
+ }
+ Text(
+ text = "${state.servers.size} servers, ${counters.critical} critical, ${counters.watch} on watch",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
+ CounterChip("Online", counters.online, StatusOnline, Modifier.weight(1f))
+ CounterChip("Watch", counters.watch, StatusWarning, Modifier.weight(1f))
+ CounterChip("Down", counters.critical, StatusCritical, Modifier.weight(1f))
+ }
+ }
+}
+
+@Composable
+private fun CounterStack(counters: FleetCounters, modifier: Modifier = Modifier) {
+ Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ CounterChip("Online", counters.online, StatusOnline)
+ CounterChip("Watch", counters.watch, StatusWarning)
+ CounterChip("Down", counters.critical, StatusCritical)
+ CounterChip("Waiting", counters.waiting, StatusUnknown)
+ }
+}
+
+@Composable
+private fun CounterChip(label: String, value: Int, color: Color, modifier: Modifier = Modifier) {
+ Surface(
+ modifier = modifier,
+ shape = RoundedCornerShape(8.dp),
+ color = color.copy(alpha = 0.13f),
+ ) {
+ Column(Modifier.padding(horizontal = 10.dp, vertical = 9.dp)) {
+ Text(value.toString(), color = color, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleLarge)
+ Text(label, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.labelSmall, maxLines = 1)
+ }
+ }
+}
+
+@Composable
+private fun FleetGauge(counters: FleetCounters, modifier: Modifier = Modifier) {
+ val total = counters.total.coerceAtLeast(1)
+ val onlineSweep = counters.online / total.toFloat()
+ val watchSweep = counters.watch / total.toFloat()
+ val criticalSweep = counters.critical / total.toFloat()
+ Box(modifier, contentAlignment = Alignment.Center) {
+ Canvas(Modifier.fillMaxSize()) {
+ val stroke = Stroke(width = 18f, cap = StrokeCap.Round)
+ drawArc(StatusUnknown.copy(alpha = 0.18f), -90f, 360f, false, style = stroke)
+ var start = -90f
+ drawArc(StatusOnline, start, onlineSweep * 360f, false, style = stroke)
+ start += onlineSweep * 360f
+ drawArc(StatusWarning, start, watchSweep * 360f, false, style = stroke)
+ start += watchSweep * 360f
+ drawArc(StatusCritical, start, criticalSweep * 360f, false, style = stroke)
+ }
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(counters.total.toString(), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
+ Text("servers", color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.labelMedium)
+ }
+ }
+}
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+private fun CommandFilters(
+ query: String,
+ tags: List,
+ selectedTag: String?,
+ onQuery: (String) -> Unit,
+ onTag: (String?) -> Unit,
+) {
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(8.dp),
+ color = MaterialTheme.colorScheme.surface,
+ tonalElevation = 1.dp,
+ ) {
+ Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ OutlinedTextField(
+ value = query,
+ onValueChange = onQuery,
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text("Find server, URL, or tag") },
+ singleLine = true,
+ shape = RoundedCornerShape(8.dp),
+ )
+ if (tags.isNotEmpty()) {
+ FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ FilterChip(selected = selectedTag == null, onClick = { onTag(null) }, label = { Text("All") })
+ tags.forEach { tag ->
+ FilterChip(selected = selectedTag == tag, onClick = { onTag(tag) }, label = { Text(tag) })
+ }
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+private fun ServerCommandRow(server: ServerProfile, summary: MetricSummary?, onClick: () -> Unit) {
+ val status = summary?.status ?: ServerStatus.Unknown
+ val health = healthScore(summary)
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick),
+ shape = RoundedCornerShape(8.dp),
+ color = MaterialTheme.colorScheme.surface,
+ tonalElevation = 1.dp,
+ ) {
+ Row(modifier = Modifier.heightIn(min = 118.dp)) {
+ Box(
+ modifier = Modifier
+ .width(5.dp)
+ .fillMaxHeight()
+ .background(colorForStatus(status)),
+ )
+ Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Column(Modifier.weight(1f)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ server.name,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f),
+ )
+ if (server.favorite) LabelCapsule("PIN", MaterialTheme.colorScheme.primary)
+ }
+ Text(
+ server.baseUrl,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ style = MaterialTheme.typography.bodySmall,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ Spacer(Modifier.width(10.dp))
+ HealthBadge(health, status)
+ }
+ FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ LabelCapsule(statusLabel(status).uppercase(), colorForStatus(status))
+ if (!server.enabled) LabelCapsule("OFF", StatusUnknown)
+ if (server.allowHttp) LabelCapsule("HTTP", StatusInsecure)
+ server.tags.take(3).forEach { LabelCapsule(it.uppercase(), MaterialTheme.colorScheme.secondary) }
+ }
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
+ MiniMetric("CPU", summary?.cpuPercent.formatPercent(), colorForPercent(summary?.cpuPercent), Modifier.weight(1f))
+ MiniMetric("MEM", summary?.memoryPercent.formatPercent(), colorForPercent(summary?.memoryPercent), Modifier.weight(1f))
+ MiniMetric("RPS", summary?.requestRate.formatNumber(), MaterialTheme.colorScheme.primary, Modifier.weight(1f))
+ MiniMetric("5XX", summary?.errors5xx?.toString() ?: "--", StatusCritical, Modifier.weight(1f))
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ServerCockpit(
+ state: MonitorUiState,
+ server: ServerProfile,
+ controller: MonitorController,
+ modifier: Modifier = Modifier,
+) {
+ val summary = state.summaries[server.id]
+ val history = state.history[server.id].orEmpty()
+ LazyColumn(
+ modifier = modifier.fillMaxSize(),
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ item {
+ CockpitHero(server, summary, controller)
+ }
+ item {
+ CockpitTabs(selected = state.selectedTab, onSelect = controller::setDetailTab)
+ }
+ when (state.selectedTab) {
+ DetailTab.Overview -> item { OverviewCockpit(summary, history) }
+ DetailTab.System -> item { SystemCockpit(summary) }
+ DetailTab.Nginx -> item { NginxCockpit(summary) }
+ DetailTab.Requests -> item { RequestsCockpit(summary, history) }
+ DetailTab.Disk -> item { DiskCockpit(summary, history) }
+ DetailTab.History -> item { HistoryCockpit(history) }
+ DetailTab.Alerts -> {
+ val alerts = state.alerts.filter { it.serverId == server.id }
+ if (alerts.isEmpty()) item { EmptyPanel("No recent alerts for this server.") }
+ else items(alerts) { alert -> AlertTicket(alert) }
+ }
+ DetailTab.Settings -> item { SettingsCockpit(server, controller) }
+ }
+ }
+}
+
+@Composable
+private fun CockpitHero(server: ServerProfile, summary: MetricSummary?, controller: MonitorController) {
+ val status = summary?.status ?: ServerStatus.Unknown
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(8.dp),
+ color = MaterialTheme.colorScheme.surface,
+ tonalElevation = 2.dp,
+ ) {
+ Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ HealthRing(
+ score = healthScore(summary),
+ color = colorForStatus(status),
+ modifier = Modifier.size(92.dp),
+ )
+ Spacer(Modifier.width(14.dp))
+ Column(Modifier.weight(1f)) {
+ Text(statusLabel(status), color = colorForStatus(status), fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium)
+ Text(summary?.message ?: "Waiting for monitor data", color = MaterialTheme.colorScheme.onSurfaceVariant)
+ Text(summary?.timestampMillis.formatTime(), color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodySmall)
+ }
+ OutlinedButton(onClick = controller::refreshSelected, shape = RoundedCornerShape(8.dp)) { Text("Pulse") }
+ }
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
+ MiniMetric("CPU", summary?.cpuPercent.formatPercent(), colorForPercent(summary?.cpuPercent), Modifier.weight(1f))
+ MiniMetric("MEM", summary?.memoryPercent.formatPercent(), colorForPercent(summary?.memoryPercent), Modifier.weight(1f))
+ MiniMetric("DISK", summary?.diskPercent.formatPercent(), colorForPercent(summary?.diskPercent), Modifier.weight(1f))
+ MiniMetric("P95", summary?.latencyP95Millis.formatMillis(), StatusWarning, Modifier.weight(1f))
+ }
+ }
+ }
+}
+
+@Composable
+private fun CockpitTabs(selected: DetailTab, onSelect: (DetailTab) -> Unit) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .horizontalScroll(rememberScrollState()),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ DetailTab.entries.forEach { tab ->
+ FilterChip(
+ selected = selected == tab,
+ onClick = { onSelect(tab) },
+ label = { Text(tab.label, maxLines = 1) },
+ )
+ }
+ }
+}
+
+@Composable
+private fun OverviewCockpit(summary: MetricSummary?, history: List) {
+ Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ SignalBoard(summary)
+ TelemetryChart("CPU telemetry", history.mapNotNull { it.cpuPercent }, StatusOnline)
+ TelemetryChart("Memory telemetry", history.mapNotNull { it.memoryPercent }, StatusWarning)
+ }
+}
+
+@Composable
+private fun SystemCockpit(summary: MetricSummary?) {
+ DataPanel("System Load") {
+ Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
+ Meter("CPU usage", summary?.cpuPercent, 100.0)
+ Meter("Memory used", summary?.memoryPercent, 100.0)
+ MiniMetric("Last update", summary?.timestampMillis.formatTime(), MaterialTheme.colorScheme.primary, Modifier.fillMaxWidth())
+ }
+ }
+}
+
+@Composable
+private fun NginxCockpit(summary: MetricSummary?) {
+ DataPanel("Nginx Signals") { SignalBoard(summary, system = false) }
+}
+
+@Composable
+private fun RequestsCockpit(summary: MetricSummary?, history: List) {
+ Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ DataPanel("Request Path") {
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
+ MiniMetric("RPS", summary?.requestRate.formatNumber(), MaterialTheme.colorScheme.primary, Modifier.weight(1f))
+ MiniMetric("P95", summary?.latencyP95Millis.formatMillis(), StatusWarning, Modifier.weight(1f))
+ MiniMetric("4XX", summary?.errors4xx?.toString() ?: "--", StatusInsecure, Modifier.weight(1f))
+ MiniMetric("5XX", summary?.errors5xx?.toString() ?: "--", StatusCritical, Modifier.weight(1f))
+ }
+ }
+ TelemetryChart("Request rate", history.mapNotNull { it.requestRate }, MaterialTheme.colorScheme.primary)
+ TelemetryChart("p95 latency", history.mapNotNull { it.latencyP95Millis }, StatusCritical)
+ }
+}
+
+@Composable
+private fun DiskCockpit(summary: MetricSummary?, history: List) {
+ Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ DataPanel("Storage") { Meter("Disk usage", summary?.diskPercent, 100.0) }
+ TelemetryChart("Disk telemetry", history.mapNotNull { it.diskPercent }, StatusCritical)
+ }
+}
+
+@Composable
+private fun HistoryCockpit(history: List) {
+ Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ TelemetryChart("CPU", history.mapNotNull { it.cpuPercent }, StatusOnline)
+ TelemetryChart("Memory", history.mapNotNull { it.memoryPercent }, StatusWarning)
+ TelemetryChart("Disk", history.mapNotNull { it.diskPercent }, StatusCritical)
+ Text("${history.size} retained samples in view", color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+}
+
+@Composable
+private fun SettingsCockpit(server: ServerProfile, controller: MonitorController) {
+ DataPanel("Alert Rules") {
+ Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ RuleRow("CPU limit", "${server.alertOverrides.cpuPercent ?: DefaultCpuThreshold}%")
+ RuleRow("Memory limit", "${server.alertOverrides.memoryPercent ?: DefaultMemoryThreshold}%")
+ RuleRow("Disk limit", "${server.alertOverrides.diskPercent ?: DefaultDiskThreshold}%")
+ RuleRow("p95 latency", "${(server.alertOverrides.latencyP95Millis ?: DefaultLatencyP95ThresholdMs).toInt()} ms")
+ RuleRow("5xx limit", "${server.alertOverrides.errors5xx ?: DefaultErrors5xxThreshold}")
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ Button(onClick = controller::showEditSelectedServer, shape = RoundedCornerShape(8.dp)) { Text("Edit") }
+ OutlinedButton(onClick = controller::deleteSelectedServer, shape = RoundedCornerShape(8.dp)) { Text("Delete") }
+ }
+ }
+ }
+}
+
+@Composable
+private fun SignalBoard(summary: MetricSummary?, system: Boolean = true) {
+ DataPanel("Live Signals") {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
+ MiniMetric("CONN", summary?.activeConnections?.toString() ?: "--", MaterialTheme.colorScheme.secondary, Modifier.weight(1f))
+ MiniMetric("RPS", summary?.requestRate.formatNumber(), MaterialTheme.colorScheme.primary, Modifier.weight(1f))
+ }
+ if (system) {
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
+ MiniMetric("CPU", summary?.cpuPercent.formatPercent(), colorForPercent(summary?.cpuPercent), Modifier.weight(1f))
+ MiniMetric("MEM", summary?.memoryPercent.formatPercent(), colorForPercent(summary?.memoryPercent), Modifier.weight(1f))
+ MiniMetric("DISK", summary?.diskPercent.formatPercent(), colorForPercent(summary?.diskPercent), Modifier.weight(1f))
+ }
+ }
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
+ MiniMetric("P95", summary?.latencyP95Millis.formatMillis(), StatusWarning, Modifier.weight(1f))
+ MiniMetric("4XX", summary?.errors4xx?.toString() ?: "--", StatusInsecure, Modifier.weight(1f))
+ MiniMetric("5XX", summary?.errors5xx?.toString() ?: "--", StatusCritical, Modifier.weight(1f))
+ }
+ }
+ }
+}
+
+@Composable
+private fun DataPanel(title: String, content: @Composable () -> Unit) {
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(8.dp),
+ color = MaterialTheme.colorScheme.surface,
+ tonalElevation = 1.dp,
+ ) {
+ Column(Modifier.padding(15.dp), verticalArrangement = Arrangement.spacedBy(13.dp)) {
+ Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
+ content()
+ }
+ }
+}
+
+@Composable
+private fun EmptyPanel(text: String) {
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(8.dp),
+ color = MaterialTheme.colorScheme.surface,
+ tonalElevation = 1.dp,
+ ) {
+ Text(text, modifier = Modifier.padding(18.dp), color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+}
+
+@Composable
+private fun EmptyFleet(onAdd: () -> Unit) {
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(8.dp),
+ color = MaterialTheme.colorScheme.surface,
+ tonalElevation = 1.dp,
+ ) {
+ Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ Text("The command deck is empty", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
+ Text("Add an Nginx monitor endpoint to start tracking live server signals.", color = MaterialTheme.colorScheme.onSurfaceVariant)
+ Button(onClick = onAdd, shape = RoundedCornerShape(8.dp)) { Text("Add first server") }
+ }
+ }
+}
+
+@Composable
+private fun MiniMetric(label: String, value: String, accent: Color, modifier: Modifier = Modifier) {
+ Surface(
+ modifier = modifier.heightIn(min = 58.dp),
+ shape = RoundedCornerShape(8.dp),
+ color = accent.copy(alpha = 0.12f),
+ ) {
+ Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(1.dp)) {
+ Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
+ Text(value, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ }
+ }
+}
+
+@Composable
+private fun LabelCapsule(text: String, color: Color) {
+ Surface(shape = RoundedCornerShape(8.dp), color = color.copy(alpha = 0.14f)) {
+ Text(
+ text = text,
+ modifier = Modifier.padding(horizontal = 7.dp, vertical = 4.dp),
+ color = color,
+ style = MaterialTheme.typography.labelSmall,
+ maxLines = 1,
+ )
+ }
+}
+
+@Composable
+private fun HealthBadge(score: Int, status: ServerStatus) {
+ val color = colorForStatus(status)
+ Surface(shape = CircleShape, color = color.copy(alpha = 0.14f)) {
+ Text(
+ text = score.toString(),
+ modifier = Modifier.padding(12.dp),
+ color = color,
+ fontWeight = FontWeight.Bold,
+ style = MaterialTheme.typography.titleMedium,
+ )
+ }
+}
+
+@Composable
+private fun HealthRing(score: Int, color: Color, modifier: Modifier = Modifier) {
+ Box(modifier, contentAlignment = Alignment.Center) {
+ Canvas(Modifier.fillMaxSize()) {
+ val stroke = Stroke(width = 14f, cap = StrokeCap.Round)
+ drawArc(StatusUnknown.copy(alpha = 0.18f), -90f, 360f, false, style = stroke)
+ drawArc(color, -90f, score.coerceIn(0, 100) * 3.6f, false, style = stroke)
+ }
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(score.toString(), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
+ Text("health", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ }
+}
+
+@Composable
+private fun Meter(label: String, value: Double?, maxValue: Double) {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Row {
+ Text(label, modifier = Modifier.weight(1f), fontWeight = FontWeight.SemiBold)
+ Text(value.formatPercent(), color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(10.dp)
+ .clip(RoundedCornerShape(8.dp))
+ .background(MaterialTheme.colorScheme.surfaceVariant),
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth(((value ?: 0.0) / maxValue).coerceIn(0.0, 1.0).toFloat())
+ .height(10.dp)
+ .background(colorForPercent(value)),
+ )
+ }
+ }
+}
+
+@Composable
+private fun TelemetryChart(title: String, values: List, color: Color) {
+ DataPanel(title) {
+ Canvas(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(180.dp),
+ ) {
+ val grid = Color(0x2A7E8796)
+ repeat(5) { index ->
+ val y = size.height * index / 4f
+ drawLine(grid, Offset(0f, y), Offset(size.width, y), 1.2f)
+ }
+ if (values.size < 2) return@Canvas
+ val maxValue = max(values.maxOrNull() ?: 1.0, 1.0)
+ val step = size.width / (values.size - 1)
+ val path = Path()
+ var latest = Offset.Zero
+ values.forEachIndexed { index, value ->
+ val x = step * index
+ val y = size.height - ((value / maxValue).coerceIn(0.0, 1.0).toFloat() * size.height)
+ latest = Offset(x, y)
+ if (index == 0) path.moveTo(x, y) else path.lineTo(x, y)
+ }
+ drawPath(path, color, style = Stroke(width = 4.5f, cap = StrokeCap.Round))
+ drawCircle(color, 7f, latest)
+ }
+ }
+}
+
+@Composable
+private fun AlertTicket(alert: AlertEvent) {
+ val color = when (alert.severity) {
+ AlertSeverity.Critical -> StatusCritical
+ AlertSeverity.Warning -> StatusWarning
+ AlertSeverity.Info -> MaterialTheme.colorScheme.primary
+ }
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(8.dp),
+ color = MaterialTheme.colorScheme.surface,
+ tonalElevation = 1.dp,
+ ) {
+ Row {
+ Box(
+ modifier = Modifier
+ .width(6.dp)
+ .height(96.dp)
+ .background(color),
+ )
+ Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
+ Text(alert.title, fontWeight = FontWeight.Bold)
+ Text(alert.message, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ Text(alert.timestampMillis.formatTime(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ }
+ }
+}
+
+@Composable
+private fun RuleRow(label: String, value: String) {
+ Surface(shape = RoundedCornerShape(8.dp), color = MaterialTheme.colorScheme.surfaceContainer) {
+ Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
+ Text(label, modifier = Modifier.weight(1f), color = MaterialTheme.colorScheme.onSurfaceVariant)
+ Text(value, fontWeight = FontWeight.Bold)
+ }
+ }
+}
+
+@Composable
+private fun LiveSwitch(live: Boolean, onClick: () -> Unit) {
+ val color = if (live) StatusOnline else StatusUnknown
+ Surface(
+ modifier = Modifier
+ .clip(RoundedCornerShape(8.dp))
+ .clickable(onClick = onClick),
+ shape = RoundedCornerShape(8.dp),
+ color = color.copy(alpha = 0.14f),
+ ) {
+ Row(Modifier.padding(horizontal = 10.dp, vertical = 7.dp), verticalAlignment = Alignment.CenterVertically) {
+ Box(
+ modifier = Modifier
+ .size(8.dp)
+ .clip(CircleShape)
+ .background(color),
+ )
+ Spacer(Modifier.width(7.dp))
+ Text(if (live) "Live" else "Hold", style = MaterialTheme.typography.labelMedium, color = color)
+ }
+ }
+}
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+private fun ServerEditorDialog(
+ draft: ServerEditorDraft,
+ onDismiss: () -> Unit,
+ onSave: () -> Unit,
+ onTest: () -> Unit,
+ onChange: ((ServerEditorDraft) -> ServerEditorDraft) -> Unit,
+) {
+ Dialog(onDismissRequest = onDismiss) {
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(max = 720.dp),
+ shape = RoundedCornerShape(8.dp),
+ color = MaterialTheme.colorScheme.surface,
+ tonalElevation = 6.dp,
+ ) {
+ LazyColumn(contentPadding = PaddingValues(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ item {
+ Text(if (draft.id == null) "Register Server" else "Server Profile", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
+ }
+ 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("Tags", draft.tags) { value -> onChange { it.copy(tags = value) } } }
+ item {
+ FlowRow(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ ToggleRow("Favorite", draft.favorite) { value -> onChange { it.copy(favorite = value) } }
+ ToggleRow("Enabled", draft.enabled) { value -> onChange { it.copy(enabled = value) } }
+ ToggleRow("Allow HTTP", draft.allowHttp) { value -> onChange { it.copy(allowHttp = value) } }
+ }
+ }
+ item { HorizontalDivider() }
+ item { SectionTitle("Auth") }
+ item { EditorField("Monitor token", draft.token, secret = true) { value -> onChange { it.copy(token = value) } } }
+ item { EditorField("Basic Auth username", draft.basicUsername) { value -> onChange { it.copy(basicUsername = value) } } }
+ item { EditorField("Basic Auth password", draft.basicPassword, secret = true) { value -> onChange { it.copy(basicPassword = value) } } }
+ item { HorizontalDivider() }
+ item { SectionTitle("Alert overrides") }
+ item {
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
+ NumberField("CPU %", draft.cpuThreshold, { value -> onChange { it.copy(cpuThreshold = value) } }, Modifier.weight(1f))
+ NumberField("Memory %", draft.memoryThreshold, { value -> onChange { it.copy(memoryThreshold = value) } }, Modifier.weight(1f))
+ }
+ }
+ item {
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
+ NumberField("Disk %", draft.diskThreshold, { value -> onChange { it.copy(diskThreshold = value) } }, Modifier.weight(1f))
+ NumberField("p95 ms", draft.latencyThreshold, { value -> onChange { it.copy(latencyThreshold = value) } }, Modifier.weight(1f))
+ }
+ }
+ item { NumberField("5xx limit", draft.errors5xxThreshold, { value -> onChange { it.copy(errors5xxThreshold = value) } }, Modifier.fillMaxWidth()) }
+ draft.testMessage?.let { message -> item { Text(message, color = MaterialTheme.colorScheme.primary) } }
+ item {
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
+ OutlinedButton(onClick = onDismiss, modifier = Modifier.weight(1f), shape = RoundedCornerShape(8.dp)) { Text("Cancel") }
+ OutlinedButton(onClick = onTest, enabled = !draft.isTesting, modifier = Modifier.weight(1f), shape = RoundedCornerShape(8.dp)) {
+ Text(if (draft.isTesting) "Testing" else "Test")
+ }
+ Button(onClick = onSave, modifier = Modifier.weight(1f), shape = RoundedCornerShape(8.dp)) { Text("Save") }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun SectionTitle(text: String) {
+ Text(text, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
+}
+
+@Composable
+private fun ToggleRow(label: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Checkbox(checked = checked, onCheckedChange = onCheckedChange)
+ Text(label)
+ }
+}
+
+@Composable
+private fun EditorField(label: String, value: String, secret: Boolean = false, onValueChange: (String) -> Unit) {
+ OutlinedTextField(
+ value = value,
+ onValueChange = onValueChange,
+ label = { Text(label) },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ shape = RoundedCornerShape(8.dp),
+ visualTransformation = if (secret) PasswordVisualTransformation() else VisualTransformation.None,
+ )
+}
+
+@Composable
+private fun NumberField(label: String, value: String, onValueChange: (String) -> Unit, modifier: Modifier = Modifier) {
+ OutlinedTextField(
+ value = value,
+ onValueChange = onValueChange,
+ label = { Text(label) },
+ modifier = modifier,
+ singleLine = true,
+ shape = RoundedCornerShape(8.dp),
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ )
+}
+
+private data class FleetCounters(
+ val total: Int,
+ val online: Int,
+ val watch: Int,
+ val critical: Int,
+ val waiting: Int,
+)
+
+private fun fleetCounters(state: MonitorUiState): FleetCounters {
+ val summaries = state.servers.map { state.summaries[it.id] }
+ val online = summaries.count { it?.status == ServerStatus.Online }
+ val watch = summaries.count { it?.status in setOf(ServerStatus.Degraded, ServerStatus.RateLimited, ServerStatus.Error) }
+ val critical = summaries.count {
+ it?.status in setOf(ServerStatus.Offline, ServerStatus.AuthFailed, ServerStatus.Forbidden, ServerStatus.Missing)
+ }
+ val waiting = (state.servers.size - online - watch - critical).coerceAtLeast(0)
+ return FleetCounters(state.servers.size, online, watch, critical, waiting)
+}
+
+private fun filteredServers(state: MonitorUiState): List {
+ val query = state.query.trim().lowercase()
+ return state.servers
+ .filter { server -> state.tagFilter?.let { it in server.tags } ?: true }
+ .filter { server ->
+ query.isBlank() ||
+ server.name.lowercase().contains(query) ||
+ server.baseUrl.lowercase().contains(query) ||
+ server.tags.any { it.lowercase().contains(query) }
+ }
+ .sortedWith(
+ compareByDescending { it.favorite }
+ .thenBy { statusRank(state.summaries[it.id]?.status) }
+ .thenBy { it.name.lowercase() },
+ )
+}
+
+private fun statusRank(status: ServerStatus?): Int = when (status) {
+ ServerStatus.Offline, ServerStatus.AuthFailed, ServerStatus.Forbidden, ServerStatus.Missing -> 0
+ ServerStatus.Degraded, ServerStatus.RateLimited, ServerStatus.Error -> 1
+ ServerStatus.Unknown, null -> 2
+ ServerStatus.Online -> 3
+ ServerStatus.Insecure -> 4
+}
+
+private fun healthScore(summary: MetricSummary?): Int {
+ if (summary == null) return 0
+ val statusPenalty = when (summary.status) {
+ ServerStatus.Online -> 0
+ ServerStatus.Degraded -> 20
+ ServerStatus.RateLimited, ServerStatus.Error -> 30
+ ServerStatus.Unknown, ServerStatus.Insecure -> 35
+ ServerStatus.Offline, ServerStatus.AuthFailed, ServerStatus.Forbidden, ServerStatus.Missing -> 70
+ }
+ val cpu = ((summary.cpuPercent ?: 0.0) * 0.18).toInt()
+ val mem = ((summary.memoryPercent ?: 0.0) * 0.16).toInt()
+ val disk = ((summary.diskPercent ?: 0.0) * 0.18).toInt()
+ val latency = ((summary.latencyP95Millis ?: 0.0) / 100.0).toInt().coerceAtMost(18)
+ val errors = ((summary.errors5xx ?: 0) * 4).coerceAtMost(24)
+ return (100 - statusPenalty - cpu - mem - disk - latency - errors).coerceIn(0, 100)
+}
+
+private fun statusLabel(status: ServerStatus): String = when (status) {
+ ServerStatus.Online -> "Online"
+ ServerStatus.Degraded -> "Watch"
+ ServerStatus.Offline -> "Offline"
+ ServerStatus.AuthFailed -> "Auth failed"
+ ServerStatus.Forbidden -> "Blocked"
+ ServerStatus.Missing -> "Missing"
+ ServerStatus.RateLimited -> "Rate limited"
+ ServerStatus.Insecure -> "Insecure"
+ ServerStatus.Error -> "Error"
+ ServerStatus.Unknown -> "Waiting"
+}
+
+private fun colorForStatus(status: ServerStatus): Color = when (status) {
+ ServerStatus.Online -> StatusOnline
+ ServerStatus.Degraded, ServerStatus.RateLimited, ServerStatus.Error -> StatusWarning
+ ServerStatus.Offline, ServerStatus.AuthFailed, ServerStatus.Forbidden, ServerStatus.Missing -> StatusCritical
+ ServerStatus.Insecure -> StatusInsecure
+ ServerStatus.Unknown -> StatusUnknown
+}
+
+private fun colorForPercent(value: Double?): Color = when {
+ value == null -> StatusUnknown
+ value >= 90.0 -> StatusCritical
+ value >= 75.0 -> StatusWarning
+ else -> StatusOnline
+}
+
+private fun Double?.formatPercent(): String = this?.let { "${it.toInt()}%" } ?: "--"
+private fun Double?.formatMillis(): String = this?.let { "${it.toInt()} ms" } ?: "--"
+private fun Double?.formatNumber(): String = this?.let { if (it >= 10) it.toInt().toString() else "%.1f".format(it) } ?: "--"
+private fun Long?.formatTime(): String = this?.let { DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(it)) } ?: "--"
+
+private val StatusOnline = Color(0xFF35D38B)
+private val StatusWarning = Color(0xFFFFB84D)
+private val StatusCritical = Color(0xFFFF5A52)
+private val StatusUnknown = Color(0xFF87909F)
+private val StatusInsecure = Color(0xFF7D8CFF)
diff --git a/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/ui/theme/Color.kt b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/ui/theme/Color.kt
index b2f778a..9c79bb6 100644
--- a/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/ui/theme/Color.kt
+++ b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/ui/theme/Color.kt
@@ -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)
\ No newline at end of file
+val SignalGreen = Color(0xFF35D38B)
+val SignalCyan = Color(0xFF4CC9F0)
+val SignalAmber = Color(0xFFFFB84D)
+val SignalRed = Color(0xFFFF5A52)
diff --git a/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/ui/theme/Theme.kt b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/ui/theme/Theme.kt
index d27b6ba..f6d6cb0 100644
--- a/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/ui/theme/Theme.kt
+++ b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/ui/theme/Theme.kt
@@ -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 {
@@ -55,4 +73,4 @@ fun NGXHttpMonitoringClientTheme(
typography = Typography,
content = content
)
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/ui/theme/Type.kt b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/ui/theme/Type.kt
index aa1b1ef..f9f39cf 100644
--- a/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/ui/theme/Type.kt
+++ b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/ui/theme/Type.kt
@@ -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(
@@ -31,4 +45,4 @@ val Typography = Typography(
letterSpacing = 0.5.sp
)
*/
-)
\ No newline at end of file
+)
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 4c87fd3..242554e 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,3 +1,3 @@
- NGX http Monitoring Client
-
\ No newline at end of file
+ NGX Monitor
+
diff --git a/app/src/test/java/net/rodakot/ngxhttpmonitoringclient/ExampleUnitTest.kt b/app/src/test/java/net/rodakot/ngxhttpmonitoringclient/ExampleUnitTest.kt
deleted file mode 100644
index d1e326a..0000000
--- a/app/src/test/java/net/rodakot/ngxhttpmonitoringclient/ExampleUnitTest.kt
+++ /dev/null
@@ -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)
- }
-}
\ No newline at end of file
diff --git a/app/src/test/java/net/rodakot/ngxhttpmonitoringclient/domain/AlertEvaluatorTest.kt b/app/src/test/java/net/rodakot/ngxhttpmonitoringclient/domain/AlertEvaluatorTest.kt
new file mode 100644
index 0000000..ba7af8a
--- /dev/null
+++ b/app/src/test/java/net/rodakot/ngxhttpmonitoringclient/domain/AlertEvaluatorTest.kt
@@ -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)
+ }
+}
diff --git a/app/src/test/java/net/rodakot/ngxhttpmonitoringclient/domain/HistorySamplingPolicyTest.kt b/app/src/test/java/net/rodakot/ngxhttpmonitoringclient/domain/HistorySamplingPolicyTest.kt
new file mode 100644
index 0000000..329277e
--- /dev/null
+++ b/app/src/test/java/net/rodakot/ngxhttpmonitoringclient/domain/HistorySamplingPolicyTest.kt
@@ -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))
+ }
+}
diff --git a/app/src/test/java/net/rodakot/ngxhttpmonitoringclient/domain/UrlRulesTest.kt b/app/src/test/java/net/rodakot/ngxhttpmonitoringclient/domain/UrlRulesTest.kt
new file mode 100644
index 0000000..19eb000
--- /dev/null
+++ b/app/src/test/java/net/rodakot/ngxhttpmonitoringclient/domain/UrlRulesTest.kt
@@ -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"),
+ )
+ }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 08911ae..cc70ad2 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -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" }
-
diff --git a/proposed-plan.md b/proposed-plan.md
new file mode 100644
index 0000000..b544db2
--- /dev/null
+++ b/proposed-plan.md
@@ -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.
diff --git a/resume b/resume
new file mode 100644
index 0000000..c052388
--- /dev/null
+++ b/resume
@@ -0,0 +1 @@
+codex resume 019e0523-11be-7293-8a05-2738ee75a23d
\ No newline at end of file