Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

---

Expand Down
1 change: 1 addition & 0 deletions bin/hyperion-web/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
176 changes: 169 additions & 7 deletions bin/hyperion-web/tests/devserver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,10 @@ impl hyperion_core::AdapterPort for StubAdapters {
hosting_id: &HostingId,
_: &str,
) -> Result<hyperion_rpc::wire::DbCredentials, AdapterError> {
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"),
Expand Down Expand Up @@ -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<StubAdapters>;

async fn start_agent_with_service() -> (PathBuf, tempfile::TempDir, Arc<StubService>) {
let dir = tempfile::tempdir().expect("dir");
let pool = open_memory().await.expect("memory db");
let secrets = Arc::new(SecretsStore::new(dir.path().join("secrets")));
Expand Down Expand Up @@ -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<dyn AgentApi> = Arc::new(AgentImpl::new(svc));
let agent: Arc<dyn AgentApi> = 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 {
Expand Down Expand Up @@ -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::<f64>() * 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::<std::net::SocketAddr>(),
Expand Down
31 changes: 22 additions & 9 deletions docs/screenshots/README.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,30 @@
## Screenshots

Drop the screenshot PNGs in this folder (exact filenames matter
— the top-level README's `<img src="...">` 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
`<td>` cell to the screenshot `<table>` 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:

Expand All @@ -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 `<img width="100%">`
attribute in the README's screenshot table.
build step. GitHub scales them to the table cell.
Binary file modified docs/screenshots/dashboard.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/hosting.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/hostings.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/stats.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.