Split web.rs into a module directory
web.rs was 685 lines holding templates, handlers, auth, DB loading, formatting, and the router. Now split into focused files: mod.rs (router), auth.rs (RemoteUser extractor + middleware), db.rs (data loading), format.rs (timestamp/duration helpers), handlers.rs (route handlers), templates.rs (Askama template structs). Public API unchanged — quire::quire::web::router() still works.
change rnzpmnmwkvvktntxtvwkxlqozvqlxxqk
commit 66c338ded3d286d85a15adad001898d59af4538f
author Alpha Chen <alpha@kejadlen.dev>
date
parent psrvluxm
diff --git a/src/quire/web.rs b/src/quire/web.rs
deleted file mode 100644
index 8e096dd..0000000
--- a/src/quire/web.rs
+++ /dev/null
@@ -1,685 +0,0 @@
-//! Read-only CI web view.
-//!
-//! Two pages:
-//! - `GET /<name>/ci` — most-recent runs for a repo.
-//! - `GET /<name>/ci/<run-id>` — per-run detail with jobs and logs.
-//!
-//! Server-rendered HTML via Askama templates. JavaScript-optional.
-
-use askama::Template;
-use axum::extract::{FromRequestParts, Path as AxumPath, State};
-use axum::http::StatusCode;
-use axum::http::request::Parts;
-use axum::middleware::{self, Next};
-use axum::response::{Html, IntoResponse, Response};
-use jiff::Timestamp;
-use rusqlite::Connection;
-
-use crate::Quire;
-
-// ── Template structs ───────────────────────────────────────────────
-
-#[derive(askama::Template)]
-#[template(path = "ci/run_list.html")]
-struct RunListTemplate {
-    repo: String,
-    page: String,
-    runs: Vec<RunListRow>,
-}
-
-struct RunListRow {
-    id: String,
-    state: String,
-    sha: String,
-    ref_name: String,
-    queued_at_ms: i64,
-    started_at_ms: Option<i64>,
-    finished_at_ms: Option<i64>,
-}
-
-impl RunListRow {
-    fn state_class(&self) -> &str {
-        match self.state.as_str() {
-            "complete" => "c-ok",
-            "failed" => "c-bad",
-            _ => "c-muted",
-        }
-    }
-
-    fn sha_short(&self) -> &str {
-        &self.sha[..self.sha.len().min(8)]
-    }
-
-    fn ref_short(&self) -> &str {
-        self.ref_name.trim_start_matches("refs/heads/")
-    }
-
-    fn queued_relative(&self) -> String {
-        format_timestamp_relative(self.queued_at_ms)
-    }
-
-    fn queued_iso(&self) -> String {
-        format_timestamp_iso(self.queued_at_ms)
-    }
-
-    fn duration_display(&self) -> String {
-        format_duration(self.started_at_ms, self.finished_at_ms)
-    }
-}
-
-#[derive(askama::Template)]
-#[template(path = "ci/run_detail.html")]
-struct RunDetailTemplate {
-    repo: String,
-    page: String,
-    run: DetailRun,
-    jobs: Vec<DetailJob>,
-}
-
-struct DetailRun {
-    state: String,
-    sha: String,
-    ref_name: String,
-    queued_at_ms: i64,
-    started_at_ms: Option<i64>,
-    finished_at_ms: Option<i64>,
-}
-
-impl DetailRun {
-    fn state_class(&self) -> &str {
-        match self.state.as_str() {
-            "complete" => "c-ok",
-            "failed" => "c-bad",
-            _ => "c-muted",
-        }
-    }
-
-    fn sha_short(&self) -> &str {
-        &self.sha[..self.sha.len().min(8)]
-    }
-
-    fn ref_short(&self) -> &str {
-        self.ref_name.trim_start_matches("refs/heads/")
-    }
-
-    fn queued_relative(&self) -> String {
-        format_timestamp_relative(self.queued_at_ms)
-    }
-
-    fn queued_iso(&self) -> String {
-        format_timestamp_iso(self.queued_at_ms)
-    }
-
-    fn started_display(&self) -> String {
-        self.started_at_ms
-            .map(format_timestamp_relative)
-            .unwrap_or_else(|| "—".to_string())
-    }
-
-    fn started_iso(&self) -> String {
-        self.started_at_ms
-            .map(format_timestamp_iso)
-            .unwrap_or_default()
-    }
-
-    fn has_started(&self) -> bool {
-        self.started_at_ms.is_some()
-    }
-
-    fn finished_display(&self) -> String {
-        self.finished_at_ms
-            .map(format_timestamp_relative)
-            .unwrap_or_else(|| "—".to_string())
-    }
-
-    fn finished_iso(&self) -> String {
-        self.finished_at_ms
-            .map(format_timestamp_iso)
-            .unwrap_or_default()
-    }
-
-    fn has_finished(&self) -> bool {
-        self.finished_at_ms.is_some()
-    }
-
-    fn duration_display(&self) -> String {
-        format_duration(self.started_at_ms, self.finished_at_ms)
-    }
-}
-
-struct DetailJob {
-    job_id: String,
-    state: String,
-    exit_code: Option<i32>,
-    started_at_ms: Option<i64>,
-    finished_at_ms: Option<i64>,
-    sh_events: Vec<DetailShEvent>,
-}
-
-impl DetailJob {
-    fn state_class(&self) -> &str {
-        match self.state.as_str() {
-            "complete" => "c-ok",
-            "failed" => "c-bad",
-            _ => "c-muted",
-        }
-    }
-
-    fn duration_display(&self) -> String {
-        format_duration(self.started_at_ms, self.finished_at_ms)
-    }
-
-    fn exit_display(&self) -> String {
-        self.exit_code
-            .map(|c| format!(" · exit {c}"))
-            .unwrap_or_default()
-    }
-}
-
-struct DetailShEvent {
-    index: usize,
-    started_at_ms: i64,
-    finished_at_ms: i64,
-    exit_code: i32,
-    cmd: String,
-    log_content: String,
-}
-
-impl DetailShEvent {
-    fn duration_display(&self) -> String {
-        format_duration_exact(self.started_at_ms, self.finished_at_ms)
-    }
-
-    fn cmd_display(&self) -> &str {
-        if self.cmd.len() > 120 {
-            &self.cmd[..120]
-        } else {
-            &self.cmd
-        }
-    }
-}
-
-#[derive(askama::Template)]
-#[template(path = "error.html")]
-struct ErrorTemplate {
-    repo: String,
-    page: String,
-    title: String,
-    detail: String,
-}
-
-// ── Data access structs (from DB rows) ─────────────────────────────
-
-struct RunRow {
-    id: String,
-    state: String,
-    sha: String,
-    ref_name: String,
-    queued_at_ms: i64,
-    started_at_ms: Option<i64>,
-    finished_at_ms: Option<i64>,
-}
-
-struct JobRow {
-    job_id: String,
-    state: String,
-    exit_code: Option<i32>,
-    started_at_ms: Option<i64>,
-    finished_at_ms: Option<i64>,
-}
-
-struct ShEvent {
-    job_id: String,
-    started_at_ms: i64,
-    finished_at_ms: i64,
-    exit_code: i32,
-    cmd: String,
-}
-
-// ── Auth ───────────────────────────────────────────────────────────
-
-/// Identity extracted from the `Remote-User` header injected by the
-/// reverse proxy. Present means authenticated; absent means
-/// unauthenticated. Both are valid — individual handlers (or future
-/// middleware) decide whether to require auth.
-#[derive(Clone, Debug)]
-pub struct RemoteUser(pub Option<String>);
-
-impl RemoteUser {
-    /// Whether the request carries an authenticated identity.
-    pub fn is_authenticated(&self) -> bool {
-        self.0.is_some()
-    }
-
-    /// The username, if authenticated.
-    pub fn username(&self) -> Option<&str> {
-        self.0.as_deref()
-    }
-}
-
-impl<S: Send + Sync> FromRequestParts<S> for RemoteUser {
-    type Rejection = std::convert::Infallible;
-
-    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
-        let user = parts
-            .headers
-            .get("Remote-User")
-            .and_then(|v| v.to_str().ok())
-            .map(|s| s.to_string());
-        Ok(RemoteUser(user))
-    }
-}
-
-// ── Formatting helpers ─────────────────────────────────────────────
-
-fn format_timestamp_relative(ms: i64) -> String {
-    match Timestamp::from_millisecond(ms) {
-        Ok(ts) => {
-            let now = Timestamp::now();
-            let span = now.since(ts).unwrap_or_else(|_| jiff::Span::new());
-            let hours = span.get_hours().abs();
-            let minutes = span.get_minutes().abs();
-            if hours < 1 {
-                if minutes < 1 {
-                    "just now".to_string()
-                } else {
-                    format!("{minutes}m ago")
-                }
-            } else if hours < 24 {
-                format!("{hours}h ago")
-            } else {
-                ts.to_string()
-            }
-        }
-        Err(_) => format!("{ms}ms"),
-    }
-}
-
-fn format_timestamp_iso(ms: i64) -> String {
-    Timestamp::from_millisecond(ms)
-        .map(|ts| ts.to_string())
-        .unwrap_or_else(|_| format!("{ms}ms"))
-}
-
-fn format_duration(start: Option<i64>, end: Option<i64>) -> String {
-    match (start, end) {
-        (Some(s), Some(e)) => {
-            let ms = e - s;
-            if ms < 1000 {
-                format!("{ms}ms")
-            } else {
-                format!("{}s", ms / 1000)
-            }
-        }
-        _ => "—".to_string(),
-    }
-}
-
-fn format_duration_exact(start: i64, end: i64) -> String {
-    let ms = end - start;
-    if ms < 1000 {
-        format!("{ms}ms")
-    } else {
-        format!("{}s", ms / 1000)
-    }
-}
-
-// ── Data loading ───────────────────────────────────────────────────
-
-fn load_runs(quire: &Quire, repo: &str) -> Result<Vec<RunRow>, String> {
-    let db = Connection::open(quire.db_path()).map_err(|e| e.to_string())?;
-    let mut stmt = db
-        .prepare(
-            "SELECT id, state, sha, ref_name, queued_at_ms, started_at_ms, finished_at_ms
-             FROM runs WHERE repo = ?1
-             ORDER BY queued_at_ms DESC
-             LIMIT 50",
-        )
-        .map_err(|e| e.to_string())?;
-
-    let rows = stmt
-        .query_map(rusqlite::params![repo], |row| {
-            Ok(RunRow {
-                id: row.get(0)?,
-                state: row.get(1)?,
-                sha: row.get(2)?,
-                ref_name: row.get(3)?,
-                queued_at_ms: row.get(4)?,
-                started_at_ms: row.get(5)?,
-                finished_at_ms: row.get(6)?,
-            })
-        })
-        .map_err(|e| e.to_string())?
-        .collect::<Result<Vec<_>, _>>()
-        .map_err(|e| e.to_string())?;
-
-    Ok(rows)
-}
-
-fn load_run_detail(
-    quire: &Quire,
-    repo: &str,
-    run_id: &str,
-) -> Result<(RunRow, Vec<JobRow>, Vec<ShEvent>), String> {
-    let db = Connection::open(quire.db_path()).map_err(|e| e.to_string())?;
-
-    let run = db
-        .query_row(
-            "SELECT id, state, sha, ref_name, queued_at_ms, started_at_ms, finished_at_ms
-             FROM runs WHERE id = ?1 AND repo = ?2",
-            rusqlite::params![run_id, repo],
-            |row| {
-                Ok(RunRow {
-                    id: row.get(0)?,
-                    state: row.get(1)?,
-                    sha: row.get(2)?,
-                    ref_name: row.get(3)?,
-                    queued_at_ms: row.get(4)?,
-                    started_at_ms: row.get(5)?,
-                    finished_at_ms: row.get(6)?,
-                })
-            },
-        )
-        .map_err(|e| e.to_string())?;
-
-    let mut job_stmt = db
-        .prepare(
-            "SELECT job_id, state, exit_code, started_at_ms, finished_at_ms
-             FROM jobs WHERE run_id = ?1
-             ORDER BY rowid",
-        )
-        .map_err(|e| e.to_string())?;
-
-    let jobs = job_stmt
-        .query_map(rusqlite::params![run_id], |row| {
-            Ok(JobRow {
-                job_id: row.get(0)?,
-                state: row.get(1)?,
-                exit_code: row.get(2)?,
-                started_at_ms: row.get(3)?,
-                finished_at_ms: row.get(4)?,
-            })
-        })
-        .map_err(|e| e.to_string())?
-        .collect::<Result<Vec<_>, _>>()
-        .map_err(|e| e.to_string())?;
-
-    let mut sh_stmt = db
-        .prepare(
-            "SELECT job_id, started_at_ms, finished_at_ms, exit_code, cmd
-             FROM sh_events WHERE run_id = ?1
-             ORDER BY job_id, started_at_ms",
-        )
-        .map_err(|e| e.to_string())?;
-
-    let sh_events = sh_stmt
-        .query_map(rusqlite::params![run_id], |row| {
-            Ok(ShEvent {
-                job_id: row.get(0)?,
-                started_at_ms: row.get(1)?,
-                finished_at_ms: row.get(2)?,
-                exit_code: row.get(3)?,
-                cmd: row.get(4)?,
-            })
-        })
-        .map_err(|e| e.to_string())?
-        .collect::<Result<Vec<_>, _>>()
-        .map_err(|e| e.to_string())?;
-
-    Ok((run, jobs, sh_events))
-}
-
-/// Determine the 1-based sh index for an event within its job.
-fn sh_index_for_event(events: &[ShEvent], job_id: &str, event_idx: usize) -> usize {
-    let mut n = 0;
-    for (i, ev) in events.iter().enumerate() {
-        if ev.job_id == job_id && i <= event_idx {
-            n += 1;
-        }
-    }
-    n
-}
-
-/// Resolve a URL slug to the on-disk repo name.
-///
-/// URLs use clean names (`foo`), disk/DB use `foo.git`.
-fn resolve_repo_name(slug: &str) -> String {
-    if slug.ends_with(".git") {
-        slug.to_string()
-    } else {
-        format!("{slug}.git")
-    }
-}
-
-// ── Handlers ───────────────────────────────────────────────────────
-
-pub async fn run_list(
-    State(quire): State<Quire>,
-    AxumPath(repo): AxumPath<String>,
-    user: RemoteUser,
-) -> Html<String> {
-    let _user = user;
-    let repo_display = repo.trim_end_matches(".git").to_string();
-    let repo_name = resolve_repo_name(&repo);
-
-    let runs = match load_runs(&quire, &repo_name) {
-        Ok(r) => r,
-        Err(e) => {
-            tracing::error!(repo = %repo, error = %e, "failed to load runs");
-            let tmpl = ErrorTemplate {
-                repo: repo_display,
-                page: "error".to_string(),
-                title: "Failed to load runs".to_string(),
-                detail: e,
-            };
-            return Html(tmpl.render().unwrap_or_default());
-        }
-    };
-
-    let template_runs: Vec<RunListRow> = runs
-        .into_iter()
-        .map(|r| RunListRow {
-            id: r.id,
-            state: r.state,
-            sha: r.sha,
-            ref_name: r.ref_name,
-            queued_at_ms: r.queued_at_ms,
-            started_at_ms: r.started_at_ms,
-            finished_at_ms: r.finished_at_ms,
-        })
-        .collect();
-
-    let tmpl = RunListTemplate {
-        repo: repo_display,
-        page: "ci".to_string(),
-        runs: template_runs,
-    };
-    Html(tmpl.render().unwrap_or_default())
-}
-
-pub async fn run_detail(
-    State(quire): State<Quire>,
-    AxumPath((repo, run_id)): AxumPath<(String, String)>,
-    user: RemoteUser,
-) -> Html<String> {
-    let _user = user;
-    let repo_display = repo.trim_end_matches(".git").to_string();
-    let repo_name = resolve_repo_name(&repo);
-
-    let result = load_run_detail(&quire, &repo_name, &run_id);
-    let (run, jobs, sh_events) = match result {
-        Ok(d) => d,
-        Err(e) => {
-            tracing::error!(repo = %repo, run_id = %run_id, error = %e, "failed to load run detail");
-            let tmpl = ErrorTemplate {
-                repo: repo_display,
-                page: "error".to_string(),
-                title: "Failed to load run".to_string(),
-                detail: e,
-            };
-            return Html(tmpl.render().unwrap_or_default());
-        }
-    };
-
-    let detail_run = DetailRun {
-        state: run.state,
-        sha: run.sha,
-        ref_name: run.ref_name,
-        queued_at_ms: run.queued_at_ms,
-        started_at_ms: run.started_at_ms,
-        finished_at_ms: run.finished_at_ms,
-    };
-
-    // Load CRI log contents for each sh event.
-    let runs_base = quire.base_dir().join("runs").join(&repo_name);
-    let mut log_contents: std::collections::HashMap<(String, usize), String> =
-        std::collections::HashMap::new();
-    for (idx, ev) in sh_events.iter().enumerate() {
-        let sh_n = sh_index_for_event(&sh_events, &ev.job_id, idx);
-        let key = (ev.job_id.clone(), sh_n);
-        if log_contents.contains_key(&key) {
-            continue;
-        }
-        let log_path = runs_base
-            .join(&run_id)
-            .join("jobs")
-            .join(&ev.job_id)
-            .join(format!("sh-{sh_n}.log"));
-        if log_path.exists() {
-            match fs_err::read_to_string(&log_path) {
-                Ok(content) => {
-                    log_contents.insert(key, content);
-                }
-                Err(e) => {
-                    tracing::warn!(path = %log_path.display(), error = %e, "failed to read CRI log");
-                }
-            }
-        }
-    }
-
-    let mut detail_jobs: Vec<DetailJob> = Vec::new();
-    for job in &jobs {
-        let job_shs: Vec<(usize, &ShEvent)> = sh_events
-            .iter()
-            .enumerate()
-            .filter(|(_, e)| e.job_id == job.job_id)
-            .collect();
-
-        let mut detail_sh_events: Vec<DetailShEvent> = Vec::new();
-        for (global_idx, ev) in &job_shs {
-            let sh_n = sh_index_for_event(&sh_events, &ev.job_id, *global_idx);
-
-            let log = log_contents
-                .get(&(ev.job_id.clone(), sh_n))
-                .cloned()
-                .unwrap_or_default();
-
-            detail_sh_events.push(DetailShEvent {
-                index: sh_n,
-                started_at_ms: ev.started_at_ms,
-                finished_at_ms: ev.finished_at_ms,
-                exit_code: ev.exit_code,
-                cmd: ev.cmd.clone(),
-                log_content: log,
-            });
-        }
-
-        detail_jobs.push(DetailJob {
-            job_id: job.job_id.clone(),
-            state: job.state.clone(),
-            exit_code: job.exit_code,
-            started_at_ms: job.started_at_ms,
-            finished_at_ms: job.finished_at_ms,
-            sh_events: detail_sh_events,
-        });
-    }
-
-    let tmpl = RunDetailTemplate {
-        repo: repo_display,
-        page: format!("ci · {}", detail_run.sha_short()),
-        run: detail_run,
-        jobs: detail_jobs,
-    };
-    Html(tmpl.render().unwrap_or_default())
-}
-
-// ── Router ─────────────────────────────────────────────────────────
-
-pub fn router(quire: Quire) -> axum::Router {
-    let ci_routes = axum::Router::new()
-        .route("/{repo}/ci", axum::routing::get(run_list))
-        .route("/{repo}/ci/{run_id}", axum::routing::get(run_detail))
-        .layer(middleware::from_fn(require_auth));
-
-    ci_routes.with_state(quire)
-}
-
-/// Middleware that rejects unauthenticated requests.
-///
-/// CI routes require auth per the access matrix in PLAN.md.
-/// Returns 401 so the client knows auth is required.
-async fn require_auth(request: axum::extract::Request, next: Next) -> Response {
-    let user = request
-        .headers()
-        .get("Remote-User")
-        .and_then(|v| v.to_str().ok());
-
-    if user.is_none() {
-        return StatusCode::UNAUTHORIZED.into_response();
-    }
-
-    next.run(request).await
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-
-    #[test]
-    fn run_list_template_renders_empty() {
-        let tmpl = RunListTemplate {
-            repo: "test".to_string(),
-            page: "ci".to_string(),
-            runs: vec![],
-        };
-        let html = tmpl.render().unwrap();
-        assert!(html.contains("no runs yet"));
-        assert!(html.contains("ci · test"));
-    }
-
-    #[test]
-    fn run_list_template_renders_runs() {
-        let tmpl = RunListTemplate {
-            repo: "test".to_string(),
-            page: "ci".to_string(),
-            runs: vec![RunListRow {
-                id: "abc123".to_string(),
-                state: "complete".to_string(),
-                sha: "deadbeef".to_string(),
-                ref_name: "refs/heads/main".to_string(),
-                queued_at_ms: 1000,
-                started_at_ms: Some(2000),
-                finished_at_ms: Some(3000),
-            }],
-        };
-        let html = tmpl.render().unwrap();
-        assert!(html.contains("deadbeef"));
-        assert!(html.contains("main"));
-        assert!(html.contains("/test/ci/abc123"));
-    }
-
-    #[test]
-    fn format_duration_shows_ms_for_subsecond() {
-        assert_eq!(format_duration(Some(0), Some(500)), "500ms");
-    }
-
-    #[test]
-    fn format_duration_shows_seconds() {
-        assert_eq!(format_duration(Some(0), Some(3500)), "3s");
-    }
-
-    #[test]
-    fn format_duration_dash_when_missing() {
-        assert_eq!(format_duration(None, None), "—");
-    }
-}
diff --git a/src/quire/web/auth.rs b/src/quire/web/auth.rs
new file mode 100644
index 0000000..930d33f
--- /dev/null
+++ b/src/quire/web/auth.rs
@@ -0,0 +1,56 @@
+//! Auth middleware and identity extractor.
+
+use axum::extract::FromRequestParts;
+use axum::http::StatusCode;
+use axum::http::request::Parts;
+use axum::middleware::Next;
+use axum::response::{IntoResponse, Response};
+
+/// Identity extracted from the `Remote-User` header injected by the
+/// reverse proxy. Present means authenticated; absent means
+/// unauthenticated. Both are valid — individual handlers (or future
+/// middleware) decide whether to require auth.
+#[derive(Clone, Debug)]
+pub struct RemoteUser(pub Option<String>);
+
+impl RemoteUser {
+    /// Whether the request carries an authenticated identity.
+    pub fn is_authenticated(&self) -> bool {
+        self.0.is_some()
+    }
+
+    /// The username, if authenticated.
+    pub fn username(&self) -> Option<&str> {
+        self.0.as_deref()
+    }
+}
+
+impl<S: Send + Sync> FromRequestParts<S> for RemoteUser {
+    type Rejection = std::convert::Infallible;
+
+    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
+        let user = parts
+            .headers
+            .get("Remote-User")
+            .and_then(|v| v.to_str().ok())
+            .map(|s| s.to_string());
+        Ok(RemoteUser(user))
+    }
+}
+
+/// Middleware that rejects unauthenticated requests.
+///
+/// CI routes require auth per the access matrix in PLAN.md.
+/// Returns 401 so the client knows auth is required.
+pub async fn require_auth(request: axum::extract::Request, next: Next) -> Response {
+    let user = request
+        .headers()
+        .get("Remote-User")
+        .and_then(|v| v.to_str().ok());
+
+    if user.is_none() {
+        return StatusCode::UNAUTHORIZED.into_response();
+    }
+
+    next.run(request).await
+}
diff --git a/src/quire/web/db.rs b/src/quire/web/db.rs
new file mode 100644
index 0000000..ff7c362
--- /dev/null
+++ b/src/quire/web/db.rs
@@ -0,0 +1,159 @@
+//! Data access structs and DB loading functions for the web view.
+
+use rusqlite::Connection;
+
+use crate::Quire;
+
+/// Raw run row from the database.
+pub struct RunRow {
+    pub id: String,
+    pub state: String,
+    pub sha: String,
+    pub ref_name: String,
+    pub queued_at_ms: i64,
+    pub started_at_ms: Option<i64>,
+    pub finished_at_ms: Option<i64>,
+}
+
+/// Raw job row from the database.
+pub struct JobRow {
+    pub job_id: String,
+    pub state: String,
+    pub exit_code: Option<i32>,
+    pub started_at_ms: Option<i64>,
+    pub finished_at_ms: Option<i64>,
+}
+
+/// Raw sh event row from the database.
+pub struct ShEvent {
+    pub job_id: String,
+    pub started_at_ms: i64,
+    pub finished_at_ms: i64,
+    pub exit_code: i32,
+    pub cmd: String,
+}
+
+pub fn load_runs(quire: &Quire, repo: &str) -> Result<Vec<RunRow>, String> {
+    let db = Connection::open(quire.db_path()).map_err(|e| e.to_string())?;
+    let mut stmt = db
+        .prepare(
+            "SELECT id, state, sha, ref_name, queued_at_ms, started_at_ms, finished_at_ms
+             FROM runs WHERE repo = ?1
+             ORDER BY queued_at_ms DESC
+             LIMIT 50",
+        )
+        .map_err(|e| e.to_string())?;
+
+    let rows = stmt
+        .query_map(rusqlite::params![repo], |row| {
+            Ok(RunRow {
+                id: row.get(0)?,
+                state: row.get(1)?,
+                sha: row.get(2)?,
+                ref_name: row.get(3)?,
+                queued_at_ms: row.get(4)?,
+                started_at_ms: row.get(5)?,
+                finished_at_ms: row.get(6)?,
+            })
+        })
+        .map_err(|e| e.to_string())?
+        .collect::<Result<Vec<_>, _>>()
+        .map_err(|e| e.to_string())?;
+
+    Ok(rows)
+}
+
+pub fn load_run_detail(
+    quire: &Quire,
+    repo: &str,
+    run_id: &str,
+) -> Result<(RunRow, Vec<JobRow>, Vec<ShEvent>), String> {
+    let db = Connection::open(quire.db_path()).map_err(|e| e.to_string())?;
+
+    let run = db
+        .query_row(
+            "SELECT id, state, sha, ref_name, queued_at_ms, started_at_ms, finished_at_ms
+             FROM runs WHERE id = ?1 AND repo = ?2",
+            rusqlite::params![run_id, repo],
+            |row| {
+                Ok(RunRow {
+                    id: row.get(0)?,
+                    state: row.get(1)?,
+                    sha: row.get(2)?,
+                    ref_name: row.get(3)?,
+                    queued_at_ms: row.get(4)?,
+                    started_at_ms: row.get(5)?,
+                    finished_at_ms: row.get(6)?,
+                })
+            },
+        )
+        .map_err(|e| e.to_string())?;
+
+    let mut job_stmt = db
+        .prepare(
+            "SELECT job_id, state, exit_code, started_at_ms, finished_at_ms
+             FROM jobs WHERE run_id = ?1
+             ORDER BY rowid",
+        )
+        .map_err(|e| e.to_string())?;
+
+    let jobs = job_stmt
+        .query_map(rusqlite::params![run_id], |row| {
+            Ok(JobRow {
+                job_id: row.get(0)?,
+                state: row.get(1)?,
+                exit_code: row.get(2)?,
+                started_at_ms: row.get(3)?,
+                finished_at_ms: row.get(4)?,
+            })
+        })
+        .map_err(|e| e.to_string())?
+        .collect::<Result<Vec<_>, _>>()
+        .map_err(|e| e.to_string())?;
+
+    let mut sh_stmt = db
+        .prepare(
+            "SELECT job_id, started_at_ms, finished_at_ms, exit_code, cmd
+             FROM sh_events WHERE run_id = ?1
+             ORDER BY job_id, started_at_ms",
+        )
+        .map_err(|e| e.to_string())?;
+
+    let sh_events = sh_stmt
+        .query_map(rusqlite::params![run_id], |row| {
+            Ok(ShEvent {
+                job_id: row.get(0)?,
+                started_at_ms: row.get(1)?,
+                finished_at_ms: row.get(2)?,
+                exit_code: row.get(3)?,
+                cmd: row.get(4)?,
+            })
+        })
+        .map_err(|e| e.to_string())?
+        .collect::<Result<Vec<_>, _>>()
+        .map_err(|e| e.to_string())?;
+
+    Ok((run, jobs, sh_events))
+}
+
+/// Determine the 1-based sh index for an event within its job.
+pub fn sh_index_for_event(events: &[ShEvent], job_id: &str, event_idx: usize) -> usize {
+    let mut n = 0;
+    for (i, ev) in events.iter().enumerate() {
+        if ev.job_id == job_id && i <= event_idx {
+            n += 1;
+        }
+    }
+    n
+}
+
+/// Resolve a URL slug to the on-disk repo name.
+///
+/// URLs use clean names (`foo`), disk/DB use `foo.git`.
+pub fn resolve_repo_name(slug: &str) -> String {
+    if slug.ends_with(".git") {
+        slug.to_string()
+    } else {
+        format!("{slug}.git")
+    }
+}
diff --git a/src/quire/web/format.rs b/src/quire/web/format.rs
new file mode 100644
index 0000000..e191eba
--- /dev/null
+++ b/src/quire/web/format.rs
@@ -0,0 +1,79 @@
+//! Formatting helpers for the web view.
+
+/// Relative time display (e.g. "3m ago", "2h ago", ISO if older than 24h).
+pub fn format_timestamp_relative(ms: i64) -> String {
+    use jiff::Timestamp;
+    match Timestamp::from_millisecond(ms) {
+        Ok(ts) => {
+            let now = Timestamp::now();
+            let span = now.since(ts).unwrap_or_else(|_| jiff::Span::new());
+            let hours = span.get_hours().abs();
+            let minutes = span.get_minutes().abs();
+            if hours < 1 {
+                if minutes < 1 {
+                    "just now".to_string()
+                } else {
+                    format!("{minutes}m ago")
+                }
+            } else if hours < 24 {
+                format!("{hours}h ago")
+            } else {
+                ts.to_string()
+            }
+        }
+        Err(_) => format!("{ms}ms"),
+    }
+}
+
+/// ISO timestamp for title attributes.
+pub fn format_timestamp_iso(ms: i64) -> String {
+    use jiff::Timestamp;
+    Timestamp::from_millisecond(ms)
+        .map(|ts| ts.to_string())
+        .unwrap_or_else(|_| format!("{ms}ms"))
+}
+
+/// Duration between optional start/end millisecond timestamps.
+pub fn format_duration(start: Option<i64>, end: Option<i64>) -> String {
+    match (start, end) {
+        (Some(s), Some(e)) => {
+            let ms = e - s;
+            if ms < 1000 {
+                format!("{ms}ms")
+            } else {
+                format!("{}s", ms / 1000)
+            }
+        }
+        _ => "—".to_string(),
+    }
+}
+
+/// Duration between exact start/end millisecond timestamps.
+pub fn format_duration_exact(start: i64, end: i64) -> String {
+    let ms = end - start;
+    if ms < 1000 {
+        format!("{ms}ms")
+    } else {
+        format!("{}s", ms / 1000)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn format_duration_shows_ms_for_subsecond() {
+        assert_eq!(format_duration(Some(0), Some(500)), "500ms");
+    }
+
+    #[test]
+    fn format_duration_shows_seconds() {
+        assert_eq!(format_duration(Some(0), Some(3500)), "3s");
+    }
+
+    #[test]
+    fn format_duration_dash_when_missing() {
+        assert_eq!(format_duration(None, None), "—");
+    }
+}
diff --git a/src/quire/web/handlers.rs b/src/quire/web/handlers.rs
new file mode 100644
index 0000000..fff7e7e
--- /dev/null
+++ b/src/quire/web/handlers.rs
@@ -0,0 +1,160 @@
+//! Route handlers for the web view.
+
+use askama::Template;
+use axum::extract::{Path as AxumPath, State};
+use axum::response::Html;
+
+use super::auth::RemoteUser;
+use super::db;
+use super::templates::*;
+use crate::Quire;
+
+pub async fn run_list(
+    State(quire): State<Quire>,
+    AxumPath(repo): AxumPath<String>,
+    user: RemoteUser,
+) -> Html<String> {
+    let _user = user;
+    let repo_display = repo.trim_end_matches(".git").to_string();
+    let repo_name = db::resolve_repo_name(&repo);
+
+    let runs = match db::load_runs(&quire, &repo_name) {
+        Ok(r) => r,
+        Err(e) => {
+            tracing::error!(repo = %repo, error = %e, "failed to load runs");
+            let tmpl = ErrorTemplate {
+                repo: repo_display,
+                page: "error".to_string(),
+                title: "Failed to load runs".to_string(),
+                detail: e,
+            };
+            return Html(tmpl.render().unwrap_or_default());
+        }
+    };
+
+    let template_runs: Vec<RunListRow> = runs
+        .into_iter()
+        .map(|r| RunListRow {
+            id: r.id,
+            state: r.state,
+            sha: r.sha,
+            ref_name: r.ref_name,
+            queued_at_ms: r.queued_at_ms,
+            started_at_ms: r.started_at_ms,
+            finished_at_ms: r.finished_at_ms,
+        })
+        .collect();
+
+    let tmpl = RunListTemplate {
+        repo: repo_display,
+        page: "ci".to_string(),
+        runs: template_runs,
+    };
+    Html(tmpl.render().unwrap_or_default())
+}
+
+pub async fn run_detail(
+    State(quire): State<Quire>,
+    AxumPath((repo, run_id)): AxumPath<(String, String)>,
+    user: RemoteUser,
+) -> Html<String> {
+    let _user = user;
+    let repo_display = repo.trim_end_matches(".git").to_string();
+    let repo_name = db::resolve_repo_name(&repo);
+
+    let result = db::load_run_detail(&quire, &repo_name, &run_id);
+    let (run, jobs, sh_events) = match result {
+        Ok(d) => d,
+        Err(e) => {
+            tracing::error!(repo = %repo, run_id = %run_id, error = %e, "failed to load run detail");
+            let tmpl = ErrorTemplate {
+                repo: repo_display,
+                page: "error".to_string(),
+                title: "Failed to load run".to_string(),
+                detail: e,
+            };
+            return Html(tmpl.render().unwrap_or_default());
+        }
+    };
+
+    let detail_run = DetailRun {
+        state: run.state,
+        sha: run.sha,
+        ref_name: run.ref_name,
+        queued_at_ms: run.queued_at_ms,
+        started_at_ms: run.started_at_ms,
+        finished_at_ms: run.finished_at_ms,
+    };
+
+    // Load CRI log contents for each sh event.
+    let runs_base = quire.base_dir().join("runs").join(&repo_name);
+    let mut log_contents: std::collections::HashMap<(String, usize), String> =
+        std::collections::HashMap::new();
+    for (idx, ev) in sh_events.iter().enumerate() {
+        let sh_n = db::sh_index_for_event(&sh_events, &ev.job_id, idx);
+        let key = (ev.job_id.clone(), sh_n);
+        if log_contents.contains_key(&key) {
+            continue;
+        }
+        let log_path = runs_base
+            .join(&run_id)
+            .join("jobs")
+            .join(&ev.job_id)
+            .join(format!("sh-{sh_n}.log"));
+        if log_path.exists() {
+            match fs_err::read_to_string(&log_path) {
+                Ok(content) => {
+                    log_contents.insert(key, content);
+                }
+                Err(e) => {
+                    tracing::warn!(path = %log_path.display(), error = %e, "failed to read CRI log");
+                }
+            }
+        }
+    }
+
+    let mut detail_jobs: Vec<DetailJob> = Vec::new();
+    for job in &jobs {
+        let job_shs: Vec<(usize, &db::ShEvent)> = sh_events
+            .iter()
+            .enumerate()
+            .filter(|(_, e)| e.job_id == job.job_id)
+            .collect();
+
+        let mut detail_sh_events: Vec<DetailShEvent> = Vec::new();
+        for (global_idx, ev) in &job_shs {
+            let sh_n = db::sh_index_for_event(&sh_events, &ev.job_id, *global_idx);
+
+            let log = log_contents
+                .get(&(ev.job_id.clone(), sh_n))
+                .cloned()
+                .unwrap_or_default();
+
+            detail_sh_events.push(DetailShEvent {
+                index: sh_n,
+                started_at_ms: ev.started_at_ms,
+                finished_at_ms: ev.finished_at_ms,
+                exit_code: ev.exit_code,
+                cmd: ev.cmd.clone(),
+                log_content: log,
+            });
+        }
+
+        detail_jobs.push(DetailJob {
+            job_id: job.job_id.clone(),
+            state: job.state.clone(),
+            exit_code: job.exit_code,
+            started_at_ms: job.started_at_ms,
+            finished_at_ms: job.finished_at_ms,
+            sh_events: detail_sh_events,
+        });
+    }
+
+    let tmpl = RunDetailTemplate {
+        repo: repo_display,
+        page: format!("ci · {}", detail_run.sha_short()),
+        run: detail_run,
+        jobs: detail_jobs,
+    };
+    Html(tmpl.render().unwrap_or_default())
+}
diff --git a/src/quire/web/mod.rs b/src/quire/web/mod.rs
new file mode 100644
index 0000000..4bf7838
--- /dev/null
+++ b/src/quire/web/mod.rs
@@ -0,0 +1,29 @@
+//! Read-only CI web view.
+//!
+//! Two pages:
+//! - `GET /<name>/ci` — most-recent runs for a repo.
+//! - `GET /<name>/ci/<run-id>` — per-run detail with jobs and logs.
+//!
+//! Server-rendered HTML via Askama templates. JavaScript-optional.
+
+pub mod auth;
+pub mod db;
+pub mod format;
+pub mod handlers;
+pub mod templates;
+
+use axum::middleware;
+
+use crate::Quire;
+
+pub fn router(quire: Quire) -> axum::Router {
+    let ci_routes = axum::Router::new()
+        .route("/{repo}/ci", axum::routing::get(handlers::run_list))
+        .route(
+            "/{repo}/ci/{run_id}",
+            axum::routing::get(handlers::run_detail),
+        )
+        .layer(middleware::from_fn(auth::require_auth));
+
+    ci_routes.with_state(quire)
+}
diff --git a/src/quire/web/templates.rs b/src/quire/web/templates.rs
new file mode 100644
index 0000000..b37488e
--- /dev/null
+++ b/src/quire/web/templates.rs
@@ -0,0 +1,200 @@
+//! Askama template structs and their formatting methods.
+
+use askama::Template;
+
+use super::format;
+
+// ── Run list ───────────────────────────────────────────────────────
+
+#[derive(Template)]
+#[template(path = "ci/run_list.html")]
+pub struct RunListTemplate {
+    pub repo: String,
+    pub page: String,
+    pub runs: Vec<RunListRow>,
+}
+
+pub struct RunListRow {
+    pub id: String,
+    pub state: String,
+    pub sha: String,
+    pub ref_name: String,
+    pub queued_at_ms: i64,
+    pub started_at_ms: Option<i64>,
+    pub finished_at_ms: Option<i64>,
+}
+
+impl RunListRow {
+    pub fn state_class(&self) -> &str {
+        match self.state.as_str() {
+            "complete" => "c-ok",
+            "failed" => "c-bad",
+            _ => "c-muted",
+        }
+    }
+
+    pub fn sha_short(&self) -> &str {
+        &self.sha[..self.sha.len().min(8)]
+    }
+
+    pub fn ref_short(&self) -> &str {
+        self.ref_name.trim_start_matches("refs/heads/")
+    }
+
+    pub fn queued_relative(&self) -> String {
+        format::format_timestamp_relative(self.queued_at_ms)
+    }
+
+    pub fn queued_iso(&self) -> String {
+        format::format_timestamp_iso(self.queued_at_ms)
+    }
+
+    pub fn duration_display(&self) -> String {
+        format::format_duration(self.started_at_ms, self.finished_at_ms)
+    }
+}
+
+// ── Run detail ─────────────────────────────────────────────────────
+
+#[derive(Template)]
+#[template(path = "ci/run_detail.html")]
+pub struct RunDetailTemplate {
+    pub repo: String,
+    pub page: String,
+    pub run: DetailRun,
+    pub jobs: Vec<DetailJob>,
+}
+
+pub struct DetailRun {
+    pub state: String,
+    pub sha: String,
+    pub ref_name: String,
+    pub queued_at_ms: i64,
+    pub started_at_ms: Option<i64>,
+    pub finished_at_ms: Option<i64>,
+}
+
+impl DetailRun {
+    pub fn state_class(&self) -> &str {
+        match self.state.as_str() {
+            "complete" => "c-ok",
+            "failed" => "c-bad",
+            _ => "c-muted",
+        }
+    }
+
+    pub fn sha_short(&self) -> &str {
+        &self.sha[..self.sha.len().min(8)]
+    }
+
+    pub fn ref_short(&self) -> &str {
+        self.ref_name.trim_start_matches("refs/heads/")
+    }
+
+    pub fn queued_relative(&self) -> String {
+        format::format_timestamp_relative(self.queued_at_ms)
+    }
+
+    pub fn queued_iso(&self) -> String {
+        format::format_timestamp_iso(self.queued_at_ms)
+    }
+
+    pub fn started_display(&self) -> String {
+        self.started_at_ms
+            .map(format::format_timestamp_relative)
+            .unwrap_or_else(|| "—".to_string())
+    }
+
+    pub fn started_iso(&self) -> String {
+        self.started_at_ms
+            .map(format::format_timestamp_iso)
+            .unwrap_or_default()
+    }
+
+    pub fn has_started(&self) -> bool {
+        self.started_at_ms.is_some()
+    }
+
+    pub fn finished_display(&self) -> String {
+        self.finished_at_ms
+            .map(format::format_timestamp_relative)
+            .unwrap_or_else(|| "—".to_string())
+    }
+
+    pub fn finished_iso(&self) -> String {
+        self.finished_at_ms
+            .map(format::format_timestamp_iso)
+            .unwrap_or_default()
+    }
+
+    pub fn has_finished(&self) -> bool {
+        self.finished_at_ms.is_some()
+    }
+
+    pub fn duration_display(&self) -> String {
+        format::format_duration(self.started_at_ms, self.finished_at_ms)
+    }
+}
+
+pub struct DetailJob {
+    pub job_id: String,
+    pub state: String,
+    pub exit_code: Option<i32>,
+    pub started_at_ms: Option<i64>,
+    pub finished_at_ms: Option<i64>,
+    pub sh_events: Vec<DetailShEvent>,
+}
+
+impl DetailJob {
+    pub fn state_class(&self) -> &str {
+        match self.state.as_str() {
+            "complete" => "c-ok",
+            "failed" => "c-bad",
+            _ => "c-muted",
+        }
+    }
+
+    pub fn duration_display(&self) -> String {
+        format::format_duration(self.started_at_ms, self.finished_at_ms)
+    }
+
+    pub fn exit_display(&self) -> String {
+        self.exit_code
+            .map(|c| format!(" · exit {c}"))
+            .unwrap_or_default()
+    }
+}
+
+pub struct DetailShEvent {
+    pub index: usize,
+    pub started_at_ms: i64,
+    pub finished_at_ms: i64,
+    pub exit_code: i32,
+    pub cmd: String,
+    pub log_content: String,
+}
+
+impl DetailShEvent {
+    pub fn duration_display(&self) -> String {
+        format::format_duration_exact(self.started_at_ms, self.finished_at_ms)
+    }
+
+    pub fn cmd_display(&self) -> &str {
+        if self.cmd.len() > 120 {
+            &self.cmd[..120]
+        } else {
+            &self.cmd
+        }
+    }
+}
+
+// ── Error ──────────────────────────────────────────────────────────
+
+#[derive(Template)]
+#[template(path = "error.html")]
+pub struct ErrorTemplate {
+    pub repo: String,
+    pub page: String,
+    pub title: String,
+    pub detail: String,
+}