Initial Meterbox commit with boot disk usage metric.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
commit
4e44204d9b
55 files changed
+12104
No files matched your search
@@ -0,0 +1,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)?,
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user