Compare commits

..

10 Commits

74 changed files with 4186 additions and 294 deletions

View File

@@ -1,6 +1,6 @@
---
name: ngx-http-monitoring-client
description: Use this skill when connecting clients or agents to an ngx_http_monitoring_module service, querying its JSON/SSE/Prometheus endpoints, debugging access problems, or integrating dashboards/health checks. Supports deployments protected by Nginx Basic Auth and/or monitor_api_token.
description: Use this skill when connecting clients or agents to an ngx_http_monitoring_module service, querying its JSON/SSE/Prometheus endpoints, reading API response structures, debugging access problems, or integrating dashboards/health checks. Supports deployments protected by Nginx Basic Auth and/or monitor_api_token.
---
# ngx_http_monitoring_module Client
@@ -42,6 +42,400 @@ MONITOR_BASIC_AUTH=user:password
- `GET /monitor/metrics` - Prometheus text format
- `GET /monitor/health` - lightweight JSON health check
## API Structures
All JSON API responses are compact, versioned, and timestamped. Treat unknown fields as forward-compatible additions, and treat missing sections as disabled collectors, unsupported Nginx build options, or unavailable Linux `/proc` data.
Common envelope:
```json
{
"version": 1,
"module": "1.0.0",
"timestamp": 1710000000,
"msec": 123,
"scope": "full",
"pid": 12345
}
```
Endpoint-specific API routes such as `/monitor/api/system` return the common envelope plus one top-level field matching the route. The examples below show that section's value unless explicitly shown as a complete response.
`GET /monitor/api` returns the envelope plus:
```json
{
"system": {},
"nginx": {},
"network": {},
"disk": {},
"processes": {},
"upstreams": [],
"connections": {},
"requests": {},
"history": []
}
```
`GET /monitor/api/system`:
```json
{
"cpu": {
"usage": 12.5,
"cores": 8,
"load": [0.12, 0.20, 0.30]
},
"memory": {
"total": 16777216,
"available": 8388608,
"free": 4194304,
"used": 8388608,
"used_pct": 50.0
},
"swap": {
"total": 2097152,
"free": 1048576,
"used_pct": 50.0
},
"uptime": 123456
}
```
`GET /monitor/api/nginx`:
```json
{
"connections": {
"accepted": 1000,
"handled": 1000,
"active": 20,
"reading": 1,
"writing": 4,
"waiting": 15
},
"requests": {
"total": 50000,
"responses": 49990,
"requests_per_sec": 125.5,
"responses_per_sec": 125.3
},
"ssl": {
"requests": 100,
"handshakes": 20
},
"keepalive": {
"requests": 250
},
"workers": []
}
```
`GET /monitor/api/network`:
```json
{
"rx_bytes": 123456789,
"tx_bytes": 987654321,
"rx_packets": 10000,
"tx_packets": 12000,
"rx_errors": 0,
"tx_errors": 0,
"interfaces": [
{
"name": "eth0",
"rx_bytes": 123456789,
"tx_bytes": 987654321,
"rx_packets": 10000,
"tx_packets": 12000,
"rx_errors": 0,
"tx_errors": 0,
"up": true
}
]
}
```
`GET /monitor/api/disk`:
```json
{
"reads": 1000,
"writes": 2000,
"read_bytes": 4096000,
"write_bytes": 8192000,
"devices": [
{
"name": "sda",
"reads": 1000,
"writes": 2000,
"read_bytes": 4096000,
"write_bytes": 8192000,
"io_ms": 120
}
],
"filesystems": [
{
"path": "/",
"type": "ext4",
"total": 107374182400,
"used": 53687091200,
"free": 53687091200,
"avail": 53687091200,
"files": 1000000,
"files_free": 750000
}
]
}
```
`GET /monitor/api/processes`:
```json
{
"process_count": 140,
"tcp": {
"established": 20,
"listen": 8
},
"sockets": {
"used": 512,
"tcp": 120,
"udp": 16
},
"workers": []
}
```
`GET /monitor/api/connections`:
```json
{
"accepted": 1000,
"handled": 1000,
"active": 20,
"reading": 1,
"writing": 4,
"waiting": 15,
"ssl_requests": 100,
"ssl_handshakes": 20,
"keepalive_requests": 250,
"sse_clients": 2,
"sse_events": 500,
"rate_limited": 0
}
```
`GET /monitor/api/requests`:
```json
{
"total": 50000,
"responses": 49990,
"bytes": 104857600,
"requests_per_sec": 125.5,
"responses_per_sec": 125.3,
"error_rate": 0.0078,
"latency": {
"avg": 8.4,
"p50": 4,
"p90": 15,
"p95": 22,
"p99": 80
},
"status": {
"1xx": 0,
"2xx": 49000,
"3xx": 600,
"4xx": 300,
"5xx": 90
},
"methods": {
"GET": 45000,
"POST": 4000,
"PUT": 100,
"DELETE": 50,
"HEAD": 500,
"OPTIONS": 200,
"PATCH": 50,
"OTHER": 100
},
"latency_histogram": [
{
"le": 1,
"count": 100
},
{
"le": "+Inf",
"count": 50000
}
],
"size_histogram": [
{
"le": 512,
"count": 10000
},
{
"le": "+Inf",
"count": 50000
}
],
"top_urls": [],
"user_agents": []
}
```
`GET /monitor/api/upstreams`:
```json
[
{
"peer": "127.0.0.1:9000",
"requests": 1000,
"failures": 2,
"status_4xx": 10,
"status_5xx": 2,
"avg_latency": 12.5,
"last_seen": 1710000000
}
]
```
History samples in `GET /monitor/api`:
```json
[
{
"timestamp": 1710000000,
"cpu": 12.5,
"memory": 50.0,
"swap": 0.0,
"rps": 125.5,
"responses_per_sec": 125.3,
"network_rx_per_sec": 1024,
"network_tx_per_sec": 2048,
"disk_read_per_sec": 0,
"disk_write_per_sec": 4096,
"latency_p95": 22,
"requests_total": 50000,
"status_4xx": 300,
"status_5xx": 90
}
]
```
Shared nested structures:
```json
{
"worker": {
"slot": 0,
"pid": 12345,
"active": true,
"requests": 10000,
"bytes": 10485760,
"errors": 3,
"vm_size": 102400,
"vm_rss": 20480,
"voluntary_ctxt": 100,
"nonvoluntary_ctxt": 5,
"last_seen": 1710000000
},
"top_url": {
"url": "/api/orders",
"hits": 1000,
"errors": 3,
"bytes": 1048576,
"avg_latency": 7.8,
"last_seen": 1710000000
},
"user_agent": {
"user_agent": "curl/8.0.1",
"hits": 100,
"errors": 0,
"bytes": 20480,
"avg_latency": 2.1,
"last_seen": 1710000000
}
}
```
SSE `GET /monitor/live` sends `metrics` events whose `data` payload has this structure:
```json
{
"version": 1,
"timestamp": 1710000000,
"sequence": 42,
"system": {
"cpu": {
"usage": 12.5,
"cores": 8
},
"memory": {
"used_pct": 50.0,
"total": 16777216,
"available": 8388608
}
},
"requests": {
"total": 50000,
"requests_per_sec": 125.5,
"responses_per_sec": 125.3,
"latency": {
"p95": 22,
"p99": 80
},
"status": {
"4xx": 300,
"5xx": 90
}
},
"connections": {
"active": 20,
"reading": 1,
"writing": 4,
"waiting": 15,
"sse_clients": 2,
"keepalive_requests": 250
},
"network": {
"rx_bytes": 123456789,
"tx_bytes": 987654321
},
"disk": {
"read_bytes": 4096000,
"write_bytes": 8192000
}
}
```
`GET /monitor/metrics` exposes Prometheus text metrics including:
```text
nginx_monitor_requests_total
nginx_monitor_active_connections
nginx_monitor_cpu_usage_ratio
nginx_monitor_memory_used_ratio
nginx_monitor_latency_p95_ms
```
`GET /monitor/health` returns a complete health response:
```json
{
"version": 1,
"module": "1.0.0",
"timestamp": 1710000000,
"msec": 123,
"scope": "health",
"pid": 12345,
"status": "ok",
"generation": 100,
"sse_clients": 2
}
```
## Auth Rules
If the service is behind Nginx Basic Auth, include Basic Auth on every request, including `/monitor`, `/monitor/live`, and `/monitor/metrics`.

View File

@@ -4,10 +4,10 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-05-08T03:24:05.700330800Z">
<DropdownSelection timestamp="2026-05-11T14:50:52.614315100Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=R5CX33ESWGH" />
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\meghdad\.android\avd\Pixel_10_Pro.avd" />
</handle>
</Target>
</DropdownSelection>

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Meghdad
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

129
README.md Normal file
View File

@@ -0,0 +1,129 @@
# NGX Monitor
NGX Monitor is an Android client for `ngx_http_monitoring_module`. It is built with Kotlin and Jetpack Compose for monitoring a fleet of local or private Nginx servers from a phone.
## Features
- Fleet dashboard for multiple servers with live status, tags, favorites, search, and critical-first sorting.
- Server detail cockpit with system, Nginx, request, disk, history, alert, and route diagnostics views.
- Home-screen widgets for fleet status, server resource bars, metric sparklines, telemetry graphs, and incident watch.
- JSON API polling via `/monitor/api` and health checks via `/monitor/health`.
- Foreground live streaming via `/monitor/live` Server-Sent Events.
- Encrypted per-server credentials for monitor tokens and optional Nginx Basic Auth.
- Local SQLite history with 1-minute summaries, 15-minute raw snapshots, and 30-day pruning.
- Background periodic checks with local Android notifications.
- VPN/DNS resilience with per-server fallback LAN IP aliases while preserving HTTPS hostnames for TLS/SNI.
## Monitor API
The app expects each server base URL to expose `/monitor`.
Common endpoints used by the app:
- `GET /monitor/api`
- `GET /monitor/health`
- `GET /monitor/live`
Authentication rules:
- Monitor tokens are sent as `X-Monitor-Token`.
- Basic Auth is sent on every monitor request when configured.
- If both are enabled on the server, configure both in the app.
## Local Network And VPN Notes
For private domains, keep the main server URL as the HTTPS domain, for example:
```text
https://edge-1.home.example
```
If VPN or DNS breaks local resolution, add fallback LAN IPs in the server editor:
```text
192.168.1.20, 192.168.1.21
```
The app keeps the URL hostname unchanged and only substitutes DNS results internally. This keeps HTTPS certificate verification and SNI aligned with the private domain.
Android or the VPN app may still block LAN access completely. In that case, the app shows the failure in the Route panel but cannot override the VPN policy.
## Home-Screen Widgets
The app provides five Android widgets with separate picker previews and labels:
- **NGX Fleet Pulse**: all-server visual status strip, large fleet health state, and priority servers.
- **NGX Server Bars**: one selected server with large status text and CPU, memory, and storage bars.
- **NGX Metric Sparkline**: one selected server and one focused signal with a large value and history graph.
- **NGX Telemetry Graph**: one selected server with CPU, memory, and storage trend lines.
- **NGX Incident Watch**: fleet incident queue for unreachable, blocked, degraded, or insecure servers.
Widgets update automatically through Android's widget scheduler and after the app's periodic background checks. Each widget also has an icon refresh control that fetches fresh monitor data for that widget's scope.
To add a widget:
1. Long-press the Android home screen.
2. Choose Widgets.
3. Select NGX Monitor.
4. For server, metric, and telemetry graph widgets, choose the server during configuration. Metric widgets also ask which signal to graph.
## Security
- Prefer HTTPS for monitor endpoints.
- Prefer header tokens over query-string tokens.
- Avoid sharing screenshots that expose internal hostnames or route details.
- Keep monitor endpoints behind least-privilege ACLs.
- Do not reuse production admin passwords for Basic Auth.
## Build
From the project root:
```powershell
$env:GRADLE_USER_HOME='C:\Users\meghdad\AndroidStudioProjects\NGXhttpMonitoringClient\.gradle-user'
.\gradlew.bat :app:assembleDebug --console=plain
```
Debug APK output:
```text
app/build/outputs/apk/debug/app-debug.apk
```
Run unit tests:
```powershell
$env:GRADLE_USER_HOME='C:\Users\meghdad\AndroidStudioProjects\NGXhttpMonitoringClient\.gradle-user'
.\gradlew.bat :app:testDebugUnitTest --console=plain
```
## Project Structure
```text
app/src/main/java/net/rodakot/ngxhttpmonitoringclient/
background/ JobScheduler checks and notifications
data/ SQLite storage, encrypted credentials, HTTP/SSE client
domain/ URL rules, alert evaluation, route planning, sampling policy
model/ Server, metric, alert, and route data models
ui/ Compose command-deck UI
ui/theme/ Material theme and colors
widget/ Android home-screen widgets
```
## App Assets
- Launcher icon foreground/background: `app/src/main/res/drawable/ic_launcher_foreground.xml`, `ic_launcher_background.xml`
- Splash artwork: `app/src/main/res/drawable/splash_hero.xml`
- Splash background: `app/src/main/res/drawable/splash_screen.xml`
- Widget layouts: `app/src/main/res/layout/widget_fleet.xml`, `widget_server.xml`, `widget_metric.xml`, `widget_graph.xml`, `widget_incidents.xml`
These assets are vector drawables, so they scale cleanly across screen densities.
## Google Play Release
Release configuration, signing instructions, Play Console metadata, and privacy/data-safety drafts live in:
- `docs/PLAY_STORE_RELEASE.md`
- `docs/PLAY_STORE_DATA_SAFETY.md`
- `docs/PRIVACY_POLICY.md`
- `fastlane/metadata/android/en-US/`

View File

