From ca2e41c0d0675340482e3d8678573e6447b5c3da Mon Sep 17 00:00:00 2001 From: falkoro <39274208+falkoro@users.noreply.github.com> Date: Thu, 28 May 2026 21:09:05 +0200 Subject: [PATCH] Add DASHBOARD_SKIP_LOGIN + live machine metrics widget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit skip-login: - New DASHBOARD_SKIP_LOGIN flag. When set, authenticated() returns true and ShellDeck trusts the network gate (network_allowed) instead of its own login password — for deployments fronted by Cloudflare Access / Zero Trust or a trusted proxy. The shell unlock (second) password is unaffected, so shells stay gated. Default off; documented in .env.example and README. machine metrics: - New src/metrics.rs reads CPU% (/proc/stat delta), core count, load average, RAM/swap (/proc/meminfo), uptime, and hardware temperatures (/sys/class/hwmon temp*_input + labels). Exposed at GET /api/metrics, login-gated but not behind the shell unlock. - Sidebar "Machine" widget (frontend/metrics.ts) polls every 5s and renders CPU/RAM meters, temperature chips, and load — styled like a system monitor. Frontend-only helpers stay global scripts; metrics.js loads before events.js. Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 5 ++ README.md | 3 +- frontend/events.ts | 4 + frontend/metrics.ts | 81 +++++++++++++++++++++ public/app.css | 15 ++++ public/events.js | 3 + public/metrics.js | 57 +++++++++++++++ src/auth.rs | 6 ++ src/config.rs | 5 ++ src/main.rs | 1 + src/metrics.rs | 173 ++++++++++++++++++++++++++++++++++++++++++++ src/pages.rs | 4 +- src/routes.rs | 16 +++- 13 files changed, 369 insertions(+), 4 deletions(-) create mode 100644 frontend/metrics.ts create mode 100644 public/metrics.js create mode 100644 src/metrics.rs diff --git a/.env.example b/.env.example index 827c816..6b1db44 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,11 @@ DASHBOARD_BYPASS_LOGIN_IPS= # IPs that skip the login form (behind a tru DASHBOARD_ALLOW_CLOUDFLARE_LOGIN=0 # 1 = accept any request carrying Cloudflare headers DASHBOARD_TRUST_CF_ACCESS_EMAIL=0 # 1 = trust Cf-Access-Authenticated-User-Email DASHBOARD_ALLOWED_EMAILS= # comma-separated emails allowed via Cloudflare Access +DASHBOARD_SKIP_LOGIN=0 # 1 = skip ShellDeck's own login password and trust the + # network gate above. Only enable when an external layer + # (e.g. Cloudflare Access / Zero Trust) already + # authenticates who can reach the dashboard. The shell + # unlock (second) password is unaffected. # --- Optional: AI "Current Work" summary --- # Either run a shell command (gets the context on $SHELLDECK_CONTEXT, prints the summary)… diff --git a/README.md b/README.md index e2f8e00..7a61dd2 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ It was built to babysit a fleet of long-running agent sessions from a phone or a - **Send / Paste / keys** — send input to a pane (with or without Enter), plus Enter / Ctrl-C / Clear, image paste, and command history. - **Mobile-friendly** — one shell at a time with a sticky tab switcher; Enter sends. - **Quick links & tickers** — configurable sidebar links (`DASHBOARD_LINKS`) to related services, plus an optional stock/crypto ticker bar (`DASHBOARD_TICKERS`). +- **Machine metrics** — a sidebar widget shows live CPU, RAM, load average, and hardware temperatures (read from `/proc` and `/sys/class/hwmon`) for the host ShellDeck runs on. - **Locked down** — primary login + a second "unlock" password to gate shell control, optional IP allowlists, and first-class support for sitting behind **Cloudflare Access** (trusts the verified email). ## Requirements @@ -56,7 +57,7 @@ ExecStart=%h/shelldeck/target/release/shelldeck Restart=always ``` -Put it behind a reverse proxy / Cloudflare Tunnel for remote access, and ideally a Cloudflare Access policy restricting to your email. +Put it behind a reverse proxy / Cloudflare Tunnel for remote access, and ideally a Cloudflare Access policy restricting to your email. When such an external layer already authenticates who can reach the dashboard, set `DASHBOARD_SKIP_LOGIN=1` to skip ShellDeck's own login password and let Cloudflare Access (or your proxy) be the front door — the shell unlock (second) password still gates shell control. ## Security notes diff --git a/frontend/events.ts b/frontend/events.ts index b3e7a82..a36b19f 100644 --- a/frontend/events.ts +++ b/frontend/events.ts @@ -357,3 +357,7 @@ setInterval(() => (window as any).updateLastActivityTimes?.(), 30000); // Live tickers (if any configured) queueMicrotask(() => loadTickers().catch(() => {})); setInterval(() => loadTickers().catch(() => {}), 60000); + +// Live host machine stats (CPU / RAM / temps) +queueMicrotask(() => loadMetrics().catch(() => {})); +setInterval(() => loadMetrics().catch(() => {}), 5000); diff --git a/frontend/metrics.ts b/frontend/metrics.ts new file mode 100644 index 0000000..a143ce6 --- /dev/null +++ b/frontend/metrics.ts @@ -0,0 +1,81 @@ +// Live host machine stats (CPU / RAM / temps), polled from /api/metrics. +// Mirrors the CachyOS system-monitor widget: at-a-glance CPU%, RAM, and temperatures. + +interface MetricTemp { + label: string; + celsius: number; +} + +interface MachineMetrics { + hostname: string; + cpu_pct: number; + cpu_cores: number; + load1: number; + load5: number; + load15: number; + mem_total_kb: number; + mem_used_kb: number; + mem_pct: number; + swap_total_kb: number; + swap_used_kb: number; + uptime_secs: number; + temps: MetricTemp[]; +} + +function meterLevel(pct: number): string { + return pct >= 90 ? 'crit' : pct >= 70 ? 'warn' : 'ok'; +} + +function tempLevel(c: number): string { + return c >= 85 ? 'crit' : c >= 70 ? 'warn' : 'ok'; +} + +function fmtGiB(kb: number): string { + return (kb / 1048576).toFixed(1); +} + +function fmtUptime(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`; +} + +function setMeter(name: string, pct: number, text: string): void { + const bar = document.querySelector(`[data-bar="${name}"]`); + const val = document.querySelector(`[data-m="${name}"]`); + if (bar) { + bar.style.width = `${Math.max(0, Math.min(100, pct)).toFixed(0)}%`; + bar.className = meterLevel(pct); + } + if (val) val.textContent = text; +} + +function renderMetrics(m: MachineMetrics): void { + const host = document.getElementById('metricsHost'); + if (host) host.textContent = `${m.hostname} · ${m.cpu_cores} cores · up ${fmtUptime(m.uptime_secs)}`; + + setMeter('cpu', m.cpu_pct, `${m.cpu_pct.toFixed(0)}%`); + setMeter('mem', m.mem_pct, `${fmtGiB(m.mem_used_kb)} / ${fmtGiB(m.mem_total_kb)} GiB`); + + const load = document.getElementById('metricLoad'); + if (load) load.textContent = `load ${m.load1.toFixed(2)} · ${m.load5.toFixed(2)} · ${m.load15.toFixed(2)}`; + + const temps = document.getElementById('metricTemps'); + if (temps) { + temps.innerHTML = (m.temps || []) + .map((t) => `${escapeHtml(t.label)} ${t.celsius.toFixed(0)}°`) + .join(''); + } +} + +async function loadMetrics(): Promise { + if (!document.getElementById('metricsPanel')) return; + const response = await fetch('/api/metrics', { cache: 'no-store', credentials: 'same-origin' }); + if (!response.ok) return; + renderMetrics(await response.json() as MachineMetrics); +} + +(window as any).loadMetrics = loadMetrics; diff --git a/public/app.css b/public/app.css index aeee124..d21c488 100644 --- a/public/app.css +++ b/public/app.css @@ -260,3 +260,18 @@ button:disabled{opacity:.48;cursor:not-allowed} /* Remove resize handle on mobile (touch has its own resize) */ @media (max-width:760px){.card-resize-handle{display:none}} .links-menu .ic{color:var(--cyan);flex:0 0 auto} +/* Machine metrics widget (CPU / RAM / temps) — CachyOS-monitor style */ +.metrics-panel .panel-header h2{margin-bottom:2px} +.metrics{display:flex;flex-direction:column;gap:10px} +.metric .metric-top{display:flex;justify-content:space-between;font-size:12px;color:var(--muted);margin-bottom:4px} +.metric .metric-top span:last-child{color:var(--text);font-variant-numeric:tabular-nums} +.meter{height:7px;border-radius:5px;background:rgba(139,246,255,.1);overflow:hidden} +.meter i{display:block;height:100%;width:0;border-radius:5px;background:var(--green);transition:width .5s ease,background .3s} +.meter i.warn{background:var(--amber)} +.meter i.crit{background:var(--red)} +.metric-temps{display:flex;flex-wrap:wrap;gap:4px;margin-top:2px} +.temp-chip{font-size:11px;padding:2px 6px;border-radius:6px;background:rgba(139,246,255,.08);border:1px solid rgba(139,246,255,.16);color:var(--muted);white-space:nowrap} +.temp-chip b{color:var(--text);font-variant-numeric:tabular-nums} +.temp-chip.warn{border-color:rgba(255,200,87,.4);color:var(--amber)} +.temp-chip.crit{border-color:rgba(255,106,122,.5);color:var(--red)} +.metric-load{font-size:11px;font-variant-numeric:tabular-nums} diff --git a/public/events.js b/public/events.js index ef20d4c..67ef896 100644 --- a/public/events.js +++ b/public/events.js @@ -376,3 +376,6 @@ setInterval(() => window.updateLastActivityTimes?.(), 30000); // Live tickers (if any configured) queueMicrotask(() => loadTickers().catch(() => { })); setInterval(() => loadTickers().catch(() => { }), 60000); +// Live host machine stats (CPU / RAM / temps) +queueMicrotask(() => loadMetrics().catch(() => { })); +setInterval(() => loadMetrics().catch(() => { }), 5000); diff --git a/public/metrics.js b/public/metrics.js new file mode 100644 index 0000000..1a1249a --- /dev/null +++ b/public/metrics.js @@ -0,0 +1,57 @@ +"use strict"; +// Live host machine stats (CPU / RAM / temps), polled from /api/metrics. +// Mirrors the CachyOS system-monitor widget: at-a-glance CPU%, RAM, and temperatures. +function meterLevel(pct) { + return pct >= 90 ? 'crit' : pct >= 70 ? 'warn' : 'ok'; +} +function tempLevel(c) { + return c >= 85 ? 'crit' : c >= 70 ? 'warn' : 'ok'; +} +function fmtGiB(kb) { + return (kb / 1048576).toFixed(1); +} +function fmtUptime(secs) { + 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`; +} +function setMeter(name, pct, text) { + const bar = document.querySelector(`[data-bar="${name}"]`); + const val = document.querySelector(`[data-m="${name}"]`); + if (bar) { + bar.style.width = `${Math.max(0, Math.min(100, pct)).toFixed(0)}%`; + bar.className = meterLevel(pct); + } + if (val) + val.textContent = text; +} +function renderMetrics(m) { + const host = document.getElementById('metricsHost'); + if (host) + host.textContent = `${m.hostname} · ${m.cpu_cores} cores · up ${fmtUptime(m.uptime_secs)}`; + setMeter('cpu', m.cpu_pct, `${m.cpu_pct.toFixed(0)}%`); + setMeter('mem', m.mem_pct, `${fmtGiB(m.mem_used_kb)} / ${fmtGiB(m.mem_total_kb)} GiB`); + const load = document.getElementById('metricLoad'); + if (load) + load.textContent = `load ${m.load1.toFixed(2)} · ${m.load5.toFixed(2)} · ${m.load15.toFixed(2)}`; + const temps = document.getElementById('metricTemps'); + if (temps) { + temps.innerHTML = (m.temps || []) + .map((t) => `${escapeHtml(t.label)} ${t.celsius.toFixed(0)}°`) + .join(''); + } +} +async function loadMetrics() { + if (!document.getElementById('metricsPanel')) + return; + const response = await fetch('/api/metrics', { cache: 'no-store', credentials: 'same-origin' }); + if (!response.ok) + return; + renderMetrics(await response.json()); +} +window.loadMetrics = loadMetrics; diff --git a/src/auth.rs b/src/auth.rs index b6af65a..0edede7 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -70,6 +70,12 @@ pub fn network_allowed(config: &Config, headers: &HeaderMap, remote: Option<&str } pub fn authenticated(config: &Config, headers: &HeaderMap, remote: Option<&str>) -> bool { + // When fronted by an external auth layer (e.g. Cloudflare Access / Zero Trust), + // skip ShellDeck's own login password — reaching this point already means the + // request passed the network gate in `network_allowed`. Shells still need unlock. + if config.skip_login { + return true; + } let email = cf_email(headers); if config.trust_cf_access_email && !email.is_empty() && config.allowed_emails.contains(&email) { return true; diff --git a/src/config.rs b/src/config.rs index f80b299..8417043 100644 --- a/src/config.rs +++ b/src/config.rs @@ -22,6 +22,7 @@ pub struct Config { pub allowed_emails: Vec, pub trust_cf_access_email: bool, pub allow_cloudflare_login: bool, + pub skip_login: bool, pub bypass_login_ips: Vec, pub unlock_password: String, pub root_dir: PathBuf, @@ -94,6 +95,10 @@ impl Config { == Some("1"), allow_cloudflare_login: env::var("DASHBOARD_ALLOW_CLOUDFLARE_LOGIN").ok().as_deref() == Some("1"), + // Skip ShellDeck's own login password entirely and trust the network gate + // (allowed IP / Cloudflare Access). Only enable when an external layer like + // Cloudflare Access already authenticates who can reach the dashboard. + skip_login: env::var("DASHBOARD_SKIP_LOGIN").ok().as_deref() == Some("1"), bypass_login_ips: split_env("DASHBOARD_BYPASS_LOGIN_IPS"), unlock_password: env::var("DASHBOARD_UNLOCK_PASSWORD") .unwrap_or_else(|_| "change-me".to_string()), diff --git a/src/main.rs b/src/main.rs index 86d8d59..45366e9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod auth; mod config; +mod metrics; mod pages; mod routes; mod stream; diff --git a/src/metrics.rs b/src/metrics.rs new file mode 100644 index 0000000..4ed342f --- /dev/null +++ b/src/metrics.rs @@ -0,0 +1,173 @@ +use serde::Serialize; +use std::time::Duration; + +#[derive(Serialize)] +pub struct Temp { + pub label: String, + pub celsius: f64, +} + +#[derive(Serialize)] +pub struct Metrics { + pub hostname: String, + pub cpu_pct: f64, + pub cpu_cores: usize, + pub load1: f64, + pub load5: f64, + pub load15: f64, + pub mem_total_kb: u64, + pub mem_used_kb: u64, + pub mem_pct: f64, + pub swap_total_kb: u64, + pub swap_used_kb: u64, + pub uptime_secs: u64, + pub temps: Vec, +} + +// Aggregate "cpu" jiffies from /proc/stat as (idle, total). +fn cpu_sample(stat: &str) -> Option<(u64, u64)> { + let line = stat.lines().find(|l| l.starts_with("cpu "))?; + let nums: Vec = line + .split_whitespace() + .skip(1) + .filter_map(|v| v.parse().ok()) + .collect(); + if nums.len() < 4 { + return None; + } + // user nice system idle iowait irq softirq steal ... + let idle = nums[3] + nums.get(4).copied().unwrap_or(0); + let total: u64 = nums.iter().sum(); + Some((idle, total)) +} + +async fn cpu_percent() -> f64 { + let Ok(a) = tokio::fs::read_to_string("/proc/stat").await else { + return 0.0; + }; + tokio::time::sleep(Duration::from_millis(200)).await; + let Ok(b) = tokio::fs::read_to_string("/proc/stat").await else { + return 0.0; + }; + let (Some((idle_a, total_a)), Some((idle_b, total_b))) = (cpu_sample(&a), cpu_sample(&b)) + else { + return 0.0; + }; + let dt = total_b.saturating_sub(total_a); + let di = idle_b.saturating_sub(idle_a); + if dt == 0 { + return 0.0; + } + (((dt - di) as f64) / (dt as f64) * 100.0).clamp(0.0, 100.0) +} + +fn meminfo_kb(info: &str, key: &str) -> u64 { + info.lines() + .find(|l| l.starts_with(key)) + .and_then(|l| l.split_whitespace().nth(1)) + .and_then(|v| v.parse().ok()) + .unwrap_or(0) +} + +// Read each hwmon's tempN_input (millidegrees) and its tempN_label, falling back to +// the hwmon `name` so AMD k10temp / Intel coretemp / nvme sensors all show up. +fn read_temps() -> Vec { + let mut temps = Vec::new(); + let Ok(entries) = std::fs::read_dir("/sys/class/hwmon") else { + return temps; + }; + for entry in entries.flatten() { + let dir = entry.path(); + let chip = std::fs::read_to_string(dir.join("name")) + .unwrap_or_default() + .trim() + .to_string(); + for i in 1..=16 { + let input = dir.join(format!("temp{i}_input")); + let Ok(raw) = std::fs::read_to_string(&input) else { + continue; + }; + let Ok(milli) = raw.trim().parse::() else { + continue; + }; + let celsius = milli / 1000.0; + if !(1.0..=150.0).contains(&celsius) { + continue; + } + let label = std::fs::read_to_string(dir.join(format!("temp{i}_label"))) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| { + if chip.is_empty() { + format!("temp{i}") + } else { + format!("{chip} {i}") + } + }); + temps.push(Temp { label, celsius }); + } + } + temps.sort_by(|a, b| b.celsius.total_cmp(&a.celsius)); + temps.truncate(8); + temps +} + +pub async fn gather() -> Metrics { + let cpu_pct = cpu_percent().await; + let cpu_cores = std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(0); + let load = tokio::fs::read_to_string("/proc/loadavg") + .await + .unwrap_or_default(); + let mut lp = load.split_whitespace(); + let load1 = lp.next().and_then(|v| v.parse().ok()).unwrap_or(0.0); + let load5 = lp.next().and_then(|v| v.parse().ok()).unwrap_or(0.0); + let load15 = lp.next().and_then(|v| v.parse().ok()).unwrap_or(0.0); + + let info = tokio::fs::read_to_string("/proc/meminfo") + .await + .unwrap_or_default(); + let mem_total_kb = meminfo_kb(&info, "MemTotal:"); + let mem_avail_kb = meminfo_kb(&info, "MemAvailable:"); + let mem_used_kb = mem_total_kb.saturating_sub(mem_avail_kb); + let mem_pct = if mem_total_kb > 0 { + (mem_used_kb as f64) / (mem_total_kb as f64) * 100.0 + } else { + 0.0 + }; + let swap_total_kb = meminfo_kb(&info, "SwapTotal:"); + let swap_free_kb = meminfo_kb(&info, "SwapFree:"); + let swap_used_kb = swap_total_kb.saturating_sub(swap_free_kb); + + let uptime = tokio::fs::read_to_string("/proc/uptime") + .await + .unwrap_or_default(); + let uptime_secs = uptime + .split_whitespace() + .next() + .and_then(|v| v.parse::().ok()) + .unwrap_or(0.0) as u64; + + let hostname = std::fs::read_to_string("/etc/hostname") + .unwrap_or_else(|_| "localhost".to_string()) + .trim() + .to_string(); + + Metrics { + hostname, + cpu_pct, + cpu_cores, + load1, + load5, + load15, + mem_total_kb, + mem_used_kb, + mem_pct, + swap_total_kb, + swap_used_kb, + uptime_secs, + temps: read_temps(), + } +} diff --git a/src/pages.rs b/src/pages.rs index de3ffda..241d69e 100644 --- a/src/pages.rs +++ b/src/pages.rs @@ -17,7 +17,7 @@ pub fn dashboard(model: &SessionModel, config: &Config) -> String { .unwrap_or_else(|_| "{}".to_string()) .replace('<', "\\u003c"); format!( - r##"ShellDeck
SD

