diff --git a/Cargo.lock b/Cargo.lock index f6bd814c..e2147858 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1699,6 +1699,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "sqlx", "subtle", "tempfile", "thiserror 1.0.69", diff --git a/README.md b/README.md index 27f7a499..c52c7fe7 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,12 @@ or CloudPanel. | Dashboard | Cluster stats | | --- | --- | | [![Dashboard](docs/screenshots/dashboard.png)](docs/screenshots/dashboard.png) | [![Stats](docs/screenshots/stats.png)](docs/screenshots/stats.png) | -| KPI tiles, load and bandwidth sparklines, audit feed. | Cluster and per-node metrics, sampled every 5 minutes. | +| KPI tiles, load, bandwidth and live network throughput, recent sites and activity. | Cluster and per-node metrics, sampled every 5 minutes, plus a live node view. | + +| Hostings | Site detail | +| --- | --- | +| [![Hostings](docs/screenshots/hostings.png)](docs/screenshots/hostings.png) | [![Site detail](docs/screenshots/hosting.png)](docs/screenshots/hosting.png) | +| Every site in the cluster with state, PHP and node; bulk suspend, resume, back up. | One site at a glance: health score, HTTPS, backups, uptime, security and database. | --- diff --git a/bin/hyperion-web/Cargo.toml b/bin/hyperion-web/Cargo.toml index a7c1693c..e8890586 100644 --- a/bin/hyperion-web/Cargo.toml +++ b/bin/hyperion-web/Cargo.toml @@ -73,3 +73,4 @@ hyperion-state = { path = "../../crates/hyperion-state" } hyperion-rpc-server = { path = "../../crates/hyperion-rpc-server" } rand.workspace = true http-body-util.workspace = true +sqlx.workspace = true diff --git a/bin/hyperion-web/tests/devserver.rs b/bin/hyperion-web/tests/devserver.rs index 13f297da..2914e153 100644 --- a/bin/hyperion-web/tests/devserver.rs +++ b/bin/hyperion-web/tests/devserver.rs @@ -65,7 +65,10 @@ impl hyperion_core::AdapterPort for StubAdapters { hosting_id: &HostingId, _: &str, ) -> Result { - let h: String = hosting_id.as_str().chars().take(6).collect(); + // The tail, not the head: ids are time-ordered, so sites created in + // the same instant (the demo seed) share a prefix. + let id = hosting_id.as_str(); + let h = &id[id.len().saturating_sub(6)..]; Ok(hyperion_rpc::wire::DbCredentials { engine, db_name: format!("lm_{h}_db"), @@ -247,6 +250,13 @@ impl hyperion_core::AdapterPort for StubAdapters { /// Start a stub hyperion-agent on a temp Unix socket. Returns the socket path /// and the temp dir guard (drop it last). async fn start_agent() -> (PathBuf, tempfile::TempDir) { + let (path, dir, _svc) = start_agent_with_service().await; + (path, dir) +} + +type StubService = HostingService; + +async fn start_agent_with_service() -> (PathBuf, tempfile::TempDir, Arc) { let dir = tempfile::tempdir().expect("dir"); let pool = open_memory().await.expect("memory db"); let secrets = Arc::new(SecretsStore::new(dir.path().join("secrets"))); @@ -277,14 +287,14 @@ async fn start_agent() -> (PathBuf, tempfile::TempDir) { )), backup_progress: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), }); - let agent: Arc = Arc::new(AgentImpl::new(svc)); + let agent: Arc = Arc::new(AgentImpl::new(svc.clone())); let path = dir.path().join("agent.sock"); let srv = hyperion_rpc_server::Server::bind(&path, agent) .await .expect("bind"); tokio::spawn(srv.run()); tokio::time::sleep(std::time::Duration::from_millis(20)).await; - (path, dir) + (path, dir, svc) } fn build_app(agent_socket: PathBuf, admin: AdminUser) -> axum::Router { @@ -344,17 +354,169 @@ fn build_app_with_signer( (router, signer) } +/// `DEVSERVER_DEMO=1`: fill the empty database with a handful of sites and a +/// few hours of plausible metrics, so the Dashboard and Stats pages have +/// something to draw (README screenshots). Pure inserts; nothing is sampled. +async fn seed_demo(svc: &StubService) { + use hyperion_rpc::wire::HostingCreateReq; + use hyperion_validate::Domain; + + let sites = [ + ("studio-lumen.cz", Some(DbProvision::MariaDB)), + ("pekarna-u-mostu.cz", Some(DbProvision::MariaDB)), + ("kavarna-sever.cz", Some(DbProvision::MariaDB)), + ("atelier-hora.com", Some(DbProvision::MariaDB)), + ("fit-centrum-brno.cz", Some(DbProvision::MariaDB)), + ("docs.example.org", None), + ("shop.zahrada-plus.cz", Some(DbProvision::MariaDB)), + ]; + let mut ids = Vec::new(); + for (domain, db) in sites { + let created = svc + .create(HostingCreateReq { + domain: Domain::parse(domain).expect("domain"), + aliases: vec![], + php_version: Some(PhpVersion::V8_3), + database: db, + system_user: None, + kind: "php".into(), + proxy_upstream_url: None, + }) + .await + .expect("demo create"); + ids.push(created.id.as_str().to_string()); + } + let pool = &svc.pool; + // One suspended site, so the state dots are not all green. + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + sqlx::query("UPDATE hostings SET state = 'suspended' WHERE id = ?") + .bind(&ids[5]) + .execute(pool) + .await + .expect("suspend"); + sqlx::query( + "INSERT INTO hosting_suspension (hosting_id, suspended_at, suspended_by) VALUES (?, ?, 'manual')", + ) + .bind(&ids[5]) + .bind(now - 3 * 86_400) + .execute(pool) + .await + .expect("suspension"); + + // Smooth-ish noise: a couple of sines plus a deterministic jitter. + let wave = |i: f64, a: f64, b: f64| { + (i / a).sin() * 0.5 + (i / b).sin() * 0.3 + ((i * 12.9898).sin() * 43_758.545).fract() * 0.4 + }; + + // Per-hosting hourly usage for the last 24 h. + let weights = [9.0, 4.0, 2.5, 6.0, 3.0, 0.4, 7.5]; + let disk_gib = [3.4, 1.2, 0.8, 5.1, 1.9, 0.3, 4.6]; + for (n, id) in ids.iter().enumerate() { + for h in 0..24 { + let t = now - h * 3600; + let period = chrono::DateTime::from_timestamp(t, 0) + .unwrap() + .format("%Y-%m-%d-%H") + .to_string(); + let day = 0.6 + 0.4 * ((h as f64 - 6.0) / 24.0 * std::f64::consts::TAU).cos(); + let w = weights[n] * day * (1.0 + 0.2 * wave(h as f64 + n as f64 * 7.0, 2.0, 5.0)); + sqlx::query( + "INSERT INTO hosting_usage (hosting_id, period, disk_used_bytes, inodes_used, \ + bw_out_bytes, php_requests, mem_rss_bytes, cpu_pct_x100) VALUES (?,?,?,?,?,?,?,?)", + ) + .bind(id) + .bind(&period) + .bind((disk_gib[n] * 1_073_741_824.0) as i64) + .bind((disk_gib[n] * 9_000.0) as i64) + .bind((w * 38_000_000.0) as i64) + .bind((w * 410.0) as i64) + .bind(((80.0 + w * 30.0) * 1_048_576.0) as i64) + .bind((w * 60.0) as i64) + .execute(pool) + .await + .expect("usage"); + } + } + + // Node samples every 5 minutes for the last 8 hours. + let mem_total: i64 = 8 * 1024 * 1024; + let disk_total: i64 = 160 * 1_073_741_824; + let hostings_disk: i64 = (disk_gib.iter().sum::() * 1_073_741_824.0) as i64; + for k in 0..96i64 { + let i = (96 - k) as f64; + let t = now - k * 300; + let load = (0.45 + 0.35 * wave(i, 3.0, 11.0)).max(0.05); + let mem = 0.52 + 0.06 * wave(i, 5.0, 17.0); + let cpu = (load * 22.0).min(100.0); + let tx = (180_000.0 + 120_000.0 * wave(i, 2.5, 9.0)).max(20_000.0); + sqlx::query( + "INSERT INTO node_metrics (sampled_at, hostings_count, hostings_active, \ + hostings_suspended, hostings_failed, total_disk_bytes, total_bw_out_24h, \ + total_requests_24h, loadavg_1m_x100, mem_total_kib, mem_used_kib, uptime_secs, \ + cpu_pct_x100, swap_total_kib, swap_used_kib, psi_cpu_x100, psi_mem_x100, \ + psi_io_x100, net_rx_bps, net_tx_bps, hostings_disk_bytes, node_disk_total_bytes) \ + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ) + .bind(t) + .bind(7) + .bind(6) + .bind(1) + .bind(0) + .bind(hostings_disk + 21 * 1_073_741_824) + .bind((tx * 86_400.0 * 0.35) as i64) + .bind(86_000 + (wave(i, 4.0, 13.0) * 9_000.0) as i64) + .bind((load * 100.0) as i64) + .bind(mem_total) + .bind((mem * mem_total as f64) as i64) + .bind(41 * 86_400 + 3 * 3600 - k * 300) + .bind((cpu * 100.0) as i64) + .bind(2 * 1024 * 1024) + .bind(96 * 1024) + .bind((load * 30.0) as i64) + .bind(0) + .bind((load * 12.0) as i64) + .bind((tx * 0.3) as i64) + .bind(tx as i64) + .bind(hostings_disk) + .bind(disk_total) + .execute(pool) + .await + .expect("node_metrics"); + } + + // The 15 s network sampler, last hour. + for k in 0..240i64 { + let i = (240 - k) as f64; + let tx = (210_000.0 + 140_000.0 * wave(i, 4.0, 15.0)).max(15_000.0); + sqlx::query("INSERT INTO net_samples (at, rx_bps, tx_bps) VALUES (?,?,?)") + .bind(now - k * 15) + .bind((tx * 0.28) as i64) + .bind(tx as i64) + .execute(pool) + .await + .expect("net_samples"); + } +} + #[tokio::test] #[ignore] async fn devserver() { let admin = admin_user::create("kevin", "secret-pw-1").expect("create"); - let (sock, _dir) = start_agent().await; + let (sock, _dir, svc) = start_agent_with_service().await; + if std::env::var_os("DEVSERVER_DEMO").is_some() { + seed_demo(&svc).await; + } let (router, _signer) = build_app_with_signer(sock, admin, Arc::new(SessionSigner::new_random())); - let listener = tokio::net::TcpListener::bind("127.0.0.1:8190") + let port = std::env::var("DEVSERVER_PORT").unwrap_or_else(|_| "8190".into()); + let addr = format!("127.0.0.1:{port}"); + let listener = tokio::net::TcpListener::bind(&addr) .await - .expect("bind 127.0.0.1:8190"); - eprintln!("devserver: http://127.0.0.1:8190 (kevin / secret-pw-1)"); + .unwrap_or_else(|e| panic!("bind {addr}: {e}")); + eprintln!("devserver: http://{addr} (kevin / secret-pw-1)"); axum::serve( listener, router.into_make_service_with_connect_info::(), diff --git a/docs/screenshots/README.md b/docs/screenshots/README.md index 73ec88c1..24156b61 100644 --- a/docs/screenshots/README.md +++ b/docs/screenshots/README.md @@ -1,16 +1,30 @@ ## Screenshots Drop the screenshot PNGs in this folder (exact filenames matter -— the top-level README's `` references them by +— the top-level README references them by name). Currently embedded in the README: -- `dashboard.png` — the "Welcome back, admin." dashboard page. -- `stats.png` — the /stats cluster overview. +- `dashboard.png` — the dashboard page. +- `stats.png` — the /stats cluster overview. +- `hostings.png` — the /hostings list. +- `hosting.png` — one site's detail page (Overview tab). -Adding more screenshots? Drop the file here, add a corresponding -`` cell to the screenshot `` in the top-level -README.md, and update the table's `width` percentages so they -still sum to ~100%. +They are taken from the local devpanel with demo data, not from a real +server, at 1600x1000 CSS px, 2x, dark theme: + +``` +DEVSERVER_DEMO=1 DEVSERVER_PORT=8191 \ + cargo test -p hyperion-web --test devserver -- --ignored --nocapture +``` + +`DEVSERVER_DEMO` seeds seven sites, one suspended, and a few hours of node and per-site metrics. The dev +machine is not a server, so before capturing, replace its hostname and public +IP with `node-1` / `203.0.113.10` and drop the "services down" and "DNS +mismatch" banners; both only say that the laptop has no nginx and the demo +domains do not resolve. + +Adding more screenshots? Drop the file here and add a cell to one of the +Markdown tables under "Screenshots" in the top-level README.md. After replacing / refreshing the files: @@ -21,5 +35,4 @@ git push ``` GitHub serves the images straight from the README — no CDN, no -build step. Re-sizes are handled by the `` -attribute in the README's screenshot table. +build step. GitHub scales them to the table cell. diff --git a/docs/screenshots/dashboard.png b/docs/screenshots/dashboard.png index c7c3d88b..7d5ecd52 100644 Binary files a/docs/screenshots/dashboard.png and b/docs/screenshots/dashboard.png differ diff --git a/docs/screenshots/hosting.png b/docs/screenshots/hosting.png new file mode 100644 index 00000000..baf5a0a9 Binary files /dev/null and b/docs/screenshots/hosting.png differ diff --git a/docs/screenshots/hostings.png b/docs/screenshots/hostings.png new file mode 100644 index 00000000..fe403a61 Binary files /dev/null and b/docs/screenshots/hostings.png differ diff --git a/docs/screenshots/stats.png b/docs/screenshots/stats.png index 2624de3b..07c93fee 100644 Binary files a/docs/screenshots/stats.png and b/docs/screenshots/stats.png differ