@@ -1,8 +1,32 @@
import java.util.Properties
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
}
val localProperties = Properties().apply {
val file = rootProject.file("local.properties")
if (file.isFile) {
file.inputStream().use { load(it) }
}
}
fun releaseSecret(name: String): String? {
return providers.environmentVariable(name).orNull ?: localProperties.getProperty(name)
}
val releaseStoreFile = releaseSecret("NGX_RELEASE_STORE_FILE")
val releaseStorePassword = releaseSecret("NGX_RELEASE_STORE_PASSWORD")
val releaseKeyAlias = releaseSecret("NGX_RELEASE_KEY_ALIAS")
val releaseKeyPassword = releaseSecret("NGX_RELEASE_KEY_PASSWORD")
val hasReleaseSigning = listOf(
releaseStoreFile,
releaseStorePassword,
releaseKeyAlias,
releaseKeyPassword,
).all { !it.isNullOrBlank() }
android {
namespace = "net.rodakot.ngxhttpmonitoringclient"
compileSdk {
@@ -15,15 +39,30 @@ android {
applicationId = "net.rodakot.ngxhttpmonitoringclient"
minSdk = 24
targetSdk = 36
versionCode = 1
versionName = "1.0"
versionCode = (providers.gradleProperty("VERSION_CODE").orNull ?: "1").toInt()
versionName = providers.gradleProperty("VERSION_NAME").orNull ?: "1.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
signingConfigs {
if (hasReleaseSigning) {
create("release") {
storeFile = file(releaseStoreFile!!)
storePassword = releaseStorePassword
keyAlias = releaseKeyAlias
keyPassword = releaseKeyPassword
}
}
}
buildTypes {
release {
isMinifyEnabled = false
isMinifyEnabled = true
isShrinkResources = true
if (hasReleaseSigning) {
signingConfig = signingConfigs.getByName("release")
}
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
@@ -56,6 +95,7 @@ dependencies {
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.okhttp)
testImplementation(libs.junit)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)

View File

@@ -8,20 +8,21 @@
<application
android:name=".NgxMonitorApplication"
android:allowBackup="true"
android:allowBackup="false"
android:appCategory="productivity"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:supportsRtl="false"
android:theme="@style/Theme.NGXHttpMonitoringClient"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.NGXHttpMonitoringClient">
android:theme="@style/Theme.NGXHttpMonitoringClient.Launcher">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -32,6 +33,79 @@
android:name=".background.MonitorJobService"
android:exported="false"
android:permission="android.permission.BIND_JOB_SERVICE" />
<activity
android:name=".widget.ServerWidgetConfigureActivity"
android:exported="true"
android:theme="@style/Theme.NGXHttpMonitoringClient" />
<activity
android:name=".widget.MetricWidgetConfigureActivity"
android:exported="true"
android:theme="@style/Theme.NGXHttpMonitoringClient" />
<activity
android:name=".widget.GraphWidgetConfigureActivity"
android:exported="true"
android:theme="@style/Theme.NGXHttpMonitoringClient" />
<receiver
android:name=".widget.FleetWidgetProvider"
android:exported="true"
android:label="@string/widget_fleet_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_fleet_info" />
</receiver>
<receiver
android:name=".widget.ServerWidgetProvider"
android:exported="true"
android:label="@string/widget_server_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_server_info" />
</receiver>
<receiver
android:name=".widget.MetricWidgetProvider"
android:exported="true"
android:label="@string/widget_metric_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_metric_info" />
</receiver>
<receiver
android:name=".widget.GraphWidgetProvider"
android:exported="true"
android:label="@string/widget_graph_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_graph_info" />
</receiver>
<receiver
android:name=".widget.IncidentsWidgetProvider"
android:exported="true"
android:label="@string/widget_incidents_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_incidents_info" />
</receiver>
</application>
</manifest>

View File

@@ -4,11 +4,15 @@ import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.view.View
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.LayoutDirection
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import net.rodakot.ngxhttpmonitoringclient.ui.MonitorApp
@@ -16,7 +20,9 @@ import net.rodakot.ngxhttpmonitoringclient.ui.theme.NGXHttpMonitoringClientTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
setTheme(R.style.Theme_NGXHttpMonitoringClient)
super.onCreate(savedInstanceState)
window.decorView.layoutDirection = View.LAYOUT_DIRECTION_LTR
enableEdgeToEdge()
requestNotificationPermission()
setContent {
@@ -25,7 +31,9 @@ class MainActivity : ComponentActivity() {
onDispose { controller.close() }
}
NGXHttpMonitoringClientTheme {
MonitorApp(controller)
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
MonitorApp(controller)
}
}
}
}

View File

@@ -41,12 +41,14 @@ class MonitorController(context: Context) {
val summaries = repository.latestSummaries(servers.map { it.id })
val history = selected?.let { mapOf(it to repository.history(it)) }.orEmpty()
val alerts = repository.alerts()
val routeDiagnostics = repository.routeDiagnostics()
_state.update {
it.copy(
servers = servers,
selectedServerId = selected,
summaries = summaries,
history = history,
routeDiagnostics = routeDiagnostics,
alerts = alerts,
globalMessage = null,
)
@@ -140,10 +142,12 @@ class MonitorController(context: Context) {
val servers = repository.servers()
val summaries = repository.latestSummaries(servers.map { it.id })
val history = repository.history(server.id)
val routeDiagnostics = repository.routeDiagnostics()
_state.update {
it.copy(
servers = servers,
summaries = summaries,
routeDiagnostics = routeDiagnostics,
selectedServerId = server.id,
showDetail = true,
selectedTab = DetailTab.Overview,
@@ -191,6 +195,7 @@ class MonitorController(context: Context) {
selectedTab = DetailTab.Overview,
summaries = repository.latestSummaries(servers.map { item -> item.id }),
history = selected?.let { id -> mapOf(id to repository.history(id)) }.orEmpty(),
routeDiagnostics = repository.routeDiagnostics(),
alerts = repository.alerts(),
globalMessage = "Server deleted",
)
@@ -233,10 +238,12 @@ class MonitorController(context: Context) {
val selected = _state.value.selectedServerId
val history = selected?.let { repository.history(it) }
val alerts = repository.alerts()
val routeDiagnostics = repository.routeDiagnostics()
withContext(Dispatchers.Main.immediate) {
_state.update {
it.copy(
alerts = alerts,
routeDiagnostics = routeDiagnostics,
history = if (selected != null && history != null) it.history + (selected to history) else it.history,
)
}

View File

@@ -8,6 +8,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import net.rodakot.ngxhttpmonitoringclient.data.MonitorRepository
import net.rodakot.ngxhttpmonitoringclient.widget.MonitorWidgetUpdater
class MonitorJobService : JobService() {
private var scope: CoroutineScope? = null
@@ -37,5 +38,6 @@ class MonitorJobService : JobService() {
MonitorNotifications.showAlert(applicationContext, server, alert)
}
}
MonitorWidgetUpdater.updateAll(applicationContext, refreshNetwork = false)
}
}

View File

@@ -10,12 +10,16 @@ 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.NetworkIssue
import net.rodakot.ngxhttpmonitoringclient.model.RouteDiagnostics
import net.rodakot.ngxhttpmonitoringclient.model.RouteKind
import net.rodakot.ngxhttpmonitoringclient.model.RoutePolicy
import net.rodakot.ngxhttpmonitoringclient.model.ServerProfile
import net.rodakot.ngxhttpmonitoringclient.model.ServerStatus
import org.json.JSONArray
import java.util.concurrent.TimeUnit
class MonitorDatabase(context: Context) : SQLiteOpenHelper(context, "ngx_monitor.db", null, 1) {
class MonitorDatabase(context: Context) : SQLiteOpenHelper(context, "ngx_monitor.db", null, 2) {
override fun onCreate(db: SQLiteDatabase) {
db.execSQL(
"""
@@ -23,6 +27,8 @@ class MonitorDatabase(context: Context) : SQLiteOpenHelper(context, "ngx_monitor
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
base_url TEXT NOT NULL,
fallback_ips_json TEXT NOT NULL,
route_policy TEXT NOT NULL,
tags_json TEXT NOT NULL,
favorite INTEGER NOT NULL,
allow_http INTEGER NOT NULL,
@@ -74,9 +80,16 @@ class MonitorDatabase(context: Context) : SQLiteOpenHelper(context, "ngx_monitor
""".trimIndent(),
)
db.execSQL("CREATE INDEX alerts_server_time ON alerts(server_id, timestamp_millis DESC)")
createRouteDiagnosticsTable(db)
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = Unit
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
if (oldVersion < 2) {
db.execSQL("ALTER TABLE servers ADD COLUMN fallback_ips_json TEXT NOT NULL DEFAULT '[]'")
db.execSQL("ALTER TABLE servers ADD COLUMN route_policy TEXT NOT NULL DEFAULT '${RoutePolicy.AutoFallback.name}'")
createRouteDiagnosticsTable(db)
}
}
@Synchronized
fun listServers(): List<ServerProfile> {
@@ -97,6 +110,7 @@ class MonitorDatabase(context: Context) : SQLiteOpenHelper(context, "ngx_monitor
writableDatabase.delete("servers", "id = ?", arrayOf(serverId))
writableDatabase.delete("samples", "server_id = ?", arrayOf(serverId))
writableDatabase.delete("alerts", "server_id = ?", arrayOf(serverId))
writableDatabase.delete("route_diagnostics", "server_id = ?", arrayOf(serverId))
}
@Synchronized
@@ -190,6 +204,28 @@ class MonitorDatabase(context: Context) : SQLiteOpenHelper(context, "ngx_monitor
return result
}
@Synchronized
fun upsertRouteDiagnostics(diagnostics: RouteDiagnostics) {
writableDatabase.insertWithOnConflict(
"route_diagnostics",
null,
diagnostics.toValues(),
SQLiteDatabase.CONFLICT_REPLACE,
)
}
@Synchronized
fun routeDiagnostics(): Map<String, RouteDiagnostics> {
val result = linkedMapOf<String, RouteDiagnostics>()
readableDatabase.query("route_diagnostics", null, null, null, null, null, "timestamp_millis DESC").use { cursor ->
while (cursor.moveToNext()) {
val diagnostics = cursor.toRouteDiagnostics()
result[diagnostics.serverId] = diagnostics
}
}
return result
}
@Synchronized
fun pruneOldSamples(nowMillis: Long = System.currentTimeMillis()) {
val cutoff = nowMillis - TimeUnit.DAYS.toMillis(HistoryRetentionDays.toLong())
@@ -200,6 +236,8 @@ class MonitorDatabase(context: Context) : SQLiteOpenHelper(context, "ngx_monitor
put("id", id)
put("name", name)
put("base_url", baseUrl)
put("fallback_ips_json", listToJson(fallbackIpAddresses))
put("route_policy", routePolicy.name)
put("tags_json", tagsToJson(tags))
put("favorite", favorite.asInt())
put("allow_http", allowHttp.asInt())
@@ -241,11 +279,25 @@ class MonitorDatabase(context: Context) : SQLiteOpenHelper(context, "ngx_monitor
put("resolved", resolved.asInt())
}
private fun RouteDiagnostics.toValues() = ContentValues().apply {
put("server_id", serverId)
put("timestamp_millis", timestampMillis)
put("active_network", activeNetwork)
put("route_used", routeUsed.name)
put("dns_result", dnsResult)
put("issue", issue.name)
put("vpn_active", vpnActive.asInt())
put("fallback_used", fallbackUsed.asInt())
put("success", success.asInt())
}
private fun Cursor.toServer(): ServerProfile {
return ServerProfile(
id = string("id"),
name = string("name"),
baseUrl = string("base_url"),
fallbackIpAddresses = listFromJson(string("fallback_ips_json")),
routePolicy = runCatching { RoutePolicy.valueOf(string("route_policy")) }.getOrDefault(RoutePolicy.AutoFallback),
tags = tagsFromJson(string("tags_json")),
favorite = int("favorite") == 1,
allowHttp = int("allow_http") == 1,
@@ -294,17 +346,55 @@ class MonitorDatabase(context: Context) : SQLiteOpenHelper(context, "ngx_monitor
)
}
private fun Cursor.toRouteDiagnostics(): RouteDiagnostics {
return RouteDiagnostics(
serverId = string("server_id"),
timestampMillis = long("timestamp_millis"),
activeNetwork = string("active_network"),
routeUsed = runCatching { RouteKind.valueOf(string("route_used")) }.getOrDefault(RouteKind.SystemDns),
dnsResult = string("dns_result"),
issue = runCatching { NetworkIssue.valueOf(string("issue")) }.getOrDefault(NetworkIssue.Unknown),
vpnActive = int("vpn_active") == 1,
fallbackUsed = int("fallback_used") == 1,
success = int("success") == 1,
)
}
private fun tagsToJson(tags: List<String>): String {
return listToJson(tags)
}
private fun listToJson(values: List<String>): String {
val array = JSONArray()
tags.map { it.trim() }.filter { it.isNotBlank() }.distinct().forEach(array::put)
values.map { it.trim() }.filter { it.isNotBlank() }.distinct().forEach(array::put)
return array.toString()
}
private fun tagsFromJson(value: String): List<String> = runCatching {
private fun tagsFromJson(value: String): List<String> = listFromJson(value)
private fun listFromJson(value: String): List<String> = runCatching {
val array = JSONArray(value)
List(array.length()) { index -> array.optString(index) }.filter { it.isNotBlank() }
}.getOrDefault(emptyList())
private fun createRouteDiagnosticsTable(db: SQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS route_diagnostics (
server_id TEXT PRIMARY KEY,
timestamp_millis INTEGER NOT NULL,
active_network TEXT NOT NULL,
route_used TEXT NOT NULL,
dns_result TEXT NOT NULL,
issue TEXT NOT NULL,
vpn_active INTEGER NOT NULL,
fallback_used INTEGER NOT NULL,
success INTEGER NOT NULL
)
""".trimIndent(),
)
}
private fun Boolean.asInt() = if (this) 1 else 0
private fun ContentValues.putNullable(key: String, value: String?) = if (value == null) putNull(key) else put(key, value)

View File

@@ -1,35 +1,70 @@
package net.rodakot.ngxhttpmonitoringclient.data
import android.util.Base64
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import net.rodakot.ngxhttpmonitoringclient.domain.RouteAttemptPlanner
import net.rodakot.ngxhttpmonitoringclient.domain.UrlRules
import net.rodakot.ngxhttpmonitoringclient.model.AuthConfig
import net.rodakot.ngxhttpmonitoringclient.model.NetworkIssue
import net.rodakot.ngxhttpmonitoringclient.model.RouteDiagnostics
import net.rodakot.ngxhttpmonitoringclient.model.RouteKind
import net.rodakot.ngxhttpmonitoringclient.model.ServerProfile
import net.rodakot.ngxhttpmonitoringclient.model.ServerStatus
import okhttp3.Credentials
import okhttp3.Dns
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import java.io.BufferedReader
import java.io.InterruptedIOException
import java.io.IOException
import java.io.InputStreamReader
import java.net.HttpURLConnection
import java.net.URL
import java.net.ConnectException
import java.net.InetAddress
import java.net.NoRouteToHostException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import java.nio.charset.StandardCharsets
import java.util.concurrent.TimeUnit
import javax.net.ssl.SSLException
class MonitorHttpClient {
fun fetchApi(server: ServerProfile, auth: AuthConfig): String {
return fetchText(UrlRules.endpoint(server.baseUrl, "/monitor/api"), auth)
class MonitorHttpClient(context: Context) {
private val connectivityManager = context.getSystemService(ConnectivityManager::class.java)
private val baseClient = OkHttpClient.Builder()
.connectTimeout(8, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.callTimeout(20, TimeUnit.SECONDS)
.build()
fun fetchApi(server: ServerProfile, auth: AuthConfig): RouteResponse {
return fetchText(server, auth, UrlRules.endpoint(server.baseUrl, "/monitor/api"))
}
fun fetchHealth(server: ServerProfile, auth: AuthConfig): String {
return fetchText(UrlRules.endpoint(server.baseUrl, "/monitor/health"), auth)
fun fetchHealth(server: ServerProfile, auth: AuthConfig): RouteResponse {
return fetchText(server, auth, UrlRules.endpoint(server.baseUrl, "/monitor/health"))
}
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 ->
fun streamLive(
server: ServerProfile,
auth: AuthConfig,
onDiagnostics: (RouteDiagnostics) -> Unit,
onMetrics: (String) -> Unit,
) {
val routed = openRoutedResponse(
server = server,
auth = auth,
url = UrlRules.endpoint(server.baseUrl, "/monitor/live"),
accept = "text/event-stream",
streaming = true,
)
onDiagnostics(routed.diagnostics)
routed.response.use { response ->
if (!response.isSuccessful) {
throw response.toException(routed.diagnostics)
}
BufferedReader(InputStreamReader(response.body!!.byteStream(), StandardCharsets.UTF_8)).use { reader ->
var eventName = "message"
val data = StringBuilder()
while (!Thread.currentThread().isInterrupted) {
@@ -47,41 +82,172 @@ class MonitorHttpClient {
}
}
}
} 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 fetchText(server: ServerProfile, auth: AuthConfig, url: String): RouteResponse {
val routed = openRoutedResponse(server, auth, url, accept = "application/json", streaming = false)
routed.response.use { response ->
if (!response.isSuccessful) {
throw response.toException(routed.diagnostics)
}
val body = response.body?.string().orEmpty()
return RouteResponse(body, routed.diagnostics)
}
}
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")
private fun openRoutedResponse(
server: ServerProfile,
auth: AuthConfig,
url: String,
accept: String,
streaming: Boolean,
): RoutedResponse {
val state = networkState()
val attempts = RouteAttemptPlanner.attempts(
hasFallbackAliases = server.fallbackIpAddresses.isNotEmpty(),
vpnActive = state.vpnActive,
lanAvailable = state.lanNetwork != null,
)
var lastDiagnostics: RouteDiagnostics? = null
var lastException: IOException? = null
attempts.forEach { kind ->
val diagnostics = diagnosticsFor(server, kind, state)
try {
val response = clientFor(kind, state, server, streaming)
.newCall(request(url, auth, accept))
.execute()
return RoutedResponse(response, diagnostics.copy(success = true, issue = NetworkIssue.None))
} catch (exception: IOException) {
lastDiagnostics = diagnostics.copy(success = false, issue = classify(exception, kind))
lastException = exception
}
}
val diagnostics = lastDiagnostics ?: RouteDiagnostics(
serverId = server.id,
timestampMillis = System.currentTimeMillis(),
activeNetwork = state.activeNetworkLabel,
vpnActive = state.vpnActive,
issue = NetworkIssue.Unknown,
)
throw MonitorClientException(
httpStatus = null,
monitorStatus = ServerStatus.Offline,
message = diagnostics.issue.label,
diagnostics = diagnostics,
cause = lastException,
)
}
private fun clientFor(
kind: RouteKind,
state: NetworkState,
server: ServerProfile,
streaming: Boolean,
): OkHttpClient {
val builder = baseClient.newBuilder()
.readTimeout(if (streaming) 0 else 15, TimeUnit.SECONDS)
.callTimeout(if (streaming) 0 else 20, TimeUnit.SECONDS)
val lanNetwork = state.lanNetwork
val dnsDelegate = when {
kind == RouteKind.LanSystemDns || kind == RouteKind.LanFallbackDns -> lanNetwork?.let(::NetworkDns) ?: Dns.SYSTEM
else -> Dns.SYSTEM
}
if (kind == RouteKind.LanSystemDns || kind == RouteKind.LanFallbackDns) {
lanNetwork?.let { builder.socketFactory(it.socketFactory) }
}
if (kind == RouteKind.FallbackDns || kind == RouteKind.LanFallbackDns) {
builder.dns(FallbackDns(server.hostName(), server.fallbackIpAddresses, dnsDelegate))
} else {
builder.dns(dnsDelegate)
}
return builder.build()
}
private fun request(url: String, auth: AuthConfig, accept: String): Request {
val builder = Request.Builder()
.url(url)
.get()
.header("Accept", accept)
auth.token?.takeIf { it.isNotBlank() }?.let {
connection.setRequestProperty("X-Monitor-Token", it)
builder.header("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")
builder.header("Authorization", Credentials.basic(auth.basicUsername, auth.basicPassword))
}
return connection
return builder.build()
}
private fun diagnosticsFor(server: ServerProfile, kind: RouteKind, state: NetworkState): RouteDiagnostics {
val fallbackUsed = kind == RouteKind.FallbackDns || kind == RouteKind.LanFallbackDns
val dnsResult = when (kind) {
RouteKind.SystemDns -> "System DNS"
RouteKind.FallbackDns -> server.fallbackIpAddresses.joinToString(", ")
RouteKind.LanSystemDns -> "LAN DNS"
RouteKind.LanFallbackDns -> server.fallbackIpAddresses.joinToString(", ")
}
return RouteDiagnostics(
serverId = server.id,
timestampMillis = System.currentTimeMillis(),
activeNetwork = state.activeNetworkLabel,
routeUsed = kind,
dnsResult = dnsResult.ifBlank { "None" },
issue = if (state.vpnActive) NetworkIssue.VpnActive else NetworkIssue.None,
vpnActive = state.vpnActive,
fallbackUsed = fallbackUsed,
success = false,
)
}
private fun networkState(): NetworkState {
val networks = connectivityManager?.allNetworks.orEmpty()
val activeLabel = connectivityManager?.activeNetwork
?.let { network -> connectivityManager.getNetworkCapabilities(network)?.transportLabel() }
?: "No active network"
val vpnActive = networks.any { network ->
connectivityManager?.getNetworkCapabilities(network)?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true
}
val lanNetwork = networks.firstOrNull { network ->
val capabilities = connectivityManager?.getNetworkCapabilities(network)
capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true ||
capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) == true
}
return NetworkState(activeLabel, vpnActive, lanNetwork)
}
private fun classify(exception: IOException, kind: RouteKind): NetworkIssue {
return when (exception) {
is UnknownHostException -> NetworkIssue.DnsFailure
is SocketTimeoutException -> NetworkIssue.Timeout
is SSLException -> NetworkIssue.TlsFailure
is NoRouteToHostException -> NetworkIssue.LanRouteBlocked
is ConnectException -> if (kind == RouteKind.LanSystemDns || kind == RouteKind.LanFallbackDns) {
NetworkIssue.LanRouteBlocked
} else {
NetworkIssue.Timeout
}
is InterruptedIOException -> NetworkIssue.Timeout
else -> NetworkIssue.Unknown
}
}
private fun Response.toException(diagnostics: RouteDiagnostics): MonitorClientException {
val issue = code.toNetworkIssue()
return MonitorClientException(
httpStatus = code,
monitorStatus = code.toMonitorStatus(),
message = code.messageForStatus(),
diagnostics = diagnostics.copy(success = false, issue = issue),
)
}
private fun Int.toNetworkIssue(): NetworkIssue = when (this) {
401 -> NetworkIssue.AuthFailure
429 -> NetworkIssue.RateLimited
else -> NetworkIssue.ApiFailure
}
private fun Int.toMonitorStatus(): ServerStatus = when (this) {
@@ -99,11 +265,58 @@ class MonitorHttpClient {
429 -> "Monitor rate limit was exceeded"
else -> "HTTP $this from monitor endpoint"
}
private fun NetworkCapabilities.transportLabel(): String {
return buildList {
if (hasTransport(NetworkCapabilities.TRANSPORT_VPN)) add("VPN")
if (hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) add("Wi-Fi")
if (hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) add("Ethernet")
if (hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) add("Cellular")
}.ifEmpty { listOf("Unknown") }.joinToString(" + ")
}
private fun ServerProfile.hostName(): String = java.net.URI(baseUrl).host.orEmpty()
}
data class RouteResponse(
val body: String,
val diagnostics: RouteDiagnostics,
)
private data class RoutedResponse(
val response: Response,
val diagnostics: RouteDiagnostics,
)
private data class NetworkState(
val activeNetworkLabel: String,
val vpnActive: Boolean,
val lanNetwork: Network?,
)
private class FallbackDns(
private val hostName: String,
private val fallbackIps: List<String>,
private val delegate: Dns,
) : Dns {
override fun lookup(hostname: String): List<InetAddress> {
if (hostname.equals(hostName, ignoreCase = true) && fallbackIps.isNotEmpty()) {
return fallbackIps.map { InetAddress.getByName(it) }
}
return delegate.lookup(hostname)
}
}
private class NetworkDns(private val network: Network) : Dns {
override fun lookup(hostname: String): List<InetAddress> {
return network.getAllByName(hostname).toList()
}
}
class MonitorClientException(
val httpStatus: Int?,
val monitorStatus: ServerStatus,
override val message: String,
val diagnostics: RouteDiagnostics,
cause: Throwable? = null,
) : Exception(message, cause)

View File

@@ -51,16 +51,45 @@ object MonitorJsonParser {
if (disk == null) return null
disk.number("used_pct")?.let { return it }
disk.number("used_percent")?.let { return it }
disk.obj("filesystem")?.let { filesystem ->
filesystem.obj("/")?.diskUsagePercent()?.let { return it }
var max: Double? = null
val names = filesystem.keys()
while (names.hasNext()) {
val item = filesystem.optJSONObject(names.next()) ?: continue
val usage = item.diskUsagePercent()
if (usage != null && (max == null || usage > max!!)) max = usage
}
if (max != null) return max
}
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")
val usage = item.diskUsagePercent()
if (usage != null && item.mountPath() == "/") return usage
if (usage != null && (max == null || usage > max!!)) max = usage
}
return max
}
private fun JSONObject.diskUsagePercent(): Double? {
number("used_pct")?.let { return it }
number("used_percent")?.let { return it }
number("usage")?.let { return it }
number("usage_pct")?.let { return it }
val used = firstNumber("used", "used_size", "used_bytes", "used_kb")
val total = firstNumber("total", "total_size", "total_bytes", "size", "size_bytes", "total_kb")
return usedPercentFromSizes(used, total)
}
internal fun usedPercentFromSizes(used: Double?, total: Double?): Double? {
if (used == null || total == null || total <= 0.0) return null
return used / total * 100.0
}
private fun normalizePercent(value: Double?): Double? {
if (value == null) return null
return if (value in 0.0..1.0) value * 100.0 else value
@@ -69,16 +98,30 @@ object MonitorJsonParser {
private fun JSONObject.obj(name: String): JSONObject? = optJSONObject(name)
private fun JSONObject.array(name: String): JSONArray? = optJSONArray(name)
private fun JSONObject.firstNumber(vararg names: String): Double? {
for (name in names) number(name)?.let { return it }
return null
}
private fun JSONObject.mountPath(): String? {
return string("path") ?: string("mount") ?: string("mount_point") ?: string("mounted_on")
}
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()
is String -> value.trim().removeSuffix("%").replace(",", "").toDoubleOrNull()
else -> null
}
}
private fun JSONObject.string(name: String): String? {
if (!has(name) || isNull(name)) return null
return optString(name).takeIf { it.isNotBlank() }
}
private fun JSONObject.int(name: String): Int? {
if (!has(name) || isNull(name)) return null
val value = opt(name)

View File

@@ -18,7 +18,7 @@ import org.json.JSONException
class MonitorRepository(context: Context) {
private val database = MonitorDatabase(context.applicationContext)
private val cipher = CredentialCipher()
private val client = MonitorHttpClient()
private val client = MonitorHttpClient(context.applicationContext)
private val alertCooldownMillis = TimeUnit.MINUTES.toMillis(30)
fun servers(): List<ServerProfile> = database.listServers()
@@ -27,6 +27,8 @@ class MonitorRepository(context: Context) {
fun history(serverId: String): List<MetricSummary> = database.samplesForServer(serverId)
fun routeDiagnostics(): Map<String, net.rodakot.ngxhttpmonitoringclient.model.RouteDiagnostics> = database.routeDiagnostics()
fun alerts(): List<AlertEvent> = database.alerts()
fun draftFor(server: ServerProfile?): ServerEditorDraft {
@@ -35,6 +37,7 @@ class MonitorRepository(context: Context) {
id = server.id,
name = server.name,
baseUrl = server.baseUrl,
fallbackIpAddresses = server.fallbackIpAddresses.joinToString(", "),
tags = server.tags.joinToString(", "),
favorite = server.favorite,
allowHttp = server.allowHttp,
@@ -63,6 +66,7 @@ class MonitorRepository(context: Context) {
id = draft.id ?: UUID.randomUUID().toString(),
name = draft.name.trim().ifBlank { baseUrl },
baseUrl = baseUrl,
fallbackIpAddresses = UrlRules.parseFallbackIps(draft.fallbackIpAddresses),
tags = draft.tags.split(',').map { it.trim() }.filter { it.isNotBlank() }.distinct(),
favorite = draft.favorite,
allowHttp = draft.allowHttp,
@@ -91,8 +95,9 @@ class MonitorRepository(context: Context) {
fun testConnection(draft: ServerEditorDraft): Result<String> = runCatching {
val server = buildServer(draft.copy(id = draft.id ?: "test"))
client.fetchHealth(server, authFor(server))
"Connection works"
val response = client.fetchHealth(server, authFor(server))
database.upsertRouteDiagnostics(response.diagnostics.copy(serverId = server.id))
"Connection works through ${response.diagnostics.routeUsed.label}"
}
fun fetchApiSummary(server: ServerProfile, forcePersist: Boolean = false): MetricSummary {
@@ -101,17 +106,19 @@ class MonitorRepository(context: Context) {
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 response = client.fetchApi(server, authFor(server))
database.upsertRouteDiagnostics(response.diagnostics)
val parsed = MonitorJsonParser.parse(server.id, response.body)
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)
persistSample(server, summary, response.body, forcePersist)
RefreshResult(summary, recordAlerts(server, summary))
} catch (exception: MonitorClientException) {
database.upsertRouteDiagnostics(exception.diagnostics)
val summary = MetricSummary(
serverId = server.id,
timestampMillis = System.currentTimeMillis(),
@@ -142,7 +149,11 @@ class MonitorRepository(context: Context) {
}
fun streamLive(server: ServerProfile, onSummary: (MetricSummary) -> Unit) {
client.streamLive(server, authFor(server)) { payload ->
client.streamLive(
server = server,
auth = authFor(server),
onDiagnostics = database::upsertRouteDiagnostics,
) { payload ->
val parsed = MonitorJsonParser.parse(server.id, payload)
val events = AlertEvaluator.evaluate(server, parsed)
val status = if (events.isEmpty()) ServerStatus.Online else ServerStatus.Degraded

View File

@@ -0,0 +1,19 @@
package net.rodakot.ngxhttpmonitoringclient.domain
import net.rodakot.ngxhttpmonitoringclient.model.RouteKind
object RouteAttemptPlanner {
fun attempts(
hasFallbackAliases: Boolean,
vpnActive: Boolean,
lanAvailable: Boolean,
): List<RouteKind> {
val result = mutableListOf(RouteKind.SystemDns)
if (hasFallbackAliases) result += RouteKind.FallbackDns
if (vpnActive && lanAvailable) {
result += RouteKind.LanSystemDns
if (hasFallbackAliases) result += RouteKind.LanFallbackDns
}
return result.distinct()
}
}

View File

@@ -1,6 +1,7 @@
package net.rodakot.ngxhttpmonitoringclient.domain
import java.net.URI
import java.net.InetAddress
object UrlRules {
fun normalizeBaseUrl(input: String): String {
@@ -30,4 +31,16 @@ object UrlRules {
val normalizedPath = if (path.startsWith("/")) path else "/$path"
return normalizeBaseUrl(baseUrl) + normalizedPath
}
fun parseFallbackIps(input: String): List<String> {
return input
.split(',', '\n', ' ')
.map { it.trim() }
.filter { it.isNotBlank() }
.distinct()
.onEach { value ->
runCatching { InetAddress.getByName(value) }
.getOrElse { throw IllegalArgumentException("Invalid fallback IP: $value") }
}
}
}

View File

@@ -13,6 +13,8 @@ data class ServerProfile(
val id: String,
val name: String,
val baseUrl: String,
val fallbackIpAddresses: List<String> = emptyList(),
val routePolicy: RoutePolicy = RoutePolicy.AutoFallback,
val tags: List<String> = emptyList(),
val favorite: Boolean = false,
val allowHttp: Boolean = false,
@@ -30,6 +32,49 @@ data class AuthConfig(
val basicPassword: String? = null,
)
enum class RoutePolicy {
AutoFallback,
}
enum class RouteKind(val label: String) {
SystemDns("System DNS"),
FallbackDns("Fallback DNS"),
LanSystemDns("LAN DNS"),
LanFallbackDns("LAN fallback"),
}
enum class NetworkIssue(val label: String) {
None("OK"),
DnsFailure("DNS failure"),
VpnActive("VPN active"),
LanRouteBlocked("LAN route blocked"),
Timeout("Timeout"),
TlsFailure("TLS failure"),
AuthFailure("Authentication failed"),
ApiFailure("API failure"),
RateLimited("Rate limited"),
Unknown("Network error"),
}
data class RouteDiagnostics(
val serverId: String,
val timestampMillis: Long,
val activeNetwork: String = "Unknown",
val routeUsed: RouteKind = RouteKind.SystemDns,
val dnsResult: String = "System DNS",
val issue: NetworkIssue = NetworkIssue.None,
val vpnActive: Boolean = false,
val fallbackUsed: Boolean = false,
val success: Boolean = false,
) {
val summary: String
get() = if (success) {
"Connected through ${routeUsed.label}"
} else {
issue.label
}
}
data class AlertOverrides(
val cpuPercent: Double? = null,
val memoryPercent: Double? = null,
@@ -87,6 +132,7 @@ data class ServerEditorDraft(
val id: String? = null,
val name: String = "",
val baseUrl: String = "",
val fallbackIpAddresses: String = "",
val tags: String = "",
val favorite: Boolean = false,
val allowHttp: Boolean = false,
@@ -118,6 +164,7 @@ data class MonitorUiState(
val servers: List<ServerProfile> = emptyList(),
val summaries: Map<String, MetricSummary> = emptyMap(),
val history: Map<String, List<MetricSummary>> = emptyMap(),
val routeDiagnostics: Map<String, RouteDiagnostics> = emptyMap(),
val alerts: List<AlertEvent> = emptyList(),
val selectedServerId: String? = null,
val showDetail: Boolean = false,

View File

@@ -71,6 +71,8 @@ import net.rodakot.ngxhttpmonitoringclient.model.DefaultMemoryThreshold
import net.rodakot.ngxhttpmonitoringclient.model.DetailTab
import net.rodakot.ngxhttpmonitoringclient.model.MetricSummary
import net.rodakot.ngxhttpmonitoringclient.model.MonitorUiState
import net.rodakot.ngxhttpmonitoringclient.model.NetworkIssue
import net.rodakot.ngxhttpmonitoringclient.model.RouteDiagnostics
import net.rodakot.ngxhttpmonitoringclient.model.ServerEditorDraft
import net.rodakot.ngxhttpmonitoringclient.model.ServerProfile
import net.rodakot.ngxhttpmonitoringclient.model.ServerStatus
@@ -204,6 +206,7 @@ private fun FleetCommandDeck(
ServerCommandRow(
server = server,
summary = state.summaries[server.id],
diagnostics = state.routeDiagnostics[server.id],
onClick = { controller.selectServer(server.id) },
)
}
@@ -346,7 +349,12 @@ private fun CommandFilters(
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun ServerCommandRow(server: ServerProfile, summary: MetricSummary?, onClick: () -> Unit) {
private fun ServerCommandRow(
server: ServerProfile,
summary: MetricSummary?,
diagnostics: RouteDiagnostics?,
onClick: () -> Unit,
) {
val status = summary?.status ?: ServerStatus.Unknown
val health = healthScore(summary)
Surface(
@@ -393,6 +401,7 @@ private fun ServerCommandRow(server: ServerProfile, summary: MetricSummary?, onC
LabelCapsule(statusLabel(status).uppercase(), colorForStatus(status))
if (!server.enabled) LabelCapsule("OFF", StatusUnknown)
if (server.allowHttp) LabelCapsule("HTTP", StatusInsecure)
RouteBadges(diagnostics)
server.tags.take(3).forEach { LabelCapsule(it.uppercase(), MaterialTheme.colorScheme.secondary) }
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
@@ -423,6 +432,9 @@ private fun ServerCockpit(
item {
CockpitHero(server, summary, controller)
}
item {
RoutePanel(state.routeDiagnostics[server.id])
}
item {
CockpitTabs(selected = state.selectedTab, onSelect = controller::setDetailTab)
}
@@ -477,6 +489,25 @@ private fun CockpitHero(server: ServerProfile, summary: MetricSummary?, controll
}
}
@Composable
private fun RoutePanel(diagnostics: RouteDiagnostics?) {
DataPanel("Route") {
if (diagnostics == null) {
Text("No route check has completed yet.", color = MaterialTheme.colorScheme.onSurfaceVariant)
} else {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
RuleRow("Network", diagnostics.activeNetwork)
RuleRow("Route", diagnostics.routeUsed.label)
RuleRow("DNS", diagnostics.dnsResult)
RuleRow("VPN", if (diagnostics.vpnActive) "Active" else "Inactive")
RuleRow("Fallback", if (diagnostics.fallbackUsed) "Used" else "Not used")
RuleRow("Last result", diagnostics.summary)
RuleRow("Checked", diagnostics.timestampMillis.formatTime())
}
}
}
}
@Composable
private fun CockpitTabs(selected: DetailTab, onSelect: (DetailTab) -> Unit) {
Row(
@@ -556,16 +587,27 @@ private fun HistoryCockpit(history: List<MetricSummary>) {
@Composable
private fun SettingsCockpit(server: ServerProfile, controller: MonitorController) {
DataPanel("Alert Rules") {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
RuleRow("CPU limit", "${server.alertOverrides.cpuPercent ?: DefaultCpuThreshold}%")
RuleRow("Memory limit", "${server.alertOverrides.memoryPercent ?: DefaultMemoryThreshold}%")
RuleRow("Disk limit", "${server.alertOverrides.diskPercent ?: DefaultDiskThreshold}%")
RuleRow("p95 latency", "${(server.alertOverrides.latencyP95Millis ?: DefaultLatencyP95ThresholdMs).toInt()} ms")
RuleRow("5xx limit", "${server.alertOverrides.errors5xx ?: DefaultErrors5xxThreshold}")
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Button(onClick = controller::showEditSelectedServer, shape = RoundedCornerShape(8.dp)) { Text("Edit") }
OutlinedButton(onClick = controller::deleteSelectedServer, shape = RoundedCornerShape(8.dp)) { Text("Delete") }
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
DataPanel("Alert Rules") {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
RuleRow("CPU limit", "${server.alertOverrides.cpuPercent ?: DefaultCpuThreshold}%")
RuleRow("Memory limit", "${server.alertOverrides.memoryPercent ?: DefaultMemoryThreshold}%")
RuleRow("Disk limit", "${server.alertOverrides.diskPercent ?: DefaultDiskThreshold}%")
RuleRow("p95 latency", "${(server.alertOverrides.latencyP95Millis ?: DefaultLatencyP95ThresholdMs).toInt()} ms")
RuleRow("5xx limit", "${server.alertOverrides.errors5xx ?: DefaultErrors5xxThreshold}")
RuleRow("Fallback IPs", server.fallbackIpAddresses.ifEmpty { listOf("None") }.joinToString(", "))
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Button(onClick = controller::showEditSelectedServer, shape = RoundedCornerShape(8.dp)) { Text("Edit") }
OutlinedButton(onClick = controller::deleteSelectedServer, shape = RoundedCornerShape(8.dp)) { Text("Delete") }
}
}
}
DataPanel("Privacy") {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
RuleRow("Storage", "Server profiles, credentials, samples, alerts, and widget choices stay on this device.")
RuleRow("Credentials", "Monitor tokens and Basic Auth passwords are encrypted with Android Keystore.")
RuleRow("Network", "The app connects only to monitor endpoints configured by the user.")
RuleRow("Backups", "Android cloud backup and device transfer are disabled for app data.")
}
}
}
@@ -665,6 +707,16 @@ private fun LabelCapsule(text: String, color: Color) {
}
}
@Composable
private fun RouteBadges(diagnostics: RouteDiagnostics?) {
if (diagnostics == null) return
if (diagnostics.vpnActive) LabelCapsule("VPN", StatusWarning)
if (diagnostics.fallbackUsed) LabelCapsule("LAN FALLBACK", StatusOnline)
if (!diagnostics.success && diagnostics.issue != NetworkIssue.None) {
LabelCapsule(diagnostics.issue.label.uppercase(), routeIssueColor(diagnostics.issue))
}
}
@Composable
private fun HealthBadge(score: Int, status: ServerStatus) {
val color = colorForStatus(status)
@@ -834,6 +886,7 @@ private fun ServerEditorDialog(
}
item { EditorField("Display name", draft.name) { value -> onChange { it.copy(name = value) } } }
item { EditorField("Base URL", draft.baseUrl) { value -> onChange { it.copy(baseUrl = value) } } }
item { EditorField("Fallback LAN IPs", draft.fallbackIpAddresses) { value -> onChange { it.copy(fallbackIpAddresses = value) } } }
item { EditorField("Tags", draft.tags) { value -> onChange { it.copy(tags = value) } } }
item {
FlowRow(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -1005,6 +1058,19 @@ private fun colorForPercent(value: Double?): Color = when {
else -> StatusOnline
}
private fun routeIssueColor(issue: NetworkIssue): Color = when (issue) {
NetworkIssue.None -> StatusOnline
NetworkIssue.DnsFailure -> StatusInsecure
NetworkIssue.VpnActive -> StatusWarning
NetworkIssue.LanRouteBlocked -> StatusWarning
NetworkIssue.Timeout -> StatusWarning
NetworkIssue.TlsFailure -> StatusCritical
NetworkIssue.AuthFailure -> StatusCritical
NetworkIssue.ApiFailure -> StatusCritical
NetworkIssue.RateLimited -> StatusWarning
NetworkIssue.Unknown -> StatusUnknown
}
private fun Double?.formatPercent(): String = this?.let { "${it.toInt()}%" } ?: "--"
private fun Double?.formatMillis(): String = this?.let { "${it.toInt()} ms" } ?: "--"
private fun Double?.formatNumber(): String = this?.let { if (it >= 10) it.toInt().toString() else "%.1f".format(it) } ?: "--"

View File

@@ -0,0 +1,145 @@
package net.rodakot.ngxhttpmonitoringclient.widget
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import android.content.Intent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
private val widgetScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
class FleetWidgetProvider : AppWidgetProvider() {
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
widgetScope.launch { MonitorWidgetUpdater.updateFleet(context.applicationContext, appWidgetIds, refreshNetwork = false) }
}
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == MonitorWidgetUpdater.ActionRefreshFleet) {
val pending = goAsync()
widgetScope.launch {
try {
val id = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
val ids = if (id == AppWidgetManager.INVALID_APPWIDGET_ID) intArrayOf() else intArrayOf(id)
MonitorWidgetUpdater.updateFleet(context.applicationContext, ids, refreshNetwork = true)
} finally {
pending.finish()
}
}
} else {
super.onReceive(context, intent)
}
}
}
class ServerWidgetProvider : AppWidgetProvider() {
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
widgetScope.launch { MonitorWidgetUpdater.updateServers(context.applicationContext, appWidgetIds, refreshNetwork = false) }
}
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == MonitorWidgetUpdater.ActionRefreshServer) {
val pending = goAsync()
widgetScope.launch {
try {
val id = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
val ids = if (id == AppWidgetManager.INVALID_APPWIDGET_ID) intArrayOf() else intArrayOf(id)
MonitorWidgetUpdater.updateServers(context.applicationContext, ids, refreshNetwork = true)
} finally {
pending.finish()
}
}
} else {
super.onReceive(context, intent)
}
}
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
WidgetPreferences.delete(context, appWidgetIds)
}
}
class MetricWidgetProvider : AppWidgetProvider() {
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
widgetScope.launch { MonitorWidgetUpdater.updateMetrics(context.applicationContext, appWidgetIds, refreshNetwork = false) }
}
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == MonitorWidgetUpdater.ActionRefreshMetric) {
val pending = goAsync()
widgetScope.launch {
try {
val id = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
val ids = if (id == AppWidgetManager.INVALID_APPWIDGET_ID) intArrayOf() else intArrayOf(id)
MonitorWidgetUpdater.updateMetrics(context.applicationContext, ids, refreshNetwork = true)
} finally {
pending.finish()
}
}
} else {
super.onReceive(context, intent)
}
}
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
WidgetPreferences.delete(context, appWidgetIds)
}
}
class GraphWidgetProvider : AppWidgetProvider() {
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
widgetScope.launch { MonitorWidgetUpdater.updateGraphs(context.applicationContext, appWidgetIds, refreshNetwork = false) }
}
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == MonitorWidgetUpdater.ActionRefreshGraph) {
val pending = goAsync()
widgetScope.launch {
try {
val id = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
val ids = if (id == AppWidgetManager.INVALID_APPWIDGET_ID) intArrayOf() else intArrayOf(id)
MonitorWidgetUpdater.updateGraphs(context.applicationContext, ids, refreshNetwork = true)
} finally {
pending.finish()
}
}
} else {
super.onReceive(context, intent)
}
}
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
WidgetPreferences.delete(context, appWidgetIds)
}
}
class IncidentsWidgetProvider : AppWidgetProvider() {
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
widgetScope.launch { MonitorWidgetUpdater.updateIncidents(context.applicationContext, appWidgetIds, refreshNetwork = false) }
}
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == MonitorWidgetUpdater.ActionRefreshIncidents) {
val pending = goAsync()
widgetScope.launch {
try {
val id = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
val ids = if (id == AppWidgetManager.INVALID_APPWIDGET_ID) {
AppWidgetManager.getInstance(context).getAppWidgetIds(
android.content.ComponentName(context, IncidentsWidgetProvider::class.java),
)
} else {
intArrayOf(id)
}
MonitorWidgetUpdater.updateIncidents(context.applicationContext, ids, refreshNetwork = true)
} finally {
pending.finish()
}
}
} else {
super.onReceive(context, intent)
}
}
}

View File

@@ -0,0 +1,721 @@
package net.rodakot.ngxhttpmonitoringclient.widget
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Path
import android.graphics.RectF
import android.widget.RemoteViews
import java.text.DateFormat
import java.util.Date
import java.util.Locale
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
import net.rodakot.ngxhttpmonitoringclient.MainActivity
import net.rodakot.ngxhttpmonitoringclient.R
import net.rodakot.ngxhttpmonitoringclient.data.MonitorRepository
import net.rodakot.ngxhttpmonitoringclient.model.MetricSummary
import net.rodakot.ngxhttpmonitoringclient.model.ServerProfile
import net.rodakot.ngxhttpmonitoringclient.model.ServerStatus
object MonitorWidgetUpdater {
const val ActionRefreshFleet = "net.rodakot.ngxhttpmonitoringclient.widget.REFRESH_FLEET"
const val ActionRefreshServer = "net.rodakot.ngxhttpmonitoringclient.widget.REFRESH_SERVER"
const val ActionRefreshMetric = "net.rodakot.ngxhttpmonitoringclient.widget.REFRESH_METRIC"
const val ActionRefreshGraph = "net.rodakot.ngxhttpmonitoringclient.widget.REFRESH_GRAPH"
const val ActionRefreshIncidents = "net.rodakot.ngxhttpmonitoringclient.widget.REFRESH_INCIDENTS"
private val Green = Color.rgb(53, 211, 139)
private val Cyan = Color.rgb(76, 201, 240)
private val Amber = Color.rgb(255, 184, 77)
private val Red = Color.rgb(255, 90, 82)
private val Text = Color.rgb(234, 240, 247)
private val Muted = Color.rgb(154, 166, 182)
private val Panel = Color.rgb(32, 39, 51)
private val Grid = Color.rgb(55, 68, 84)
fun updateAll(context: Context, refreshNetwork: Boolean = false) {
val manager = AppWidgetManager.getInstance(context)
updateFleet(context, manager.idsFor(context, FleetWidgetProvider::class.java), refreshNetwork)
updateServers(context, manager.idsFor(context, ServerWidgetProvider::class.java), refreshNetwork)
updateMetrics(context, manager.idsFor(context, MetricWidgetProvider::class.java), refreshNetwork)
updateGraphs(context, manager.idsFor(context, GraphWidgetProvider::class.java), refreshNetwork)
updateIncidents(context, manager.idsFor(context, IncidentsWidgetProvider::class.java), refreshNetwork)
}
fun updateFleet(context: Context, appWidgetIds: IntArray, refreshNetwork: Boolean) {
if (appWidgetIds.isEmpty()) return
val repository = MonitorRepository(context)
val servers = repository.servers()
if (refreshNetwork) {
servers.filter { it.enabled }.forEach { repository.refreshServer(it, forcePersist = true) }
}
val summaries = repository.latestSummaries(servers.map { it.id })
val manager = AppWidgetManager.getInstance(context)
appWidgetIds.forEach { appWidgetId ->
manager.updateAppWidget(appWidgetId, fleetViews(context, servers, summaries, appWidgetId))
}
}
fun updateServers(context: Context, appWidgetIds: IntArray, refreshNetwork: Boolean) {
if (appWidgetIds.isEmpty()) return
val repository = MonitorRepository(context)
val servers = repository.servers()
val summaries = mutableMapOf<String, MetricSummary>().apply {
putAll(repository.latestSummaries(servers.map { it.id }))
}
val refreshed = mutableSetOf<String>()
val manager = AppWidgetManager.getInstance(context)
appWidgetIds.forEach { appWidgetId ->
val server = configuredServer(context, appWidgetId, servers)
if (refreshNetwork && server != null && refreshed.add(server.id)) {
summaries[server.id] = repository.refreshServer(server, forcePersist = true).summary
}
manager.updateAppWidget(appWidgetId, serverViews(context, appWidgetId, server, server?.let { summaries[it.id] }))
}
}
fun updateMetrics(context: Context, appWidgetIds: IntArray, refreshNetwork: Boolean) {
if (appWidgetIds.isEmpty()) return
val repository = MonitorRepository(context)
val servers = repository.servers()
val summaries = mutableMapOf<String, MetricSummary>().apply {
putAll(repository.latestSummaries(servers.map { it.id }))
}
val refreshed = mutableSetOf<String>()
val manager = AppWidgetManager.getInstance(context)
appWidgetIds.forEach { appWidgetId ->
val server = configuredServer(context, appWidgetId, servers)
if (refreshNetwork && server != null && refreshed.add(server.id)) {
summaries[server.id] = repository.refreshServer(server, forcePersist = true).summary
}
val history = server?.let { repository.history(it.id).asReversed().takeLast(48) }.orEmpty()
manager.updateAppWidget(
appWidgetId,
metricViews(
context = context,
appWidgetId = appWidgetId,
server = server,
summary = server?.let { summaries[it.id] },
history = history,
metric = WidgetPreferences.metricKind(context, appWidgetId),
),
)
}
}
fun updateGraphs(context: Context, appWidgetIds: IntArray, refreshNetwork: Boolean) {
if (appWidgetIds.isEmpty()) return
val repository = MonitorRepository(context)
val servers = repository.servers()
val summaries = mutableMapOf<String, MetricSummary>().apply {
putAll(repository.latestSummaries(servers.map { it.id }))
}
val refreshed = mutableSetOf<String>()
val manager = AppWidgetManager.getInstance(context)
appWidgetIds.forEach { appWidgetId ->
val server = configuredServer(context, appWidgetId, servers)
if (refreshNetwork && server != null && refreshed.add(server.id)) {
summaries[server.id] = repository.refreshServer(server, forcePersist = true).summary
}
val history = server?.let { repository.history(it.id).asReversed().takeLast(60) }.orEmpty()
manager.updateAppWidget(
appWidgetId,
graphViews(
context = context,
appWidgetId = appWidgetId,
server = server,
summary = server?.let { summaries[it.id] },
history = history,
),
)
}
}
fun updateIncidents(context: Context, appWidgetIds: IntArray, refreshNetwork: Boolean) {
if (appWidgetIds.isEmpty()) return
val repository = MonitorRepository(context)
val servers = repository.servers()
if (refreshNetwork) {
servers.filter { it.enabled }.forEach { repository.refreshServer(it, forcePersist = true) }
}
val summaries = repository.latestSummaries(servers.map { it.id })
val manager = AppWidgetManager.getInstance(context)
appWidgetIds.forEach { appWidgetId ->
manager.updateAppWidget(appWidgetId, incidentsViews(context, servers, summaries, appWidgetId))
}
}
private fun fleetViews(
context: Context,
servers: List<ServerProfile>,
summaries: Map<String, MetricSummary>,
appWidgetId: Int,
): RemoteViews {
val views = RemoteViews(context.packageName, R.layout.widget_fleet)
val counters = counters(servers, summaries)
val headline = fleetHeadline(counters)
views.setTextViewText(R.id.widget_title, "Fleet Pulse")
views.setTextViewText(R.id.widget_subtitle, "${servers.size} servers - ${formatTime(System.currentTimeMillis())}")
views.setTextViewText(R.id.widget_status, headline)
views.setTextColor(R.id.widget_status, colorForFleet(counters))
views.setImageViewBitmap(R.id.widget_graph, fleetStatusBitmap(servers, summaries))
views.setTextViewText(R.id.widget_online, "${counters.online} OK")
views.setTextViewText(R.id.widget_watch, "${counters.watch} WATCH")
views.setTextViewText(R.id.widget_down, "${counters.down} DOWN")
views.setTextViewText(R.id.widget_priority, fleetPriority(servers, summaries))
views.bindCommon(context, FleetWidgetProvider::class.java, ActionRefreshFleet, appWidgetId)
return views
}
private fun serverViews(
context: Context,
appWidgetId: Int,
server: ServerProfile?,
summary: MetricSummary?,
): RemoteViews {
val views = RemoteViews(context.packageName, R.layout.widget_server)
if (server == null) {
views.setTextViewText(R.id.widget_title, "Choose server")
views.setTextViewText(R.id.widget_status, "Create a server in the app first")
views.setTextViewText(R.id.widget_health, "NO SERVER")
views.setTextColor(R.id.widget_health, Muted)
clearServerMetrics(views)
} else {
val status = summary?.status ?: ServerStatus.Unknown
views.setTextViewText(R.id.widget_title, server.name)
views.setTextViewText(R.id.widget_status, summary?.message ?: "Waiting for first sample")
views.setTextColor(R.id.widget_status, colorForStatus(status))
views.setTextViewText(R.id.widget_health, statusLabel(status).uppercase(Locale.US))
views.setTextColor(R.id.widget_health, colorForStatus(status))
setPercentBar(views, R.id.widget_cpu_bar, R.id.widget_cpu_value, summary?.cpuPercent)
setPercentBar(views, R.id.widget_memory_bar, R.id.widget_memory_value, summary?.memoryPercent)
setPercentBar(views, R.id.widget_disk_bar, R.id.widget_disk_value, summary?.diskPercent)
views.setTextViewText(R.id.widget_rps, "RPS ${summary?.requestRate.number()}")
views.setTextViewText(R.id.widget_p95, "P95 ${summary?.latencyP95Millis.ms()}")
views.setTextViewText(R.id.widget_5xx, "5XX ${summary?.errors5xx?.toString() ?: "--"}")
views.setTextColor(R.id.widget_5xx, if ((summary?.errors5xx ?: 0) > 0) Red else Green)
}
views.bindCommon(context, ServerWidgetProvider::class.java, ActionRefreshServer, appWidgetId)
return views
}
private fun metricViews(
context: Context,
appWidgetId: Int,
server: ServerProfile?,
summary: MetricSummary?,
history: List<MetricSummary>,
metric: WidgetMetricKind,
): RemoteViews {
val views = RemoteViews(context.packageName, R.layout.widget_metric)
if (server == null) {
views.setTextViewText(R.id.widget_title, "Choose server")
views.setTextViewText(R.id.widget_value, "--")
views.setTextViewText(R.id.widget_status, "Add a server, then add this widget again")
views.setImageViewBitmap(R.id.widget_graph, emptyBitmap("No samples"))
} else {
val color = colorForMetric(metric, summary)
views.setTextViewText(R.id.widget_title, server.name)
views.setTextViewText(R.id.widget_value, valueFor(metric, summary))
views.setTextColor(R.id.widget_value, color)
views.setTextViewText(
R.id.widget_status,
"${metric.label} - ${statusLabel(summary?.status ?: ServerStatus.Unknown)} - ${formatTime(summary?.timestampMillis)}",
)
views.setImageViewBitmap(
R.id.widget_graph,
sparklineBitmap(
values = metricValues(metric, history, summary),
metric = metric,
color = color,
),
)
}
views.bindCommon(context, MetricWidgetProvider::class.java, ActionRefreshMetric, appWidgetId)
return views
}
private fun graphViews(
context: Context,
appWidgetId: Int,
server: ServerProfile?,
summary: MetricSummary?,
history: List<MetricSummary>,
): RemoteViews {
val views = RemoteViews(context.packageName, R.layout.widget_graph)
if (server == null) {
views.setTextViewText(R.id.widget_title, "Choose server")
views.setTextViewText(R.id.widget_status, "Create a server in the app first")
views.setTextViewText(R.id.widget_cpu, "CPU --")
views.setTextViewText(R.id.widget_memory, "MEM --")
views.setTextViewText(R.id.widget_disk, "STORAGE --")
views.setImageViewBitmap(R.id.widget_graph, emptyBitmap("No samples"))
} else {
val status = summary?.status ?: ServerStatus.Unknown
views.setTextViewText(R.id.widget_title, "${server.name} flow")
views.setTextViewText(R.id.widget_status, "${statusLabel(status)} - ${formatTime(summary?.timestampMillis)}")
views.setTextColor(R.id.widget_status, colorForStatus(status))
views.setTextViewText(R.id.widget_cpu, "CPU ${summary?.cpuPercent.percent()}")
views.setTextViewText(R.id.widget_memory, "MEM ${summary?.memoryPercent.percent()}")
views.setTextViewText(R.id.widget_disk, "STORAGE ${summary?.diskPercent.percent()}")
views.setImageViewBitmap(R.id.widget_graph, multilineBitmap(history, summary))
}
views.bindCommon(context, GraphWidgetProvider::class.java, ActionRefreshGraph, appWidgetId)
return views
}
private fun incidentsViews(
context: Context,
servers: List<ServerProfile>,
summaries: Map<String, MetricSummary>,
appWidgetId: Int,
): RemoteViews {
val views = RemoteViews(context.packageName, R.layout.widget_incidents)
views.setTextViewText(R.id.widget_title, "Incident Watch")
views.setTextViewText(R.id.widget_subtitle, "${servers.size} servers - ${formatTime(System.currentTimeMillis())}")
if (servers.isEmpty()) {
views.setTextViewText(R.id.widget_issue_count, "--")
views.setTextViewText(R.id.widget_issue_label, "SETUP")
views.setTextColor(R.id.widget_issue_count, Muted)
views.setTextViewText(R.id.widget_issue_one, "No servers configured")
views.setTextViewText(R.id.widget_issue_two, "Open the app and add endpoints")
views.setTextViewText(R.id.widget_issue_three, "Then place this widget again")
} else {
val issues = servers
.map { it to summaries[it.id] }
.filter { (_, summary) -> isIssueStatus(summary?.status ?: ServerStatus.Unknown) }
.sortedWith(compareBy<Pair<ServerProfile, MetricSummary?>> { statusRank(it.second?.status) }.thenBy { it.first.name.lowercase() })
if (issues.isEmpty()) {
views.setTextViewText(R.id.widget_issue_count, "OK")
views.setTextViewText(R.id.widget_issue_label, "CLEAR")
views.setTextColor(R.id.widget_issue_count, Green)
views.setTextColor(R.id.widget_issue_label, Green)
views.setTextViewText(R.id.widget_issue_one, "All monitored servers look healthy")
views.setTextColor(R.id.widget_issue_one, Green)
views.setTextViewText(R.id.widget_issue_two, "No unreachable or degraded nodes")
views.setTextColor(R.id.widget_issue_two, Muted)
views.setTextViewText(R.id.widget_issue_three, "Last update ${formatTime(System.currentTimeMillis())}")
views.setTextColor(R.id.widget_issue_three, Muted)
} else {
views.setTextViewText(R.id.widget_issue_count, issues.size.toString())
views.setTextViewText(R.id.widget_issue_label, "ISSUES")
views.setTextColor(R.id.widget_issue_count, if (issues.any { isDownStatus(it.second?.status) }) Red else Amber)
views.setTextColor(R.id.widget_issue_label, if (issues.any { isDownStatus(it.second?.status) }) Red else Amber)
val rows = issues.take(3).map { (server, summary) ->
"${statusLabel(summary?.status ?: ServerStatus.Unknown)} - ${server.name}"
}
views.setTextViewText(R.id.widget_issue_one, rows.getOrNull(0) ?: "")
views.setTextViewText(R.id.widget_issue_two, rows.getOrNull(1) ?: "")
views.setTextViewText(R.id.widget_issue_three, rows.getOrNull(2) ?: "Open app for the full queue")
views.setTextColor(R.id.widget_issue_one, colorForStatus(issues.getOrNull(0)?.second?.status ?: ServerStatus.Unknown))
views.setTextColor(R.id.widget_issue_two, colorForStatus(issues.getOrNull(1)?.second?.status ?: ServerStatus.Unknown))
views.setTextColor(R.id.widget_issue_three, colorForStatus(issues.getOrNull(2)?.second?.status ?: ServerStatus.Unknown))
}
}
views.bindCommon(context, IncidentsWidgetProvider::class.java, ActionRefreshIncidents, appWidgetId)
return views
}
private fun setPercentBar(views: RemoteViews, barId: Int, valueId: Int, value: Double?) {
views.setProgressBar(barId, 100, value.progressValue(), false)
views.setTextViewText(valueId, value.percent())
views.setTextColor(valueId, colorForPercent(value))
}
private fun clearServerMetrics(views: RemoteViews) {
setPercentBar(views, R.id.widget_cpu_bar, R.id.widget_cpu_value, null)
setPercentBar(views, R.id.widget_memory_bar, R.id.widget_memory_value, null)
setPercentBar(views, R.id.widget_disk_bar, R.id.widget_disk_value, null)
views.setTextViewText(R.id.widget_rps, "RPS --")
views.setTextViewText(R.id.widget_p95, "P95 --")
views.setTextViewText(R.id.widget_5xx, "5XX --")
views.setTextColor(R.id.widget_5xx, Muted)
}
private fun configuredServer(context: Context, appWidgetId: Int, servers: List<ServerProfile>): ServerProfile? {
val configured = WidgetPreferences.serverId(context, appWidgetId)
return servers.firstOrNull { it.id == configured } ?: servers.firstOrNull()
}
private fun RemoteViews.bindCommon(
context: Context,
providerClass: Class<*>,
refreshAction: String,
appWidgetId: Int,
) {
setOnClickPendingIntent(R.id.widget_root, openAppIntent(context, appWidgetId))
setOnClickPendingIntent(R.id.widget_refresh, refreshIntent(context, providerClass, refreshAction, appWidgetId))
}
private fun openAppIntent(context: Context, appWidgetId: Int): PendingIntent {
val intent = Intent(context, MainActivity::class.java)
return PendingIntent.getActivity(
context,
50_000 + appWidgetId,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
private fun refreshIntent(
context: Context,
providerClass: Class<*>,
action: String,
appWidgetId: Int,
): PendingIntent {
val intent = Intent(context, providerClass)
.setAction(action)
.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
return PendingIntent.getBroadcast(
context,
70_000 + appWidgetId,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
private fun AppWidgetManager.idsFor(context: Context, providerClass: Class<*>): IntArray {
return getAppWidgetIds(ComponentName(context, providerClass))
}
private fun counters(servers: List<ServerProfile>, summaries: Map<String, MetricSummary>): Counters {
var online = 0
var watch = 0
var down = 0
var unknown = 0
servers.forEach { server ->
when (summaries[server.id]?.status ?: ServerStatus.Unknown) {
ServerStatus.Online -> online += 1
ServerStatus.Degraded, ServerStatus.RateLimited, ServerStatus.Error, ServerStatus.Insecure -> watch += 1
ServerStatus.Offline, ServerStatus.AuthFailed, ServerStatus.Forbidden, ServerStatus.Missing -> down += 1
ServerStatus.Unknown -> unknown += 1
}
}
return Counters(online, watch, down, unknown, servers.size)
}
private fun fleetHeadline(counters: Counters): String = when {
counters.total == 0 -> "Add servers"
counters.down > 0 -> "${counters.down} down"
counters.watch > 0 -> "${counters.watch} on watch"
counters.unknown == counters.total -> "Waiting"
counters.unknown > 0 -> "${counters.unknown} waiting"
else -> "All clear"
}
private fun colorForFleet(counters: Counters): Int = when {
counters.down > 0 -> Red
counters.watch > 0 -> Amber
counters.unknown > 0 -> Muted
else -> Green
}
private fun fleetPriority(servers: List<ServerProfile>, summaries: Map<String, MetricSummary>): String {
if (servers.isEmpty()) return "Open the app and add your first monitor endpoint"
val priority = servers
.sortedWith(compareBy<ServerProfile> { statusRank(summaries[it.id]?.status) }.thenBy { it.name.lowercase() })
.filter { isIssueStatus(summaries[it.id]?.status ?: ServerStatus.Unknown) }
.take(2)
if (priority.isEmpty()) return "No active incidents"
return priority.joinToString(" ") { server ->
"${statusLabel(summaries[server.id]?.status ?: ServerStatus.Unknown)} - ${server.name}"
}
}
private fun isIssueStatus(status: ServerStatus): Boolean = when (status) {
ServerStatus.Degraded,
ServerStatus.Offline,
ServerStatus.AuthFailed,
ServerStatus.Forbidden,
ServerStatus.Missing,
ServerStatus.RateLimited,
ServerStatus.Insecure,
ServerStatus.Error,
-> true
ServerStatus.Online, ServerStatus.Unknown -> false
}
private fun isDownStatus(status: ServerStatus?): Boolean = status in setOf(
ServerStatus.Offline,
ServerStatus.AuthFailed,
ServerStatus.Forbidden,
ServerStatus.Missing,
)
private fun statusRank(status: ServerStatus?): Int = when (status) {
ServerStatus.Offline, ServerStatus.AuthFailed, ServerStatus.Forbidden, ServerStatus.Missing -> 0
ServerStatus.Degraded, ServerStatus.RateLimited, ServerStatus.Error -> 1
ServerStatus.Insecure -> 2
ServerStatus.Unknown, null -> 3
ServerStatus.Online -> 4
}
private fun statusLabel(status: ServerStatus): String = when (status) {
ServerStatus.Online -> "Online"
ServerStatus.Degraded -> "Watch"
ServerStatus.Offline -> "Offline"
ServerStatus.AuthFailed -> "Auth"
ServerStatus.Forbidden -> "Blocked"
ServerStatus.Missing -> "Missing"
ServerStatus.RateLimited -> "Limited"
ServerStatus.Insecure -> "HTTP"
ServerStatus.Error -> "Error"
ServerStatus.Unknown -> "Waiting"
}
private fun valueFor(metric: WidgetMetricKind, summary: MetricSummary?): String = when (metric) {
WidgetMetricKind.Cpu -> summary?.cpuPercent.percent()
WidgetMetricKind.Memory -> summary?.memoryPercent.percent()
WidgetMetricKind.Disk -> summary?.diskPercent.percent()
WidgetMetricKind.RequestRate -> summary?.requestRate.number()
WidgetMetricKind.LatencyP95 -> summary?.latencyP95Millis.ms()
WidgetMetricKind.Errors5xx -> summary?.errors5xx?.toString() ?: "--"
WidgetMetricKind.Connections -> summary?.activeConnections?.toString() ?: "--"
}
private fun metricValues(metric: WidgetMetricKind, history: List<MetricSummary>, summary: MetricSummary?): List<Double?> {
val samples = if (history.isEmpty()) listOfNotNull(summary) else history
return samples.takeLast(48).map { metricValue(metric, it) }
}
private fun metricValue(metric: WidgetMetricKind, summary: MetricSummary): Double? = when (metric) {
WidgetMetricKind.Cpu -> summary.cpuPercent
WidgetMetricKind.Memory -> summary.memoryPercent
WidgetMetricKind.Disk -> summary.diskPercent
WidgetMetricKind.RequestRate -> summary.requestRate
WidgetMetricKind.LatencyP95 -> summary.latencyP95Millis
WidgetMetricKind.Errors5xx -> summary.errors5xx?.toDouble()
WidgetMetricKind.Connections -> summary.activeConnections?.toDouble()
}
private fun colorForMetric(metric: WidgetMetricKind, summary: MetricSummary?): Int = when (metric) {
WidgetMetricKind.Cpu -> colorForPercent(summary?.cpuPercent)
WidgetMetricKind.Memory -> colorForPercent(summary?.memoryPercent)
WidgetMetricKind.Disk -> colorForPercent(summary?.diskPercent)
WidgetMetricKind.Errors5xx -> if ((summary?.errors5xx ?: 0) > 0) Red else Green
WidgetMetricKind.LatencyP95 -> Amber
WidgetMetricKind.RequestRate, WidgetMetricKind.Connections -> Cyan
}
private fun colorForStatus(status: ServerStatus): Int = when (status) {
ServerStatus.Online -> Green
ServerStatus.Degraded, ServerStatus.RateLimited, ServerStatus.Error -> Amber
ServerStatus.Offline, ServerStatus.AuthFailed, ServerStatus.Forbidden, ServerStatus.Missing -> Red
ServerStatus.Insecure -> Cyan
ServerStatus.Unknown -> Muted
}
private fun colorForPercent(value: Double?): Int = when {
value == null -> Muted
value >= 90.0 -> Red
value >= 75.0 -> Amber
else -> Green
}
private fun fleetStatusBitmap(
servers: List<ServerProfile>,
summaries: Map<String, MetricSummary>,
width: Int = 640,
height: Int = 88,
): Bitmap {
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
paint.color = Panel
canvas.drawRoundRect(RectF(0f, 0f, width.toFloat(), height.toFloat()), 24f, 24f, paint)
if (servers.isEmpty()) {
paint.color = Muted
repeat(6) { index ->
val left = 22f + index * 98f
canvas.drawRoundRect(RectF(left, 26f, left + 60f, 62f), 18f, 18f, paint)
}
return bitmap
}
val statuses = servers
.map { summaries[it.id]?.status ?: ServerStatus.Unknown }
.sortedBy { statusRank(it) }
val count = statuses.size.coerceAtLeast(1)
val outer = 14f
val gap = if (count > 38) 2f else 5f
val cellWidth = ((width - outer * 2 - gap * (count - 1)) / count).coerceAtLeast(3f)
statuses.forEachIndexed { index, status ->
val left = outer + index * (cellWidth + gap)
val right = min(width - outer, left + cellWidth)
paint.color = colorForStatus(status)
canvas.drawRoundRect(RectF(left, 18f, right, height - 18f), 18f, 18f, paint)
}
return bitmap
}
private fun sparklineBitmap(
values: List<Double?>,
metric: WidgetMetricKind,
color: Int,
width: Int = 520,
height: Int = 150,
): Bitmap {
val clean = values.filterNotNull().takeLast(48)
if (clean.isEmpty()) return emptyBitmap("No samples", width, height)
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val graph = RectF(10f, 12f, width - 10f, height - 16f)
drawGraphBackground(canvas, graph)
val maxValue = if (metric.isPercentMetric()) {
100.0
} else {
max(1.0, clean.maxOrNull().orZero() * 1.25)
}
drawLine(
canvas = canvas,
values = clean,
graph = graph,
color = color,
minValue = 0.0,
maxValue = maxValue,
stroke = 7f,
fill = true,
)
return bitmap
}
private fun multilineBitmap(
history: List<MetricSummary>,
summary: MetricSummary?,
width: Int = 720,
height: Int = 230,
): Bitmap {
val samples = (if (history.isEmpty()) listOfNotNull(summary) else history).takeLast(60)
if (samples.isEmpty()) return emptyBitmap("No samples", width, height)
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val graph = RectF(12f, 10f, width - 12f, height - 14f)
drawGraphBackground(canvas, graph)
drawLine(canvas, samples.map { it.cpuPercent }, graph, Green, minValue = 0.0, maxValue = 100.0, stroke = 5f)
drawLine(canvas, samples.map { it.memoryPercent }, graph, Cyan, minValue = 0.0, maxValue = 100.0, stroke = 5f)
drawLine(canvas, samples.map { it.diskPercent }, graph, Amber, minValue = 0.0, maxValue = 100.0, stroke = 5f)
return bitmap
}
private fun emptyBitmap(label: String, width: Int = 520, height: Int = 150): Bitmap {
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val graph = RectF(10f, 10f, width - 10f, height - 10f)
drawGraphBackground(canvas, graph)
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Muted
textSize = 32f
textAlign = Paint.Align.CENTER
}
canvas.drawText(label, width / 2f, height / 2f + 12f, paint)
return bitmap
}
private fun drawGraphBackground(canvas: Canvas, graph: RectF) {
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
paint.color = Panel
canvas.drawRoundRect(graph, 24f, 24f, paint)
paint.color = withAlpha(Grid, 120)
paint.strokeWidth = 2f
repeat(4) { index ->
val y = graph.top + graph.height() * (index + 1) / 5f
canvas.drawLine(graph.left + 12f, y, graph.right - 12f, y, paint)
}
}
private fun drawLine(
canvas: Canvas,
values: List<Double?>,
graph: RectF,
color: Int,
minValue: Double,
maxValue: Double,
stroke: Float,
fill: Boolean = false,
) {
val clean = values.filterNotNull()
if (clean.isEmpty()) return
val plotted = if (clean.size == 1) listOf(clean.first(), clean.first()) else clean
val path = Path()
plotted.forEachIndexed { index, value ->
val x = graph.left + 18f + index * ((graph.width() - 36f) / (plotted.size - 1).coerceAtLeast(1))
val normalized = ((value - minValue) / (maxValue - minValue).coerceAtLeast(1.0)).coerceIn(0.0, 1.0)
val y = graph.bottom - 16f - (graph.height() - 32f) * normalized.toFloat()
if (index == 0) path.moveTo(x, y) else path.lineTo(x, y)
}
if (fill) {
val fillPath = Path(path)
fillPath.lineTo(graph.right - 18f, graph.bottom - 16f)
fillPath.lineTo(graph.left + 18f, graph.bottom - 16f)
fillPath.close()
val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
this.color = withAlpha(color, 45)
style = Paint.Style.FILL
}
canvas.drawPath(fillPath, fillPaint)
}
val linePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
this.color = color
style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND
strokeJoin = Paint.Join.ROUND
strokeWidth = stroke
}
canvas.drawPath(path, linePaint)
val last = plotted.last()
val lastX = graph.right - 18f
val lastY = graph.bottom - 16f - (graph.height() - 32f) *
((last - minValue) / (maxValue - minValue).coerceAtLeast(1.0)).coerceIn(0.0, 1.0).toFloat()
val dotPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
this.color = color
style = Paint.Style.FILL
}
canvas.drawCircle(lastX, lastY, stroke * 1.25f, dotPaint)
}
private fun WidgetMetricKind.isPercentMetric(): Boolean = when (this) {
WidgetMetricKind.Cpu, WidgetMetricKind.Memory, WidgetMetricKind.Disk -> true
WidgetMetricKind.RequestRate,
WidgetMetricKind.LatencyP95,
WidgetMetricKind.Errors5xx,
WidgetMetricKind.Connections,
-> false
}
private fun withAlpha(color: Int, alpha: Int): Int {
return Color.argb(alpha, Color.red(color), Color.green(color), Color.blue(color))
}
private fun Double?.progressValue(): Int = this?.coerceIn(0.0, 100.0)?.roundToInt() ?: 0
private fun Double?.percent(): String = this?.let { "${it.coerceIn(0.0, 999.0).roundToInt()}%" } ?: "--"
private fun Double?.ms(): String = this?.let { "${it.roundToInt()}ms" } ?: "--"
private fun Double?.number(): String = this?.let {
when {
it >= 1000.0 -> String.format(Locale.US, "%.1fk", it / 1000.0)
it >= 10.0 -> it.roundToInt().toString()
else -> String.format(Locale.US, "%.1f", it)
}
} ?: "--"
private fun Double?.orZero(): Double = this ?: 0.0
private fun formatTime(timestampMillis: Long?): String {
return timestampMillis?.let { DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(it)) } ?: "--"
}
private data class Counters(
val online: Int,
val watch: Int,
val down: Int,
val unknown: Int,
val total: Int,
)
}

View File

@@ -0,0 +1,172 @@
package net.rodakot.ngxhttpmonitoringclient.widget
import android.app.Activity
import android.appwidget.AppWidgetManager
import android.content.Intent
import android.graphics.Color
import android.os.Bundle
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.Button
import android.widget.LinearLayout
import android.widget.RadioButton
import android.widget.RadioGroup
import android.widget.ScrollView
import android.widget.Spinner
import android.widget.TextView
import net.rodakot.ngxhttpmonitoringclient.data.MonitorRepository
class ServerWidgetConfigureActivity : BaseWidgetConfigureActivity() {
override val mode: WidgetConfigureMode = WidgetConfigureMode.Server
}
class MetricWidgetConfigureActivity : BaseWidgetConfigureActivity() {
override val mode: WidgetConfigureMode = WidgetConfigureMode.Metric
}
class GraphWidgetConfigureActivity : BaseWidgetConfigureActivity() {
override val mode: WidgetConfigureMode = WidgetConfigureMode.Graph
}
abstract class BaseWidgetConfigureActivity : Activity() {
protected abstract val mode: WidgetConfigureMode
private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window.decorView.layoutDirection = View.LAYOUT_DIRECTION_LTR
setResult(RESULT_CANCELED)
appWidgetId = intent?.extras?.getInt(
AppWidgetManager.EXTRA_APPWIDGET_ID,
AppWidgetManager.INVALID_APPWIDGET_ID,
) ?: AppWidgetManager.INVALID_APPWIDGET_ID
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
finish()
return
}
val repository = MonitorRepository(applicationContext)
val servers = repository.servers()
val root = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
layoutDirection = View.LAYOUT_DIRECTION_LTR
textDirection = View.TEXT_DIRECTION_LTR
setPadding(32, 32, 32, 32)
setBackgroundColor(Color.rgb(14, 17, 22))
}
root.addView(title(mode.title))
root.addView(subtitle(mode.description))
if (servers.isEmpty()) {
root.addView(subtitle("No servers exist yet. Open NGX Monitor and add a server first."))
root.addView(actionButton("Close") { finish() })
setContentView(root)
return
}
val serverGroup = RadioGroup(this).apply {
orientation = RadioGroup.VERTICAL
servers.forEachIndexed { index, server ->
addView(RadioButton(this@BaseWidgetConfigureActivity).apply {
id = View.generateViewId()
text = server.name
setTextColor(Color.rgb(234, 240, 247))
textSize = 16f
setPadding(0, 10, 0, 10)
})
}
check(getChildAt(0).id)
}
val scroll = ScrollView(this).apply {
addView(serverGroup)
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
0,
1f,
)
}
root.addView(scroll)
val metricSpinner = if (mode == WidgetConfigureMode.Metric) {
Spinner(this).apply {
adapter = ArrayAdapter(
this@BaseWidgetConfigureActivity,
android.R.layout.simple_spinner_dropdown_item,
WidgetMetricKind.entries.map { it.label },
)
}.also { root.addView(it) }
} else {
null
}
root.addView(actionButton("Add ${mode.buttonLabel}") {
val checkedView = serverGroup.findViewById<RadioButton>(serverGroup.checkedRadioButtonId)
val serverIndex = serverGroup.indexOfChild(checkedView).coerceAtLeast(0)
val server = servers[serverIndex]
when (mode) {
WidgetConfigureMode.Server -> {
WidgetPreferences.saveServerWidget(this, appWidgetId, server.id)
MonitorWidgetUpdater.updateServers(this, intArrayOf(appWidgetId), refreshNetwork = false)
}
WidgetConfigureMode.Metric -> {
val metric = WidgetMetricKind.entries[metricSpinner?.selectedItemPosition ?: 0]
WidgetPreferences.saveMetricWidget(this, appWidgetId, server.id, metric)
MonitorWidgetUpdater.updateMetrics(this, intArrayOf(appWidgetId), refreshNetwork = false)
}
WidgetConfigureMode.Graph -> {
WidgetPreferences.saveServerWidget(this, appWidgetId, server.id)
MonitorWidgetUpdater.updateGraphs(this, intArrayOf(appWidgetId), refreshNetwork = false)
}
}
val resultValue = Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
setResult(RESULT_OK, resultValue)
finish()
})
setContentView(root)
}
private fun title(text: String): TextView = TextView(this).apply {
this.text = text
setTextColor(Color.rgb(234, 240, 247))
textSize = 24f
setTypeface(typeface, android.graphics.Typeface.BOLD)
setPadding(0, 0, 0, 8)
}
private fun subtitle(text: String): TextView = TextView(this).apply {
this.text = text
setTextColor(Color.rgb(154, 166, 182))
textSize = 14f
setPadding(0, 0, 0, 20)
}
private fun actionButton(text: String, onClick: () -> Unit): Button = Button(this).apply {
this.text = text
gravity = Gravity.CENTER
setOnClickListener { onClick() }
}
}
enum class WidgetConfigureMode(
val title: String,
val description: String,
val buttonLabel: String,
) {
Server(
title = "Server Bars",
description = "Choose one server for a large health state with CPU, memory, and storage bars.",
buttonLabel = "server widget",
),
Metric(
title = "Metric Sparkline",
description = "Choose one server and one signal for a focused history graph.",
buttonLabel = "metric widget",
),
Graph(
title = "Telemetry Graph",
description = "Choose one server for CPU, memory, and storage trend lines.",
buttonLabel = "graph widget",
),
}

View File

@@ -0,0 +1,50 @@
package net.rodakot.ngxhttpmonitoringclient.widget
import android.content.Context
enum class WidgetMetricKind(val label: String) {
Cpu("CPU"),
Memory("Memory"),
Disk("Storage"),
RequestRate("Requests/s"),
LatencyP95("p95 latency"),
Errors5xx("5xx errors"),
Connections("Connections"),
}
object WidgetPreferences {
private const val Name = "ngx_monitor_widgets"
fun saveServerWidget(context: Context, appWidgetId: Int, serverId: String) {
prefs(context).edit().putString(serverKey(appWidgetId), serverId).apply()
}
fun serverId(context: Context, appWidgetId: Int): String? {
return prefs(context).getString(serverKey(appWidgetId), null)
}
fun saveMetricWidget(context: Context, appWidgetId: Int, serverId: String, metric: WidgetMetricKind) {
prefs(context).edit()
.putString(serverKey(appWidgetId), serverId)
.putString(metricKey(appWidgetId), metric.name)
.apply()
}
fun metricKind(context: Context, appWidgetId: Int): WidgetMetricKind {
val raw = prefs(context).getString(metricKey(appWidgetId), null)
return runCatching { WidgetMetricKind.valueOf(raw ?: "") }.getOrDefault(WidgetMetricKind.Cpu)
}
fun delete(context: Context, appWidgetIds: IntArray) {
prefs(context).edit().apply {
appWidgetIds.forEach { appWidgetId ->
remove(serverKey(appWidgetId))
remove(metricKey(appWidgetId))
}
}.apply()
}
private fun prefs(context: Context) = context.getSharedPreferences(Name, Context.MODE_PRIVATE)
private fun serverKey(appWidgetId: Int) = "server_$appWidgetId"
private fun metricKey(appWidgetId: Int) = "metric_$appWidgetId"
}

View File

@@ -5,166 +5,17 @@
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:fillColor="@color/command_black"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#FF151B24"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
android:pathData="M8,22h92M8,42h92M8,62h92M8,82h92M22,8v92M42,8v92M62,8v92M82,8v92"
android:strokeColor="#2235D38B"
android:strokeWidth="1" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
android:fillColor="#1035D38B"
android:pathData="M18,18h72a10,10 0,0 1,10 10v52a10,10 0,0 1,-10 10h-72a10,10 0,0 1,-10 -10v-52a10,10 0,0 1,10 -10z" />
</vector>

View File

@@ -1,30 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
android:fillColor="#FF202733"
android:pathData="M25,23h58a9,9 0,0 1,9 9v44a9,9 0,0 1,-9 9h-58a9,9 0,0 1,-9 -9v-44a9,9 0,0 1,9 -9z" />
<path
android:fillColor="#FF0E1116"
android:pathData="M28,33h52a4,4 0,0 1,4 4v8a4,4 0,0 1,-4 4h-52a4,4 0,0 1,-4 -4v-8a4,4 0,0 1,4 -4z" />
<path
android:fillColor="#FF0E1116"
android:pathData="M28,59h52a4,4 0,0 1,4 4v8a4,4 0,0 1,-4 4h-52a4,4 0,0 1,-4 -4v-8a4,4 0,0 1,4 -4z" />
<path
android:fillColor="#FF35D38B"
android:pathData="M32,38a3,3 0,1 0,0.1 0zM32,64a3,3 0,1 0,0.1 0z" />
<path
android:fillColor="#FFFFB84D"
android:pathData="M43,38a3,3 0,1 0,0.1 0zM43,64a3,3 0,1 0,0.1 0z" />
<path
android:fillColor="#FF4CC9F0"
android:pathData="M58,39h17a2,2 0,0 1,0 4h-17a2,2 0,0 1,0 -4zM58,65h17a2,2 0,0 1,0 4h-17a2,2 0,0 1,0 -4z" />
<path
android:fillColor="#00000000"
android:pathData="M28,53h12l7,-9l9,20l7,-11h17"
android:strokeColor="#FF35D38B"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:strokeWidth="4" />
<path
android:fillColor="#2235D38B"
android:pathData="M54,16a38,38 0,1 1,-0.1 0M54,22a32,32 0,1 0,0.1 0z" />
</vector>

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#00000000"
android:pathData="M20,12a8,8 0,0 1,-13.7 5.6"
android:strokeColor="#FFFFFFFF"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:strokeWidth="2" />
<path
android:fillColor="#00000000"
android:pathData="M4,12a8,8 0,0 1,13.7 -5.6"
android:strokeColor="#FFFFFFFF"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:strokeWidth="2" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M17,3l3,3l-4,1z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M7,21l-3,-3l4,-1z" />
</vector>

View File

@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="260dp"
android:height="260dp"
android:viewportWidth="260"
android:viewportHeight="260">
<path
android:fillColor="#FF0E1116"
android:pathData="M0,0h260v260h-260z" />
<path
android:fillColor="#FF171C23"
android:pathData="M36,38h188a24,24 0,0 1,24 24v136a24,24 0,0 1,-24 24h-188a24,24 0,0 1,-24 -24v-136a24,24 0,0 1,24 -24z" />
<path
android:fillColor="#00000000"
android:pathData="M38,86h184M38,130h184M38,174h184M86,54v152M130,54v152M174,54v152"
android:strokeColor="#244CC9F0"
android:strokeWidth="2" />
<path
android:fillColor="#FF202733"
android:pathData="M58,76h144a10,10 0,0 1,10 10v26a10,10 0,0 1,-10 10h-144a10,10 0,0 1,-10 -10v-26a10,10 0,0 1,10 -10z" />
<path
android:fillColor="#FF202733"
android:pathData="M58,138h144a10,10 0,0 1,10 10v26a10,10 0,0 1,-10 10h-144a10,10 0,0 1,-10 -10v-26a10,10 0,0 1,10 -10z" />
<path
android:fillColor="#FF35D38B"
android:pathData="M70,92a8,8 0,1 0,0.1 0zM70,154a8,8 0,1 0,0.1 0z" />
<path
android:fillColor="#FFFFB84D"
android:pathData="M96,92a8,8 0,1 0,0.1 0z" />
<path
android:fillColor="#FFFF5A52"
android:pathData="M96,154a8,8 0,1 0,0.1 0z" />
<path
android:fillColor="#FF4CC9F0"
android:pathData="M126,93h58a6,6 0,0 1,0 12h-58a6,6 0,0 1,0 -12zM126,155h58a6,6 0,0 1,0 12h-58a6,6 0,0 1,0 -12z" />
<path
android:fillColor="#00000000"
android:pathData="M62,128h32l18,-28l24,60l19,-32h44"
android:strokeColor="#FF35D38B"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:strokeWidth="8" />
<path
android:fillColor="#5535D38B"
android:pathData="M130,24a106,106 0,1 1,-0.1 0M130,34a96,96 0,1 0,0.1 0z" />
<path
android:fillColor="#334CC9F0"
android:pathData="M130,48a82,82 0,1 1,-0.1 0M130,54a76,76 0,1 0,0.1 0z" />
</vector>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<solid android:color="@color/command_black" />
</shape>
</item>
<item
android:drawable="@drawable/splash_hero"
android:gravity="center" />
</layer-list>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="22dp" />
<solid android:color="#F0171C23" />
<stroke
android:width="1dp"
android:color="#3335D38B" />
<padding
android:bottom="12dp"
android:left="12dp"
android:right="12dp"
android:top="12dp" />
</shape>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="999dp" />
<solid android:color="#2635D38B" />
<stroke
android:width="1dp"
android:color="#6635D38B" />
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="14dp" />
<solid android:color="#FF202733" />
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="14dp" />
<solid android:color="#2CFFB84D" />
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="14dp" />
<solid android:color="#264CC9F0" />
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="14dp" />
<solid android:color="#2635D38B" />
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="14dp" />
<solid android:color="#2CFF5A52" />
</shape>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="360dp"
android:height="180dp"
android:viewportWidth="360"
android:viewportHeight="180">
<path android:fillColor="#FF171C23" android:pathData="M0,0h360v180h-360z" />
<path android:fillColor="#FF26313D" android:pathData="M24,24h198v18h-198z" />
<path android:fillColor="#FF35D38B" android:pathData="M24,62h34v34h-34zM64,62h34v34h-34zM104,62h34v34h-34zM144,62h34v34h-34zM184,62h34v34h-34zM224,62h34v34h-34z" />
<path android:fillColor="#FFFFB84D" android:pathData="M264,62h34v34h-34z" />
<path android:fillColor="#FFFF5A52" android:pathData="M304,62h34v34h-34z" />
<path android:fillColor="#2635D38B" android:pathData="M24,118h88v34h-88z" />
<path android:fillColor="#26FFB84D" android:pathData="M136,118h88v34h-88z" />
<path android:fillColor="#26FF5A52" android:pathData="M248,118h88v34h-88z" />
</vector>

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="360dp"
android:height="180dp"
android:viewportWidth="360"
android:viewportHeight="180">
<path android:fillColor="#FF171C23" android:pathData="M0,0h360v180h-360z" />
<path android:fillColor="#FF27313D" android:pathData="M24,48h312v2h-312zM24,84h312v2h-312zM24,120h312v2h-312zM24,156h312v2h-312z" />
<path
android:fillColor="#00000000"
android:pathData="M24,138 C72,118 96,130 132,96 C172,58 214,82 248,54 C284,24 304,46 336,32"
android:strokeColor="#FF35D38B"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:strokeWidth="6" />
<path
android:fillColor="#00000000"
android:pathData="M24,118 C72,98 102,112 142,84 C182,60 212,92 250,76 C292,58 310,82 336,68"
android:strokeColor="#FF4CC9F0"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:strokeWidth="6" />
<path
android:fillColor="#00000000"
android:pathData="M24,152 C66,138 98,144 136,126 C178,108 210,116 250,98 C288,82 312,104 336,92"
android:strokeColor="#FFFFB84D"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:strokeWidth="6" />
</vector>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="360dp"
android:height="180dp"
android:viewportWidth="360"
android:viewportHeight="180">
<path android:fillColor="#FF171C23" android:pathData="M0,0h360v180h-360z" />
<path android:fillColor="#26FF5A52" android:pathData="M24,48h82v104h-82z" />
<path android:fillColor="#FFFF5A52" android:pathData="M46,72h38v50h-38z" />
<path android:fillColor="#FF202733" android:pathData="M126,48h210v28h-210zM126,88h210v28h-210zM126,128h210v28h-210z" />
<path android:fillColor="#FFFF5A52" android:pathData="M138,58h22v8h-22z" />
<path android:fillColor="#FFFFB84D" android:pathData="M138,98h22v8h-22z" />
<path android:fillColor="#FF35D38B" android:pathData="M138,138h22v8h-22z" />
</vector>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="360dp"
android:height="180dp"
android:viewportWidth="360"
android:viewportHeight="180">
<path android:fillColor="#FF171C23" android:pathData="M0,0h360v180h-360z" />
<path android:fillColor="#FF4CC9F0" android:pathData="M24,28h96v46h-96z" />
<path android:fillColor="#FF27313D" android:pathData="M24,102h312v2h-312zM24,132h312v2h-312z" />
<path
android:fillColor="#00000000"
android:pathData="M24,142 C64,96 92,128 130,88 C166,50 196,82 226,64 C264,40 292,74 336,36"
android:strokeColor="#FF4CC9F0"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:strokeWidth="8" />
<path android:fillColor="#2635D38B" android:pathData="M300,24h36v36h-36z" />
</vector>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="360dp"
android:height="180dp"
android:viewportWidth="360"
android:viewportHeight="180">
<path android:fillColor="#FF171C23" android:pathData="M0,0h360v180h-360z" />
<path android:fillColor="#FF35D38B" android:pathData="M24,24h150v24h-150z" />
<path android:fillColor="#FF27313D" android:pathData="M24,68h250v18h-250zM24,100h250v18h-250zM24,132h250v18h-250z" />
<path android:fillColor="#FF35D38B" android:pathData="M24,68h176v18h-176z" />
<path android:fillColor="#FF4CC9F0" android:pathData="M24,100h214v18h-214z" />
<path android:fillColor="#FFFFB84D" android:pathData="M24,132h142v18h-142z" />
<path android:fillColor="#2635D38B" android:pathData="M300,24h36v36h-36z" />
</vector>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:id="@android:id/background">
<shape>
<corners android:radius="999dp" />
<solid android:color="#FF27313D" />
</shape>
</item>
<item android:id="@android:id/progress">
<clip>
<shape>
<corners android:radius="999dp" />
<solid android:color="#FFFFB84D" />
</shape>
</clip>
</item>
</layer-list>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:id="@android:id/background">
<shape>
<corners android:radius="999dp" />
<solid android:color="#FF27313D" />
</shape>
</item>
<item android:id="@android:id/progress">
<clip>
<shape>
<corners android:radius="999dp" />
<solid android:color="#FF4CC9F0" />
</shape>
</clip>
</item>
</layer-list>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:id="@android:id/background">
<shape>
<corners android:radius="999dp" />
<solid android:color="#FF27313D" />
</shape>
</item>
<item android:id="@android:id/progress">
<clip>
<shape>
<corners android:radius="999dp" />
<solid android:color="#FF35D38B" />
</shape>
</clip>
</item>
</layer-list>

View File

@@ -0,0 +1,131 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/widget_background"
android:layoutDirection="ltr"
android:textDirection="ltr"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/widget_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="Fleet Pulse"
android:textColor="#FFEAF0F7"
android:textSize="17sp"
android:textStyle="bold" />
<TextView
android:id="@+id/widget_subtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="Waiting for samples"
android:textColor="#FF9AA6B6"
android:textSize="12sp" />
</LinearLayout>
<ImageView
android:id="@+id/widget_refresh"
android:layout_width="38dp"
android:layout_height="38dp"
android:background="@drawable/widget_button"
android:contentDescription="@string/widget_refresh"
android:padding="9dp"
android:src="@drawable/ic_widget_refresh"
android:tint="#FF35D38B" />
</LinearLayout>
<TextView
android:id="@+id/widget_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:ellipsize="end"
android:maxLines="1"
android:text="All clear"
android:textColor="#FF35D38B"
android:textSize="24sp"
android:textStyle="bold" />
<ImageView
android:id="@+id/widget_graph"
android:layout_width="match_parent"
android:layout_height="42dp"
android:layout_marginTop="8dp"
android:contentDescription="@string/widget_fleet_graph"
android:scaleType="fitXY" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal">
<TextView
android:id="@+id/widget_online"
android:layout_width="0dp"
android:layout_height="34dp"
android:layout_marginEnd="6dp"
android:layout_weight="1"
android:background="@drawable/widget_panel_green"
android:gravity="center"
android:text="0 OK"
android:textColor="#FF35D38B"
android:textSize="13sp"
android:textStyle="bold" />
<TextView
android:id="@+id/widget_watch"
android:layout_width="0dp"
android:layout_height="34dp"
android:layout_marginEnd="6dp"
android:layout_weight="1"
android:background="@drawable/widget_panel_amber"
android:gravity="center"
android:text="0 WATCH"
android:textColor="#FFFFB84D"
android:textSize="13sp"
android:textStyle="bold" />
<TextView
android:id="@+id/widget_down"
android:layout_width="0dp"
android:layout_height="34dp"
android:layout_weight="1"
android:background="@drawable/widget_panel_red"
android:gravity="center"
android:text="0 DOWN"
android:textColor="#FFFF5A52"
android:textSize="13sp"
android:textStyle="bold" />
</LinearLayout>
<TextView
android:id="@+id/widget_priority"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:ellipsize="end"
android:maxLines="2"
android:text="No priority servers"
android:textColor="#FFEAF0F7"
android:textSize="12sp" />
</LinearLayout>

View File

@@ -0,0 +1,109 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/widget_background"
android:layoutDirection="ltr"
android:textDirection="ltr"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/widget_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="Telemetry Flow"
android:textColor="#FFEAF0F7"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/widget_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="CPU / MEM / STORAGE"
android:textColor="#FF9AA6B6"
android:textSize="12sp" />
</LinearLayout>
<ImageView
android:id="@+id/widget_refresh"
android:layout_width="38dp"
android:layout_height="38dp"
android:background="@drawable/widget_button"
android:contentDescription="@string/widget_refresh"
android:padding="9dp"
android:src="@drawable/ic_widget_refresh"
android:tint="#FF35D38B" />
</LinearLayout>
<ImageView
android:id="@+id/widget_graph"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="8dp"
android:layout_weight="1"
android:contentDescription="@string/widget_graph_chart"
android:scaleType="fitXY" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="24dp"
android:layout_marginTop="6dp"
android:orientation="horizontal">
<TextView
android:id="@+id/widget_cpu"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginEnd="6dp"
android:layout_weight="1"
android:background="@drawable/widget_panel_green"
android:gravity="center"
android:text="CPU"
android:textColor="#FF35D38B"
android:textSize="10sp"
android:textStyle="bold" />
<TextView
android:id="@+id/widget_memory"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginEnd="6dp"
android:layout_weight="1"
android:background="@drawable/widget_panel_blue"
android:gravity="center"
android:text="MEM"
android:textColor="#FF4CC9F0"
android:textSize="10sp"
android:textStyle="bold" />
<TextView
android:id="@+id/widget_disk"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:background="@drawable/widget_panel_amber"
android:gravity="center"
android:text="STORAGE"
android:textColor="#FFFFB84D"
android:textSize="10sp"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>

View File

@@ -0,0 +1,147 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/widget_background"
android:layoutDirection="ltr"
android:textDirection="ltr"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/widget_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="Incident Watch"
android:textColor="#FFEAF0F7"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/widget_subtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="Highest priority first"
android:textColor="#FF9AA6B6"
android:textSize="12sp" />
</LinearLayout>
<ImageView
android:id="@+id/widget_refresh"
android:layout_width="38dp"
android:layout_height="38dp"
android:background="@drawable/widget_button"
android:contentDescription="@string/widget_refresh"
android:padding="9dp"
android:src="@drawable/ic_widget_refresh"
android:tint="#FF35D38B" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="9dp"
android:layout_weight="1"
android:orientation="horizontal">
<LinearLayout
android:layout_width="86dp"
android:layout_height="match_parent"
android:layout_marginEnd="10dp"
android:background="@drawable/widget_panel"
android:gravity="center"
android:orientation="vertical">
<TextView
android:id="@+id/widget_issue_count"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:text="0"
android:textColor="#FFFF5A52"
android:textSize="32sp"
android:textStyle="bold" />
<TextView
android:id="@+id/widget_issue_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:text="ISSUES"
android:textColor="#FFFFB5B1"
android:textSize="10sp"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/widget_issue_one"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginBottom="5dp"
android:layout_weight="1"
android:background="@drawable/widget_panel"
android:ellipsize="end"
android:gravity="center_vertical"
android:maxLines="1"
android:paddingLeft="9dp"
android:paddingRight="9dp"
android:text="No active incidents"
android:textColor="#FFEAF0F7"
android:textSize="12sp"
android:textStyle="bold" />
<TextView
android:id="@+id/widget_issue_two"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginBottom="5dp"
android:layout_weight="1"
android:background="@drawable/widget_panel"
android:ellipsize="end"
android:gravity="center_vertical"
android:maxLines="1"
android:paddingLeft="9dp"
android:paddingRight="9dp"
android:text="Routes are healthy"
android:textColor="#FF9AA6B6"
android:textSize="12sp" />
<TextView
android:id="@+id/widget_issue_three"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:background="@drawable/widget_panel"
android:ellipsize="end"
android:gravity="center_vertical"
android:maxLines="1"
android:paddingLeft="9dp"
android:paddingRight="9dp"
android:text="Last update --"
android:textColor="#FF9AA6B6"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>
</LinearLayout>

View File

@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/widget_background"
android:layoutDirection="ltr"
android:textDirection="ltr"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/widget_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:ellipsize="end"
android:maxLines="1"
android:text="Metric"
android:textColor="#FFEAF0F7"
android:textSize="15sp"
android:textStyle="bold" />
<ImageView
android:id="@+id/widget_refresh"
android:layout_width="38dp"
android:layout_height="38dp"
android:background="@drawable/widget_button"
android:contentDescription="@string/widget_refresh"
android:padding="9dp"
android:src="@drawable/ic_widget_refresh"
android:tint="#FF35D38B" />
</LinearLayout>
<TextView
android:id="@+id/widget_value"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:ellipsize="end"
android:gravity="start"
android:maxLines="1"
android:text="--"
android:textColor="#FF4CC9F0"
android:textSize="36sp"
android:textStyle="bold" />
<ImageView
android:id="@+id/widget_graph"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="4dp"
android:layout_weight="1"
android:contentDescription="@string/widget_metric_graph"
android:scaleType="fitXY" />
<TextView
android:id="@+id/widget_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:ellipsize="end"
android:maxLines="2"
android:text="Waiting"
android:textColor="#FF9AA6B6"
android:textSize="12sp" />
</LinearLayout>

View File

@@ -0,0 +1,229 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/widget_background"
android:layoutDirection="ltr"
android:textDirection="ltr"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/widget_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="Server"
android:textColor="#FFEAF0F7"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/widget_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="Waiting"
android:textColor="#FF9AA6B6"
android:textSize="12sp" />
</LinearLayout>
<ImageView
android:id="@+id/widget_refresh"
android:layout_width="38dp"
android:layout_height="38dp"
android:background="@drawable/widget_button"
android:contentDescription="@string/widget_refresh"
android:padding="9dp"
android:src="@drawable/ic_widget_refresh"
android:tint="#FF35D38B" />
</LinearLayout>
<TextView
android:id="@+id/widget_health"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="9dp"
android:ellipsize="end"
android:maxLines="1"
android:text="ONLINE"
android:textColor="#FF35D38B"
android:textSize="25sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="6dp"
android:orientation="horizontal">
<TextView
android:id="@+id/widget_cpu_label"
android:layout_width="72dp"
android:layout_height="22dp"
android:gravity="center_vertical"
android:text="CPU"
android:textColor="#FF9AA6B6"
android:textSize="11sp"
android:textStyle="bold" />
<ProgressBar
android:id="@+id/widget_cpu_bar"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="0dp"
android:layout_height="22dp"
android:layout_weight="1"
android:max="100"
android:progress="0"
android:progressDrawable="@drawable/widget_progress_green" />
<TextView
android:id="@+id/widget_cpu_value"
android:layout_width="48dp"
android:layout_height="22dp"
android:gravity="end|center_vertical"
android:text="--"
android:textColor="#FFEAF0F7"
android:textSize="12sp"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="6dp"
android:orientation="horizontal">
<TextView
android:id="@+id/widget_memory_label"
android:layout_width="72dp"
android:layout_height="22dp"
android:gravity="center_vertical"
android:text="MEM"
android:textColor="#FF9AA6B6"
android:textSize="11sp"
android:textStyle="bold" />
<ProgressBar
android:id="@+id/widget_memory_bar"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="0dp"
android:layout_height="22dp"
android:layout_weight="1"
android:max="100"
android:progress="0"
android:progressDrawable="@drawable/widget_progress_cyan" />
<TextView
android:id="@+id/widget_memory_value"
android:layout_width="48dp"
android:layout_height="22dp"
android:gravity="end|center_vertical"
android:text="--"
android:textColor="#FFEAF0F7"
android:textSize="12sp"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:id="@+id/widget_disk_label"
android:layout_width="72dp"
android:layout_height="22dp"
android:gravity="center_vertical"
android:text="STORAGE"
android:textColor="#FF9AA6B6"
android:textSize="11sp"
android:textStyle="bold" />
<ProgressBar
android:id="@+id/widget_disk_bar"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="0dp"
android:layout_height="22dp"
android:layout_weight="1"
android:max="100"
android:progress="0"
android:progressDrawable="@drawable/widget_progress_amber" />
<TextView
android:id="@+id/widget_disk_value"
android:layout_width="48dp"
android:layout_height="22dp"
android:gravity="end|center_vertical"
android:text="--"
android:textColor="#FFEAF0F7"
android:textSize="12sp"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="34dp"
android:layout_marginTop="8dp"
android:orientation="horizontal">
<TextView
android:id="@+id/widget_rps"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginEnd="6dp"
android:layout_weight="1"
android:background="@drawable/widget_panel"
android:gravity="center"
android:text="RPS --"
android:textColor="#FFEAF0F7"
android:textSize="12sp"
android:textStyle="bold" />
<TextView
android:id="@+id/widget_p95"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginEnd="6dp"
android:layout_weight="1"
android:background="@drawable/widget_panel"
android:gravity="center"
android:text="P95 --"
android:textColor="#FFEAF0F7"
android:textSize="12sp"
android:textStyle="bold" />
<TextView
android:id="@+id/widget_5xx"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:background="@drawable/widget_panel_red"
android:gravity="center"
android:text="5XX --"
android:textColor="#FFFF5A52"
android:textSize="12sp"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="@color/command_black"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#FF202733"
android:pathData="M20,20h68a10,10 0,0 1,10 10v48a10,10 0,0 1,-10 10h-68a10,10 0,0 1,-10 -10v-48a10,10 0,0 1,10 -10z" />
<path
android:fillColor="#FF0E1116"
android:pathData="M27,34h54a4,4 0,0 1,4 4v9a4,4 0,0 1,-4 4h-54a4,4 0,0 1,-4 -4v-9a4,4 0,0 1,4 -4zM27,59h54a4,4 0,0 1,4 4v9a4,4 0,0 1,-4 4h-54a4,4 0,0 1,-4 -4v-9a4,4 0,0 1,4 -4z" />
<path
android:fillColor="#FF35D38B"
android:pathData="M33,39a3,3 0,1 0,0.1 0zM33,64a3,3 0,1 0,0.1 0z" />
<path
android:fillColor="#FFFFB84D"
android:pathData="M44,39a3,3 0,1 0,0.1 0zM44,64a3,3 0,1 0,0.1 0z" />
<path
android:fillColor="#00000000"
android:pathData="M29,54h13l7,-9l8,19l7,-10h17"
android:strokeColor="#FF35D38B"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:strokeWidth="4" />
</vector>

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="@color/command_black"
android:pathData="M54,0a54,54 0,1 1,-0.1 0z" />
<path
android:fillColor="#FF202733"
android:pathData="M25,24h58a9,9 0,0 1,9 9v42a9,9 0,0 1,-9 9h-58a9,9 0,0 1,-9 -9v-42a9,9 0,0 1,9 -9z" />
<path
android:fillColor="#FF0E1116"
android:pathData="M29,36h50a4,4 0,0 1,4 4v8a4,4 0,0 1,-4 4h-50a4,4 0,0 1,-4 -4v-8a4,4 0,0 1,4 -4zM29,60h50a4,4 0,0 1,4 4v8a4,4 0,0 1,-4 4h-50a4,4 0,0 1,-4 -4v-8a4,4 0,0 1,4 -4z" />
<path
android:fillColor="#FF35D38B"
android:pathData="M35,41a3,3 0,1 0,0.1 0zM35,65a3,3 0,1 0,0.1 0z" />
<path
android:fillColor="#00000000"
android:pathData="M31,55h13l7,-9l8,19l7,-10h15"
android:strokeColor="#FF35D38B"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:strokeWidth="4" />
</vector>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.NGXHttpMonitoringClient.Launcher" parent="Theme.NGXHttpMonitoringClient">
<item name="android:windowSplashScreenBackground">@color/command_black</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_hero</item>
<item name="android:windowSplashScreenIconBackgroundColor">@color/command_black</item>
</style>
</resources>

View File

@@ -1,10 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="command_black">#FF0E1116</color>
<color name="command_panel">#FF171C23</color>
<color name="signal_green">#FF35D38B</color>
<color name="signal_cyan">#FF4CC9F0</color>
<color name="signal_amber">#FFFFB84D</color>
<color name="signal_red">#FFFF5A52</color>
<color name="white">#FFFFFFFF</color>
</resources>

View File

@@ -1,3 +1,17 @@
<resources>
<string name="app_name">NGX Monitor</string>
<string name="widget_fleet_label">NGX Fleet Pulse</string>
<string name="widget_server_label">NGX Server Bars</string>
<string name="widget_metric_label">NGX Metric Sparkline</string>
<string name="widget_graph_label">NGX Telemetry Graph</string>
<string name="widget_incidents_label">NGX Incident Watch</string>
<string name="widget_fleet_description">Visual all-server status strip with online, watch, and down groups.</string>
<string name="widget_server_description">One selected server with large health state and CPU, memory, and storage bars.</string>
<string name="widget_metric_description">One focused metric with a large value and history sparkline.</string>
<string name="widget_graph_description">One selected server with CPU, memory, and storage trend lines.</string>
<string name="widget_incidents_description">Fleet incident queue with the highest-priority unreachable or degraded servers.</string>
<string name="widget_refresh">Refresh widget</string>
<string name="widget_fleet_graph">Fleet status strip</string>
<string name="widget_metric_graph">Metric history graph</string>
<string name="widget_graph_chart">Telemetry trend graph</string>
</resources>

View File

@@ -1,5 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.NGXHttpMonitoringClient" parent="android:Theme.Material.Light.NoActionBar" />
<style name="Theme.NGXHttpMonitoringClient" parent="android:style/Theme.Material.NoActionBar">
<item name="android:windowNoTitle">true</item>
<item name="android:windowActionBar">false</item>
<item name="android:windowLightStatusBar">false</item>
<item name="android:windowBackground">@color/command_black</item>
<item name="android:fontFamily">sans</item>
<item name="android:colorAccent">@color/signal_green</item>
</style>
<style name="Theme.NGXHttpMonitoringClient.Launcher" parent="Theme.NGXHttpMonitoringClient">
<item name="android:windowBackground">@drawable/splash_screen</item>
<item name="android:windowDisablePreview">false</item>
</style>
<style name="WidgetMetricCell">
<item name="android:layout_width">0dp</item>
<item name="android:layout_height">match_parent</item>
<item name="android:layout_weight">1</item>
<item name="android:background">@drawable/widget_panel</item>
<item name="android:gravity">center</item>
<item name="android:textColor">#FFEAF0F7</item>
<item name="android:textSize">12sp</item>
<item name="android:textStyle">bold</item>
</style>
</resources>

View File

@@ -1,13 +1,12 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
<exclude
domain="database"
path="." />
<exclude
domain="sharedpref"
path="." />
<exclude
domain="file"
path="." />
</full-backup-content>

View File

@@ -1,19 +1,25 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
<exclude
domain="database"
path="." />
<exclude
domain="sharedpref"
path="." />
<exclude
domain="file"
path="." />
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
<exclude
domain="database"
path="." />
<exclude
domain="sharedpref"
path="." />
<exclude
domain="file"
path="." />
</device-transfer>
-->
</data-extraction-rules>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/widget_fleet_description"
android:initialLayout="@layout/widget_fleet"
android:minWidth="250dp"
android:minHeight="140dp"
android:previewImage="@drawable/widget_preview_fleet"
android:resizeMode="horizontal|vertical"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen" />

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:configure="net.rodakot.ngxhttpmonitoringclient.widget.GraphWidgetConfigureActivity"
android:description="@string/widget_graph_description"
android:initialLayout="@layout/widget_graph"
android:minWidth="250dp"
android:minHeight="140dp"
android:previewImage="@drawable/widget_preview_graph"
android:resizeMode="horizontal|vertical"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen" />

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/widget_incidents_description"
android:initialLayout="@layout/widget_incidents"
android:minWidth="250dp"
android:minHeight="140dp"
android:previewImage="@drawable/widget_preview_incidents"
android:resizeMode="horizontal|vertical"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen" />

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:configure="net.rodakot.ngxhttpmonitoringclient.widget.MetricWidgetConfigureActivity"
android:description="@string/widget_metric_description"
android:initialLayout="@layout/widget_metric"
android:minWidth="120dp"
android:minHeight="120dp"
android:previewImage="@drawable/widget_preview_metric"
android:resizeMode="horizontal|vertical"
android:targetCellWidth="2"
android:targetCellHeight="2"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen" />

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:configure="net.rodakot.ngxhttpmonitoringclient.widget.ServerWidgetConfigureActivity"
android:description="@string/widget_server_description"
android:initialLayout="@layout/widget_server"
android:minWidth="250dp"
android:minHeight="140dp"
android:previewImage="@drawable/widget_preview_server"
android:resizeMode="horizontal|vertical"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen" />

View File

@@ -0,0 +1,30 @@
package net.rodakot.ngxhttpmonitoringclient.data
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class DiskUsageCalculationTest {
@Test
fun usedPercentFromSizes_calculatesStoragePercent() {
assertEquals(25.0, MonitorJsonParser.usedPercentFromSizes(500.0, 2000.0) ?: -1.0, 0.001)
}
@Test
fun usedPercentFromSizes_matchesMonitorApiRootFilesystemSample() {
assertEquals(
20.307,
MonitorJsonParser.usedPercentFromSizes(
used = 100_424_171_520.0,
total = 494_532_001_792.0,
) ?: -1.0,
0.001,
)
}
@Test
fun usedPercentFromSizes_ignoresInvalidTotals() {
assertNull(MonitorJsonParser.usedPercentFromSizes(500.0, 0.0))
assertNull(MonitorJsonParser.usedPercentFromSizes(500.0, null))
}
}

View File

@@ -0,0 +1,36 @@
package net.rodakot.ngxhttpmonitoringclient.domain
import net.rodakot.ngxhttpmonitoringclient.model.RouteKind
import org.junit.Assert.assertEquals
import org.junit.Test
class RouteAttemptPlannerTest {
@Test
fun attempts_usesSystemDnsWhenNoFallbacksOrVpn() {
assertEquals(
listOf(RouteKind.SystemDns),
RouteAttemptPlanner.attempts(hasFallbackAliases = false, vpnActive = false, lanAvailable = false),
)
}
@Test
fun attempts_addsFallbackDnsWhenAliasesExist() {
assertEquals(
listOf(RouteKind.SystemDns, RouteKind.FallbackDns),
RouteAttemptPlanner.attempts(hasFallbackAliases = true, vpnActive = false, lanAvailable = false),
)
}
@Test
fun attempts_addsLanRoutesWhenVpnAndLanAreAvailable() {
assertEquals(
listOf(
RouteKind.SystemDns,
RouteKind.FallbackDns,
RouteKind.LanSystemDns,
RouteKind.LanFallbackDns,
),
RouteAttemptPlanner.attempts(hasFallbackAliases = true, vpnActive = true, lanAvailable = true),
)
}
}

View File

@@ -24,4 +24,12 @@ class UrlRulesTest {
UrlRules.endpoint("https://monitor.example.com/", "/monitor/api"),
)
}
@Test
fun parseFallbackIps_acceptsCommaAndWhitespaceSeparatedValues() {
assertEquals(
listOf("192.168.1.20", "10.0.0.5"),
UrlRules.parseFallbackIps("192.168.1.20, 10.0.0.5\n192.168.1.20"),
)
}
}

118
docs/FALLBACK_IP_TLS.md Normal file
View File

@@ -0,0 +1,118 @@
# Fallback IP Routing And TLS Verification
The app stores the server as a normal URL plus optional fallback IP addresses.
The fallback IPs are used as DNS answers for the URL host; the app does not
rewrite the request URL to an IP address.
## Where It Is Implemented
- `ServerProfile.baseUrl` and `ServerProfile.fallbackIpAddresses` store the URL
and fallback list in `app/src/main/java/net/rodakot/ngxhttpmonitoringclient/model/MonitorModels.kt`.
- The editor exposes those values as `Base URL` and `Fallback LAN IPs` in
`app/src/main/java/net/rodakot/ngxhttpmonitoringclient/ui/MonitorApp.kt`.
- `MonitorRepository.buildServer()` normalizes the URL, checks whether plain
HTTP is allowed, and parses fallback addresses in
`app/src/main/java/net/rodakot/ngxhttpmonitoringclient/data/MonitorRepository.kt`.
- `RouteAttemptPlanner.attempts()` tries `SystemDns` first, then `FallbackDns`
when fallback addresses exist. When a VPN is active and a LAN network is
available, it also adds `LanSystemDns` and `LanFallbackDns`.
- `MonitorHttpClient.clientFor()` installs a custom OkHttp `Dns` implementation
for fallback routes.
## Request Flow
For a server configured like this:
```text
Base URL: https://monitor.example.com
Fallback LAN IPs: 192.168.1.20, 10.0.0.5
```
the app builds endpoint URLs from the base URL:
```text
https://monitor.example.com/monitor/api
https://monitor.example.com/monitor/health
https://monitor.example.com/monitor/live
```
The first request uses normal system DNS. If that network request fails before
a usable HTTP response is returned, the app tries the fallback route. In that
route, OkHttp still receives a request for `https://monitor.example.com/...`,
but the custom `FallbackDns` returns `192.168.1.20` and `10.0.0.5` when OkHttp
asks how to resolve `monitor.example.com`.
The LAN variants do the same thing through Android's LAN network socket factory
when the active device state includes both VPN and Wi-Fi or Ethernet.
## Why TLS Still Works
TLS verification is based on the URL hostname, not the numeric socket address.
Because the app keeps the request URL as `https://monitor.example.com/...`,
OkHttp still uses `monitor.example.com` for:
- SNI during the TLS handshake.
- Certificate hostname verification after the certificate is received.
That means the server certificate must be valid for `monitor.example.com`.
It does not need to contain `192.168.1.20` as an IP subject alternative name
because the client is not asking TLS to verify `192.168.1.20`.
This is different from requesting:
```text
https://192.168.1.20/monitor/api
```
With that URL, the TLS hostname is `192.168.1.20`, so the certificate would
need to be valid for the IP address. Adding only an HTTP `Host` header is not
enough to fix that because TLS verification happens before the HTTP request is
processed.
If the fallback IP reaches a server that does not present a certificate valid
for the original URL host, OkHttp throws an SSL error and the app reports it as
`TlsFailure`.
## Bash Equivalent
The same behavior in curl is `--resolve`:
```bash
curl --resolve monitor.example.com:443:192.168.1.20 \
https://monitor.example.com/monitor/api
```
`--resolve` says "connect to this IP for this host and port." The URL still
uses `monitor.example.com`, so curl sends SNI for `monitor.example.com` and
verifies the certificate against `monitor.example.com`.
The sample script in this directory wraps that pattern:
```bash
bash docs/fallback-ip-curl-example.sh \
--base-url https://monitor.example.com \
--fallback-ip 192.168.1.20 \
--fallback-ip 10.0.0.5 \
--token '<monitor token>'
```
For Basic Auth:
```bash
bash docs/fallback-ip-curl-example.sh \
--base-url https://monitor.example.com \
--fallback-ips "192.168.1.20,10.0.0.5" \
--basic-auth 'user:password'
```
For the health endpoint:
```bash
bash docs/fallback-ip-curl-example.sh \
--base-url https://monitor.example.com \
--fallback-ip 192.168.1.20 \
--endpoint /monitor/health
```
Do not use `--insecure` for this behavior. `--insecure` disables certificate
verification, while the app's fallback keeps verification enabled.

View File

@@ -0,0 +1,37 @@
# Play Console Data Safety Notes
Use this as a starting point for the Play Console Data safety form. Review it before submission because the final answers are your legal responsibility.
## App Data Flow
NGX Monitor is a client for monitor endpoints configured by the user. The developer does not operate a backend for this app.
Stored locally on the device:
- Server names, base URLs, tags, fallback LAN IPs, and alert thresholds
- Monitor API tokens and optional Basic Auth credentials, encrypted with Android Keystore
- Metric samples, alert history, route diagnostics, and widget preferences
Transmitted by the app:
- HTTP(S) requests to user-configured monitor endpoints
- Authentication headers only for the specific endpoint configured by the user
- Notification content generated locally on the device
Not used by the app:
- Advertising ID
- Location APIs
- Contacts, photos, camera, microphone, calendar, or SMS
- Developer-operated analytics or crash reporting SDKs
## Suggested Data Safety Answers
- Data collected by developer: No
- Data shared with third parties by developer: No
- Data is encrypted in transit: Yes, when users configure HTTPS monitor endpoints
- Users can request data deletion: Data is local; users can delete server profiles in-app or clear app data
- App type: utility/productivity
- Account creation: Not required
Important note for the form: the app allows user-configured HTTP endpoints for private LAN monitoring. If you publish with HTTP support enabled, disclose that encryption in transit depends on the endpoint configured by the user.

View File

@@ -0,0 +1,93 @@
# Google Play Release Checklist
This project is configured to build Google Play compatible Android App Bundles.
## Current Project Readiness
- Package name: `net.rodakot.ngxhttpmonitoringclient`
- Minimum SDK: 24
- Target SDK: 36
- Version: `VERSION_NAME` / `VERSION_CODE` in `gradle.properties`
- Release build: R8 minification enabled, resource shrinking enabled
- App data backup: disabled for app data, databases, shared preferences, and files
- Release signing: read from environment variables or `local.properties`
## Create An Upload Key
Create an upload keystore outside the repository:
```powershell
keytool -genkeypair `
-v `
-keystore "C:\secure\keys\ngx-monitor-upload.jks" `
-storetype JKS `
-keyalg RSA `
-keysize 4096 `
-validity 10000 `
-alias ngx-monitor-upload
```
Copy `local.properties.example` to `local.properties` and set:
```properties
NGX_RELEASE_STORE_FILE=C:\Users\meghdad\keystores\ngx-monitor-upload.jks
NGX_RELEASE_STORE_PASSWORD=<redacted>
NGX_RELEASE_KEY_ALIAS=ngx-monitor-upload
NGX_RELEASE_KEY_PASSWORD=<redacted>
```
Do not commit `local.properties` or the keystore.
## Build Release Artifacts
Unsigned release sanity build:
```powershell
$env:GRADLE_USER_HOME='C:\Users\meghdad\AndroidStudioProjects\NGXhttpMonitoringClient\.gradle-user'
.\gradlew.bat :app:bundleRelease --console=plain
```
Signed upload bundle, after signing values are configured:
```powershell
$env:GRADLE_USER_HOME='C:\Users\meghdad\AndroidStudioProjects\NGXhttpMonitoringClient\.gradle-user'
.\gradlew.bat :app:bundleRelease --console=plain # for bulding aab
.\gradlew.bat :app:assembleRelease --console=plain # for bulding apk
```
Output:
```text
app/build/outputs/bundle/release/app-release.aab
```
## Before Uploading To Play Console
1. Create the app in Play Console.
2. Enable Play App Signing.
3. Upload `app-release.aab`.
4. Fill the store listing using `fastlane/metadata/android/en-US`.
5. Upload phone screenshots and a 1024x500 feature graphic.
6. Host `docs/PRIVACY_POLICY.md` as a public web page and paste that URL into Play Console.
7. Complete Data Safety using `docs/PLAY_STORE_DATA_SAFETY.md`.
8. Complete Content Rating. Suggested category: utility/productivity/server monitoring.
9. Complete Target Audience. This app is for server administrators, not children.
10. Run an internal test release before production.
## Release Version Bump
For every Play upload, increment `VERSION_CODE` in `gradle.properties`.
Example:
```properties
VERSION_CODE=2
VERSION_NAME=1.0.1
```
## Official References
- Google Play target API requirements: https://support.google.com/googleplay/android-developer/answer/11926878
- Android app signing: https://developer.android.com/studio/publish/app-signing
- Build and upload an Android App Bundle: https://developer.android.com/guide/app-bundle
- Google Play Data safety: https://support.google.com/googleplay/android-developer/answer/10787469

37
docs/PRIVACY_POLICY.md Normal file
View File

@@ -0,0 +1,37 @@
# NGX Monitor Privacy Policy
Effective date: 2026-05-11
NGX Monitor is a mobile client for monitoring Nginx servers that you configure in the app.
## Data Stored On Your Device
The app stores server profiles, monitor endpoint URLs, optional fallback LAN IP addresses, tags, alert thresholds, metric history, route diagnostics, alerts, and widget preferences on your device.
If you save monitor tokens or Basic Auth credentials, the app encrypts them with Android Keystore before storing them locally.
Android cloud backup and device transfer are disabled for app data.
## Data Sent By The App
The app sends monitor API requests only to endpoints that you configure. If you configure authentication, the app sends the configured token or Basic Auth credentials to that monitor endpoint.
The app does not send your monitor data, credentials, server list, or usage data to the app developer.
## Network Security
HTTPS is recommended for monitor endpoints. The app also supports HTTP endpoints for private LAN monitoring when the user explicitly configures them.
## Third Parties
The app does not include advertising SDKs, analytics SDKs, or developer-operated crash reporting SDKs.
Google Play may process app install, billing, review, crash, and device information according to Google's own policies.
## Data Deletion
You can delete server profiles inside the app. You can also remove all app data from Android system settings by clearing the app's storage or uninstalling the app.
## Contact
Replace this section with the publisher support email before submitting the app to Google Play.

View File

@@ -0,0 +1,243 @@
#!/usr/bin/env bash
set -u
usage() {
cat <<'USAGE'
Usage:
bash docs/fallback-ip-curl-example.sh --base-url URL --fallback-ip IP [options]
Required:
--base-url URL Server root URL, for example https://monitor.example.com
--fallback-ip IP Fallback address for the URL host. Can be repeated.
--fallback-ips "LIST" Comma or space separated fallback addresses.
Options:
--endpoint PATH Monitor endpoint path. Default: /monitor/api
--token TOKEN Send TOKEN as X-Monitor-Token.
--basic-auth USER:PASS Send HTTP Basic Auth credentials.
--connect-timeout SEC Curl connection timeout. Default: 8
--max-time SEC Curl total request timeout. Default: 20
-h, --help Show this help.
Example:
bash docs/fallback-ip-curl-example.sh \
--base-url https://monitor.example.com \
--fallback-ip 192.168.1.20 \
--fallback-ip 10.0.0.5 \
--token secret
The fallback keeps the URL host as monitor.example.com and only overrides DNS
with curl --resolve. For HTTPS, curl still sends SNI for that host and verifies
the certificate against that host.
USAGE
}
die() {
printf '%s\n' "$1" >&2
printf '\n' >&2
usage >&2
exit 2
}
add_fallback_ips() {
local values ip
values="${1//,/ }"
for ip in $values; do
fallback_list+=("$ip")
done
}
base_url=""
endpoint="/monitor/api"
token=""
basic_auth=""
connect_timeout="8"
max_time="20"
fallback_list=()
while (($# > 0)); do
case "$1" in
--base-url)
(($# >= 2)) || die "--base-url requires a value"
base_url="$2"
shift 2
;;
--base-url=*)
base_url="${1#*=}"
shift
;;
--fallback-ip)
(($# >= 2)) || die "--fallback-ip requires a value"
add_fallback_ips "$2"
shift 2
;;
--fallback-ip=*)
add_fallback_ips "${1#*=}"
shift
;;
--fallback-ips)
(($# >= 2)) || die "--fallback-ips requires a value"
add_fallback_ips "$2"
shift 2
;;
--fallback-ips=*)
add_fallback_ips "${1#*=}"
shift
;;
--endpoint)
(($# >= 2)) || die "--endpoint requires a value"
endpoint="$2"
shift 2
;;
--endpoint=*)
endpoint="${1#*=}"
shift
;;
--token)
(($# >= 2)) || die "--token requires a value"
token="$2"
shift 2
;;
--token=*)
token="${1#*=}"
shift
;;
--basic-auth)
(($# >= 2)) || die "--basic-auth requires a value"
basic_auth="$2"
shift 2
;;
--basic-auth=*)
basic_auth="${1#*=}"
shift
;;
--connect-timeout)
(($# >= 2)) || die "--connect-timeout requires a value"
connect_timeout="$2"
shift 2
;;
--connect-timeout=*)
connect_timeout="${1#*=}"
shift
;;
--max-time)
(($# >= 2)) || die "--max-time requires a value"
max_time="$2"
shift 2
;;
--max-time=*)
max_time="${1#*=}"
shift
;;
-h|--help)
usage
exit 0
;;
*)
die "Unknown argument: $1"
;;
esac
done
[[ -n "$base_url" ]] || die "--base-url is required"
((${#fallback_list[@]} > 0)) || die "At least one --fallback-ip or --fallback-ips value is required"
base_url="${base_url%/}"
[[ "$endpoint" == /* ]] || endpoint="/$endpoint"
url="${base_url}${endpoint}"
if [[ "$base_url" =~ ^([A-Za-z][A-Za-z0-9+.-]*)://([^/]+) ]]; then
scheme="${BASH_REMATCH[1]}"
authority="${BASH_REMATCH[2]}"
else
die "--base-url must be an http or https URL"
fi
if [[ "$authority" == *"@"* ]]; then
die "Put Basic Auth in --basic-auth USER:PASS, not in the URL"
fi
if [[ "$authority" =~ ^\[([^]]+)\](:([0-9]+))?$ ]]; then
host="${BASH_REMATCH[1]}"
port="${BASH_REMATCH[3]:-}"
elif [[ "$authority" =~ ^([^:]+)(:([0-9]+))?$ ]]; then
host="${BASH_REMATCH[1]}"
port="${BASH_REMATCH[3]:-}"
else
die "Could not parse host and port from $authority"
fi
case "$scheme" in
[Hh][Tt][Tt][Pp][Ss]) port="${port:-443}" ;;
[Hh][Tt][Tt][Pp]) port="${port:-80}" ;;
*) die "Only http and https URLs are supported" ;;
esac
curl_args=(
--silent
--show-error
--location
--connect-timeout "$connect_timeout"
--max-time "$max_time"
--header "Accept: application/json"
)
if [[ -n "$token" ]]; then
curl_args+=(--header "X-Monitor-Token: $token")
fi
if [[ -n "$basic_auth" ]]; then
curl_args+=(--user "$basic_auth")
fi
request_once() {
local body_file http_code rc
body_file="$(mktemp)"
http_code="$(curl "${curl_args[@]}" "$@" --output "$body_file" --write-out '%{http_code}' "$url")"
rc=$?
if ((rc != 0)); then
rm -f "$body_file"
return "$rc"
fi
cat "$body_file"
rm -f "$body_file"
if [[ "$http_code" == 2* ]]; then
return 0
fi
printf '\nHTTP %s from monitor endpoint\n' "$http_code" >&2
return 22
}
printf 'Trying normal DNS for %s\n' "$host" >&2
if request_once; then
exit 0
fi
rc=$?
if ((rc == 22)); then
exit "$rc"
fi
printf 'Normal request failed before a usable HTTP response; trying fallback IPs\n' >&2
last_rc="$rc"
for ip in "${fallback_list[@]}"; do
[[ -n "$ip" ]] || continue
resolve_ip="$ip"
if [[ "$resolve_ip" == *:* && "$resolve_ip" != \[*\] ]]; then
resolve_ip="[$resolve_ip]"
fi
printf 'Trying %s via --resolve %s:%s:%s\n' "$ip" "$host" "$port" "$resolve_ip" >&2
if request_once --resolve "${host}:${port}:${resolve_ip}"; then
exit 0
fi
last_rc=$?
done
exit "$last_rc"

View File

@@ -0,0 +1,7 @@
Initial release.
- Fleet monitor for Nginx monitor endpoints
- Server detail cockpit with system, request, disk, alert, history, and route diagnostics
- VPN and DNS resilience with fallback LAN IPs
- Home-screen widgets for fleet status, server bars, metric sparklines, telemetry graphs, and incidents
- Local encrypted credential storage

View File

@@ -0,0 +1,15 @@
NGX Monitor is a focused Android client for Nginx monitor endpoints.
Track CPU, memory, storage, request rate, latency, connection state, HTTP errors, and route health from your phone. The app is designed for server administrators who monitor multiple Nginx hosts and need a fast, readable mobile command deck.
Key features:
- Monitor 20+ servers from one fleet view
- View live CPU, memory, storage, request, latency, and connection signals
- Follow Nginx worker, request, upstream, disk, history, alert, and route diagnostics
- Configure monitor API tokens and optional Basic Auth credentials
- Store credentials locally with Android Keystore encryption
- Use fallback LAN IPs for VPN, DNS, and local network routing problems
- Get Android notifications for degraded or unreachable servers
- Add home-screen widgets for fleet status, server bars, metric sparklines, telemetry graphs, and incident watch
NGX Monitor connects only to the monitor endpoints you configure. HTTPS endpoints are recommended, and HTTP endpoints are supported for private LAN monitoring.

View File

@@ -0,0 +1 @@
Monitor Nginx servers, alerts, routes, and home-screen widgets.

View File

@@ -0,0 +1 @@
NGX Monitor

View File

@@ -13,3 +13,5 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# org.gradle.parallel=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
VERSION_CODE=102000
VERSION_NAME=1.2.0

View File

@@ -9,6 +9,7 @@ lifecycleRuntimeKtx = "2.10.0"
activityCompose = "1.13.0"
kotlin = "2.2.10"
composeBom = "2026.02.01"
okhttp = "4.12.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -28,6 +29,7 @@ androidx-compose-material3 = { group = "androidx.compose.material3", name = "mat
androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" }
androidx-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" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }

9
local.properties.example Normal file
View File

@@ -0,0 +1,9 @@
## Copy these values into local.properties or export them as environment variables
## before building the signed Play upload bundle.
##
## Do not commit local.properties or the keystore file.
NGX_RELEASE_STORE_FILE=C:\\path\\to\\ngx-monitor-upload.jks
NGX_RELEASE_STORE_PASSWORD=replace-with-keystore-password
NGX_RELEASE_KEY_ALIAS=ngx-monitor-upload
NGX_RELEASE_KEY_PASSWORD=replace-with-key-password