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

8.6 KB · 223 lines · Rust Raw History
  1//! Import an existing Django status SQLite into the rust schema.
  2//!
  3//! Reads `properties_property` and `properties_check` from the Django DB
  4//! and writes them into the new schema, preserving Property UUIDs so
  5//! existing public status URLs keep working.
  6use anyhow::{Context, Result};
  7use sqlx::sqlite::SqliteConnectOptions;
  8use sqlx::{Connection, SqliteConnection};
  9use std::path::Path;
 10use std::path::PathBuf;
 11use std::str::FromStr;
 12
 13pub async fn run(source: PathBuf, force: bool) -> Result<()> {
 14    if !source.exists() {
 15        anyhow::bail!("source DB not found: {}", source.display());
 16    }
 17
 18    let data_dir = std::env::var("STATUS_DATA_DIR")
 19        .map(PathBuf::from)
 20        .unwrap_or_else(|_| PathBuf::from("data"));
 21    std::fs::create_dir_all(&data_dir)?;
 22    let dest_path = data_dir.join("db.sqlite3");
 23
 24    if dest_path.exists() && !force {
 25        anyhow::bail!(
 26            "{} already exists. Pass --force to wipe it first.",
 27            dest_path.display()
 28        );
 29    }
 30    if dest_path.exists() {
 31        std::fs::remove_file(&dest_path).context("removing existing dest db")?;
 32    }
 33
 34    let pool = crate::db::init(&data_dir).await?;
 35
 36    let src_url = format!("sqlite://{}?mode=ro", source.display());
 37    let opts = SqliteConnectOptions::from_str(&src_url)?
 38        .create_if_missing(false)
 39        .read_only(true);
 40    let mut src = SqliteConnection::connect_with(&opts).await.context("opening source db")?;
 41
 42    // ---------- properties ----------
 43    // Django columns: id (UUID hex string), url, is_public, last_run_at,
 44    // next_run_at, last_run_at_crawler, next_run_at_crawler, crawler_insights
 45    // (JSON or NULL), crawl_state, crawl_started_at, last_crawl_success_at,
 46    // last_crawl_error, last_crawl_duration_ms, last_crawl_pages_count,
 47    // lighthouse_scores, lighthouse_details, last_lighthouse_run_at,
 48    // last_lighthouse_success_at, last_lighthouse_error,
 49    // last_lighthouse_duration_ms, next_lighthouse_run_at, lighthouse_state,
 50    // lighthouse_started_at, last_alert_sent, alert_state, created_at,
 51    // updated_at, user_id.
 52    let rows: Vec<DjangoProperty> = sqlx::query_as::<_, DjangoProperty>(
 53        "SELECT id, url, is_public, \
 54                last_run_at, next_run_at, \
 55                last_run_at_crawler, next_run_at_crawler, crawler_insights, \
 56                crawl_state, crawl_started_at, last_crawl_success_at, last_crawl_error, \
 57                last_crawl_duration_ms, last_crawl_pages_count, \
 58                lighthouse_scores, lighthouse_details, last_lighthouse_run_at, \
 59                last_lighthouse_success_at, last_lighthouse_error, last_lighthouse_duration_ms, \
 60                next_lighthouse_run_at, lighthouse_state, lighthouse_started_at, \
 61                last_alert_sent, alert_state, created_at, updated_at \
 62         FROM properties_property",
 63    )
 64    .fetch_all(&mut src)
 65    .await
 66    .context("reading properties from source db")?;
 67
 68    let mut prop_count = 0;
 69    for row in &rows {
 70        let uuid = uuid::Uuid::parse_str(&row.id)
 71            .or_else(|_| uuid::Uuid::parse_str(&format!(
 72                "{}-{}-{}-{}-{}",
 73                &row.id[0..8], &row.id[8..12], &row.id[12..16], &row.id[16..20], &row.id[20..]
 74            )))
 75            .with_context(|| format!("parsing uuid: {}", row.id))?;
 76        let blob = uuid.as_bytes().to_vec();
 77        sqlx::query(
 78            r#"INSERT INTO properties (
 79                id, url, is_public, is_protected,
 80                last_run_at, next_run_at,
 81                last_run_at_crawler, next_run_at_crawler, crawler_insights,
 82                crawl_state, crawl_started_at, last_crawl_success_at, last_crawl_error,
 83                last_crawl_duration_ms, last_crawl_pages_count,
 84                lighthouse_scores, lighthouse_details, last_lighthouse_run_at,
 85                last_lighthouse_success_at, last_lighthouse_error, last_lighthouse_duration_ms,
 86                next_lighthouse_run_at, lighthouse_state, lighthouse_started_at,
 87                alert_state, last_alert_sent, created_at, updated_at
 88            ) VALUES (?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#,
 89        )
 90        .bind(&blob)
 91        .bind(&row.url)
 92        .bind(row.is_public)
 93        .bind(parse_django_dt(&row.last_run_at))
 94        .bind(parse_django_dt(&row.next_run_at))
 95        .bind(parse_django_dt(&row.last_run_at_crawler))
 96        .bind(parse_django_dt(&row.next_run_at_crawler))
 97        .bind(&row.crawler_insights)
 98        .bind(&row.crawl_state)
 99        .bind(parse_django_dt(&row.crawl_started_at))
100        .bind(parse_django_dt(&row.last_crawl_success_at))
101        .bind(&row.last_crawl_error)
102        .bind(row.last_crawl_duration_ms)
103        .bind(row.last_crawl_pages_count)
104        .bind(&row.lighthouse_scores)
105        .bind(&row.lighthouse_details)
106        .bind(parse_django_dt(&row.last_lighthouse_run_at))
107        .bind(parse_django_dt(&row.last_lighthouse_success_at))
108        .bind(&row.last_lighthouse_error)
109        .bind(row.last_lighthouse_duration_ms)
110        .bind(parse_django_dt(&row.next_lighthouse_run_at))
111        .bind(&row.lighthouse_state)
112        .bind(parse_django_dt(&row.lighthouse_started_at))
113        .bind(&row.alert_state)
114        .bind(parse_django_dt(&row.last_alert_sent))
115        .bind(parse_django_dt(&row.created_at).unwrap_or(0))
116        .bind(parse_django_dt(&row.updated_at).unwrap_or(0))
117        .execute(&pool)
118        .await
119        .with_context(|| format!("inserting property {}", row.url))?;
120        prop_count += 1;
121    }
122
123    // ---------- checks ----------
124    let checks: Vec<DjangoCheck> = sqlx::query_as::<_, DjangoCheck>(
125        "SELECT property_id, status_code, response_time, headers, created_at FROM properties_check",
126    )
127    .fetch_all(&mut src)
128    .await
129    .context("reading checks from source db")?;
130
131    let mut check_count = 0;
132    for row in &checks {
133        let uuid = match uuid::Uuid::parse_str(&row.property_id) {
134            Ok(u) => u,
135            Err(_) => continue,
136        };
137        let blob = uuid.as_bytes().to_vec();
138        let created = parse_django_dt(&row.created_at).unwrap_or(0);
139        let response_ms = row.response_time;
140        let _ = sqlx::query(
141            "INSERT INTO checks (property_id, status_code, response_ms, headers, created_at) \
142             VALUES (?, ?, ?, ?, ?)",
143        )
144        .bind(&blob)
145        .bind(row.status_code)
146        .bind(response_ms)
147        .bind(&row.headers)
148        .bind(created)
149        .execute(&pool)
150        .await;
151        check_count += 1;
152    }
153
154    println!(
155        "[migrate] imported {prop_count} properties, {check_count} checks into {}",
156        dest_path.display()
157    );
158    let _ = src.close().await;
159    Ok(())
160}
161
162#[derive(sqlx::FromRow)]
163struct DjangoProperty {
164    id: String,
165    url: String,
166    is_public: i64,
167    last_run_at: Option<String>,
168    next_run_at: Option<String>,
169    last_run_at_crawler: Option<String>,
170    next_run_at_crawler: Option<String>,
171    crawler_insights: Option<String>,
172    crawl_state: String,
173    crawl_started_at: Option<String>,
174    last_crawl_success_at: Option<String>,
175    last_crawl_error: Option<String>,
176    last_crawl_duration_ms: Option<i64>,
177    last_crawl_pages_count: Option<i64>,
178    lighthouse_scores: Option<String>,
179    lighthouse_details: Option<String>,
180    last_lighthouse_run_at: Option<String>,
181    last_lighthouse_success_at: Option<String>,
182    last_lighthouse_error: Option<String>,
183    last_lighthouse_duration_ms: Option<i64>,
184    next_lighthouse_run_at: Option<String>,
185    lighthouse_state: String,
186    lighthouse_started_at: Option<String>,
187    last_alert_sent: Option<String>,
188    alert_state: String,
189    created_at: Option<String>,
190    updated_at: Option<String>,
191}
192
193#[derive(sqlx::FromRow)]
194struct DjangoCheck {
195    property_id: String,
196    status_code: i64,
197    // Django schema declared `INTEGER NOT NULL`; the Python ORM stored
198    // milliseconds as ints. (An earlier draft tried `f64` and crashed on
199    // strict-typed sqlx decoding.)
200    response_time: i64,
201    headers: String,
202    created_at: Option<String>,
203}
204
205/// Django stores datetimes as ISO8601 strings (`YYYY-MM-DD HH:MM:SS[.f]`).
206/// Parse to UTC ms since epoch; return None if absent or unparseable.
207fn parse_django_dt(s: &Option<String>) -> Option<i64> {
208    let raw = s.as_deref()?.trim();
209    if raw.is_empty() {
210        return None;
211    }
212    let normalized = raw.replace('T', " ");
213    let parsed = chrono::NaiveDateTime::parse_from_str(&normalized, "%Y-%m-%d %H:%M:%S%.f")
214        .or_else(|_| chrono::NaiveDateTime::parse_from_str(&normalized, "%Y-%m-%d %H:%M:%S"))
215        .ok()?;
216    Some(chrono::Utc.from_utc_datetime(&parsed).timestamp_millis())
217}
218
219use chrono::TimeZone;
220
221#[allow(dead_code)]
222fn _unused(_: &Path) {}