Compare commits
10 Commits
7b31e14eeb
...
fb863fc666
| Author | SHA1 | Date | |
|---|---|---|---|
| fb863fc666 | |||
| b5f3fefa7e | |||
| b15617c7a2 | |||
| ad05e9da35 | |||
| 33fa8744e1 | |||
| aae9bffb48 | |||
| d1f196e9b6 | |||
| d2a108f605 | |||
| 327a146d21 | |||
| 91f8eda94a |
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: ngx-http-monitoring-client
|
name: ngx-http-monitoring-client
|
||||||
description: Use this skill when connecting clients or agents to an ngx_http_monitoring_module service, querying its JSON/SSE/Prometheus endpoints, debugging access problems, or integrating dashboards/health checks. Supports deployments protected by Nginx Basic Auth and/or monitor_api_token.
|
description: Use this skill when connecting clients or agents to an ngx_http_monitoring_module service, querying its JSON/SSE/Prometheus endpoints, reading API response structures, debugging access problems, or integrating dashboards/health checks. Supports deployments protected by Nginx Basic Auth and/or monitor_api_token.
|
||||||
---
|
---
|
||||||
|
|
||||||
# ngx_http_monitoring_module Client
|
# ngx_http_monitoring_module Client
|
||||||
@@ -42,6 +42,400 @@ MONITOR_BASIC_AUTH=user:password
|
|||||||
- `GET /monitor/metrics` - Prometheus text format
|
- `GET /monitor/metrics` - Prometheus text format
|
||||||
- `GET /monitor/health` - lightweight JSON health check
|
- `GET /monitor/health` - lightweight JSON health check
|
||||||
|
|
||||||
|
## API Structures
|
||||||
|
|
||||||
|
All JSON API responses are compact, versioned, and timestamped. Treat unknown fields as forward-compatible additions, and treat missing sections as disabled collectors, unsupported Nginx build options, or unavailable Linux `/proc` data.
|
||||||
|
|
||||||
|
Common envelope:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"module": "1.0.0",
|
||||||
|
"timestamp": 1710000000,
|
||||||
|
"msec": 123,
|
||||||
|
"scope": "full",
|
||||||
|
"pid": 12345
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Endpoint-specific API routes such as `/monitor/api/system` return the common envelope plus one top-level field matching the route. The examples below show that section's value unless explicitly shown as a complete response.
|
||||||
|
|
||||||
|
`GET /monitor/api` returns the envelope plus:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"system": {},
|
||||||
|
"nginx": {},
|
||||||
|
"network": {},
|
||||||
|
"disk": {},
|
||||||
|
"processes": {},
|
||||||
|
"upstreams": [],
|
||||||
|
"connections": {},
|
||||||
|
"requests": {},
|
||||||
|
"history": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`GET /monitor/api/system`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cpu": {
|
||||||
|
"usage": 12.5,
|
||||||
|
"cores": 8,
|
||||||
|
"load": [0.12, 0.20, 0.30]
|
||||||
|
},
|
||||||
|
"memory": {
|
||||||
|
"total": 16777216,
|
||||||
|
"available": 8388608,
|
||||||
|
"free": 4194304,
|
||||||
|
"used": 8388608,
|
||||||
|
"used_pct": 50.0
|
||||||
|
},
|
||||||
|
"swap": {
|
||||||
|
"total": 2097152,
|
||||||
|
"free": 1048576,
|
||||||
|
"used_pct": 50.0
|
||||||
|
},
|
||||||
|
"uptime": 123456
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`GET /monitor/api/nginx`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"connections": {
|
||||||
|
"accepted": 1000,
|
||||||
|
"handled": 1000,
|
||||||
|
"active": 20,
|
||||||
|
"reading": 1,
|
||||||
|
"writing": 4,
|
||||||
|
"waiting": 15
|
||||||
|
},
|
||||||
|
"requests": {
|
||||||
|
"total": 50000,
|
||||||
|
"responses": 49990,
|
||||||
|
"requests_per_sec": 125.5,
|
||||||
|
"responses_per_sec": 125.3
|
||||||
|
},
|
||||||
|
"ssl": {
|
||||||
|
"requests": 100,
|
||||||
|
"handshakes": 20
|
||||||
|
},
|
||||||
|
"keepalive": {
|
||||||
|
"requests": 250
|
||||||
|
},
|
||||||
|
"workers": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`GET /monitor/api/network`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"rx_bytes": 123456789,
|
||||||
|
"tx_bytes": 987654321,
|
||||||
|
"rx_packets": 10000,
|
||||||
|
"tx_packets": 12000,
|
||||||
|
"rx_errors": 0,
|
||||||
|
"tx_errors": 0,
|
||||||
|
"interfaces": [
|
||||||
|
{
|
||||||
|
"name": "eth0",
|
||||||
|
"rx_bytes": 123456789,
|
||||||
|
"tx_bytes": 987654321,
|
||||||
|
"rx_packets": 10000,
|
||||||
|
"tx_packets": 12000,
|
||||||
|
"rx_errors": 0,
|
||||||
|
"tx_errors": 0,
|
||||||
|
"up": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`GET /monitor/api/disk`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"reads": 1000,
|
||||||
|
"writes": 2000,
|
||||||
|
"read_bytes": 4096000,
|
||||||
|
"write_bytes": 8192000,
|
||||||
|
"devices": [
|
||||||
|
{
|
||||||
|
"name": "sda",
|
||||||
|
"reads": 1000,
|
||||||
|
"writes": 2000,
|
||||||
|
"read_bytes": 4096000,
|
||||||
|
"write_bytes": 8192000,
|
||||||
|
"io_ms": 120
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"filesystems": [
|
||||||
|
{
|
||||||
|
"path": "/",
|
||||||
|
"type": "ext4",
|
||||||
|
"total": 107374182400,
|
||||||
|
"used": 53687091200,
|
||||||
|
"free": 53687091200,
|
||||||
|
"avail": 53687091200,
|
||||||
|
"files": 1000000,
|
||||||
|
"files_free": 750000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`GET /monitor/api/processes`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"process_count": 140,
|
||||||
|
"tcp": {
|
||||||
|
"established": 20,
|
||||||
|
"listen": 8
|
||||||
|
},
|
||||||
|
"sockets": {
|
||||||
|
"used": 512,
|
||||||
|
"tcp": 120,
|
||||||
|
"udp": 16
|
||||||
|
},
|
||||||
|
"workers": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`GET /monitor/api/connections`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"accepted": 1000,
|
||||||
|
"handled": 1000,
|
||||||
|
"active": 20,
|
||||||
|
"reading": 1,
|
||||||
|
"writing": 4,
|
||||||
|
"waiting": 15,
|
||||||
|
"ssl_requests": 100,
|
||||||
|
"ssl_handshakes": 20,
|
||||||
|
"keepalive_requests": 250,
|
||||||
|
"sse_clients": 2,
|
||||||
|
"sse_events": 500,
|
||||||
|
"rate_limited": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`GET /monitor/api/requests`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"total": 50000,
|
||||||
|
"responses": 49990,
|
||||||
|
"bytes": 104857600,
|
||||||
|
"requests_per_sec": 125.5,
|
||||||
|
"responses_per_sec": 125.3,
|
||||||
|
"error_rate": 0.0078,
|
||||||
|
"latency": {
|
||||||
|
"avg": 8.4,
|
||||||
|
"p50": 4,
|
||||||
|
"p90": 15,
|
||||||
|
"p95": 22,
|
||||||
|
"p99": 80
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"1xx": 0,
|
||||||
|
"2xx": 49000,
|
||||||
|
"3xx": 600,
|
||||||
|
"4xx": 300,
|
||||||
|
"5xx": 90
|
||||||
|
},
|
||||||
|
"methods": {
|
||||||
|
"GET": 45000,
|
||||||
|
"POST": 4000,
|
||||||
|
"PUT": 100,
|
||||||
|
"DELETE": 50,
|
||||||
|
"HEAD": 500,
|
||||||
|
"OPTIONS": 200,
|
||||||
|
"PATCH": 50,
|
||||||
|
"OTHER": 100
|
||||||
|
},
|
||||||
|
"latency_histogram": [
|
||||||
|
{
|
||||||
|
"le": 1,
|
||||||
|
"count": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"le": "+Inf",
|
||||||
|
"count": 50000
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"size_histogram": [
|
||||||
|
{
|
||||||
|
"le": 512,
|
||||||
|
"count": 10000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"le": "+Inf",
|
||||||
|
"count": 50000
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"top_urls": [],
|
||||||
|
"user_agents": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`GET /monitor/api/upstreams`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"peer": "127.0.0.1:9000",
|
||||||
|
"requests": 1000,
|
||||||
|
"failures": 2,
|
||||||
|
"status_4xx": 10,
|
||||||
|
"status_5xx": 2,
|
||||||
|
"avg_latency": 12.5,
|
||||||
|
"last_seen": 1710000000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
History samples in `GET /monitor/api`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"timestamp": 1710000000,
|
||||||
|
"cpu": 12.5,
|
||||||
|
"memory": 50.0,
|
||||||
|
"swap": 0.0,
|
||||||
|
"rps": 125.5,
|
||||||
|
"responses_per_sec": 125.3,
|
||||||
|
"network_rx_per_sec": 1024,
|
||||||
|
"network_tx_per_sec": 2048,
|
||||||
|
"disk_read_per_sec": 0,
|
||||||
|
"disk_write_per_sec": 4096,
|
||||||
|
"latency_p95": 22,
|
||||||
|
"requests_total": 50000,
|
||||||
|
"status_4xx": 300,
|
||||||
|
"status_5xx": 90
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Shared nested structures:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"worker": {
|
||||||
|
"slot": 0,
|
||||||
|
"pid": 12345,
|
||||||
|
"active": true,
|
||||||
|
"requests": 10000,
|
||||||
|
"bytes": 10485760,
|
||||||
|
"errors": 3,
|
||||||
|
"vm_size": 102400,
|
||||||
|
"vm_rss": 20480,
|
||||||
|
"voluntary_ctxt": 100,
|
||||||
|
"nonvoluntary_ctxt": 5,
|
||||||
|
"last_seen": 1710000000
|
||||||
|
},
|
||||||
|
"top_url": {
|
||||||
|
"url": "/api/orders",
|
||||||
|
"hits": 1000,
|
||||||
|
"errors": 3,
|
||||||
|
"bytes": 1048576,
|
||||||
|
"avg_latency": 7.8,
|
||||||
|
"last_seen": 1710000000
|
||||||
|
},
|
||||||
|
"user_agent": {
|
||||||
|
"user_agent": "curl/8.0.1",
|
||||||
|
"hits": 100,
|
||||||
|
"errors": 0,
|
||||||
|
"bytes": 20480,
|
||||||
|
"avg_latency": 2.1,
|
||||||
|
"last_seen": 1710000000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
SSE `GET /monitor/live` sends `metrics` events whose `data` payload has this structure:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"timestamp": 1710000000,
|
||||||
|
"sequence": 42,
|
||||||
|
"system": {
|
||||||
|
"cpu": {
|
||||||
|
"usage": 12.5,
|
||||||
|
"cores": 8
|
||||||
|
},
|
||||||
|
"memory": {
|
||||||
|
"used_pct": 50.0,
|
||||||
|
"total": 16777216,
|
||||||
|
"available": 8388608
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"requests": {
|
||||||
|
"total": 50000,
|
||||||
|
"requests_per_sec": 125.5,
|
||||||
|
"responses_per_sec": 125.3,
|
||||||
|
"latency": {
|
||||||
|
"p95": 22,
|
||||||
|
"p99": 80
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"4xx": 300,
|
||||||
|
"5xx": 90
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"connections": {
|
||||||
|
"active": 20,
|
||||||
|
"reading": 1,
|
||||||
|
"writing": 4,
|
||||||
|
"waiting": 15,
|
||||||
|
"sse_clients": 2,
|
||||||
|
"keepalive_requests": 250
|
||||||
|
},
|
||||||
|
"network": {
|
||||||
|
"rx_bytes": 123456789,
|
||||||
|
"tx_bytes": 987654321
|
||||||
|
},
|
||||||
|
"disk": {
|
||||||
|
"read_bytes": 4096000,
|
||||||
|
"write_bytes": 8192000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`GET /monitor/metrics` exposes Prometheus text metrics including:
|
||||||
|
|
||||||
|
```text
|
||||||
|
nginx_monitor_requests_total
|
||||||
|
nginx_monitor_active_connections
|
||||||
|
nginx_monitor_cpu_usage_ratio
|
||||||
|
nginx_monitor_memory_used_ratio
|
||||||
|
nginx_monitor_latency_p95_ms
|
||||||
|
```
|
||||||
|
|
||||||
|
`GET /monitor/health` returns a complete health response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"module": "1.0.0",
|
||||||
|
"timestamp": 1710000000,
|
||||||
|
"msec": 123,
|
||||||
|
"scope": "health",
|
||||||
|
"pid": 12345,
|
||||||
|
"status": "ok",
|
||||||
|
"generation": 100,
|
||||||
|
"sse_clients": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Auth Rules
|
## Auth Rules
|
||||||
|
|
||||||
If the service is behind Nginx Basic Auth, include Basic Auth on every request, including `/monitor`, `/monitor/live`, and `/monitor/metrics`.
|
If the service is behind Nginx Basic Auth, include Basic Auth on every request, including `/monitor`, `/monitor/live`, and `/monitor/metrics`.
|
||||||
|
|||||||
4
.idea/deploymentTargetSelector.xml
generated
4
.idea/deploymentTargetSelector.xml
generated
@@ -4,10 +4,10 @@
|
|||||||
<selectionStates>
|
<selectionStates>
|
||||||
<SelectionState runConfigName="app">
|
<SelectionState runConfigName="app">
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
<option name="selectionMode" value="DROPDOWN" />
|
||||||
<DropdownSelection timestamp="2026-05-08T03:24:05.700330800Z">
|
<DropdownSelection timestamp="2026-05-11T14:50:52.614315100Z">
|
||||||
<Target type="DEFAULT_BOOT">
|
<Target type="DEFAULT_BOOT">
|
||||||
<handle>
|
<handle>
|
||||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=R5CX33ESWGH" />
|
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\meghdad\.android\avd\Pixel_10_Pro.avd" />
|
||||||
</handle>
|
</handle>
|
||||||
</Target>
|
</Target>
|
||||||
</DropdownSelection>
|
</DropdownSelection>
|
||||||
|
|||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Meghdad
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
129
README.md
Normal file
129
README.md
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
# NGX Monitor
|
||||||
|
|
||||||
|
NGX Monitor is an Android client for `ngx_http_monitoring_module`. It is built with Kotlin and Jetpack Compose for monitoring a fleet of local or private Nginx servers from a phone.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Fleet dashboard for multiple servers with live status, tags, favorites, search, and critical-first sorting.
|
||||||
|
- Server detail cockpit with system, Nginx, request, disk, history, alert, and route diagnostics views.
|
||||||
|
- Home-screen widgets for fleet status, server resource bars, metric sparklines, telemetry graphs, and incident watch.
|
||||||
|
- JSON API polling via `/monitor/api` and health checks via `/monitor/health`.
|
||||||
|
- Foreground live streaming via `/monitor/live` Server-Sent Events.
|
||||||
|
- Encrypted per-server credentials for monitor tokens and optional Nginx Basic Auth.
|
||||||
|
- Local SQLite history with 1-minute summaries, 15-minute raw snapshots, and 30-day pruning.
|
||||||
|
- Background periodic checks with local Android notifications.
|
||||||
|
- VPN/DNS resilience with per-server fallback LAN IP aliases while preserving HTTPS hostnames for TLS/SNI.
|
||||||
|
|
||||||
|
## Monitor API
|
||||||
|
|
||||||
|
The app expects each server base URL to expose `/monitor`.
|
||||||
|
|
||||||
|
Common endpoints used by the app:
|
||||||
|
|
||||||
|
- `GET /monitor/api`
|
||||||
|
- `GET /monitor/health`
|
||||||
|
- `GET /monitor/live`
|
||||||
|
|
||||||
|
Authentication rules:
|
||||||
|
|
||||||
|
- Monitor tokens are sent as `X-Monitor-Token`.
|
||||||
|
- Basic Auth is sent on every monitor request when configured.
|
||||||
|
- If both are enabled on the server, configure both in the app.
|
||||||
|
|
||||||
|
## Local Network And VPN Notes
|
||||||
|
|
||||||
|
For private domains, keep the main server URL as the HTTPS domain, for example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://edge-1.home.example
|
||||||
|
```
|
||||||
|
|
||||||
|
If VPN or DNS breaks local resolution, add fallback LAN IPs in the server editor:
|
||||||
|
|
||||||
|
```text
|
||||||
|
192.168.1.20, 192.168.1.21
|
||||||
|
```
|
||||||
|
|
||||||
|
The app keeps the URL hostname unchanged and only substitutes DNS results internally. This keeps HTTPS certificate verification and SNI aligned with the private domain.
|
||||||
|
|
||||||
|
Android or the VPN app may still block LAN access completely. In that case, the app shows the failure in the Route panel but cannot override the VPN policy.
|
||||||
|
|
||||||
|
## Home-Screen Widgets
|
||||||
|
|
||||||
|
The app provides five Android widgets with separate picker previews and labels:
|
||||||
|
|
||||||
|
- **NGX Fleet Pulse**: all-server visual status strip, large fleet health state, and priority servers.
|
||||||
|
- **NGX Server Bars**: one selected server with large status text and CPU, memory, and storage bars.
|
||||||
|
- **NGX Metric Sparkline**: one selected server and one focused signal with a large value and history graph.
|
||||||
|
- **NGX Telemetry Graph**: one selected server with CPU, memory, and storage trend lines.
|
||||||
|
- **NGX Incident Watch**: fleet incident queue for unreachable, blocked, degraded, or insecure servers.
|
||||||
|
|
||||||
|
Widgets update automatically through Android's widget scheduler and after the app's periodic background checks. Each widget also has an icon refresh control that fetches fresh monitor data for that widget's scope.
|
||||||
|
|
||||||
|
To add a widget:
|
||||||
|
|
||||||
|
1. Long-press the Android home screen.
|
||||||
|
2. Choose Widgets.
|
||||||
|
3. Select NGX Monitor.
|
||||||
|
4. For server, metric, and telemetry graph widgets, choose the server during configuration. Metric widgets also ask which signal to graph.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- Prefer HTTPS for monitor endpoints.
|
||||||
|
- Prefer header tokens over query-string tokens.
|
||||||
|
- Avoid sharing screenshots that expose internal hostnames or route details.
|
||||||
|
- Keep monitor endpoints behind least-privilege ACLs.
|
||||||
|
- Do not reuse production admin passwords for Basic Auth.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
From the project root:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:GRADLE_USER_HOME='C:\Users\meghdad\AndroidStudioProjects\NGXhttpMonitoringClient\.gradle-user'
|
||||||
|
.\gradlew.bat :app:assembleDebug --console=plain
|
||||||
|
```
|
||||||
|
|
||||||
|
Debug APK output:
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/build/outputs/apk/debug/app-debug.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
Run unit tests:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:GRADLE_USER_HOME='C:\Users\meghdad\AndroidStudioProjects\NGXhttpMonitoringClient\.gradle-user'
|
||||||
|
.\gradlew.bat :app:testDebugUnitTest --console=plain
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/src/main/java/net/rodakot/ngxhttpmonitoringclient/
|
||||||
|
background/ JobScheduler checks and notifications
|
||||||
|
data/ SQLite storage, encrypted credentials, HTTP/SSE client
|
||||||
|
domain/ URL rules, alert evaluation, route planning, sampling policy
|
||||||
|
model/ Server, metric, alert, and route data models
|
||||||
|
ui/ Compose command-deck UI
|
||||||
|
ui/theme/ Material theme and colors
|
||||||
|
widget/ Android home-screen widgets
|
||||||
|
```
|
||||||
|
|
||||||
|
## App Assets
|
||||||
|
|
||||||
|
- Launcher icon foreground/background: `app/src/main/res/drawable/ic_launcher_foreground.xml`, `ic_launcher_background.xml`
|
||||||
|
- Splash artwork: `app/src/main/res/drawable/splash_hero.xml`
|
||||||
|
- Splash background: `app/src/main/res/drawable/splash_screen.xml`
|
||||||
|
- Widget layouts: `app/src/main/res/layout/widget_fleet.xml`, `widget_server.xml`, `widget_metric.xml`, `widget_graph.xml`, `widget_incidents.xml`
|
||||||
|
|
||||||
|
These assets are vector drawables, so they scale cleanly across screen densities.
|
||||||
|
|
||||||
|
## Google Play Release
|
||||||
|
|
||||||
|
Release configuration, signing instructions, Play Console metadata, and privacy/data-safety drafts live in:
|
||||||
|
|
||||||
|
- `docs/PLAY_STORE_RELEASE.md`
|
||||||
|
- `docs/PLAY_STORE_DATA_SAFETY.md`
|
||||||
|
- `docs/PRIVACY_POLICY.md`
|
||||||
|
- `fastlane/metadata/android/en-US/`
|
||||||
@@ -1,8 +1,32 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.compose)
|
alias(libs.plugins.kotlin.compose)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val localProperties = Properties().apply {
|
||||||
|
val file = rootProject.file("local.properties")
|
||||||
|
if (file.isFile) {
|
||||||
|
file.inputStream().use { load(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun releaseSecret(name: String): String? {
|
||||||
|
return providers.environmentVariable(name).orNull ?: localProperties.getProperty(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
val releaseStoreFile = releaseSecret("NGX_RELEASE_STORE_FILE")
|
||||||
|
val releaseStorePassword = releaseSecret("NGX_RELEASE_STORE_PASSWORD")
|
||||||
|
val releaseKeyAlias = releaseSecret("NGX_RELEASE_KEY_ALIAS")
|
||||||
|
val releaseKeyPassword = releaseSecret("NGX_RELEASE_KEY_PASSWORD")
|
||||||
|
val hasReleaseSigning = listOf(
|
||||||
|
releaseStoreFile,
|
||||||
|
releaseStorePassword,
|
||||||
|
releaseKeyAlias,
|
||||||
|
releaseKeyPassword,
|
||||||
|
).all { !it.isNullOrBlank() }
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "net.rodakot.ngxhttpmonitoringclient"
|
namespace = "net.rodakot.ngxhttpmonitoringclient"
|
||||||
compileSdk {
|
compileSdk {
|
||||||
@@ -15,15 +39,30 @@ android {
|
|||||||
applicationId = "net.rodakot.ngxhttpmonitoringclient"
|
applicationId = "net.rodakot.ngxhttpmonitoringclient"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 1
|
versionCode = (providers.gradleProperty("VERSION_CODE").orNull ?: "1").toInt()
|
||||||
versionName = "1.0"
|
versionName = providers.gradleProperty("VERSION_NAME").orNull ?: "1.0.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
if (hasReleaseSigning) {
|
||||||
|
create("release") {
|
||||||
|
storeFile = file(releaseStoreFile!!)
|
||||||
|
storePassword = releaseStorePassword
|
||||||
|
keyAlias = releaseKeyAlias
|
||||||
|
keyPassword = releaseKeyPassword
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = true
|
||||||
|
isShrinkResources = true
|
||||||
|
if (hasReleaseSigning) {
|
||||||
|
signingConfig = signingConfigs.getByName("release")
|
||||||
|
}
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro"
|
"proguard-rules.pro"
|
||||||
@@ -56,6 +95,7 @@ dependencies {
|
|||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
implementation(libs.androidx.lifecycle.viewmodel.ktx)
|
implementation(libs.androidx.lifecycle.viewmodel.ktx)
|
||||||
implementation(libs.kotlinx.coroutines.android)
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
|
implementation(libs.okhttp)
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||||
|
|||||||
@@ -8,20 +8,21 @@
|
|||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".NgxMonitorApplication"
|
android:name=".NgxMonitorApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="false"
|
||||||
|
android:appCategory="productivity"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="false"
|
||||||
android:theme="@style/Theme.NGXHttpMonitoringClient"
|
android:theme="@style/Theme.NGXHttpMonitoringClient"
|
||||||
android:usesCleartextTraffic="true">
|
android:usesCleartextTraffic="true">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:theme="@style/Theme.NGXHttpMonitoringClient">
|
android:theme="@style/Theme.NGXHttpMonitoringClient.Launcher">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
@@ -32,6 +33,79 @@
|
|||||||
android:name=".background.MonitorJobService"
|
android:name=".background.MonitorJobService"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:permission="android.permission.BIND_JOB_SERVICE" />
|
android:permission="android.permission.BIND_JOB_SERVICE" />
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".widget.ServerWidgetConfigureActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:theme="@style/Theme.NGXHttpMonitoringClient" />
|
||||||
|
<activity
|
||||||
|
android:name=".widget.MetricWidgetConfigureActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:theme="@style/Theme.NGXHttpMonitoringClient" />
|
||||||
|
<activity
|
||||||
|
android:name=".widget.GraphWidgetConfigureActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:theme="@style/Theme.NGXHttpMonitoringClient" />
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".widget.FleetWidgetProvider"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="@string/widget_fleet_label">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.appwidget.provider"
|
||||||
|
android:resource="@xml/widget_fleet_info" />
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".widget.ServerWidgetProvider"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="@string/widget_server_label">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.appwidget.provider"
|
||||||
|
android:resource="@xml/widget_server_info" />
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".widget.MetricWidgetProvider"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="@string/widget_metric_label">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.appwidget.provider"
|
||||||
|
android:resource="@xml/widget_metric_info" />
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".widget.GraphWidgetProvider"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="@string/widget_graph_label">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.appwidget.provider"
|
||||||
|
android:resource="@xml/widget_graph_info" />
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".widget.IncidentsWidgetProvider"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="@string/widget_incidents_label">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.appwidget.provider"
|
||||||
|
android:resource="@xml/widget_incidents_info" />
|
||||||
|
</receiver>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -4,11 +4,15 @@ import android.Manifest
|
|||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.view.View
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
import androidx.core.app.ActivityCompat
|
import androidx.core.app.ActivityCompat
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import net.rodakot.ngxhttpmonitoringclient.ui.MonitorApp
|
import net.rodakot.ngxhttpmonitoringclient.ui.MonitorApp
|
||||||
@@ -16,7 +20,9 @@ import net.rodakot.ngxhttpmonitoringclient.ui.theme.NGXHttpMonitoringClientTheme
|
|||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
setTheme(R.style.Theme_NGXHttpMonitoringClient)
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
window.decorView.layoutDirection = View.LAYOUT_DIRECTION_LTR
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
requestNotificationPermission()
|
requestNotificationPermission()
|
||||||
setContent {
|
setContent {
|
||||||
@@ -25,7 +31,9 @@ class MainActivity : ComponentActivity() {
|
|||||||
onDispose { controller.close() }
|
onDispose { controller.close() }
|
||||||
}
|
}
|
||||||
NGXHttpMonitoringClientTheme {
|
NGXHttpMonitoringClientTheme {
|
||||||
MonitorApp(controller)
|
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
|
||||||
|
MonitorApp(controller)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,12 +41,14 @@ class MonitorController(context: Context) {
|
|||||||
val summaries = repository.latestSummaries(servers.map { it.id })
|
val summaries = repository.latestSummaries(servers.map { it.id })
|
||||||
val history = selected?.let { mapOf(it to repository.history(it)) }.orEmpty()
|
val history = selected?.let { mapOf(it to repository.history(it)) }.orEmpty()
|
||||||
val alerts = repository.alerts()
|
val alerts = repository.alerts()
|
||||||
|
val routeDiagnostics = repository.routeDiagnostics()
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
servers = servers,
|
servers = servers,
|
||||||
selectedServerId = selected,
|
selectedServerId = selected,
|
||||||
summaries = summaries,
|
summaries = summaries,
|
||||||
history = history,
|
history = history,
|
||||||
|
routeDiagnostics = routeDiagnostics,
|
||||||
alerts = alerts,
|
alerts = alerts,
|
||||||
globalMessage = null,
|
globalMessage = null,
|
||||||
)
|
)
|
||||||
@@ -140,10 +142,12 @@ class MonitorController(context: Context) {
|
|||||||
val servers = repository.servers()
|
val servers = repository.servers()
|
||||||
val summaries = repository.latestSummaries(servers.map { it.id })
|
val summaries = repository.latestSummaries(servers.map { it.id })
|
||||||
val history = repository.history(server.id)
|
val history = repository.history(server.id)
|
||||||
|
val routeDiagnostics = repository.routeDiagnostics()
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
servers = servers,
|
servers = servers,
|
||||||
summaries = summaries,
|
summaries = summaries,
|
||||||
|
routeDiagnostics = routeDiagnostics,
|
||||||
selectedServerId = server.id,
|
selectedServerId = server.id,
|
||||||
showDetail = true,
|
showDetail = true,
|
||||||
selectedTab = DetailTab.Overview,
|
selectedTab = DetailTab.Overview,
|
||||||
@@ -191,6 +195,7 @@ class MonitorController(context: Context) {
|
|||||||
selectedTab = DetailTab.Overview,
|
selectedTab = DetailTab.Overview,
|
||||||
summaries = repository.latestSummaries(servers.map { item -> item.id }),
|
summaries = repository.latestSummaries(servers.map { item -> item.id }),
|
||||||
history = selected?.let { id -> mapOf(id to repository.history(id)) }.orEmpty(),
|
history = selected?.let { id -> mapOf(id to repository.history(id)) }.orEmpty(),
|
||||||
|
routeDiagnostics = repository.routeDiagnostics(),
|
||||||
alerts = repository.alerts(),
|
alerts = repository.alerts(),
|
||||||
globalMessage = "Server deleted",
|
globalMessage = "Server deleted",
|
||||||
)
|
)
|
||||||
@@ -233,10 +238,12 @@ class MonitorController(context: Context) {
|
|||||||
val selected = _state.value.selectedServerId
|
val selected = _state.value.selectedServerId
|
||||||
val history = selected?.let { repository.history(it) }
|
val history = selected?.let { repository.history(it) }
|
||||||
val alerts = repository.alerts()
|
val alerts = repository.alerts()
|
||||||
|
val routeDiagnostics = repository.routeDiagnostics()
|
||||||
withContext(Dispatchers.Main.immediate) {
|
withContext(Dispatchers.Main.immediate) {
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
alerts = alerts,
|
alerts = alerts,
|
||||||
|
routeDiagnostics = routeDiagnostics,
|
||||||
history = if (selected != null && history != null) it.history + (selected to history) else it.history,
|
history = if (selected != null && history != null) it.history + (selected to history) else it.history,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import kotlinx.coroutines.SupervisorJob
|
|||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import net.rodakot.ngxhttpmonitoringclient.data.MonitorRepository
|
import net.rodakot.ngxhttpmonitoringclient.data.MonitorRepository
|
||||||
|
import net.rodakot.ngxhttpmonitoringclient.widget.MonitorWidgetUpdater
|
||||||
|
|
||||||
class MonitorJobService : JobService() {
|
class MonitorJobService : JobService() {
|
||||||
private var scope: CoroutineScope? = null
|
private var scope: CoroutineScope? = null
|
||||||
@@ -37,5 +38,6 @@ class MonitorJobService : JobService() {
|
|||||||
MonitorNotifications.showAlert(applicationContext, server, alert)
|
MonitorNotifications.showAlert(applicationContext, server, alert)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
MonitorWidgetUpdater.updateAll(applicationContext, refreshNetwork = false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,12 +10,16 @@ import net.rodakot.ngxhttpmonitoringclient.model.AlertOverrides
|
|||||||
import net.rodakot.ngxhttpmonitoringclient.model.AlertSeverity
|
import net.rodakot.ngxhttpmonitoringclient.model.AlertSeverity
|
||||||
import net.rodakot.ngxhttpmonitoringclient.model.HistoryRetentionDays
|
import net.rodakot.ngxhttpmonitoringclient.model.HistoryRetentionDays
|
||||||
import net.rodakot.ngxhttpmonitoringclient.model.MetricSummary
|
import net.rodakot.ngxhttpmonitoringclient.model.MetricSummary
|
||||||
|
import net.rodakot.ngxhttpmonitoringclient.model.NetworkIssue
|
||||||
|
import net.rodakot.ngxhttpmonitoringclient.model.RouteDiagnostics
|
||||||
|
import net.rodakot.ngxhttpmonitoringclient.model.RouteKind
|
||||||
|
import net.rodakot.ngxhttpmonitoringclient.model.RoutePolicy
|
||||||
import net.rodakot.ngxhttpmonitoringclient.model.ServerProfile
|
import net.rodakot.ngxhttpmonitoringclient.model.ServerProfile
|
||||||
import net.rodakot.ngxhttpmonitoringclient.model.ServerStatus
|
import net.rodakot.ngxhttpmonitoringclient.model.ServerStatus
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
class MonitorDatabase(context: Context) : SQLiteOpenHelper(context, "ngx_monitor.db", null, 1) {
|
class MonitorDatabase(context: Context) : SQLiteOpenHelper(context, "ngx_monitor.db", null, 2) {
|
||||||
override fun onCreate(db: SQLiteDatabase) {
|
override fun onCreate(db: SQLiteDatabase) {
|
||||||
db.execSQL(
|
db.execSQL(
|
||||||
"""
|
"""
|
||||||
@@ -23,6 +27,8 @@ class MonitorDatabase(context: Context) : SQLiteOpenHelper(context, "ngx_monitor
|
|||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
base_url TEXT NOT NULL,
|
base_url TEXT NOT NULL,
|
||||||
|
fallback_ips_json TEXT NOT NULL,
|
||||||
|
route_policy TEXT NOT NULL,
|
||||||
tags_json TEXT NOT NULL,
|
tags_json TEXT NOT NULL,
|
||||||
favorite INTEGER NOT NULL,
|
favorite INTEGER NOT NULL,
|
||||||
allow_http INTEGER NOT NULL,
|
allow_http INTEGER NOT NULL,
|
||||||
@@ -74,9 +80,16 @@ class MonitorDatabase(context: Context) : SQLiteOpenHelper(context, "ngx_monitor
|
|||||||
""".trimIndent(),
|
""".trimIndent(),
|
||||||
)
|
)
|
||||||
db.execSQL("CREATE INDEX alerts_server_time ON alerts(server_id, timestamp_millis DESC)")
|
db.execSQL("CREATE INDEX alerts_server_time ON alerts(server_id, timestamp_millis DESC)")
|
||||||
|
createRouteDiagnosticsTable(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = Unit
|
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||||
|
if (oldVersion < 2) {
|
||||||
|
db.execSQL("ALTER TABLE servers ADD COLUMN fallback_ips_json TEXT NOT NULL DEFAULT '[]'")
|
||||||
|
db.execSQL("ALTER TABLE servers ADD COLUMN route_policy TEXT NOT NULL DEFAULT '${RoutePolicy.AutoFallback.name}'")
|
||||||
|
createRouteDiagnosticsTable(db)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun listServers(): List<ServerProfile> {
|
fun listServers(): List<ServerProfile> {
|
||||||
@@ -97,6 +110,7 @@ class MonitorDatabase(context: Context) : SQLiteOpenHelper(context, "ngx_monitor
|
|||||||
writableDatabase.delete("servers", "id = ?", arrayOf(serverId))
|
writableDatabase.delete("servers", "id = ?", arrayOf(serverId))
|
||||||
writableDatabase.delete("samples", "server_id = ?", arrayOf(serverId))
|
writableDatabase.delete("samples", "server_id = ?", arrayOf(serverId))
|
||||||
writableDatabase.delete("alerts", "server_id = ?", arrayOf(serverId))
|
writableDatabase.delete("alerts", "server_id = ?", arrayOf(serverId))
|
||||||
|
writableDatabase.delete("route_diagnostics", "server_id = ?", arrayOf(serverId))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
@@ -190,6 +204,28 @@ class MonitorDatabase(context: Context) : SQLiteOpenHelper(context, "ngx_monitor
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun upsertRouteDiagnostics(diagnostics: RouteDiagnostics) {
|
||||||
|
writableDatabase.insertWithOnConflict(
|
||||||
|
"route_diagnostics",
|
||||||
|
null,
|
||||||
|
diagnostics.toValues(),
|
||||||
|
SQLiteDatabase.CONFLICT_REPLACE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun routeDiagnostics(): Map<String, RouteDiagnostics> {
|
||||||
|
val result = linkedMapOf<String, RouteDiagnostics>()
|
||||||
|
readableDatabase.query("route_diagnostics", null, null, null, null, null, "timestamp_millis DESC").use { cursor ->
|
||||||
|
while (cursor.moveToNext()) {
|
||||||
|
val diagnostics = cursor.toRouteDiagnostics()
|
||||||
|
result[diagnostics.serverId] = diagnostics
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun pruneOldSamples(nowMillis: Long = System.currentTimeMillis()) {
|
fun pruneOldSamples(nowMillis: Long = System.currentTimeMillis()) {
|
||||||
val cutoff = nowMillis - TimeUnit.DAYS.toMillis(HistoryRetentionDays.toLong())
|
val cutoff = nowMillis - TimeUnit.DAYS.toMillis(HistoryRetentionDays.toLong())
|
||||||
@@ -200,6 +236,8 @@ class MonitorDatabase(context: Context) : SQLiteOpenHelper(context, "ngx_monitor
|
|||||||
put("id", id)
|
put("id", id)
|
||||||
put("name", name)
|
put("name", name)
|
||||||
put("base_url", baseUrl)
|
put("base_url", baseUrl)
|
||||||
|
put("fallback_ips_json", listToJson(fallbackIpAddresses))
|
||||||
|
put("route_policy", routePolicy.name)
|
||||||
put("tags_json", tagsToJson(tags))
|
put("tags_json", tagsToJson(tags))
|
||||||
put("favorite", favorite.asInt())
|
put("favorite", favorite.asInt())
|
||||||
put("allow_http", allowHttp.asInt())
|
put("allow_http", allowHttp.asInt())
|
||||||
@@ -241,11 +279,25 @@ class MonitorDatabase(context: Context) : SQLiteOpenHelper(context, "ngx_monitor
|
|||||||
put("resolved", resolved.asInt())
|
put("resolved", resolved.asInt())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun RouteDiagnostics.toValues() = ContentValues().apply {
|
||||||
|
put("server_id", serverId)
|
||||||
|
put("timestamp_millis", timestampMillis)
|
||||||
|
put("active_network", activeNetwork)
|
||||||
|
put("route_used", routeUsed.name)
|
||||||
|
put("dns_result", dnsResult)
|
||||||
|
put("issue", issue.name)
|
||||||
|
put("vpn_active", vpnActive.asInt())
|
||||||
|
put("fallback_used", fallbackUsed.asInt())
|
||||||
|
put("success", success.asInt())
|
||||||
|
}
|
||||||
|
|
||||||
private fun Cursor.toServer(): ServerProfile {
|
private fun Cursor.toServer(): ServerProfile {
|
||||||
return ServerProfile(
|
return ServerProfile(
|
||||||
id = string("id"),
|
id = string("id"),
|
||||||
name = string("name"),
|
name = string("name"),
|
||||||
baseUrl = string("base_url"),
|
baseUrl = string("base_url"),
|
||||||
|
fallbackIpAddresses = listFromJson(string("fallback_ips_json")),
|
||||||
|
routePolicy = runCatching { RoutePolicy.valueOf(string("route_policy")) }.getOrDefault(RoutePolicy.AutoFallback),
|
||||||
tags = tagsFromJson(string("tags_json")),
|
tags = tagsFromJson(string("tags_json")),
|
||||||
favorite = int("favorite") == 1,
|
favorite = int("favorite") == 1,
|
||||||
allowHttp = int("allow_http") == 1,
|
allowHttp = int("allow_http") == 1,
|
||||||
@@ -294,17 +346,55 @@ class MonitorDatabase(context: Context) : SQLiteOpenHelper(context, "ngx_monitor
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun Cursor.toRouteDiagnostics(): RouteDiagnostics {
|
||||||
|
return RouteDiagnostics(
|
||||||
|
serverId = string("server_id"),
|
||||||
|
timestampMillis = long("timestamp_millis"),
|
||||||
|
activeNetwork = string("active_network"),
|
||||||
|
routeUsed = runCatching { RouteKind.valueOf(string("route_used")) }.getOrDefault(RouteKind.SystemDns),
|
||||||
|
dnsResult = string("dns_result"),
|
||||||
|
issue = runCatching { NetworkIssue.valueOf(string("issue")) }.getOrDefault(NetworkIssue.Unknown),
|
||||||
|
vpnActive = int("vpn_active") == 1,
|
||||||
|
fallbackUsed = int("fallback_used") == 1,
|
||||||
|
success = int("success") == 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun tagsToJson(tags: List<String>): String {
|
private fun tagsToJson(tags: List<String>): String {
|
||||||
|
return listToJson(tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun listToJson(values: List<String>): String {
|
||||||
val array = JSONArray()
|
val array = JSONArray()
|
||||||
tags.map { it.trim() }.filter { it.isNotBlank() }.distinct().forEach(array::put)
|
values.map { it.trim() }.filter { it.isNotBlank() }.distinct().forEach(array::put)
|
||||||
return array.toString()
|
return array.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun tagsFromJson(value: String): List<String> = runCatching {
|
private fun tagsFromJson(value: String): List<String> = listFromJson(value)
|
||||||
|
|
||||||
|
private fun listFromJson(value: String): List<String> = runCatching {
|
||||||
val array = JSONArray(value)
|
val array = JSONArray(value)
|
||||||
List(array.length()) { index -> array.optString(index) }.filter { it.isNotBlank() }
|
List(array.length()) { index -> array.optString(index) }.filter { it.isNotBlank() }
|
||||||
}.getOrDefault(emptyList())
|
}.getOrDefault(emptyList())
|
||||||
|
|
||||||
|
private fun createRouteDiagnosticsTable(db: SQLiteDatabase) {
|
||||||
|
db.execSQL(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS route_diagnostics (
|
||||||
|
server_id TEXT PRIMARY KEY,
|
||||||
|
timestamp_millis INTEGER NOT NULL,
|
||||||
|
active_network TEXT NOT NULL,
|
||||||
|
route_used TEXT NOT NULL,
|
||||||
|
dns_result TEXT NOT NULL,
|
||||||
|
issue TEXT NOT NULL,
|
||||||
|
vpn_active INTEGER NOT NULL,
|
||||||
|
fallback_used INTEGER NOT NULL,
|
||||||
|
success INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun Boolean.asInt() = if (this) 1 else 0
|
private fun Boolean.asInt() = if (this) 1 else 0
|
||||||
|
|
||||||
private fun ContentValues.putNullable(key: String, value: String?) = if (value == null) putNull(key) else put(key, value)
|
private fun ContentValues.putNullable(key: String, value: String?) = if (value == null) putNull(key) else put(key, value)
|
||||||
|
|||||||
@@ -1,35 +1,70 @@
|
|||||||
package net.rodakot.ngxhttpmonitoringclient.data
|
package net.rodakot.ngxhttpmonitoringclient.data
|
||||||
|
|
||||||
import android.util.Base64
|
import android.content.Context
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.Network
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
import net.rodakot.ngxhttpmonitoringclient.domain.RouteAttemptPlanner
|
||||||
import net.rodakot.ngxhttpmonitoringclient.domain.UrlRules
|
import net.rodakot.ngxhttpmonitoringclient.domain.UrlRules
|
||||||
import net.rodakot.ngxhttpmonitoringclient.model.AuthConfig
|
import net.rodakot.ngxhttpmonitoringclient.model.AuthConfig
|
||||||
|
import net.rodakot.ngxhttpmonitoringclient.model.NetworkIssue
|
||||||
|
import net.rodakot.ngxhttpmonitoringclient.model.RouteDiagnostics
|
||||||
|
import net.rodakot.ngxhttpmonitoringclient.model.RouteKind
|
||||||
import net.rodakot.ngxhttpmonitoringclient.model.ServerProfile
|
import net.rodakot.ngxhttpmonitoringclient.model.ServerProfile
|
||||||
import net.rodakot.ngxhttpmonitoringclient.model.ServerStatus
|
import net.rodakot.ngxhttpmonitoringclient.model.ServerStatus
|
||||||
|
import okhttp3.Credentials
|
||||||
|
import okhttp3.Dns
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
import java.io.BufferedReader
|
import java.io.BufferedReader
|
||||||
|
import java.io.InterruptedIOException
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.InputStreamReader
|
import java.io.InputStreamReader
|
||||||
import java.net.HttpURLConnection
|
import java.net.ConnectException
|
||||||
import java.net.URL
|
import java.net.InetAddress
|
||||||
|
import java.net.NoRouteToHostException
|
||||||
|
import java.net.SocketTimeoutException
|
||||||
|
import java.net.UnknownHostException
|
||||||
import java.nio.charset.StandardCharsets
|
import java.nio.charset.StandardCharsets
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import javax.net.ssl.SSLException
|
||||||
|
|
||||||
class MonitorHttpClient {
|
class MonitorHttpClient(context: Context) {
|
||||||
fun fetchApi(server: ServerProfile, auth: AuthConfig): String {
|
private val connectivityManager = context.getSystemService(ConnectivityManager::class.java)
|
||||||
return fetchText(UrlRules.endpoint(server.baseUrl, "/monitor/api"), auth)
|
private val baseClient = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(8, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.callTimeout(20, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
fun fetchApi(server: ServerProfile, auth: AuthConfig): RouteResponse {
|
||||||
|
return fetchText(server, auth, UrlRules.endpoint(server.baseUrl, "/monitor/api"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun fetchHealth(server: ServerProfile, auth: AuthConfig): String {
|
fun fetchHealth(server: ServerProfile, auth: AuthConfig): RouteResponse {
|
||||||
return fetchText(UrlRules.endpoint(server.baseUrl, "/monitor/health"), auth)
|
return fetchText(server, auth, UrlRules.endpoint(server.baseUrl, "/monitor/health"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun streamLive(server: ServerProfile, auth: AuthConfig, onMetrics: (String) -> Unit) {
|
fun streamLive(
|
||||||
val connection = openConnection(UrlRules.endpoint(server.baseUrl, "/monitor/live"), auth).apply {
|
server: ServerProfile,
|
||||||
readTimeout = 0
|
auth: AuthConfig,
|
||||||
setRequestProperty("Accept", "text/event-stream")
|
onDiagnostics: (RouteDiagnostics) -> Unit,
|
||||||
}
|
onMetrics: (String) -> Unit,
|
||||||
try {
|
) {
|
||||||
val status = connection.responseCode
|
val routed = openRoutedResponse(
|
||||||
if (status !in 200..299) throw MonitorClientException(status, status.toMonitorStatus(), status.messageForStatus())
|
server = server,
|
||||||
BufferedReader(InputStreamReader(connection.inputStream, StandardCharsets.UTF_8)).use { reader ->
|
auth = auth,
|
||||||
|
url = UrlRules.endpoint(server.baseUrl, "/monitor/live"),
|
||||||
|
accept = "text/event-stream",
|
||||||
|
streaming = true,
|
||||||
|
)
|
||||||
|
onDiagnostics(routed.diagnostics)
|
||||||
|
routed.response.use { response ->
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
throw response.toException(routed.diagnostics)
|
||||||
|
}
|
||||||
|
BufferedReader(InputStreamReader(response.body!!.byteStream(), StandardCharsets.UTF_8)).use { reader ->
|
||||||
var eventName = "message"
|
var eventName = "message"
|
||||||
val data = StringBuilder()
|
val data = StringBuilder()
|
||||||
while (!Thread.currentThread().isInterrupted) {
|
while (!Thread.currentThread().isInterrupted) {
|
||||||
@@ -47,41 +82,172 @@ class MonitorHttpClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
connection.disconnect()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun fetchText(url: String, auth: AuthConfig): String {
|
private fun fetchText(server: ServerProfile, auth: AuthConfig, url: String): RouteResponse {
|
||||||
val connection = openConnection(url, auth)
|
val routed = openRoutedResponse(server, auth, url, accept = "application/json", streaming = false)
|
||||||
return try {
|
routed.response.use { response ->
|
||||||
val status = connection.responseCode
|
if (!response.isSuccessful) {
|
||||||
if (status !in 200..299) throw MonitorClientException(status, status.toMonitorStatus(), status.messageForStatus())
|
throw response.toException(routed.diagnostics)
|
||||||
connection.inputStream.bufferedReader(StandardCharsets.UTF_8).use { it.readText() }
|
}
|
||||||
} catch (exception: IOException) {
|
val body = response.body?.string().orEmpty()
|
||||||
throw MonitorClientException(null, ServerStatus.Offline, exception.message ?: "Network error", exception)
|
return RouteResponse(body, routed.diagnostics)
|
||||||
} finally {
|
|
||||||
connection.disconnect()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun openConnection(url: String, auth: AuthConfig): HttpURLConnection {
|
private fun openRoutedResponse(
|
||||||
val connection = URL(url).openConnection() as HttpURLConnection
|
server: ServerProfile,
|
||||||
connection.connectTimeout = 8_000
|
auth: AuthConfig,
|
||||||
connection.readTimeout = 15_000
|
url: String,
|
||||||
connection.requestMethod = "GET"
|
accept: String,
|
||||||
connection.setRequestProperty("Accept", "application/json")
|
streaming: Boolean,
|
||||||
|
): RoutedResponse {
|
||||||
|
val state = networkState()
|
||||||
|
val attempts = RouteAttemptPlanner.attempts(
|
||||||
|
hasFallbackAliases = server.fallbackIpAddresses.isNotEmpty(),
|
||||||
|
vpnActive = state.vpnActive,
|
||||||
|
lanAvailable = state.lanNetwork != null,
|
||||||
|
)
|
||||||
|
var lastDiagnostics: RouteDiagnostics? = null
|
||||||
|
var lastException: IOException? = null
|
||||||
|
|
||||||
|
attempts.forEach { kind ->
|
||||||
|
val diagnostics = diagnosticsFor(server, kind, state)
|
||||||
|
try {
|
||||||
|
val response = clientFor(kind, state, server, streaming)
|
||||||
|
.newCall(request(url, auth, accept))
|
||||||
|
.execute()
|
||||||
|
return RoutedResponse(response, diagnostics.copy(success = true, issue = NetworkIssue.None))
|
||||||
|
} catch (exception: IOException) {
|
||||||
|
lastDiagnostics = diagnostics.copy(success = false, issue = classify(exception, kind))
|
||||||
|
lastException = exception
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val diagnostics = lastDiagnostics ?: RouteDiagnostics(
|
||||||
|
serverId = server.id,
|
||||||
|
timestampMillis = System.currentTimeMillis(),
|
||||||
|
activeNetwork = state.activeNetworkLabel,
|
||||||
|
vpnActive = state.vpnActive,
|
||||||
|
issue = NetworkIssue.Unknown,
|
||||||
|
)
|
||||||
|
throw MonitorClientException(
|
||||||
|
httpStatus = null,
|
||||||
|
monitorStatus = ServerStatus.Offline,
|
||||||
|
message = diagnostics.issue.label,
|
||||||
|
diagnostics = diagnostics,
|
||||||
|
cause = lastException,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clientFor(
|
||||||
|
kind: RouteKind,
|
||||||
|
state: NetworkState,
|
||||||
|
server: ServerProfile,
|
||||||
|
streaming: Boolean,
|
||||||
|
): OkHttpClient {
|
||||||
|
val builder = baseClient.newBuilder()
|
||||||
|
.readTimeout(if (streaming) 0 else 15, TimeUnit.SECONDS)
|
||||||
|
.callTimeout(if (streaming) 0 else 20, TimeUnit.SECONDS)
|
||||||
|
|
||||||
|
val lanNetwork = state.lanNetwork
|
||||||
|
val dnsDelegate = when {
|
||||||
|
kind == RouteKind.LanSystemDns || kind == RouteKind.LanFallbackDns -> lanNetwork?.let(::NetworkDns) ?: Dns.SYSTEM
|
||||||
|
else -> Dns.SYSTEM
|
||||||
|
}
|
||||||
|
if (kind == RouteKind.LanSystemDns || kind == RouteKind.LanFallbackDns) {
|
||||||
|
lanNetwork?.let { builder.socketFactory(it.socketFactory) }
|
||||||
|
}
|
||||||
|
if (kind == RouteKind.FallbackDns || kind == RouteKind.LanFallbackDns) {
|
||||||
|
builder.dns(FallbackDns(server.hostName(), server.fallbackIpAddresses, dnsDelegate))
|
||||||
|
} else {
|
||||||
|
builder.dns(dnsDelegate)
|
||||||
|
}
|
||||||
|
return builder.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun request(url: String, auth: AuthConfig, accept: String): Request {
|
||||||
|
val builder = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.get()
|
||||||
|
.header("Accept", accept)
|
||||||
auth.token?.takeIf { it.isNotBlank() }?.let {
|
auth.token?.takeIf { it.isNotBlank() }?.let {
|
||||||
connection.setRequestProperty("X-Monitor-Token", it)
|
builder.header("X-Monitor-Token", it)
|
||||||
}
|
}
|
||||||
if (!auth.basicUsername.isNullOrBlank() && !auth.basicPassword.isNullOrBlank()) {
|
if (!auth.basicUsername.isNullOrBlank() && !auth.basicPassword.isNullOrBlank()) {
|
||||||
val encoded = Base64.encodeToString(
|
builder.header("Authorization", Credentials.basic(auth.basicUsername, auth.basicPassword))
|
||||||
"${auth.basicUsername}:${auth.basicPassword}".toByteArray(StandardCharsets.UTF_8),
|
|
||||||
Base64.NO_WRAP,
|
|
||||||
)
|
|
||||||
connection.setRequestProperty("Authorization", "Basic $encoded")
|
|
||||||
}
|
}
|
||||||
return connection
|
return builder.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun diagnosticsFor(server: ServerProfile, kind: RouteKind, state: NetworkState): RouteDiagnostics {
|
||||||
|
val fallbackUsed = kind == RouteKind.FallbackDns || kind == RouteKind.LanFallbackDns
|
||||||
|
val dnsResult = when (kind) {
|
||||||
|
RouteKind.SystemDns -> "System DNS"
|
||||||
|
RouteKind.FallbackDns -> server.fallbackIpAddresses.joinToString(", ")
|
||||||
|
RouteKind.LanSystemDns -> "LAN DNS"
|
||||||
|
RouteKind.LanFallbackDns -> server.fallbackIpAddresses.joinToString(", ")
|
||||||
|
}
|
||||||
|
return RouteDiagnostics(
|
||||||
|
serverId = server.id,
|
||||||
|
timestampMillis = System.currentTimeMillis(),
|
||||||
|
activeNetwork = state.activeNetworkLabel,
|
||||||
|
routeUsed = kind,
|
||||||
|
dnsResult = dnsResult.ifBlank { "None" },
|
||||||
|
issue = if (state.vpnActive) NetworkIssue.VpnActive else NetworkIssue.None,
|
||||||
|
vpnActive = state.vpnActive,
|
||||||
|
fallbackUsed = fallbackUsed,
|
||||||
|
success = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun networkState(): NetworkState {
|
||||||
|
val networks = connectivityManager?.allNetworks.orEmpty()
|
||||||
|
val activeLabel = connectivityManager?.activeNetwork
|
||||||
|
?.let { network -> connectivityManager.getNetworkCapabilities(network)?.transportLabel() }
|
||||||
|
?: "No active network"
|
||||||
|
val vpnActive = networks.any { network ->
|
||||||
|
connectivityManager?.getNetworkCapabilities(network)?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true
|
||||||
|
}
|
||||||
|
val lanNetwork = networks.firstOrNull { network ->
|
||||||
|
val capabilities = connectivityManager?.getNetworkCapabilities(network)
|
||||||
|
capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true ||
|
||||||
|
capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) == true
|
||||||
|
}
|
||||||
|
return NetworkState(activeLabel, vpnActive, lanNetwork)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun classify(exception: IOException, kind: RouteKind): NetworkIssue {
|
||||||
|
return when (exception) {
|
||||||
|
is UnknownHostException -> NetworkIssue.DnsFailure
|
||||||
|
is SocketTimeoutException -> NetworkIssue.Timeout
|
||||||
|
is SSLException -> NetworkIssue.TlsFailure
|
||||||
|
is NoRouteToHostException -> NetworkIssue.LanRouteBlocked
|
||||||
|
is ConnectException -> if (kind == RouteKind.LanSystemDns || kind == RouteKind.LanFallbackDns) {
|
||||||
|
NetworkIssue.LanRouteBlocked
|
||||||
|
} else {
|
||||||
|
NetworkIssue.Timeout
|
||||||
|
}
|
||||||
|
is InterruptedIOException -> NetworkIssue.Timeout
|
||||||
|
else -> NetworkIssue.Unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Response.toException(diagnostics: RouteDiagnostics): MonitorClientException {
|
||||||
|
val issue = code.toNetworkIssue()
|
||||||
|
return MonitorClientException(
|
||||||
|
httpStatus = code,
|
||||||
|
monitorStatus = code.toMonitorStatus(),
|
||||||
|
message = code.messageForStatus(),
|
||||||
|
diagnostics = diagnostics.copy(success = false, issue = issue),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Int.toNetworkIssue(): NetworkIssue = when (this) {
|
||||||
|
401 -> NetworkIssue.AuthFailure
|
||||||
|
429 -> NetworkIssue.RateLimited
|
||||||
|
else -> NetworkIssue.ApiFailure
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Int.toMonitorStatus(): ServerStatus = when (this) {
|
private fun Int.toMonitorStatus(): ServerStatus = when (this) {
|
||||||
@@ -99,11 +265,58 @@ class MonitorHttpClient {
|
|||||||
429 -> "Monitor rate limit was exceeded"
|
429 -> "Monitor rate limit was exceeded"
|
||||||
else -> "HTTP $this from monitor endpoint"
|
else -> "HTTP $this from monitor endpoint"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun NetworkCapabilities.transportLabel(): String {
|
||||||
|
return buildList {
|
||||||
|
if (hasTransport(NetworkCapabilities.TRANSPORT_VPN)) add("VPN")
|
||||||
|
if (hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) add("Wi-Fi")
|
||||||
|
if (hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) add("Ethernet")
|
||||||
|
if (hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) add("Cellular")
|
||||||
|
}.ifEmpty { listOf("Unknown") }.joinToString(" + ")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ServerProfile.hostName(): String = java.net.URI(baseUrl).host.orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
data class RouteResponse(
|
||||||
|
val body: String,
|
||||||
|
val diagnostics: RouteDiagnostics,
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class RoutedResponse(
|
||||||
|
val response: Response,
|
||||||
|
val diagnostics: RouteDiagnostics,
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class NetworkState(
|
||||||
|
val activeNetworkLabel: String,
|
||||||
|
val vpnActive: Boolean,
|
||||||
|
val lanNetwork: Network?,
|
||||||
|
)
|
||||||
|
|
||||||
|
private class FallbackDns(
|
||||||
|
private val hostName: String,
|
||||||
|
private val fallbackIps: List<String>,
|
||||||
|
private val delegate: Dns,
|
||||||
|
) : Dns {
|
||||||
|
override fun lookup(hostname: String): List<InetAddress> {
|
||||||
|
if (hostname.equals(hostName, ignoreCase = true) && fallbackIps.isNotEmpty()) {
|
||||||
|
return fallbackIps.map { InetAddress.getByName(it) }
|
||||||
|
}
|
||||||
|
return delegate.lookup(hostname)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class NetworkDns(private val network: Network) : Dns {
|
||||||
|
override fun lookup(hostname: String): List<InetAddress> {
|
||||||
|
return network.getAllByName(hostname).toList()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class MonitorClientException(
|
class MonitorClientException(
|
||||||
val httpStatus: Int?,
|
val httpStatus: Int?,
|
||||||
val monitorStatus: ServerStatus,
|
val monitorStatus: ServerStatus,
|
||||||
override val message: String,
|
override val message: String,
|
||||||
|
val diagnostics: RouteDiagnostics,
|
||||||
cause: Throwable? = null,
|
cause: Throwable? = null,
|
||||||
) : Exception(message, cause)
|
) : Exception(message, cause)
|
||||||
|
|||||||
@@ -51,16 +51,45 @@ object MonitorJsonParser {
|
|||||||
if (disk == null) return null
|
if (disk == null) return null
|
||||||
disk.number("used_pct")?.let { return it }
|
disk.number("used_pct")?.let { return it }
|
||||||
disk.number("used_percent")?.let { return it }
|
disk.number("used_percent")?.let { return it }
|
||||||
|
|
||||||
|
disk.obj("filesystem")?.let { filesystem ->
|
||||||
|
filesystem.obj("/")?.diskUsagePercent()?.let { return it }
|
||||||
|
var max: Double? = null
|
||||||
|
val names = filesystem.keys()
|
||||||
|
while (names.hasNext()) {
|
||||||
|
val item = filesystem.optJSONObject(names.next()) ?: continue
|
||||||
|
val usage = item.diskUsagePercent()
|
||||||
|
if (usage != null && (max == null || usage > max!!)) max = usage
|
||||||
|
}
|
||||||
|
if (max != null) return max
|
||||||
|
}
|
||||||
|
|
||||||
val filesystems = disk.array("filesystems") ?: disk.array("mounts") ?: return null
|
val filesystems = disk.array("filesystems") ?: disk.array("mounts") ?: return null
|
||||||
var max: Double? = null
|
var max: Double? = null
|
||||||
for (index in 0 until filesystems.length()) {
|
for (index in 0 until filesystems.length()) {
|
||||||
val item = filesystems.optJSONObject(index) ?: continue
|
val item = filesystems.optJSONObject(index) ?: continue
|
||||||
val usage = item.number("used_pct") ?: item.number("used_percent") ?: item.number("usage")
|
val usage = item.diskUsagePercent()
|
||||||
|
if (usage != null && item.mountPath() == "/") return usage
|
||||||
if (usage != null && (max == null || usage > max!!)) max = usage
|
if (usage != null && (max == null || usage > max!!)) max = usage
|
||||||
}
|
}
|
||||||
return max
|
return max
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun JSONObject.diskUsagePercent(): Double? {
|
||||||
|
number("used_pct")?.let { return it }
|
||||||
|
number("used_percent")?.let { return it }
|
||||||
|
number("usage")?.let { return it }
|
||||||
|
number("usage_pct")?.let { return it }
|
||||||
|
val used = firstNumber("used", "used_size", "used_bytes", "used_kb")
|
||||||
|
val total = firstNumber("total", "total_size", "total_bytes", "size", "size_bytes", "total_kb")
|
||||||
|
return usedPercentFromSizes(used, total)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun usedPercentFromSizes(used: Double?, total: Double?): Double? {
|
||||||
|
if (used == null || total == null || total <= 0.0) return null
|
||||||
|
return used / total * 100.0
|
||||||
|
}
|
||||||
|
|
||||||
private fun normalizePercent(value: Double?): Double? {
|
private fun normalizePercent(value: Double?): Double? {
|
||||||
if (value == null) return null
|
if (value == null) return null
|
||||||
return if (value in 0.0..1.0) value * 100.0 else value
|
return if (value in 0.0..1.0) value * 100.0 else value
|
||||||
@@ -69,16 +98,30 @@ object MonitorJsonParser {
|
|||||||
private fun JSONObject.obj(name: String): JSONObject? = optJSONObject(name)
|
private fun JSONObject.obj(name: String): JSONObject? = optJSONObject(name)
|
||||||
private fun JSONObject.array(name: String): JSONArray? = optJSONArray(name)
|
private fun JSONObject.array(name: String): JSONArray? = optJSONArray(name)
|
||||||
|
|
||||||
|
private fun JSONObject.firstNumber(vararg names: String): Double? {
|
||||||
|
for (name in names) number(name)?.let { return it }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JSONObject.mountPath(): String? {
|
||||||
|
return string("path") ?: string("mount") ?: string("mount_point") ?: string("mounted_on")
|
||||||
|
}
|
||||||
|
|
||||||
private fun JSONObject.number(name: String): Double? {
|
private fun JSONObject.number(name: String): Double? {
|
||||||
if (!has(name) || isNull(name)) return null
|
if (!has(name) || isNull(name)) return null
|
||||||
val value = opt(name)
|
val value = opt(name)
|
||||||
return when (value) {
|
return when (value) {
|
||||||
is Number -> value.toDouble()
|
is Number -> value.toDouble()
|
||||||
is String -> value.toDoubleOrNull()
|
is String -> value.trim().removeSuffix("%").replace(",", "").toDoubleOrNull()
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun JSONObject.string(name: String): String? {
|
||||||
|
if (!has(name) || isNull(name)) return null
|
||||||
|
return optString(name).takeIf { it.isNotBlank() }
|
||||||
|
}
|
||||||
|
|
||||||
private fun JSONObject.int(name: String): Int? {
|
private fun JSONObject.int(name: String): Int? {
|
||||||
if (!has(name) || isNull(name)) return null
|
if (!has(name) || isNull(name)) return null
|
||||||
val value = opt(name)
|
val value = opt(name)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import org.json.JSONException
|
|||||||
class MonitorRepository(context: Context) {
|
class MonitorRepository(context: Context) {
|
||||||
private val database = MonitorDatabase(context.applicationContext)
|
private val database = MonitorDatabase(context.applicationContext)
|
||||||
private val cipher = CredentialCipher()
|
private val cipher = CredentialCipher()
|
||||||
private val client = MonitorHttpClient()
|
private val client = MonitorHttpClient(context.applicationContext)
|
||||||
private val alertCooldownMillis = TimeUnit.MINUTES.toMillis(30)
|
private val alertCooldownMillis = TimeUnit.MINUTES.toMillis(30)
|
||||||
|
|
||||||
fun servers(): List<ServerProfile> = database.listServers()
|
fun servers(): List<ServerProfile> = database.listServers()
|
||||||
@@ -27,6 +27,8 @@ class MonitorRepository(context: Context) {
|
|||||||
|
|
||||||
fun history(serverId: String): List<MetricSummary> = database.samplesForServer(serverId)
|
fun history(serverId: String): List<MetricSummary> = database.samplesForServer(serverId)
|
||||||
|
|
||||||
|
fun routeDiagnostics(): Map<String, net.rodakot.ngxhttpmonitoringclient.model.RouteDiagnostics> = database.routeDiagnostics()
|
||||||
|
|
||||||
fun alerts(): List<AlertEvent> = database.alerts()
|
fun alerts(): List<AlertEvent> = database.alerts()
|
||||||
|
|
||||||
fun draftFor(server: ServerProfile?): ServerEditorDraft {
|
fun draftFor(server: ServerProfile?): ServerEditorDraft {
|
||||||
@@ -35,6 +37,7 @@ class MonitorRepository(context: Context) {
|
|||||||
id = server.id,
|
id = server.id,
|
||||||
name = server.name,
|
name = server.name,
|
||||||
baseUrl = server.baseUrl,
|
baseUrl = server.baseUrl,
|
||||||
|
fallbackIpAddresses = server.fallbackIpAddresses.joinToString(", "),
|
||||||
tags = server.tags.joinToString(", "),
|
tags = server.tags.joinToString(", "),
|
||||||
favorite = server.favorite,
|
favorite = server.favorite,
|
||||||
allowHttp = server.allowHttp,
|
allowHttp = server.allowHttp,
|
||||||
@@ -63,6 +66,7 @@ class MonitorRepository(context: Context) {
|
|||||||
id = draft.id ?: UUID.randomUUID().toString(),
|
id = draft.id ?: UUID.randomUUID().toString(),
|
||||||
name = draft.name.trim().ifBlank { baseUrl },
|
name = draft.name.trim().ifBlank { baseUrl },
|
||||||
baseUrl = baseUrl,
|
baseUrl = baseUrl,
|
||||||
|
fallbackIpAddresses = UrlRules.parseFallbackIps(draft.fallbackIpAddresses),
|
||||||
tags = draft.tags.split(',').map { it.trim() }.filter { it.isNotBlank() }.distinct(),
|
tags = draft.tags.split(',').map { it.trim() }.filter { it.isNotBlank() }.distinct(),
|
||||||
favorite = draft.favorite,
|
favorite = draft.favorite,
|
||||||
allowHttp = draft.allowHttp,
|
allowHttp = draft.allowHttp,
|
||||||
@@ -91,8 +95,9 @@ class MonitorRepository(context: Context) {
|
|||||||
|
|
||||||
fun testConnection(draft: ServerEditorDraft): Result<String> = runCatching {
|
fun testConnection(draft: ServerEditorDraft): Result<String> = runCatching {
|
||||||
val server = buildServer(draft.copy(id = draft.id ?: "test"))
|
val server = buildServer(draft.copy(id = draft.id ?: "test"))
|
||||||
client.fetchHealth(server, authFor(server))
|
val response = client.fetchHealth(server, authFor(server))
|
||||||
"Connection works"
|
database.upsertRouteDiagnostics(response.diagnostics.copy(serverId = server.id))
|
||||||
|
"Connection works through ${response.diagnostics.routeUsed.label}"
|
||||||
}
|
}
|
||||||
|
|
||||||
fun fetchApiSummary(server: ServerProfile, forcePersist: Boolean = false): MetricSummary {
|
fun fetchApiSummary(server: ServerProfile, forcePersist: Boolean = false): MetricSummary {
|
||||||
@@ -101,17 +106,19 @@ class MonitorRepository(context: Context) {
|
|||||||
|
|
||||||
fun refreshServer(server: ServerProfile, forcePersist: Boolean = false): RefreshResult {
|
fun refreshServer(server: ServerProfile, forcePersist: Boolean = false): RefreshResult {
|
||||||
return try {
|
return try {
|
||||||
val payload = client.fetchApi(server, authFor(server))
|
val response = client.fetchApi(server, authFor(server))
|
||||||
val parsed = MonitorJsonParser.parse(server.id, payload)
|
database.upsertRouteDiagnostics(response.diagnostics)
|
||||||
|
val parsed = MonitorJsonParser.parse(server.id, response.body)
|
||||||
val events = AlertEvaluator.evaluate(server, parsed)
|
val events = AlertEvaluator.evaluate(server, parsed)
|
||||||
val status = if (events.isEmpty()) ServerStatus.Online else ServerStatus.Degraded
|
val status = if (events.isEmpty()) ServerStatus.Online else ServerStatus.Degraded
|
||||||
val summary = parsed.copy(
|
val summary = parsed.copy(
|
||||||
status = status,
|
status = status,
|
||||||
message = if (status == ServerStatus.Online) "Live" else "Threshold breached",
|
message = if (status == ServerStatus.Online) "Live" else "Threshold breached",
|
||||||
)
|
)
|
||||||
persistSample(server, summary, payload, forcePersist)
|
persistSample(server, summary, response.body, forcePersist)
|
||||||
RefreshResult(summary, recordAlerts(server, summary))
|
RefreshResult(summary, recordAlerts(server, summary))
|
||||||
} catch (exception: MonitorClientException) {
|
} catch (exception: MonitorClientException) {
|
||||||
|
database.upsertRouteDiagnostics(exception.diagnostics)
|
||||||
val summary = MetricSummary(
|
val summary = MetricSummary(
|
||||||
serverId = server.id,
|
serverId = server.id,
|
||||||
timestampMillis = System.currentTimeMillis(),
|
timestampMillis = System.currentTimeMillis(),
|
||||||
@@ -142,7 +149,11 @@ class MonitorRepository(context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun streamLive(server: ServerProfile, onSummary: (MetricSummary) -> Unit) {
|
fun streamLive(server: ServerProfile, onSummary: (MetricSummary) -> Unit) {
|
||||||
client.streamLive(server, authFor(server)) { payload ->
|
client.streamLive(
|
||||||
|
server = server,
|
||||||
|
auth = authFor(server),
|
||||||
|
onDiagnostics = database::upsertRouteDiagnostics,
|
||||||
|
) { payload ->
|
||||||
val parsed = MonitorJsonParser.parse(server.id, payload)
|
val parsed = MonitorJsonParser.parse(server.id, payload)
|
||||||
val events = AlertEvaluator.evaluate(server, parsed)
|
val events = AlertEvaluator.evaluate(server, parsed)
|
||||||
val status = if (events.isEmpty()) ServerStatus.Online else ServerStatus.Degraded
|
val status = if (events.isEmpty()) ServerStatus.Online else ServerStatus.Degraded
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package net.rodakot.ngxhttpmonitoringclient.domain
|
||||||
|
|
||||||
|
import net.rodakot.ngxhttpmonitoringclient.model.RouteKind
|
||||||
|
|
||||||
|
object RouteAttemptPlanner {
|
||||||
|
fun attempts(
|
||||||
|
hasFallbackAliases: Boolean,
|
||||||
|
vpnActive: Boolean,
|
||||||
|
lanAvailable: Boolean,
|
||||||
|
): List<RouteKind> {
|
||||||
|
val result = mutableListOf(RouteKind.SystemDns)
|
||||||
|
if (hasFallbackAliases) result += RouteKind.FallbackDns
|
||||||
|
if (vpnActive && lanAvailable) {
|
||||||
|
result += RouteKind.LanSystemDns
|
||||||
|
if (hasFallbackAliases) result += RouteKind.LanFallbackDns
|
||||||
|
}
|
||||||
|
return result.distinct()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package net.rodakot.ngxhttpmonitoringclient.domain
|
package net.rodakot.ngxhttpmonitoringclient.domain
|
||||||
|
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
|
import java.net.InetAddress
|
||||||
|
|
||||||
object UrlRules {
|
object UrlRules {
|
||||||
fun normalizeBaseUrl(input: String): String {
|
fun normalizeBaseUrl(input: String): String {
|
||||||
@@ -30,4 +31,16 @@ object UrlRules {
|
|||||||
val normalizedPath = if (path.startsWith("/")) path else "/$path"
|
val normalizedPath = if (path.startsWith("/")) path else "/$path"
|
||||||
return normalizeBaseUrl(baseUrl) + normalizedPath
|
return normalizeBaseUrl(baseUrl) + normalizedPath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun parseFallbackIps(input: String): List<String> {
|
||||||
|
return input
|
||||||
|
.split(',', '\n', ' ')
|
||||||
|
.map { it.trim() }
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.distinct()
|
||||||
|
.onEach { value ->
|
||||||
|
runCatching { InetAddress.getByName(value) }
|
||||||
|
.getOrElse { throw IllegalArgumentException("Invalid fallback IP: $value") }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ data class ServerProfile(
|
|||||||
val id: String,
|
val id: String,
|
||||||
val name: String,
|
val name: String,
|
||||||
val baseUrl: String,
|
val baseUrl: String,
|
||||||
|
val fallbackIpAddresses: List<String> = emptyList(),
|
||||||
|
val routePolicy: RoutePolicy = RoutePolicy.AutoFallback,
|
||||||
val tags: List<String> = emptyList(),
|
val tags: List<String> = emptyList(),
|
||||||
val favorite: Boolean = false,
|
val favorite: Boolean = false,
|
||||||
val allowHttp: Boolean = false,
|
val allowHttp: Boolean = false,
|
||||||
@@ -30,6 +32,49 @@ data class AuthConfig(
|
|||||||
val basicPassword: String? = null,
|
val basicPassword: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
enum class RoutePolicy {
|
||||||
|
AutoFallback,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class RouteKind(val label: String) {
|
||||||
|
SystemDns("System DNS"),
|
||||||
|
FallbackDns("Fallback DNS"),
|
||||||
|
LanSystemDns("LAN DNS"),
|
||||||
|
LanFallbackDns("LAN fallback"),
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class NetworkIssue(val label: String) {
|
||||||
|
None("OK"),
|
||||||
|
DnsFailure("DNS failure"),
|
||||||
|
VpnActive("VPN active"),
|
||||||
|
LanRouteBlocked("LAN route blocked"),
|
||||||
|
Timeout("Timeout"),
|
||||||
|
TlsFailure("TLS failure"),
|
||||||
|
AuthFailure("Authentication failed"),
|
||||||
|
ApiFailure("API failure"),
|
||||||
|
RateLimited("Rate limited"),
|
||||||
|
Unknown("Network error"),
|
||||||
|
}
|
||||||
|
|
||||||
|
data class RouteDiagnostics(
|
||||||
|
val serverId: String,
|
||||||
|
val timestampMillis: Long,
|
||||||
|
val activeNetwork: String = "Unknown",
|
||||||
|
val routeUsed: RouteKind = RouteKind.SystemDns,
|
||||||
|
val dnsResult: String = "System DNS",
|
||||||
|
val issue: NetworkIssue = NetworkIssue.None,
|
||||||
|
val vpnActive: Boolean = false,
|
||||||
|
val fallbackUsed: Boolean = false,
|
||||||
|
val success: Boolean = false,
|
||||||
|
) {
|
||||||
|
val summary: String
|
||||||
|
get() = if (success) {
|
||||||
|
"Connected through ${routeUsed.label}"
|
||||||
|
} else {
|
||||||
|
issue.label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
data class AlertOverrides(
|
data class AlertOverrides(
|
||||||
val cpuPercent: Double? = null,
|
val cpuPercent: Double? = null,
|
||||||
val memoryPercent: Double? = null,
|
val memoryPercent: Double? = null,
|
||||||
@@ -87,6 +132,7 @@ data class ServerEditorDraft(
|
|||||||
val id: String? = null,
|
val id: String? = null,
|
||||||
val name: String = "",
|
val name: String = "",
|
||||||
val baseUrl: String = "",
|
val baseUrl: String = "",
|
||||||
|
val fallbackIpAddresses: String = "",
|
||||||
val tags: String = "",
|
val tags: String = "",
|
||||||
val favorite: Boolean = false,
|
val favorite: Boolean = false,
|
||||||
val allowHttp: Boolean = false,
|
val allowHttp: Boolean = false,
|
||||||
@@ -118,6 +164,7 @@ data class MonitorUiState(
|
|||||||
val servers: List<ServerProfile> = emptyList(),
|
val servers: List<ServerProfile> = emptyList(),
|
||||||
val summaries: Map<String, MetricSummary> = emptyMap(),
|
val summaries: Map<String, MetricSummary> = emptyMap(),
|
||||||
val history: Map<String, List<MetricSummary>> = emptyMap(),
|
val history: Map<String, List<MetricSummary>> = emptyMap(),
|
||||||
|
val routeDiagnostics: Map<String, RouteDiagnostics> = emptyMap(),
|
||||||
val alerts: List<AlertEvent> = emptyList(),
|
val alerts: List<AlertEvent> = emptyList(),
|
||||||
val selectedServerId: String? = null,
|
val selectedServerId: String? = null,
|
||||||
val showDetail: Boolean = false,
|
val showDetail: Boolean = false,
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ import net.rodakot.ngxhttpmonitoringclient.model.DefaultMemoryThreshold
|
|||||||
import net.rodakot.ngxhttpmonitoringclient.model.DetailTab
|
import net.rodakot.ngxhttpmonitoringclient.model.DetailTab
|
||||||
import net.rodakot.ngxhttpmonitoringclient.model.MetricSummary
|
import net.rodakot.ngxhttpmonitoringclient.model.MetricSummary
|
||||||
import net.rodakot.ngxhttpmonitoringclient.model.MonitorUiState
|
import net.rodakot.ngxhttpmonitoringclient.model.MonitorUiState
|
||||||
|
import net.rodakot.ngxhttpmonitoringclient.model.NetworkIssue
|
||||||
|
import net.rodakot.ngxhttpmonitoringclient.model.RouteDiagnostics
|
||||||
import net.rodakot.ngxhttpmonitoringclient.model.ServerEditorDraft
|
import net.rodakot.ngxhttpmonitoringclient.model.ServerEditorDraft
|
||||||
import net.rodakot.ngxhttpmonitoringclient.model.ServerProfile
|
import net.rodakot.ngxhttpmonitoringclient.model.ServerProfile
|
||||||
import net.rodakot.ngxhttpmonitoringclient.model.ServerStatus
|
import net.rodakot.ngxhttpmonitoringclient.model.ServerStatus
|
||||||
@@ -204,6 +206,7 @@ private fun FleetCommandDeck(
|
|||||||
ServerCommandRow(
|
ServerCommandRow(
|
||||||
server = server,
|
server = server,
|
||||||
summary = state.summaries[server.id],
|
summary = state.summaries[server.id],
|
||||||
|
diagnostics = state.routeDiagnostics[server.id],
|
||||||
onClick = { controller.selectServer(server.id) },
|
onClick = { controller.selectServer(server.id) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -346,7 +349,12 @@ private fun CommandFilters(
|
|||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun ServerCommandRow(server: ServerProfile, summary: MetricSummary?, onClick: () -> Unit) {
|
private fun ServerCommandRow(
|
||||||
|
server: ServerProfile,
|
||||||
|
summary: MetricSummary?,
|
||||||
|
diagnostics: RouteDiagnostics?,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
val status = summary?.status ?: ServerStatus.Unknown
|
val status = summary?.status ?: ServerStatus.Unknown
|
||||||
val health = healthScore(summary)
|
val health = healthScore(summary)
|
||||||
Surface(
|
Surface(
|
||||||
@@ -393,6 +401,7 @@ private fun ServerCommandRow(server: ServerProfile, summary: MetricSummary?, onC
|
|||||||
LabelCapsule(statusLabel(status).uppercase(), colorForStatus(status))
|
LabelCapsule(statusLabel(status).uppercase(), colorForStatus(status))
|
||||||
if (!server.enabled) LabelCapsule("OFF", StatusUnknown)
|
if (!server.enabled) LabelCapsule("OFF", StatusUnknown)
|
||||||
if (server.allowHttp) LabelCapsule("HTTP", StatusInsecure)
|
if (server.allowHttp) LabelCapsule("HTTP", StatusInsecure)
|
||||||
|
RouteBadges(diagnostics)
|
||||||
server.tags.take(3).forEach { LabelCapsule(it.uppercase(), MaterialTheme.colorScheme.secondary) }
|
server.tags.take(3).forEach { LabelCapsule(it.uppercase(), MaterialTheme.colorScheme.secondary) }
|
||||||
}
|
}
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
@@ -423,6 +432,9 @@ private fun ServerCockpit(
|
|||||||
item {
|
item {
|
||||||
CockpitHero(server, summary, controller)
|
CockpitHero(server, summary, controller)
|
||||||
}
|
}
|
||||||
|
item {
|
||||||
|
RoutePanel(state.routeDiagnostics[server.id])
|
||||||
|
}
|
||||||
item {
|
item {
|
||||||
CockpitTabs(selected = state.selectedTab, onSelect = controller::setDetailTab)
|
CockpitTabs(selected = state.selectedTab, onSelect = controller::setDetailTab)
|
||||||
}
|
}
|
||||||
@@ -477,6 +489,25 @@ private fun CockpitHero(server: ServerProfile, summary: MetricSummary?, controll
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RoutePanel(diagnostics: RouteDiagnostics?) {
|
||||||
|
DataPanel("Route") {
|
||||||
|
if (diagnostics == null) {
|
||||||
|
Text("No route check has completed yet.", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
} else {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
RuleRow("Network", diagnostics.activeNetwork)
|
||||||
|
RuleRow("Route", diagnostics.routeUsed.label)
|
||||||
|
RuleRow("DNS", diagnostics.dnsResult)
|
||||||
|
RuleRow("VPN", if (diagnostics.vpnActive) "Active" else "Inactive")
|
||||||
|
RuleRow("Fallback", if (diagnostics.fallbackUsed) "Used" else "Not used")
|
||||||
|
RuleRow("Last result", diagnostics.summary)
|
||||||
|
RuleRow("Checked", diagnostics.timestampMillis.formatTime())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun CockpitTabs(selected: DetailTab, onSelect: (DetailTab) -> Unit) {
|
private fun CockpitTabs(selected: DetailTab, onSelect: (DetailTab) -> Unit) {
|
||||||
Row(
|
Row(
|
||||||
@@ -556,16 +587,27 @@ private fun HistoryCockpit(history: List<MetricSummary>) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SettingsCockpit(server: ServerProfile, controller: MonitorController) {
|
private fun SettingsCockpit(server: ServerProfile, controller: MonitorController) {
|
||||||
DataPanel("Alert Rules") {
|
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
DataPanel("Alert Rules") {
|
||||||
RuleRow("CPU limit", "${server.alertOverrides.cpuPercent ?: DefaultCpuThreshold}%")
|
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
RuleRow("Memory limit", "${server.alertOverrides.memoryPercent ?: DefaultMemoryThreshold}%")
|
RuleRow("CPU limit", "${server.alertOverrides.cpuPercent ?: DefaultCpuThreshold}%")
|
||||||
RuleRow("Disk limit", "${server.alertOverrides.diskPercent ?: DefaultDiskThreshold}%")
|
RuleRow("Memory limit", "${server.alertOverrides.memoryPercent ?: DefaultMemoryThreshold}%")
|
||||||
RuleRow("p95 latency", "${(server.alertOverrides.latencyP95Millis ?: DefaultLatencyP95ThresholdMs).toInt()} ms")
|
RuleRow("Disk limit", "${server.alertOverrides.diskPercent ?: DefaultDiskThreshold}%")
|
||||||
RuleRow("5xx limit", "${server.alertOverrides.errors5xx ?: DefaultErrors5xxThreshold}")
|
RuleRow("p95 latency", "${(server.alertOverrides.latencyP95Millis ?: DefaultLatencyP95ThresholdMs).toInt()} ms")
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
RuleRow("5xx limit", "${server.alertOverrides.errors5xx ?: DefaultErrors5xxThreshold}")
|
||||||
Button(onClick = controller::showEditSelectedServer, shape = RoundedCornerShape(8.dp)) { Text("Edit") }
|
RuleRow("Fallback IPs", server.fallbackIpAddresses.ifEmpty { listOf("None") }.joinToString(", "))
|
||||||
OutlinedButton(onClick = controller::deleteSelectedServer, shape = RoundedCornerShape(8.dp)) { Text("Delete") }
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
Button(onClick = controller::showEditSelectedServer, shape = RoundedCornerShape(8.dp)) { Text("Edit") }
|
||||||
|
OutlinedButton(onClick = controller::deleteSelectedServer, shape = RoundedCornerShape(8.dp)) { Text("Delete") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DataPanel("Privacy") {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
RuleRow("Storage", "Server profiles, credentials, samples, alerts, and widget choices stay on this device.")
|
||||||
|
RuleRow("Credentials", "Monitor tokens and Basic Auth passwords are encrypted with Android Keystore.")
|
||||||
|
RuleRow("Network", "The app connects only to monitor endpoints configured by the user.")
|
||||||
|
RuleRow("Backups", "Android cloud backup and device transfer are disabled for app data.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -665,6 +707,16 @@ private fun LabelCapsule(text: String, color: Color) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RouteBadges(diagnostics: RouteDiagnostics?) {
|
||||||
|
if (diagnostics == null) return
|
||||||
|
if (diagnostics.vpnActive) LabelCapsule("VPN", StatusWarning)
|
||||||
|
if (diagnostics.fallbackUsed) LabelCapsule("LAN FALLBACK", StatusOnline)
|
||||||
|
if (!diagnostics.success && diagnostics.issue != NetworkIssue.None) {
|
||||||
|
LabelCapsule(diagnostics.issue.label.uppercase(), routeIssueColor(diagnostics.issue))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun HealthBadge(score: Int, status: ServerStatus) {
|
private fun HealthBadge(score: Int, status: ServerStatus) {
|
||||||
val color = colorForStatus(status)
|
val color = colorForStatus(status)
|
||||||
@@ -834,6 +886,7 @@ private fun ServerEditorDialog(
|
|||||||
}
|
}
|
||||||
item { EditorField("Display name", draft.name) { value -> onChange { it.copy(name = value) } } }
|
item { EditorField("Display name", draft.name) { value -> onChange { it.copy(name = value) } } }
|
||||||
item { EditorField("Base URL", draft.baseUrl) { value -> onChange { it.copy(baseUrl = value) } } }
|
item { EditorField("Base URL", draft.baseUrl) { value -> onChange { it.copy(baseUrl = value) } } }
|
||||||
|
item { EditorField("Fallback LAN IPs", draft.fallbackIpAddresses) { value -> onChange { it.copy(fallbackIpAddresses = value) } } }
|
||||||
item { EditorField("Tags", draft.tags) { value -> onChange { it.copy(tags = value) } } }
|
item { EditorField("Tags", draft.tags) { value -> onChange { it.copy(tags = value) } } }
|
||||||
item {
|
item {
|
||||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
@@ -1005,6 +1058,19 @@ private fun colorForPercent(value: Double?): Color = when {
|
|||||||
else -> StatusOnline
|
else -> StatusOnline
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun routeIssueColor(issue: NetworkIssue): Color = when (issue) {
|
||||||
|
NetworkIssue.None -> StatusOnline
|
||||||
|
NetworkIssue.DnsFailure -> StatusInsecure
|
||||||
|
NetworkIssue.VpnActive -> StatusWarning
|
||||||
|
NetworkIssue.LanRouteBlocked -> StatusWarning
|
||||||
|
NetworkIssue.Timeout -> StatusWarning
|
||||||
|
NetworkIssue.TlsFailure -> StatusCritical
|
||||||
|
NetworkIssue.AuthFailure -> StatusCritical
|
||||||
|
NetworkIssue.ApiFailure -> StatusCritical
|
||||||
|
NetworkIssue.RateLimited -> StatusWarning
|
||||||
|
NetworkIssue.Unknown -> StatusUnknown
|
||||||
|
}
|
||||||
|
|
||||||
private fun Double?.formatPercent(): String = this?.let { "${it.toInt()}%" } ?: "--"
|
private fun Double?.formatPercent(): String = this?.let { "${it.toInt()}%" } ?: "--"
|
||||||
private fun Double?.formatMillis(): String = this?.let { "${it.toInt()} ms" } ?: "--"
|
private fun Double?.formatMillis(): String = this?.let { "${it.toInt()} ms" } ?: "--"
|
||||||
private fun Double?.formatNumber(): String = this?.let { if (it >= 10) it.toInt().toString() else "%.1f".format(it) } ?: "--"
|
private fun Double?.formatNumber(): String = this?.let { if (it >= 10) it.toInt().toString() else "%.1f".format(it) } ?: "--"
|
||||||
|
|||||||
@@ -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,172 @@
|
|||||||
|
package net.rodakot.ngxhttpmonitoringclient.widget
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.appwidget.AppWidgetManager
|
||||||
|
import android.content.Intent
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.Gravity
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.ArrayAdapter
|
||||||
|
import android.widget.Button
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.RadioButton
|
||||||
|
import android.widget.RadioGroup
|
||||||
|
import android.widget.ScrollView
|
||||||
|
import android.widget.Spinner
|
||||||
|
import android.widget.TextView
|
||||||
|
import net.rodakot.ngxhttpmonitoringclient.data.MonitorRepository
|
||||||
|
|
||||||
|
class ServerWidgetConfigureActivity : BaseWidgetConfigureActivity() {
|
||||||
|
override val mode: WidgetConfigureMode = WidgetConfigureMode.Server
|
||||||
|
}
|
||||||
|
|
||||||
|
class MetricWidgetConfigureActivity : BaseWidgetConfigureActivity() {
|
||||||
|
override val mode: WidgetConfigureMode = WidgetConfigureMode.Metric
|
||||||
|
}
|
||||||
|
|
||||||
|
class GraphWidgetConfigureActivity : BaseWidgetConfigureActivity() {
|
||||||
|
override val mode: WidgetConfigureMode = WidgetConfigureMode.Graph
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class BaseWidgetConfigureActivity : Activity() {
|
||||||
|
protected abstract val mode: WidgetConfigureMode
|
||||||
|
private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
window.decorView.layoutDirection = View.LAYOUT_DIRECTION_LTR
|
||||||
|
setResult(RESULT_CANCELED)
|
||||||
|
appWidgetId = intent?.extras?.getInt(
|
||||||
|
AppWidgetManager.EXTRA_APPWIDGET_ID,
|
||||||
|
AppWidgetManager.INVALID_APPWIDGET_ID,
|
||||||
|
) ?: AppWidgetManager.INVALID_APPWIDGET_ID
|
||||||
|
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val repository = MonitorRepository(applicationContext)
|
||||||
|
val servers = repository.servers()
|
||||||
|
val root = LinearLayout(this).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
layoutDirection = View.LAYOUT_DIRECTION_LTR
|
||||||
|
textDirection = View.TEXT_DIRECTION_LTR
|
||||||
|
setPadding(32, 32, 32, 32)
|
||||||
|
setBackgroundColor(Color.rgb(14, 17, 22))
|
||||||
|
}
|
||||||
|
root.addView(title(mode.title))
|
||||||
|
root.addView(subtitle(mode.description))
|
||||||
|
|
||||||
|
if (servers.isEmpty()) {
|
||||||
|
root.addView(subtitle("No servers exist yet. Open NGX Monitor and add a server first."))
|
||||||
|
root.addView(actionButton("Close") { finish() })
|
||||||
|
setContentView(root)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val serverGroup = RadioGroup(this).apply {
|
||||||
|
orientation = RadioGroup.VERTICAL
|
||||||
|
servers.forEachIndexed { index, server ->
|
||||||
|
addView(RadioButton(this@BaseWidgetConfigureActivity).apply {
|
||||||
|
id = View.generateViewId()
|
||||||
|
text = server.name
|
||||||
|
setTextColor(Color.rgb(234, 240, 247))
|
||||||
|
textSize = 16f
|
||||||
|
setPadding(0, 10, 0, 10)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
check(getChildAt(0).id)
|
||||||
|
}
|
||||||
|
val scroll = ScrollView(this).apply {
|
||||||
|
addView(serverGroup)
|
||||||
|
layoutParams = LinearLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
0,
|
||||||
|
1f,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
root.addView(scroll)
|
||||||
|
|
||||||
|
val metricSpinner = if (mode == WidgetConfigureMode.Metric) {
|
||||||
|
Spinner(this).apply {
|
||||||
|
adapter = ArrayAdapter(
|
||||||
|
this@BaseWidgetConfigureActivity,
|
||||||
|
android.R.layout.simple_spinner_dropdown_item,
|
||||||
|
WidgetMetricKind.entries.map { it.label },
|
||||||
|
)
|
||||||
|
}.also { root.addView(it) }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
root.addView(actionButton("Add ${mode.buttonLabel}") {
|
||||||
|
val checkedView = serverGroup.findViewById<RadioButton>(serverGroup.checkedRadioButtonId)
|
||||||
|
val serverIndex = serverGroup.indexOfChild(checkedView).coerceAtLeast(0)
|
||||||
|
val server = servers[serverIndex]
|
||||||
|
when (mode) {
|
||||||
|
WidgetConfigureMode.Server -> {
|
||||||
|
WidgetPreferences.saveServerWidget(this, appWidgetId, server.id)
|
||||||
|
MonitorWidgetUpdater.updateServers(this, intArrayOf(appWidgetId), refreshNetwork = false)
|
||||||
|
}
|
||||||
|
WidgetConfigureMode.Metric -> {
|
||||||
|
val metric = WidgetMetricKind.entries[metricSpinner?.selectedItemPosition ?: 0]
|
||||||
|
WidgetPreferences.saveMetricWidget(this, appWidgetId, server.id, metric)
|
||||||
|
MonitorWidgetUpdater.updateMetrics(this, intArrayOf(appWidgetId), refreshNetwork = false)
|
||||||
|
}
|
||||||
|
WidgetConfigureMode.Graph -> {
|
||||||
|
WidgetPreferences.saveServerWidget(this, appWidgetId, server.id)
|
||||||
|
MonitorWidgetUpdater.updateGraphs(this, intArrayOf(appWidgetId), refreshNetwork = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val resultValue = Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
|
||||||
|
setResult(RESULT_OK, resultValue)
|
||||||
|
finish()
|
||||||
|
})
|
||||||
|
setContentView(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun title(text: String): TextView = TextView(this).apply {
|
||||||
|
this.text = text
|
||||||
|
setTextColor(Color.rgb(234, 240, 247))
|
||||||
|
textSize = 24f
|
||||||
|
setTypeface(typeface, android.graphics.Typeface.BOLD)
|
||||||
|
setPadding(0, 0, 0, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun subtitle(text: String): TextView = TextView(this).apply {
|
||||||
|
this.text = text
|
||||||
|
setTextColor(Color.rgb(154, 166, 182))
|
||||||
|
textSize = 14f
|
||||||
|
setPadding(0, 0, 0, 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun actionButton(text: String, onClick: () -> Unit): Button = Button(this).apply {
|
||||||
|
this.text = text
|
||||||
|
gravity = Gravity.CENTER
|
||||||
|
setOnClickListener { onClick() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class WidgetConfigureMode(
|
||||||
|
val title: String,
|
||||||
|
val description: String,
|
||||||
|
val buttonLabel: String,
|
||||||
|
) {
|
||||||
|
Server(
|
||||||
|
title = "Server Bars",
|
||||||
|
description = "Choose one server for a large health state with CPU, memory, and storage bars.",
|
||||||
|
buttonLabel = "server widget",
|
||||||
|
),
|
||||||
|
Metric(
|
||||||
|
title = "Metric Sparkline",
|
||||||
|
description = "Choose one server and one signal for a focused history graph.",
|
||||||
|
buttonLabel = "metric widget",
|
||||||
|
),
|
||||||
|
Graph(
|
||||||
|
title = "Telemetry Graph",
|
||||||
|
description = "Choose one server for CPU, memory, and storage trend lines.",
|
||||||
|
buttonLabel = "graph widget",
|
||||||
|
),
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -5,166 +5,17 @@
|
|||||||
android:viewportWidth="108"
|
android:viewportWidth="108"
|
||||||
android:viewportHeight="108">
|
android:viewportHeight="108">
|
||||||
<path
|
<path
|
||||||
android:fillColor="#3DDC84"
|
android:fillColor="@color/command_black"
|
||||||
|
android:pathData="M0,0h108v108h-108z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF151B24"
|
||||||
android:pathData="M0,0h108v108h-108z" />
|
android:pathData="M0,0h108v108h-108z" />
|
||||||
<path
|
<path
|
||||||
android:fillColor="#00000000"
|
android:fillColor="#00000000"
|
||||||
android:pathData="M9,0L9,108"
|
android:pathData="M8,22h92M8,42h92M8,62h92M8,82h92M22,8v92M42,8v92M62,8v92M82,8v92"
|
||||||
android:strokeWidth="0.8"
|
android:strokeColor="#2235D38B"
|
||||||
android:strokeColor="#33FFFFFF" />
|
android:strokeWidth="1" />
|
||||||
<path
|
<path
|
||||||
android:fillColor="#00000000"
|
android:fillColor="#1035D38B"
|
||||||
android:pathData="M19,0L19,108"
|
android:pathData="M18,18h72a10,10 0,0 1,10 10v52a10,10 0,0 1,-10 10h-72a10,10 0,0 1,-10 -10v-52a10,10 0,0 1,10 -10z" />
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M29,0L29,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M39,0L39,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M49,0L49,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M59,0L59,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M69,0L69,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M79,0L79,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M89,0L89,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M99,0L99,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,9L108,9"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,19L108,19"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,29L108,29"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,39L108,39"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,49L108,49"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,59L108,59"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,69L108,69"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,79L108,79"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,89L108,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,99L108,99"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,29L89,29"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,39L89,39"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,49L89,49"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,59L89,59"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,69L89,69"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,79L89,79"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M29,19L29,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M39,19L39,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M49,19L49,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M59,19L59,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M69,19L69,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M79,19L79,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
</vector>
|
</vector>
|
||||||
|
|||||||
@@ -1,30 +1,35 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:aapt="http://schemas.android.com/aapt"
|
|
||||||
android:width="108dp"
|
android:width="108dp"
|
||||||
android:height="108dp"
|
android:height="108dp"
|
||||||
android:viewportWidth="108"
|
android:viewportWidth="108"
|
||||||
android:viewportHeight="108">
|
android:viewportHeight="108">
|
||||||
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
|
||||||
<aapt:attr name="android:fillColor">
|
|
||||||
<gradient
|
|
||||||
android:endX="85.84757"
|
|
||||||
android:endY="92.4963"
|
|
||||||
android:startX="42.9492"
|
|
||||||
android:startY="49.59793"
|
|
||||||
android:type="linear">
|
|
||||||
<item
|
|
||||||
android:color="#44000000"
|
|
||||||
android:offset="0.0" />
|
|
||||||
<item
|
|
||||||
android:color="#00000000"
|
|
||||||
android:offset="1.0" />
|
|
||||||
</gradient>
|
|
||||||
</aapt:attr>
|
|
||||||
</path>
|
|
||||||
<path
|
<path
|
||||||
android:fillColor="#FFFFFF"
|
android:fillColor="#FF202733"
|
||||||
android:fillType="nonZero"
|
android:pathData="M25,23h58a9,9 0,0 1,9 9v44a9,9 0,0 1,-9 9h-58a9,9 0,0 1,-9 -9v-44a9,9 0,0 1,9 -9z" />
|
||||||
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
<path
|
||||||
android:strokeWidth="1"
|
android:fillColor="#FF0E1116"
|
||||||
android:strokeColor="#00000000" />
|
android:pathData="M28,33h52a4,4 0,0 1,4 4v8a4,4 0,0 1,-4 4h-52a4,4 0,0 1,-4 -4v-8a4,4 0,0 1,4 -4z" />
|
||||||
</vector>
|
<path
|
||||||
|
android:fillColor="#FF0E1116"
|
||||||
|
android:pathData="M28,59h52a4,4 0,0 1,4 4v8a4,4 0,0 1,-4 4h-52a4,4 0,0 1,-4 -4v-8a4,4 0,0 1,4 -4z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF35D38B"
|
||||||
|
android:pathData="M32,38a3,3 0,1 0,0.1 0zM32,64a3,3 0,1 0,0.1 0z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFB84D"
|
||||||
|
android:pathData="M43,38a3,3 0,1 0,0.1 0zM43,64a3,3 0,1 0,0.1 0z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF4CC9F0"
|
||||||
|
android:pathData="M58,39h17a2,2 0,0 1,0 4h-17a2,2 0,0 1,0 -4zM58,65h17a2,2 0,0 1,0 4h-17a2,2 0,0 1,0 -4z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M28,53h12l7,-9l9,20l7,-11h17"
|
||||||
|
android:strokeColor="#FF35D38B"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:strokeWidth="4" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#2235D38B"
|
||||||
|
android:pathData="M54,16a38,38 0,1 1,-0.1 0M54,22a32,32 0,1 0,0.1 0z" />
|
||||||
|
</vector>
|
||||||
|
|||||||
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>
|
||||||
49
app/src/main/res/drawable/splash_hero.xml
Normal file
49
app/src/main/res/drawable/splash_hero.xml
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="260dp"
|
||||||
|
android:height="260dp"
|
||||||
|
android:viewportWidth="260"
|
||||||
|
android:viewportHeight="260">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF0E1116"
|
||||||
|
android:pathData="M0,0h260v260h-260z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF171C23"
|
||||||
|
android:pathData="M36,38h188a24,24 0,0 1,24 24v136a24,24 0,0 1,-24 24h-188a24,24 0,0 1,-24 -24v-136a24,24 0,0 1,24 -24z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M38,86h184M38,130h184M38,174h184M86,54v152M130,54v152M174,54v152"
|
||||||
|
android:strokeColor="#244CC9F0"
|
||||||
|
android:strokeWidth="2" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF202733"
|
||||||
|
android:pathData="M58,76h144a10,10 0,0 1,10 10v26a10,10 0,0 1,-10 10h-144a10,10 0,0 1,-10 -10v-26a10,10 0,0 1,10 -10z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF202733"
|
||||||
|
android:pathData="M58,138h144a10,10 0,0 1,10 10v26a10,10 0,0 1,-10 10h-144a10,10 0,0 1,-10 -10v-26a10,10 0,0 1,10 -10z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF35D38B"
|
||||||
|
android:pathData="M70,92a8,8 0,1 0,0.1 0zM70,154a8,8 0,1 0,0.1 0z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFB84D"
|
||||||
|
android:pathData="M96,92a8,8 0,1 0,0.1 0z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFF5A52"
|
||||||
|
android:pathData="M96,154a8,8 0,1 0,0.1 0z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF4CC9F0"
|
||||||
|
android:pathData="M126,93h58a6,6 0,0 1,0 12h-58a6,6 0,0 1,0 -12zM126,155h58a6,6 0,0 1,0 12h-58a6,6 0,0 1,0 -12z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M62,128h32l18,-28l24,60l19,-32h44"
|
||||||
|
android:strokeColor="#FF35D38B"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:strokeWidth="8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#5535D38B"
|
||||||
|
android:pathData="M130,24a106,106 0,1 1,-0.1 0M130,34a96,96 0,1 0,0.1 0z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#334CC9F0"
|
||||||
|
android:pathData="M130,48a82,82 0,1 1,-0.1 0M130,54a76,76 0,1 0,0.1 0z" />
|
||||||
|
</vector>
|
||||||
11
app/src/main/res/drawable/splash_screen.xml
Normal file
11
app/src/main/res/drawable/splash_screen.xml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item>
|
||||||
|
<shape android:shape="rectangle">
|
||||||
|
<solid android:color="@color/command_black" />
|
||||||
|
</shape>
|
||||||
|
</item>
|
||||||
|
<item
|
||||||
|
android:drawable="@drawable/splash_hero"
|
||||||
|
android:gravity="center" />
|
||||||
|
</layer-list>
|
||||||
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>
|
||||||
131
app/src/main/res/layout/widget_fleet.xml
Normal file
131
app/src/main/res/layout/widget_fleet.xml
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:id="@+id/widget_root"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="@drawable/widget_background"
|
||||||
|
android:layoutDirection="ltr"
|
||||||
|
android:textDirection="ltr"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_title"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:text="Fleet Pulse"
|
||||||
|
android:textColor="#FFEAF0F7"
|
||||||
|
android:textSize="17sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_subtitle"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:text="Waiting for samples"
|
||||||
|
android:textColor="#FF9AA6B6"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/widget_refresh"
|
||||||
|
android:layout_width="38dp"
|
||||||
|
android:layout_height="38dp"
|
||||||
|
android:background="@drawable/widget_button"
|
||||||
|
android:contentDescription="@string/widget_refresh"
|
||||||
|
android:padding="9dp"
|
||||||
|
android:src="@drawable/ic_widget_refresh"
|
||||||
|
android:tint="#FF35D38B" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_status"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="10dp"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:text="All clear"
|
||||||
|
android:textColor="#FF35D38B"
|
||||||
|
android:textSize="24sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/widget_graph"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="42dp"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:contentDescription="@string/widget_fleet_graph"
|
||||||
|
android:scaleType="fitXY" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_online"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="34dp"
|
||||||
|
android:layout_marginEnd="6dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/widget_panel_green"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="0 OK"
|
||||||
|
android:textColor="#FF35D38B"
|
||||||
|
android:textSize="13sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_watch"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="34dp"
|
||||||
|
android:layout_marginEnd="6dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/widget_panel_amber"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="0 WATCH"
|
||||||
|
android:textColor="#FFFFB84D"
|
||||||
|
android:textSize="13sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_down"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="34dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/widget_panel_red"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="0 DOWN"
|
||||||
|
android:textColor="#FFFF5A52"
|
||||||
|
android:textSize="13sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_priority"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="2"
|
||||||
|
android:text="No priority servers"
|
||||||
|
android:textColor="#FFEAF0F7"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
</LinearLayout>
|
||||||
109
app/src/main/res/layout/widget_graph.xml
Normal file
109
app/src/main/res/layout/widget_graph.xml
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:id="@+id/widget_root"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="@drawable/widget_background"
|
||||||
|
android:layoutDirection="ltr"
|
||||||
|
android:textDirection="ltr"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_title"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:text="Telemetry Flow"
|
||||||
|
android:textColor="#FFEAF0F7"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_status"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:text="CPU / MEM / STORAGE"
|
||||||
|
android:textColor="#FF9AA6B6"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/widget_refresh"
|
||||||
|
android:layout_width="38dp"
|
||||||
|
android:layout_height="38dp"
|
||||||
|
android:background="@drawable/widget_button"
|
||||||
|
android:contentDescription="@string/widget_refresh"
|
||||||
|
android:padding="9dp"
|
||||||
|
android:src="@drawable/ic_widget_refresh"
|
||||||
|
android:tint="#FF35D38B" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/widget_graph"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:contentDescription="@string/widget_graph_chart"
|
||||||
|
android:scaleType="fitXY" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="24dp"
|
||||||
|
android:layout_marginTop="6dp"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_cpu"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_marginEnd="6dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/widget_panel_green"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="CPU"
|
||||||
|
android:textColor="#FF35D38B"
|
||||||
|
android:textSize="10sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_memory"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_marginEnd="6dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/widget_panel_blue"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="MEM"
|
||||||
|
android:textColor="#FF4CC9F0"
|
||||||
|
android:textSize="10sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_disk"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/widget_panel_amber"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="STORAGE"
|
||||||
|
android:textColor="#FFFFB84D"
|
||||||
|
android:textSize="10sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
147
app/src/main/res/layout/widget_incidents.xml
Normal file
147
app/src/main/res/layout/widget_incidents.xml
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:id="@+id/widget_root"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="@drawable/widget_background"
|
||||||
|
android:layoutDirection="ltr"
|
||||||
|
android:textDirection="ltr"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_title"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:text="Incident Watch"
|
||||||
|
android:textColor="#FFEAF0F7"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_subtitle"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:text="Highest priority first"
|
||||||
|
android:textColor="#FF9AA6B6"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/widget_refresh"
|
||||||
|
android:layout_width="38dp"
|
||||||
|
android:layout_height="38dp"
|
||||||
|
android:background="@drawable/widget_button"
|
||||||
|
android:contentDescription="@string/widget_refresh"
|
||||||
|
android:padding="9dp"
|
||||||
|
android:src="@drawable/ic_widget_refresh"
|
||||||
|
android:tint="#FF35D38B" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_marginTop="9dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="86dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_marginEnd="10dp"
|
||||||
|
android:background="@drawable/widget_panel"
|
||||||
|
android:gravity="center"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_issue_count"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="0"
|
||||||
|
android:textColor="#FFFF5A52"
|
||||||
|
android:textSize="32sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_issue_label"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="ISSUES"
|
||||||
|
android:textColor="#FFFFB5B1"
|
||||||
|
android:textSize="10sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_issue_one"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_marginBottom="5dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/widget_panel"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:paddingLeft="9dp"
|
||||||
|
android:paddingRight="9dp"
|
||||||
|
android:text="No active incidents"
|
||||||
|
android:textColor="#FFEAF0F7"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_issue_two"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_marginBottom="5dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/widget_panel"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:paddingLeft="9dp"
|
||||||
|
android:paddingRight="9dp"
|
||||||
|
android:text="Routes are healthy"
|
||||||
|
android:textColor="#FF9AA6B6"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_issue_three"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/widget_panel"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:paddingLeft="9dp"
|
||||||
|
android:paddingRight="9dp"
|
||||||
|
android:text="Last update --"
|
||||||
|
android:textColor="#FF9AA6B6"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
72
app/src/main/res/layout/widget_metric.xml
Normal file
72
app/src/main/res/layout/widget_metric.xml
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:id="@+id/widget_root"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="@drawable/widget_background"
|
||||||
|
android:layoutDirection="ltr"
|
||||||
|
android:textDirection="ltr"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_title"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:text="Metric"
|
||||||
|
android:textColor="#FFEAF0F7"
|
||||||
|
android:textSize="15sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/widget_refresh"
|
||||||
|
android:layout_width="38dp"
|
||||||
|
android:layout_height="38dp"
|
||||||
|
android:background="@drawable/widget_button"
|
||||||
|
android:contentDescription="@string/widget_refresh"
|
||||||
|
android:padding="9dp"
|
||||||
|
android:src="@drawable/ic_widget_refresh"
|
||||||
|
android:tint="#FF35D38B" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_value"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:gravity="start"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:text="--"
|
||||||
|
android:textColor="#FF4CC9F0"
|
||||||
|
android:textSize="36sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/widget_graph"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:contentDescription="@string/widget_metric_graph"
|
||||||
|
android:scaleType="fitXY" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_status"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="2"
|
||||||
|
android:text="Waiting"
|
||||||
|
android:textColor="#FF9AA6B6"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
</LinearLayout>
|
||||||
229
app/src/main/res/layout/widget_server.xml
Normal file
229
app/src/main/res/layout/widget_server.xml
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:id="@+id/widget_root"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="@drawable/widget_background"
|
||||||
|
android:layoutDirection="ltr"
|
||||||
|
android:textDirection="ltr"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_title"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:text="Server"
|
||||||
|
android:textColor="#FFEAF0F7"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_status"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:text="Waiting"
|
||||||
|
android:textColor="#FF9AA6B6"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/widget_refresh"
|
||||||
|
android:layout_width="38dp"
|
||||||
|
android:layout_height="38dp"
|
||||||
|
android:background="@drawable/widget_button"
|
||||||
|
android:contentDescription="@string/widget_refresh"
|
||||||
|
android:padding="9dp"
|
||||||
|
android:src="@drawable/ic_widget_refresh"
|
||||||
|
android:tint="#FF35D38B" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_health"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="9dp"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:text="ONLINE"
|
||||||
|
android:textColor="#FF35D38B"
|
||||||
|
android:textSize="25sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="6dp"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="6dp"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_cpu_label"
|
||||||
|
android:layout_width="72dp"
|
||||||
|
android:layout_height="22dp"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:text="CPU"
|
||||||
|
android:textColor="#FF9AA6B6"
|
||||||
|
android:textSize="11sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
android:id="@+id/widget_cpu_bar"
|
||||||
|
style="?android:attr/progressBarStyleHorizontal"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="22dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:max="100"
|
||||||
|
android:progress="0"
|
||||||
|
android:progressDrawable="@drawable/widget_progress_green" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_cpu_value"
|
||||||
|
android:layout_width="48dp"
|
||||||
|
android:layout_height="22dp"
|
||||||
|
android:gravity="end|center_vertical"
|
||||||
|
android:text="--"
|
||||||
|
android:textColor="#FFEAF0F7"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="6dp"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_memory_label"
|
||||||
|
android:layout_width="72dp"
|
||||||
|
android:layout_height="22dp"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:text="MEM"
|
||||||
|
android:textColor="#FF9AA6B6"
|
||||||
|
android:textSize="11sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
android:id="@+id/widget_memory_bar"
|
||||||
|
style="?android:attr/progressBarStyleHorizontal"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="22dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:max="100"
|
||||||
|
android:progress="0"
|
||||||
|
android:progressDrawable="@drawable/widget_progress_cyan" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_memory_value"
|
||||||
|
android:layout_width="48dp"
|
||||||
|
android:layout_height="22dp"
|
||||||
|
android:gravity="end|center_vertical"
|
||||||
|
android:text="--"
|
||||||
|
android:textColor="#FFEAF0F7"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_disk_label"
|
||||||
|
android:layout_width="72dp"
|
||||||
|
android:layout_height="22dp"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:text="STORAGE"
|
||||||
|
android:textColor="#FF9AA6B6"
|
||||||
|
android:textSize="11sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
android:id="@+id/widget_disk_bar"
|
||||||
|
style="?android:attr/progressBarStyleHorizontal"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="22dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:max="100"
|
||||||
|
android:progress="0"
|
||||||
|
android:progressDrawable="@drawable/widget_progress_amber" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_disk_value"
|
||||||
|
android:layout_width="48dp"
|
||||||
|
android:layout_height="22dp"
|
||||||
|
android:gravity="end|center_vertical"
|
||||||
|
android:text="--"
|
||||||
|
android:textColor="#FFEAF0F7"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="34dp"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_rps"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_marginEnd="6dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/widget_panel"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="RPS --"
|
||||||
|
android:textColor="#FFEAF0F7"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_p95"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_marginEnd="6dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/widget_panel"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="P95 --"
|
||||||
|
android:textColor="#FFEAF0F7"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_5xx"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/widget_panel_red"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="5XX --"
|
||||||
|
android:textColor="#FFFF5A52"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
29
app/src/main/res/mipmap-anydpi/ic_launcher.xml
Normal file
29
app/src/main/res/mipmap-anydpi/ic_launcher.xml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/command_black"
|
||||||
|
android:pathData="M0,0h108v108h-108z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF202733"
|
||||||
|
android:pathData="M20,20h68a10,10 0,0 1,10 10v48a10,10 0,0 1,-10 10h-68a10,10 0,0 1,-10 -10v-48a10,10 0,0 1,10 -10z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF0E1116"
|
||||||
|
android:pathData="M27,34h54a4,4 0,0 1,4 4v9a4,4 0,0 1,-4 4h-54a4,4 0,0 1,-4 -4v-9a4,4 0,0 1,4 -4zM27,59h54a4,4 0,0 1,4 4v9a4,4 0,0 1,-4 4h-54a4,4 0,0 1,-4 -4v-9a4,4 0,0 1,4 -4z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF35D38B"
|
||||||
|
android:pathData="M33,39a3,3 0,1 0,0.1 0zM33,64a3,3 0,1 0,0.1 0z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFB84D"
|
||||||
|
android:pathData="M44,39a3,3 0,1 0,0.1 0zM44,64a3,3 0,1 0,0.1 0z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,54h13l7,-9l8,19l7,-10h17"
|
||||||
|
android:strokeColor="#FF35D38B"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:strokeWidth="4" />
|
||||||
|
</vector>
|
||||||
26
app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
Normal file
26
app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/command_black"
|
||||||
|
android:pathData="M54,0a54,54 0,1 1,-0.1 0z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF202733"
|
||||||
|
android:pathData="M25,24h58a9,9 0,0 1,9 9v42a9,9 0,0 1,-9 9h-58a9,9 0,0 1,-9 -9v-42a9,9 0,0 1,9 -9z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF0E1116"
|
||||||
|
android:pathData="M29,36h50a4,4 0,0 1,4 4v8a4,4 0,0 1,-4 4h-50a4,4 0,0 1,-4 -4v-8a4,4 0,0 1,4 -4zM29,60h50a4,4 0,0 1,4 4v8a4,4 0,0 1,-4 4h-50a4,4 0,0 1,-4 -4v-8a4,4 0,0 1,4 -4z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF35D38B"
|
||||||
|
android:pathData="M35,41a3,3 0,1 0,0.1 0zM35,65a3,3 0,1 0,0.1 0z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M31,55h13l7,-9l8,19l7,-10h15"
|
||||||
|
android:strokeColor="#FF35D38B"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:strokeWidth="4" />
|
||||||
|
</vector>
|
||||||
9
app/src/main/res/values-v31/themes.xml
Normal file
9
app/src/main/res/values-v31/themes.xml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
|
||||||
|
<style name="Theme.NGXHttpMonitoringClient.Launcher" parent="Theme.NGXHttpMonitoringClient">
|
||||||
|
<item name="android:windowSplashScreenBackground">@color/command_black</item>
|
||||||
|
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_hero</item>
|
||||||
|
<item name="android:windowSplashScreenIconBackgroundColor">@color/command_black</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<color name="purple_200">#FFBB86FC</color>
|
<color name="command_black">#FF0E1116</color>
|
||||||
<color name="purple_500">#FF6200EE</color>
|
<color name="command_panel">#FF171C23</color>
|
||||||
<color name="purple_700">#FF3700B3</color>
|
<color name="signal_green">#FF35D38B</color>
|
||||||
<color name="teal_200">#FF03DAC5</color>
|
<color name="signal_cyan">#FF4CC9F0</color>
|
||||||
<color name="teal_700">#FF018786</color>
|
<color name="signal_amber">#FFFFB84D</color>
|
||||||
<color name="black">#FF000000</color>
|
<color name="signal_red">#FFFF5A52</color>
|
||||||
<color name="white">#FFFFFFFF</color>
|
<color name="white">#FFFFFFFF</color>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1,3 +1,17 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">NGX Monitor</string>
|
<string name="app_name">NGX Monitor</string>
|
||||||
|
<string name="widget_fleet_label">NGX Fleet Pulse</string>
|
||||||
|
<string name="widget_server_label">NGX Server Bars</string>
|
||||||
|
<string name="widget_metric_label">NGX Metric Sparkline</string>
|
||||||
|
<string name="widget_graph_label">NGX Telemetry Graph</string>
|
||||||
|
<string name="widget_incidents_label">NGX Incident Watch</string>
|
||||||
|
<string name="widget_fleet_description">Visual all-server status strip with online, watch, and down groups.</string>
|
||||||
|
<string name="widget_server_description">One selected server with large health state and CPU, memory, and storage bars.</string>
|
||||||
|
<string name="widget_metric_description">One focused metric with a large value and history sparkline.</string>
|
||||||
|
<string name="widget_graph_description">One selected server with CPU, memory, and storage trend lines.</string>
|
||||||
|
<string name="widget_incidents_description">Fleet incident queue with the highest-priority unreachable or degraded servers.</string>
|
||||||
|
<string name="widget_refresh">Refresh widget</string>
|
||||||
|
<string name="widget_fleet_graph">Fleet status strip</string>
|
||||||
|
<string name="widget_metric_graph">Metric history graph</string>
|
||||||
|
<string name="widget_graph_chart">Telemetry trend graph</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1,5 +1,28 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
|
|
||||||
<style name="Theme.NGXHttpMonitoringClient" parent="android:Theme.Material.Light.NoActionBar" />
|
<style name="Theme.NGXHttpMonitoringClient" parent="android:style/Theme.Material.NoActionBar">
|
||||||
</resources>
|
<item name="android:windowNoTitle">true</item>
|
||||||
|
<item name="android:windowActionBar">false</item>
|
||||||
|
<item name="android:windowLightStatusBar">false</item>
|
||||||
|
<item name="android:windowBackground">@color/command_black</item>
|
||||||
|
<item name="android:fontFamily">sans</item>
|
||||||
|
<item name="android:colorAccent">@color/signal_green</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="Theme.NGXHttpMonitoringClient.Launcher" parent="Theme.NGXHttpMonitoringClient">
|
||||||
|
<item name="android:windowBackground">@drawable/splash_screen</item>
|
||||||
|
<item name="android:windowDisablePreview">false</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="WidgetMetricCell">
|
||||||
|
<item name="android:layout_width">0dp</item>
|
||||||
|
<item name="android:layout_height">match_parent</item>
|
||||||
|
<item name="android:layout_weight">1</item>
|
||||||
|
<item name="android:background">@drawable/widget_panel</item>
|
||||||
|
<item name="android:gravity">center</item>
|
||||||
|
<item name="android:textColor">#FFEAF0F7</item>
|
||||||
|
<item name="android:textSize">12sp</item>
|
||||||
|
<item name="android:textStyle">bold</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?><!--
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
Sample backup rules file; uncomment and customize as necessary.
|
|
||||||
See https://developer.android.com/guide/topics/data/autobackup
|
|
||||||
for details.
|
|
||||||
Note: This file is ignored for devices older than API 31
|
|
||||||
See https://developer.android.com/about/versions/12/backup-restore
|
|
||||||
-->
|
|
||||||
<full-backup-content>
|
<full-backup-content>
|
||||||
<!--
|
<exclude
|
||||||
<include domain="sharedpref" path="."/>
|
domain="database"
|
||||||
<exclude domain="sharedpref" path="device.xml"/>
|
path="." />
|
||||||
-->
|
<exclude
|
||||||
</full-backup-content>
|
domain="sharedpref"
|
||||||
|
path="." />
|
||||||
|
<exclude
|
||||||
|
domain="file"
|
||||||
|
path="." />
|
||||||
|
</full-backup-content>
|
||||||
|
|||||||
@@ -1,19 +1,25 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?><!--
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
Sample data extraction rules file; uncomment and customize as necessary.
|
|
||||||
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
|
|
||||||
for details.
|
|
||||||
-->
|
|
||||||
<data-extraction-rules>
|
<data-extraction-rules>
|
||||||
<cloud-backup>
|
<cloud-backup>
|
||||||
<!-- TODO: Use <include> and <exclude> to control what is backed up.
|
<exclude
|
||||||
<include .../>
|
domain="database"
|
||||||
<exclude .../>
|
path="." />
|
||||||
-->
|
<exclude
|
||||||
|
domain="sharedpref"
|
||||||
|
path="." />
|
||||||
|
<exclude
|
||||||
|
domain="file"
|
||||||
|
path="." />
|
||||||
</cloud-backup>
|
</cloud-backup>
|
||||||
<!--
|
|
||||||
<device-transfer>
|
<device-transfer>
|
||||||
<include .../>
|
<exclude
|
||||||
<exclude .../>
|
domain="database"
|
||||||
|
path="." />
|
||||||
|
<exclude
|
||||||
|
domain="sharedpref"
|
||||||
|
path="." />
|
||||||
|
<exclude
|
||||||
|
domain="file"
|
||||||
|
path="." />
|
||||||
</device-transfer>
|
</device-transfer>
|
||||||
-->
|
</data-extraction-rules>
|
||||||
</data-extraction-rules>
|
|
||||||
|
|||||||
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" />
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package net.rodakot.ngxhttpmonitoringclient.data
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class DiskUsageCalculationTest {
|
||||||
|
@Test
|
||||||
|
fun usedPercentFromSizes_calculatesStoragePercent() {
|
||||||
|
assertEquals(25.0, MonitorJsonParser.usedPercentFromSizes(500.0, 2000.0) ?: -1.0, 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun usedPercentFromSizes_matchesMonitorApiRootFilesystemSample() {
|
||||||
|
assertEquals(
|
||||||
|
20.307,
|
||||||
|
MonitorJsonParser.usedPercentFromSizes(
|
||||||
|
used = 100_424_171_520.0,
|
||||||
|
total = 494_532_001_792.0,
|
||||||
|
) ?: -1.0,
|
||||||
|
0.001,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun usedPercentFromSizes_ignoresInvalidTotals() {
|
||||||
|
assertNull(MonitorJsonParser.usedPercentFromSizes(500.0, 0.0))
|
||||||
|
assertNull(MonitorJsonParser.usedPercentFromSizes(500.0, null))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package net.rodakot.ngxhttpmonitoringclient.domain
|
||||||
|
|
||||||
|
import net.rodakot.ngxhttpmonitoringclient.model.RouteKind
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class RouteAttemptPlannerTest {
|
||||||
|
@Test
|
||||||
|
fun attempts_usesSystemDnsWhenNoFallbacksOrVpn() {
|
||||||
|
assertEquals(
|
||||||
|
listOf(RouteKind.SystemDns),
|
||||||
|
RouteAttemptPlanner.attempts(hasFallbackAliases = false, vpnActive = false, lanAvailable = false),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun attempts_addsFallbackDnsWhenAliasesExist() {
|
||||||
|
assertEquals(
|
||||||
|
listOf(RouteKind.SystemDns, RouteKind.FallbackDns),
|
||||||
|
RouteAttemptPlanner.attempts(hasFallbackAliases = true, vpnActive = false, lanAvailable = false),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun attempts_addsLanRoutesWhenVpnAndLanAreAvailable() {
|
||||||
|
assertEquals(
|
||||||
|
listOf(
|
||||||
|
RouteKind.SystemDns,
|
||||||
|
RouteKind.FallbackDns,
|
||||||
|
RouteKind.LanSystemDns,
|
||||||
|
RouteKind.LanFallbackDns,
|
||||||
|
),
|
||||||
|
RouteAttemptPlanner.attempts(hasFallbackAliases = true, vpnActive = true, lanAvailable = true),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,4 +24,12 @@ class UrlRulesTest {
|
|||||||
UrlRules.endpoint("https://monitor.example.com/", "/monitor/api"),
|
UrlRules.endpoint("https://monitor.example.com/", "/monitor/api"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseFallbackIps_acceptsCommaAndWhitespaceSeparatedValues() {
|
||||||
|
assertEquals(
|
||||||
|
listOf("192.168.1.20", "10.0.0.5"),
|
||||||
|
UrlRules.parseFallbackIps("192.168.1.20, 10.0.0.5\n192.168.1.20"),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
118
docs/FALLBACK_IP_TLS.md
Normal file
118
docs/FALLBACK_IP_TLS.md
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# Fallback IP Routing And TLS Verification
|
||||||
|
|
||||||
|
The app stores the server as a normal URL plus optional fallback IP addresses.
|
||||||
|
The fallback IPs are used as DNS answers for the URL host; the app does not
|
||||||
|
rewrite the request URL to an IP address.
|
||||||
|
|
||||||
|
## Where It Is Implemented
|
||||||
|
|
||||||
|
- `ServerProfile.baseUrl` and `ServerProfile.fallbackIpAddresses` store the URL
|
||||||
|
and fallback list in `app/src/main/java/net/rodakot/ngxhttpmonitoringclient/model/MonitorModels.kt`.
|
||||||
|
- The editor exposes those values as `Base URL` and `Fallback LAN IPs` in
|
||||||
|
`app/src/main/java/net/rodakot/ngxhttpmonitoringclient/ui/MonitorApp.kt`.
|
||||||
|
- `MonitorRepository.buildServer()` normalizes the URL, checks whether plain
|
||||||
|
HTTP is allowed, and parses fallback addresses in
|
||||||
|
`app/src/main/java/net/rodakot/ngxhttpmonitoringclient/data/MonitorRepository.kt`.
|
||||||
|
- `RouteAttemptPlanner.attempts()` tries `SystemDns` first, then `FallbackDns`
|
||||||
|
when fallback addresses exist. When a VPN is active and a LAN network is
|
||||||
|
available, it also adds `LanSystemDns` and `LanFallbackDns`.
|
||||||
|
- `MonitorHttpClient.clientFor()` installs a custom OkHttp `Dns` implementation
|
||||||
|
for fallback routes.
|
||||||
|
|
||||||
|
## Request Flow
|
||||||
|
|
||||||
|
For a server configured like this:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Base URL: https://monitor.example.com
|
||||||
|
Fallback LAN IPs: 192.168.1.20, 10.0.0.5
|
||||||
|
```
|
||||||
|
|
||||||
|
the app builds endpoint URLs from the base URL:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://monitor.example.com/monitor/api
|
||||||
|
https://monitor.example.com/monitor/health
|
||||||
|
https://monitor.example.com/monitor/live
|
||||||
|
```
|
||||||
|
|
||||||
|
The first request uses normal system DNS. If that network request fails before
|
||||||
|
a usable HTTP response is returned, the app tries the fallback route. In that
|
||||||
|
route, OkHttp still receives a request for `https://monitor.example.com/...`,
|
||||||
|
but the custom `FallbackDns` returns `192.168.1.20` and `10.0.0.5` when OkHttp
|
||||||
|
asks how to resolve `monitor.example.com`.
|
||||||
|
|
||||||
|
The LAN variants do the same thing through Android's LAN network socket factory
|
||||||
|
when the active device state includes both VPN and Wi-Fi or Ethernet.
|
||||||
|
|
||||||
|
## Why TLS Still Works
|
||||||
|
|
||||||
|
TLS verification is based on the URL hostname, not the numeric socket address.
|
||||||
|
Because the app keeps the request URL as `https://monitor.example.com/...`,
|
||||||
|
OkHttp still uses `monitor.example.com` for:
|
||||||
|
|
||||||
|
- SNI during the TLS handshake.
|
||||||
|
- Certificate hostname verification after the certificate is received.
|
||||||
|
|
||||||
|
That means the server certificate must be valid for `monitor.example.com`.
|
||||||
|
It does not need to contain `192.168.1.20` as an IP subject alternative name
|
||||||
|
because the client is not asking TLS to verify `192.168.1.20`.
|
||||||
|
|
||||||
|
This is different from requesting:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://192.168.1.20/monitor/api
|
||||||
|
```
|
||||||
|
|
||||||
|
With that URL, the TLS hostname is `192.168.1.20`, so the certificate would
|
||||||
|
need to be valid for the IP address. Adding only an HTTP `Host` header is not
|
||||||
|
enough to fix that because TLS verification happens before the HTTP request is
|
||||||
|
processed.
|
||||||
|
|
||||||
|
If the fallback IP reaches a server that does not present a certificate valid
|
||||||
|
for the original URL host, OkHttp throws an SSL error and the app reports it as
|
||||||
|
`TlsFailure`.
|
||||||
|
|
||||||
|
## Bash Equivalent
|
||||||
|
|
||||||
|
The same behavior in curl is `--resolve`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --resolve monitor.example.com:443:192.168.1.20 \
|
||||||
|
https://monitor.example.com/monitor/api
|
||||||
|
```
|
||||||
|
|
||||||
|
`--resolve` says "connect to this IP for this host and port." The URL still
|
||||||
|
uses `monitor.example.com`, so curl sends SNI for `monitor.example.com` and
|
||||||
|
verifies the certificate against `monitor.example.com`.
|
||||||
|
|
||||||
|
The sample script in this directory wraps that pattern:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash docs/fallback-ip-curl-example.sh \
|
||||||
|
--base-url https://monitor.example.com \
|
||||||
|
--fallback-ip 192.168.1.20 \
|
||||||
|
--fallback-ip 10.0.0.5 \
|
||||||
|
--token '<monitor token>'
|
||||||
|
```
|
||||||
|
|
||||||
|
For Basic Auth:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash docs/fallback-ip-curl-example.sh \
|
||||||
|
--base-url https://monitor.example.com \
|
||||||
|
--fallback-ips "192.168.1.20,10.0.0.5" \
|
||||||
|
--basic-auth 'user:password'
|
||||||
|
```
|
||||||
|
|
||||||
|
For the health endpoint:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash docs/fallback-ip-curl-example.sh \
|
||||||
|
--base-url https://monitor.example.com \
|
||||||
|
--fallback-ip 192.168.1.20 \
|
||||||
|
--endpoint /monitor/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not use `--insecure` for this behavior. `--insecure` disables certificate
|
||||||
|
verification, while the app's fallback keeps verification enabled.
|
||||||
37
docs/PLAY_STORE_DATA_SAFETY.md
Normal file
37
docs/PLAY_STORE_DATA_SAFETY.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Play Console Data Safety Notes
|
||||||
|
|
||||||
|
Use this as a starting point for the Play Console Data safety form. Review it before submission because the final answers are your legal responsibility.
|
||||||
|
|
||||||
|
## App Data Flow
|
||||||
|
|
||||||
|
NGX Monitor is a client for monitor endpoints configured by the user. The developer does not operate a backend for this app.
|
||||||
|
|
||||||
|
Stored locally on the device:
|
||||||
|
|
||||||
|
- Server names, base URLs, tags, fallback LAN IPs, and alert thresholds
|
||||||
|
- Monitor API tokens and optional Basic Auth credentials, encrypted with Android Keystore
|
||||||
|
- Metric samples, alert history, route diagnostics, and widget preferences
|
||||||
|
|
||||||
|
Transmitted by the app:
|
||||||
|
|
||||||
|
- HTTP(S) requests to user-configured monitor endpoints
|
||||||
|
- Authentication headers only for the specific endpoint configured by the user
|
||||||
|
- Notification content generated locally on the device
|
||||||
|
|
||||||
|
Not used by the app:
|
||||||
|
|
||||||
|
- Advertising ID
|
||||||
|
- Location APIs
|
||||||
|
- Contacts, photos, camera, microphone, calendar, or SMS
|
||||||
|
- Developer-operated analytics or crash reporting SDKs
|
||||||
|
|
||||||
|
## Suggested Data Safety Answers
|
||||||
|
|
||||||
|
- Data collected by developer: No
|
||||||
|
- Data shared with third parties by developer: No
|
||||||
|
- Data is encrypted in transit: Yes, when users configure HTTPS monitor endpoints
|
||||||
|
- Users can request data deletion: Data is local; users can delete server profiles in-app or clear app data
|
||||||
|
- App type: utility/productivity
|
||||||
|
- Account creation: Not required
|
||||||
|
|
||||||
|
Important note for the form: the app allows user-configured HTTP endpoints for private LAN monitoring. If you publish with HTTP support enabled, disclose that encryption in transit depends on the endpoint configured by the user.
|
||||||
93
docs/PLAY_STORE_RELEASE.md
Normal file
93
docs/PLAY_STORE_RELEASE.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# Google Play Release Checklist
|
||||||
|
|
||||||
|
This project is configured to build Google Play compatible Android App Bundles.
|
||||||
|
|
||||||
|
## Current Project Readiness
|
||||||
|
|
||||||
|
- Package name: `net.rodakot.ngxhttpmonitoringclient`
|
||||||
|
- Minimum SDK: 24
|
||||||
|
- Target SDK: 36
|
||||||
|
- Version: `VERSION_NAME` / `VERSION_CODE` in `gradle.properties`
|
||||||
|
- Release build: R8 minification enabled, resource shrinking enabled
|
||||||
|
- App data backup: disabled for app data, databases, shared preferences, and files
|
||||||
|
- Release signing: read from environment variables or `local.properties`
|
||||||
|
|
||||||
|
## Create An Upload Key
|
||||||
|
|
||||||
|
Create an upload keystore outside the repository:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
keytool -genkeypair `
|
||||||
|
-v `
|
||||||
|
-keystore "C:\secure\keys\ngx-monitor-upload.jks" `
|
||||||
|
-storetype JKS `
|
||||||
|
-keyalg RSA `
|
||||||
|
-keysize 4096 `
|
||||||
|
-validity 10000 `
|
||||||
|
-alias ngx-monitor-upload
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy `local.properties.example` to `local.properties` and set:
|
||||||
|
|
||||||
|
```properties
|
||||||
|
NGX_RELEASE_STORE_FILE=C:\Users\meghdad\keystores\ngx-monitor-upload.jks
|
||||||
|
NGX_RELEASE_STORE_PASSWORD=<redacted>
|
||||||
|
NGX_RELEASE_KEY_ALIAS=ngx-monitor-upload
|
||||||
|
NGX_RELEASE_KEY_PASSWORD=<redacted>
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not commit `local.properties` or the keystore.
|
||||||
|
|
||||||
|
## Build Release Artifacts
|
||||||
|
|
||||||
|
Unsigned release sanity build:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:GRADLE_USER_HOME='C:\Users\meghdad\AndroidStudioProjects\NGXhttpMonitoringClient\.gradle-user'
|
||||||
|
.\gradlew.bat :app:bundleRelease --console=plain
|
||||||
|
```
|
||||||
|
|
||||||
|
Signed upload bundle, after signing values are configured:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:GRADLE_USER_HOME='C:\Users\meghdad\AndroidStudioProjects\NGXhttpMonitoringClient\.gradle-user'
|
||||||
|
.\gradlew.bat :app:bundleRelease --console=plain # for bulding aab
|
||||||
|
.\gradlew.bat :app:assembleRelease --console=plain # for bulding apk
|
||||||
|
```
|
||||||
|
|
||||||
|
Output:
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/build/outputs/bundle/release/app-release.aab
|
||||||
|
```
|
||||||
|
|
||||||
|
## Before Uploading To Play Console
|
||||||
|
|
||||||
|
1. Create the app in Play Console.
|
||||||
|
2. Enable Play App Signing.
|
||||||
|
3. Upload `app-release.aab`.
|
||||||
|
4. Fill the store listing using `fastlane/metadata/android/en-US`.
|
||||||
|
5. Upload phone screenshots and a 1024x500 feature graphic.
|
||||||
|
6. Host `docs/PRIVACY_POLICY.md` as a public web page and paste that URL into Play Console.
|
||||||
|
7. Complete Data Safety using `docs/PLAY_STORE_DATA_SAFETY.md`.
|
||||||
|
8. Complete Content Rating. Suggested category: utility/productivity/server monitoring.
|
||||||
|
9. Complete Target Audience. This app is for server administrators, not children.
|
||||||
|
10. Run an internal test release before production.
|
||||||
|
|
||||||
|
## Release Version Bump
|
||||||
|
|
||||||
|
For every Play upload, increment `VERSION_CODE` in `gradle.properties`.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```properties
|
||||||
|
VERSION_CODE=2
|
||||||
|
VERSION_NAME=1.0.1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Official References
|
||||||
|
|
||||||
|
- Google Play target API requirements: https://support.google.com/googleplay/android-developer/answer/11926878
|
||||||
|
- Android app signing: https://developer.android.com/studio/publish/app-signing
|
||||||
|
- Build and upload an Android App Bundle: https://developer.android.com/guide/app-bundle
|
||||||
|
- Google Play Data safety: https://support.google.com/googleplay/android-developer/answer/10787469
|
||||||
37
docs/PRIVACY_POLICY.md
Normal file
37
docs/PRIVACY_POLICY.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# NGX Monitor Privacy Policy
|
||||||
|
|
||||||
|
Effective date: 2026-05-11
|
||||||
|
|
||||||
|
NGX Monitor is a mobile client for monitoring Nginx servers that you configure in the app.
|
||||||
|
|
||||||
|
## Data Stored On Your Device
|
||||||
|
|
||||||
|
The app stores server profiles, monitor endpoint URLs, optional fallback LAN IP addresses, tags, alert thresholds, metric history, route diagnostics, alerts, and widget preferences on your device.
|
||||||
|
|
||||||
|
If you save monitor tokens or Basic Auth credentials, the app encrypts them with Android Keystore before storing them locally.
|
||||||
|
|
||||||
|
Android cloud backup and device transfer are disabled for app data.
|
||||||
|
|
||||||
|
## Data Sent By The App
|
||||||
|
|
||||||
|
The app sends monitor API requests only to endpoints that you configure. If you configure authentication, the app sends the configured token or Basic Auth credentials to that monitor endpoint.
|
||||||
|
|
||||||
|
The app does not send your monitor data, credentials, server list, or usage data to the app developer.
|
||||||
|
|
||||||
|
## Network Security
|
||||||
|
|
||||||
|
HTTPS is recommended for monitor endpoints. The app also supports HTTP endpoints for private LAN monitoring when the user explicitly configures them.
|
||||||
|
|
||||||
|
## Third Parties
|
||||||
|
|
||||||
|
The app does not include advertising SDKs, analytics SDKs, or developer-operated crash reporting SDKs.
|
||||||
|
|
||||||
|
Google Play may process app install, billing, review, crash, and device information according to Google's own policies.
|
||||||
|
|
||||||
|
## Data Deletion
|
||||||
|
|
||||||
|
You can delete server profiles inside the app. You can also remove all app data from Android system settings by clearing the app's storage or uninstalling the app.
|
||||||
|
|
||||||
|
## Contact
|
||||||
|
|
||||||
|
Replace this section with the publisher support email before submitting the app to Google Play.
|
||||||
243
docs/fallback-ip-curl-example.sh
Normal file
243
docs/fallback-ip-curl-example.sh
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -u
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'USAGE'
|
||||||
|
Usage:
|
||||||
|
bash docs/fallback-ip-curl-example.sh --base-url URL --fallback-ip IP [options]
|
||||||
|
|
||||||
|
Required:
|
||||||
|
--base-url URL Server root URL, for example https://monitor.example.com
|
||||||
|
--fallback-ip IP Fallback address for the URL host. Can be repeated.
|
||||||
|
--fallback-ips "LIST" Comma or space separated fallback addresses.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--endpoint PATH Monitor endpoint path. Default: /monitor/api
|
||||||
|
--token TOKEN Send TOKEN as X-Monitor-Token.
|
||||||
|
--basic-auth USER:PASS Send HTTP Basic Auth credentials.
|
||||||
|
--connect-timeout SEC Curl connection timeout. Default: 8
|
||||||
|
--max-time SEC Curl total request timeout. Default: 20
|
||||||
|
-h, --help Show this help.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
bash docs/fallback-ip-curl-example.sh \
|
||||||
|
--base-url https://monitor.example.com \
|
||||||
|
--fallback-ip 192.168.1.20 \
|
||||||
|
--fallback-ip 10.0.0.5 \
|
||||||
|
--token secret
|
||||||
|
|
||||||
|
The fallback keeps the URL host as monitor.example.com and only overrides DNS
|
||||||
|
with curl --resolve. For HTTPS, curl still sends SNI for that host and verifies
|
||||||
|
the certificate against that host.
|
||||||
|
USAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
die() {
|
||||||
|
printf '%s\n' "$1" >&2
|
||||||
|
printf '\n' >&2
|
||||||
|
usage >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
add_fallback_ips() {
|
||||||
|
local values ip
|
||||||
|
values="${1//,/ }"
|
||||||
|
for ip in $values; do
|
||||||
|
fallback_list+=("$ip")
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
base_url=""
|
||||||
|
endpoint="/monitor/api"
|
||||||
|
token=""
|
||||||
|
basic_auth=""
|
||||||
|
connect_timeout="8"
|
||||||
|
max_time="20"
|
||||||
|
fallback_list=()
|
||||||
|
|
||||||
|
while (($# > 0)); do
|
||||||
|
case "$1" in
|
||||||
|
--base-url)
|
||||||
|
(($# >= 2)) || die "--base-url requires a value"
|
||||||
|
base_url="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--base-url=*)
|
||||||
|
base_url="${1#*=}"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--fallback-ip)
|
||||||
|
(($# >= 2)) || die "--fallback-ip requires a value"
|
||||||
|
add_fallback_ips "$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--fallback-ip=*)
|
||||||
|
add_fallback_ips "${1#*=}"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--fallback-ips)
|
||||||
|
(($# >= 2)) || die "--fallback-ips requires a value"
|
||||||
|
add_fallback_ips "$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--fallback-ips=*)
|
||||||
|
add_fallback_ips "${1#*=}"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--endpoint)
|
||||||
|
(($# >= 2)) || die "--endpoint requires a value"
|
||||||
|
endpoint="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--endpoint=*)
|
||||||
|
endpoint="${1#*=}"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--token)
|
||||||
|
(($# >= 2)) || die "--token requires a value"
|
||||||
|
token="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--token=*)
|
||||||
|
token="${1#*=}"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--basic-auth)
|
||||||
|
(($# >= 2)) || die "--basic-auth requires a value"
|
||||||
|
basic_auth="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--basic-auth=*)
|
||||||
|
basic_auth="${1#*=}"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--connect-timeout)
|
||||||
|
(($# >= 2)) || die "--connect-timeout requires a value"
|
||||||
|
connect_timeout="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--connect-timeout=*)
|
||||||
|
connect_timeout="${1#*=}"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--max-time)
|
||||||
|
(($# >= 2)) || die "--max-time requires a value"
|
||||||
|
max_time="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--max-time=*)
|
||||||
|
max_time="${1#*=}"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
die "Unknown argument: $1"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[[ -n "$base_url" ]] || die "--base-url is required"
|
||||||
|
((${#fallback_list[@]} > 0)) || die "At least one --fallback-ip or --fallback-ips value is required"
|
||||||
|
|
||||||
|
base_url="${base_url%/}"
|
||||||
|
[[ "$endpoint" == /* ]] || endpoint="/$endpoint"
|
||||||
|
url="${base_url}${endpoint}"
|
||||||
|
|
||||||
|
if [[ "$base_url" =~ ^([A-Za-z][A-Za-z0-9+.-]*)://([^/]+) ]]; then
|
||||||
|
scheme="${BASH_REMATCH[1]}"
|
||||||
|
authority="${BASH_REMATCH[2]}"
|
||||||
|
else
|
||||||
|
die "--base-url must be an http or https URL"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$authority" == *"@"* ]]; then
|
||||||
|
die "Put Basic Auth in --basic-auth USER:PASS, not in the URL"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$authority" =~ ^\[([^]]+)\](:([0-9]+))?$ ]]; then
|
||||||
|
host="${BASH_REMATCH[1]}"
|
||||||
|
port="${BASH_REMATCH[3]:-}"
|
||||||
|
elif [[ "$authority" =~ ^([^:]+)(:([0-9]+))?$ ]]; then
|
||||||
|
host="${BASH_REMATCH[1]}"
|
||||||
|
port="${BASH_REMATCH[3]:-}"
|
||||||
|
else
|
||||||
|
die "Could not parse host and port from $authority"
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$scheme" in
|
||||||
|
[Hh][Tt][Tt][Pp][Ss]) port="${port:-443}" ;;
|
||||||
|
[Hh][Tt][Tt][Pp]) port="${port:-80}" ;;
|
||||||
|
*) die "Only http and https URLs are supported" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
curl_args=(
|
||||||
|
--silent
|
||||||
|
--show-error
|
||||||
|
--location
|
||||||
|
--connect-timeout "$connect_timeout"
|
||||||
|
--max-time "$max_time"
|
||||||
|
--header "Accept: application/json"
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ -n "$token" ]]; then
|
||||||
|
curl_args+=(--header "X-Monitor-Token: $token")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$basic_auth" ]]; then
|
||||||
|
curl_args+=(--user "$basic_auth")
|
||||||
|
fi
|
||||||
|
|
||||||
|
request_once() {
|
||||||
|
local body_file http_code rc
|
||||||
|
|
||||||
|
body_file="$(mktemp)"
|
||||||
|
http_code="$(curl "${curl_args[@]}" "$@" --output "$body_file" --write-out '%{http_code}' "$url")"
|
||||||
|
rc=$?
|
||||||
|
|
||||||
|
if ((rc != 0)); then
|
||||||
|
rm -f "$body_file"
|
||||||
|
return "$rc"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat "$body_file"
|
||||||
|
rm -f "$body_file"
|
||||||
|
|
||||||
|
if [[ "$http_code" == 2* ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '\nHTTP %s from monitor endpoint\n' "$http_code" >&2
|
||||||
|
return 22
|
||||||
|
}
|
||||||
|
|
||||||
|
printf 'Trying normal DNS for %s\n' "$host" >&2
|
||||||
|
if request_once; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
rc=$?
|
||||||
|
if ((rc == 22)); then
|
||||||
|
exit "$rc"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'Normal request failed before a usable HTTP response; trying fallback IPs\n' >&2
|
||||||
|
last_rc="$rc"
|
||||||
|
|
||||||
|
for ip in "${fallback_list[@]}"; do
|
||||||
|
[[ -n "$ip" ]] || continue
|
||||||
|
resolve_ip="$ip"
|
||||||
|
if [[ "$resolve_ip" == *:* && "$resolve_ip" != \[*\] ]]; then
|
||||||
|
resolve_ip="[$resolve_ip]"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'Trying %s via --resolve %s:%s:%s\n' "$ip" "$host" "$port" "$resolve_ip" >&2
|
||||||
|
if request_once --resolve "${host}:${port}:${resolve_ip}"; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
last_rc=$?
|
||||||
|
done
|
||||||
|
|
||||||
|
exit "$last_rc"
|
||||||
7
fastlane/metadata/android/en-US/changelogs/1.txt
Normal file
7
fastlane/metadata/android/en-US/changelogs/1.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
Initial release.
|
||||||
|
|
||||||
|
- Fleet monitor for Nginx monitor endpoints
|
||||||
|
- Server detail cockpit with system, request, disk, alert, history, and route diagnostics
|
||||||
|
- VPN and DNS resilience with fallback LAN IPs
|
||||||
|
- Home-screen widgets for fleet status, server bars, metric sparklines, telemetry graphs, and incidents
|
||||||
|
- Local encrypted credential storage
|
||||||
15
fastlane/metadata/android/en-US/full_description.txt
Normal file
15
fastlane/metadata/android/en-US/full_description.txt
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
NGX Monitor is a focused Android client for Nginx monitor endpoints.
|
||||||
|
|
||||||
|
Track CPU, memory, storage, request rate, latency, connection state, HTTP errors, and route health from your phone. The app is designed for server administrators who monitor multiple Nginx hosts and need a fast, readable mobile command deck.
|
||||||
|
|
||||||
|
Key features:
|
||||||
|
- Monitor 20+ servers from one fleet view
|
||||||
|
- View live CPU, memory, storage, request, latency, and connection signals
|
||||||
|
- Follow Nginx worker, request, upstream, disk, history, alert, and route diagnostics
|
||||||
|
- Configure monitor API tokens and optional Basic Auth credentials
|
||||||
|
- Store credentials locally with Android Keystore encryption
|
||||||
|
- Use fallback LAN IPs for VPN, DNS, and local network routing problems
|
||||||
|
- Get Android notifications for degraded or unreachable servers
|
||||||
|
- Add home-screen widgets for fleet status, server bars, metric sparklines, telemetry graphs, and incident watch
|
||||||
|
|
||||||
|
NGX Monitor connects only to the monitor endpoints you configure. HTTPS endpoints are recommended, and HTTP endpoints are supported for private LAN monitoring.
|
||||||
1
fastlane/metadata/android/en-US/short_description.txt
Normal file
1
fastlane/metadata/android/en-US/short_description.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Monitor Nginx servers, alerts, routes, and home-screen widgets.
|
||||||
1
fastlane/metadata/android/en-US/title.txt
Normal file
1
fastlane/metadata/android/en-US/title.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
NGX Monitor
|
||||||
@@ -12,4 +12,6 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
|||||||
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
|
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
|
||||||
# org.gradle.parallel=true
|
# org.gradle.parallel=true
|
||||||
# Kotlin code style for this project: "official" or "obsolete":
|
# Kotlin code style for this project: "official" or "obsolete":
|
||||||
kotlin.code.style=official
|
kotlin.code.style=official
|
||||||
|
VERSION_CODE=102000
|
||||||
|
VERSION_NAME=1.2.0
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ lifecycleRuntimeKtx = "2.10.0"
|
|||||||
activityCompose = "1.13.0"
|
activityCompose = "1.13.0"
|
||||||
kotlin = "2.2.10"
|
kotlin = "2.2.10"
|
||||||
composeBom = "2026.02.01"
|
composeBom = "2026.02.01"
|
||||||
|
okhttp = "4.12.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
@@ -28,6 +29,7 @@ androidx-compose-material3 = { group = "androidx.compose.material3", name = "mat
|
|||||||
androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" }
|
androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" }
|
||||||
androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycleRuntimeKtx" }
|
androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||||
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
|
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||||
|
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|||||||
9
local.properties.example
Normal file
9
local.properties.example
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
## Copy these values into local.properties or export them as environment variables
|
||||||
|
## before building the signed Play upload bundle.
|
||||||
|
##
|
||||||
|
## Do not commit local.properties or the keystore file.
|
||||||
|
|
||||||
|
NGX_RELEASE_STORE_FILE=C:\\path\\to\\ngx-monitor-upload.jks
|
||||||
|
NGX_RELEASE_STORE_PASSWORD=replace-with-keystore-password
|
||||||
|
NGX_RELEASE_KEY_ALIAS=ngx-monitor-upload
|
||||||
|
NGX_RELEASE_KEY_PASSWORD=replace-with-key-password
|
||||||
Reference in New Issue
Block a user