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
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}