Skip to main content

max / makenotwork

5.0 KB · 179 lines History Blame Raw
1 //! Health check storage and the queries built on it: history, latest snapshot,
2 //! uptime percentage, and the response-time series used for drift detection.
3
4 use super::{HealthDetails, HealthSnapshot, HealthStatus, Result, SqlitePool};
5 use tracing::instrument;
6
7 #[instrument(skip_all)]
8 pub async fn insert_health_check(pool: &SqlitePool, snapshot: &HealthSnapshot) -> Result<i64> {
9 let status = snapshot.status.to_string();
10 let details_json = snapshot
11 .details
12 .as_ref()
13 .map(|d| serde_json::to_string(d).unwrap_or_default());
14
15 let result = sqlx::query(
16 "INSERT INTO health_checks (target, status, checked_at, response_time_ms, details_json, error)
17 VALUES (?, ?, ?, ?, ?, ?)",
18 )
19 .bind(&snapshot.target)
20 .bind(&status)
21 .bind(&snapshot.checked_at)
22 .bind(snapshot.response_time_ms)
23 .bind(&details_json)
24 .bind(&snapshot.error)
25 .execute(pool)
26 .await?;
27
28 Ok(result.last_insert_rowid())
29 }
30
31 #[instrument(skip_all)]
32 pub async fn get_health_history(
33 pool: &SqlitePool,
34 target: Option<&str>,
35 limit: i64,
36 ) -> Result<Vec<HealthSnapshot>> {
37 let rows = match target {
38 Some(t) => {
39 sqlx::query_as::<_, HealthCheckRow>(
40 "SELECT id, target, status, checked_at, response_time_ms, details_json, error
41 FROM health_checks WHERE target = ? ORDER BY id DESC LIMIT ?",
42 )
43 .bind(t)
44 .bind(limit)
45 .fetch_all(pool)
46 .await?
47 }
48 None => {
49 sqlx::query_as::<_, HealthCheckRow>(
50 "SELECT id, target, status, checked_at, response_time_ms, details_json, error
51 FROM health_checks ORDER BY id DESC LIMIT ?",
52 )
53 .bind(limit)
54 .fetch_all(pool)
55 .await?
56 }
57 };
58
59 Ok(rows
60 .into_iter()
61 .map(HealthCheckRow::into_snapshot)
62 .collect())
63 }
64
65 #[instrument(skip_all)]
66 pub async fn get_latest_health(pool: &SqlitePool, target: &str) -> Result<Option<HealthSnapshot>> {
67 let row = sqlx::query_as::<_, HealthCheckRow>(
68 "SELECT id, target, status, checked_at, response_time_ms, details_json, error
69 FROM health_checks WHERE target = ? ORDER BY id DESC LIMIT 1",
70 )
71 .bind(target)
72 .fetch_optional(pool)
73 .await?;
74
75 Ok(row.map(HealthCheckRow::into_snapshot))
76 }
77
78 /// Calculate uptime percentage for a target over the given number of hours.
79 /// Returns the percentage of health checks with "operational" status.
80 #[instrument(skip_all)]
81 pub async fn get_uptime_percent(
82 pool: &SqlitePool,
83 target: &str,
84 hours: i64,
85 ) -> Result<Option<f64>> {
86 let cutoff = chrono::Utc::now() - chrono::Duration::hours(hours);
87 let cutoff_str = cutoff.to_rfc3339();
88
89 let row = sqlx::query_as::<_, (i64, i64)>(
90 "SELECT
91 COUNT(*) as total,
92 SUM(CASE WHEN status = 'operational' THEN 1 ELSE 0 END) as operational
93 FROM health_checks
94 WHERE target = ? AND checked_at >= ?",
95 )
96 .bind(target)
97 .bind(&cutoff_str)
98 .fetch_one(pool)
99 .await?;
100
101 if row.0 == 0 {
102 Ok(None)
103 } else {
104 Ok(Some(row.1 as f64 / row.0 as f64 * 100.0))
105 }
106 }
107
108 /// Fetch all response times for a target since a given timestamp, ordered ASC.
109 #[instrument(skip_all)]
110 pub async fn get_response_times(
111 pool: &SqlitePool,
112 target: &str,
113 since_rfc3339: &str,
114 ) -> Result<Vec<(String, i64)>> {
115 let rows = sqlx::query_as::<_, (String, i64)>(
116 "SELECT checked_at, response_time_ms FROM health_checks
117 WHERE target = ? AND checked_at >= ?
118 ORDER BY checked_at ASC",
119 )
120 .bind(target)
121 .bind(since_rfc3339)
122 .fetch_all(pool)
123 .await?;
124 Ok(rows)
125 }
126
127 /// Fetch the last N response times for **operational** checks only (most recent first).
128 #[instrument(skip_all)]
129 pub async fn get_recent_response_times(
130 pool: &SqlitePool,
131 target: &str,
132 count: i64,
133 ) -> Result<Vec<i64>> {
134 let rows = sqlx::query_as::<_, (i64,)>(
135 "SELECT response_time_ms FROM health_checks
136 WHERE target = ? AND status = 'operational'
137 ORDER BY id DESC LIMIT ?",
138 )
139 .bind(target)
140 .bind(count)
141 .fetch_all(pool)
142 .await?;
143 Ok(rows.into_iter().map(|r| r.0).collect())
144 }
145
146 #[derive(sqlx::FromRow)]
147 struct HealthCheckRow {
148 id: i64,
149 target: String,
150 status: String,
151 checked_at: String,
152 response_time_ms: i64,
153 details_json: Option<String>,
154 error: Option<String>,
155 }
156
157 impl HealthCheckRow {
158 fn into_snapshot(self) -> HealthSnapshot {
159 let status = self
160 .status
161 .parse::<HealthStatus>()
162 .unwrap_or(HealthStatus::Error);
163 let details = self
164 .details_json
165 .as_deref()
166 .and_then(|s| serde_json::from_str::<HealthDetails>(s).ok());
167
168 HealthSnapshot {
169 id: Some(self.id),
170 target: self.target,
171 status,
172 checked_at: self.checked_at,
173 response_time_ms: self.response_time_ms,
174 details,
175 error: self.error,
176 }
177 }
178 }
179