implement home page widgets

This commit is contained in:
2026-05-11 18:27:57 +03:30
parent d2a108f605
commit d1f196e9b6
35 changed files with 2161 additions and 0 deletions

View File

@@ -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. - 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. - 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`. - JSON API polling via `/monitor/api` and health checks via `/monitor/health`.
- Foreground live streaming via `/monitor/live` Server-Sent Events. - Foreground live streaming via `/monitor/live` Server-Sent Events.
- Encrypted per-server credentials for monitor tokens and optional Nginx Basic Auth. - 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. 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 ## Security
- Prefer HTTPS for monitor endpoints. - Prefer HTTPS for monitor endpoints.
@@ -87,6 +107,7 @@ app/src/main/java/net/rodakot/ngxhttpmonitoringclient/
model/ Server, metric, alert, and route data models model/ Server, metric, alert, and route data models
ui/ Compose command-deck UI ui/ Compose command-deck UI
ui/theme/ Material theme and colors ui/theme/ Material theme and colors
widget/ Android home-screen widgets
``` ```
## App Assets ## 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` - 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 artwork: `app/src/main/res/drawable/splash_hero.xml`
- Splash background: `app/src/main/res/drawable/splash_screen.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. These assets are vector drawables, so they scale cleanly across screen densities.

View File

@@ -32,6 +32,79 @@
android:name=".background.MonitorJobService" android:name=".background.MonitorJobService"
android:exported="false" android:exported="false"
android:permission="android.permission.BIND_JOB_SERVICE" /> 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> </application>
</manifest> </manifest>

View File

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

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,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",
),
}

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

@@ -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,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,129 @@
<?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: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,107 @@
<?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: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,145 @@
<?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: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,70 @@
<?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: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,227 @@
<?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: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

@@ -1,3 +1,17 @@
<resources> <resources>
<string name="app_name">NGX Monitor</string> <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> </resources>

View File

@@ -15,4 +15,15 @@
<item name="android:windowBackground">@drawable/splash_screen</item> <item name="android:windowBackground">@drawable/splash_screen</item>
<item name="android:windowDisablePreview">false</item> <item name="android:windowDisablePreview">false</item>
</style> </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> </resources>

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" />