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

7.9 KB · 266 lines · Rust Raw History
  1use chrono::{DateTime, TimeZone, Utc};
  2use serde::{Deserialize, Serialize};
  3use sqlx::SqlitePool;
  4use uuid::Uuid;
  5
  6/// Row from the `properties` table. Timestamps are stored as integer ms since
  7/// epoch; we keep them as `i64` here and convert at the rendering boundary.
  8#[allow(dead_code)]
  9#[derive(Debug, Clone, sqlx::FromRow)]
 10pub struct PropertyRow {
 11    pub id: Vec<u8>,
 12    pub url: String,
 13    pub is_public: i64,
 14    pub is_protected: i64,
 15
 16    pub last_run_at: Option<i64>,
 17    pub next_run_at: Option<i64>,
 18
 19    pub last_run_at_crawler: Option<i64>,
 20    pub next_run_at_crawler: Option<i64>,
 21    pub crawler_insights: Option<String>,
 22    pub crawl_state: String,
 23    pub crawl_started_at: Option<i64>,
 24    pub last_crawl_success_at: Option<i64>,
 25    pub last_crawl_error: Option<String>,
 26    pub last_crawl_duration_ms: Option<i64>,
 27    pub last_crawl_pages_count: Option<i64>,
 28
 29    pub lighthouse_scores: Option<String>,
 30    pub lighthouse_details: Option<String>,
 31    pub last_lighthouse_run_at: Option<i64>,
 32    pub last_lighthouse_success_at: Option<i64>,
 33    pub last_lighthouse_error: Option<String>,
 34    pub last_lighthouse_duration_ms: Option<i64>,
 35    pub next_lighthouse_run_at: Option<i64>,
 36    pub lighthouse_state: String,
 37    pub lighthouse_started_at: Option<i64>,
 38
 39    pub alert_state: String,
 40    pub last_alert_sent: Option<i64>,
 41
 42    pub created_at: i64,
 43    pub updated_at: i64,
 44}
 45
 46impl PropertyRow {
 47    pub fn uuid(&self) -> Uuid {
 48        Uuid::from_slice(&self.id).unwrap_or(Uuid::nil())
 49    }
 50
 51    /// "example.com" stripped of leading www. Used for display/title.
 52    pub fn name(&self) -> String {
 53        self.url
 54            .split('/')
 55            .nth(2)
 56            .unwrap_or(&self.url)
 57            .trim_start_matches("www.")
 58            .to_string()
 59    }
 60}
 61
 62/// Minimal serializable shape used in templates. Contains everything every
 63/// template touches; we lift expensive computations (latest_headers,
 64/// recent_uptime_pct, etc.) into pre-computed fields fetched alongside.
 65#[derive(Debug, Serialize)]
 66pub struct PropertyContext {
 67    pub id: String,
 68    pub url: String,
 69    pub name: String,
 70    pub is_public: bool,
 71    pub is_protected: bool,
 72
 73    pub current_status: i64,
 74    pub avg_response_time: i64,
 75    pub recent_uptime_pct: Option<f64>,
 76    pub recent_tick_stream: Vec<&'static str>,
 77    pub total_checks: i64,
 78
 79    pub crawl_state: String,
 80    pub crawler_insights: serde_json::Value,
 81    pub last_crawl_success_at: Option<String>,
 82    pub last_crawl_error: Option<String>,
 83    pub last_crawl_duration_ms: Option<i64>,
 84    pub last_crawl_pages_count: Option<i64>,
 85    pub next_run_at_crawler: Option<String>,
 86    pub crawl_started_at: Option<String>,
 87
 88    pub lighthouse_state: String,
 89    pub lighthouse_scores: serde_json::Value,
 90    pub lighthouse_details: serde_json::Value,
 91    pub last_lighthouse_success_at: Option<String>,
 92    pub last_lighthouse_error: Option<String>,
 93    pub last_lighthouse_duration_ms: Option<i64>,
 94    pub next_lighthouse_run_at: Option<String>,
 95    pub lighthouse_started_at: Option<String>,
 96    pub avg_lighthouse_score: Option<i64>,
 97
 98    pub alert_state: String,
 99    pub created_at: String,
100    pub updated_at: String,
101
102    // Security flags derived from the latest response headers.
103    pub is_https: bool,
104    pub invalid_cert: bool,
105    pub has_mime_type: bool,
106    pub has_content_sniffing_protection: bool,
107    pub has_clickjack_protection: bool,
108    pub hides_server_version: bool,
109    pub has_hsts: bool,
110    pub has_hsts_preload: bool,
111    pub has_security_issue: bool,
112}
113
114pub fn ms_to_iso(ms: i64) -> String {
115    Utc.timestamp_millis_opt(ms)
116        .single()
117        .map(|d| d.to_rfc3339())
118        .unwrap_or_default()
119}
120
121pub fn ms_to_iso_opt(ms: Option<i64>) -> Option<String> {
122    ms.and_then(|m| Utc.timestamp_millis_opt(m).single().map(|d| d.to_rfc3339()))
123}
124
125#[allow(dead_code)]
126pub fn ms_to_dt(ms: i64) -> DateTime<Utc> {
127    Utc.timestamp_millis_opt(ms).single().unwrap_or_else(Utc::now)
128}
129
130pub async fn list_properties(
131    pool: &SqlitePool,
132    search: Option<&str>,
133) -> sqlx::Result<Vec<PropertyRow>> {
134    if let Some(q) = search {
135        sqlx::query_as::<_, PropertyRow>(
136            "SELECT * FROM properties WHERE url LIKE ? ORDER BY url",
137        )
138        .bind(format!("%{q}%"))
139        .fetch_all(pool)
140        .await
141    } else {
142        sqlx::query_as::<_, PropertyRow>("SELECT * FROM properties ORDER BY url")
143            .fetch_all(pool)
144            .await
145    }
146}
147
148pub async fn get_property(pool: &SqlitePool, id: Uuid) -> sqlx::Result<Option<PropertyRow>> {
149    sqlx::query_as::<_, PropertyRow>("SELECT * FROM properties WHERE id = ?")
150        .bind(id.as_bytes().to_vec())
151        .fetch_optional(pool)
152        .await
153}
154
155pub async fn delete_property(pool: &SqlitePool, id: Uuid) -> sqlx::Result<()> {
156    sqlx::query("DELETE FROM properties WHERE id = ? AND is_protected = 0")
157        .bind(id.as_bytes().to_vec())
158        .execute(pool)
159        .await?;
160    Ok(())
161}
162
163pub async fn create_property(pool: &SqlitePool, url: &str) -> sqlx::Result<Uuid> {
164    let id = Uuid::new_v4();
165    let now = crate::db::now_ms();
166    sqlx::query(
167        r#"INSERT INTO properties (id, url, created_at, updated_at)
168           VALUES (?, ?, ?, ?)"#,
169    )
170    .bind(id.as_bytes().to_vec())
171    .bind(url)
172    .bind(now)
173    .bind(now)
174    .execute(pool)
175    .await?;
176    Ok(id)
177}
178
179pub async fn toggle_public(pool: &SqlitePool, id: Uuid) -> sqlx::Result<bool> {
180    let row: Option<(i64,)> = sqlx::query_as("SELECT is_public FROM properties WHERE id = ?")
181        .bind(id.as_bytes().to_vec())
182        .fetch_optional(pool)
183        .await?;
184    let Some((cur,)) = row else { return Ok(false) };
185    let new = if cur == 0 { 1 } else { 0 };
186    sqlx::query("UPDATE properties SET is_public = ?, updated_at = ? WHERE id = ?")
187        .bind(new)
188        .bind(crate::db::now_ms())
189        .bind(id.as_bytes().to_vec())
190        .execute(pool)
191        .await?;
192    Ok(new == 1)
193}
194
195#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize)]
196pub struct CheckRow {
197    pub id: i64,
198    pub property_id: Vec<u8>,
199    pub status_code: i64,
200    pub response_ms: i64,
201    pub headers: String,
202    pub created_at: i64,
203    // Phase-by-phase timings (added in migration 0002). NULL for rows
204    // written before the rewrite to a phased prober; new rows always have
205    // dns_ms/tcp_ms/ttfb_ms set, and tls_ms set for HTTPS targets only.
206    #[serde(default)]
207    pub dns_ms: Option<i64>,
208    #[serde(default)]
209    pub tcp_ms: Option<i64>,
210    #[serde(default)]
211    pub tls_ms: Option<i64>,
212    #[serde(default)]
213    pub ttfb_ms: Option<i64>,
214}
215
216pub async fn recent_checks(
217    pool: &SqlitePool,
218    property_id: Uuid,
219    limit: i64,
220) -> sqlx::Result<Vec<CheckRow>> {
221    sqlx::query_as::<_, CheckRow>(
222        "SELECT * FROM checks WHERE property_id = ? ORDER BY created_at DESC LIMIT ?",
223    )
224    .bind(property_id.as_bytes().to_vec())
225    .bind(limit)
226    .fetch_all(pool)
227    .await
228}
229
230pub async fn count_status_codes(
231    pool: &SqlitePool,
232    property_id: Uuid,
233) -> sqlx::Result<Vec<(i64, i64)>> {
234    sqlx::query_as::<_, (i64, i64)>(
235        "SELECT status_code, COUNT(*) FROM checks WHERE property_id = ? GROUP BY status_code",
236    )
237    .bind(property_id.as_bytes().to_vec())
238    .fetch_all(pool)
239    .await
240}
241
242pub async fn count_checks(pool: &SqlitePool, property_id: Uuid) -> sqlx::Result<i64> {
243    let (n,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM checks WHERE property_id = ?")
244        .bind(property_id.as_bytes().to_vec())
245        .fetch_one(pool)
246        .await?;
247    Ok(n)
248}
249
250pub async fn count_uptime(
251    pool: &SqlitePool,
252    property_id: Uuid,
253) -> sqlx::Result<(i64, i64)> {
254    let (up, down): (i64, i64) = sqlx::query_as(
255        "SELECT \
256           SUM(CASE WHEN status_code = 200 THEN 1 ELSE 0 END), \
257           SUM(CASE WHEN status_code <> 200 THEN 1 ELSE 0 END) \
258         FROM checks WHERE property_id = ?",
259    )
260    .bind(property_id.as_bytes().to_vec())
261    .fetch_one(pool)
262    .await
263    .unwrap_or((0, 0));
264    Ok((up, down))
265}