Initial Meterbox commit with boot disk usage metric.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
commit
4e44204d9b
55 files changed
+12104
No files matched your search
@@ -0,0 +1,13 @@
|
|||||||
|
---
|
||||||
|
description: Meterbox always runs via Docker with localhost-only exposure
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Meterbox runtime
|
||||||
|
|
||||||
|
- **Always Dockerize** Meterbox. Do not run the native binary as the supported deploy path.
|
||||||
|
- **Localhost only.** Never publish as `8787:8787` or `0.0.0.0:8787`.
|
||||||
|
- Preferred compose mode: `network_mode: host` with `METERBOX_LISTEN=127.0.0.1:8787` (no Docker port publish, no DNAT bypass).
|
||||||
|
- If bridge networking is used instead, publish only `127.0.0.1:8787:8787`.
|
||||||
|
- After Docker changes: `sudo iptables -L -n -v` and confirm `DOCKER*` chains coexist with the Oracle firewall.
|
||||||
|
- Caddy/Rathole proxy to `127.0.0.1:8787`; Internet clients must not reach the container directly.
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
description: Docker published ports must bind to localhost only on this Oracle host (iptables/Caddy/Rathole)
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Docker + iptables: localhost-only publishes
|
||||||
|
|
||||||
|
Docker modifies iptables and creates chains such as `DOCKER`, `DOCKER-FORWARD`, and `DOCKER-USER`. Published container ports can bypass some host-firewall assumptions.
|
||||||
|
|
||||||
|
Do **not** assume:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -p 8080:8080 ...
|
||||||
|
```
|
||||||
|
|
||||||
|
is protected by the host `INPUT REJECT` the way an ordinary host process is. Oracle firewall rules remain, but Docker port publishing is a different path.
|
||||||
|
|
||||||
|
## Hard rule
|
||||||
|
|
||||||
|
Bind **all** Docker published ports to localhost unless the user explicitly requests public exposure:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Required default
|
||||||
|
docker run -p 127.0.0.1:8080:8080 ...
|
||||||
|
|
||||||
|
# Do not use by default
|
||||||
|
docker run -p 8080:8080 ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Compose equivalent:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8080:8080"
|
||||||
|
```
|
||||||
|
|
||||||
|
Caddy/Rathole can reach localhost; Internet clients must not hit containers directly. Public ingress stays on intentionally opened host ports (80/443, Rathole, etc.).
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- Before adding containers with published ports: `sudo iptables -L -n -v`
|
||||||
|
- Confirm `DOCKER*` chains coexist with the Oracle firewall
|
||||||
|
- Prefer `DOCKER-USER` for extra Docker firewall policy
|
||||||
|
- Never publish `0.0.0.0:PORT` / bare `-p HOST:CONTAINER` without explicit user approval
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
data
|
||||||
|
meterbox
|
||||||
|
backend/target
|
||||||
|
frontend/node_modules
|
||||||
|
frontend/dist
|
||||||
|
**/*.md
|
||||||
|
.cursor
|
||||||
|
scripts
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
target/
|
||||||
|
data/*.db
|
||||||
|
data/*.db-*
|
||||||
|
meterbox
|
||||||
|
frontend/dist
|
||||||
|
frontend/node_modules
|
||||||
|
**/*.swp
|
||||||
|
.DS_Store
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
FROM node:22-bookworm AS frontend
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM rust:1-bookworm AS backend
|
||||||
|
WORKDIR /app/backend
|
||||||
|
COPY backend/Cargo.toml backend/Cargo.lock ./
|
||||||
|
RUN mkdir src && echo 'fn main(){}' > src/main.rs && cargo build --release && rm -rf src
|
||||||
|
COPY backend/src ./src
|
||||||
|
RUN touch src/main.rs && cargo build --release
|
||||||
|
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates vnstat \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=backend /app/backend/target/release/meterbox /usr/local/bin/meterbox
|
||||||
|
COPY --from=frontend /app/frontend/dist /app/frontend/dist
|
||||||
|
RUN mkdir -p /app/data
|
||||||
|
ENV METERBOX_LISTEN=127.0.0.1:8787 \
|
||||||
|
METERBOX_DB=/app/data/meterbox.db \
|
||||||
|
METERBOX_STATIC=/app/frontend/dist \
|
||||||
|
METERBOX_INTERVAL_SECS=30 \
|
||||||
|
METERBOX_RETENTION_DAYS=90 \
|
||||||
|
METERBOX_BWFLOW_SOCK=/run/bwflow/bwflow.sock
|
||||||
|
EXPOSE 8787
|
||||||
|
VOLUME ["/app/data"]
|
||||||
|
CMD ["meterbox"]
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Meterbox
|
||||||
|
|
||||||
|
Reusable host resource dashboard. **Always run via Docker**, bound to localhost only.
|
||||||
|
|
||||||
|
Rust collector + API, React charts for **CPU**, **memory**, and **network**.
|
||||||
|
|
||||||
|
| Range | Bucket |
|
||||||
|
|-------|--------|
|
||||||
|
| 24 hours | 1 minute |
|
||||||
|
| 7 days | 5 minutes |
|
||||||
|
| 4 weeks | 30 minutes |
|
||||||
|
| 3 months | 2 hours |
|
||||||
|
|
||||||
|
## Install / start (Docker only)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /workspace/meterbox
|
||||||
|
./scripts/up.sh
|
||||||
|
# → http://127.0.0.1:8787
|
||||||
|
```
|
||||||
|
|
||||||
|
Stop:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/down.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Compose uses `network_mode: host` and `METERBOX_LISTEN=127.0.0.1:8787`:
|
||||||
|
|
||||||
|
- No Docker port publish (`8787:8787` never used)
|
||||||
|
- Does not DNAT around the host `INPUT REJECT`
|
||||||
|
- Caddy/Rathole can proxy to `127.0.0.1:8787`
|
||||||
|
- Host PID namespace for real host load/CPU visibility
|
||||||
|
|
||||||
|
**Never** change the listen address to `0.0.0.0` or publish `0.0.0.0:8787` unless you explicitly want the dashboard on the public Internet.
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
| Variable | Default (compose) | Meaning |
|
||||||
|
|----------|-------------------|---------|
|
||||||
|
| `METERBOX_LISTEN` | `127.0.0.1:8787` | Bind address (keep localhost) |
|
||||||
|
| `METERBOX_DB` | `/app/data/meterbox.db` | SQLite path |
|
||||||
|
| `METERBOX_STATIC` | `/app/frontend/dist` | Built UI |
|
||||||
|
| `METERBOX_INTERVAL_SECS` | `30` | Sample period |
|
||||||
|
| `METERBOX_RETENTION_DAYS` | `90` | Drop samples older than this |
|
||||||
|
| `METERBOX_MAX_DB_BYTES` | `10737418240` (10 GiB) | Hard on-disk SQLite size cap |
|
||||||
|
| `METERBOX_CLEANUP_INTERVAL_SECS` | `3600` | Periodic cleanup job cadence |
|
||||||
|
|
||||||
|
### SQLite cleanup job
|
||||||
|
|
||||||
|
A background job runs on startup (after ~15s) and every `METERBOX_CLEANUP_INTERVAL_SECS`:
|
||||||
|
|
||||||
|
1. Deletes samples older than `METERBOX_RETENTION_DAYS`
|
||||||
|
2. If on-disk size (`.db` + `-wal` + `-shm`) exceeds **10 GiB**, deletes oldest rows in batches until under ~90% of the cap
|
||||||
|
3. Checkpoints WAL / `VACUUM`s when needed so deleted space is reclaimed
|
||||||
|
|
||||||
|
An emergency size check also runs after each sample insert if the hard cap is breached.
|
||||||
|
|
||||||
|
`GET /api/health` reports `db_bytes`, `max_db_bytes`, and retention settings.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
- `GET /api/health`
|
||||||
|
- `GET /api/host`
|
||||||
|
- `GET /api/snapshot`
|
||||||
|
- `GET /api/series?range=24h|7d|4w|3m`
|
||||||
|
|
||||||
|
## Install on another server
|
||||||
|
|
||||||
|
1. Copy/clone this repo.
|
||||||
|
2. `./scripts/up.sh`
|
||||||
|
3. Reverse-proxy `127.0.0.1:8787` with Caddy/Rathole.
|
||||||
|
4. Confirm with `sudo iptables -L -n -v` that Docker chains coexist safely with your host firewall.
|
||||||
Generated
+2409
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,35 @@
|
|||||||
|
[package]
|
||||||
|
name = "meterbox"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Reusable host resource dashboard — collects and charts CPU, memory, and network usage"
|
||||||
|
license = "MIT"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
axum = { version = "0.8", features = ["macros"] }
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
tower-http = { version = "0.6", features = ["fs", "cors", "trace", "compression-gzip"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
chrono = { version = "0.4", features = ["serde", "clock"] }
|
||||||
|
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||||
|
sysinfo = "0.33"
|
||||||
|
libc = "0.2"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
anyhow = "1"
|
||||||
|
clap = { version = "4", features = ["derive", "env"] }
|
||||||
|
thiserror = "2"
|
||||||
|
hostname = "0.4"
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "gzip", "brotli"] }
|
||||||
|
argon2 = "0.5"
|
||||||
|
password-hash = "0.5"
|
||||||
|
rand = "0.8"
|
||||||
|
sha2 = "0.10"
|
||||||
|
hex = "0.4"
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
lto = true
|
||||||
|
codegen-units = 1
|
||||||
|
strip = true
|
||||||
@@ -0,0 +1,789 @@
|
|||||||
|
use axum::extract::{Path, Query, State};
|
||||||
|
use axum::http::{HeaderMap, StatusCode};
|
||||||
|
use axum::routing::{get, post};
|
||||||
|
use axum::{Json, Router};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::auth::UserPublic;
|
||||||
|
use crate::health::{HealthSite, SiteHistory, UpsertSite};
|
||||||
|
use crate::metrics::{HostInfo, Sample, Sampler};
|
||||||
|
use crate::probe::{probe_url, ProbeResult};
|
||||||
|
use crate::settings::{AppSettings, PublicAccess};
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
const SESSION_TTL_SECS: i64 = 30 * 24 * 60 * 60;
|
||||||
|
|
||||||
|
pub fn router(state: AppState) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/health", get(health))
|
||||||
|
.route("/host", get(host))
|
||||||
|
.route("/snapshot", get(snapshot))
|
||||||
|
.route("/series", get(series))
|
||||||
|
.route("/compliance", get(compliance))
|
||||||
|
.route("/bwflow", get(bwflow_stats))
|
||||||
|
.route("/auth/status", get(auth_status))
|
||||||
|
.route("/auth/setup", post(auth_setup))
|
||||||
|
.route("/auth/login", post(auth_login))
|
||||||
|
.route("/auth/logout", post(auth_logout))
|
||||||
|
.route("/auth/me", get(auth_me))
|
||||||
|
.route("/sites", get(list_sites).post(create_site))
|
||||||
|
.route("/sites/probe", post(probe_site))
|
||||||
|
.route("/sites/{id}", get(get_site).put(update_site).delete(delete_site))
|
||||||
|
.route("/sites/{id}/check", post(check_site_now))
|
||||||
|
.route("/sites/{id}/history", get(site_history))
|
||||||
|
.route("/settings", get(get_settings).put(put_settings))
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct Health {
|
||||||
|
ok: bool,
|
||||||
|
service: &'static str,
|
||||||
|
samples: i64,
|
||||||
|
oldest_ts: Option<i64>,
|
||||||
|
db_bytes: u64,
|
||||||
|
max_db_bytes: u64,
|
||||||
|
retention_days: i64,
|
||||||
|
cleanup_interval_secs: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn health(State(state): State<AppState>) -> Json<Health> {
|
||||||
|
Json(Health {
|
||||||
|
ok: true,
|
||||||
|
service: "meterbox",
|
||||||
|
samples: state.db.count().unwrap_or(0),
|
||||||
|
oldest_ts: state.db.oldest_ts().ok().flatten(),
|
||||||
|
db_bytes: state.db.on_disk_bytes().unwrap_or(0),
|
||||||
|
max_db_bytes: state.config.max_db_bytes,
|
||||||
|
retention_days: state.config.retention_days,
|
||||||
|
cleanup_interval_secs: state.config.cleanup_interval_secs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn host(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Json<HostInfo>, StatusCode> {
|
||||||
|
require_dashboard_read(&state, &headers)?;
|
||||||
|
let mut sampler = Sampler::new();
|
||||||
|
Ok(Json(sampler.host_info()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn snapshot(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Json<Option<Sample>>, StatusCode> {
|
||||||
|
require_dashboard_read(&state, &headers)?;
|
||||||
|
Ok(Json(state.db.latest().ok().flatten()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn compliance(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Json<crate::compliance::ComplianceReport>, StatusCode> {
|
||||||
|
require_dashboard_read(&state, &headers)?;
|
||||||
|
let mut sampler = Sampler::new();
|
||||||
|
let host = sampler.host_info();
|
||||||
|
let bw = crate::bwflow::fetch(&crate::bwflow::socket_from_env()).await;
|
||||||
|
let bw_tx = if bw.available { bw.tx_bytes } else { None };
|
||||||
|
Ok(Json(crate::compliance::evaluate(
|
||||||
|
&state.db,
|
||||||
|
&host,
|
||||||
|
state.config.interval_secs,
|
||||||
|
bw_tx,
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn bwflow_stats(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Json<crate::bwflow::BwflowStats>, StatusCode> {
|
||||||
|
require_dashboard_read(&state, &headers)?;
|
||||||
|
let sock = crate::bwflow::socket_from_env();
|
||||||
|
Ok(Json(crate::bwflow::fetch(&sock).await))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct SeriesQuery {
|
||||||
|
/// 24h | 7d | 4w | 3m
|
||||||
|
range: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct SeriesPoint {
|
||||||
|
ts: i64,
|
||||||
|
cpu_pct: f32,
|
||||||
|
mem_pct: f32,
|
||||||
|
mem_used_bytes: u64,
|
||||||
|
mem_total_bytes: u64,
|
||||||
|
disk_pct: f32,
|
||||||
|
disk_used_bytes: u64,
|
||||||
|
disk_total_bytes: u64,
|
||||||
|
net_rx_bps: f64,
|
||||||
|
net_tx_bps: f64,
|
||||||
|
load1: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct SeriesResponse {
|
||||||
|
range: String,
|
||||||
|
from_ts: i64,
|
||||||
|
to_ts: i64,
|
||||||
|
bucket_secs: i64,
|
||||||
|
points: Vec<SeriesPoint>,
|
||||||
|
averages: Averages,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct Averages {
|
||||||
|
cpu_pct: f32,
|
||||||
|
mem_pct: f32,
|
||||||
|
disk_pct: f32,
|
||||||
|
net_rx_bps: f64,
|
||||||
|
net_tx_bps: f64,
|
||||||
|
load1: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn range_window(range: &str) -> Option<(i64, i64)> {
|
||||||
|
let secs = match range {
|
||||||
|
"24h" => 24 * 3600,
|
||||||
|
"7d" => 7 * 24 * 3600,
|
||||||
|
"4w" => 28 * 24 * 3600,
|
||||||
|
"3m" => 90 * 24 * 3600,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
let bucket = match range {
|
||||||
|
"24h" => 60, // 1 min
|
||||||
|
"7d" => 5 * 60, // 5 min
|
||||||
|
"4w" => 30 * 60, // 30 min
|
||||||
|
"3m" => 2 * 3600, // 2 hours
|
||||||
|
_ => 60,
|
||||||
|
};
|
||||||
|
Some((secs, bucket))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn downsample(samples: &[Sample], bucket_secs: i64) -> Vec<SeriesPoint> {
|
||||||
|
if samples.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut bucket_start = samples[0].ts - (samples[0].ts % bucket_secs);
|
||||||
|
|
||||||
|
let mut cpu = 0.0f64;
|
||||||
|
let mut mem = 0.0f64;
|
||||||
|
let mut mem_used = 0u64;
|
||||||
|
let mut mem_total = 0u64;
|
||||||
|
let mut disk = 0.0f64;
|
||||||
|
let mut disk_used = 0u64;
|
||||||
|
let mut disk_total = 0u64;
|
||||||
|
let mut rx = 0.0f64;
|
||||||
|
let mut tx = 0.0f64;
|
||||||
|
let mut load = 0.0f64;
|
||||||
|
let mut n = 0u64;
|
||||||
|
|
||||||
|
let flush = |out: &mut Vec<SeriesPoint>,
|
||||||
|
start: i64,
|
||||||
|
cpu: f64,
|
||||||
|
mem: f64,
|
||||||
|
mem_used: u64,
|
||||||
|
mem_total: u64,
|
||||||
|
disk: f64,
|
||||||
|
disk_used: u64,
|
||||||
|
disk_total: u64,
|
||||||
|
rx: f64,
|
||||||
|
tx: f64,
|
||||||
|
load: f64,
|
||||||
|
n: u64| {
|
||||||
|
if n == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let nf = n as f64;
|
||||||
|
out.push(SeriesPoint {
|
||||||
|
ts: start,
|
||||||
|
cpu_pct: (cpu / nf) as f32,
|
||||||
|
mem_pct: (mem / nf) as f32,
|
||||||
|
mem_used_bytes: mem_used / n,
|
||||||
|
mem_total_bytes: mem_total / n,
|
||||||
|
disk_pct: (disk / nf) as f32,
|
||||||
|
disk_used_bytes: disk_used / n,
|
||||||
|
disk_total_bytes: disk_total / n,
|
||||||
|
net_rx_bps: rx / nf,
|
||||||
|
net_tx_bps: tx / nf,
|
||||||
|
load1: load / nf,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
for s in samples {
|
||||||
|
let b = s.ts - (s.ts % bucket_secs);
|
||||||
|
if b != bucket_start {
|
||||||
|
flush(
|
||||||
|
&mut out,
|
||||||
|
bucket_start,
|
||||||
|
cpu,
|
||||||
|
mem,
|
||||||
|
mem_used,
|
||||||
|
mem_total,
|
||||||
|
disk,
|
||||||
|
disk_used,
|
||||||
|
disk_total,
|
||||||
|
rx,
|
||||||
|
tx,
|
||||||
|
load,
|
||||||
|
n,
|
||||||
|
);
|
||||||
|
bucket_start = b;
|
||||||
|
cpu = 0.0;
|
||||||
|
mem = 0.0;
|
||||||
|
mem_used = 0;
|
||||||
|
mem_total = 0;
|
||||||
|
disk = 0.0;
|
||||||
|
disk_used = 0;
|
||||||
|
disk_total = 0;
|
||||||
|
rx = 0.0;
|
||||||
|
tx = 0.0;
|
||||||
|
load = 0.0;
|
||||||
|
n = 0;
|
||||||
|
}
|
||||||
|
cpu += s.cpu_pct as f64;
|
||||||
|
mem += s.mem_pct as f64;
|
||||||
|
mem_used += s.mem_used_bytes;
|
||||||
|
mem_total += s.mem_total_bytes;
|
||||||
|
disk += s.disk_pct as f64;
|
||||||
|
disk_used += s.disk_used_bytes;
|
||||||
|
disk_total += s.disk_total_bytes;
|
||||||
|
rx += s.net_rx_bps;
|
||||||
|
tx += s.net_tx_bps;
|
||||||
|
load += s.load1;
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
flush(
|
||||||
|
&mut out,
|
||||||
|
bucket_start,
|
||||||
|
cpu,
|
||||||
|
mem,
|
||||||
|
mem_used,
|
||||||
|
mem_total,
|
||||||
|
disk,
|
||||||
|
disk_used,
|
||||||
|
disk_total,
|
||||||
|
rx,
|
||||||
|
tx,
|
||||||
|
load,
|
||||||
|
n,
|
||||||
|
);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_averages(points: &[SeriesPoint]) -> Averages {
|
||||||
|
if points.is_empty() {
|
||||||
|
return Averages {
|
||||||
|
cpu_pct: 0.0,
|
||||||
|
mem_pct: 0.0,
|
||||||
|
disk_pct: 0.0,
|
||||||
|
net_rx_bps: 0.0,
|
||||||
|
net_tx_bps: 0.0,
|
||||||
|
load1: 0.0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let n = points.len() as f64;
|
||||||
|
Averages {
|
||||||
|
cpu_pct: (points.iter().map(|p| p.cpu_pct as f64).sum::<f64>() / n) as f32,
|
||||||
|
mem_pct: (points.iter().map(|p| p.mem_pct as f64).sum::<f64>() / n) as f32,
|
||||||
|
disk_pct: (points.iter().map(|p| p.disk_pct as f64).sum::<f64>() / n) as f32,
|
||||||
|
net_rx_bps: points.iter().map(|p| p.net_rx_bps).sum::<f64>() / n,
|
||||||
|
net_tx_bps: points.iter().map(|p| p.net_tx_bps).sum::<f64>() / n,
|
||||||
|
load1: points.iter().map(|p| p.load1).sum::<f64>() / n,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn series(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Query(q): Query<SeriesQuery>,
|
||||||
|
) -> Result<Json<SeriesResponse>, StatusCode> {
|
||||||
|
require_dashboard_read(&state, &headers)?;
|
||||||
|
let (window, bucket) = range_window(q.range.as_str()).ok_or(StatusCode::BAD_REQUEST)?;
|
||||||
|
let to_ts = chrono::Utc::now().timestamp();
|
||||||
|
let from_ts = to_ts - window;
|
||||||
|
let samples = state
|
||||||
|
.db
|
||||||
|
.query_range(from_ts, to_ts)
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
let points = downsample(&samples, bucket);
|
||||||
|
let averages = compute_averages(&points);
|
||||||
|
Ok(Json(SeriesResponse {
|
||||||
|
range: q.range,
|
||||||
|
from_ts,
|
||||||
|
to_ts,
|
||||||
|
bucket_secs: bucket,
|
||||||
|
points,
|
||||||
|
averages,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Auth (one-time admin setup + login for interactive dashboard) ----------
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct AuthStatus {
|
||||||
|
needs_setup: bool,
|
||||||
|
user: Option<UserPublic>,
|
||||||
|
public: PublicAccess,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Credentials {
|
||||||
|
email: String,
|
||||||
|
password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct AuthSession {
|
||||||
|
token: String,
|
||||||
|
user: UserPublic,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ApiError {
|
||||||
|
error: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bearer_token(headers: &HeaderMap) -> Option<String> {
|
||||||
|
let raw = headers.get(axum::http::header::AUTHORIZATION)?.to_str().ok()?;
|
||||||
|
raw.strip_prefix("Bearer ")
|
||||||
|
.or_else(|| raw.strip_prefix("bearer "))
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn optional_user(state: &AppState, headers: &HeaderMap) -> Result<Option<UserPublic>, StatusCode> {
|
||||||
|
let Some(token) = bearer_token(headers) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.user_from_token(&token)
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_user(state: &AppState, headers: &HeaderMap) -> Result<UserPublic, StatusCode> {
|
||||||
|
optional_user(state, headers)?.ok_or(StatusCode::UNAUTHORIZED)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_settings(state: &AppState) -> Result<AppSettings, StatusCode> {
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.get_settings()
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_dashboard_read(state: &AppState, headers: &HeaderMap) -> Result<(), StatusCode> {
|
||||||
|
if optional_user(state, headers)?.is_some() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if load_settings(state)?.public_dashboard {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(StatusCode::UNAUTHORIZED)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_site_read(state: &AppState, headers: &HeaderMap, site_id: i64) -> Result<(), StatusCode> {
|
||||||
|
if optional_user(state, headers)?.is_some() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if load_settings(state)?.site_is_public(site_id) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(StatusCode::UNAUTHORIZED)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn auth_status(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Json<AuthStatus>, StatusCode> {
|
||||||
|
let needs_setup = state
|
||||||
|
.db
|
||||||
|
.user_count()
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||||
|
== 0;
|
||||||
|
let user = optional_user(&state, &headers)?;
|
||||||
|
let public = load_settings(&state)?.public_access();
|
||||||
|
Ok(Json(AuthStatus {
|
||||||
|
needs_setup,
|
||||||
|
user,
|
||||||
|
public,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn auth_setup(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(body): Json<Credentials>,
|
||||||
|
) -> Result<Json<AuthSession>, (StatusCode, Json<ApiError>)> {
|
||||||
|
let user = state
|
||||||
|
.db
|
||||||
|
.create_admin(&body.email, &body.password)
|
||||||
|
.map_err(|e| {
|
||||||
|
let msg = e.to_string();
|
||||||
|
if msg.contains("already exists") {
|
||||||
|
(
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
Json(ApiError {
|
||||||
|
error: "setup already completed".into(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
bad_request(msg)
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
let token = state
|
||||||
|
.db
|
||||||
|
.create_session(user.id, SESSION_TTL_SECS)
|
||||||
|
.map_err(internal_err)?;
|
||||||
|
Ok(Json(AuthSession { token, user }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn auth_login(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(body): Json<Credentials>,
|
||||||
|
) -> Result<Json<AuthSession>, (StatusCode, Json<ApiError>)> {
|
||||||
|
let user = state
|
||||||
|
.db
|
||||||
|
.authenticate(&body.email, &body.password)
|
||||||
|
.map_err(internal_err)?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
Json(ApiError {
|
||||||
|
error: "invalid email or password".into(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let token = state
|
||||||
|
.db
|
||||||
|
.create_session(user.id, SESSION_TTL_SECS)
|
||||||
|
.map_err(internal_err)?;
|
||||||
|
Ok(Json(AuthSession { token, user }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn auth_logout(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<StatusCode, StatusCode> {
|
||||||
|
if let Some(token) = bearer_token(&headers) {
|
||||||
|
let _ = state.db.delete_session(&token);
|
||||||
|
}
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn auth_me(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Json<UserPublic>, StatusCode> {
|
||||||
|
Ok(Json(require_user(&state, &headers)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_settings(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Json<AppSettings>, StatusCode> {
|
||||||
|
let _user = require_user(&state, &headers)?;
|
||||||
|
Ok(Json(load_settings(&state)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn put_settings(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(body): Json<AppSettings>,
|
||||||
|
) -> Result<Json<AppSettings>, (StatusCode, Json<ApiError>)> {
|
||||||
|
let _user = require_user(&state, &headers).map_err(|_| unauthorized())?;
|
||||||
|
let saved = state.db.save_settings(&body).map_err(internal_err)?;
|
||||||
|
if saved.cleanup_logs_older_than_3_months {
|
||||||
|
if let Err(e) = state
|
||||||
|
.db
|
||||||
|
.prune_health_results_older_than(crate::settings::HEALTH_LOG_RETENTION_DAYS)
|
||||||
|
{
|
||||||
|
tracing::warn!(error = %e, "immediate health log prune failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Json(saved))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bad_request(msg: String) -> (StatusCode, Json<ApiError>) {
|
||||||
|
(StatusCode::BAD_REQUEST, Json(ApiError { error: msg }))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn internal_err(e: anyhow::Error) -> (StatusCode, Json<ApiError>) {
|
||||||
|
tracing::error!(error = %e, "internal error");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(ApiError {
|
||||||
|
error: "internal error".into(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Site health checks -----------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ProbeRequest {
|
||||||
|
url: String,
|
||||||
|
expected_status: Option<u16>,
|
||||||
|
expect_body_contains: Option<String>,
|
||||||
|
timeout_ms: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct SiteCreateResponse {
|
||||||
|
site: HealthSite,
|
||||||
|
probe: ProbeResult,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unauthorized() -> (StatusCode, Json<ApiError>) {
|
||||||
|
(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
Json(ApiError {
|
||||||
|
error: "unauthorized".into(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_sites(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Json<Vec<HealthSite>>, StatusCode> {
|
||||||
|
let user = optional_user(&state, &headers)?;
|
||||||
|
let settings = load_settings(&state)?;
|
||||||
|
let sites = state
|
||||||
|
.db
|
||||||
|
.list_health_sites()
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
if user.is_some() {
|
||||||
|
return Ok(Json(sites));
|
||||||
|
}
|
||||||
|
if settings.public_site_ids.is_empty() {
|
||||||
|
return Err(StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
let public: Vec<_> = sites
|
||||||
|
.into_iter()
|
||||||
|
.filter(|s| settings.site_is_public(s.id))
|
||||||
|
.collect();
|
||||||
|
Ok(Json(public))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_site(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> Result<Json<HealthSite>, StatusCode> {
|
||||||
|
require_site_read(&state, &headers, id)?;
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.get_health_site(id)
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||||
|
.map(Json)
|
||||||
|
.ok_or(StatusCode::NOT_FOUND)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct HistoryQuery {
|
||||||
|
range: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn site_history(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
Query(q): Query<HistoryQuery>,
|
||||||
|
) -> Result<Json<SiteHistory>, StatusCode> {
|
||||||
|
require_site_read(&state, &headers, id)?;
|
||||||
|
let range = q.range.as_deref().unwrap_or("24h");
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.site_history(id, range)
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||||
|
.map(Json)
|
||||||
|
.ok_or(StatusCode::NOT_FOUND)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn probe_site(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(body): Json<ProbeRequest>,
|
||||||
|
) -> Result<Json<ProbeResult>, StatusCode> {
|
||||||
|
let _user = require_user(&state, &headers)?;
|
||||||
|
let expected = body.expected_status.unwrap_or(200);
|
||||||
|
let timeout = body.timeout_ms.unwrap_or(10_000);
|
||||||
|
let expect = body
|
||||||
|
.expect_body_contains
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.trim())
|
||||||
|
.filter(|s| !s.is_empty());
|
||||||
|
Ok(Json(
|
||||||
|
probe_url(&body.url, expected, expect, timeout).await,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_site(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(body): Json<UpsertSite>,
|
||||||
|
) -> Result<Json<SiteCreateResponse>, (StatusCode, Json<ApiError>)> {
|
||||||
|
let _user = require_user(&state, &headers).map_err(|_| unauthorized())?;
|
||||||
|
|
||||||
|
let expected = body.expected_status.unwrap_or(200) as u16;
|
||||||
|
let timeout = body.timeout_ms.unwrap_or(10_000) as u64;
|
||||||
|
let expect = body
|
||||||
|
.expect_body_contains
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty());
|
||||||
|
|
||||||
|
let probe = probe_url(&body.url, expected, expect.as_deref(), timeout).await;
|
||||||
|
if !probe.ok {
|
||||||
|
let detail = if probe.cloudflare_fallback || probe.cloudflare_challenge {
|
||||||
|
format!(
|
||||||
|
"URL failed Cloudflare/origin validation: {}",
|
||||||
|
probe.reasons.join("; ")
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!("URL health probe failed: {}", probe.reasons.join("; "))
|
||||||
|
};
|
||||||
|
return Err((StatusCode::UNPROCESSABLE_ENTITY, Json(ApiError { error: detail })));
|
||||||
|
}
|
||||||
|
|
||||||
|
let site = state
|
||||||
|
.db
|
||||||
|
.insert_health_site(&body)
|
||||||
|
.map_err(|e| {
|
||||||
|
let msg = e.to_string();
|
||||||
|
if msg.contains("UNIQUE") || msg.contains("unique") {
|
||||||
|
bad_request("a site with this URL already exists".into())
|
||||||
|
} else {
|
||||||
|
bad_request(msg)
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if let Err(e) = state.db.record_health_result(site.id, &probe) {
|
||||||
|
tracing::warn!(error = %e, "failed to store initial probe result");
|
||||||
|
}
|
||||||
|
let site = state
|
||||||
|
.db
|
||||||
|
.get_health_site(site.id)
|
||||||
|
.map_err(internal_err)?
|
||||||
|
.unwrap_or(site);
|
||||||
|
|
||||||
|
Ok(Json(SiteCreateResponse { site, probe }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_site(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
Json(body): Json<UpsertSite>,
|
||||||
|
) -> Result<Json<SiteCreateResponse>, (StatusCode, Json<ApiError>)> {
|
||||||
|
let _user = require_user(&state, &headers).map_err(|_| unauthorized())?;
|
||||||
|
|
||||||
|
let expected = body.expected_status.unwrap_or(200) as u16;
|
||||||
|
let timeout = body.timeout_ms.unwrap_or(10_000) as u64;
|
||||||
|
let expect = body
|
||||||
|
.expect_body_contains
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty());
|
||||||
|
|
||||||
|
let probe = probe_url(&body.url, expected, expect.as_deref(), timeout).await;
|
||||||
|
if !probe.ok {
|
||||||
|
let detail = if probe.cloudflare_fallback || probe.cloudflare_challenge {
|
||||||
|
format!(
|
||||||
|
"URL failed Cloudflare/origin validation: {}",
|
||||||
|
probe.reasons.join("; ")
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!("URL health probe failed: {}", probe.reasons.join("; "))
|
||||||
|
};
|
||||||
|
return Err((StatusCode::UNPROCESSABLE_ENTITY, Json(ApiError { error: detail })));
|
||||||
|
}
|
||||||
|
|
||||||
|
let site = state
|
||||||
|
.db
|
||||||
|
.update_health_site(id, &body)
|
||||||
|
.map_err(|e| {
|
||||||
|
let msg = e.to_string();
|
||||||
|
if msg.contains("not found") {
|
||||||
|
(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(ApiError {
|
||||||
|
error: "site not found".into(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
} else if msg.contains("UNIQUE") || msg.contains("unique") {
|
||||||
|
bad_request("a site with this URL already exists".into())
|
||||||
|
} else {
|
||||||
|
bad_request(msg)
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let _ = state.db.record_health_result(site.id, &probe);
|
||||||
|
let site = state
|
||||||
|
.db
|
||||||
|
.get_health_site(site.id)
|
||||||
|
.map_err(internal_err)?
|
||||||
|
.unwrap_or(site);
|
||||||
|
|
||||||
|
Ok(Json(SiteCreateResponse { site, probe }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_site(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> Result<StatusCode, StatusCode> {
|
||||||
|
let _user = require_user(&state, &headers)?;
|
||||||
|
let deleted = state
|
||||||
|
.db
|
||||||
|
.delete_health_site(id)
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
if deleted {
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
} else {
|
||||||
|
Err(StatusCode::NOT_FOUND)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn check_site_now(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> Result<Json<SiteCreateResponse>, (StatusCode, Json<ApiError>)> {
|
||||||
|
let _user = require_user(&state, &headers).map_err(|_| unauthorized())?;
|
||||||
|
let site = state
|
||||||
|
.db
|
||||||
|
.get_health_site(id)
|
||||||
|
.map_err(internal_err)?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(ApiError {
|
||||||
|
error: "site not found".into(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let expect = site.expect_body_contains.clone();
|
||||||
|
let probe = probe_url(
|
||||||
|
&site.url,
|
||||||
|
site.expected_status as u16,
|
||||||
|
expect.as_deref(),
|
||||||
|
site.timeout_ms as u64,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.record_health_result(site.id, &probe)
|
||||||
|
.map_err(internal_err)?;
|
||||||
|
let site = state
|
||||||
|
.db
|
||||||
|
.get_health_site(site.id)
|
||||||
|
.map_err(internal_err)?
|
||||||
|
.unwrap_or(site);
|
||||||
|
Ok(Json(SiteCreateResponse { site, probe }))
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
//! Single local admin account + session tokens (SQLite-backed).
|
||||||
|
//!
|
||||||
|
//! First-run setup creates the only admin (email + password). After that,
|
||||||
|
//! interactive dashboard use requires login.
|
||||||
|
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
|
||||||
|
use argon2::Argon2;
|
||||||
|
use rand::rngs::OsRng;
|
||||||
|
use rand::RngCore;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
use crate::db::Db;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
pub struct UserPublic {
|
||||||
|
pub id: i64,
|
||||||
|
pub email: String,
|
||||||
|
pub created_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn hash_password(password: &str) -> Result<String> {
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
let hash = Argon2::default()
|
||||||
|
.hash_password(password.as_bytes(), &salt)
|
||||||
|
.map_err(|e| anyhow::anyhow!("hash password: {e}"))?
|
||||||
|
.to_string();
|
||||||
|
Ok(hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify_password(password: &str, password_hash: &str) -> Result<bool> {
|
||||||
|
let parsed = PasswordHash::new(password_hash)
|
||||||
|
.map_err(|e| anyhow::anyhow!("parse hash: {e}"))?;
|
||||||
|
Ok(Argon2::default()
|
||||||
|
.verify_password(password.as_bytes(), &parsed)
|
||||||
|
.is_ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash_token(token: &str) -> String {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(token.as_bytes());
|
||||||
|
hex::encode(hasher.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new_token() -> String {
|
||||||
|
let mut bytes = [0u8; 32];
|
||||||
|
OsRng.fill_bytes(&mut bytes);
|
||||||
|
hex::encode(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_email(email: &str) -> Result<String> {
|
||||||
|
let email = email.trim().to_lowercase();
|
||||||
|
if email.len() < 5 || !email.contains('@') || !email.contains('.') {
|
||||||
|
bail!("enter a valid email address");
|
||||||
|
}
|
||||||
|
let (local, domain) = email
|
||||||
|
.split_once('@')
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("enter a valid email address"))?;
|
||||||
|
if local.is_empty() || domain.is_empty() || !domain.contains('.') {
|
||||||
|
bail!("enter a valid email address");
|
||||||
|
}
|
||||||
|
Ok(email)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Db {
|
||||||
|
pub fn user_count(&self) -> Result<i64> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let n: i64 = conn.query_row("SELECT COUNT(*) FROM users", [], |r| r.get(0))?;
|
||||||
|
Ok(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create the single admin account. Email is stored in the `username` column.
|
||||||
|
pub fn create_admin(&self, email: &str, password: &str) -> Result<UserPublic> {
|
||||||
|
if self.user_count()? > 0 {
|
||||||
|
bail!("admin account already exists");
|
||||||
|
}
|
||||||
|
let email = normalize_email(email)?;
|
||||||
|
if password.len() < 8 {
|
||||||
|
bail!("password must be at least 8 characters");
|
||||||
|
}
|
||||||
|
let hash = hash_password(password)?;
|
||||||
|
let now = chrono::Utc::now().timestamp();
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO users (username, password_hash, created_at) VALUES (?1, ?2, ?3)",
|
||||||
|
rusqlite::params![email, hash, now],
|
||||||
|
)
|
||||||
|
.context("create admin")?;
|
||||||
|
let id = conn.last_insert_rowid();
|
||||||
|
Ok(UserPublic {
|
||||||
|
id,
|
||||||
|
email,
|
||||||
|
created_at: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn authenticate(&self, email: &str, password: &str) -> Result<Option<UserPublic>> {
|
||||||
|
let email = normalize_email(email)?;
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let row = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT id, username, password_hash, created_at FROM users WHERE username = ?1",
|
||||||
|
rusqlite::params![email],
|
||||||
|
|r| {
|
||||||
|
Ok((
|
||||||
|
r.get::<_, i64>(0)?,
|
||||||
|
r.get::<_, String>(1)?,
|
||||||
|
r.get::<_, String>(2)?,
|
||||||
|
r.get::<_, i64>(3)?,
|
||||||
|
))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
let Some((id, email, hash, created_at)) = row else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
drop(conn);
|
||||||
|
if !verify_password(password, &hash)? {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
Ok(Some(UserPublic {
|
||||||
|
id,
|
||||||
|
email,
|
||||||
|
created_at,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_session(&self, user_id: i64, ttl_secs: i64) -> Result<String> {
|
||||||
|
let token = new_token();
|
||||||
|
let token_hash = hash_token(&token);
|
||||||
|
let now = chrono::Utc::now().timestamp();
|
||||||
|
let expires = now + ttl_secs;
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO sessions (token_hash, user_id, created_at, expires_at) VALUES (?1, ?2, ?3, ?4)",
|
||||||
|
rusqlite::params![token_hash, user_id, now, expires],
|
||||||
|
)?;
|
||||||
|
Ok(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn user_from_token(&self, token: &str) -> Result<Option<UserPublic>> {
|
||||||
|
if token.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let token_hash = hash_token(token);
|
||||||
|
let now = chrono::Utc::now().timestamp();
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let row = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT u.id, u.username, u.created_at
|
||||||
|
FROM sessions s
|
||||||
|
JOIN users u ON u.id = s.user_id
|
||||||
|
WHERE s.token_hash = ?1 AND s.expires_at > ?2",
|
||||||
|
rusqlite::params![token_hash, now],
|
||||||
|
|r| {
|
||||||
|
Ok(UserPublic {
|
||||||
|
id: r.get(0)?,
|
||||||
|
email: r.get(1)?,
|
||||||
|
created_at: r.get(2)?,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
Ok(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_session(&self, token: &str) -> Result<()> {
|
||||||
|
let token_hash = hash_token(token);
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM sessions WHERE token_hash = ?1",
|
||||||
|
rusqlite::params![token_hash],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prune_expired_sessions(&self) -> Result<usize> {
|
||||||
|
let now = chrono::Utc::now().timestamp();
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let n = conn.execute(
|
||||||
|
"DELETE FROM sessions WHERE expires_at <= ?1",
|
||||||
|
rusqlite::params![now],
|
||||||
|
)?;
|
||||||
|
Ok(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
use rusqlite::OptionalExtension;
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
//! Client for the bwflow daemon Unix socket (`/run/bwflow/bwflow.sock`).
|
||||||
|
//!
|
||||||
|
//! Protocol: send `status\n`, receive one NDJSON object.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
|
use tokio::net::UnixStream;
|
||||||
|
|
||||||
|
pub const DEFAULT_SOCKET: &str = "/run/bwflow/bwflow.sock";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct BwflowRawStatus {
|
||||||
|
pub state: String,
|
||||||
|
pub tx_bytes: u64,
|
||||||
|
pub tokens_bytes: i64,
|
||||||
|
pub throttle_kbps: u64,
|
||||||
|
pub monthly_budget_gb: u64,
|
||||||
|
pub pre_charge_gb: u64,
|
||||||
|
pub interface: String,
|
||||||
|
pub as_of: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct MonthTraffic {
|
||||||
|
pub year: i32,
|
||||||
|
pub month: u32,
|
||||||
|
pub rx_bytes: u64,
|
||||||
|
pub tx_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct BwflowStats {
|
||||||
|
pub available: bool,
|
||||||
|
pub error: Option<String>,
|
||||||
|
pub socket: String,
|
||||||
|
pub state: Option<String>,
|
||||||
|
pub interface: Option<String>,
|
||||||
|
pub as_of: Option<String>,
|
||||||
|
pub tx_bytes: Option<u64>,
|
||||||
|
pub tokens_bytes: Option<i64>,
|
||||||
|
pub throttle_kbps: Option<u64>,
|
||||||
|
pub monthly_budget_gb: Option<u64>,
|
||||||
|
pub pre_charge_gb: Option<u64>,
|
||||||
|
/// 0–100 of monthly budget consumed
|
||||||
|
pub budget_used_pct: Option<f64>,
|
||||||
|
pub tokens_remaining_gb: Option<f64>,
|
||||||
|
pub tx_gb: Option<f64>,
|
||||||
|
pub rate_limit_on: Option<bool>,
|
||||||
|
/// Calendar-month period label, e.g. "September 2026"
|
||||||
|
pub calendar_month: Option<String>,
|
||||||
|
/// Inclusive start of the Oracle calendar-month window (UTC ISO date)
|
||||||
|
pub period_start: Option<String>,
|
||||||
|
/// Exclusive end / reset date of the window (1st of next month, UTC)
|
||||||
|
pub period_resets: Option<String>,
|
||||||
|
pub months: Vec<MonthTraffic>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BwflowStats {
|
||||||
|
pub fn unavailable(socket: impl Into<String>, error: impl Into<String>) -> Self {
|
||||||
|
let (calendar_month, period_start, period_resets) = calendar_period_labels();
|
||||||
|
Self {
|
||||||
|
available: false,
|
||||||
|
error: Some(error.into()),
|
||||||
|
socket: socket.into(),
|
||||||
|
state: None,
|
||||||
|
interface: None,
|
||||||
|
as_of: None,
|
||||||
|
tx_bytes: None,
|
||||||
|
tokens_bytes: None,
|
||||||
|
throttle_kbps: None,
|
||||||
|
monthly_budget_gb: None,
|
||||||
|
pre_charge_gb: None,
|
||||||
|
budget_used_pct: None,
|
||||||
|
tokens_remaining_gb: None,
|
||||||
|
tx_gb: None,
|
||||||
|
rate_limit_on: None,
|
||||||
|
calendar_month: Some(calendar_month),
|
||||||
|
period_start: Some(period_start),
|
||||||
|
period_resets: Some(period_resets),
|
||||||
|
months: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const GB: f64 = 1_000_000_000.0;
|
||||||
|
|
||||||
|
fn calendar_period_labels() -> (String, String, String) {
|
||||||
|
use chrono::{Datelike, Duration, TimeZone, Utc};
|
||||||
|
let now = Utc::now();
|
||||||
|
let start = Utc
|
||||||
|
.with_ymd_and_hms(now.year(), now.month(), 1, 0, 0, 0)
|
||||||
|
.single()
|
||||||
|
.unwrap_or(now);
|
||||||
|
let resets = if now.month() == 12 {
|
||||||
|
Utc.with_ymd_and_hms(now.year() + 1, 1, 1, 0, 0, 0)
|
||||||
|
.single()
|
||||||
|
.unwrap_or(start + Duration::days(31))
|
||||||
|
} else {
|
||||||
|
Utc.with_ymd_and_hms(now.year(), now.month() + 1, 1, 0, 0, 0)
|
||||||
|
.single()
|
||||||
|
.unwrap_or(start + Duration::days(31))
|
||||||
|
};
|
||||||
|
let label = start.format("%B %Y").to_string();
|
||||||
|
(
|
||||||
|
label,
|
||||||
|
start.format("%Y-%m-%d").to_string(),
|
||||||
|
resets.format("%Y-%m-%d").to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fetch(socket: &Path) -> BwflowStats {
|
||||||
|
let sock = socket.display().to_string();
|
||||||
|
let (calendar_month, period_start, period_resets) = calendar_period_labels();
|
||||||
|
match query_status(socket).await {
|
||||||
|
Ok(raw) => {
|
||||||
|
let tx_gb = raw.tx_bytes as f64 / GB;
|
||||||
|
let budget_gb = raw.monthly_budget_gb as f64;
|
||||||
|
let used_pct = if budget_gb > 0.0 {
|
||||||
|
Some((tx_gb / budget_gb * 100.0).min(100.0))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let iface = raw.interface.clone();
|
||||||
|
let months = tokio::task::spawn_blocking(move || fetch_vnstat_months(&iface))
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| Vec::new());
|
||||||
|
|
||||||
|
BwflowStats {
|
||||||
|
available: true,
|
||||||
|
error: None,
|
||||||
|
socket: sock,
|
||||||
|
state: Some(raw.state.clone()),
|
||||||
|
interface: Some(raw.interface),
|
||||||
|
as_of: Some(raw.as_of),
|
||||||
|
tx_bytes: Some(raw.tx_bytes),
|
||||||
|
tokens_bytes: Some(raw.tokens_bytes),
|
||||||
|
throttle_kbps: Some(raw.throttle_kbps),
|
||||||
|
monthly_budget_gb: Some(raw.monthly_budget_gb),
|
||||||
|
pre_charge_gb: Some(raw.pre_charge_gb),
|
||||||
|
budget_used_pct: used_pct,
|
||||||
|
tokens_remaining_gb: Some(raw.tokens_bytes as f64 / GB),
|
||||||
|
tx_gb: Some(tx_gb),
|
||||||
|
rate_limit_on: Some(raw.state == "throttled"),
|
||||||
|
calendar_month: Some(calendar_month),
|
||||||
|
period_start: Some(period_start),
|
||||||
|
period_resets: Some(period_resets),
|
||||||
|
months,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => BwflowStats::unavailable(sock, format!("{e:#}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn query_status(socket: &Path) -> Result<BwflowRawStatus> {
|
||||||
|
if !socket.exists() {
|
||||||
|
anyhow::bail!("socket not found at {}", socket.display());
|
||||||
|
}
|
||||||
|
let stream = tokio::time::timeout(Duration::from_secs(2), UnixStream::connect(socket))
|
||||||
|
.await
|
||||||
|
.context("connect timeout")?
|
||||||
|
.with_context(|| format!("connect {}", socket.display()))?;
|
||||||
|
|
||||||
|
let (reader, mut writer) = stream.into_split();
|
||||||
|
writer.write_all(b"status\n").await?;
|
||||||
|
writer.shutdown().await.ok();
|
||||||
|
|
||||||
|
let mut lines = BufReader::new(reader).lines();
|
||||||
|
let line = tokio::time::timeout(Duration::from_secs(2), lines.next_line())
|
||||||
|
.await
|
||||||
|
.context("read timeout")?
|
||||||
|
.context("read line")?
|
||||||
|
.context("empty response from bwflow")?;
|
||||||
|
|
||||||
|
let raw: BwflowRawStatus =
|
||||||
|
serde_json::from_str(&line).with_context(|| format!("parse bwflow json: {line}"))?;
|
||||||
|
Ok(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch_vnstat_months(interface: &str) -> Vec<MonthTraffic> {
|
||||||
|
let output = Command::new("vnstat")
|
||||||
|
.args(["--json", "m", "-i", interface])
|
||||||
|
.output();
|
||||||
|
let Ok(out) = output else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
if !out.status.success() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let Ok(v) = serde_json::from_slice::<serde_json::Value>(&out.stdout) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let mut months = Vec::new();
|
||||||
|
let Some(ifaces) = v.get("interfaces").and_then(|x| x.as_array()) else {
|
||||||
|
return months;
|
||||||
|
};
|
||||||
|
for iface in ifaces {
|
||||||
|
let name = iface.get("name").and_then(|n| n.as_str()).unwrap_or("");
|
||||||
|
if name != interface && !interface.is_empty() {
|
||||||
|
// Prefer matching interface; if only one, still take it below.
|
||||||
|
}
|
||||||
|
let Some(arr) = iface
|
||||||
|
.pointer("/traffic/month")
|
||||||
|
.and_then(|m| m.as_array())
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let take = name == interface || ifaces.len() == 1;
|
||||||
|
if !take {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for m in arr {
|
||||||
|
let year = m
|
||||||
|
.pointer("/date/year")
|
||||||
|
.and_then(|x| x.as_i64())
|
||||||
|
.unwrap_or(0) as i32;
|
||||||
|
let month = m
|
||||||
|
.pointer("/date/month")
|
||||||
|
.and_then(|x| x.as_u64())
|
||||||
|
.unwrap_or(0) as u32;
|
||||||
|
let rx = m.get("rx").and_then(|x| x.as_u64()).unwrap_or(0);
|
||||||
|
let tx = m.get("tx").and_then(|x| x.as_u64()).unwrap_or(0);
|
||||||
|
if year > 0 && month > 0 {
|
||||||
|
months.push(MonthTraffic {
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
rx_bytes: rx,
|
||||||
|
tx_bytes: tx,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if name == interface {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Keep last 6 months
|
||||||
|
if months.len() > 6 {
|
||||||
|
let skip = months.len() - 6;
|
||||||
|
months = months.into_iter().skip(skip).collect();
|
||||||
|
}
|
||||||
|
months
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn socket_from_env() -> PathBuf {
|
||||||
|
std::env::var("METERBOX_BWFLOW_SOCK")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|_| PathBuf::from(DEFAULT_SOCKET))
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
/// Background job: age retention + hard SQLite size cap (default 10 GiB).
|
||||||
|
pub fn spawn(state: AppState) {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
// Run once shortly after boot, then on the configured cadence.
|
||||||
|
tokio::time::sleep(Duration::from_secs(15)).await;
|
||||||
|
run_once(&state);
|
||||||
|
|
||||||
|
let secs = state.config.cleanup_interval_secs.max(60);
|
||||||
|
let mut ticker = tokio::time::interval(Duration::from_secs(secs));
|
||||||
|
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||||
|
// Skip the immediate first tick (already ran above).
|
||||||
|
ticker.tick().await;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
ticker.tick().await;
|
||||||
|
run_once(&state);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_once(state: &AppState) {
|
||||||
|
let max = state.config.max_db_bytes;
|
||||||
|
// Aim for 90% of the cap so we don't thrash at the ceiling.
|
||||||
|
let target = (max as f64 * 0.90) as u64;
|
||||||
|
match state
|
||||||
|
.db
|
||||||
|
.run_cleanup(state.config.retention_days, max, target)
|
||||||
|
{
|
||||||
|
Ok(report) => {
|
||||||
|
tracing::info!(
|
||||||
|
deleted_by_age = report.deleted_by_age,
|
||||||
|
deleted_by_size = report.deleted_by_size,
|
||||||
|
vacuumed = report.vacuumed,
|
||||||
|
bytes_before = report.bytes_before,
|
||||||
|
bytes_after = report.bytes_after,
|
||||||
|
rows = report.rows_after,
|
||||||
|
max_bytes = max,
|
||||||
|
"sqlite cleanup finished"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => tracing::warn!(error = %e, "sqlite cleanup failed"),
|
||||||
|
}
|
||||||
|
|
||||||
|
match state.db.get_settings() {
|
||||||
|
Ok(settings) if settings.cleanup_logs_older_than_3_months => {
|
||||||
|
match state
|
||||||
|
.db
|
||||||
|
.prune_health_results_older_than(crate::settings::HEALTH_LOG_RETENTION_DAYS)
|
||||||
|
{
|
||||||
|
Ok(n) if n > 0 => {
|
||||||
|
tracing::info!(deleted = n, "pruned health logs older than 3 months")
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => tracing::warn!(error = %e, "health log prune failed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => tracing::warn!(error = %e, "load settings for cleanup failed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
use crate::metrics::Sampler;
|
||||||
|
|
||||||
|
pub fn spawn(state: AppState) {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut sampler = Sampler::new();
|
||||||
|
// Prime CPU & network counters
|
||||||
|
let _ = sampler.sample();
|
||||||
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||||
|
|
||||||
|
let interval = Duration::from_secs(state.config.interval_secs.max(5));
|
||||||
|
let mut ticker = tokio::time::interval(interval);
|
||||||
|
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
ticker.tick().await;
|
||||||
|
let sample = sampler.sample();
|
||||||
|
if let Err(e) = state.db.insert_sample(&sample) {
|
||||||
|
tracing::error!(error = %e, "failed to insert sample");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emergency size guard: if a write pushed us over the hard cap, trim ASAP.
|
||||||
|
match state.db.on_disk_bytes() {
|
||||||
|
Ok(bytes) if bytes > state.config.max_db_bytes => {
|
||||||
|
tracing::warn!(bytes, max = state.config.max_db_bytes, "db over size cap; emergency cleanup");
|
||||||
|
let target = (state.config.max_db_bytes as f64 * 0.90) as u64;
|
||||||
|
if let Err(e) = state.db.run_cleanup(
|
||||||
|
state.config.retention_days,
|
||||||
|
state.config.max_db_bytes,
|
||||||
|
target,
|
||||||
|
) {
|
||||||
|
tracing::error!(error = %e, "emergency cleanup failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => tracing::debug!(error = %e, "could not stat db size"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
//! Host capacity / budget targets for this instance.
|
||||||
|
//!
|
||||||
|
//! Operational ceilings we track against local stats:
|
||||||
|
//! - ≤ 2 OCPUs and ≤ 12 GB RAM
|
||||||
|
//! - ≤ 200 GB root/block volume
|
||||||
|
//! - ≤ 10 TB outbound data transfer per calendar month
|
||||||
|
//!
|
||||||
|
//! Activity floor (7-day window) — warn when all are simultaneously low:
|
||||||
|
//! - CPU 95th percentile < 20%
|
||||||
|
//! - Network utilization < 20% of shape bandwidth
|
||||||
|
//! - Memory utilization < 20%
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::db::Db;
|
||||||
|
use crate::metrics::{boot_disk_usage, HostInfo, Sample};
|
||||||
|
|
||||||
|
pub const TARGET_OCPU_LIMIT: f64 = 2.0;
|
||||||
|
pub const TARGET_MEM_BYTES: u64 = 12 * 1024 * 1024 * 1024;
|
||||||
|
pub const TARGET_BLOCK_BYTES: u64 = 200 * 1024 * 1024 * 1024;
|
||||||
|
pub const TARGET_EGRESS_BYTES_MONTH: u64 = 10_000_000_000_000; // 10 TB / calendar month
|
||||||
|
|
||||||
|
pub const IDLE_CPU_P95_PCT: f32 = 20.0;
|
||||||
|
pub const IDLE_MEM_PCT: f32 = 20.0;
|
||||||
|
pub const IDLE_NET_UTIL_PCT: f32 = 20.0;
|
||||||
|
|
||||||
|
fn shape_bandwidth_bps(ocpus: f64) -> f64 {
|
||||||
|
ocpus.max(0.125) * 1_000_000_000.0 / 8.0
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct Check {
|
||||||
|
pub id: String,
|
||||||
|
pub label: String,
|
||||||
|
pub status: String, // "pass" | "fail" | "warn" | "info"
|
||||||
|
pub detail: String,
|
||||||
|
pub current: String,
|
||||||
|
pub limit: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct ComplianceReport {
|
||||||
|
pub overall: String, // "compliant" | "at_risk" | "over_limit"
|
||||||
|
pub summary: String,
|
||||||
|
pub tier: String,
|
||||||
|
pub docs_url: String,
|
||||||
|
pub notes: Vec<String>,
|
||||||
|
pub checks: Vec<Check>,
|
||||||
|
pub host: HostSnapshot,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct HostSnapshot {
|
||||||
|
pub ocpus: f64,
|
||||||
|
pub mem_total_bytes: u64,
|
||||||
|
pub disk_total_bytes: u64,
|
||||||
|
pub arch: String,
|
||||||
|
pub is_arm: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn disk_total_bytes() -> u64 {
|
||||||
|
boot_disk_usage().total_bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
fn percentile_95(values: &mut [f32]) -> Option<f32> {
|
||||||
|
if values.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
let idx = ((values.len() as f64 - 1.0) * 0.95).round() as usize;
|
||||||
|
Some(values[idx.min(values.len() - 1)])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn avg_f32(values: &[f32]) -> Option<f32> {
|
||||||
|
if values.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(values.iter().map(|v| *v as f64).sum::<f64>() as f32 / values.len() as f32)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn avg_f64(values: &[f64]) -> Option<f64> {
|
||||||
|
if values.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(values.iter().sum::<f64>() / values.len() as f64)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_bytes(n: u64) -> String {
|
||||||
|
const KIB: f64 = 1024.0;
|
||||||
|
const MIB: f64 = KIB * 1024.0;
|
||||||
|
const GIB: f64 = MIB * 1024.0;
|
||||||
|
const TIB: f64 = GIB * 1024.0;
|
||||||
|
let x = n as f64;
|
||||||
|
if x >= TIB {
|
||||||
|
format!("{:.2} TiB", x / TIB)
|
||||||
|
} else if x >= GIB {
|
||||||
|
format!("{:.2} GiB", x / GIB)
|
||||||
|
} else if x >= MIB {
|
||||||
|
format!("{:.1} MiB", x / MIB)
|
||||||
|
} else {
|
||||||
|
format!("{n} B")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn month_start_ts() -> i64 {
|
||||||
|
use chrono::{Datelike, TimeZone, Utc};
|
||||||
|
let now = Utc::now();
|
||||||
|
Utc.with_ymd_and_hms(now.year(), now.month(), 1, 0, 0, 0)
|
||||||
|
.single()
|
||||||
|
.map(|d| d.timestamp())
|
||||||
|
.unwrap_or(now.timestamp())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn estimate_month_egress(samples: &[Sample], interval_hint_secs: f64) -> u64 {
|
||||||
|
if samples.len() < 2 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let mut total = 0.0f64;
|
||||||
|
for w in samples.windows(2) {
|
||||||
|
let dt = (w[1].ts - w[0].ts).max(1) as f64;
|
||||||
|
let dt = if dt > 3600.0 { interval_hint_secs } else { dt };
|
||||||
|
total += w[1].net_tx_bps * dt;
|
||||||
|
}
|
||||||
|
total.max(0.0) as u64
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn evaluate(
|
||||||
|
db: &Db,
|
||||||
|
host: &HostInfo,
|
||||||
|
interval_secs: u64,
|
||||||
|
bwflow_tx_bytes: Option<u64>,
|
||||||
|
) -> ComplianceReport {
|
||||||
|
let ocpus = host.cpus as f64;
|
||||||
|
let mem = host.mem_total_bytes;
|
||||||
|
let disk = disk_total_bytes();
|
||||||
|
let arch = std::env::consts::ARCH.to_string();
|
||||||
|
let is_arm = arch == "aarch64" || arch == "arm";
|
||||||
|
|
||||||
|
let mut checks = Vec::new();
|
||||||
|
let notes = vec![
|
||||||
|
"Targets apply to this host’s measured shape and traffic.".into(),
|
||||||
|
"Outbound budget is tracked per calendar month and resets on the 1st.".into(),
|
||||||
|
];
|
||||||
|
|
||||||
|
let ocpu_ok = ocpus <= TARGET_OCPU_LIMIT + 0.01;
|
||||||
|
checks.push(Check {
|
||||||
|
id: "ocpu".into(),
|
||||||
|
label: "CPU capacity".into(),
|
||||||
|
status: if ocpu_ok { "pass".into() } else { "fail".into() },
|
||||||
|
detail: if ocpu_ok {
|
||||||
|
"CPU count is within the configured target.".into()
|
||||||
|
} else {
|
||||||
|
"CPU count is above the configured target (2 OCPU).".into()
|
||||||
|
},
|
||||||
|
current: format!("{ocpus:.0} OCPU"),
|
||||||
|
limit: format!("≤ {TARGET_OCPU_LIMIT:.0} OCPU"),
|
||||||
|
});
|
||||||
|
|
||||||
|
let mem_ok = mem <= TARGET_MEM_BYTES + 512 * 1024 * 1024;
|
||||||
|
checks.push(Check {
|
||||||
|
id: "memory".into(),
|
||||||
|
label: "Memory capacity".into(),
|
||||||
|
status: if mem_ok { "pass".into() } else { "fail".into() },
|
||||||
|
detail: if mem_ok {
|
||||||
|
"Installed memory is within the configured target.".into()
|
||||||
|
} else {
|
||||||
|
"Installed memory is above the configured target (12 GB).".into()
|
||||||
|
},
|
||||||
|
current: format_bytes(mem),
|
||||||
|
limit: format!("≤ {}", format_bytes(TARGET_MEM_BYTES)),
|
||||||
|
});
|
||||||
|
|
||||||
|
let disk_ok = disk == 0 || disk <= TARGET_BLOCK_BYTES;
|
||||||
|
checks.push(Check {
|
||||||
|
id: "block".into(),
|
||||||
|
label: "Disk capacity".into(),
|
||||||
|
status: if disk == 0 {
|
||||||
|
"info".into()
|
||||||
|
} else if disk_ok {
|
||||||
|
"pass".into()
|
||||||
|
} else {
|
||||||
|
"fail".into()
|
||||||
|
},
|
||||||
|
detail: if disk == 0 {
|
||||||
|
"Could not read root filesystem size.".into()
|
||||||
|
} else if !disk_ok {
|
||||||
|
"Root volume is above the configured disk target (200 GB).".into()
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"Root volume is within the disk target ({} of {}).",
|
||||||
|
format_bytes(disk),
|
||||||
|
format_bytes(TARGET_BLOCK_BYTES)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
current: if disk == 0 {
|
||||||
|
"unknown".into()
|
||||||
|
} else {
|
||||||
|
format_bytes(disk)
|
||||||
|
},
|
||||||
|
limit: format!("≤ {}", format_bytes(TARGET_BLOCK_BYTES)),
|
||||||
|
});
|
||||||
|
|
||||||
|
checks.push(Check {
|
||||||
|
id: "arch".into(),
|
||||||
|
label: "Architecture".into(),
|
||||||
|
status: if is_arm { "pass".into() } else { "info".into() },
|
||||||
|
detail: if is_arm {
|
||||||
|
"aarch64 host — matches the expected ARM shape for these targets.".into()
|
||||||
|
} else {
|
||||||
|
"Non-ARM host — capacity targets still apply; verify shape separately.".into()
|
||||||
|
},
|
||||||
|
current: arch.clone(),
|
||||||
|
limit: "aarch64 preferred".into(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let now = chrono::Utc::now().timestamp();
|
||||||
|
let week_ago = now - 7 * 24 * 3600;
|
||||||
|
let week_samples = db.query_range(week_ago, now).unwrap_or_default();
|
||||||
|
let mut cpu_vals: Vec<f32> = week_samples.iter().map(|s| s.cpu_pct).collect();
|
||||||
|
let mem_vals: Vec<f32> = week_samples.iter().map(|s| s.mem_pct).collect();
|
||||||
|
let bw = shape_bandwidth_bps(ocpus);
|
||||||
|
let net_utils: Vec<f64> = week_samples
|
||||||
|
.iter()
|
||||||
|
.map(|s| ((s.net_rx_bps + s.net_tx_bps) / bw * 100.0).min(100.0))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let cpu_p95 = percentile_95(&mut cpu_vals);
|
||||||
|
let mem_avg = avg_f32(&mem_vals);
|
||||||
|
let net_avg = avg_f64(&net_utils);
|
||||||
|
|
||||||
|
let idle_cpu = cpu_p95.map(|v| v < IDLE_CPU_P95_PCT).unwrap_or(false);
|
||||||
|
let idle_mem = mem_avg.map(|v| v < IDLE_MEM_PCT).unwrap_or(false);
|
||||||
|
let idle_net = net_avg.map(|v| v < IDLE_NET_UTIL_PCT as f64).unwrap_or(false);
|
||||||
|
let low_activity = !week_samples.is_empty() && idle_cpu && idle_mem && idle_net;
|
||||||
|
|
||||||
|
let coverage_hours = if week_samples.len() < 2 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
(week_samples.last().unwrap().ts - week_samples.first().unwrap().ts) as f64 / 3600.0
|
||||||
|
};
|
||||||
|
let enough_history = coverage_hours >= 24.0;
|
||||||
|
|
||||||
|
let idle_status = if week_samples.is_empty() {
|
||||||
|
"info"
|
||||||
|
} else if low_activity && enough_history {
|
||||||
|
"warn"
|
||||||
|
} else if low_activity {
|
||||||
|
"info"
|
||||||
|
} else {
|
||||||
|
"pass"
|
||||||
|
};
|
||||||
|
|
||||||
|
checks.push(Check {
|
||||||
|
id: "idle_reclaim".into(),
|
||||||
|
label: "Activity floor (7-day)".into(),
|
||||||
|
status: idle_status.into(),
|
||||||
|
detail: {
|
||||||
|
let cpu_s = cpu_p95
|
||||||
|
.map(|v| format!("{v:.1}%"))
|
||||||
|
.unwrap_or_else(|| "n/a".into());
|
||||||
|
let mem_s = mem_avg
|
||||||
|
.map(|v| format!("{v:.1}%"))
|
||||||
|
.unwrap_or_else(|| "n/a".into());
|
||||||
|
let net_s = net_avg
|
||||||
|
.map(|v| format!("{v:.2}%"))
|
||||||
|
.unwrap_or_else(|| "n/a".into());
|
||||||
|
if week_samples.is_empty() {
|
||||||
|
"Not enough samples yet to judge activity.".into()
|
||||||
|
} else if low_activity {
|
||||||
|
format!(
|
||||||
|
"Sustained low activity on collected data (CPU p95 {cpu_s}, mem avg {mem_s}, net util avg {net_s}). History ≈ {coverage_hours:.0}h."
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"Activity above floor. CPU p95 {cpu_s}, mem avg {mem_s}, net util avg {net_s}. History ≈ {coverage_hours:.0}h."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
current: format!(
|
||||||
|
"cpu_p95={} mem_avg={} net_avg={}",
|
||||||
|
cpu_p95.map(|v| format!("{v:.1}%")).unwrap_or_else(|| "n/a".into()),
|
||||||
|
mem_avg.map(|v| format!("{v:.1}%")).unwrap_or_else(|| "n/a".into()),
|
||||||
|
net_avg.map(|v| format!("{v:.2}%")).unwrap_or_else(|| "n/a".into()),
|
||||||
|
),
|
||||||
|
limit: "keep some of: CPU p95≥20%, mem≥20%, or net≥20%".into(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let month_samples = db
|
||||||
|
.query_range(month_start_ts(), now)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let sample_egress = estimate_month_egress(&month_samples, interval_secs as f64);
|
||||||
|
let (egress, egress_source) = match bwflow_tx_bytes {
|
||||||
|
Some(tx) => (tx, "bwflow"),
|
||||||
|
None => (sample_egress, "meterbox samples"),
|
||||||
|
};
|
||||||
|
let egress_ok = egress <= TARGET_EGRESS_BYTES_MONTH;
|
||||||
|
let egress_pct = egress as f64 / TARGET_EGRESS_BYTES_MONTH as f64 * 100.0;
|
||||||
|
checks.push(Check {
|
||||||
|
id: "egress".into(),
|
||||||
|
label: "Outbound data (calendar month)".into(),
|
||||||
|
status: if bwflow_tx_bytes.is_none() && month_samples.len() < 10 {
|
||||||
|
"info".into()
|
||||||
|
} else if !egress_ok {
|
||||||
|
"fail".into()
|
||||||
|
} else if egress_pct > 80.0 {
|
||||||
|
"warn".into()
|
||||||
|
} else {
|
||||||
|
"pass".into()
|
||||||
|
},
|
||||||
|
detail: if bwflow_tx_bytes.is_none() && month_samples.len() < 10 {
|
||||||
|
"Needs bwflow or more samples for this calendar month.".into()
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"Calendar-month egress from {egress_source} ({egress_pct:.2}% of 10 TB target). Resets on the 1st."
|
||||||
|
)
|
||||||
|
},
|
||||||
|
current: format_bytes(egress),
|
||||||
|
limit: "≤ 10 TB / calendar month".into(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let has_fail = checks.iter().any(|c| c.status == "fail");
|
||||||
|
let has_warn = checks.iter().any(|c| c.status == "warn");
|
||||||
|
let (overall, summary) = if has_fail {
|
||||||
|
(
|
||||||
|
"over_limit".into(),
|
||||||
|
"One or more capacity targets are exceeded.".into(),
|
||||||
|
)
|
||||||
|
} else if has_warn {
|
||||||
|
(
|
||||||
|
"at_risk".into(),
|
||||||
|
"Within core targets, with warnings that need attention.".into(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
"compliant".into(),
|
||||||
|
"Host is within configured capacity and budget targets.".into(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
ComplianceReport {
|
||||||
|
overall,
|
||||||
|
summary,
|
||||||
|
tier: "Host capacity targets".into(),
|
||||||
|
docs_url: String::new(),
|
||||||
|
notes,
|
||||||
|
checks,
|
||||||
|
host: HostSnapshot {
|
||||||
|
ocpus,
|
||||||
|
mem_total_bytes: mem,
|
||||||
|
disk_total_bytes: disk,
|
||||||
|
arch,
|
||||||
|
is_arm,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use clap::Parser;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Parser)]
|
||||||
|
#[command(name = "meterbox", about = "Host resource usage dashboard")]
|
||||||
|
pub struct Config {
|
||||||
|
/// Bind address — default localhost only (safe behind Caddy/Rathole)
|
||||||
|
#[arg(long, env = "METERBOX_LISTEN", default_value = "127.0.0.1:8787")]
|
||||||
|
pub listen: String,
|
||||||
|
|
||||||
|
/// SQLite database path
|
||||||
|
#[arg(long, env = "METERBOX_DB", default_value = "data/meterbox.db")]
|
||||||
|
pub db_path: PathBuf,
|
||||||
|
|
||||||
|
/// Directory with built React assets
|
||||||
|
#[arg(long, env = "METERBOX_STATIC", default_value = "frontend/dist")]
|
||||||
|
pub static_dir: PathBuf,
|
||||||
|
|
||||||
|
/// Sample interval in seconds
|
||||||
|
#[arg(long, env = "METERBOX_INTERVAL_SECS", default_value_t = 30)]
|
||||||
|
pub interval_secs: u64,
|
||||||
|
|
||||||
|
/// Retain samples for this many days (max ~3 months)
|
||||||
|
#[arg(long, env = "METERBOX_RETENTION_DAYS", default_value_t = 90)]
|
||||||
|
pub retention_days: i64,
|
||||||
|
|
||||||
|
/// Hard cap for SQLite on-disk size (main + WAL + SHM), bytes. Default 10 GiB.
|
||||||
|
#[arg(long, env = "METERBOX_MAX_DB_BYTES", default_value_t = 10 * 1024 * 1024 * 1024)]
|
||||||
|
pub max_db_bytes: u64,
|
||||||
|
|
||||||
|
/// How often the cleanup job runs (seconds). Default 1 hour.
|
||||||
|
#[arg(long, env = "METERBOX_CLEANUP_INTERVAL_SECS", default_value_t = 3600)]
|
||||||
|
pub cleanup_interval_secs: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn ensure_dirs(&self) -> anyhow::Result<()> {
|
||||||
|
if let Some(parent) = self.db_path.parent() {
|
||||||
|
if !parent.as_os_str().is_empty() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use rusqlite::{params, Connection, OptionalExtension};
|
||||||
|
|
||||||
|
use crate::metrics::Sample;
|
||||||
|
|
||||||
|
pub struct Db {
|
||||||
|
path: PathBuf,
|
||||||
|
pub(crate) conn: Mutex<Connection>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CleanupReport {
|
||||||
|
pub deleted_by_age: usize,
|
||||||
|
pub deleted_by_size: usize,
|
||||||
|
pub vacuumed: bool,
|
||||||
|
pub bytes_before: u64,
|
||||||
|
pub bytes_after: u64,
|
||||||
|
pub rows_after: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Db {
|
||||||
|
pub fn open(path: &Path) -> Result<Self> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
if !parent.as_os_str().is_empty() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let conn = Connection::open(path).with_context(|| format!("open db {}", path.display()))?;
|
||||||
|
conn.execute_batch(
|
||||||
|
"
|
||||||
|
PRAGMA journal_mode = WAL;
|
||||||
|
PRAGMA synchronous = NORMAL;
|
||||||
|
PRAGMA temp_store = MEMORY;
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
",
|
||||||
|
)?;
|
||||||
|
Ok(Self {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
conn: Mutex::new(conn),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn path(&self) -> &Path {
|
||||||
|
&self.path
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn migrate(&self) -> Result<()> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
conn.execute_batch(
|
||||||
|
"
|
||||||
|
CREATE TABLE IF NOT EXISTS samples (
|
||||||
|
ts INTEGER PRIMARY KEY,
|
||||||
|
cpu_pct REAL NOT NULL,
|
||||||
|
mem_used_bytes INTEGER NOT NULL,
|
||||||
|
mem_total_bytes INTEGER NOT NULL,
|
||||||
|
mem_pct REAL NOT NULL,
|
||||||
|
disk_used_bytes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
disk_total_bytes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
disk_pct REAL NOT NULL DEFAULT 0,
|
||||||
|
net_rx_bps REAL NOT NULL,
|
||||||
|
net_tx_bps REAL NOT NULL,
|
||||||
|
load1 REAL NOT NULL,
|
||||||
|
load5 REAL NOT NULL,
|
||||||
|
load15 REAL NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_samples_ts ON samples(ts);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
token_hash TEXT PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS health_sites (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
interval_secs INTEGER NOT NULL DEFAULT 60,
|
||||||
|
timeout_ms INTEGER NOT NULL DEFAULT 10000,
|
||||||
|
expected_status INTEGER NOT NULL DEFAULT 200,
|
||||||
|
expect_body_contains TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
last_check_at INTEGER,
|
||||||
|
last_ok INTEGER,
|
||||||
|
last_status INTEGER,
|
||||||
|
last_latency_ms INTEGER,
|
||||||
|
last_error TEXT,
|
||||||
|
last_via_cloudflare INTEGER,
|
||||||
|
last_cf_fallback INTEGER,
|
||||||
|
last_cf_challenge INTEGER
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_health_sites_url ON health_sites(url);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS health_results (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
site_id INTEGER NOT NULL REFERENCES health_sites(id) ON DELETE CASCADE,
|
||||||
|
checked_at INTEGER NOT NULL,
|
||||||
|
ok INTEGER NOT NULL,
|
||||||
|
status INTEGER NOT NULL,
|
||||||
|
latency_ms INTEGER NOT NULL,
|
||||||
|
via_cloudflare INTEGER NOT NULL,
|
||||||
|
cf_fallback INTEGER NOT NULL,
|
||||||
|
cf_challenge INTEGER NOT NULL,
|
||||||
|
error TEXT,
|
||||||
|
reasons_json TEXT NOT NULL DEFAULT '[]'
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_health_results_site_ts ON health_results(site_id, checked_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
",
|
||||||
|
)?;
|
||||||
|
// Additive migrations for samples disk columns (existing DBs).
|
||||||
|
let _ = conn.execute(
|
||||||
|
"ALTER TABLE samples ADD COLUMN disk_used_bytes INTEGER NOT NULL DEFAULT 0",
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
let _ = conn.execute(
|
||||||
|
"ALTER TABLE samples ADD COLUMN disk_total_bytes INTEGER NOT NULL DEFAULT 0",
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
let _ = conn.execute(
|
||||||
|
"ALTER TABLE samples ADD COLUMN disk_pct REAL NOT NULL DEFAULT 0",
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn insert_sample(&self, s: &Sample) -> Result<()> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO samples
|
||||||
|
(ts, cpu_pct, mem_used_bytes, mem_total_bytes, mem_pct,
|
||||||
|
disk_used_bytes, disk_total_bytes, disk_pct,
|
||||||
|
net_rx_bps, net_tx_bps, load1, load5, load15)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||||
|
params![
|
||||||
|
s.ts,
|
||||||
|
s.cpu_pct,
|
||||||
|
s.mem_used_bytes as i64,
|
||||||
|
s.mem_total_bytes as i64,
|
||||||
|
s.mem_pct,
|
||||||
|
s.disk_used_bytes as i64,
|
||||||
|
s.disk_total_bytes as i64,
|
||||||
|
s.disk_pct,
|
||||||
|
s.net_rx_bps,
|
||||||
|
s.net_tx_bps,
|
||||||
|
s.load1,
|
||||||
|
s.load5,
|
||||||
|
s.load15,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prune_older_than(&self, older_than_ts: i64) -> Result<usize> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let n = conn.execute("DELETE FROM samples WHERE ts < ?1", params![older_than_ts])?;
|
||||||
|
Ok(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete the oldest `limit` rows. Returns how many were removed.
|
||||||
|
pub fn prune_oldest(&self, limit: i64) -> Result<usize> {
|
||||||
|
if limit <= 0 {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let n = conn.execute(
|
||||||
|
"DELETE FROM samples WHERE ts IN (
|
||||||
|
SELECT ts FROM samples ORDER BY ts ASC LIMIT ?1
|
||||||
|
)",
|
||||||
|
params![limit],
|
||||||
|
)?;
|
||||||
|
Ok(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn checkpoint_truncate(&self) -> Result<()> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn vacuum(&self) -> Result<()> {
|
||||||
|
// VACUUM cannot run inside a transaction; unlock and run dedicated connection.
|
||||||
|
{
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
|
||||||
|
}
|
||||||
|
let conn = Connection::open(&self.path)?;
|
||||||
|
conn.execute_batch("VACUUM;")?;
|
||||||
|
// Re-apply WAL on the long-lived connection
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
conn.execute_batch("PRAGMA journal_mode = WAL;")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// On-disk size of main DB + WAL + SHM.
|
||||||
|
pub fn on_disk_bytes(&self) -> Result<u64> {
|
||||||
|
Ok(file_size(&self.path)?
|
||||||
|
+ file_size(Path::new(&format!("{}-wal", self.path.display())))?
|
||||||
|
+ file_size(Path::new(&format!("{}-shm", self.path.display())))?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn latest(&self) -> Result<Option<Sample>> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT ts, cpu_pct, mem_used_bytes, mem_total_bytes, mem_pct,
|
||||||
|
disk_used_bytes, disk_total_bytes, disk_pct,
|
||||||
|
net_rx_bps, net_tx_bps, load1, load5, load15
|
||||||
|
FROM samples ORDER BY ts DESC LIMIT 1",
|
||||||
|
[],
|
||||||
|
row_to_sample,
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn query_range(&self, from_ts: i64, to_ts: i64) -> Result<Vec<Sample>> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT ts, cpu_pct, mem_used_bytes, mem_total_bytes, mem_pct,
|
||||||
|
disk_used_bytes, disk_total_bytes, disk_pct,
|
||||||
|
net_rx_bps, net_tx_bps, load1, load5, load15
|
||||||
|
FROM samples WHERE ts >= ?1 AND ts <= ?2 ORDER BY ts ASC",
|
||||||
|
)?;
|
||||||
|
let rows = stmt.query_map(params![from_ts, to_ts], row_to_sample)?;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for r in rows {
|
||||||
|
out.push(r?);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn count(&self) -> Result<i64> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let n: i64 = conn.query_row("SELECT COUNT(*) FROM samples", [], |r| r.get(0))?;
|
||||||
|
Ok(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn oldest_ts(&self) -> Result<Option<i64>> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
conn.query_row("SELECT MIN(ts) FROM samples", [], |r| r.get(0))
|
||||||
|
.optional()
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Age retention + hard size cap. Shrinks to `target_bytes` when over `max_bytes`.
|
||||||
|
pub fn run_cleanup(
|
||||||
|
&self,
|
||||||
|
retention_days: i64,
|
||||||
|
max_bytes: u64,
|
||||||
|
target_bytes: u64,
|
||||||
|
) -> Result<CleanupReport> {
|
||||||
|
let bytes_before = self.on_disk_bytes()?;
|
||||||
|
let mut deleted_by_age = 0usize;
|
||||||
|
let mut deleted_by_size = 0usize;
|
||||||
|
let mut vacuumed = false;
|
||||||
|
|
||||||
|
let cutoff = chrono::Utc::now().timestamp() - retention_days.max(1) * 24 * 60 * 60;
|
||||||
|
deleted_by_age = self.prune_older_than(cutoff)?;
|
||||||
|
|
||||||
|
// Keep deleting oldest batches until under target when over the hard cap.
|
||||||
|
let mut guard = 0;
|
||||||
|
while self.on_disk_bytes()? > max_bytes.max(target_bytes) && guard < 64 {
|
||||||
|
guard += 1;
|
||||||
|
let rows = self.count()?.max(1);
|
||||||
|
// Remove ~5% of rows each pass (min 1k, max 100k) so we converge quickly.
|
||||||
|
let batch = ((rows as f64) * 0.05).ceil() as i64;
|
||||||
|
let batch = batch.clamp(1_000, 100_000);
|
||||||
|
let n = self.prune_oldest(batch)?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
deleted_by_size += n;
|
||||||
|
let _ = self.checkpoint_truncate();
|
||||||
|
}
|
||||||
|
|
||||||
|
let needs_reclaim = deleted_by_age > 0
|
||||||
|
|| deleted_by_size > 0
|
||||||
|
|| self.on_disk_bytes()? > target_bytes;
|
||||||
|
if needs_reclaim && self.on_disk_bytes()? > target_bytes {
|
||||||
|
self.vacuum()?;
|
||||||
|
vacuumed = true;
|
||||||
|
} else if deleted_by_age > 0 || deleted_by_size > 0 {
|
||||||
|
let _ = self.checkpoint_truncate();
|
||||||
|
}
|
||||||
|
|
||||||
|
// If still over hard cap after vacuum, keep trimming (pathological case).
|
||||||
|
guard = 0;
|
||||||
|
while self.on_disk_bytes()? > max_bytes && guard < 32 {
|
||||||
|
guard += 1;
|
||||||
|
let rows = self.count()?.max(1);
|
||||||
|
let batch = ((rows as f64) * 0.10).ceil() as i64;
|
||||||
|
let batch = batch.clamp(5_000, 200_000);
|
||||||
|
let n = self.prune_oldest(batch)?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
deleted_by_size += n;
|
||||||
|
self.vacuum()?;
|
||||||
|
vacuumed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(CleanupReport {
|
||||||
|
deleted_by_age,
|
||||||
|
deleted_by_size,
|
||||||
|
vacuumed,
|
||||||
|
bytes_before,
|
||||||
|
bytes_after: self.on_disk_bytes()?,
|
||||||
|
rows_after: self.count()?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn file_size(path: &Path) -> Result<u64> {
|
||||||
|
match std::fs::metadata(path) {
|
||||||
|
Ok(m) => Ok(m.len()),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(0),
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row_to_sample(row: &rusqlite::Row<'_>) -> rusqlite::Result<Sample> {
|
||||||
|
Ok(Sample {
|
||||||
|
ts: row.get(0)?,
|
||||||
|
cpu_pct: row.get(1)?,
|
||||||
|
mem_used_bytes: row.get::<_, i64>(2)? as u64,
|
||||||
|
mem_total_bytes: row.get::<_, i64>(3)? as u64,
|
||||||
|
mem_pct: row.get(4)?,
|
||||||
|
disk_used_bytes: row.get::<_, i64>(5)? as u64,
|
||||||
|
disk_total_bytes: row.get::<_, i64>(6)? as u64,
|
||||||
|
disk_pct: row.get(7)?,
|
||||||
|
net_rx_bps: row.get(8)?,
|
||||||
|
net_tx_bps: row.get(9)?,
|
||||||
|
load1: row.get(10)?,
|
||||||
|
load5: row.get(11)?,
|
||||||
|
load15: row.get(12)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,596 @@
|
|||||||
|
//! Configurable site health checks stored in SQLite.
|
||||||
|
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
use rusqlite::{params, OptionalExtension};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::db::Db;
|
||||||
|
use crate::probe::{probe_url, ProbeResult};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct HealthSite {
|
||||||
|
pub id: i64,
|
||||||
|
pub name: String,
|
||||||
|
pub url: String,
|
||||||
|
pub enabled: bool,
|
||||||
|
pub interval_secs: i64,
|
||||||
|
pub timeout_ms: i64,
|
||||||
|
pub expected_status: i64,
|
||||||
|
pub expect_body_contains: Option<String>,
|
||||||
|
pub created_at: i64,
|
||||||
|
pub updated_at: i64,
|
||||||
|
pub last_check_at: Option<i64>,
|
||||||
|
pub last_ok: Option<bool>,
|
||||||
|
pub last_status: Option<i64>,
|
||||||
|
pub last_latency_ms: Option<i64>,
|
||||||
|
pub last_error: Option<String>,
|
||||||
|
pub last_via_cloudflare: Option<bool>,
|
||||||
|
pub last_cf_fallback: Option<bool>,
|
||||||
|
pub last_cf_challenge: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct UpsertSite {
|
||||||
|
pub name: String,
|
||||||
|
pub url: String,
|
||||||
|
pub enabled: Option<bool>,
|
||||||
|
pub interval_secs: Option<i64>,
|
||||||
|
pub timeout_ms: Option<i64>,
|
||||||
|
pub expected_status: Option<i64>,
|
||||||
|
pub expect_body_contains: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct HealthCheckResult {
|
||||||
|
pub id: i64,
|
||||||
|
pub site_id: i64,
|
||||||
|
pub checked_at: i64,
|
||||||
|
pub ok: bool,
|
||||||
|
pub status: i64,
|
||||||
|
pub latency_ms: i64,
|
||||||
|
pub via_cloudflare: bool,
|
||||||
|
pub cf_fallback: bool,
|
||||||
|
pub cf_challenge: bool,
|
||||||
|
pub error: Option<String>,
|
||||||
|
pub reasons: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct HealthIncident {
|
||||||
|
pub started_at: i64,
|
||||||
|
pub ended_at: Option<i64>,
|
||||||
|
pub duration_secs: Option<i64>,
|
||||||
|
pub ongoing: bool,
|
||||||
|
pub error: String,
|
||||||
|
pub status: Option<i64>,
|
||||||
|
pub checks: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct HistorySummary {
|
||||||
|
pub checks: i64,
|
||||||
|
pub ok: i64,
|
||||||
|
pub down: i64,
|
||||||
|
pub uptime_pct: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct SiteHistory {
|
||||||
|
pub site: HealthSite,
|
||||||
|
pub range: String,
|
||||||
|
pub from_ts: i64,
|
||||||
|
pub to_ts: i64,
|
||||||
|
pub bucket_secs: i64,
|
||||||
|
pub summary: HistorySummary,
|
||||||
|
pub incidents: Vec<HealthIncident>,
|
||||||
|
pub series: Vec<HealthSeriesPoint>,
|
||||||
|
pub results: Vec<HealthCheckResult>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct HealthSeriesPoint {
|
||||||
|
pub ts: i64,
|
||||||
|
/// Fraction healthy in bucket, 0–100.
|
||||||
|
pub uptime_pct: f64,
|
||||||
|
pub ok: i64,
|
||||||
|
pub down: i64,
|
||||||
|
pub avg_latency_ms: f64,
|
||||||
|
/// 1 if any check failed in the bucket, else 0 (for status chart).
|
||||||
|
pub had_down: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_health_range(range: &str) -> (i64, i64) {
|
||||||
|
// (window_secs, bucket_secs)
|
||||||
|
match range {
|
||||||
|
"1h" => (3600, 60),
|
||||||
|
"12h" => (12 * 3600, 5 * 60),
|
||||||
|
"24h" => (24 * 3600, 10 * 60),
|
||||||
|
"7d" => (7 * 86400, 3600),
|
||||||
|
"30d" => (30 * 86400, 4 * 3600),
|
||||||
|
_ => (24 * 3600, 10 * 60),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collapse consecutive failures into downtime incidents (newest first).
|
||||||
|
fn derive_incidents(results_newest_first: &[HealthCheckResult]) -> Vec<HealthIncident> {
|
||||||
|
let mut chronological: Vec<&HealthCheckResult> = results_newest_first.iter().collect();
|
||||||
|
chronological.reverse();
|
||||||
|
|
||||||
|
let mut incidents = Vec::new();
|
||||||
|
let mut current: Option<(i64, String, Option<i64>, i64)> = None; // start, error, status, checks
|
||||||
|
|
||||||
|
for r in &chronological {
|
||||||
|
if !r.ok {
|
||||||
|
match &mut current {
|
||||||
|
Some((_start, error, status, checks)) => {
|
||||||
|
*checks += 1;
|
||||||
|
if error.is_empty() {
|
||||||
|
if let Some(e) = &r.error {
|
||||||
|
*error = e.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if status.is_none() && r.status > 0 {
|
||||||
|
*status = Some(r.status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
current = Some((
|
||||||
|
r.checked_at,
|
||||||
|
r.error.clone().unwrap_or_else(|| "health check failed".into()),
|
||||||
|
if r.status > 0 { Some(r.status) } else { None },
|
||||||
|
1,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let Some((started_at, error, status, checks)) = current.take() {
|
||||||
|
incidents.push(HealthIncident {
|
||||||
|
started_at,
|
||||||
|
ended_at: Some(r.checked_at),
|
||||||
|
duration_secs: Some((r.checked_at - started_at).max(0)),
|
||||||
|
ongoing: false,
|
||||||
|
error,
|
||||||
|
status,
|
||||||
|
checks,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((started_at, error, status, checks)) = current {
|
||||||
|
let ended = chronological.last().map(|r| r.checked_at);
|
||||||
|
incidents.push(HealthIncident {
|
||||||
|
started_at,
|
||||||
|
ended_at: None,
|
||||||
|
duration_secs: ended.map(|e| (e - started_at).max(0)),
|
||||||
|
ongoing: true,
|
||||||
|
error,
|
||||||
|
status,
|
||||||
|
checks,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
incidents.reverse(); // newest first
|
||||||
|
incidents
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Db {
|
||||||
|
pub fn list_health_sites(&self) -> Result<Vec<HealthSite>> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT id, name, url, enabled, interval_secs, timeout_ms, expected_status,
|
||||||
|
expect_body_contains, created_at, updated_at, last_check_at, last_ok,
|
||||||
|
last_status, last_latency_ms, last_error, last_via_cloudflare,
|
||||||
|
last_cf_fallback, last_cf_challenge
|
||||||
|
FROM health_sites ORDER BY name COLLATE NOCASE ASC",
|
||||||
|
)?;
|
||||||
|
let rows = stmt.query_map([], row_to_site)?;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for r in rows {
|
||||||
|
out.push(r?);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_health_site(&self, id: i64) -> Result<Option<HealthSite>> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT id, name, url, enabled, interval_secs, timeout_ms, expected_status,
|
||||||
|
expect_body_contains, created_at, updated_at, last_check_at, last_ok,
|
||||||
|
last_status, last_latency_ms, last_error, last_via_cloudflare,
|
||||||
|
last_cf_fallback, last_cf_challenge
|
||||||
|
FROM health_sites WHERE id = ?1",
|
||||||
|
params![id],
|
||||||
|
row_to_site,
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn insert_health_site(&self, input: &UpsertSite) -> Result<HealthSite> {
|
||||||
|
let name = input.name.trim();
|
||||||
|
let url = input.url.trim();
|
||||||
|
if name.is_empty() {
|
||||||
|
bail!("name is required");
|
||||||
|
}
|
||||||
|
if url.is_empty() {
|
||||||
|
bail!("url is required");
|
||||||
|
}
|
||||||
|
let now = chrono::Utc::now().timestamp();
|
||||||
|
let enabled = input.enabled.unwrap_or(true);
|
||||||
|
let interval_secs = input.interval_secs.unwrap_or(60).clamp(15, 3600);
|
||||||
|
let timeout_ms = input.timeout_ms.unwrap_or(10_000).clamp(1000, 60_000);
|
||||||
|
let expected_status = input.expected_status.unwrap_or(200).clamp(100, 599);
|
||||||
|
let expect_body = input
|
||||||
|
.expect_body_contains
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty());
|
||||||
|
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO health_sites
|
||||||
|
(name, url, enabled, interval_secs, timeout_ms, expected_status, expect_body_contains, created_at, updated_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||||
|
params![
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
enabled as i64,
|
||||||
|
interval_secs,
|
||||||
|
timeout_ms,
|
||||||
|
expected_status,
|
||||||
|
expect_body,
|
||||||
|
now,
|
||||||
|
now
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.context("insert health site")?;
|
||||||
|
let id = conn.last_insert_rowid();
|
||||||
|
drop(conn);
|
||||||
|
self.get_health_site(id)?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("site missing after insert"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_health_site(&self, id: i64, input: &UpsertSite) -> Result<HealthSite> {
|
||||||
|
let existing = self
|
||||||
|
.get_health_site(id)?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("site not found"))?;
|
||||||
|
let name = input.name.trim();
|
||||||
|
let url = input.url.trim();
|
||||||
|
if name.is_empty() || url.is_empty() {
|
||||||
|
bail!("name and url are required");
|
||||||
|
}
|
||||||
|
let now = chrono::Utc::now().timestamp();
|
||||||
|
let enabled = input.enabled.unwrap_or(existing.enabled);
|
||||||
|
let interval_secs = input
|
||||||
|
.interval_secs
|
||||||
|
.unwrap_or(existing.interval_secs)
|
||||||
|
.clamp(15, 3600);
|
||||||
|
let timeout_ms = input
|
||||||
|
.timeout_ms
|
||||||
|
.unwrap_or(existing.timeout_ms)
|
||||||
|
.clamp(1000, 60_000);
|
||||||
|
let expected_status = input
|
||||||
|
.expected_status
|
||||||
|
.unwrap_or(existing.expected_status)
|
||||||
|
.clamp(100, 599);
|
||||||
|
let expect_body = input
|
||||||
|
.expect_body_contains
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty());
|
||||||
|
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE health_sites SET
|
||||||
|
name = ?1, url = ?2, enabled = ?3, interval_secs = ?4, timeout_ms = ?5,
|
||||||
|
expected_status = ?6, expect_body_contains = ?7, updated_at = ?8
|
||||||
|
WHERE id = ?9",
|
||||||
|
params![
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
enabled as i64,
|
||||||
|
interval_secs,
|
||||||
|
timeout_ms,
|
||||||
|
expected_status,
|
||||||
|
expect_body,
|
||||||
|
now,
|
||||||
|
id
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
drop(conn);
|
||||||
|
self.get_health_site(id)?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("site missing after update"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_health_site(&self, id: i64) -> Result<bool> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let n = conn.execute("DELETE FROM health_sites WHERE id = ?1", params![id])?;
|
||||||
|
Ok(n > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_health_result(&self, site_id: i64, probe: &ProbeResult) -> Result<()> {
|
||||||
|
let now = chrono::Utc::now().timestamp();
|
||||||
|
let error_text = if probe.ok {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(
|
||||||
|
probe
|
||||||
|
.error
|
||||||
|
.clone()
|
||||||
|
.filter(|s| !s.trim().is_empty())
|
||||||
|
.unwrap_or_else(|| probe.reasons.join("; ")),
|
||||||
|
)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.or_else(|| Some("health check failed".into()))
|
||||||
|
};
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO health_results
|
||||||
|
(site_id, checked_at, ok, status, latency_ms, via_cloudflare, cf_fallback, cf_challenge, error, reasons_json)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||||
|
params![
|
||||||
|
site_id,
|
||||||
|
now,
|
||||||
|
probe.ok as i64,
|
||||||
|
probe.status as i64,
|
||||||
|
probe.latency_ms as i64,
|
||||||
|
probe.via_cloudflare as i64,
|
||||||
|
probe.cloudflare_fallback as i64,
|
||||||
|
probe.cloudflare_challenge as i64,
|
||||||
|
error_text,
|
||||||
|
serde_json::to_string(&probe.reasons).unwrap_or_else(|_| "[]".into()),
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE health_sites SET
|
||||||
|
last_check_at = ?1,
|
||||||
|
last_ok = ?2,
|
||||||
|
last_status = ?3,
|
||||||
|
last_latency_ms = ?4,
|
||||||
|
last_error = ?5,
|
||||||
|
last_via_cloudflare = ?6,
|
||||||
|
last_cf_fallback = ?7,
|
||||||
|
last_cf_challenge = ?8,
|
||||||
|
updated_at = ?1
|
||||||
|
WHERE id = ?9",
|
||||||
|
params![
|
||||||
|
now,
|
||||||
|
probe.ok as i64,
|
||||||
|
probe.status as i64,
|
||||||
|
probe.latency_ms as i64,
|
||||||
|
error_text,
|
||||||
|
probe.via_cloudflare as i64,
|
||||||
|
probe.cloudflare_fallback as i64,
|
||||||
|
probe.cloudflare_challenge as i64,
|
||||||
|
site_id
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
// Age pruning of health logs is controlled by settings (3-month checkbox).
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_health_results_since(
|
||||||
|
&self,
|
||||||
|
site_id: i64,
|
||||||
|
since: i64,
|
||||||
|
limit: i64,
|
||||||
|
) -> Result<Vec<HealthCheckResult>> {
|
||||||
|
let limit = limit.clamp(1, 50_000);
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT id, site_id, checked_at, ok, status, latency_ms, via_cloudflare,
|
||||||
|
cf_fallback, cf_challenge, error, reasons_json
|
||||||
|
FROM health_results
|
||||||
|
WHERE site_id = ?1 AND checked_at >= ?2
|
||||||
|
ORDER BY checked_at DESC
|
||||||
|
LIMIT ?3",
|
||||||
|
)?;
|
||||||
|
let rows = stmt.query_map(params![site_id, since, limit], row_to_result)?;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for r in rows {
|
||||||
|
out.push(r?);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn site_history(&self, site_id: i64, range: &str) -> Result<Option<SiteHistory>> {
|
||||||
|
let site = match self.get_health_site(site_id)? {
|
||||||
|
Some(s) => s,
|
||||||
|
None => return Ok(None),
|
||||||
|
};
|
||||||
|
let (window, bucket) = parse_health_range(range);
|
||||||
|
let to_ts = chrono::Utc::now().timestamp();
|
||||||
|
let from_ts = to_ts - window;
|
||||||
|
let results = self.list_health_results_since(site_id, from_ts, 50_000)?;
|
||||||
|
let total = results.len() as i64;
|
||||||
|
let ok_count = results.iter().filter(|r| r.ok).count() as i64;
|
||||||
|
let down_count = total - ok_count;
|
||||||
|
let uptime_pct = if total == 0 {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some((ok_count as f64) * 100.0 / (total as f64))
|
||||||
|
};
|
||||||
|
let incidents = derive_incidents(&results);
|
||||||
|
let series = bucket_results(&results, from_ts, to_ts, bucket);
|
||||||
|
Ok(Some(SiteHistory {
|
||||||
|
site,
|
||||||
|
range: normalize_range(range),
|
||||||
|
from_ts,
|
||||||
|
to_ts,
|
||||||
|
bucket_secs: bucket,
|
||||||
|
results,
|
||||||
|
incidents,
|
||||||
|
series,
|
||||||
|
summary: HistorySummary {
|
||||||
|
checks: total,
|
||||||
|
ok: ok_count,
|
||||||
|
down: down_count,
|
||||||
|
uptime_pct,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sites_due_for_check(&self, now: i64) -> Result<Vec<HealthSite>> {
|
||||||
|
let sites = self.list_health_sites()?;
|
||||||
|
Ok(sites
|
||||||
|
.into_iter()
|
||||||
|
.filter(|s| s.enabled)
|
||||||
|
.filter(|s| match s.last_check_at {
|
||||||
|
None => true,
|
||||||
|
Some(ts) => now - ts >= s.interval_secs,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row_to_result(row: &rusqlite::Row<'_>) -> rusqlite::Result<HealthCheckResult> {
|
||||||
|
let reasons_json: String = row.get(10)?;
|
||||||
|
let reasons: Vec<String> = serde_json::from_str(&reasons_json).unwrap_or_default();
|
||||||
|
Ok(HealthCheckResult {
|
||||||
|
id: row.get(0)?,
|
||||||
|
site_id: row.get(1)?,
|
||||||
|
checked_at: row.get(2)?,
|
||||||
|
ok: row.get::<_, i64>(3)? != 0,
|
||||||
|
status: row.get(4)?,
|
||||||
|
latency_ms: row.get(5)?,
|
||||||
|
via_cloudflare: row.get::<_, i64>(6)? != 0,
|
||||||
|
cf_fallback: row.get::<_, i64>(7)? != 0,
|
||||||
|
cf_challenge: row.get::<_, i64>(8)? != 0,
|
||||||
|
error: row.get(9)?,
|
||||||
|
reasons,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_range(range: &str) -> String {
|
||||||
|
match range {
|
||||||
|
"1h" | "12h" | "24h" | "7d" | "30d" => range.to_string(),
|
||||||
|
_ => "24h".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bucket_results(
|
||||||
|
results_newest_first: &[HealthCheckResult],
|
||||||
|
from_ts: i64,
|
||||||
|
to_ts: i64,
|
||||||
|
bucket: i64,
|
||||||
|
) -> Vec<HealthSeriesPoint> {
|
||||||
|
if bucket <= 0 || to_ts <= from_ts {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let n = ((to_ts - from_ts) / bucket) as usize + 1;
|
||||||
|
let mut ok = vec![0i64; n];
|
||||||
|
let mut down = vec![0i64; n];
|
||||||
|
let mut lat_sum = vec![0f64; n];
|
||||||
|
let mut lat_n = vec![0i64; n];
|
||||||
|
|
||||||
|
for r in results_newest_first {
|
||||||
|
if r.checked_at < from_ts || r.checked_at > to_ts {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let idx = ((r.checked_at - from_ts) / bucket) as usize;
|
||||||
|
let idx = idx.min(n - 1);
|
||||||
|
if r.ok {
|
||||||
|
ok[idx] += 1;
|
||||||
|
} else {
|
||||||
|
down[idx] += 1;
|
||||||
|
}
|
||||||
|
lat_sum[idx] += r.latency_ms as f64;
|
||||||
|
lat_n[idx] += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
(0..n)
|
||||||
|
.map(|i| {
|
||||||
|
let total = ok[i] + down[i];
|
||||||
|
let uptime_pct = if total == 0 {
|
||||||
|
// empty bucket: treat as unknown → show as 100 only if we leave gaps;
|
||||||
|
// use NaN-friendly 100 for continuity when no data, but mark had_down=0
|
||||||
|
f64::NAN
|
||||||
|
} else {
|
||||||
|
(ok[i] as f64) * 100.0 / (total as f64)
|
||||||
|
};
|
||||||
|
HealthSeriesPoint {
|
||||||
|
ts: from_ts + (i as i64) * bucket,
|
||||||
|
uptime_pct: if uptime_pct.is_nan() { 100.0 } else { uptime_pct },
|
||||||
|
ok: ok[i],
|
||||||
|
down: down[i],
|
||||||
|
avg_latency_ms: if lat_n[i] == 0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
lat_sum[i] / lat_n[i] as f64
|
||||||
|
},
|
||||||
|
had_down: if down[i] > 0 { 1 } else { 0 },
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(|p| p.ok + p.down > 0) // only buckets with data
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row_to_site(row: &rusqlite::Row<'_>) -> rusqlite::Result<HealthSite> {
|
||||||
|
Ok(HealthSite {
|
||||||
|
id: row.get(0)?,
|
||||||
|
name: row.get(1)?,
|
||||||
|
url: row.get(2)?,
|
||||||
|
enabled: row.get::<_, i64>(3)? != 0,
|
||||||
|
interval_secs: row.get(4)?,
|
||||||
|
timeout_ms: row.get(5)?,
|
||||||
|
expected_status: row.get(6)?,
|
||||||
|
expect_body_contains: row.get(7)?,
|
||||||
|
created_at: row.get(8)?,
|
||||||
|
updated_at: row.get(9)?,
|
||||||
|
last_check_at: row.get(10)?,
|
||||||
|
last_ok: row
|
||||||
|
.get::<_, Option<i64>>(11)?
|
||||||
|
.map(|v| v != 0),
|
||||||
|
last_status: row.get(12)?,
|
||||||
|
last_latency_ms: row.get(13)?,
|
||||||
|
last_error: row.get(14)?,
|
||||||
|
last_via_cloudflare: row
|
||||||
|
.get::<_, Option<i64>>(15)?
|
||||||
|
.map(|v| v != 0),
|
||||||
|
last_cf_fallback: row
|
||||||
|
.get::<_, Option<i64>>(16)?
|
||||||
|
.map(|v| v != 0),
|
||||||
|
last_cf_challenge: row
|
||||||
|
.get::<_, Option<i64>>(17)?
|
||||||
|
.map(|v| v != 0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn spawn(state: crate::AppState) {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut ticker = tokio::time::interval(std::time::Duration::from_secs(15));
|
||||||
|
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||||
|
loop {
|
||||||
|
ticker.tick().await;
|
||||||
|
let _ = state.db.prune_expired_sessions();
|
||||||
|
let now = chrono::Utc::now().timestamp();
|
||||||
|
let due = match state.db.sites_due_for_check(now) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "list due health sites failed");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for site in due {
|
||||||
|
let expect = site.expect_body_contains.clone();
|
||||||
|
let probe = probe_url(
|
||||||
|
&site.url,
|
||||||
|
site.expected_status as u16,
|
||||||
|
expect.as_deref(),
|
||||||
|
site.timeout_ms as u64,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
if let Err(e) = state.db.record_health_result(site.id, &probe) {
|
||||||
|
tracing::warn!(site_id = site.id, error = %e, "record health result failed");
|
||||||
|
} else if !probe.ok {
|
||||||
|
tracing::info!(
|
||||||
|
site = %site.name,
|
||||||
|
status = probe.status,
|
||||||
|
cf_fallback = probe.cloudflare_fallback,
|
||||||
|
cf_challenge = probe.cloudflare_challenge,
|
||||||
|
"health check failed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
mod api;
|
||||||
|
mod auth;
|
||||||
|
mod bwflow;
|
||||||
|
mod cleanup;
|
||||||
|
mod collector;
|
||||||
|
mod compliance;
|
||||||
|
mod config;
|
||||||
|
mod db;
|
||||||
|
mod health;
|
||||||
|
mod metrics;
|
||||||
|
mod probe;
|
||||||
|
mod settings;
|
||||||
|
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::Router;
|
||||||
|
use clap::Parser;
|
||||||
|
use tower_http::compression::CompressionLayer;
|
||||||
|
use tower_http::cors::{Any, CorsLayer};
|
||||||
|
use tower_http::services::{ServeDir, ServeFile};
|
||||||
|
use tower_http::trace::TraceLayer;
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::db::Db;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub db: Arc<Db>,
|
||||||
|
pub config: Arc<Config>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "meterbox=info".into()))
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let config = Arc::new(Config::parse());
|
||||||
|
config.ensure_dirs()?;
|
||||||
|
|
||||||
|
let db = Arc::new(Db::open(&config.db_path)?);
|
||||||
|
db.migrate()?;
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
db: db.clone(),
|
||||||
|
config: config.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
collector::spawn(state.clone());
|
||||||
|
cleanup::spawn(state.clone());
|
||||||
|
health::spawn(state.clone());
|
||||||
|
|
||||||
|
let static_dir = config.static_dir.clone();
|
||||||
|
let index = static_dir.join("index.html");
|
||||||
|
|
||||||
|
let api = api::router(state);
|
||||||
|
|
||||||
|
let app = Router::new()
|
||||||
|
.nest("/api", api)
|
||||||
|
.fallback_service(
|
||||||
|
ServeDir::new(&static_dir)
|
||||||
|
.not_found_service(ServeFile::new(index)),
|
||||||
|
)
|
||||||
|
.layer(CompressionLayer::new())
|
||||||
|
.layer(
|
||||||
|
CorsLayer::new()
|
||||||
|
.allow_origin(Any)
|
||||||
|
.allow_methods(Any)
|
||||||
|
.allow_headers(Any),
|
||||||
|
)
|
||||||
|
.layer(TraceLayer::new_for_http());
|
||||||
|
|
||||||
|
let addr: SocketAddr = config.listen.parse()?;
|
||||||
|
tracing::info!(%addr, db = %config.db_path.display(), "meterbox listening");
|
||||||
|
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||||
|
axum::serve(listener, app).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn default_static() -> PathBuf {
|
||||||
|
PathBuf::from("frontend/dist")
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
use sysinfo::{Networks, System};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct Sample {
|
||||||
|
pub ts: i64,
|
||||||
|
pub cpu_pct: f32,
|
||||||
|
pub mem_used_bytes: u64,
|
||||||
|
pub mem_total_bytes: u64,
|
||||||
|
pub mem_pct: f32,
|
||||||
|
pub disk_used_bytes: u64,
|
||||||
|
pub disk_total_bytes: u64,
|
||||||
|
pub disk_pct: f32,
|
||||||
|
pub net_rx_bps: f64,
|
||||||
|
pub net_tx_bps: f64,
|
||||||
|
pub load1: f64,
|
||||||
|
pub load5: f64,
|
||||||
|
pub load15: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct HostInfo {
|
||||||
|
pub hostname: String,
|
||||||
|
pub os: String,
|
||||||
|
pub kernel: String,
|
||||||
|
pub cpus: usize,
|
||||||
|
pub mem_total_bytes: u64,
|
||||||
|
pub disk_used_bytes: u64,
|
||||||
|
pub disk_total_bytes: u64,
|
||||||
|
pub disk_pct: f32,
|
||||||
|
pub uptime_secs: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct DiskUsage {
|
||||||
|
pub used_bytes: u64,
|
||||||
|
pub total_bytes: u64,
|
||||||
|
pub pct: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Boot / root filesystem usage via `statvfs("/")`.
|
||||||
|
pub fn boot_disk_usage() -> DiskUsage {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
use std::ffi::CString;
|
||||||
|
let Ok(path) = CString::new("/") else {
|
||||||
|
return DiskUsage {
|
||||||
|
used_bytes: 0,
|
||||||
|
total_bytes: 0,
|
||||||
|
pct: 0.0,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
let mut buf: libc::statvfs = unsafe { std::mem::zeroed() };
|
||||||
|
let rc = unsafe { libc::statvfs(path.as_ptr(), &mut buf) };
|
||||||
|
if rc != 0 {
|
||||||
|
return DiskUsage {
|
||||||
|
used_bytes: 0,
|
||||||
|
total_bytes: 0,
|
||||||
|
pct: 0.0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let frsize = buf.f_frsize as u64;
|
||||||
|
let total = buf.f_blocks as u64 * frsize;
|
||||||
|
let available = buf.f_bavail as u64 * frsize;
|
||||||
|
let used = total.saturating_sub(available);
|
||||||
|
let pct = if total > 0 {
|
||||||
|
(used as f64 / total as f64 * 100.0) as f32
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
return DiskUsage {
|
||||||
|
used_bytes: used,
|
||||||
|
total_bytes: total,
|
||||||
|
pct,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
{
|
||||||
|
DiskUsage {
|
||||||
|
used_bytes: 0,
|
||||||
|
total_bytes: 0,
|
||||||
|
pct: 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Sampler {
|
||||||
|
sys: System,
|
||||||
|
networks: Networks,
|
||||||
|
last_rx: u64,
|
||||||
|
last_tx: u64,
|
||||||
|
last_ts: Option<i64>,
|
||||||
|
primed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Sampler {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let mut sys = System::new();
|
||||||
|
sys.refresh_memory();
|
||||||
|
sys.refresh_cpu_all();
|
||||||
|
let networks = Networks::new_with_refreshed_list();
|
||||||
|
Self {
|
||||||
|
sys,
|
||||||
|
networks,
|
||||||
|
last_rx: 0,
|
||||||
|
last_tx: 0,
|
||||||
|
last_ts: None,
|
||||||
|
primed: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn host_info(&mut self) -> HostInfo {
|
||||||
|
self.sys.refresh_memory();
|
||||||
|
let disk = boot_disk_usage();
|
||||||
|
HostInfo {
|
||||||
|
hostname: hostname::get()
|
||||||
|
.ok()
|
||||||
|
.and_then(|h| h.into_string().ok())
|
||||||
|
.unwrap_or_else(|| "unknown".into()),
|
||||||
|
os: System::long_os_version().unwrap_or_else(|| "unknown".into()),
|
||||||
|
kernel: System::kernel_version().unwrap_or_else(|| "unknown".into()),
|
||||||
|
cpus: self
|
||||||
|
.sys
|
||||||
|
.physical_core_count()
|
||||||
|
.unwrap_or_else(|| self.sys.cpus().len())
|
||||||
|
.max(1),
|
||||||
|
mem_total_bytes: self.sys.total_memory(),
|
||||||
|
disk_used_bytes: disk.used_bytes,
|
||||||
|
disk_total_bytes: disk.total_bytes,
|
||||||
|
disk_pct: disk.pct,
|
||||||
|
uptime_secs: System::uptime(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sample(&mut self) -> Sample {
|
||||||
|
self.sys.refresh_cpu_all();
|
||||||
|
self.sys.refresh_memory();
|
||||||
|
self.networks.refresh(true);
|
||||||
|
|
||||||
|
let now = chrono::Utc::now().timestamp();
|
||||||
|
let cpu_pct = self.sys.global_cpu_usage();
|
||||||
|
let mem_total = self.sys.total_memory();
|
||||||
|
let mem_used = self.sys.used_memory();
|
||||||
|
let mem_pct = if mem_total > 0 {
|
||||||
|
(mem_used as f64 / mem_total as f64 * 100.0) as f32
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
let disk = boot_disk_usage();
|
||||||
|
|
||||||
|
let (rx, tx) = aggregate_networks(&self.networks);
|
||||||
|
let (rx_bps, tx_bps) = if let Some(prev) = self.last_ts {
|
||||||
|
let dt = (now - prev).max(1) as f64;
|
||||||
|
if self.primed && rx >= self.last_rx && tx >= self.last_tx {
|
||||||
|
(
|
||||||
|
(rx - self.last_rx) as f64 / dt,
|
||||||
|
(tx - self.last_tx) as f64 / dt,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(0.0, 0.0)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(0.0, 0.0)
|
||||||
|
};
|
||||||
|
|
||||||
|
self.last_rx = rx;
|
||||||
|
self.last_tx = tx;
|
||||||
|
self.last_ts = Some(now);
|
||||||
|
self.primed = true;
|
||||||
|
|
||||||
|
let load = System::load_average();
|
||||||
|
|
||||||
|
Sample {
|
||||||
|
ts: now,
|
||||||
|
cpu_pct,
|
||||||
|
mem_used_bytes: mem_used,
|
||||||
|
mem_total_bytes: mem_total,
|
||||||
|
mem_pct,
|
||||||
|
disk_used_bytes: disk.used_bytes,
|
||||||
|
disk_total_bytes: disk.total_bytes,
|
||||||
|
disk_pct: disk.pct,
|
||||||
|
net_rx_bps: rx_bps,
|
||||||
|
net_tx_bps: tx_bps,
|
||||||
|
load1: load.one,
|
||||||
|
load5: load.five,
|
||||||
|
load15: load.fifteen,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn aggregate_networks(networks: &Networks) -> (u64, u64) {
|
||||||
|
let mut rx = 0u64;
|
||||||
|
let mut tx = 0u64;
|
||||||
|
for (name, data) in networks.iter() {
|
||||||
|
let n = name.to_lowercase();
|
||||||
|
if n == "lo" || n.starts_with("docker") || n.starts_with("br-") || n.starts_with("veth") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
rx = rx.saturating_add(data.total_received());
|
||||||
|
tx = tx.saturating_add(data.total_transmitted());
|
||||||
|
}
|
||||||
|
(rx, tx)
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
//! HTTP health probes with Cloudflare fallback / challenge detection.
|
||||||
|
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct ProbeResult {
|
||||||
|
pub ok: bool,
|
||||||
|
pub url: String,
|
||||||
|
pub final_url: String,
|
||||||
|
pub status: u16,
|
||||||
|
pub latency_ms: u64,
|
||||||
|
pub server: Option<String>,
|
||||||
|
pub via_cloudflare: bool,
|
||||||
|
pub cloudflare_fallback: bool,
|
||||||
|
pub cloudflare_challenge: bool,
|
||||||
|
pub body_snippet: String,
|
||||||
|
pub error: Option<String>,
|
||||||
|
pub reasons: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const CF_ORIGIN_ERRORS: &[u16] = &[520, 521, 522, 523, 524, 525, 526, 527, 530];
|
||||||
|
|
||||||
|
const CF_BODY_MARKERS: &[&str] = &[
|
||||||
|
"cloudflare ray id",
|
||||||
|
"cf-error-details",
|
||||||
|
"attention required! | cloudflare",
|
||||||
|
"just a moment...",
|
||||||
|
"enable javascript and cookies to continue",
|
||||||
|
"cdn-cgi/challenge",
|
||||||
|
"cdn-cgi/l/chk_jschl",
|
||||||
|
"always online",
|
||||||
|
"error 1000",
|
||||||
|
"error 1001",
|
||||||
|
"error 1014",
|
||||||
|
"error 1015",
|
||||||
|
"error 1016",
|
||||||
|
"origin is unreachable",
|
||||||
|
"web server is down",
|
||||||
|
"connection timed out",
|
||||||
|
"a timeout occurred",
|
||||||
|
"ssl handshake failed",
|
||||||
|
"invalid ssl certificate",
|
||||||
|
"please enable cookies",
|
||||||
|
"checking if the site connection is secure",
|
||||||
|
];
|
||||||
|
|
||||||
|
pub async fn probe_url(
|
||||||
|
url: &str,
|
||||||
|
expected_status: u16,
|
||||||
|
expect_body_contains: Option<&str>,
|
||||||
|
timeout_ms: u64,
|
||||||
|
) -> ProbeResult {
|
||||||
|
let mut reasons = Vec::new();
|
||||||
|
let parsed = match reqwest::Url::parse(url) {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(e) => {
|
||||||
|
return ProbeResult {
|
||||||
|
ok: false,
|
||||||
|
url: url.to_string(),
|
||||||
|
final_url: url.to_string(),
|
||||||
|
status: 0,
|
||||||
|
latency_ms: 0,
|
||||||
|
server: None,
|
||||||
|
via_cloudflare: false,
|
||||||
|
cloudflare_fallback: false,
|
||||||
|
cloudflare_challenge: false,
|
||||||
|
body_snippet: String::new(),
|
||||||
|
error: Some(format!("invalid url: {e}")),
|
||||||
|
reasons: vec!["URL failed to parse".into()],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if parsed.scheme() != "http" && parsed.scheme() != "https" {
|
||||||
|
return ProbeResult {
|
||||||
|
ok: false,
|
||||||
|
url: url.to_string(),
|
||||||
|
final_url: url.to_string(),
|
||||||
|
status: 0,
|
||||||
|
latency_ms: 0,
|
||||||
|
server: None,
|
||||||
|
via_cloudflare: false,
|
||||||
|
cloudflare_fallback: false,
|
||||||
|
cloudflare_challenge: false,
|
||||||
|
body_snippet: String::new(),
|
||||||
|
error: Some("only http/https URLs are supported".into()),
|
||||||
|
reasons: vec!["Unsupported URL scheme".into()],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = match reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_millis(timeout_ms.max(1000)))
|
||||||
|
.redirect(reqwest::redirect::Policy::limited(5))
|
||||||
|
.user_agent("MeterboxHealthCheck/0.1 (+local)")
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
return ProbeResult {
|
||||||
|
ok: false,
|
||||||
|
url: url.to_string(),
|
||||||
|
final_url: url.to_string(),
|
||||||
|
status: 0,
|
||||||
|
latency_ms: 0,
|
||||||
|
server: None,
|
||||||
|
via_cloudflare: false,
|
||||||
|
cloudflare_fallback: false,
|
||||||
|
cloudflare_challenge: false,
|
||||||
|
body_snippet: String::new(),
|
||||||
|
error: Some(format!("client build failed: {e}")),
|
||||||
|
reasons: vec!["HTTP client error".into()],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let started = Instant::now();
|
||||||
|
let response = match client.get(parsed.clone()).send().await {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
return ProbeResult {
|
||||||
|
ok: false,
|
||||||
|
url: url.to_string(),
|
||||||
|
final_url: url.to_string(),
|
||||||
|
status: 0,
|
||||||
|
latency_ms: started.elapsed().as_millis() as u64,
|
||||||
|
server: None,
|
||||||
|
via_cloudflare: false,
|
||||||
|
cloudflare_fallback: false,
|
||||||
|
cloudflare_challenge: false,
|
||||||
|
body_snippet: String::new(),
|
||||||
|
error: Some(format!("request failed: {e}")),
|
||||||
|
reasons: vec!["Request failed (network/TLS/DNS)".into()],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let latency_ms = started.elapsed().as_millis() as u64;
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
let final_url = response.url().to_string();
|
||||||
|
let server = response
|
||||||
|
.headers()
|
||||||
|
.get("server")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
let cf_ray = response.headers().get("cf-ray").is_some();
|
||||||
|
let cf_mitigated = response.headers().get("cf-mitigated").is_some();
|
||||||
|
let via_cloudflare = cf_ray
|
||||||
|
|| server
|
||||||
|
.as_deref()
|
||||||
|
.map(|s| s.to_ascii_lowercase().contains("cloudflare"))
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
let body_lower = body.to_ascii_lowercase();
|
||||||
|
let snippet: String = body.chars().take(280).collect();
|
||||||
|
|
||||||
|
let mut cloudflare_fallback = false;
|
||||||
|
let mut cloudflare_challenge = false;
|
||||||
|
|
||||||
|
if CF_ORIGIN_ERRORS.contains(&status) {
|
||||||
|
cloudflare_fallback = true;
|
||||||
|
reasons.push(format!(
|
||||||
|
"Cloudflare origin error status {status} (site not reachable behind CF)"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if cf_mitigated {
|
||||||
|
cloudflare_challenge = true;
|
||||||
|
reasons.push("cf-mitigated header present (Cloudflare challenge/bot fight)".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
for marker in CF_BODY_MARKERS {
|
||||||
|
if body_lower.contains(marker) {
|
||||||
|
if marker.contains("just a moment")
|
||||||
|
|| marker.contains("challenge")
|
||||||
|
|| marker.contains("checking if the site")
|
||||||
|
|| marker.contains("enable javascript")
|
||||||
|
{
|
||||||
|
cloudflare_challenge = true;
|
||||||
|
reasons.push(format!("Response body looks like a Cloudflare challenge ({marker})"));
|
||||||
|
} else {
|
||||||
|
cloudflare_fallback = true;
|
||||||
|
reasons.push(format!("Response body matches Cloudflare fallback/error page ({marker})"));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft 200 that is clearly CF interstitial
|
||||||
|
if status == 200 && via_cloudflare && (cloudflare_challenge || cloudflare_fallback) {
|
||||||
|
reasons.push("Got HTTP 200 but content is Cloudflare interstitial/fallback, not origin".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut ok = true;
|
||||||
|
if status != expected_status {
|
||||||
|
ok = false;
|
||||||
|
reasons.push(format!(
|
||||||
|
"Expected HTTP {expected_status}, got {status}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if cloudflare_fallback || cloudflare_challenge {
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
|
if let Some(needle) = expect_body_contains {
|
||||||
|
let needle = needle.trim();
|
||||||
|
if !needle.is_empty() && !body.contains(needle) {
|
||||||
|
ok = false;
|
||||||
|
reasons.push(format!("Body does not contain expected text: {needle:?}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
reasons.push("Probe passed origin health checks".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
ProbeResult {
|
||||||
|
ok,
|
||||||
|
url: url.to_string(),
|
||||||
|
final_url,
|
||||||
|
status,
|
||||||
|
latency_ms,
|
||||||
|
server,
|
||||||
|
via_cloudflare,
|
||||||
|
cloudflare_fallback,
|
||||||
|
cloudflare_challenge,
|
||||||
|
body_snippet: snippet,
|
||||||
|
error: None,
|
||||||
|
reasons,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
//! Persistent app settings (SQLite key/value).
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use rusqlite::params;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::db::Db;
|
||||||
|
|
||||||
|
pub const HEALTH_LOG_RETENTION_DAYS: i64 = 90;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AppSettings {
|
||||||
|
/// When true, prune health check logs older than 3 months.
|
||||||
|
pub cleanup_logs_older_than_3_months: bool,
|
||||||
|
/// When true, host telemetry dashboard is readable without sign-in.
|
||||||
|
pub public_dashboard: bool,
|
||||||
|
/// Site IDs that are readable without sign-in.
|
||||||
|
pub public_site_ids: Vec<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AppSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
cleanup_logs_older_than_3_months: false,
|
||||||
|
public_dashboard: false,
|
||||||
|
public_site_ids: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct PublicAccess {
|
||||||
|
pub dashboard: bool,
|
||||||
|
pub site_ids: Vec<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppSettings {
|
||||||
|
pub fn public_access(&self) -> PublicAccess {
|
||||||
|
PublicAccess {
|
||||||
|
dashboard: self.public_dashboard,
|
||||||
|
site_ids: self.public_site_ids.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_any_public(&self) -> bool {
|
||||||
|
self.public_dashboard || !self.public_site_ids.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn site_is_public(&self, id: i64) -> bool {
|
||||||
|
self.public_site_ids.contains(&id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Db {
|
||||||
|
pub fn ensure_settings_table(&self) -> Result<()> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
conn.execute_batch(
|
||||||
|
"
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
",
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_setting_raw(&self, key: &str) -> Result<Option<String>> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let v = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT value FROM settings WHERE key = ?1",
|
||||||
|
params![key],
|
||||||
|
|r| r.get::<_, String>(0),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
Ok(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_setting_raw(&self, key: &str, value: &str) -> Result<()> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO settings (key, value) VALUES (?1, ?2)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||||
|
params![key, value],
|
||||||
|
)
|
||||||
|
.context("set setting")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_settings(&self) -> Result<AppSettings> {
|
||||||
|
let mut s = AppSettings::default();
|
||||||
|
if let Some(v) = self.get_setting_raw("cleanup_logs_older_than_3_months")? {
|
||||||
|
s.cleanup_logs_older_than_3_months = v == "true" || v == "1";
|
||||||
|
}
|
||||||
|
if let Some(v) = self.get_setting_raw("public_dashboard")? {
|
||||||
|
s.public_dashboard = v == "true" || v == "1";
|
||||||
|
}
|
||||||
|
if let Some(v) = self.get_setting_raw("public_site_ids")? {
|
||||||
|
s.public_site_ids = serde_json::from_str(&v).unwrap_or_default();
|
||||||
|
}
|
||||||
|
// Drop IDs for sites that no longer exist
|
||||||
|
let known: std::collections::HashSet<i64> = self
|
||||||
|
.list_health_sites()?
|
||||||
|
.into_iter()
|
||||||
|
.map(|site| site.id)
|
||||||
|
.collect();
|
||||||
|
s.public_site_ids.retain(|id| known.contains(id));
|
||||||
|
Ok(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_settings(&self, settings: &AppSettings) -> Result<AppSettings> {
|
||||||
|
let mut clean = settings.clone();
|
||||||
|
clean.public_site_ids.sort_unstable();
|
||||||
|
clean.public_site_ids.dedup();
|
||||||
|
let known: std::collections::HashSet<i64> = self
|
||||||
|
.list_health_sites()?
|
||||||
|
.into_iter()
|
||||||
|
.map(|site| site.id)
|
||||||
|
.collect();
|
||||||
|
clean.public_site_ids.retain(|id| known.contains(id));
|
||||||
|
|
||||||
|
self.set_setting_raw(
|
||||||
|
"cleanup_logs_older_than_3_months",
|
||||||
|
if clean.cleanup_logs_older_than_3_months {
|
||||||
|
"true"
|
||||||
|
} else {
|
||||||
|
"false"
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
self.set_setting_raw(
|
||||||
|
"public_dashboard",
|
||||||
|
if clean.public_dashboard { "true" } else { "false" },
|
||||||
|
)?;
|
||||||
|
self.set_setting_raw(
|
||||||
|
"public_site_ids",
|
||||||
|
&serde_json::to_string(&clean.public_site_ids).unwrap_or_else(|_| "[]".into()),
|
||||||
|
)?;
|
||||||
|
Ok(clean)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prune_health_results_older_than(&self, days: i64) -> Result<usize> {
|
||||||
|
let cutoff = chrono::Utc::now().timestamp() - days.max(1) * 86400;
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let n = conn.execute(
|
||||||
|
"DELETE FROM health_results WHERE checked_at < ?1",
|
||||||
|
params![cutoff],
|
||||||
|
)?;
|
||||||
|
Ok(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
use rusqlite::OptionalExtension;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
services:
|
||||||
|
meterbox:
|
||||||
|
build: .
|
||||||
|
container_name: meterbox
|
||||||
|
restart: unless-stopped
|
||||||
|
# Host network + bind 127.0.0.1 inside the app:
|
||||||
|
# - accurate host NIC / load metrics
|
||||||
|
# - no Docker published ports (avoids DNAT bypass of INPUT REJECT)
|
||||||
|
# - Caddy/Rathole can still reach 127.0.0.1:8787
|
||||||
|
network_mode: host
|
||||||
|
pid: host
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
- /etc/os-release:/etc/os-release:ro
|
||||||
|
- /etc/hostname:/etc/hostname:ro
|
||||||
|
- /run/bwflow:/run/bwflow:ro
|
||||||
|
- /var/lib/vnstat:/var/lib/vnstat:ro
|
||||||
|
- /etc/vnstat.conf:/etc/vnstat.conf:ro
|
||||||
|
environment:
|
||||||
|
METERBOX_LISTEN: "127.0.0.1:8787"
|
||||||
|
METERBOX_DB: "/app/data/meterbox.db"
|
||||||
|
METERBOX_STATIC: "/app/frontend/dist"
|
||||||
|
METERBOX_INTERVAL_SECS: "30"
|
||||||
|
METERBOX_RETENTION_DAYS: "90"
|
||||||
|
# Hard cap: 10 GiB for SQLite (db + wal + shm). Cleanup job trims oldest rows.
|
||||||
|
METERBOX_MAX_DB_BYTES: "10737418240"
|
||||||
|
METERBOX_CLEANUP_INTERVAL_SECS: "3600"
|
||||||
|
METERBOX_BWFLOW_SOCK: "/run/bwflow/bwflow.sock"
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["react", "typescript", "oxc"],
|
||||||
|
"rules": {
|
||||||
|
"react/rules-of-hooks": "error",
|
||||||
|
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Meterbox</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,500;12..96,700&family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+1756
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "oxlint",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"react": "^19.2.8",
|
||||||
|
"react-dom": "^19.2.8",
|
||||||
|
"recharts": "^3.10.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.13.5",
|
||||||
|
"@types/react": "^19.2.18",
|
||||||
|
"@types/react-dom": "^19.2.7",
|
||||||
|
"@vitejs/plugin-react": "^6.1.1",
|
||||||
|
"oxlint": "^1.81.0",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"vite": "^8.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filtLine truncated
|
||||||
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,512 @@
|
|||||||
|
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
closestCenter,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
type DragEndEvent,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
SortableContext,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
useSortable,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
type BwflowStats,
|
||||||
|
type ComplianceReport,
|
||||||
|
type HostInfo,
|
||||||
|
type RangeKey,
|
||||||
|
type Sample,
|
||||||
|
type SeriesResponse,
|
||||||
|
} from "./api";
|
||||||
|
import { CpuChart, MemChart, NetChart } from "./charts";
|
||||||
|
import { BwflowPanel } from "./BwflowPanel";
|
||||||
|
import { CompliancePanel } from "./CompliancePanel";
|
||||||
|
import { AuthGate, type AuthContext } from "./AuthGate";
|
||||||
|
import { SitesPage } from "./SitesPage";
|
||||||
|
import { SiteDashboard } from "./SiteDashboard";
|
||||||
|
import { SettingsPage } from "./SettingsPage";
|
||||||
|
import { useRoute } from "./routing";
|
||||||
|
import { formatBytes, formatPct, formatRate, formatUptime } from "./format";
|
||||||
|
import "./App.css";
|
||||||
|
|
||||||
|
const RANGES: { key: RangeKey; label: string }[] = [
|
||||||
|
{ key: "24h", label: "24 hours" },
|
||||||
|
{ key: "7d", label: "7 days" },
|
||||||
|
{ key: "4w", label: "4 weeks" },
|
||||||
|
{ key: "3m", label: "3 months" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const LAYOUT_KEY = "meterbox-layout-v3";
|
||||||
|
|
||||||
|
const DEFAULT_ORDER = [
|
||||||
|
"live",
|
||||||
|
"toolbar",
|
||||||
|
"compliance",
|
||||||
|
"averages",
|
||||||
|
"chart-cpu",
|
||||||
|
"chart-mem",
|
||||||
|
"chart-net",
|
||||||
|
"bwflow",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type SectionId = (typeof DEFAULT_ORDER)[number];
|
||||||
|
|
||||||
|
function loadOrder(): SectionId[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(LAYOUT_KEY);
|
||||||
|
if (!raw) return [...DEFAULT_ORDER];
|
||||||
|
const parsed = JSON.parse(raw) as string[];
|
||||||
|
const known = new Set<string>(DEFAULT_ORDER);
|
||||||
|
const next = parsed.filter((id): id is SectionId => known.has(id));
|
||||||
|
for (const id of DEFAULT_ORDER) {
|
||||||
|
if (!next.includes(id)) next.push(id);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
} catch {
|
||||||
|
return [...DEFAULT_ORDER];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortableSection({
|
||||||
|
id,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
id: SectionId;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.85 : 1,
|
||||||
|
zIndex: isDragging ? 5 : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={setNodeRef} style={style} className={`sortable-block${isDragging ? " dragging" : ""}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="drag-handle mono"
|
||||||
|
aria-label={`Drag to reorder ${id}`}
|
||||||
|
title="Drag to reorder"
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
⋮⋮
|
||||||
|
</button>
|
||||||
|
<div className="sortable-body">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return <AuthGate>{(ctx) => <AppShell ctx={ctx} />}</AuthGate>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppShell({ ctx }: { ctx: AuthContext }) {
|
||||||
|
const { user, guest, publicAccess, logout, requestLogin } = ctx;
|
||||||
|
const [route, navigate] = useRoute();
|
||||||
|
|
||||||
|
const canDashboard = !guest || publicAccess.dashboard;
|
||||||
|
const canSites = !guest || publicAccess.site_ids.length > 0;
|
||||||
|
const navKind =
|
||||||
|
route.kind === "site" ? "sites" : route.kind === "settings" ? "settings" : route.kind;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (guest && route.kind === "settings") {
|
||||||
|
navigate({ kind: canDashboard ? "dashboard" : "sites" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (guest && route.kind === "dashboard" && !publicAccess.dashboard) {
|
||||||
|
navigate({ kind: canSites ? "sites" : "dashboard" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (guest && route.kind === "sites" && publicAccess.site_ids.length === 0) {
|
||||||
|
navigate({ kind: canDashboard ? "dashboard" : "sites" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (guest && route.kind === "site" && !publicAccess.site_ids.includes(route.id)) {
|
||||||
|
navigate({ kind: canSites ? "sites" : "dashboard" });
|
||||||
|
}
|
||||||
|
}, [guest, route, publicAccess, canDashboard, canSites, navigate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<nav className="app-nav" aria-label="Primary">
|
||||||
|
<div className="app-nav-brand mono">meterbox</div>
|
||||||
|
<div className="app-nav-links" role="tablist" aria-label="Pages">
|
||||||
|
{canDashboard ? (
|
||||||
|
<NavLink
|
||||||
|
label="Dashboard"
|
||||||
|
active={navKind === "dashboard"}
|
||||||
|
onClick={() => navigate({ kind: "dashboard" })}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{canSites ? (
|
||||||
|
<NavLink
|
||||||
|
label="Sites"
|
||||||
|
active={navKind === "sites"}
|
||||||
|
onClick={() => navigate({ kind: "sites" })}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{!guest ? (
|
||||||
|
<NavLink
|
||||||
|
label="Settings"
|
||||||
|
active={navKind === "settings"}
|
||||||
|
onClick={() => navigate({ kind: "settings" })}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="app-nav-meta mono">
|
||||||
|
{guest ? <span>guest</span> : <span>{user?.email}</span>}
|
||||||
|
{guest ? (
|
||||||
|
<button type="button" className="ghost-btn" onClick={requestLogin}>
|
||||||
|
sign in
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button type="button" className="ghost-btn" onClick={() => void logout()}>
|
||||||
|
sign out
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
{route.kind === "settings" && !guest ? (
|
||||||
|
<SettingsPage onBack={() => navigate({ kind: "dashboard" })} />
|
||||||
|
) : route.kind === "site" ? (
|
||||||
|
<SiteDashboard
|
||||||
|
siteId={route.id}
|
||||||
|
readOnly={guest}
|
||||||
|
onBack={() => navigate({ kind: "sites" })}
|
||||||
|
/>
|
||||||
|
) : route.kind === "sites" ? (
|
||||||
|
<SitesPage
|
||||||
|
readOnly={guest}
|
||||||
|
onOpenSite={(id) => navigate({ kind: "site", id })}
|
||||||
|
/>
|
||||||
|
) : canDashboard ? (
|
||||||
|
<Dashboard />
|
||||||
|
) : (
|
||||||
|
<div className="shell">
|
||||||
|
<p className="empty">No public pages enabled. Sign in to continue.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavLink({
|
||||||
|
label,
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
active: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={active}
|
||||||
|
className={active ? "active" : ""}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Dashboard() {
|
||||||
|
const [range, setRange] = useState<RangeKey>("24h");
|
||||||
|
const [host, setHost] = useState<HostInfo | null>(null);
|
||||||
|
const [snap, setSnap] = useState<Sample | null>(null);
|
||||||
|
const [series, setSeries] = useState<SeriesResponse | null>(null);
|
||||||
|
const [compliance, setCompliance] = useState<ComplianceReport | null>(null);
|
||||||
|
const [bwflow, setBwflow] = useState<BwflowStats | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [order, setOrder] = useState<SectionId[]>(loadOrder);
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
||||||
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem(LAYOUT_KEY, JSON.stringify(order));
|
||||||
|
}, [order]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
async function loadStatic() {
|
||||||
|
try {
|
||||||
|
const [h, s, c, b] = await Promise.all([
|
||||||
|
api.host(),
|
||||||
|
api.snapshot(),
|
||||||
|
api.compliance(),
|
||||||
|
api.bwflow(),
|
||||||
|
]);
|
||||||
|
if (!cancelled) {
|
||||||
|
setHost(h);
|
||||||
|
setSnap(s);
|
||||||
|
setCompliance(c);
|
||||||
|
setBwflow(b);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) setError(e instanceof Error ? e.message : "Failed to load host");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadStatic();
|
||||||
|
const id = window.setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const [s, c, b] = await Promise.all([api.snapshot(), api.compliance(), api.bwflow()]);
|
||||||
|
if (!cancelled) {
|
||||||
|
setSnap(s);
|
||||||
|
setCompliance(c);
|
||||||
|
setBwflow(b);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore transient */
|
||||||
|
}
|
||||||
|
}, 15000);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearInterval(id);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
async function loadSeries() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await api.series(range);
|
||||||
|
if (!cancelled) setSeries(data);
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) setError(e instanceof Error ? e.message : "Failed to load series");
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadSeries();
|
||||||
|
const id = window.setInterval(loadSeries, 60000);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearInterval(id);
|
||||||
|
};
|
||||||
|
}, [range]);
|
||||||
|
|
||||||
|
const avg = series?.averages;
|
||||||
|
|
||||||
|
function onDragEnd(event: DragEndEvent) {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over || active.id === over.id) return;
|
||||||
|
setOrder((items) => {
|
||||||
|
const oldIndex = items.indexOf(active.id as SectionId);
|
||||||
|
const newIndex = items.indexOf(over.id as SectionId);
|
||||||
|
if (oldIndex < 0 || newIndex < 0) return items;
|
||||||
|
return arrayMove(items, oldIndex, newIndex);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sections = useMemo(() => {
|
||||||
|
const map: Record<SectionId, ReactNode> = {
|
||||||
|
live: (
|
||||||
|
<section className="live">
|
||||||
|
<Stat label="CPU now" value={snap ? formatPct(snap.cpu_pct) : "—"} hint="live sample" />
|
||||||
|
<Stat
|
||||||
|
label="Memory now"
|
||||||
|
value={snap ? formatPct(snap.mem_pct) : "—"}
|
||||||
|
hint={snap ? formatBytes(snap.mem_used_bytes) : ""}
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label="Boot disk"
|
||||||
|
value={snap ? formatPct(snap.disk_pct) : "—"}
|
||||||
|
hint={
|
||||||
|
snap && snap.disk_total_bytes > 0
|
||||||
|
? `${formatBytes(snap.disk_used_bytes)} / ${formatBytes(snap.disk_total_bytes)}`
|
||||||
|
: "root volume"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Stat label="RX now" value={snap ? formatRate(snap.net_rx_bps) : "—"} hint="inbound" />
|
||||||
|
<Stat label="TX now" value={snap ? formatRate(snap.net_tx_bps) : "—"} hint="outbound" />
|
||||||
|
</section>
|
||||||
|
),
|
||||||
|
toolbar: (
|
||||||
|
<div className="toolbar">
|
||||||
|
<div className="ranges" role="tablist" aria-label="Time range">
|
||||||
|
{RANGES.map((r) => (
|
||||||
|
<button
|
||||||
|
key={r.key}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={range === r.key}
|
||||||
|
className={range === r.key ? "active" : ""}
|
||||||
|
onClick={() => setRange(r.key)}
|
||||||
|
>
|
||||||
|
{r.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="bucket mono">
|
||||||
|
{series
|
||||||
|
? `avg over ${range} · bucket ${series.bucket_secs}s · ${series.points.length} pts`
|
||||||
|
: loading
|
||||||
|
? "loading…"
|
||||||
|
: "no data yet"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
compliance: compliance ? <CompliancePanel report={compliance} /> : null,
|
||||||
|
averages: (
|
||||||
|
<section className="averages">
|
||||||
|
<Stat label="Avg CPU" value={avg ? formatPct(avg.cpu_pct) : "—"} hint={range} />
|
||||||
|
<Stat label="Avg memory" value={avg ? formatPct(avg.mem_pct) : "—"} hint={range} />
|
||||||
|
<Stat label="Avg boot disk" value={avg ? formatPct(avg.disk_pct) : "—"} hint={range} />
|
||||||
|
<Stat label="Avg RX" value={avg ? formatRate(avg.net_rx_bps) : "—"} hint={range} />
|
||||||
|
<Stat label="Avg TX" value={avg ? formatRate(avg.net_tx_bps) : "—"} hint={range} />
|
||||||
|
</section>
|
||||||
|
),
|
||||||
|
"chart-cpu": (
|
||||||
|
<article className="panel chart-solo">
|
||||||
|
<header>
|
||||||
|
<h2>CPU utilization</h2>
|
||||||
|
<span className="mono">percent busy</span>
|
||||||
|
</header>
|
||||||
|
{series && series.points.length > 0 ? (
|
||||||
|
<CpuChart points={series.points} range={range} />
|
||||||
|
) : (
|
||||||
|
<Empty loading={loading} />
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
),
|
||||||
|
"chart-mem": (
|
||||||
|
<article className="panel chart-solo">
|
||||||
|
<header>
|
||||||
|
<h2>Memory used</h2>
|
||||||
|
<span className="mono">percent of RAM</span>
|
||||||
|
</header>
|
||||||
|
{series && series.points.length > 0 ? (
|
||||||
|
<MemChart points={series.points} range={range} />
|
||||||
|
) : (
|
||||||
|
<Empty loading={loading} />
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
),
|
||||||
|
"chart-net": (
|
||||||
|
<article className="panel chart-solo">
|
||||||
|
<header>
|
||||||
|
<h2>Network throughput</h2>
|
||||||
|
<span className="mono">bytes/sec · primary interfaces</span>
|
||||||
|
</header>
|
||||||
|
{series && series.points.length > 0 ? (
|
||||||
|
<NetChart points={series.points} range={range} />
|
||||||
|
) : (
|
||||||
|
<Empty loading={loading} />
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
),
|
||||||
|
bwflow: bwflow ? <BwflowPanel stats={bwflow} /> : null,
|
||||||
|
};
|
||||||
|
return map;
|
||||||
|
}, [avg, bwflow, compliance, loading, range, series, snap]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="shell">
|
||||||
|
<header className="hero">
|
||||||
|
<div className="hero-copy">
|
||||||
|
<p className="eyebrow mono">host telemetry</p>
|
||||||
|
<h1 className="brand">Meterbox</h1>
|
||||||
|
<p className="lede">
|
||||||
|
CPU, memory, boot disk, and network for this machine — last 24 hours through 3 months.
|
||||||
|
</p>
|
||||||
|
<p className="layout-hint mono">Drag ⋮⋮ to rearrange panels · layout saved in this browser</p>
|
||||||
|
</div>
|
||||||
|
<div className="hero-meta mono">
|
||||||
|
<div>
|
||||||
|
<span>host</span>
|
||||||
|
<strong>{host?.hostname ?? "…"}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>cores</span>
|
||||||
|
<strong>{host?.cpus ?? "—"}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>ram</span>
|
||||||
|
<strong>{host ? formatBytes(host.mem_total_bytes) : "—"}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>disk</span>
|
||||||
|
<strong>
|
||||||
|
{host && host.disk_total_bytes > 0
|
||||||
|
? `${formatPct(host.disk_pct)} · ${formatBytes(host.disk_total_bytes)}`
|
||||||
|
: "—"}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>uptime</span>
|
||||||
|
<strong>{host ? formatUptime(host.uptime_secs) : "—"}</strong>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="reset-layout" onClick={() => setOrder([...DEFAULT_ORDER])}>
|
||||||
|
reset layout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{error && <div className="error">{error}</div>}
|
||||||
|
|
||||||
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
||||||
|
<SortableContext items={order} strategy={verticalListSortingStrategy}>
|
||||||
|
<div className="sortable-stack">
|
||||||
|
{order.map((id) => {
|
||||||
|
const body = sections[id];
|
||||||
|
if (!body) return null;
|
||||||
|
return (
|
||||||
|
<SortableSection key={id} id={id}>
|
||||||
|
{body}
|
||||||
|
</SortableSection>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
|
||||||
|
<footer className="foot mono">
|
||||||
|
<span>{host?.os ?? ""}</span>
|
||||||
|
<span>kernel {host?.kernel ?? "—"}</span>
|
||||||
|
<span>binds 127.0.0.1 by default · proxy via Caddy/Rathole</span>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({ label, value, hint }: { label: string; value: string; hint?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="stat">
|
||||||
|
<span className="stat-label">{label}</span>
|
||||||
|
<strong className="stat-value mono">{value}</strong>
|
||||||
|
{hint ? <span className="stat-hint">{hint}</span> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Empty({ loading }: { loading: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="empty">
|
||||||
|
{loading
|
||||||
|
? "Fetching series…"
|
||||||
|
: "No samples in this window yet. Meterbox collects every 30s — give it a minute."}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import { useCallback, useEffect, useState, type FormEvent, type ReactNode } from "react";
|
||||||
|
import { api, type PublicAccess, type UserPublic } from "./api";
|
||||||
|
import { PasswordField } from "./PasswordField";
|
||||||
|
|
||||||
|
type Phase = "loading" | "setup" | "login" | "ready";
|
||||||
|
|
||||||
|
export type AuthContext = {
|
||||||
|
user: UserPublic | null;
|
||||||
|
guest: boolean;
|
||||||
|
publicAccess: PublicAccess;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
requestLogin: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** First-run setup, then login — or guest access when public pages are enabled. */
|
||||||
|
export function AuthGate({ children }: { children: (ctx: AuthContext) => ReactNode }) {
|
||||||
|
const [phase, setPhase] = useState<Phase>("loading");
|
||||||
|
const [user, setUser] = useState<UserPublic | null>(null);
|
||||||
|
const [publicAccess, setPublicAccess] = useState<PublicAccess>({
|
||||||
|
dashboard: false,
|
||||||
|
site_ids: [],
|
||||||
|
});
|
||||||
|
const [forceLogin, setForceLogin] = useState(false);
|
||||||
|
const [bootError, setBootError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
const status = await api.authStatus();
|
||||||
|
setPublicAccess(status.public);
|
||||||
|
if (status.needs_setup) {
|
||||||
|
setUser(null);
|
||||||
|
setPhase("setup");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (status.user) {
|
||||||
|
setUser(status.user);
|
||||||
|
setForceLogin(false);
|
||||||
|
setPhase("ready");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUser(null);
|
||||||
|
const canGuest =
|
||||||
|
status.public.dashboard || status.public.site_ids.length > 0;
|
||||||
|
if (canGuest && !forceLogin) {
|
||||||
|
setPhase("ready");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPhase("login");
|
||||||
|
}, [forceLogin]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setBootError(e instanceof Error ? e.message : "Could not check auth");
|
||||||
|
setPhase("login");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
await api.logout();
|
||||||
|
setUser(null);
|
||||||
|
setForceLogin(false);
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phase === "loading") {
|
||||||
|
return (
|
||||||
|
<div className="setup-gate">
|
||||||
|
<p className="mono">Checking Meterbox setup…</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phase === "setup") {
|
||||||
|
return (
|
||||||
|
<SetupForm
|
||||||
|
onDone={async () => {
|
||||||
|
await refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phase === "login") {
|
||||||
|
return (
|
||||||
|
<LoginForm
|
||||||
|
bootError={bootError}
|
||||||
|
allowGuest={publicAccess.dashboard || publicAccess.site_ids.length > 0}
|
||||||
|
onGuest={() => {
|
||||||
|
setForceLogin(false);
|
||||||
|
setPhase("ready");
|
||||||
|
}}
|
||||||
|
onDone={async () => {
|
||||||
|
setBootError(null);
|
||||||
|
setForceLogin(false);
|
||||||
|
await refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{children({
|
||||||
|
user,
|
||||||
|
guest: user == null,
|
||||||
|
publicAccess,
|
||||||
|
logout,
|
||||||
|
requestLogin: () => {
|
||||||
|
setForceLogin(true);
|
||||||
|
setPhase("login");
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SetupForm({ onDone }: { onDone: () => Promise<void> }) {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [confirm, setConfirm] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function submit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
if (password !== confirm) {
|
||||||
|
setError("Passwords do not match");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await api.setup(email.trim(), password);
|
||||||
|
await onDone();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Setup failed");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="setup-gate">
|
||||||
|
<div className="setup-card">
|
||||||
|
<p className="eyebrow mono">first run</p>
|
||||||
|
<h1 className="brand">Create admin</h1>
|
||||||
|
<p className="setup-lede">
|
||||||
|
Set the email and password for the single account that manages this Meterbox. You only do
|
||||||
|
this once.
|
||||||
|
</p>
|
||||||
|
<form className="health-auth" onSubmit={(ev) => void submit(ev)}>
|
||||||
|
<label>
|
||||||
|
Email
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<PasswordField
|
||||||
|
label="Password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
minLength={8}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<PasswordField
|
||||||
|
label="Confirm password"
|
||||||
|
value={confirm}
|
||||||
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
minLength={8}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={busy}>
|
||||||
|
{busy ? "…" : "Save admin account"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{error && <div className="error">{error}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoginForm({
|
||||||
|
onDone,
|
||||||
|
onGuest,
|
||||||
|
allowGuest,
|
||||||
|
bootError,
|
||||||
|
}: {
|
||||||
|
onDone: () => Promise<void>;
|
||||||
|
onGuest: () => void;
|
||||||
|
allowGuest: boolean;
|
||||||
|
bootError: string | null;
|
||||||
|
}) {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(bootError);
|
||||||
|
|
||||||
|
async function submit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.login(email.trim(), password);
|
||||||
|
await onDone();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Login failed");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="setup-gate">
|
||||||
|
<div className="setup-card">
|
||||||
|
<p className="eyebrow mono">meterbox</p>
|
||||||
|
<h1 className="brand">Sign in</h1>
|
||||||
|
<p className="setup-lede">
|
||||||
|
Sign in with the admin account to manage this Meterbox dashboard.
|
||||||
|
</p>
|
||||||
|
<form className="health-auth" onSubmit={(ev) => void submit(ev)}>
|
||||||
|
<label>
|
||||||
|
Email
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<PasswordField
|
||||||
|
label="Password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
minLength={8}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={busy}>
|
||||||
|
{busy ? "…" : "Sign in"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{allowGuest ? (
|
||||||
|
<button type="button" className="ghost-btn guest-continue" onClick={onGuest}>
|
||||||
|
Continue as guest
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{error && <div className="error">{error}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import type { BwflowStats } from "./api";
|
||||||
|
import { formatBytes } from "./format";
|
||||||
|
|
||||||
|
const MONTHS = [
|
||||||
|
"Jan",
|
||||||
|
"Feb",
|
||||||
|
"Mar",
|
||||||
|
"Apr",
|
||||||
|
"May",
|
||||||
|
"Jun",
|
||||||
|
"Jul",
|
||||||
|
"Aug",
|
||||||
|
"Sep",
|
||||||
|
"Oct",
|
||||||
|
"Nov",
|
||||||
|
"Dec",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function BwflowPanel({ stats }: { stats: BwflowStats }) {
|
||||||
|
if (!stats.available) {
|
||||||
|
return (
|
||||||
|
<section className="bwflow panel-block">
|
||||||
|
<header className="panel-block-head">
|
||||||
|
<h2 className="brand">bwflow</h2>
|
||||||
|
<span className="mono">unavailable</span>
|
||||||
|
</header>
|
||||||
|
<p className="bwflow-error">
|
||||||
|
{stats.error ?? "bwflow daemon socket not reachable"}
|
||||||
|
<span className="mono"> · {stats.socket}</span>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pct = stats.budget_used_pct ?? 0;
|
||||||
|
const throttled = stats.rate_limit_on === true;
|
||||||
|
const period =
|
||||||
|
stats.calendar_month && stats.period_start && stats.period_resets
|
||||||
|
? `${stats.calendar_month} · ${stats.period_start} → resets ${stats.period_resets}`
|
||||||
|
: "calendar month";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="bwflow panel-block">
|
||||||
|
<header className="panel-block-head">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow mono" style={{ margin: 0 }}>
|
||||||
|
outbound budget · calendar month
|
||||||
|
</p>
|
||||||
|
<h2 className="brand" style={{ margin: "2px 0 0", fontSize: 26 }}>
|
||||||
|
bwflow
|
||||||
|
</h2>
|
||||||
|
<p className="mono" style={{ margin: "6px 0 0", fontSize: 12, color: "var(--muted)" }}>
|
||||||
|
{period}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bwflow-badges mono">
|
||||||
|
<span className={throttled ? "badge warn" : "badge ok"}>
|
||||||
|
{throttled ? `throttled ${stats.throttle_kbps} kbps` : "rate limit off"}
|
||||||
|
</span>
|
||||||
|
<span className="badge">{stats.interface}</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="bwflow-stats">
|
||||||
|
<div className="stat">
|
||||||
|
<span className="stat-label">Egress this month</span>
|
||||||
|
<strong className="stat-value mono">
|
||||||
|
{(stats.tx_gb ?? 0).toFixed(2)} GB
|
||||||
|
</strong>
|
||||||
|
<span className="stat-hint">of {stats.monthly_budget_gb} GB budget</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat">
|
||||||
|
<span className="stat-label">Tokens left</span>
|
||||||
|
<strong className="stat-value mono">
|
||||||
|
{(stats.tokens_remaining_gb ?? 0).toFixed(1)} GB
|
||||||
|
</strong>
|
||||||
|
<span className="stat-hint">pre-charge {stats.pre_charge_gb} GB</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat">
|
||||||
|
<span className="stat-label">Budget used</span>
|
||||||
|
<strong className="stat-value mono">{pct.toFixed(2)}%</strong>
|
||||||
|
<span className="stat-hint">as of {stats.as_of ?? "—"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat">
|
||||||
|
<span className="stat-label">State</span>
|
||||||
|
<strong className="stat-value mono">{stats.state}</strong>
|
||||||
|
<span className="stat-hint">{formatBytes(stats.tx_bytes ?? 0)} TX</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="budget-bar" aria-label={`Budget used ${pct.toFixed(1)} percent`}>
|
||||||
|
<div className="budget-bar-fill" style={{ width: `${Math.min(100, Math.max(0, pct))}%` }} />
|
||||||
|
</div>
|
||||||
|
<p className="mono" style={{ margin: "8px 0 0", fontSize: 11, color: "var(--muted)" }}>
|
||||||
|
Outbound counters use the calendar month and reset on the 1st. Soft budget{" "}
|
||||||
|
{stats.monthly_budget_gb} GB (of 10 TB monthly target).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{stats.months.length > 0 && (
|
||||||
|
<div className="bwflow-months">
|
||||||
|
<h3>Calendar-month traffic</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Month</th>
|
||||||
|
<th>Egress ↑</th>
|
||||||
|
<th>Ingress ↓</th>
|
||||||
|
<th>Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{[...stats.months].reverse().map((m) => (
|
||||||
|
<tr key={`${m.year}-${m.month}`}>
|
||||||
|
<td className="mono">
|
||||||
|
{MONTHS[(m.month - 1) % 12]} {m.year}
|
||||||
|
</td>
|
||||||
|
<td className="mono">{formatBytes(m.tx_bytes)}</td>
|
||||||
|
<td className="mono">{formatBytes(m.rx_bytes)}</td>
|
||||||
|
<td className="mono">{formatBytes(m.rx_bytes + m.tx_bytes)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import type { CSSProperties } from "react";
|
||||||
|
import type { ComplianceReport } from "./api";
|
||||||
|
|
||||||
|
const statusColor: Record<string, string> = {
|
||||||
|
pass: "#3f7d4e",
|
||||||
|
fail: "#b91c1c",
|
||||||
|
warn: "#c2410c",
|
||||||
|
info: "#5c6b64",
|
||||||
|
};
|
||||||
|
|
||||||
|
const overallStyle: Record<string, CSSProperties> = {
|
||||||
|
compliant: { background: "rgba(63,125,78,0.12)", borderColor: "rgba(63,125,78,0.35)", color: "#1f4d2a" },
|
||||||
|
at_risk: { background: "rgba(194,65,12,0.12)", borderColor: "rgba(194,65,12,0.35)", color: "#7c2d12" },
|
||||||
|
over_limit: { background: "rgba(185,28,28,0.12)", borderColor: "rgba(185,28,28,0.4)", color: "#7f1d1d" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CompliancePanel({ report }: { report: ComplianceReport }) {
|
||||||
|
const banner = overallStyle[report.overall] ?? overallStyle.at_risk;
|
||||||
|
const label =
|
||||||
|
report.overall === "compliant"
|
||||||
|
? "On target"
|
||||||
|
: report.overall === "over_limit"
|
||||||
|
? "Over target"
|
||||||
|
: "Needs attention";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="compliance">
|
||||||
|
<div className="compliance-banner" style={banner}>
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow mono" style={{ margin: 0 }}>
|
||||||
|
capacity & budgets
|
||||||
|
</p>
|
||||||
|
<h2 className="brand" style={{ fontSize: 28, margin: "4px 0 6px" }}>
|
||||||
|
{label}
|
||||||
|
</h2>
|
||||||
|
<p style={{ margin: 0, maxWidth: "62ch" }}>{report.summary}</p>
|
||||||
|
{report.tier ? (
|
||||||
|
<p className="mono" style={{ margin: "8px 0 0", fontSize: 12, opacity: 0.85 }}>
|
||||||
|
{report.tier}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="compliance-grid">
|
||||||
|
{report.checks.map((c) => (
|
||||||
|
<article key={c.id} className="check">
|
||||||
|
<header>
|
||||||
|
<span className="check-status mono" style={{ color: statusColor[c.status] ?? statusColor.info }}>
|
||||||
|
{c.status}
|
||||||
|
</span>
|
||||||
|
<h3>{c.label}</h3>
|
||||||
|
</header>
|
||||||
|
<p className="check-detail">{c.detail}</p>
|
||||||
|
<div className="check-meta mono">
|
||||||
|
<span>now {c.current}</span>
|
||||||
|
<span>{c.limit}</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{report.notes.length > 0 && (
|
||||||
|
<ul className="compliance-notes">
|
||||||
|
{report.notes.map((n) => (
|
||||||
|
<li key={n}>{n}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,464 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { api, type HealthSite, type ProbeResult } from "./api";
|
||||||
|
|
||||||
|
type FormState = {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
interval_secs: number;
|
||||||
|
timeout_ms: number;
|
||||||
|
expected_status: number;
|
||||||
|
expect_body_contains: string;
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type HealthTab = "sites" | "manage";
|
||||||
|
|
||||||
|
const emptyForm = (): FormState => ({
|
||||||
|
name: "",
|
||||||
|
url: "https://",
|
||||||
|
interval_secs: 60,
|
||||||
|
timeout_ms: 10000,
|
||||||
|
expected_status: 200,
|
||||||
|
expect_body_contains: "",
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function HealthPanel({
|
||||||
|
onOpenSite,
|
||||||
|
readOnly = false,
|
||||||
|
}: {
|
||||||
|
onOpenSite: (id: number) => void;
|
||||||
|
readOnly?: boolean;
|
||||||
|
}) {
|
||||||
|
const [tab, setTab] = useState<HealthTab>("sites");
|
||||||
|
const [sites, setSites] = useState<HealthSite[]>([]);
|
||||||
|
const [form, setForm] = useState<FormState>(emptyForm);
|
||||||
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [probeBusy, setProbeBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [probe, setProbe] = useState<ProbeResult | null>(null);
|
||||||
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const refreshSites = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setSites(await api.listSites());
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to load sites");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
await refreshSites();
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) setError(e instanceof Error ? e.message : "Failed to load sites");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [refreshSites]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const id = window.setInterval(() => {
|
||||||
|
void refreshSites();
|
||||||
|
}, 20000);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [refreshSites]);
|
||||||
|
|
||||||
|
function openAdd() {
|
||||||
|
setEditingId(null);
|
||||||
|
setForm(emptyForm());
|
||||||
|
setProbe(null);
|
||||||
|
setError(null);
|
||||||
|
setNotice(null);
|
||||||
|
setTab("manage");
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(site: HealthSite) {
|
||||||
|
setEditingId(site.id);
|
||||||
|
setForm({
|
||||||
|
name: site.name,
|
||||||
|
url: site.url,
|
||||||
|
interval_secs: site.interval_secs,
|
||||||
|
timeout_ms: site.timeout_ms,
|
||||||
|
expected_status: site.expected_status,
|
||||||
|
expect_body_contains: site.expect_body_contains ?? "",
|
||||||
|
enabled: site.enabled,
|
||||||
|
});
|
||||||
|
setProbe(null);
|
||||||
|
setError(null);
|
||||||
|
setNotice(null);
|
||||||
|
setTab("manage");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runProbe() {
|
||||||
|
setProbeBusy(true);
|
||||||
|
setError(null);
|
||||||
|
setNotice(null);
|
||||||
|
try {
|
||||||
|
const result = await api.probeSite({
|
||||||
|
url: form.url.trim(),
|
||||||
|
expected_status: form.expected_status,
|
||||||
|
expect_body_contains: form.expect_body_contains.trim() || undefined,
|
||||||
|
timeout_ms: form.timeout_ms,
|
||||||
|
});
|
||||||
|
setProbe(result);
|
||||||
|
if (!result.ok) {
|
||||||
|
setError(result.reasons.join(" · ") || "Probe failed");
|
||||||
|
} else {
|
||||||
|
setNotice("Probe passed — origin reachable, no Cloudflare fallback/challenge.");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Probe failed");
|
||||||
|
} finally {
|
||||||
|
setProbeBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSite() {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
setNotice(null);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: form.name.trim(),
|
||||||
|
url: form.url.trim(),
|
||||||
|
enabled: form.enabled,
|
||||||
|
interval_secs: form.interval_secs,
|
||||||
|
timeout_ms: form.timeout_ms,
|
||||||
|
expected_status: form.expected_status,
|
||||||
|
expect_body_contains: form.expect_body_contains.trim() || null,
|
||||||
|
};
|
||||||
|
const res =
|
||||||
|
editingId == null
|
||||||
|
? await api.createSite(payload)
|
||||||
|
: await api.updateSite(editingId, payload);
|
||||||
|
setProbe(res.probe);
|
||||||
|
setNotice(
|
||||||
|
editingId == null
|
||||||
|
? `Added ${res.site.name} after passing origin/Cloudflare checks.`
|
||||||
|
: `Updated ${res.site.name}.`,
|
||||||
|
);
|
||||||
|
setForm(emptyForm());
|
||||||
|
setEditingId(null);
|
||||||
|
await refreshSites();
|
||||||
|
setTab("sites");
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Save failed");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeSite(id: number) {
|
||||||
|
if (!window.confirm("Remove this health check?")) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await api.deleteSite(id);
|
||||||
|
if (editingId === id) {
|
||||||
|
setEditingId(null);
|
||||||
|
setForm(emptyForm());
|
||||||
|
}
|
||||||
|
await refreshSites();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Delete failed");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkNow(id: number) {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await api.checkSite(id);
|
||||||
|
setProbe(res.probe);
|
||||||
|
if (!res.probe.ok) setError(res.probe.reasons.join(" · "));
|
||||||
|
await refreshSites();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Check failed");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const up = sites.filter((s) => s.last_ok === true).length;
|
||||||
|
const down = sites.filter((s) => s.last_ok === false).length;
|
||||||
|
const unknown = sites.filter((s) => s.last_ok == null).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="health-panel">
|
||||||
|
<div className="health-banner">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow mono">status</p>
|
||||||
|
<h2 className="brand" style={{ fontSize: 28, margin: "4px 0 6px" }}>
|
||||||
|
{sites.length === 0
|
||||||
|
? "No sites yet"
|
||||||
|
: down > 0
|
||||||
|
? `${down} down`
|
||||||
|
: `${up} healthy`}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="health-banner-meta mono">
|
||||||
|
<span>
|
||||||
|
{up} up · {down} down · {unknown} pending
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!readOnly ? (
|
||||||
|
<div className="health-tabs" role="tablist" aria-label="Site health">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={tab === "sites"}
|
||||||
|
className={tab === "sites" ? "active" : ""}
|
||||||
|
onClick={() => setTab("sites")}
|
||||||
|
>
|
||||||
|
Sites
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={tab === "manage"}
|
||||||
|
className={tab === "manage" ? "active" : ""}
|
||||||
|
onClick={() => {
|
||||||
|
if (editingId != null) {
|
||||||
|
setTab("manage");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openAdd();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{editingId == null ? "Add site" : "Edit site"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{tab === "sites" || readOnly ? (
|
||||||
|
<div className="health-sites" role="tabpanel">
|
||||||
|
{sites.length === 0 ? (
|
||||||
|
<div className="health-empty">
|
||||||
|
<p className="empty">No health checks yet.</p>
|
||||||
|
{!readOnly ? (
|
||||||
|
<button type="button" className="ghost-btn" onClick={() => openAdd()}>
|
||||||
|
Add a site
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
sites.map((site) => (
|
||||||
|
<article key={site.id} className={`health-site ${siteStatusClass(site)}`}>
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="site-name-link"
|
||||||
|
onClick={() => onOpenSite(site.id)}
|
||||||
|
>
|
||||||
|
{site.name}
|
||||||
|
</button>
|
||||||
|
</h3>
|
||||||
|
<a className="mono health-url" href={site.url} target="_blank" rel="noreferrer">
|
||||||
|
{site.url}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<span className="health-pill mono">{statusLabel(site)}</span>
|
||||||
|
</header>
|
||||||
|
<div className="health-site-meta mono">
|
||||||
|
<span>every {site.interval_secs}s</span>
|
||||||
|
<span>{site.last_latency_ms != null ? `${site.last_latency_ms} ms` : "—"}</span>
|
||||||
|
<span>
|
||||||
|
{site.last_status != null ? `HTTP ${site.last_status}` : "no check yet"}
|
||||||
|
</span>
|
||||||
|
{site.last_via_cloudflare ? <span>via CF</span> : null}
|
||||||
|
{site.last_cf_fallback ? <span className="bad">CF fallback</span> : null}
|
||||||
|
{site.last_cf_challenge ? <span className="bad">CF challenge</span> : null}
|
||||||
|
</div>
|
||||||
|
{site.last_error ? <p className="health-error">{site.last_error}</p> : null}
|
||||||
|
<div className="health-site-actions">
|
||||||
|
<button type="button" className="ghost-btn" onClick={() => onOpenSite(site.id)}>
|
||||||
|
History
|
||||||
|
</button>
|
||||||
|
{!readOnly ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-btn"
|
||||||
|
onClick={() => void checkNow(site.id)}
|
||||||
|
>
|
||||||
|
Check now
|
||||||
|
</button>
|
||||||
|
<button type="button" className="ghost-btn" onClick={() => startEdit(site)}>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-btn danger"
|
||||||
|
onClick={() => void removeSite(site.id)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="health-manage" role="tabpanel">
|
||||||
|
<form
|
||||||
|
className="health-form"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void saveSite();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3>{editingId == null ? "Add site" : "Edit site"}</h3>
|
||||||
|
<label>
|
||||||
|
Name
|
||||||
|
<input
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
|
placeholder="docs"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
URL
|
||||||
|
<input
|
||||||
|
value={form.url}
|
||||||
|
onChange={(e) => setForm({ ...form, url: e.target.value })}
|
||||||
|
placeholder="https://example.com/health"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="health-form-row">
|
||||||
|
<label>
|
||||||
|
Interval (s)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={15}
|
||||||
|
max={3600}
|
||||||
|
value={form.interval_secs}
|
||||||
|
onChange={(e) => setForm({ ...form, interval_secs: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Timeout (ms)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1000}
|
||||||
|
max={60000}
|
||||||
|
value={form.timeout_ms}
|
||||||
|
onChange={(e) => setForm({ ...form, timeout_ms: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Expect status
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={100}
|
||||||
|
max={599}
|
||||||
|
value={form.expected_status}
|
||||||
|
onChange={(e) => setForm({ ...form, expected_status: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
Body must contain (optional)
|
||||||
|
<input
|
||||||
|
value={form.expect_body_contains}
|
||||||
|
onChange={(e) => setForm({ ...form, expect_body_contains: e.target.value })}
|
||||||
|
placeholder='e.g. "ok" or a unique origin marker'
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="health-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.enabled}
|
||||||
|
onChange={(e) => setForm({ ...form, enabled: e.target.checked })}
|
||||||
|
/>
|
||||||
|
Enabled
|
||||||
|
</label>
|
||||||
|
<div className="health-form-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-btn"
|
||||||
|
disabled={probeBusy}
|
||||||
|
onClick={() => void runProbe()}
|
||||||
|
>
|
||||||
|
{probeBusy ? "Probing…" : "Test URL"}
|
||||||
|
</button>
|
||||||
|
<button type="submit" disabled={busy}>
|
||||||
|
{busy ? "…" : editingId == null ? "Add after probe" : "Save after probe"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-btn"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingId(null);
|
||||||
|
setForm(emptyForm());
|
||||||
|
setProbe(null);
|
||||||
|
setError(null);
|
||||||
|
setNotice(null);
|
||||||
|
setTab("sites");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Back to sites
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{notice && <div className="health-notice">{notice}</div>}
|
||||||
|
{error && <div className="error">{error}</div>}
|
||||||
|
{probe && <ProbeDetails probe={probe} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "sites" && error && <div className="error">{error}</div>}
|
||||||
|
{tab === "sites" && probe && <ProbeDetails probe={probe} />}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function siteStatusClass(site: HealthSite): string {
|
||||||
|
if (site.last_ok === true) return "is-up";
|
||||||
|
if (site.last_ok === false) return "is-down";
|
||||||
|
return "is-pending";
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(site: HealthSite): string {
|
||||||
|
if (!site.enabled) return "paused";
|
||||||
|
if (site.last_ok === true) return "up";
|
||||||
|
if (site.last_ok === false) return "down";
|
||||||
|
return "pending";
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProbeDetails({ probe }: { probe: ProbeResult }) {
|
||||||
|
return (
|
||||||
|
<div className={`probe-box ${probe.ok ? "ok" : "fail"}`}>
|
||||||
|
<div className="probe-head mono">
|
||||||
|
<strong>{probe.ok ? "probe ok" : "probe failed"}</strong>
|
||||||
|
<span>HTTP {probe.status || "—"}</span>
|
||||||
|
<span>{probe.latency_ms} ms</span>
|
||||||
|
{probe.via_cloudflare ? <span>Cloudflare edge</span> : <span>direct/origin</span>}
|
||||||
|
{probe.cloudflare_fallback ? <span className="bad">fallback</span> : null}
|
||||||
|
{probe.cloudflare_challenge ? <span className="bad">challenge</span> : null}
|
||||||
|
</div>
|
||||||
|
<ul>
|
||||||
|
{probe.reasons.map((r) => (
|
||||||
|
<li key={r}>{r}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{probe.body_snippet ? (
|
||||||
|
<pre className="probe-snippet mono">{probe.body_snippet}</pre>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { useState, type InputHTMLAttributes } from "react";
|
||||||
|
|
||||||
|
type PasswordFieldProps = Omit<InputHTMLAttributes<HTMLInputElement>, "type"> & {
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PasswordField({ label, id, ...inputProps }: PasswordFieldProps) {
|
||||||
|
const [show, setShow] = useState(false);
|
||||||
|
const inputId = id ?? label.toLowerCase().replace(/\s+/g, "-");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label htmlFor={inputId}>
|
||||||
|
{label}
|
||||||
|
<span className="password-field">
|
||||||
|
<input id={inputId} type={show ? "text" : "password"} {...inputProps} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="password-toggle"
|
||||||
|
aria-label={show ? `Hide ${label.toLowerCase()}` : `Show ${label.toLowerCase()}`}
|
||||||
|
aria-pressed={show}
|
||||||
|
onClick={() => setShow((v) => !v)}
|
||||||
|
>
|
||||||
|
<EyeIcon open={show} />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EyeIcon({ open }: { open: boolean }) {
|
||||||
|
if (open) {
|
||||||
|
return (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M3 3l18 18"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.75"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M10.6 10.6a2.5 2.5 0 003.5 3.5M9.9 5.1A10.5 10.5 0 0121 12c-.5 1-1.2 2-2.1 2.9M6.1 6.1C4.7 7.2 3.6 8.6 3 12c1.7 4.2 5.4 7 9 7 1.3 0 2.6-.3 3.8-.9"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.75"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M2.5 12S6.5 5.5 12 5.5 21.5 12 21.5 12 17.5 18.5 12 18.5 2.5 12 2.5 12z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.75"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<circle cx="12" cy="12" r="2.75" stroke="currentColor" strokeWidth="1.75" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api, type AppSettings, type HealthSite } from "./api";
|
||||||
|
|
||||||
|
export function SettingsPage({ onBack }: { onBack: () => void }) {
|
||||||
|
const [settings, setSettings] = useState<AppSettings | null>(null);
|
||||||
|
const [sites, setSites] = useState<HealthSite[]>([]);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const [s, list] = await Promise.all([api.getSettings(), api.listSites()]);
|
||||||
|
if (!cancelled) {
|
||||||
|
setSettings(s);
|
||||||
|
setSites(list);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) setError(e instanceof Error ? e.message : "Failed to load settings");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!settings) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
setNotice(null);
|
||||||
|
try {
|
||||||
|
const saved = await api.saveSettings(settings);
|
||||||
|
setSettings(saved);
|
||||||
|
setNotice("Settings saved.");
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Save failed");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSite(id: number) {
|
||||||
|
setSettings((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
const has = prev.public_site_ids.includes(id);
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
public_site_ids: has
|
||||||
|
? prev.public_site_ids.filter((x) => x !== id)
|
||||||
|
: [...prev.public_site_ids, id],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!settings && !error) {
|
||||||
|
return (
|
||||||
|
<div className="shell sites-shell">
|
||||||
|
<p className="mono">Loading settings…</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!settings) {
|
||||||
|
return (
|
||||||
|
<div className="shell sites-shell">
|
||||||
|
<button type="button" className="ghost-btn" onClick={onBack}>
|
||||||
|
← Back
|
||||||
|
</button>
|
||||||
|
<div className="error">{error}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="shell sites-shell">
|
||||||
|
<header className="page-hero">
|
||||||
|
<div>
|
||||||
|
<button type="button" className="ghost-btn site-back" onClick={onBack}>
|
||||||
|
← Dashboard
|
||||||
|
</button>
|
||||||
|
<p className="eyebrow mono">admin</p>
|
||||||
|
<h1 className="brand">Settings</h1>
|
||||||
|
<p className="lede">Retention and public read access for Meterbox.</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="site-section settings-section">
|
||||||
|
<header>
|
||||||
|
<h2>Health logs</h2>
|
||||||
|
</header>
|
||||||
|
<label className="settings-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.cleanup_logs_older_than_3_months}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
cleanup_logs_older_than_3_months: e.target.checked,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
Clean up health check logs older than 3 months
|
||||||
|
<small>When enabled, failed/ok probe history older than 90 days is deleted.</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="site-section settings-section">
|
||||||
|
<header>
|
||||||
|
<h2>Public access</h2>
|
||||||
|
<span className="mono">viewable without sign-in</span>
|
||||||
|
</header>
|
||||||
|
<p className="settings-hint">
|
||||||
|
Guests can only view selected pages. Adding, editing, and settings still require admin
|
||||||
|
sign-in.
|
||||||
|
</p>
|
||||||
|
<label className="settings-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.public_dashboard}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
public_dashboard: e.target.checked,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
Host telemetry dashboard
|
||||||
|
<small>CPU, memory, network, compliance, bandwidth.</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="settings-sites">
|
||||||
|
<h3>Sites</h3>
|
||||||
|
{sites.length === 0 ? (
|
||||||
|
<p className="empty">No sites yet. Add one under Sites first.</p>
|
||||||
|
) : (
|
||||||
|
sites.map((site) => (
|
||||||
|
<label key={site.id} className="settings-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.public_site_ids.includes(site.id)}
|
||||||
|
onChange={() => toggleSite(site.id)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{site.name}
|
||||||
|
<small className="mono">{site.url}</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="settings-actions">
|
||||||
|
<button type="button" disabled={busy} onClick={() => void save()}>
|
||||||
|
{busy ? "Saving…" : "Save settings"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{notice && <div className="health-notice">{notice}</div>}
|
||||||
|
{error && <div className="error">{error}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
type HealthCheckResult,
|
||||||
|
type HealthIncident,
|
||||||
|
type HealthRangeKey,
|
||||||
|
type SiteHistory,
|
||||||
|
} from "./api";
|
||||||
|
import { HealthUptimeChart } from "./charts";
|
||||||
|
import { formatDuration, formatWhen } from "./format";
|
||||||
|
|
||||||
|
const RANGES: { key: HealthRangeKey; label: string }[] = [
|
||||||
|
{ key: "1h", label: "1 hour" },
|
||||||
|
{ key: "12h", label: "12 hours" },
|
||||||
|
{ key: "24h", label: "24 hours" },
|
||||||
|
{ key: "7d", label: "7 days" },
|
||||||
|
{ key: "30d", label: "30 days" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const LOG_PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
type LogFilter = "all" | "not_200" | "down";
|
||||||
|
|
||||||
|
export function SiteDashboard({
|
||||||
|
siteId,
|
||||||
|
onBack,
|
||||||
|
readOnly = false,
|
||||||
|
}: {
|
||||||
|
siteId: number;
|
||||||
|
onBack: () => void;
|
||||||
|
readOnly?: boolean;
|
||||||
|
}) {
|
||||||
|
const [range, setRange] = useState<HealthRangeKey>("24h");
|
||||||
|
const [history, setHistory] = useState<SiteHistory | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [logFilter, setLogFilter] = useState<LogFilter>("all");
|
||||||
|
const [logPage, setLogPage] = useState(0);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setHistory(await api.siteHistory(siteId, range));
|
||||||
|
setError(null);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to load history");
|
||||||
|
}
|
||||||
|
}, [siteId, range]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setHistory(null);
|
||||||
|
setLogPage(0);
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.siteHistory(siteId, range);
|
||||||
|
if (!cancelled) setHistory(data);
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) setError(e instanceof Error ? e.message : "Failed to load history");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [siteId, range]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const id = window.setInterval(() => {
|
||||||
|
void refresh();
|
||||||
|
}, 20000);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLogPage(0);
|
||||||
|
}, [logFilter, range]);
|
||||||
|
|
||||||
|
async function checkNow() {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.checkSite(siteId);
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Check failed");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredLogs = useMemo(() => {
|
||||||
|
if (!history) return [];
|
||||||
|
return history.results.filter((r) => {
|
||||||
|
if (logFilter === "not_200") return r.status !== 200;
|
||||||
|
if (logFilter === "down") return !r.ok;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [history, logFilter]);
|
||||||
|
|
||||||
|
const pageCount = Math.max(1, Math.ceil(filteredLogs.length / LOG_PAGE_SIZE));
|
||||||
|
const safePage = Math.min(logPage, pageCount - 1);
|
||||||
|
const pageLogs = filteredLogs.slice(
|
||||||
|
safePage * LOG_PAGE_SIZE,
|
||||||
|
safePage * LOG_PAGE_SIZE + LOG_PAGE_SIZE,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!history && !error) {
|
||||||
|
return (
|
||||||
|
<div className="shell sites-shell">
|
||||||
|
<p className="mono">Loading site history…</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!history) {
|
||||||
|
return (
|
||||||
|
<div className="shell sites-shell">
|
||||||
|
<button type="button" className="ghost-btn" onClick={onBack}>
|
||||||
|
← Back to sites
|
||||||
|
</button>
|
||||||
|
<div className="error">{error}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { site, summary, incidents, series } = history;
|
||||||
|
const status =
|
||||||
|
site.last_ok === true ? "up" : site.last_ok === false ? "down" : "pending";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="shell sites-shell">
|
||||||
|
<header className="page-hero site-dash-hero">
|
||||||
|
<div>
|
||||||
|
<button type="button" className="ghost-btn site-back" onClick={onBack}>
|
||||||
|
← Sites
|
||||||
|
</button>
|
||||||
|
<p className="eyebrow mono">site dashboard</p>
|
||||||
|
<h1 className="brand">{site.name}</h1>
|
||||||
|
<a className="mono health-url" href={site.url} target="_blank" rel="noreferrer">
|
||||||
|
{site.url}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="site-dash-actions">
|
||||||
|
<span className={`health-pill mono is-${status}`}>{status}</span>
|
||||||
|
{!readOnly ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-btn"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void checkNow()}
|
||||||
|
>
|
||||||
|
{busy ? "Checking…" : "Check now"}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="toolbar">
|
||||||
|
<div className="ranges" role="tablist" aria-label="Health range">
|
||||||
|
{RANGES.map((r) => (
|
||||||
|
<button
|
||||||
|
key={r.key}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={range === r.key}
|
||||||
|
className={range === r.key ? "active" : ""}
|
||||||
|
onClick={() => setRange(r.key)}
|
||||||
|
>
|
||||||
|
{r.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="bucket mono">
|
||||||
|
{summary.checks} checks · bucket {history.bucket_secs}s
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="site-summary">
|
||||||
|
<SummaryStat
|
||||||
|
label="Uptime"
|
||||||
|
value={summary.uptime_pct != null ? `${summary.uptime_pct.toFixed(1)}%` : "—"}
|
||||||
|
hint={range}
|
||||||
|
/>
|
||||||
|
<SummaryStat label="Healthy" value={String(summary.ok)} hint="ok probes" />
|
||||||
|
<SummaryStat label="Down" value={String(summary.down)} hint="failed probes" />
|
||||||
|
<SummaryStat
|
||||||
|
label="Last latency"
|
||||||
|
value={site.last_latency_ms != null ? `${site.last_latency_ms} ms` : "—"}
|
||||||
|
hint={site.last_check_at ? formatWhen(site.last_check_at) : "no check yet"}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{site.last_ok === false && site.last_error ? (
|
||||||
|
<div className="site-current-error">
|
||||||
|
<strong className="mono">Currently down</strong>
|
||||||
|
<p>{site.last_error}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<section className="site-section">
|
||||||
|
<header>
|
||||||
|
<h2>Health status</h2>
|
||||||
|
<span className="mono">{range}</span>
|
||||||
|
</header>
|
||||||
|
{series.length === 0 ? (
|
||||||
|
<p className="empty">No checks in this window yet.</p>
|
||||||
|
) : (
|
||||||
|
<HealthUptimeChart points={series} range={range} />
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="site-section">
|
||||||
|
<header>
|
||||||
|
<h2>Downtime</h2>
|
||||||
|
<span className="mono">{incidents.length} events</span>
|
||||||
|
</header>
|
||||||
|
{incidents.length === 0 ? (
|
||||||
|
<p className="empty">No downtime in this window.</p>
|
||||||
|
) : (
|
||||||
|
<div className="incident-list">
|
||||||
|
{incidents.slice(0, 10).map((inc) => (
|
||||||
|
<IncidentCard key={`${inc.started_at}-${inc.ended_at ?? "open"}`} incident={inc} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="site-section">
|
||||||
|
<header className="log-header">
|
||||||
|
<div>
|
||||||
|
<h2>Check log</h2>
|
||||||
|
<span className="mono">
|
||||||
|
{filteredLogs.length} shown · page {safePage + 1}/{pageCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="log-filters" role="group" aria-label="Log filter">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={logFilter === "all" ? "active" : ""}
|
||||||
|
onClick={() => setLogFilter("all")}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={logFilter === "not_200" ? "active" : ""}
|
||||||
|
onClick={() => setLogFilter("not_200")}
|
||||||
|
>
|
||||||
|
Not 200
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={logFilter === "down" ? "active" : ""}
|
||||||
|
onClick={() => setLogFilter("down")}
|
||||||
|
>
|
||||||
|
Down
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="check-log compact">
|
||||||
|
{pageLogs.length === 0 ? (
|
||||||
|
<p className="empty">No matching checks.</p>
|
||||||
|
) : (
|
||||||
|
pageLogs.map((r) => <CheckRow key={r.id} result={r} />)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="log-pager">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-btn"
|
||||||
|
disabled={safePage <= 0}
|
||||||
|
onClick={() => setLogPage((p) => Math.max(0, p - 1))}
|
||||||
|
>
|
||||||
|
Prev
|
||||||
|
</button>
|
||||||
|
<span className="mono">
|
||||||
|
{safePage * LOG_PAGE_SIZE + (pageLogs.length ? 1 : 0)}–
|
||||||
|
{safePage * LOG_PAGE_SIZE + pageLogs.length} of {filteredLogs.length}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-btn"
|
||||||
|
disabled={safePage >= pageCount - 1}
|
||||||
|
onClick={() => setLogPage((p) => Math.min(pageCount - 1, p + 1))}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{error && <div className="error">{error}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryStat({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
hint,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
hint: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="stat">
|
||||||
|
<span className="stat-label">{label}</span>
|
||||||
|
<strong className="stat-value mono">{value}</strong>
|
||||||
|
<span className="stat-hint">{hint}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IncidentCard({ incident }: { incident: HealthIncident }) {
|
||||||
|
return (
|
||||||
|
<article className={`incident-card ${incident.ongoing ? "ongoing" : ""}`}>
|
||||||
|
<header>
|
||||||
|
<strong className="mono">{incident.ongoing ? "Ongoing outage" : "Outage"}</strong>
|
||||||
|
<span className="mono">
|
||||||
|
{incident.duration_secs != null ? formatDuration(incident.duration_secs) : "—"}
|
||||||
|
{" · "}
|
||||||
|
{incident.checks} check{incident.checks === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
<div className="incident-times mono">
|
||||||
|
<span>Down from {formatWhen(incident.started_at)}</span>
|
||||||
|
<span>
|
||||||
|
{incident.ended_at ? `Recovered ${formatWhen(incident.ended_at)}` : "Still down"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{incident.status != null && incident.status > 0 ? (
|
||||||
|
<span className="mono incident-status">HTTP {incident.status}</span>
|
||||||
|
) : null}
|
||||||
|
<p className="incident-error">{incident.error}</p>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CheckRow({ result }: { result: HealthCheckResult }) {
|
||||||
|
return (
|
||||||
|
<article className={`check-row compact ${result.ok ? "ok" : "down"}`}>
|
||||||
|
<div className="check-row-head mono">
|
||||||
|
<span className={`health-pill ${result.ok ? "" : "is-down"}`}>
|
||||||
|
{result.ok ? "up" : "down"}
|
||||||
|
</span>
|
||||||
|
<span>{formatWhen(result.checked_at)}</span>
|
||||||
|
<span>{result.latency_ms} ms</span>
|
||||||
|
<span>{result.status > 0 ? `HTTP ${result.status}` : "—"}</span>
|
||||||
|
{!result.ok && result.error ? (
|
||||||
|
<span className="check-error-inline" title={result.error}>
|
||||||
|
{result.error}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { HealthPanel } from "./HealthPanel";
|
||||||
|
|
||||||
|
export function SitesPage({
|
||||||
|
onOpenSite,
|
||||||
|
readOnly = false,
|
||||||
|
}: {
|
||||||
|
onOpenSite: (id: number) => void;
|
||||||
|
readOnly?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="shell sites-shell">
|
||||||
|
<header className="page-hero">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow mono">monitoring</p>
|
||||||
|
<h1 className="brand">Sites</h1>
|
||||||
|
<p className="lede">
|
||||||
|
HTTP health checks from this host. Open a site for its uptime timeline and downtime
|
||||||
|
history.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<HealthPanel onOpenSite={onOpenSite} readOnly={readOnly} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
export type RangeKey = "24h" | "7d" | "4w" | "3m";
|
||||||
|
|
||||||
|
export type HostInfo = {
|
||||||
|
hostname: string;
|
||||||
|
os: string;
|
||||||
|
kernel: string;
|
||||||
|
cpus: number;
|
||||||
|
mem_total_bytes: number;
|
||||||
|
disk_used_bytes: number;
|
||||||
|
disk_total_bytes: number;
|
||||||
|
disk_pct: number;
|
||||||
|
uptime_secs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Sample = {
|
||||||
|
ts: number;
|
||||||
|
cpu_pct: number;
|
||||||
|
mem_used_bytes: number;
|
||||||
|
mem_total_bytes: number;
|
||||||
|
mem_pct: number;
|
||||||
|
disk_used_bytes: number;
|
||||||
|
disk_total_bytes: number;
|
||||||
|
disk_pct: number;
|
||||||
|
net_rx_bps: number;
|
||||||
|
net_tx_bps: number;
|
||||||
|
load1: number;
|
||||||
|
load5: number;
|
||||||
|
load15: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SeriesPoint = {
|
||||||
|
ts: number;
|
||||||
|
cpu_pct: number;
|
||||||
|
mem_pct: number;
|
||||||
|
mem_used_bytes: number;
|
||||||
|
mem_total_bytes: number;
|
||||||
|
disk_pct: number;
|
||||||
|
disk_used_bytes: number;
|
||||||
|
disk_total_bytes: number;
|
||||||
|
net_rx_bps: number;
|
||||||
|
net_tx_bps: number;
|
||||||
|
load1: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SeriesResponse = {
|
||||||
|
range: string;
|
||||||
|
from_ts: number;
|
||||||
|
to_ts: number;
|
||||||
|
bucket_secs: number;
|
||||||
|
points: SeriesPoint[];
|
||||||
|
averages: {
|
||||||
|
cpu_pct: number;
|
||||||
|
mem_pct: number;
|
||||||
|
disk_pct: number;
|
||||||
|
net_rx_bps: number;
|
||||||
|
net_tx_bps: number;
|
||||||
|
load1: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ComplianceCheck = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
status: "pass" | "fail" | "warn" | "info" | string;
|
||||||
|
detail: string;
|
||||||
|
current: string;
|
||||||
|
limit: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ComplianceReport = {
|
||||||
|
overall: "compliant" | "at_risk" | "over_limit" | string;
|
||||||
|
summary: string;
|
||||||
|
tier: string;
|
||||||
|
docs_url: string;
|
||||||
|
notes: string[];
|
||||||
|
checks: ComplianceCheck[];
|
||||||
|
host: {
|
||||||
|
ocpus: number;
|
||||||
|
mem_total_bytes: number;
|
||||||
|
disk_total_bytes: number;
|
||||||
|
arch: string;
|
||||||
|
is_arm: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BwflowMonth = {
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
rx_bytes: number;
|
||||||
|
tx_bytes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BwflowStats = {
|
||||||
|
available: boolean;
|
||||||
|
error: string | null;
|
||||||
|
socket: string;
|
||||||
|
state: string | null;
|
||||||
|
interface: string | null;
|
||||||
|
as_of: string | null;
|
||||||
|
tx_bytes: number | null;
|
||||||
|
tokens_bytes: number | null;
|
||||||
|
throttle_kbps: number | null;
|
||||||
|
monthly_budget_gb: number | null;
|
||||||
|
pre_charge_gb: number | null;
|
||||||
|
budget_used_pct: number | null;
|
||||||
|
tokens_remaining_gb: number | null;
|
||||||
|
tx_gb: number | null;
|
||||||
|
rate_limit_on: boolean | null;
|
||||||
|
calendar_month: string | null;
|
||||||
|
period_start: string | null;
|
||||||
|
period_resets: string | null;
|
||||||
|
months: BwflowMonth[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserPublic = {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
created_at: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AuthStatus = {
|
||||||
|
needs_setup: boolean;
|
||||||
|
user: UserPublic | null;
|
||||||
|
public: PublicAccess;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PublicAccess = {
|
||||||
|
dashboard: boolean;
|
||||||
|
site_ids: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AppSettings = {
|
||||||
|
cleanup_logs_older_than_3_months: boolean;
|
||||||
|
public_dashboard: boolean;
|
||||||
|
public_site_ids: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AuthSession = {
|
||||||
|
token: string;
|
||||||
|
user: UserPublic;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HealthSite = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
enabled: boolean;
|
||||||
|
interval_secs: number;
|
||||||
|
timeout_ms: number;
|
||||||
|
expected_status: number;
|
||||||
|
expect_body_contains: string | null;
|
||||||
|
created_at: number;
|
||||||
|
updated_at: number;
|
||||||
|
last_check_at: number | null;
|
||||||
|
last_ok: boolean | null;
|
||||||
|
last_status: number | null;
|
||||||
|
last_latency_ms: number | null;
|
||||||
|
last_error: string | null;
|
||||||
|
last_via_cloudflare: boolean | null;
|
||||||
|
last_cf_fallback: boolean | null;
|
||||||
|
last_cf_challenge: boolean | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpsertSite = {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
interval_secs?: number;
|
||||||
|
timeout_ms?: number;
|
||||||
|
expected_status?: number;
|
||||||
|
expect_body_contains?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProbeResult = {
|
||||||
|
ok: boolean;
|
||||||
|
url: string;
|
||||||
|
final_url: string;
|
||||||
|
status: number;
|
||||||
|
latency_ms: number;
|
||||||
|
server: string | null;
|
||||||
|
via_cloudflare: boolean;
|
||||||
|
cloudflare_fallback: boolean;
|
||||||
|
cloudflare_challenge: boolean;
|
||||||
|
body_snippet: string;
|
||||||
|
error: string | null;
|
||||||
|
reasons: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SiteCreateResponse = {
|
||||||
|
site: HealthSite;
|
||||||
|
probe: ProbeResult;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HealthCheckResult = {
|
||||||
|
id: number;
|
||||||
|
site_id: number;
|
||||||
|
checked_at: number;
|
||||||
|
ok: boolean;
|
||||||
|
status: number;
|
||||||
|
latency_ms: number;
|
||||||
|
via_cloudflare: boolean;
|
||||||
|
cf_fallback: boolean;
|
||||||
|
cf_challenge: boolean;
|
||||||
|
error: string | null;
|
||||||
|
reasons: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HealthIncident = {
|
||||||
|
started_at: number;
|
||||||
|
ended_at: number | null;
|
||||||
|
duration_secs: number | null;
|
||||||
|
ongoing: boolean;
|
||||||
|
error: string;
|
||||||
|
status: number | null;
|
||||||
|
checks: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HealthSeriesPoint = {
|
||||||
|
ts: number;
|
||||||
|
uptime_pct: number;
|
||||||
|
ok: number;
|
||||||
|
down: number;
|
||||||
|
avg_latency_ms: number;
|
||||||
|
had_down: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HealthRangeKey = "1h" | "12h" | "24h" | "7d" | "30d";
|
||||||
|
|
||||||
|
export type SiteHistory = {
|
||||||
|
site: HealthSite;
|
||||||
|
range: HealthRangeKey | string;
|
||||||
|
from_ts: number;
|
||||||
|
to_ts: number;
|
||||||
|
bucket_secs: number;
|
||||||
|
summary: {
|
||||||
|
checks: number;
|
||||||
|
ok: number;
|
||||||
|
down: number;
|
||||||
|
uptime_pct: number | null;
|
||||||
|
};
|
||||||
|
incidents: HealthIncident[];
|
||||||
|
series: HealthSeriesPoint[];
|
||||||
|
results: HealthCheckResult[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const TOKEN_KEY = "meterbox-token";
|
||||||
|
|
||||||
|
export function getToken(): string | null {
|
||||||
|
return localStorage.getItem(TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setToken(token: string | null) {
|
||||||
|
if (token) localStorage.setItem(TOKEN_KEY, token);
|
||||||
|
else localStorage.removeItem(TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const headers = new Headers(init?.headers);
|
||||||
|
const token = getToken();
|
||||||
|
if (token) headers.set("Authorization", `Bearer ${token}`);
|
||||||
|
if (init?.body && !headers.has("Content-Type")) {
|
||||||
|
headers.set("Content-Type", "application/json");
|
||||||
|
}
|
||||||
|
const res = await fetch(path, { ...init, headers });
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 401) setToken(null);
|
||||||
|
let detail = `${path} failed: ${res.status}`;
|
||||||
|
try {
|
||||||
|
const body = (await res.json()) as { error?: string };
|
||||||
|
if (body.error) detail = body.error;
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
throw new Error(detail);
|
||||||
|
}
|
||||||
|
if (res.status === 204) return undefined as T;
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
getToken,
|
||||||
|
host: () => getJson<HostInfo>("/api/host"),
|
||||||
|
snapshot: () => getJson<Sample | null>("/api/snapshot"),
|
||||||
|
series: (range: RangeKey) => getJson<SeriesResponse>(`/api/series?range=${range}`),
|
||||||
|
compliance: () => getJson<ComplianceReport>("/api/compliance"),
|
||||||
|
bwflow: () => getJson<BwflowStats>("/api/bwflow"),
|
||||||
|
health: () =>
|
||||||
|
getJson<{ ok: boolean; samples: number; oldest_ts: number | null }>("/api/health"),
|
||||||
|
authStatus: () => getJson<AuthStatus>("/api/auth/status"),
|
||||||
|
setup: async (email: string, password: string) => {
|
||||||
|
const session = await getJson<AuthSession>("/api/auth/setup", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
setToken(session.token);
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
login: async (email: string, password: string) => {
|
||||||
|
const session = await getJson<AuthSession>("/api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
setToken(session.token);
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
logout: async () => {
|
||||||
|
try {
|
||||||
|
await getJson<void>("/api/auth/logout", { method: "POST" });
|
||||||
|
} finally {
|
||||||
|
setToken(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
listSites: () => getJson<HealthSite[]>("/api/sites"),
|
||||||
|
getSite: (id: number) => getJson<HealthSite>(`/api/sites/${id}`),
|
||||||
|
siteHistory: (id: number, range: HealthRangeKey | string = "24h") =>
|
||||||
|
getJson<SiteHistory>(`/api/sites/${id}/history?range=${encodeURIComponent(range)}`),
|
||||||
|
createSite: (body: UpsertSite) =>
|
||||||
|
getJson<SiteCreateResponse>("/api/sites", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
updateSite: (id: number, body: UpsertSite) =>
|
||||||
|
getJson<SiteCreateResponse>(`/api/sites/${id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
deleteSite: (id: number) =>
|
||||||
|
getJson<void>(`/api/sites/${id}`, { method: "DELETE" }),
|
||||||
|
probeSite: (body: {
|
||||||
|
url: string;
|
||||||
|
expected_status?: number;
|
||||||
|
expect_body_contains?: string;
|
||||||
|
timeout_ms?: number;
|
||||||
|
}) =>
|
||||||
|
getJson<ProbeResult>("/api/sites/probe", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
checkSite: (id: number) =>
|
||||||
|
getJson<SiteCreateResponse>(`/api/sites/${id}/check`, { method: "POST" }),
|
||||||
|
getSettings: () => getJson<AppSettings>("/api/settings"),
|
||||||
|
saveSettings: (body: AppSettings) =>
|
||||||
|
getJson<AppSettings>("/api/settings", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
};
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="77" height="47" fill="none" aria-labelledby="vite-logo-title" viewBox="0 0 77 47"><title id="vite-logo-title">Vite</title><style>.parenthesis{fill:#000}@media (prefers-color-scheme:dark){.parenthesis{fill:#fff}}</style><path fill="#9135ff" d="M40.151 45.71c-.663.844-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.493c-.92 0-1.457-1.04-.92-1.788l7.479-10.471c1.07-1.498 0-3.578-1.842-3.578H15.443c-.92 0-1.456-1.04-.92-1.788l9.696-13.576c.213-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.472c-1.07 1.497 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.087.89 1.83L40.153 45.712z"/><mask id="a" width="48" height="47" x="14" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M40.047 45.71c-.663.843-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.389c-.92 0-1.457-1.04-.92-1.788l7.479-10.472c1.07-1.497 0-3.578-1.842-3.578H15.34c-.92 0-1.456-1.04-.92-1.788l9.696-13.575c.213-.297.556-.474.92-.474H53.93c.92 0 1.456 1.04.92 1.788L47.37 13.03c-1.07 1.498 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.088.89 1.831L40.049 45.712z"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#eee6ff" rx="5.508" ry="14.704" transform="rotate(269.814 20.96 11.29)scale(-1 1)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#eee6ff" rx="10.399" ry="29.851" transform="rotate(89.814 -16.902 -8.275)scale(1 -1)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#8900ff" rx="5.508" ry="30.487" transform="rotate(89.814 -19.197 -7.127)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.928 4.177)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.738 5.52)scale(1 -1)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#eee6ff" rx="14.072" ry="22.078" transform="rotate(93.35 31.245 55.578)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx="14.592" cy="9.743" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(39.51 14.592 9.743)"/></g><g filter="url(#k)"><ellipse cx="61.728" cy="-5.321" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 61.728 -5.32)"/></g><g filter="url(#l)"><ellipse cx="55.618" cy="7.104" fill="#00c2ff" rx="5.971" ry="9.665" transform="rotate(37.892 55.618 7.104)"/></g><g filter="url(#m)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#n)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#o)"><ellipse cx="49.857" cy="30.678" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 49.857 30.678)"/></g><g filter="url(#p)"><ellipse cx="52.623" cy="33.171" fill="#00c2ff" rx="5.971" ry="15.297" transform="rotate(37.892 52.623 33.17)"/></g></g><path d="M6.919 0c-9.198 13.166-9.252 33.575 0 46.789h6.215c-9.25-13.214-9.196-33.623 0-46.789zm62.424 0h-6.215c9.198 13.166 9.252 33.575 0 46.789h6.215c9.25-13.214 9.196-33.623 0-46.789" class="parenthesis"/><defs><filter id="b" width="60.045" height="41.654" x="-5.564" y="16.92" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-40.407" y="-6.762" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-35.435" y="2.801" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-30.84" y="20.8" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-29.307" y="21.949" color-interpolation-filters="sRGB" filterUnits="usLine truncated
|
||||||
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,228 @@
|
|||||||
|
import {
|
||||||
|
Area,
|
||||||
|
AreaChart,
|
||||||
|
CartesianGrid,
|
||||||
|
Legend,
|
||||||
|
Line,
|
||||||
|
LineChart,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
|
import type { HealthSeriesPoint, SeriesPoint } from "./api";
|
||||||
|
import { formatPct, formatRate, formatTick } from "./format";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
points: SeriesPoint[];
|
||||||
|
range: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const tooltipStyle = {
|
||||||
|
background: "#f7faf5",
|
||||||
|
border: "1px solid rgba(21,32,28,0.12)",
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CpuChart({ points, range }: Props) {
|
||||||
|
const data = points.map((p) => ({
|
||||||
|
...p,
|
||||||
|
label: formatTick(p.ts, range),
|
||||||
|
}));
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={240}>
|
||||||
|
<AreaChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="cpuFill" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="#0f766e" stopOpacity={0.35} />
|
||||||
|
<stop offset="100%" stopColor="#0f766e" stopOpacity={0.02} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid stroke="rgba(21,32,28,0.08)" vertical={false} />
|
||||||
|
<XAxis dataKey="label" tick={{ fill: "#5c6b64", fontSize: 11 }} minTickGap={28} />
|
||||||
|
<YAxis
|
||||||
|
domain={[0, "auto"]}
|
||||||
|
tick={{ fill: "#5c6b64", fontSize: 11 }}
|
||||||
|
tickFormatter={(v) => `${v}%`}
|
||||||
|
width={44}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={tooltipStyle}
|
||||||
|
formatter={(value) => [formatPct(Number(value)), "CPU"]}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="cpu_pct"
|
||||||
|
name="CPU %"
|
||||||
|
stroke="#0f766e"
|
||||||
|
fill="url(#cpuFill)"
|
||||||
|
strokeWidth={2}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MemChart({ points, range }: Props) {
|
||||||
|
const data = points.map((p) => ({
|
||||||
|
...p,
|
||||||
|
label: formatTick(p.ts, range),
|
||||||
|
}));
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={240}>
|
||||||
|
<AreaChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="memFill" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="#c2410c" stopOpacity={0.3} />
|
||||||
|
<stop offset="100%" stopColor="#c2410c" stopOpacity={0.02} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid stroke="rgba(21,32,28,0.08)" vertical={false} />
|
||||||
|
<XAxis dataKey="label" tick={{ fill: "#5c6b64", fontSize: 11 }} minTickGap={28} />
|
||||||
|
<YAxis
|
||||||
|
domain={[0, 100]}
|
||||||
|
tick={{ fill: "#5c6b64", fontSize: 11 }}
|
||||||
|
tickFormatter={(v) => `${v}%`}
|
||||||
|
width={44}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={tooltipStyle}
|
||||||
|
formatter={(value) => [formatPct(Number(value)), "Memory"]}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="mem_pct"
|
||||||
|
name="Memory %"
|
||||||
|
stroke="#c2410c"
|
||||||
|
fill="url(#memFill)"
|
||||||
|
strokeWidth={2}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NetChart({ points, range }: Props) {
|
||||||
|
const data = points.map((p) => ({
|
||||||
|
...p,
|
||||||
|
label: formatTick(p.ts, range),
|
||||||
|
}));
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
|
<LineChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
|
||||||
|
<CartesianGrid stroke="rgba(21,32,28,0.08)" vertical={false} />
|
||||||
|
<XAxis dataKey="label" tick={{ fill: "#5c6b64", fontSize: 11 }} minTickGap={28} />
|
||||||
|
<YAxis
|
||||||
|
tick={{ fill: "#5c6b64", fontSize: 11 }}
|
||||||
|
tickFormatter={(v) => formatRate(Number(v))}
|
||||||
|
width={72}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={tooltipStyle}
|
||||||
|
formatter={(value, name) => [formatRate(Number(value)), String(name)]}
|
||||||
|
/>
|
||||||
|
<Legend />
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="net_rx_bps"
|
||||||
|
name="RX"
|
||||||
|
stroke="#1d4ed8"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="net_tx_bps"
|
||||||
|
name="TX"
|
||||||
|
stroke="#0f766e"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HealthUptimeChart({
|
||||||
|
points,
|
||||||
|
range,
|
||||||
|
}: {
|
||||||
|
points: HealthSeriesPoint[];
|
||||||
|
range: string;
|
||||||
|
}) {
|
||||||
|
const data = points.map((p) => ({
|
||||||
|
...p,
|
||||||
|
label: formatTick(p.ts, range),
|
||||||
|
status: p.had_down ? 0 : 1,
|
||||||
|
}));
|
||||||
|
return (
|
||||||
|
<div className="health-charts">
|
||||||
|
<ResponsiveContainer width="100%" height={200}>
|
||||||
|
<AreaChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="upFill" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="#3f7d4e" stopOpacity={0.35} />
|
||||||
|
<stop offset="100%" stopColor="#3f7d4e" stopOpacity={0.02} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid stroke="rgba(21,32,28,0.08)" vertical={false} />
|
||||||
|
<XAxis dataKey="label" tick={{ fill: "#5c6b64", fontSize: 11 }} minTickGap={28} />
|
||||||
|
<YAxis
|
||||||
|
domain={[0, 100]}
|
||||||
|
tick={{ fill: "#5c6b64", fontSize: 11 }}
|
||||||
|
tickFormatter={(v) => `${v}%`}
|
||||||
|
width={44}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={tooltipStyle}
|
||||||
|
formatter={(value, name) => {
|
||||||
|
if (String(name) === "Uptime") return [`${Number(value).toFixed(1)}%`, "Uptime"];
|
||||||
|
return [String(value), String(name)];
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="stepAfter"
|
||||||
|
dataKey="uptime_pct"
|
||||||
|
name="Uptime"
|
||||||
|
stroke="#3f7d4e"
|
||||||
|
fill="url(#upFill)"
|
||||||
|
strokeWidth={2}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<ResponsiveContainer width="100%" height={120}>
|
||||||
|
<AreaChart data={data} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
||||||
|
<CartesianGrid stroke="rgba(21,32,28,0.08)" vertical={false} />
|
||||||
|
<XAxis dataKey="label" tick={{ fill: "#5c6b64", fontSize: 11 }} minTickGap={28} />
|
||||||
|
<YAxis
|
||||||
|
domain={[0, 1]}
|
||||||
|
ticks={[0, 1]}
|
||||||
|
tick={{ fill: "#5c6b64", fontSize: 11 }}
|
||||||
|
tickFormatter={(v) => (Number(v) === 1 ? "up" : "down")}
|
||||||
|
width={44}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={tooltipStyle}
|
||||||
|
formatter={(value) => [Number(value) === 1 ? "up" : "down", "Status"]}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="stepAfter"
|
||||||
|
dataKey="status"
|
||||||
|
name="Status"
|
||||||
|
stroke="#0f766e"
|
||||||
|
fill="#0f766e"
|
||||||
|
fillOpacity={0.2}
|
||||||
|
strokeWidth={2}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
export function formatBytes(n: number): string {
|
||||||
|
if (!Number.isFinite(n) || n < 0) return "—";
|
||||||
|
const units = ["B", "KiB", "MiB", "GiB", "TiB"];
|
||||||
|
let v = n;
|
||||||
|
let i = 0;
|
||||||
|
while (v >= 1024 && i < units.length - 1) {
|
||||||
|
v /= 1024;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
return `${v.toFixed(v >= 10 || i === 0 ? 0 : 1)} ${units[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRate(bps: number): string {
|
||||||
|
if (!Number.isFinite(bps) || bps < 0) return "—";
|
||||||
|
// Show as bits/s style for network familiarity when large, else bytes/s
|
||||||
|
const bits = bps * 8;
|
||||||
|
if (bits >= 1_000_000) return `${(bits / 1_000_000).toFixed(2)} Mbit/s`;
|
||||||
|
if (bits >= 1_000) return `${(bits / 1_000).toFixed(1)} kbit/s`;
|
||||||
|
return `${bps.toFixed(0)} B/s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPct(n: number): string {
|
||||||
|
if (!Number.isFinite(n)) return "—";
|
||||||
|
return `${n.toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatUptime(secs: number): string {
|
||||||
|
const d = Math.floor(secs / 86400);
|
||||||
|
const h = Math.floor((secs % 86400) / 3600);
|
||||||
|
const m = Math.floor((secs % 3600) / 60);
|
||||||
|
if (d > 0) return `${d}d ${h}h`;
|
||||||
|
if (h > 0) return `${h}h ${m}m`;
|
||||||
|
return `${m}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTick(ts: number, range: string): string {
|
||||||
|
const d = new Date(ts * 1000);
|
||||||
|
if (range === "1h" || range === "12h" || range === "24h") {
|
||||||
|
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||||
|
}
|
||||||
|
if (range === "7d") {
|
||||||
|
return d.toLocaleString([], { weekday: "short", hour: "2-digit" });
|
||||||
|
}
|
||||||
|
return d.toLocaleDateString([], { month: "short", day: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatWhen(ts: number): string {
|
||||||
|
return new Date(ts * 1000).toLocaleString([], {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDuration(secs: number): string {
|
||||||
|
if (!Number.isFinite(secs) || secs < 0) return "—";
|
||||||
|
if (secs < 60) return `${Math.round(secs)}s`;
|
||||||
|
const m = Math.floor(secs / 60);
|
||||||
|
const s = Math.round(secs % 60);
|
||||||
|
if (m < 60) return s > 0 ? `${m}m ${s}s` : `${m}m`;
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
const rm = m % 60;
|
||||||
|
if (h < 48) return rm > 0 ? `${h}h ${rm}m` : `${h}h`;
|
||||||
|
const d = Math.floor(h / 24);
|
||||||
|
const rh = h % 24;
|
||||||
|
return rh > 0 ? `${d}d ${rh}h` : `${d}d`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
:root {
|
||||||
|
--ink: #15201c;
|
||||||
|
--ink-soft: #2a3a34;
|
||||||
|
--paper: #e6ebe4;
|
||||||
|
--paper-2: #d5ddd2;
|
||||||
|
--panel: rgba(255, 255, 255, 0.55);
|
||||||
|
--stroke: rgba(21, 32, 28, 0.12);
|
||||||
|
--accent: #0f766e;
|
||||||
|
--accent-2: #c2410c;
|
||||||
|
--good: #3f7d4e;
|
||||||
|
--muted: #5c6b64;
|
||||||
|
--chart-cpu: #0f766e;
|
||||||
|
--chart-mem: #c2410c;
|
||||||
|
--chart-rx: #1d4ed8;
|
||||||
|
--chart-tx: #0f766e;
|
||||||
|
--shadow: 0 1px 0 rgba(255, 255, 255, 0.4) inset;
|
||||||
|
font-family: "IBM Plex Sans", system-ui, sans-serif;
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--paper);
|
||||||
|
line-height: 1.45;
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
background:
|
||||||
|
radial-gradient(1200px 600px at 10% -10%, rgba(15, 118, 110, 0.14), transparent 55%),
|
||||||
|
radial-gradient(900px 500px at 100% 0%, rgba(194, 65, 12, 0.1), transparent 50%),
|
||||||
|
linear-gradient(180deg, #eef2ec 0%, #dfe6db 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: "IBM Plex Mono", ui-monospace, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
font-family: "Bricolage Grotesque", sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import App from "./App";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export type AppRoute =
|
||||||
|
| { kind: "dashboard" }
|
||||||
|
| { kind: "sites" }
|
||||||
|
| { kind: "site"; id: number }
|
||||||
|
| { kind: "settings" };
|
||||||
|
|
||||||
|
function pathToRoute(pathname: string): AppRoute {
|
||||||
|
const path = pathname.replace(/\/+$/, "") || "/";
|
||||||
|
const siteMatch = path.match(/^\/sites\/(\d+)$/);
|
||||||
|
if (siteMatch) {
|
||||||
|
return { kind: "site", id: Number(siteMatch[1]) };
|
||||||
|
}
|
||||||
|
if (path === "/sites") return { kind: "sites" };
|
||||||
|
if (path === "/settings") return { kind: "settings" };
|
||||||
|
return { kind: "dashboard" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function routeToPath(route: AppRoute): string {
|
||||||
|
if (route.kind === "site") return `/sites/${route.id}`;
|
||||||
|
if (route.kind === "sites") return "/sites";
|
||||||
|
if (route.kind === "settings") return "/settings";
|
||||||
|
return "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRoute(): [AppRoute, (route: AppRoute) => void] {
|
||||||
|
const [route, setRouteState] = useState<AppRoute>(() =>
|
||||||
|
pathToRoute(window.location.pathname),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onPopState() {
|
||||||
|
setRouteState(pathToRoute(window.location.pathname));
|
||||||
|
}
|
||||||
|
window.addEventListener("popstate", onPopState);
|
||||||
|
return () => window.removeEventListener("popstate", onPopState);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function navigate(next: AppRoute) {
|
||||||
|
const path = routeToPath(next);
|
||||||
|
if (window.location.pathname !== path) {
|
||||||
|
window.history.pushState({}, "", path);
|
||||||
|
}
|
||||||
|
setRouteState(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [route, navigate];
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023", "DOM"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"allowArbitraryExtensions": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"module": "nodenext",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
"/api": "http://127.0.0.1:8787",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: "dist",
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
Executable
+11
@@ -0,0 +1,11 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
DOCKER=(docker)
|
||||||
|
if ! docker info >/dev/null 2>&1; then
|
||||||
|
DOCKER=(sudo docker)
|
||||||
|
fi
|
||||||
|
|
||||||
|
"${DOCKER[@]}" compose build "$@"
|
||||||
Executable
+12
@@ -0,0 +1,12 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
DOCKER=(docker)
|
||||||
|
if ! docker info >/dev/null 2>&1; then
|
||||||
|
DOCKER=(sudo docker)
|
||||||
|
fi
|
||||||
|
|
||||||
|
"${DOCKER[@]}" compose down
|
||||||
|
echo "meterbox stopped"
|
||||||
Executable
+4
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
# Supported runtime is Docker only. See scripts/up.sh.
|
||||||
|
exec "$(cd "$(dirname "$0")" && pwd)/up.sh"
|
||||||
Executable
+27
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
DOCKER=(docker)
|
||||||
|
if ! docker info >/dev/null 2>&1; then
|
||||||
|
DOCKER=(sudo docker)
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> iptables snapshot (pre)"
|
||||||
|
sudo iptables -L -n -v | head -40 || true
|
||||||
|
|
||||||
|
echo "==> building & starting meterbox (Docker, localhost-only)"
|
||||||
|
"${DOCKER[@]}" compose up -d --build
|
||||||
|
|
||||||
|
echo "==> published / listening check"
|
||||||
|
ss -tlnp | grep 8787 || true
|
||||||
|
"${DOCKER[@]}" compose ps
|
||||||
|
|
||||||
|
echo "==> health"
|
||||||
|
sleep 2
|
||||||
|
curl -sf http://127.0.0.1:8787/api/health
|
||||||
|
echo
|
||||||
|
curl -sf http://127.0.0.1:8787/api/health | grep -q '"ok":true'
|
||||||
|
|
||||||
|
echo "Meterbox up at http://127.0.0.1:8787 (127.0.0.1 only)"
|
||||||
Reference in new issue
Block a user