repos
/ status-rust master

status-rust

mirror archived upstream

Single-binary self-hosted uptime monitoring and status pages on Rust axum: HTTP probes, Lighthouse audits, SEO crawler, and PDF reports.

axumdockerrustself-hostedsqlitestatus-pageuptime-monitoringvite

9.9 KB · 279 lines · Rust Raw History
  1use serde_json::Value;
  2use std::path::PathBuf;
  3use std::process::Stdio;
  4use std::time::Duration;
  5use thiserror::Error;
  6use tokio::process::Command;
  7
  8/// Locate a chromium binary for lighthouse to drive. Tries `CHROMIUM_BIN`,
  9/// then a PATH search for the common binary names, then a glob over
 10/// `/opt/playwright-browsers/` so the webdev container Just Works without
 11/// per-shell env vars. Lighthouse passes the resulting path via the
 12/// `CHROME_PATH` env var (the npm CLI looks for that).
 13fn find_chromium() -> Option<String> {
 14    if let Ok(p) = std::env::var("CHROMIUM_BIN") {
 15        let path = PathBuf::from(&p);
 16        if path.is_file() {
 17            return Some(p);
 18        }
 19    }
 20    let names = [
 21        "chromium",
 22        "chromium-browser",
 23        "google-chrome",
 24        "chrome",
 25        "chrome-headless-shell",
 26    ];
 27    if let Some(path_var) = std::env::var_os("PATH") {
 28        for dir in std::env::split_paths(&path_var) {
 29            for name in &names {
 30                let candidate = dir.join(name);
 31                if candidate.is_file() {
 32                    return Some(candidate.to_string_lossy().into_owned());
 33                }
 34            }
 35        }
 36    }
 37    if let Ok(entries) = std::fs::read_dir("/opt/playwright-browsers") {
 38        for entry in entries.flatten() {
 39            let base = entry.path();
 40            // Lighthouse needs a full chrome (it drives DevTools), not the
 41            // headless-shell. Prefer chrome-linux64/chrome, fall back to the
 42            // chromium build that ships under chromium-*/chrome-linux/chrome.
 43            for rel in [
 44                "chrome-linux64/chrome",
 45                "chrome-linux/chrome",
 46                "chrome-headless-shell-linux64/chrome-headless-shell",
 47            ] {
 48                let candidate = base.join(rel);
 49                if candidate.is_file() {
 50                    return Some(candidate.to_string_lossy().into_owned());
 51                }
 52            }
 53        }
 54    }
 55    None
 56}
 57
 58const SUBPROCESS_TIMEOUT_SECS: u64 = 180;
 59const CHROME_FLAGS: &str = "--headless --no-sandbox --disable-dev-shm-usage --disable-gpu";
 60
 61#[derive(Debug, Error)]
 62pub enum LighthouseError {
 63    #[error("lighthouse binary missing at {0:?}")]
 64    BinaryMissing(PathBuf),
 65    #[error("lighthouse timed out after {0}s")]
 66    Timeout(u64),
 67    #[error("lighthouse exited {code}: {stderr}")]
 68    ExitNonZero { code: i32, stderr: String },
 69    #[error("could not parse lighthouse output: {0}")]
 70    Parse(#[from] serde_json::Error),
 71    #[error("missing category in lighthouse output: {0}")]
 72    MissingCategory(&'static str),
 73    #[error("null score(s) returned by lighthouse: {0:?}")]
 74    NullScores(Vec<&'static str>),
 75    #[error("subprocess io: {0}")]
 76    Io(#[from] std::io::Error),
 77}
 78
 79/// Run the lighthouse npm CLI and return the parsed JSON report.
 80pub async fn fetch(root: &std::path::Path, url: &str) -> Result<Value, LighthouseError> {
 81    let bin = root.join("node_modules/.bin/lighthouse");
 82    if !bin.exists() {
 83        return Err(LighthouseError::BinaryMissing(bin));
 84    }
 85
 86    let chromium = find_chromium();
 87
 88    // `bun run --bun` symlinks `node` → bun, so the lighthouse shim's
 89    // `#!/usr/bin/env node` shebang resolves to bun's runtime. Lets us drop
 90    // nodejs/npm from the image entirely.
 91    let mut cmd = Command::new("bun");
 92    cmd.arg("run")
 93        .arg("--bun")
 94        .arg(&bin)
 95        .arg(url)
 96        .arg(format!("--chrome-flags={CHROME_FLAGS}"))
 97        .arg("--output=json")
 98        .arg("--output-path=stdout")
 99        .arg("--quiet")
100        .env_clear()
101        .env("PATH", "/usr/bin:/bin:/usr/local/bin")
102        .stdout(Stdio::piped())
103        .stderr(Stdio::piped())
104        // On timeout the wait_with_output future is dropped; without this the
105        // orphaned lighthouse/chromium subprocess would keep running forever.
106        .kill_on_drop(true);
107    if let Some(c) = chromium {
108        cmd.env("CHROME_PATH", &c);
109    }
110
111    let child = cmd.spawn()?;
112    let output = match tokio::time::timeout(
113        Duration::from_secs(SUBPROCESS_TIMEOUT_SECS),
114        child.wait_with_output(),
115    )
116    .await
117    {
118        Ok(r) => r?,
119        Err(_) => return Err(LighthouseError::Timeout(SUBPROCESS_TIMEOUT_SECS)),
120    };
121
122    if !output.status.success() {
123        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
124        let truncated = stderr
125            .chars()
126            .rev()
127            .take(500)
128            .collect::<String>()
129            .chars()
130            .rev()
131            .collect::<String>();
132        return Err(LighthouseError::ExitNonZero {
133            code: output.status.code().unwrap_or(-1),
134            stderr: truncated,
135        });
136    }
137
138    Ok(serde_json::from_slice(&output.stdout)?)
139}
140
141#[derive(Debug, serde::Serialize)]
142pub struct Scores {
143    #[serde(rename = "Performance")]
144    pub performance: i64,
145    #[serde(rename = "Accessibility")]
146    pub accessibility: i64,
147    #[serde(rename = "Best practices")]
148    pub best_practices: i64,
149    #[serde(rename = "SEO")]
150    pub seo: i64,
151}
152
153pub fn parse_scores(results: &Value) -> Result<Scores, LighthouseError> {
154    let cats = results
155        .get("categories")
156        .ok_or(LighthouseError::MissingCategory("categories"))?;
157    let pull = |k: &'static str| -> Result<Option<f64>, LighthouseError> {
158        let cat = cats.get(k).ok_or(LighthouseError::MissingCategory(k))?;
159        Ok(cat.get("score").and_then(|v| v.as_f64()))
160    };
161    let p = pull("performance")?;
162    let a = pull("accessibility")?;
163    let b = pull("best-practices")?;
164    let s = pull("seo")?;
165    let mut nulls = Vec::new();
166    if p.is_none() { nulls.push("Performance"); }
167    if a.is_none() { nulls.push("Accessibility"); }
168    if b.is_none() { nulls.push("Best practices"); }
169    if s.is_none() { nulls.push("SEO"); }
170    if !nulls.is_empty() {
171        return Err(LighthouseError::NullScores(nulls));
172    }
173    let to_pct = |v: f64| (v * 100.0).round() as i64;
174    Ok(Scores {
175        performance: to_pct(p.unwrap()),
176        accessibility: to_pct(a.unwrap()),
177        best_practices: to_pct(b.unwrap()),
178        seo: to_pct(s.unwrap()),
179    })
180}
181
182#[derive(Debug, serde::Serialize)]
183pub struct Details {
184    pub metrics: Vec<Value>,
185    pub opportunities: Vec<Value>,
186}
187
188pub fn parse_details(results: &Value) -> Option<Details> {
189    let category = results.get("categories")?.get("performance")?;
190    let audits = results.get("audits")?.as_object()?;
191
192    let mut metrics: Vec<Value> = Vec::new();
193    let mut opportunities: Vec<Value> = Vec::new();
194
195    if let Some(refs) = category.get("auditRefs").and_then(|v| v.as_array()) {
196        for r in refs {
197            let id = r.get("id").and_then(|v| v.as_str()).unwrap_or_default();
198            let Some(audit) = audits.get(id) else { continue };
199            let group = r.get("group").and_then(|v| v.as_str()).unwrap_or("");
200            let weight = r.get("weight").and_then(|v| v.as_f64()).unwrap_or(0.0);
201            let score = audit.get("score").and_then(|v| v.as_f64());
202
203            if group == "metrics" && weight > 0.0 {
204                metrics.push(serde_json::json!({
205                    "id": id,
206                    "acronym": r.get("acronym").and_then(|v| v.as_str()).unwrap_or(id),
207                    "title": audit.get("title"),
208                    "display_value": audit.get("displayValue"),
209                    "score": score,
210                    "weight": weight,
211                }));
212                continue;
213            }
214
215            // Opportunities/diagnostics: skip passing/manual/not-applicable.
216            // `group: "hidden"` covers TTI and other audits Lighthouse keeps
217            // around but no longer scores; they shouldn't masquerade as wins.
218            if group == "hidden" {
219                continue;
220            }
221            let mode = audit
222                .get("scoreDisplayMode")
223                .and_then(|v| v.as_str())
224                .unwrap_or("");
225            if matches!(mode, "manual" | "notApplicable" | "informative") {
226                continue;
227            }
228            let Some(s) = score else { continue };
229            if s >= 0.9 {
230                continue;
231            }
232            let savings_ms = audit
233                .get("details")
234                .and_then(|d| d.get("overallSavingsMs"))
235                .and_then(|v| v.as_f64())
236                .unwrap_or(0.0);
237            let savings_bytes = audit
238                .get("details")
239                .and_then(|d| d.get("overallSavingsBytes"))
240                .and_then(|v| v.as_f64())
241                .unwrap_or(0.0);
242            let has_metric_savings = audit
243                .get("metricSavings")
244                .and_then(|v| v.as_object())
245                .map(|m| {
246                    m.values()
247                        .any(|v| v.as_f64().map(|n| n > 0.0).unwrap_or(false))
248                })
249                .unwrap_or(false);
250            // Require at least one actionable signal; pure diagnostics
251            // (forced-reflow, network-dependency-tree, etc.) carry none and
252            // would otherwise show up with a meaningless 0 score.
253            if savings_ms == 0.0 && savings_bytes == 0.0 && !has_metric_savings {
254                continue;
255            }
256            opportunities.push(serde_json::json!({
257                "id": id,
258                "title": audit.get("title"),
259                "display_value": audit.get("displayValue"),
260                "savings_ms": savings_ms,
261            }));
262        }
263    }
264
265    metrics.sort_by(|a, b| {
266        let aw = a.get("weight").and_then(|v| v.as_f64()).unwrap_or(0.0);
267        let bw = b.get("weight").and_then(|v| v.as_f64()).unwrap_or(0.0);
268        bw.partial_cmp(&aw).unwrap_or(std::cmp::Ordering::Equal)
269    });
270    opportunities.sort_by(|a, b| {
271        let asav = a.get("savings_ms").and_then(|v| v.as_f64()).unwrap_or(0.0);
272        let bsav = b.get("savings_ms").and_then(|v| v.as_f64()).unwrap_or(0.0);
273        bsav.partial_cmp(&asav).unwrap_or(std::cmp::Ordering::Equal)
274    });
275    opportunities.truncate(10);
276
277    Some(Details { metrics, opportunities })
278}