ShellDeck

{}dashboard signed insyncingshells locked
Sign out
{}
"##, + r##"ShellDeck
SD

ShellDeck

{}dashboard signed insyncingshells locked
Sign out
{}
"##, data, html_escape(&model.hostname), workspace(&links_panel_html(config)) @@ -41,7 +41,7 @@ fn links_panel_html(config: &Config) -> String { fn workspace(links_panel: &str) -> String { format!( - r#"

Shells

All panes side-by-side. Type into any shell.

stream idle
"#, + r#"

Shells

All panes side-by-side. Type into any shell.

stream idle
"#, links_panel ) } diff --git a/src/routes.rs b/src/routes.rs index 245669f..b7246a8 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -1,4 +1,4 @@ -use crate::{auth, pages, stream, summary, term, tmux, uploads, webutil, AppState}; +use crate::{auth, metrics, pages, stream, summary, term, tmux, uploads, webutil, AppState}; use axum::{ extract::{ConnectInfo, Path, Query, State}, http::{HeaderMap, StatusCode}, @@ -51,6 +51,7 @@ pub fn router(state: AppState) -> Router { .route("/api/unlock", post(api_unlock)) .route("/api/summary", get(api_summary)) .route("/api/shells", get(api_shells)) + .route("/api/metrics", get(api_metrics)) .route("/api/tickers", get(api_tickers)) .route("/api/shells/stream", get(stream::api_shell_stream)) .route("/api/term", get(term::term_ws)) @@ -458,6 +459,19 @@ async fn api_restart( session_result(tmux::restart_session(state.config.clone(), &body.name).await) } +// Live machine stats (CPU / RAM / temps) for the host ShellDeck runs on. Login-gated +// like the rest of the dashboard, but not behind the shell unlock — they are not sensitive. +async fn api_metrics( + State(state): State, + headers: HeaderMap, + connect: ConnectInfo, +) -> Response { + if let Some(response) = guard(&state, &headers, &connect) { + return response; + } + webutil::json_response(StatusCode::OK, &metrics::gather().await) +} + fn session_result(result: Result) -> Response { match result { Ok(message) => webutil::json_response(