init app
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
*.iml
|
||||
.gradle
|
||||
.gradle-user
|
||||
/local.properties
|
||||
/.idea/caches
|
||||
/.idea/libraries
|
||||
|
||||
18
.idea/deploymentTargetSelector.xml
generated
18
.idea/deploymentTargetSelector.xml
generated
@@ -4,6 +4,24 @@
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DropdownSelection timestamp="2026-05-08T02:10:54.105792300Z">
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\meghdad\.android\avd\Pixel_10_Pro.avd" />
|
||||
</handle>
|
||||
</Target>
|
||||
</DropdownSelection>
|
||||
<DialogSelection />
|
||||
</SelectionState>
|
||||
<SelectionState runConfigName="MainActivity">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DropdownSelection timestamp="2026-05-08T02:10:54.105792300Z">
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\meghdad\.android\avd\Pixel_10_Pro.avd" />
|
||||
</handle>
|
||||
</Target>
|
||||
</DropdownSelection>
|
||||
<DialogSelection />
|
||||
</SelectionState>
|
||||
</selectionStates>
|
||||
|
||||
13
.idea/deviceManager.xml
generated
Normal file
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"?>
|
||||
<project version="4">
|
||||
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||
<component name="GradleSettings">
|
||||
<option name="linkedExternalProjectsSettings">
|
||||
<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">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||
|
||||
6
.idea/studiobot.xml
generated
Normal file
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
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:name=".NgxMonitorApplication"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
@@ -10,7 +15,8 @@
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.NGXHttpMonitoringClient">
|
||||
android:theme="@style/Theme.NGXHttpMonitoringClient"
|
||||
android:usesCleartextTraffic="true">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
@@ -22,6 +28,10 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<service
|
||||
android:name=".background.MonitorJobService"
|
||||
android:exported="false"
|
||||
android:permission="android.permission.BIND_JOB_SERVICE" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
</manifest>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
val Purple80 = Color(0xFFD0BCFF)
|
||||
val PurpleGrey80 = Color(0xFFCCC2DC)
|
||||
val Pink80 = Color(0xFFEFB8C8)
|
||||
val CommandBlack = Color(0xFF0E1116)
|
||||
val CommandPanel = Color(0xFF171C23)
|
||||
val CommandPanelRaised = Color(0xFF202733)
|
||||
val CommandText = Color(0xFFEAF0F7)
|
||||
val CommandMuted = Color(0xFF9AA6B6)
|
||||
val CommandLight = Color(0xFFF5F7FA)
|
||||
|
||||
val Purple40 = Color(0xFF6650a4)
|
||||
val PurpleGrey40 = Color(0xFF625b71)
|
||||
val Pink40 = Color(0xFF7D5260)
|
||||
val SignalGreen = Color(0xFF35D38B)
|
||||
val SignalCyan = Color(0xFF4CC9F0)
|
||||
val SignalAmber = Color(0xFFFFB84D)
|
||||
val SignalRed = Color(0xFFFF5A52)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
*/
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">NGX http Monitoring Client</string>
|
||||
</resources>
|
||||
<string name="app_name">NGX Monitor</string>
|
||||
</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]
|
||||
agp = "9.2.1"
|
||||
coreKtx = "1.18.0"
|
||||
coroutines = "1.9.0"
|
||||
junit = "4.13.2"
|
||||
junitVersion = "1.3.0"
|
||||
espressoCore = "3.7.0"
|
||||
@@ -24,8 +25,11 @@ androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "u
|
||||
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
|
||||
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||
androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" }
|
||||
androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
|
||||
|
||||
44
proposed-plan.md
Normal file
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