Initial Meterbox commit with boot disk usage metric.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
JamesandCursor committed 2026-09-19 02:45:52 +00:00
commit 4e44204d9b
55 files changed
+12104

No files matched your search

+13
View File
@@ -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.
+44
View File
@@ -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
+10
View File
@@ -0,0 +1,10 @@
.git
.gitignore
data
meterbox
backend/target
frontend/node_modules
frontend/dist
**/*.md
.cursor
scripts
+8
View File
@@ -0,0 +1,8 @@
target/
data/*.db
data/*.db-*
meterbox
frontend/dist
frontend/node_modules
**/*.swp
.DS_Store
+33
View File
@@ -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"]
+73
View File
@@ -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.
+2409
View File
File diff suppressed because it is too large. Load diff
+35
View File
@@ -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
+789
View File
@@ -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 }))
}
+188
View File
@@ -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;
+252
View File
@@ -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))
}
+64
View File
@@ -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"),
}
}
+43
View File
@@ -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"),
}
}
});
}
+355
View File
@@ -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,
},
}
}
+46
View File
@@ -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(())
}
}
+356
View File
@@ -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)?,
})
}
+596
View File
@@ -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"
);
}
}
}
});
}
+86
View File
@@ -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")
}
+203
View File
@@ -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)
}
+230
View File
@@ -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,
}
}
+153
View File
@@ -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;
+30
View File
@@ -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
+24
View File
@@ -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?
+8
View File
@@ -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 }]
}
}
+18
View File
@@ -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>
+1756
View File
File diff suppressed because it is too large. Load diff
+29
View File
@@ -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"
}
}
+1
View File
@@ -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

+24
View File
@@ -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

+1284
View File
File diff suppressed because it is too large. Load diff
+512
View File
@@ -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>
);
}
+267
View File
@@ -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>
);
}
+128
View File
@@ -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>
);
}
+72
View File
@@ -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>
);
}
+464
View File
@@ -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>
);
}
+61
View File
@@ -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>
);
}
+169
View File
@@ -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>
);
}
+358
View File
@@ -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>
);
}
+25
View File
@@ -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>
);
}
+347
View File
@@ -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

+1
View File
@@ -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

+228
View File
@@ -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>
);
}
+69
View File
@@ -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`;
}
+57
View File
@@ -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;
}
+10
View File
@@ -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>,
);
+49
View File
@@ -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];
}
+26
View File
@@ -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"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -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"]
}
+17
View File
@@ -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,
},
});
+11
View File
@@ -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 "$@"
+12
View File
@@ -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
View File
@@ -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
View File
@@ -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)"