implement home page widgets
This commit is contained in:
22
README.md
22
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.
|
||||
|
||||
@@ -32,6 +32,79 @@
|
||||
android:name=".background.MonitorJobService"
|
||||
android:exported="false"
|
||||
android:permission="android.permission.BIND_JOB_SERVICE" />
|
||||
|
||||
<activity
|
||||
android:name=".widget.ServerWidgetConfigureActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.NGXHttpMonitoringClient" />
|
||||
<activity
|
||||
android:name=".widget.MetricWidgetConfigureActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.NGXHttpMonitoringClient" />
|
||||
<activity
|
||||
android:name=".widget.GraphWidgetConfigureActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.NGXHttpMonitoringClient" />
|
||||
|
||||
<receiver
|
||||
android:name=".widget.FleetWidgetProvider"
|
||||
android:exported="true"
|
||||
android:label="@string/widget_fleet_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/widget_fleet_info" />
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".widget.ServerWidgetProvider"
|
||||
android:exported="true"
|
||||
android:label="@string/widget_server_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/widget_server_info" />
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".widget.MetricWidgetProvider"
|
||||
android:exported="true"
|
||||
android:label="@string/widget_metric_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/widget_metric_info" />
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".widget.GraphWidgetProvider"
|
||||
android:exported="true"
|
||||
android:label="@string/widget_graph_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/widget_graph_info" />
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".widget.IncidentsWidgetProvider"
|
||||
android:exported="true"
|
||||
android:label="@string/widget_incidents_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/widget_incidents_info" />
|
||||
</receiver>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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",
|
||||
),
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
27
app/src/main/res/drawable/ic_widget_refresh.xml
Normal file
27
app/src/main/res/drawable/ic_widget_refresh.xml
Normal 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>
|
||||
13
app/src/main/res/drawable/widget_background.xml
Normal file
13
app/src/main/res/drawable/widget_background.xml
Normal 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>
|
||||
8
app/src/main/res/drawable/widget_button.xml
Normal file
8
app/src/main/res/drawable/widget_button.xml
Normal 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>
|
||||
5
app/src/main/res/drawable/widget_panel.xml
Normal file
5
app/src/main/res/drawable/widget_panel.xml
Normal 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>
|
||||
5
app/src/main/res/drawable/widget_panel_amber.xml
Normal file
5
app/src/main/res/drawable/widget_panel_amber.xml
Normal 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>
|
||||
5
app/src/main/res/drawable/widget_panel_blue.xml
Normal file
5
app/src/main/res/drawable/widget_panel_blue.xml
Normal 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>
|
||||
5
app/src/main/res/drawable/widget_panel_green.xml
Normal file
5
app/src/main/res/drawable/widget_panel_green.xml
Normal 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>
|
||||
5
app/src/main/res/drawable/widget_panel_red.xml
Normal file
5
app/src/main/res/drawable/widget_panel_red.xml
Normal 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>
|
||||
15
app/src/main/res/drawable/widget_preview_fleet.xml
Normal file
15
app/src/main/res/drawable/widget_preview_fleet.xml
Normal 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>
|
||||
30
app/src/main/res/drawable/widget_preview_graph.xml
Normal file
30
app/src/main/res/drawable/widget_preview_graph.xml
Normal 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>
|
||||
14
app/src/main/res/drawable/widget_preview_incidents.xml
Normal file
14
app/src/main/res/drawable/widget_preview_incidents.xml
Normal 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>
|
||||
18
app/src/main/res/drawable/widget_preview_metric.xml
Normal file
18
app/src/main/res/drawable/widget_preview_metric.xml
Normal 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>
|
||||
14
app/src/main/res/drawable/widget_preview_server.xml
Normal file
14
app/src/main/res/drawable/widget_preview_server.xml
Normal 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>
|
||||
17
app/src/main/res/drawable/widget_progress_amber.xml
Normal file
17
app/src/main/res/drawable/widget_progress_amber.xml
Normal 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>
|
||||
17
app/src/main/res/drawable/widget_progress_cyan.xml
Normal file
17
app/src/main/res/drawable/widget_progress_cyan.xml
Normal 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>
|
||||
17
app/src/main/res/drawable/widget_progress_green.xml
Normal file
17
app/src/main/res/drawable/widget_progress_green.xml
Normal 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>
|
||||
129
app/src/main/res/layout/widget_fleet.xml
Normal file
129
app/src/main/res/layout/widget_fleet.xml
Normal 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>
|
||||
107
app/src/main/res/layout/widget_graph.xml
Normal file
107
app/src/main/res/layout/widget_graph.xml
Normal 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>
|
||||
145
app/src/main/res/layout/widget_incidents.xml
Normal file
145
app/src/main/res/layout/widget_incidents.xml
Normal 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>
|
||||
70
app/src/main/res/layout/widget_metric.xml
Normal file
70
app/src/main/res/layout/widget_metric.xml
Normal 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>
|
||||
227
app/src/main/res/layout/widget_server.xml
Normal file
227
app/src/main/res/layout/widget_server.xml
Normal 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>
|
||||
@@ -1,3 +1,17 @@
|
||||
<resources>
|
||||
<string name="app_name">NGX Monitor</string>
|
||||
<string name="widget_fleet_label">NGX Fleet Pulse</string>
|
||||
<string name="widget_server_label">NGX Server Bars</string>
|
||||
<string name="widget_metric_label">NGX Metric Sparkline</string>
|
||||
<string name="widget_graph_label">NGX Telemetry Graph</string>
|
||||
<string name="widget_incidents_label">NGX Incident Watch</string>
|
||||
<string name="widget_fleet_description">Visual all-server status strip with online, watch, and down groups.</string>
|
||||
<string name="widget_server_description">One selected server with large health state and CPU, memory, and storage bars.</string>
|
||||
<string name="widget_metric_description">One focused metric with a large value and history sparkline.</string>
|
||||
<string name="widget_graph_description">One selected server with CPU, memory, and storage trend lines.</string>
|
||||
<string name="widget_incidents_description">Fleet incident queue with the highest-priority unreachable or degraded servers.</string>
|
||||
<string name="widget_refresh">Refresh widget</string>
|
||||
<string name="widget_fleet_graph">Fleet status strip</string>
|
||||
<string name="widget_metric_graph">Metric history graph</string>
|
||||
<string name="widget_graph_chart">Telemetry trend graph</string>
|
||||
</resources>
|
||||
|
||||
@@ -15,4 +15,15 @@
|
||||
<item name="android:windowBackground">@drawable/splash_screen</item>
|
||||
<item name="android:windowDisablePreview">false</item>
|
||||
</style>
|
||||
|
||||
<style name="WidgetMetricCell">
|
||||
<item name="android:layout_width">0dp</item>
|
||||
<item name="android:layout_height">match_parent</item>
|
||||
<item name="android:layout_weight">1</item>
|
||||
<item name="android:background">@drawable/widget_panel</item>
|
||||
<item name="android:gravity">center</item>
|
||||
<item name="android:textColor">#FFEAF0F7</item>
|
||||
<item name="android:textSize">12sp</item>
|
||||
<item name="android:textStyle">bold</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
12
app/src/main/res/xml/widget_fleet_info.xml
Normal file
12
app/src/main/res/xml/widget_fleet_info.xml
Normal 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" />
|
||||
13
app/src/main/res/xml/widget_graph_info.xml
Normal file
13
app/src/main/res/xml/widget_graph_info.xml
Normal 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" />
|
||||
12
app/src/main/res/xml/widget_incidents_info.xml
Normal file
12
app/src/main/res/xml/widget_incidents_info.xml
Normal 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" />
|
||||
13
app/src/main/res/xml/widget_metric_info.xml
Normal file
13
app/src/main/res/xml/widget_metric_info.xml
Normal 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" />
|
||||
13
app/src/main/res/xml/widget_server_info.xml
Normal file
13
app/src/main/res/xml/widget_server_info.xml
Normal 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" />
|
||||
Reference in New Issue
Block a user