From d1f196e9b67ef76e7aab712c96ae0c2c50489588 Mon Sep 17 00:00:00 2001 From: Meghdad Date: Mon, 11 May 2026 18:27:57 +0330 Subject: [PATCH] implement home page widgets --- README.md | 22 + app/src/main/AndroidManifest.xml | 73 ++ .../background/MonitorJobService.kt | 2 + .../widget/MonitorWidgetProviders.kt | 145 ++++ .../widget/MonitorWidgetUpdater.kt | 721 ++++++++++++++++++ .../widget/WidgetConfigureActivities.kt | 167 ++++ .../widget/WidgetPreferences.kt | 50 ++ .../main/res/drawable/ic_widget_refresh.xml | 27 + .../main/res/drawable/widget_background.xml | 13 + app/src/main/res/drawable/widget_button.xml | 8 + app/src/main/res/drawable/widget_panel.xml | 5 + .../main/res/drawable/widget_panel_amber.xml | 5 + .../main/res/drawable/widget_panel_blue.xml | 5 + .../main/res/drawable/widget_panel_green.xml | 5 + .../main/res/drawable/widget_panel_red.xml | 5 + .../res/drawable/widget_preview_fleet.xml | 15 + .../res/drawable/widget_preview_graph.xml | 30 + .../res/drawable/widget_preview_incidents.xml | 14 + .../res/drawable/widget_preview_metric.xml | 18 + .../res/drawable/widget_preview_server.xml | 14 + .../res/drawable/widget_progress_amber.xml | 17 + .../res/drawable/widget_progress_cyan.xml | 17 + .../res/drawable/widget_progress_green.xml | 17 + app/src/main/res/layout/widget_fleet.xml | 129 ++++ app/src/main/res/layout/widget_graph.xml | 107 +++ app/src/main/res/layout/widget_incidents.xml | 145 ++++ app/src/main/res/layout/widget_metric.xml | 70 ++ app/src/main/res/layout/widget_server.xml | 227 ++++++ app/src/main/res/values/strings.xml | 14 + app/src/main/res/values/themes.xml | 11 + app/src/main/res/xml/widget_fleet_info.xml | 12 + app/src/main/res/xml/widget_graph_info.xml | 13 + .../main/res/xml/widget_incidents_info.xml | 12 + app/src/main/res/xml/widget_metric_info.xml | 13 + app/src/main/res/xml/widget_server_info.xml | 13 + 35 files changed, 2161 insertions(+) create mode 100644 app/src/main/java/net/rodakot/ngxhttpmonitoringclient/widget/MonitorWidgetProviders.kt create mode 100644 app/src/main/java/net/rodakot/ngxhttpmonitoringclient/widget/MonitorWidgetUpdater.kt create mode 100644 app/src/main/java/net/rodakot/ngxhttpmonitoringclient/widget/WidgetConfigureActivities.kt create mode 100644 app/src/main/java/net/rodakot/ngxhttpmonitoringclient/widget/WidgetPreferences.kt create mode 100644 app/src/main/res/drawable/ic_widget_refresh.xml create mode 100644 app/src/main/res/drawable/widget_background.xml create mode 100644 app/src/main/res/drawable/widget_button.xml create mode 100644 app/src/main/res/drawable/widget_panel.xml create mode 100644 app/src/main/res/drawable/widget_panel_amber.xml create mode 100644 app/src/main/res/drawable/widget_panel_blue.xml create mode 100644 app/src/main/res/drawable/widget_panel_green.xml create mode 100644 app/src/main/res/drawable/widget_panel_red.xml create mode 100644 app/src/main/res/drawable/widget_preview_fleet.xml create mode 100644 app/src/main/res/drawable/widget_preview_graph.xml create mode 100644 app/src/main/res/drawable/widget_preview_incidents.xml create mode 100644 app/src/main/res/drawable/widget_preview_metric.xml create mode 100644 app/src/main/res/drawable/widget_preview_server.xml create mode 100644 app/src/main/res/drawable/widget_progress_amber.xml create mode 100644 app/src/main/res/drawable/widget_progress_cyan.xml create mode 100644 app/src/main/res/drawable/widget_progress_green.xml create mode 100644 app/src/main/res/layout/widget_fleet.xml create mode 100644 app/src/main/res/layout/widget_graph.xml create mode 100644 app/src/main/res/layout/widget_incidents.xml create mode 100644 app/src/main/res/layout/widget_metric.xml create mode 100644 app/src/main/res/layout/widget_server.xml create mode 100644 app/src/main/res/xml/widget_fleet_info.xml create mode 100644 app/src/main/res/xml/widget_graph_info.xml create mode 100644 app/src/main/res/xml/widget_incidents_info.xml create mode 100644 app/src/main/res/xml/widget_metric_info.xml create mode 100644 app/src/main/res/xml/widget_server_info.xml diff --git a/README.md b/README.md index 26785f5..4fb0697 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ NGX Monitor is an Android client for `ngx_http_monitoring_module`. It is built w - 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. @@ -47,6 +48,25 @@ The app keeps the URL hostname unchanged and only substitutes DNS results intern 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. @@ -87,6 +107,7 @@ app/src/main/java/net/rodakot/ngxhttpmonitoringclient/ 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 @@ -94,5 +115,6 @@ app/src/main/java/net/rodakot/ngxhttpmonitoringclient/ - 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. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 01aaca6..578a630 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -32,6 +32,79 @@ android:name=".background.MonitorJobService" android:exported="false" android:permission="android.permission.BIND_JOB_SERVICE" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/background/MonitorJobService.kt b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/background/MonitorJobService.kt index c6c1476..b49b749 100644 --- a/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/background/MonitorJobService.kt +++ b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/background/MonitorJobService.kt @@ -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) } } diff --git a/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/widget/MonitorWidgetProviders.kt b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/widget/MonitorWidgetProviders.kt new file mode 100644 index 0000000..bbadd74 --- /dev/null +++ b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/widget/MonitorWidgetProviders.kt @@ -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) + } + } +} diff --git a/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/widget/MonitorWidgetUpdater.kt b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/widget/MonitorWidgetUpdater.kt new file mode 100644 index 0000000..0db4e1c --- /dev/null +++ b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/widget/MonitorWidgetUpdater.kt @@ -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().apply { + putAll(repository.latestSummaries(servers.map { it.id })) + } + val refreshed = mutableSetOf() + 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().apply { + putAll(repository.latestSummaries(servers.map { it.id })) + } + val refreshed = mutableSetOf() + 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().apply { + putAll(repository.latestSummaries(servers.map { it.id })) + } + val refreshed = mutableSetOf() + 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, + summaries: Map, + 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, + 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, + ): 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, + summaries: Map, + 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> { 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? { + 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, summaries: Map): 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, summaries: Map): String { + if (servers.isEmpty()) return "Open the app and add your first monitor endpoint" + val priority = servers + .sortedWith(compareBy { 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, summary: MetricSummary?): List { + 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, + summaries: Map, + 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, + 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, + 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, + 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, + ) +} diff --git a/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/widget/WidgetConfigureActivities.kt b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/widget/WidgetConfigureActivities.kt new file mode 100644 index 0000000..97b3eb6 --- /dev/null +++ b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/widget/WidgetConfigureActivities.kt @@ -0,0 +1,167 @@ +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.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) + 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 + 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 = index + 1 + text = server.name + setTextColor(Color.rgb(234, 240, 247)) + textSize = 16f + setPadding(0, 10, 0, 10) + }) + } + check(1) + } + 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 serverIndex = (serverGroup.checkedRadioButtonId - 1).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", + ), +} diff --git a/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/widget/WidgetPreferences.kt b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/widget/WidgetPreferences.kt new file mode 100644 index 0000000..da0b06b --- /dev/null +++ b/app/src/main/java/net/rodakot/ngxhttpmonitoringclient/widget/WidgetPreferences.kt @@ -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" +} diff --git a/app/src/main/res/drawable/ic_widget_refresh.xml b/app/src/main/res/drawable/ic_widget_refresh.xml new file mode 100644 index 0000000..d3836a9 --- /dev/null +++ b/app/src/main/res/drawable/ic_widget_refresh.xml @@ -0,0 +1,27 @@ + + + + + + + diff --git a/app/src/main/res/drawable/widget_background.xml b/app/src/main/res/drawable/widget_background.xml new file mode 100644 index 0000000..dbde283 --- /dev/null +++ b/app/src/main/res/drawable/widget_background.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/app/src/main/res/drawable/widget_button.xml b/app/src/main/res/drawable/widget_button.xml new file mode 100644 index 0000000..3e57224 --- /dev/null +++ b/app/src/main/res/drawable/widget_button.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/app/src/main/res/drawable/widget_panel.xml b/app/src/main/res/drawable/widget_panel.xml new file mode 100644 index 0000000..b174fa1 --- /dev/null +++ b/app/src/main/res/drawable/widget_panel.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/widget_panel_amber.xml b/app/src/main/res/drawable/widget_panel_amber.xml new file mode 100644 index 0000000..2cdf166 --- /dev/null +++ b/app/src/main/res/drawable/widget_panel_amber.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/widget_panel_blue.xml b/app/src/main/res/drawable/widget_panel_blue.xml new file mode 100644 index 0000000..66ebb0e --- /dev/null +++ b/app/src/main/res/drawable/widget_panel_blue.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/widget_panel_green.xml b/app/src/main/res/drawable/widget_panel_green.xml new file mode 100644 index 0000000..bfea098 --- /dev/null +++ b/app/src/main/res/drawable/widget_panel_green.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/widget_panel_red.xml b/app/src/main/res/drawable/widget_panel_red.xml new file mode 100644 index 0000000..ae483dd --- /dev/null +++ b/app/src/main/res/drawable/widget_panel_red.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/widget_preview_fleet.xml b/app/src/main/res/drawable/widget_preview_fleet.xml new file mode 100644 index 0000000..5e08ad3 --- /dev/null +++ b/app/src/main/res/drawable/widget_preview_fleet.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/widget_preview_graph.xml b/app/src/main/res/drawable/widget_preview_graph.xml new file mode 100644 index 0000000..069a658 --- /dev/null +++ b/app/src/main/res/drawable/widget_preview_graph.xml @@ -0,0 +1,30 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/widget_preview_incidents.xml b/app/src/main/res/drawable/widget_preview_incidents.xml new file mode 100644 index 0000000..cd0d8cb --- /dev/null +++ b/app/src/main/res/drawable/widget_preview_incidents.xml @@ -0,0 +1,14 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/widget_preview_metric.xml b/app/src/main/res/drawable/widget_preview_metric.xml new file mode 100644 index 0000000..843ea30 --- /dev/null +++ b/app/src/main/res/drawable/widget_preview_metric.xml @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/widget_preview_server.xml b/app/src/main/res/drawable/widget_preview_server.xml new file mode 100644 index 0000000..b84c149 --- /dev/null +++ b/app/src/main/res/drawable/widget_preview_server.xml @@ -0,0 +1,14 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/widget_progress_amber.xml b/app/src/main/res/drawable/widget_progress_amber.xml new file mode 100644 index 0000000..3f587ef --- /dev/null +++ b/app/src/main/res/drawable/widget_progress_amber.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/widget_progress_cyan.xml b/app/src/main/res/drawable/widget_progress_cyan.xml new file mode 100644 index 0000000..f83ade7 --- /dev/null +++ b/app/src/main/res/drawable/widget_progress_cyan.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/widget_progress_green.xml b/app/src/main/res/drawable/widget_progress_green.xml new file mode 100644 index 0000000..7053ad5 --- /dev/null +++ b/app/src/main/res/drawable/widget_progress_green.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/widget_fleet.xml b/app/src/main/res/layout/widget_fleet.xml new file mode 100644 index 0000000..8edd0d8 --- /dev/null +++ b/app/src/main/res/layout/widget_fleet.xml @@ -0,0 +1,129 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/widget_graph.xml b/app/src/main/res/layout/widget_graph.xml new file mode 100644 index 0000000..cb12939 --- /dev/null +++ b/app/src/main/res/layout/widget_graph.xml @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/widget_incidents.xml b/app/src/main/res/layout/widget_incidents.xml new file mode 100644 index 0000000..1219c54 --- /dev/null +++ b/app/src/main/res/layout/widget_incidents.xml @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/widget_metric.xml b/app/src/main/res/layout/widget_metric.xml new file mode 100644 index 0000000..fb4899a --- /dev/null +++ b/app/src/main/res/layout/widget_metric.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/widget_server.xml b/app/src/main/res/layout/widget_server.xml new file mode 100644 index 0000000..0c4db0d --- /dev/null +++ b/app/src/main/res/layout/widget_server.xml @@ -0,0 +1,227 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 242554e..931a871 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,17 @@ NGX Monitor + NGX Fleet Pulse + NGX Server Bars + NGX Metric Sparkline + NGX Telemetry Graph + NGX Incident Watch + Visual all-server status strip with online, watch, and down groups. + One selected server with large health state and CPU, memory, and storage bars. + One focused metric with a large value and history sparkline. + One selected server with CPU, memory, and storage trend lines. + Fleet incident queue with the highest-priority unreachable or degraded servers. + Refresh widget + Fleet status strip + Metric history graph + Telemetry trend graph diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index e0a6d1d..19cfc46 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -15,4 +15,15 @@ @drawable/splash_screen false + + diff --git a/app/src/main/res/xml/widget_fleet_info.xml b/app/src/main/res/xml/widget_fleet_info.xml new file mode 100644 index 0000000..cbfa368 --- /dev/null +++ b/app/src/main/res/xml/widget_fleet_info.xml @@ -0,0 +1,12 @@ + + diff --git a/app/src/main/res/xml/widget_graph_info.xml b/app/src/main/res/xml/widget_graph_info.xml new file mode 100644 index 0000000..9c39655 --- /dev/null +++ b/app/src/main/res/xml/widget_graph_info.xml @@ -0,0 +1,13 @@ + + diff --git a/app/src/main/res/xml/widget_incidents_info.xml b/app/src/main/res/xml/widget_incidents_info.xml new file mode 100644 index 0000000..3e700d7 --- /dev/null +++ b/app/src/main/res/xml/widget_incidents_info.xml @@ -0,0 +1,12 @@ + + diff --git a/app/src/main/res/xml/widget_metric_info.xml b/app/src/main/res/xml/widget_metric_info.xml new file mode 100644 index 0000000..ddce0b9 --- /dev/null +++ b/app/src/main/res/xml/widget_metric_info.xml @@ -0,0 +1,13 @@ + + diff --git a/app/src/main/res/xml/widget_server_info.xml b/app/src/main/res/xml/widget_server_info.xml new file mode 100644 index 0000000..38f353a --- /dev/null +++ b/app/src/main/res/xml/widget_server_info.xml @@ -0,0 +1,13 @@ + +