init app
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
|||||||
*.iml
|
*.iml
|
||||||
.gradle
|
.gradle
|
||||||
|
.gradle-user
|
||||||
/local.properties
|
/local.properties
|
||||||
/.idea/caches
|
/.idea/caches
|
||||||
/.idea/libraries
|
/.idea/libraries
|
||||||
|
|||||||
18
.idea/deploymentTargetSelector.xml
generated
18
.idea/deploymentTargetSelector.xml
generated
@@ -4,6 +4,24 @@
|
|||||||
<selectionStates>
|
<selectionStates>
|
||||||
<SelectionState runConfigName="app">
|
<SelectionState runConfigName="app">
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
<option name="selectionMode" value="DROPDOWN" />
|
||||||
|
<DropdownSelection timestamp="2026-05-08T02:10:54.105792300Z">
|
||||||
|
<Target type="DEFAULT_BOOT">
|
||||||
|
<handle>
|
||||||
|
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\meghdad\.android\avd\Pixel_10_Pro.avd" />
|
||||||
|
</handle>
|
||||||
|
</Target>
|
||||||
|
</DropdownSelection>
|
||||||
|
<DialogSelection />
|
||||||
|
</SelectionState>
|
||||||
|
<SelectionState runConfigName="MainActivity">
|
||||||
|
<option name="selectionMode" value="DROPDOWN" />
|
||||||
|
<DropdownSelection timestamp="2026-05-08T02:10:54.105792300Z">
|
||||||
|
<Target type="DEFAULT_BOOT">
|
||||||
|
<handle>
|
||||||
|
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\meghdad\.android\avd\Pixel_10_Pro.avd" />
|
||||||
|
</handle>
|
||||||
|
</Target>
|
||||||
|
</DropdownSelection>
|
||||||
<DialogSelection />
|
<DialogSelection />
|
||||||
</SelectionState>
|
</SelectionState>
|
||||||
</selectionStates>
|
</selectionStates>
|
||||||
|
|||||||
13
.idea/deviceManager.xml
generated
Normal file
13
.idea/deviceManager.xml
generated
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DeviceTable">
|
||||||
|
<option name="columnSorters">
|
||||||
|
<list>
|
||||||
|
<ColumnSorterState>
|
||||||
|
<option name="column" value="Name" />
|
||||||
|
<option name="order" value="ASCENDING" />
|
||||||
|
</ColumnSorterState>
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
1
.idea/gradle.xml
generated
1
.idea/gradle.xml
generated
@@ -1,5 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
|
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||||
<component name="GradleSettings">
|
<component name="GradleSettings">
|
||||||
<option name="linkedExternalProjectsSettings">
|
<option name="linkedExternalProjectsSettings">
|
||||||
<GradleProjectSettings>
|
<GradleProjectSettings>
|
||||||
|
|||||||
1
.idea/misc.xml
generated
1
.idea/misc.xml
generated
@@ -1,4 +1,3 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||||
|
|||||||
6
.idea/studiobot.xml
generated
Normal file
6
.idea/studiobot.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="StudioBotProjectSettings">
|
||||||
|
<option name="shareContext" value="OptedOut" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -34,6 +34,11 @@ android {
|
|||||||
sourceCompatibility = JavaVersion.VERSION_11
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
targetCompatibility = JavaVersion.VERSION_11
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
}
|
}
|
||||||
|
kotlin {
|
||||||
|
compilerOptions {
|
||||||
|
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11)
|
||||||
|
}
|
||||||
|
}
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
}
|
}
|
||||||
@@ -42,12 +47,15 @@ android {
|
|||||||
dependencies {
|
dependencies {
|
||||||
implementation(platform(libs.androidx.compose.bom))
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
|
implementation(libs.androidx.compose.foundation)
|
||||||
implementation(libs.androidx.compose.material3)
|
implementation(libs.androidx.compose.material3)
|
||||||
implementation(libs.androidx.compose.ui)
|
implementation(libs.androidx.compose.ui)
|
||||||
implementation(libs.androidx.compose.ui.graphics)
|
implementation(libs.androidx.compose.ui.graphics)
|
||||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.viewmodel.ktx)
|
||||||
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||||
|
|||||||
@@ -2,7 +2,12 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
|
android:name=".NgxMonitorApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
@@ -10,7 +15,8 @@
|
|||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.NGXHttpMonitoringClient">
|
android:theme="@style/Theme.NGXHttpMonitoringClient"
|
||||||
|
android:usesCleartextTraffic="true">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
@@ -22,6 +28,10 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
<service
|
||||||
|
android:name=".background.MonitorJobService"
|
||||||
|
android:exported="false"
|
||||||
|
android:permission="android.permission.BIND_JOB_SERVICE" />
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -1,47 +1,40 @@
|
|||||||
package net.rodakot.ngxhttpmonitoringclient
|
package net.rodakot.ngxhttpmonitoringclient
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.core.app.ActivityCompat
|
||||||
import androidx.compose.material3.Text
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.compose.runtime.Composable
|
import net.rodakot.ngxhttpmonitoringclient.ui.MonitorApp
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import net.rodakot.ngxhttpmonitoringclient.ui.theme.NGXHttpMonitoringClientTheme
|
import net.rodakot.ngxhttpmonitoringclient.ui.theme.NGXHttpMonitoringClientTheme
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
requestNotificationPermission()
|
||||||
setContent {
|
setContent {
|
||||||
|
val controller = remember { MonitorController(applicationContext) }
|
||||||
|
DisposableEffect(controller) {
|
||||||
|
onDispose { controller.close() }
|
||||||
|
}
|
||||||
NGXHttpMonitoringClientTheme {
|
NGXHttpMonitoringClientTheme {
|
||||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
MonitorApp(controller)
|
||||||
Greeting(
|
|
||||||
name = "Android",
|
|
||||||
modifier = Modifier.padding(innerPadding)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
private fun requestNotificationPermission() {
|
||||||
fun Greeting(name: String, modifier: Modifier = Modifier) {
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
|
||||||
Text(
|
val granted = ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
|
||||||
text = "Hello $name!",
|
if (!granted) {
|
||||||
modifier = modifier
|
ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1001)
|
||||||
)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
|
||||||
@Composable
|
|
||||||
fun GreetingPreview() {
|
|
||||||
NGXHttpMonitoringClientTheme {
|
|
||||||
Greeting("Android")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
package net.rodakot.ngxhttpmonitoringclient
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import net.rodakot.ngxhttpmonitoringclient.data.MonitorRepository
|
||||||
|
import net.rodakot.ngxhttpmonitoringclient.model.DetailTab
|
||||||
|
import net.rodakot.ngxhttpmonitoringclient.model.MetricSummary
|
||||||
|
import net.rodakot.ngxhttpmonitoringclient.model.MonitorUiState
|
||||||
|
import net.rodakot.ngxhttpmonitoringclient.model.ServerEditorDraft
|
||||||
|
import net.rodakot.ngxhttpmonitoringclient.model.ServerProfile
|
||||||
|
import kotlin.coroutines.coroutineContext
|
||||||
|
|
||||||
|
class MonitorController(context: Context) {
|
||||||
|
private val repository = MonitorRepository(context.applicationContext)
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||||
|
private val liveJobs = mutableMapOf<String, Job>()
|
||||||
|
private val _state = MutableStateFlow(MonitorUiState())
|
||||||
|
|
||||||
|
val state: StateFlow<MonitorUiState> = _state
|
||||||
|
|
||||||
|
init {
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reload() {
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
val servers = repository.servers()
|
||||||
|
val selected = _state.value.selectedServerId?.takeIf { id -> servers.any { it.id == id } }
|
||||||
|
?: servers.firstOrNull()?.id
|
||||||
|
val summaries = repository.latestSummaries(servers.map { it.id })
|
||||||
|
val history = selected?.let { mapOf(it to repository.history(it)) }.orEmpty()
|
||||||
|
val alerts = repository.alerts()
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
servers = servers,
|
||||||
|
selectedServerId = selected,
|
||||||
|
summaries = summaries,
|
||||||
|
history = history,
|
||||||
|
alerts = alerts,
|
||||||
|
globalMessage = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (_state.value.liveMonitoring) restartLiveMonitoring(servers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startLiveMonitoring() {
|
||||||
|
_state.update { it.copy(liveMonitoring = true) }
|
||||||
|
restartLiveMonitoring(_state.value.servers)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopLiveMonitoring() {
|
||||||
|
liveJobs.values.forEach { it.cancel() }
|
||||||
|
liveJobs.clear()
|
||||||
|
_state.update { it.copy(liveMonitoring = false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun close() {
|
||||||
|
stopLiveMonitoring()
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshAll() {
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
_state.value.servers.filter { it.enabled }.forEach { server ->
|
||||||
|
updateSummary(repository.fetchApiSummary(server, forcePersist = true))
|
||||||
|
}
|
||||||
|
reloadAlertsAndSelectedHistory()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshSelected() {
|
||||||
|
val server = selectedServer() ?: return
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
updateSummary(repository.fetchApiSummary(server, forcePersist = true))
|
||||||
|
reloadAlertsAndSelectedHistory()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectServer(serverId: String) {
|
||||||
|
_state.update { it.copy(selectedServerId = serverId, showDetail = true, selectedTab = DetailTab.Overview) }
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
val history = repository.history(serverId)
|
||||||
|
_state.update { it.copy(history = it.history + (serverId to history)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showDashboard() {
|
||||||
|
_state.update { it.copy(showDetail = false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDetailTab(tab: DetailTab) {
|
||||||
|
_state.update { it.copy(selectedTab = tab) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setQuery(query: String) {
|
||||||
|
_state.update { it.copy(query = query) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setTagFilter(tag: String?) {
|
||||||
|
_state.update { it.copy(tagFilter = tag) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showAddServer() {
|
||||||
|
_state.update { it.copy(editorDraft = ServerEditorDraft()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showEditSelectedServer() {
|
||||||
|
val server = selectedServer() ?: return
|
||||||
|
_state.update { it.copy(editorDraft = repository.draftFor(server)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissEditor() {
|
||||||
|
_state.update { it.copy(editorDraft = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateDraft(transform: (ServerEditorDraft) -> ServerEditorDraft) {
|
||||||
|
_state.update { current ->
|
||||||
|
val draft = current.editorDraft ?: return@update current
|
||||||
|
current.copy(editorDraft = transform(draft))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveEditor() {
|
||||||
|
val draft = _state.value.editorDraft ?: return
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
runCatching { repository.saveDraft(draft) }
|
||||||
|
.onSuccess { server ->
|
||||||
|
val servers = repository.servers()
|
||||||
|
val summaries = repository.latestSummaries(servers.map { it.id })
|
||||||
|
val history = repository.history(server.id)
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
servers = servers,
|
||||||
|
summaries = summaries,
|
||||||
|
selectedServerId = server.id,
|
||||||
|
showDetail = true,
|
||||||
|
selectedTab = DetailTab.Overview,
|
||||||
|
history = it.history + (server.id to history),
|
||||||
|
editorDraft = null,
|
||||||
|
globalMessage = "Server saved",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (_state.value.liveMonitoring) restartLiveMonitoring(servers)
|
||||||
|
}
|
||||||
|
.onFailure { error ->
|
||||||
|
_state.update { it.copy(editorDraft = draft.copy(testMessage = error.message ?: "Unable to save server")) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun testEditorConnection() {
|
||||||
|
val draft = _state.value.editorDraft ?: return
|
||||||
|
_state.update { it.copy(editorDraft = draft.copy(isTesting = true, testMessage = null)) }
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
val result = repository.testConnection(draft)
|
||||||
|
_state.update { current ->
|
||||||
|
current.copy(
|
||||||
|
editorDraft = current.editorDraft?.copy(
|
||||||
|
isTesting = false,
|
||||||
|
testMessage = result.getOrElse { it.message ?: "Connection failed" },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteSelectedServer() {
|
||||||
|
val server = selectedServer() ?: return
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
liveJobs.remove(server.id)?.cancel()
|
||||||
|
repository.deleteServer(server.id)
|
||||||
|
val servers = repository.servers()
|
||||||
|
val selected = servers.firstOrNull()?.id
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
servers = servers,
|
||||||
|
selectedServerId = selected,
|
||||||
|
showDetail = false,
|
||||||
|
selectedTab = DetailTab.Overview,
|
||||||
|
summaries = repository.latestSummaries(servers.map { item -> item.id }),
|
||||||
|
history = selected?.let { id -> mapOf(id to repository.history(id)) }.orEmpty(),
|
||||||
|
alerts = repository.alerts(),
|
||||||
|
globalMessage = "Server deleted",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun restartLiveMonitoring(servers: List<ServerProfile>) {
|
||||||
|
val enabledIds = servers.filter { it.enabled }.map { it.id }.toSet()
|
||||||
|
liveJobs.filterKeys { it !in enabledIds }.values.forEach { it.cancel() }
|
||||||
|
liveJobs.keys.removeAll { it !in enabledIds }
|
||||||
|
servers.filter { it.enabled && liveJobs[it.id]?.isActive != true }.forEach { server ->
|
||||||
|
liveJobs[server.id] = scope.launch(Dispatchers.IO) { liveLoop(server) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun liveLoop(server: ServerProfile) {
|
||||||
|
var backoffMillis = 2_000L
|
||||||
|
while (coroutineContext.isActive) {
|
||||||
|
runCatching {
|
||||||
|
repository.streamLive(server) { summary ->
|
||||||
|
backoffMillis = 2_000L
|
||||||
|
updateSummary(summary)
|
||||||
|
}
|
||||||
|
}.onFailure {
|
||||||
|
updateSummary(repository.fetchApiSummary(server, forcePersist = true))
|
||||||
|
delay(backoffMillis)
|
||||||
|
backoffMillis = (backoffMillis * 2).coerceAtMost(60_000L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateSummary(summary: MetricSummary) {
|
||||||
|
_state.update { current ->
|
||||||
|
current.copy(summaries = current.summaries + (summary.serverId to summary))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun reloadAlertsAndSelectedHistory() {
|
||||||
|
val selected = _state.value.selectedServerId
|
||||||
|
val history = selected?.let { repository.history(it) }
|
||||||
|
val alerts = repository.alerts()
|
||||||
|
withContext(Dispatchers.Main.immediate) {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
alerts = alerts,
|
||||||
|
history = if (selected != null && history != null) it.history + (selected to history) else it.history,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun selectedServer(): ServerProfile? {
|
||||||
|
val selected = _state.value.selectedServerId ?: return null
|
||||||
|
return _state.value.servers.firstOrNull { it.id == selected }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
package net.rodakot.ngxhttpmonitoringclient.data
|
||||||
|
|
||||||
|
import android.content.ContentValues
|
||||||
|
import android.content.Context
|
||||||
|
import android.database.Cursor
|
||||||
|
import android.database.sqlite.SQLiteDatabase
|
||||||
|
import android.database.sqlite.SQLiteOpenHelper
|
||||||
|
import net.rodakot.ngxhttpmonitoringclient.model.AlertEvent
|
||||||
|
import net.rodakot.ngxhttpmonitoringclient.model.AlertOverrides
|
||||||
|
import net.rodakot.ngxhttpmonitoringclient.model.AlertSeverity
|
||||||
|
import net.rodakot.ngxhttpmonitoringclient.model.HistoryRetentionDays
|
||||||
|
import net.rodakot.ngxhttpmonitoringclient.model.MetricSummary
|
||||||
|
import net.rodakot.ngxhttpmonitoringclient.model.ServerProfile
|
||||||
|
import net.rodakot.ngxhttpmonitoringclient.model.ServerStatus
|
||||||
|
import org.json.JSONArray
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
class MonitorDatabase(context: Context) : SQLiteOpenHelper(context, "ngx_monitor.db", null, 1) {
|
||||||
|
override fun onCreate(db: SQLiteDatabase) {
|
||||||
|
db.execSQL(
|
||||||
|
"""
|
||||||
|
CREATE TABLE servers (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
base_url TEXT NOT NULL,
|
||||||
|
tags_json TEXT NOT NULL,
|
||||||
|
favorite INTEGER NOT NULL,
|
||||||
|
allow_http INTEGER NOT NULL,
|
||||||
|
enabled INTEGER NOT NULL,
|
||||||
|
token_cipher TEXT,
|
||||||
|
basic_user_cipher TEXT,
|
||||||
|
basic_password_cipher TEXT,
|
||||||
|
cpu_override REAL,
|
||||||
|
memory_override REAL,
|
||||||
|
disk_override REAL,
|
||||||
|
latency_override REAL,
|
||||||
|
errors5xx_override INTEGER,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
db.execSQL(
|
||||||
|
"""
|
||||||
|
CREATE TABLE samples (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
server_id TEXT NOT NULL,
|
||||||
|
timestamp_millis INTEGER NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
cpu_percent REAL,
|
||||||
|
memory_percent REAL,
|
||||||
|
disk_percent REAL,
|
||||||
|
request_rate REAL,
|
||||||
|
latency_p95_millis REAL,
|
||||||
|
errors_4xx INTEGER,
|
||||||
|
errors_5xx INTEGER,
|
||||||
|
active_connections INTEGER,
|
||||||
|
raw_json TEXT
|
||||||
|
)
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
db.execSQL("CREATE INDEX samples_server_time ON samples(server_id, timestamp_millis DESC)")
|
||||||
|
db.execSQL(
|
||||||
|
"""
|
||||||
|
CREATE TABLE alerts (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
server_id TEXT NOT NULL,
|
||||||
|
timestamp_millis INTEGER NOT NULL,
|
||||||
|
severity TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
resolved INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
db.execSQL("CREATE INDEX alerts_server_time ON alerts(server_id, timestamp_millis DESC)")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = Unit
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun listServers(): List<ServerProfile> {
|
||||||
|
val result = mutableListOf<ServerProfile>()
|
||||||
|
readableDatabase.query("servers", null, null, null, null, null, "favorite DESC, name COLLATE NOCASE ASC").use { cursor ->
|
||||||
|
while (cursor.moveToNext()) result += cursor.toServer()
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun upsertServer(server: ServerProfile) {
|
||||||
|
writableDatabase.insertWithOnConflict("servers", null, server.toValues(), SQLiteDatabase.CONFLICT_REPLACE)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun deleteServer(serverId: String) {
|
||||||
|
writableDatabase.delete("servers", "id = ?", arrayOf(serverId))
|
||||||
|
writableDatabase.delete("samples", "server_id = ?", arrayOf(serverId))
|
||||||
|
writableDatabase.delete("alerts", "server_id = ?", arrayOf(serverId))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun insertSample(summary: MetricSummary) {
|
||||||
|
writableDatabase.insert("samples", null, summary.toValues())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun latestSummaries(serverIds: List<String>): Map<String, MetricSummary> {
|
||||||
|
val result = linkedMapOf<String, MetricSummary>()
|
||||||
|
serverIds.forEach { serverId ->
|
||||||
|
readableDatabase.query(
|
||||||
|
"samples",
|
||||||
|
null,
|
||||||
|
"server_id = ?",
|
||||||
|
arrayOf(serverId),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
"timestamp_millis DESC",
|
||||||
|
"1",
|
||||||
|
).use { cursor ->
|
||||||
|
if (cursor.moveToFirst()) result[serverId] = cursor.toSummary()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun samplesForServer(serverId: String, limit: Int = 360): List<MetricSummary> {
|
||||||
|
val result = mutableListOf<MetricSummary>()
|
||||||
|
val cutoff = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(HistoryRetentionDays.toLong())
|
||||||
|
readableDatabase.query(
|
||||||
|
"samples",
|
||||||
|
null,
|
||||||
|
"server_id = ? AND timestamp_millis >= ?",
|
||||||
|
arrayOf(serverId, cutoff.toString()),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
"timestamp_millis DESC",
|
||||||
|
limit.toString(),
|
||||||
|
).use { cursor ->
|
||||||
|
while (cursor.moveToNext()) result += cursor.toSummary()
|
||||||
|
}
|
||||||
|
return result.asReversed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun lastSampleTimestamp(serverId: String, rawOnly: Boolean): Long? {
|
||||||
|
val selection = if (rawOnly) "server_id = ? AND raw_json IS NOT NULL" else "server_id = ?"
|
||||||
|
readableDatabase.query(
|
||||||
|
"samples",
|
||||||
|
arrayOf("timestamp_millis"),
|
||||||
|
selection,
|
||||||
|
arrayOf(serverId),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
"timestamp_millis DESC",
|
||||||
|
"1",
|
||||||
|
).use { cursor ->
|
||||||
|
return if (cursor.moveToFirst()) cursor.getLong(0) else null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun insertAlert(alert: AlertEvent) {
|
||||||
|
writableDatabase.insertWithOnConflict("alerts", null, alert.toValues(), SQLiteDatabase.CONFLICT_IGNORE)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun hasRecentAlert(serverId: String, title: String, afterMillis: Long): Boolean {
|
||||||
|
readableDatabase.query(
|
||||||
|
"alerts",
|
||||||
|
arrayOf("id"),
|
||||||
|
"server_id = ? AND title = ? AND timestamp_millis >= ?",
|
||||||
|
arrayOf(serverId, title, afterMillis.toString()),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
"timestamp_millis DESC",
|
||||||
|
"1",
|
||||||
|
).use { cursor ->
|
||||||
|
return cursor.moveToFirst()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun alerts(limit: Int = 200): List<AlertEvent> {
|
||||||
|
val result = mutableListOf<AlertEvent>()
|
||||||
|
readableDatabase.query("alerts", null, null, null, null, null, "timestamp_millis DESC", limit.toString()).use { cursor ->
|
||||||
|
while (cursor.moveToNext()) result += cursor.toAlert()
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun pruneOldSamples(nowMillis: Long = System.currentTimeMillis()) {
|
||||||
|
val cutoff = nowMillis - TimeUnit.DAYS.toMillis(HistoryRetentionDays.toLong())
|
||||||
|
writableDatabase.delete("samples", "timestamp_millis < ?", arrayOf(cutoff.toString()))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ServerProfile.toValues() = ContentValues().apply {
|
||||||
|
put("id", id)
|
||||||
|
put("name", name)
|
||||||
|
put("base_url", baseUrl)
|
||||||
|
put("tags_json", tagsToJson(tags))
|
||||||
|
put("favorite", favorite.asInt())
|
||||||
|
put("allow_http", allowHttp.asInt())
|
||||||
|
put("enabled", enabled.asInt())
|
||||||
|
putNullable("token_cipher", tokenCipherText)
|
||||||
|
putNullable("basic_user_cipher", basicUserCipherText)
|
||||||
|
putNullable("basic_password_cipher", basicPasswordCipherText)
|
||||||
|
putNullable("cpu_override", alertOverrides.cpuPercent)
|
||||||
|
putNullable("memory_override", alertOverrides.memoryPercent)
|
||||||
|
putNullable("disk_override", alertOverrides.diskPercent)
|
||||||
|
putNullable("latency_override", alertOverrides.latencyP95Millis)
|
||||||
|
putNullable("errors5xx_override", alertOverrides.errors5xx)
|
||||||
|
put("updated_at", updatedAtMillis)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MetricSummary.toValues() = ContentValues().apply {
|
||||||
|
put("server_id", serverId)
|
||||||
|
put("timestamp_millis", timestampMillis)
|
||||||
|
put("status", status.name)
|
||||||
|
put("message", message)
|
||||||
|
putNullable("cpu_percent", cpuPercent)
|
||||||
|
putNullable("memory_percent", memoryPercent)
|
||||||
|
putNullable("disk_percent", diskPercent)
|
||||||
|
putNullable("request_rate", requestRate)
|
||||||
|
putNullable("latency_p95_millis", latencyP95Millis)
|
||||||
|
putNullable("errors_4xx", errors4xx)
|
||||||
|
putNullable("errors_5xx", errors5xx)
|
||||||
|
putNullable("active_connections", activeConnections)
|
||||||
|
putNullable("raw_json", rawJson)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun AlertEvent.toValues() = ContentValues().apply {
|
||||||
|
put("id", id)
|
||||||
|
put("server_id", serverId)
|
||||||
|
put("timestamp_millis", timestampMillis)
|
||||||
|
put("severity", severity.name)
|
||||||
|
put("title", title)
|
||||||
|
put("message", message)
|
||||||
|
put("resolved", resolved.asInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Cursor.toServer(): ServerProfile {
|
||||||
|
return ServerProfile(
|
||||||
|
id = string("id"),
|
||||||
|
name = string("name"),
|
||||||
|
baseUrl = string("base_url"),
|
||||||
|
tags = tagsFromJson(string("tags_json")),
|
||||||
|
favorite = int("favorite") == 1,
|
||||||
|
allowHttp = int("allow_http") == 1,
|
||||||
|
enabled = int("enabled") == 1,
|
||||||
|
tokenCipherText = nullableString("token_cipher"),
|
||||||
|
basicUserCipherText = nullableString("basic_user_cipher"),
|
||||||
|
basicPasswordCipherText = nullableString("basic_password_cipher"),
|
||||||
|
alertOverrides = AlertOverrides(
|
||||||
|
cpuPercent = nullableDouble("cpu_override"),
|
||||||
|
memoryPercent = nullableDouble("memory_override"),
|
||||||
|
diskPercent = nullableDouble("disk_override"),
|
||||||
|
latencyP95Millis = nullableDouble("latency_override"),
|
||||||
|
errors5xx = nullableInt("errors5xx_override"),
|
||||||
|
),
|
||||||
|
updatedAtMillis = long("updated_at"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Cursor.toSummary(): MetricSummary {
|
||||||
|
return MetricSummary(
|
||||||
|
serverId = string("server_id"),
|
||||||
|
timestampMillis = long("timestamp_millis"),
|
||||||
|
status = runCatching { ServerStatus.valueOf(string("status")) }.getOrDefault(ServerStatus.Unknown),
|
||||||
|
message = string("message"),
|
||||||
|
cpuPercent = nullableDouble("cpu_percent"),
|
||||||
|
memoryPercent = nullableDouble("memory_percent"),
|
||||||
|
diskPercent = nullableDouble("disk_percent"),
|
||||||
|
requestRate = nullableDouble("request_rate"),
|
||||||
|
latencyP95Millis = nullableDouble("latency_p95_millis"),
|
||||||
|
errors4xx = nullableInt("errors_4xx"),
|
||||||
|
errors5xx = nullableInt("errors_5xx"),
|
||||||
|
activeConnections = nullableInt("active_connections"),
|
||||||
|
rawJson = nullableString("raw_json"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Cursor.toAlert(): AlertEvent {
|
||||||
|
return AlertEvent(
|
||||||
|
id = string("id"),
|
||||||
|
serverId = string("server_id"),
|
||||||
|
timestampMillis = long("timestamp_millis"),
|
||||||
|
severity = runCatching { AlertSeverity.valueOf(string("severity")) }.getOrDefault(AlertSeverity.Warning),
|
||||||
|
title = string("title"),
|
||||||
|
message = string("message"),
|
||||||
|
resolved = int("resolved") == 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tagsToJson(tags: List<String>): String {
|
||||||
|
val array = JSONArray()
|
||||||
|
tags.map { it.trim() }.filter { it.isNotBlank() }.distinct().forEach(array::put)
|
||||||
|
return array.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tagsFromJson(value: String): List<String> = runCatching {
|
||||||
|
val array = JSONArray(value)
|
||||||
|
List(array.length()) { index -> array.optString(index) }.filter { it.isNotBlank() }
|
||||||
|
}.getOrDefault(emptyList())
|
||||||
|
|
||||||
|
private fun Boolean.asInt() = if (this) 1 else 0
|
||||||
|
|
||||||
|
private fun ContentValues.putNullable(key: String, value: String?) = if (value == null) putNull(key) else put(key, value)
|
||||||
|
private fun ContentValues.putNullable(key: String, value: Double?) = if (value == null) putNull(key) else put(key, value)
|
||||||
|
private fun ContentValues.putNullable(key: String, value: Int?) = if (value == null) putNull(key) else put(key, value)
|
||||||
|
|
||||||
|
private fun Cursor.string(column: String): String = getString(getColumnIndexOrThrow(column))
|
||||||
|
private fun Cursor.long(column: String): Long = getLong(getColumnIndexOrThrow(column))
|
||||||
|
private fun Cursor.int(column: String): Int = getInt(getColumnIndexOrThrow(column))
|
||||||
|
private fun Cursor.nullableString(column: String): String? {
|
||||||
|
val index = getColumnIndexOrThrow(column)
|
||||||
|
return if (isNull(index)) null else getString(index)
|
||||||
|
}
|
||||||
|
private fun Cursor.nullableDouble(column: String): Double? {
|
||||||
|
val index = getColumnIndexOrThrow(column)
|
||||||
|
return if (isNull(index)) null else getDouble(index)
|
||||||
|
}
|
||||||
|
private fun Cursor.nullableInt(column: String): Int? {
|
||||||
|
val index = getColumnIndexOrThrow(column)
|
||||||
|
return if (isNull(index)) null else getInt(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
package net.rodakot.ngxhttpmonitoringclient.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import net.rodakot.ngxhttpmonitoringclient.domain.AlertEvaluator
|
||||||
|
import net.rodakot.ngxhttpmonitoringclient.domain.HistorySamplingPolicy
|
||||||
|
import net.rodakot.ngxhttpmonitoringclient.domain.UrlRules
|
||||||
|
import net.rodakot.ngxhttpmonitoringclient.model.AlertEvent
|
||||||
|
import net.rodakot.ngxhttpmonitoringclient.model.AlertOverrides
|
||||||
|
import net.rodakot.ngxhttpmonitoringclient.model.AuthConfig
|
||||||
|
import net.rodakot.ngxhttpmonitoringclient.model.MetricSummary
|
||||||
|
import net.rodakot.ngxhttpmonitoringclient.model.ServerEditorDraft
|
||||||
|
import net.rodakot.ngxhttpmonitoringclient.model.ServerProfile
|
||||||
|
import net.rodakot.ngxhttpmonitoringclient.model.ServerStatus
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import org.json.JSONException
|
||||||
|
|
||||||
|
class MonitorRepository(context: Context) {
|
||||||
|
private val database = MonitorDatabase(context.applicationContext)
|
||||||
|
private val cipher = CredentialCipher()
|
||||||
|
private val client = MonitorHttpClient()
|
||||||
|
private val alertCooldownMillis = TimeUnit.MINUTES.toMillis(30)
|
||||||
|
|
||||||
|
fun servers(): List<ServerProfile> = database.listServers()
|
||||||
|
|
||||||
|
fun latestSummaries(serverIds: List<String>): Map<String, MetricSummary> = database.latestSummaries(serverIds)
|
||||||
|
|
||||||
|
fun history(serverId: String): List<MetricSummary> = database.samplesForServer(serverId)
|
||||||
|
|
||||||
|
fun alerts(): List<AlertEvent> = database.alerts()
|
||||||
|
|
||||||
|
fun draftFor(server: ServerProfile?): ServerEditorDraft {
|
||||||
|
if (server == null) return ServerEditorDraft()
|
||||||
|
return ServerEditorDraft(
|
||||||
|
id = server.id,
|
||||||
|
name = server.name,
|
||||||
|
baseUrl = server.baseUrl,
|
||||||
|
tags = server.tags.joinToString(", "),
|
||||||
|
favorite = server.favorite,
|
||||||
|
allowHttp = server.allowHttp,
|
||||||
|
enabled = server.enabled,
|
||||||
|
token = cipher.decrypt(server.tokenCipherText).orEmpty(),
|
||||||
|
basicUsername = cipher.decrypt(server.basicUserCipherText).orEmpty(),
|
||||||
|
basicPassword = cipher.decrypt(server.basicPasswordCipherText).orEmpty(),
|
||||||
|
cpuThreshold = server.alertOverrides.cpuPercent?.trimmed().orEmpty(),
|
||||||
|
memoryThreshold = server.alertOverrides.memoryPercent?.trimmed().orEmpty(),
|
||||||
|
diskThreshold = server.alertOverrides.diskPercent?.trimmed().orEmpty(),
|
||||||
|
latencyThreshold = server.alertOverrides.latencyP95Millis?.trimmed().orEmpty(),
|
||||||
|
errors5xxThreshold = server.alertOverrides.errors5xx?.toString().orEmpty(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveDraft(draft: ServerEditorDraft): ServerProfile {
|
||||||
|
val server = buildServer(draft)
|
||||||
|
database.upsertServer(server)
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildServer(draft: ServerEditorDraft): ServerProfile {
|
||||||
|
val baseUrl = UrlRules.normalizeBaseUrl(draft.baseUrl)
|
||||||
|
UrlRules.validateHttpPolicy(baseUrl, draft.allowHttp)
|
||||||
|
return ServerProfile(
|
||||||
|
id = draft.id ?: UUID.randomUUID().toString(),
|
||||||
|
name = draft.name.trim().ifBlank { baseUrl },
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
tags = draft.tags.split(',').map { it.trim() }.filter { it.isNotBlank() }.distinct(),
|
||||||
|
favorite = draft.favorite,
|
||||||
|
allowHttp = draft.allowHttp,
|
||||||
|
enabled = draft.enabled,
|
||||||
|
tokenCipherText = cipher.encrypt(draft.token),
|
||||||
|
basicUserCipherText = cipher.encrypt(draft.basicUsername),
|
||||||
|
basicPasswordCipherText = cipher.encrypt(draft.basicPassword),
|
||||||
|
alertOverrides = AlertOverrides(
|
||||||
|
cpuPercent = draft.cpuThreshold.toDoubleOrNull(),
|
||||||
|
memoryPercent = draft.memoryThreshold.toDoubleOrNull(),
|
||||||
|
diskPercent = draft.diskThreshold.toDoubleOrNull(),
|
||||||
|
latencyP95Millis = draft.latencyThreshold.toDoubleOrNull(),
|
||||||
|
errors5xx = draft.errors5xxThreshold.toIntOrNull(),
|
||||||
|
),
|
||||||
|
updatedAtMillis = System.currentTimeMillis(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteServer(serverId: String) = database.deleteServer(serverId)
|
||||||
|
|
||||||
|
fun authFor(server: ServerProfile): AuthConfig = AuthConfig(
|
||||||
|
token = cipher.decrypt(server.tokenCipherText),
|
||||||
|
basicUsername = cipher.decrypt(server.basicUserCipherText),
|
||||||
|
basicPassword = cipher.decrypt(server.basicPasswordCipherText),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun testConnection(draft: ServerEditorDraft): Result<String> = runCatching {
|
||||||
|
val server = buildServer(draft.copy(id = draft.id ?: "test"))
|
||||||
|
client.fetchHealth(server, authFor(server))
|
||||||
|
"Connection works"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fetchApiSummary(server: ServerProfile, forcePersist: Boolean = false): MetricSummary {
|
||||||
|
return refreshServer(server, forcePersist).summary
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshServer(server: ServerProfile, forcePersist: Boolean = false): RefreshResult {
|
||||||
|
return try {
|
||||||
|
val payload = client.fetchApi(server, authFor(server))
|
||||||
|
val parsed = MonitorJsonParser.parse(server.id, payload)
|
||||||
|
val events = AlertEvaluator.evaluate(server, parsed)
|
||||||
|
val status = if (events.isEmpty()) ServerStatus.Online else ServerStatus.Degraded
|
||||||
|
val summary = parsed.copy(
|
||||||
|
status = status,
|
||||||
|
message = if (status == ServerStatus.Online) "Live" else "Threshold breached",
|
||||||
|
)
|
||||||
|
persistSample(server, summary, payload, forcePersist)
|
||||||
|
RefreshResult(summary, recordAlerts(server, summary))
|
||||||
|
} catch (exception: MonitorClientException) {
|
||||||
|
val summary = MetricSummary(
|
||||||
|
serverId = server.id,
|
||||||
|
timestampMillis = System.currentTimeMillis(),
|
||||||
|
status = exception.monitorStatus,
|
||||||
|
message = exception.message,
|
||||||
|
)
|
||||||
|
persistSample(server, summary, null, forcePersist = true)
|
||||||
|
RefreshResult(summary, recordAlerts(server, summary))
|
||||||
|
} catch (exception: JSONException) {
|
||||||
|
val summary = MetricSummary(
|
||||||
|
serverId = server.id,
|
||||||
|
timestampMillis = System.currentTimeMillis(),
|
||||||
|
status = ServerStatus.Error,
|
||||||
|
message = "Malformed monitor JSON",
|
||||||
|
)
|
||||||
|
persistSample(server, summary, null, forcePersist = true)
|
||||||
|
RefreshResult(summary, recordAlerts(server, summary))
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
val summary = MetricSummary(
|
||||||
|
serverId = server.id,
|
||||||
|
timestampMillis = System.currentTimeMillis(),
|
||||||
|
status = ServerStatus.Offline,
|
||||||
|
message = exception.message ?: "Unable to reach server",
|
||||||
|
)
|
||||||
|
persistSample(server, summary, null, forcePersist = true)
|
||||||
|
RefreshResult(summary, recordAlerts(server, summary))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun streamLive(server: ServerProfile, onSummary: (MetricSummary) -> Unit) {
|
||||||
|
client.streamLive(server, authFor(server)) { payload ->
|
||||||
|
val parsed = MonitorJsonParser.parse(server.id, payload)
|
||||||
|
val events = AlertEvaluator.evaluate(server, parsed)
|
||||||
|
val status = if (events.isEmpty()) ServerStatus.Online else ServerStatus.Degraded
|
||||||
|
val summary = parsed.copy(
|
||||||
|
status = status,
|
||||||
|
message = if (status == ServerStatus.Online) "Live" else "Threshold breached",
|
||||||
|
)
|
||||||
|
persistSample(server, summary, payload, forcePersist = false)
|
||||||
|
recordAlerts(server, summary)
|
||||||
|
onSummary(summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun recordAlerts(server: ServerProfile, summary: MetricSummary): List<AlertEvent> {
|
||||||
|
val events = AlertEvaluator.evaluate(server, summary)
|
||||||
|
val cutoff = System.currentTimeMillis() - alertCooldownMillis
|
||||||
|
val fresh = events.filterNot { database.hasRecentAlert(server.id, it.title, cutoff) }
|
||||||
|
fresh.forEach(database::insertAlert)
|
||||||
|
return fresh
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pruneHistory() = database.pruneOldSamples()
|
||||||
|
|
||||||
|
private fun persistSample(server: ServerProfile, summary: MetricSummary, rawJson: String?, forcePersist: Boolean) {
|
||||||
|
val now = summary.timestampMillis
|
||||||
|
val lastSummary = database.lastSampleTimestamp(server.id, rawOnly = false)
|
||||||
|
val shouldStoreSummary = HistorySamplingPolicy.shouldStoreSummary(now, lastSummary, forcePersist)
|
||||||
|
if (!shouldStoreSummary) return
|
||||||
|
|
||||||
|
val lastRaw = database.lastSampleTimestamp(server.id, rawOnly = true)
|
||||||
|
val shouldStoreRaw = HistorySamplingPolicy.shouldStoreRaw(now, lastRaw, rawJson != null, forcePersist)
|
||||||
|
database.insertSample(summary.copy(rawJson = if (shouldStoreRaw) rawJson else null))
|
||||||
|
database.pruneOldSamples(now)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Double.trimmed(): String = if (this % 1.0 == 0.0) toInt().toString() else toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
data class RefreshResult(
|
||||||
|
val summary: MetricSummary,
|
||||||
|
val alerts: List<AlertEvent>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package net.rodakot.ngxhttpmonitoringclient.domain
|
||||||
|
|
||||||
|
import net.rodakot.ngxhttpmonitoringclient.model.AlertEvent
|
||||||
|
import net.rodakot.ngxhttpmonitoringclient.model.AlertSeverity
|
||||||
|
import net.rodakot.ngxhttpmonitoringclient.model.DefaultCpuThreshold
|
||||||
|
import net.rodakot.ngxhttpmonitoringclient.model.DefaultDiskThreshold
|
||||||
|
import net.rodakot.ngxhttpmonitoringclient.model.DefaultErrors5xxThreshold
|
||||||
|
import net.rodakot.ngxhttpmonitoringclient.model.DefaultLatencyP95ThresholdMs
|
||||||
|
import net.rodakot.ngxhttpmonitoringclient.model.DefaultMemoryThreshold
|
||||||
|
import net.rodakot.ngxhttpmonitoringclient.model.MetricSummary
|
||||||
|
import net.rodakot.ngxhttpmonitoringclient.model.ServerProfile
|
||||||
|
import net.rodakot.ngxhttpmonitoringclient.model.ServerStatus
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
object AlertEvaluator {
|
||||||
|
fun evaluate(server: ServerProfile, summary: MetricSummary): List<AlertEvent> {
|
||||||
|
val events = mutableListOf<AlertEvent>()
|
||||||
|
val now = summary.timestampMillis
|
||||||
|
when (summary.status) {
|
||||||
|
ServerStatus.AuthFailed -> events += event(server, now, AlertSeverity.Critical, "Authentication failed", summary.message)
|
||||||
|
ServerStatus.Forbidden -> events += event(server, now, AlertSeverity.Critical, "Access denied", summary.message)
|
||||||
|
ServerStatus.Missing -> events += event(server, now, AlertSeverity.Critical, "Monitor endpoint missing", summary.message)
|
||||||
|
ServerStatus.RateLimited -> events += event(server, now, AlertSeverity.Warning, "Rate limited", summary.message)
|
||||||
|
ServerStatus.Offline -> events += event(server, now, AlertSeverity.Critical, "Server unreachable", summary.message)
|
||||||
|
ServerStatus.Error -> events += event(server, now, AlertSeverity.Warning, "Monitor error", summary.message)
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
val cpuLimit = server.alertOverrides.cpuPercent ?: DefaultCpuThreshold
|
||||||
|
if ((summary.cpuPercent ?: 0.0) >= cpuLimit) {
|
||||||
|
events += event(server, now, AlertSeverity.Warning, "High CPU", "CPU is ${summary.cpuPercent.formatPercent()} (limit ${cpuLimit.formatPercent()})")
|
||||||
|
}
|
||||||
|
|
||||||
|
val memoryLimit = server.alertOverrides.memoryPercent ?: DefaultMemoryThreshold
|
||||||
|
if ((summary.memoryPercent ?: 0.0) >= memoryLimit) {
|
||||||
|
events += event(server, now, AlertSeverity.Warning, "High memory", "Memory is ${summary.memoryPercent.formatPercent()} (limit ${memoryLimit.formatPercent()})")
|
||||||
|
}
|
||||||
|
|
||||||
|
val diskLimit = server.alertOverrides.diskPercent ?: DefaultDiskThreshold
|
||||||
|
if ((summary.diskPercent ?: 0.0) >= diskLimit) {
|
||||||
|
events += event(server, now, AlertSeverity.Critical, "Disk pressure", "Disk usage is ${summary.diskPercent.formatPercent()} (limit ${diskLimit.formatPercent()})")
|
||||||
|
}
|
||||||
|
|
||||||
|
val latencyLimit = server.alertOverrides.latencyP95Millis ?: DefaultLatencyP95ThresholdMs
|
||||||
|
if ((summary.latencyP95Millis ?: 0.0) >= latencyLimit) {
|
||||||
|
events += event(server, now, AlertSeverity.Warning, "High latency", "p95 latency is ${summary.latencyP95Millis?.toInt()} ms (limit ${latencyLimit.toInt()} ms)")
|
||||||
|
}
|
||||||
|
|
||||||
|
val errorsLimit = server.alertOverrides.errors5xx ?: DefaultErrors5xxThreshold
|
||||||
|
if ((summary.errors5xx ?: 0) >= errorsLimit) {
|
||||||
|
events += event(server, now, AlertSeverity.Critical, "5xx spike", "${summary.errors5xx} server errors observed (limit $errorsLimit)")
|
||||||
|
}
|
||||||
|
|
||||||
|
return events.distinctBy { it.title }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun statusFor(summary: MetricSummary, server: ServerProfile): ServerStatus {
|
||||||
|
if (summary.status != ServerStatus.Online && summary.status != ServerStatus.Unknown) return summary.status
|
||||||
|
return if (evaluate(server, summary).any { it.severity == AlertSeverity.Critical }) {
|
||||||
|
ServerStatus.Degraded
|
||||||
|
} else {
|
||||||
|
ServerStatus.Online
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun event(
|
||||||
|
server: ServerProfile,
|
||||||
|
now: Long,
|
||||||
|
severity: AlertSeverity,
|
||||||
|
title: String,
|
||||||
|
message: String,
|
||||||
|
) = AlertEvent(
|
||||||
|
id = UUID.randomUUID().toString(),
|
||||||
|
serverId = server.id,
|
||||||
|
timestampMillis = now,
|
||||||
|
severity = severity,
|
||||||
|
title = title,
|
||||||
|
message = message,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun Double?.formatPercent(): String = when (this) {
|
||||||
|
null -> "unknown"
|
||||||
|
else -> "${toInt()}%"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package net.rodakot.ngxhttpmonitoringclient.model
|
||||||
|
|
||||||
|
const val DefaultCpuThreshold = 85.0
|
||||||
|
const val DefaultMemoryThreshold = 90.0
|
||||||
|
const val DefaultDiskThreshold = 90.0
|
||||||
|
const val DefaultLatencyP95ThresholdMs = 1_000.0
|
||||||
|
const val DefaultErrors5xxThreshold = 5
|
||||||
|
const val HistoryRetentionDays = 30
|
||||||
|
const val SummarySampleIntervalMillis = 60_000L
|
||||||
|
const val RawSnapshotIntervalMillis = 15 * 60_000L
|
||||||
|
|
||||||
|
data class ServerProfile(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val baseUrl: String,
|
||||||
|
val tags: List<String> = emptyList(),
|
||||||
|
val favorite: Boolean = false,
|
||||||
|
val allowHttp: Boolean = false,
|
||||||
|
val enabled: Boolean = true,
|
||||||
|
val tokenCipherText: String? = null,
|
||||||
|
val basicUserCipherText: String? = null,
|
||||||
|
val basicPasswordCipherText: String? = null,
|
||||||
|
val alertOverrides: AlertOverrides = AlertOverrides(),
|
||||||
|
val updatedAtMillis: Long = System.currentTimeMillis(),
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AuthConfig(
|
||||||
|
val token: String? = null,
|
||||||
|
val basicUsername: String? = null,
|
||||||
|
val basicPassword: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AlertOverrides(
|
||||||
|
val cpuPercent: Double? = null,
|
||||||
|
val memoryPercent: Double? = null,
|
||||||
|
val diskPercent: Double? = null,
|
||||||
|
val latencyP95Millis: Double? = null,
|
||||||
|
val errors5xx: Int? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class ServerStatus {
|
||||||
|
Unknown,
|
||||||
|
Online,
|
||||||
|
Degraded,
|
||||||
|
Offline,
|
||||||
|
AuthFailed,
|
||||||
|
Forbidden,
|
||||||
|
Missing,
|
||||||
|
RateLimited,
|
||||||
|
Insecure,
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class AlertSeverity {
|
||||||
|
Info,
|
||||||
|
Warning,
|
||||||
|
Critical,
|
||||||
|
}
|
||||||
|
|
||||||
|
data class MetricSummary(
|
||||||
|
val serverId: String,
|
||||||
|
val timestampMillis: Long,
|
||||||
|
val status: ServerStatus = ServerStatus.Unknown,
|
||||||
|
val message: String = "Waiting for data",
|
||||||
|
val cpuPercent: Double? = null,
|
||||||
|
val memoryPercent: Double? = null,
|
||||||
|
val diskPercent: Double? = null,
|
||||||
|
val requestRate: Double? = null,
|
||||||
|
val latencyP95Millis: Double? = null,
|
||||||
|
val errors4xx: Int? = null,
|
||||||
|
val errors5xx: Int? = null,
|
||||||
|
val activeConnections: Int? = null,
|
||||||
|
val rawJson: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AlertEvent(
|
||||||
|
val id: String,
|
||||||
|
val serverId: String,
|
||||||
|
val timestampMillis: Long,
|
||||||
|
val severity: AlertSeverity,
|
||||||
|
val title: String,
|
||||||
|
val message: String,
|
||||||
|
val resolved: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ServerEditorDraft(
|
||||||
|
val id: String? = null,
|
||||||
|
val name: String = "",
|
||||||
|
val baseUrl: String = "",
|
||||||
|
val tags: String = "",
|
||||||
|
val favorite: Boolean = false,
|
||||||
|
val allowHttp: Boolean = false,
|
||||||
|
val enabled: Boolean = true,
|
||||||
|
val token: String = "",
|
||||||
|
val basicUsername: String = "",
|
||||||
|
val basicPassword: String = "",
|
||||||
|
val cpuThreshold: String = "",
|
||||||
|
val memoryThreshold: String = "",
|
||||||
|
val diskThreshold: String = "",
|
||||||
|
val latencyThreshold: String = "",
|
||||||
|
val errors5xxThreshold: String = "",
|
||||||
|
val testMessage: String? = null,
|
||||||
|
val isTesting: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class DetailTab(val label: String) {
|
||||||
|
Overview("Overview"),
|
||||||
|
System("System"),
|
||||||
|
Nginx("Nginx"),
|
||||||
|
Requests("Requests"),
|
||||||
|
Disk("Disk"),
|
||||||
|
History("History"),
|
||||||
|
Alerts("Alerts"),
|
||||||
|
Settings("Settings"),
|
||||||
|
}
|
||||||
|
|
||||||
|
data class MonitorUiState(
|
||||||
|
val servers: List<ServerProfile> = emptyList(),
|
||||||
|
val summaries: Map<String, MetricSummary> = emptyMap(),
|
||||||
|
val history: Map<String, List<MetricSummary>> = emptyMap(),
|
||||||
|
val alerts: List<AlertEvent> = emptyList(),
|
||||||
|
val selectedServerId: String? = null,
|
||||||
|
val showDetail: Boolean = false,
|
||||||
|
val selectedTab: DetailTab = DetailTab.Overview,
|
||||||
|
val query: String = "",
|
||||||
|
val tagFilter: String? = null,
|
||||||
|
val editorDraft: ServerEditorDraft? = null,
|
||||||
|
val globalMessage: String? = null,
|
||||||
|
val liveMonitoring: Boolean = false,
|
||||||
|
)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -2,10 +2,14 @@ package net.rodakot.ngxhttpmonitoringclient.ui.theme
|
|||||||
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
val Purple80 = Color(0xFFD0BCFF)
|
val CommandBlack = Color(0xFF0E1116)
|
||||||
val PurpleGrey80 = Color(0xFFCCC2DC)
|
val CommandPanel = Color(0xFF171C23)
|
||||||
val Pink80 = Color(0xFFEFB8C8)
|
val CommandPanelRaised = Color(0xFF202733)
|
||||||
|
val CommandText = Color(0xFFEAF0F7)
|
||||||
|
val CommandMuted = Color(0xFF9AA6B6)
|
||||||
|
val CommandLight = Color(0xFFF5F7FA)
|
||||||
|
|
||||||
val Purple40 = Color(0xFF6650a4)
|
val SignalGreen = Color(0xFF35D38B)
|
||||||
val PurpleGrey40 = Color(0xFF625b71)
|
val SignalCyan = Color(0xFF4CC9F0)
|
||||||
val Pink40 = Color(0xFF7D5260)
|
val SignalAmber = Color(0xFFFFB84D)
|
||||||
|
val SignalRed = Color(0xFFFF5A52)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package net.rodakot.ngxhttpmonitoringclient.ui.theme
|
package net.rodakot.ngxhttpmonitoringclient.ui.theme
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -9,18 +8,37 @@ import androidx.compose.material3.dynamicDarkColorScheme
|
|||||||
import androidx.compose.material3.dynamicLightColorScheme
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
import androidx.compose.material3.lightColorScheme
|
import androidx.compose.material3.lightColorScheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
||||||
private val DarkColorScheme = darkColorScheme(
|
private val DarkColorScheme = darkColorScheme(
|
||||||
primary = Purple80,
|
primary = SignalGreen,
|
||||||
secondary = PurpleGrey80,
|
secondary = SignalCyan,
|
||||||
tertiary = Pink80
|
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(
|
private val LightColorScheme = lightColorScheme(
|
||||||
primary = Purple40,
|
primary = Color(0xFF08764D),
|
||||||
secondary = PurpleGrey40,
|
secondary = Color(0xFF146B8D),
|
||||||
tertiary = Pink40
|
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
|
/* Other default colors to override
|
||||||
background = Color(0xFFFFFBFE),
|
background = Color(0xFFFFFBFE),
|
||||||
@@ -35,9 +53,9 @@ private val LightColorScheme = lightColorScheme(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NGXHttpMonitoringClientTheme(
|
fun NGXHttpMonitoringClientTheme(
|
||||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
darkTheme: Boolean = true,
|
||||||
// Dynamic color is available on Android 12+
|
// Dynamic color is available on Android 12+
|
||||||
dynamicColor: Boolean = true,
|
dynamicColor: Boolean = false,
|
||||||
content: @Composable () -> Unit
|
content: @Composable () -> Unit
|
||||||
) {
|
) {
|
||||||
val colorScheme = when {
|
val colorScheme = when {
|
||||||
|
|||||||
@@ -13,7 +13,21 @@ val Typography = Typography(
|
|||||||
fontWeight = FontWeight.Normal,
|
fontWeight = FontWeight.Normal,
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
lineHeight = 24.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
|
/* Other default text styles to override
|
||||||
titleLarge = TextStyle(
|
titleLarge = TextStyle(
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">NGX http Monitoring Client</string>
|
<string name="app_name">NGX Monitor</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
[versions]
|
[versions]
|
||||||
agp = "9.2.1"
|
agp = "9.2.1"
|
||||||
coreKtx = "1.18.0"
|
coreKtx = "1.18.0"
|
||||||
|
coroutines = "1.9.0"
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
junitVersion = "1.3.0"
|
junitVersion = "1.3.0"
|
||||||
espressoCore = "3.7.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-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
|
||||||
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||||
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
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]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
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" }
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
|
|
||||||
|
|||||||
44
proposed-plan.md
Normal file
44
proposed-plan.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# NGX HTTP Monitoring Client App Plan
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Build a polished Jetpack Compose operations app for monitoring 20+ Nginx monitoring-module servers. The app is local-first, stores encrypted per-server credentials, uses foreground SSE streams for all enabled servers while
|
||||||
|
the app is open, and uses Android-friendly periodic background checks for alerts.
|
||||||
|
|
||||||
|
## Key Changes
|
||||||
|
|
||||||
|
- Add app architecture with Compose, Navigation, ViewModels, Room, WorkManager, OkHttp SSE, kotlinx serialization, and encrypted credential storage.
|
||||||
|
- Create core models: ServerProfile, AuthConfig, MonitorSnapshot, MetricSummary, AlertRule, AlertEvent, and ServerTag.
|
||||||
|
- Support /monitor/api, /monitor/health, and /monitor/live; send Basic Auth and X-Monitor-Token when configured.
|
||||||
|
- Add first-run and server setup flow: name, base URL, tags, favorite flag, token, optional Basic Auth, opt-in HTTP toggle, and test connection.
|
||||||
|
- Store derived metric summaries every 1 minute and full raw JSON snapshots every 15 minutes, retained for 30 days.
|
||||||
|
|
||||||
|
## App Experience
|
||||||
|
|
||||||
|
- Dashboard: fleet health counts, search, tags, favorites, critical-first sorting, stale/offline indicators, and compact server cards with CPU, memory, disk, request rate, latency, 4xx/5xx, active connections, and last
|
||||||
|
update.
|
||||||
|
- Server detail: live overview plus tabs for System, Nginx, Requests, Network, Disk, Processes, Upstreams, History, Alerts, and Settings.
|
||||||
|
- Alerts: ship global defaults with per-server overrides for unreachable/stale server, CPU/memory/disk pressure, high 5xx, high latency, and auth/API failures.
|
||||||
|
- Background monitoring: WorkManager periodic checks using /monitor/health and lightweight API fetches; local notifications fire only on state changes or sustained rule breaches.
|
||||||
|
- Security UX: HTTPS by default; HTTP allowed only per server with a visible warning. Never log tokens, Basic Auth, or URLs containing secrets.
|
||||||
|
|
||||||
|
## Failure Handling
|
||||||
|
|
||||||
|
- Treat missing JSON fields as unavailable collectors, not crashes.
|
||||||
|
- Show clear states for 401, 403, 404, 429, timeout, TLS failure, malformed JSON, and stale SSE.
|
||||||
|
- Foreground SSE opens one stream per enabled server; if a stream fails repeatedly, fall back to staggered JSON polling for that server and show degraded-live status.
|
||||||
|
- Apply retry backoff and avoid notification spam with cooldowns and alert state tracking.
|
||||||
|
|
||||||
|
## Test Plan
|
||||||
|
|
||||||
|
- Unit tests for JSON parsing with missing collectors, auth header creation, alert evaluation, retention pruning, URL validation, and HTTP opt-in rules.
|
||||||
|
- Repository tests with fake monitor responses for /monitor/api, /monitor/health, and SSE events.
|
||||||
|
- Compose UI tests for empty state, add server, dashboard filtering/sorting, server detail tabs, and alert rule editing.
|
||||||
|
- Background-worker tests for periodic checks, notification deduping, and failed-auth handling.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- No cloud backend or account sync in v1; all data stays on the phone.
|
||||||
|
- Android minSdk remains 24.
|
||||||
|
- “SSE everywhere” means all enabled servers while the app is foregrounded; background monitoring uses periodic checks for Android reliability.
|
||||||
|
- Long history means balanced local retention, not every SSE payload forever.
|
||||||
Reference in New Issue
Block a user