diff --git a/.sqlx/query-110727bf8db2b15e50a755397eef76df66ef4963fe7dd8e6818d778de132ab7c.json b/.sqlx/query-bf7b19943512e8e3f242005934bec0b8b50553270e00decb2e849eda31eaca03.json similarity index 80% rename from .sqlx/query-110727bf8db2b15e50a755397eef76df66ef4963fe7dd8e6818d778de132ab7c.json rename to .sqlx/query-bf7b19943512e8e3f242005934bec0b8b50553270e00decb2e849eda31eaca03.json index b0576dd..68560c3 100644 --- a/.sqlx/query-110727bf8db2b15e50a755397eef76df66ef4963fe7dd8e6818d778de132ab7c.json +++ b/.sqlx/query-bf7b19943512e8e3f242005934bec0b8b50553270e00decb2e849eda31eaca03.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT *\n FROM Checkins\n WHERE page_id = $1\n ORDER BY created_at DESC\n LIMIT 10\n ", + "query": "\n SELECT *\n FROM Checkins\n WHERE page_id = $1\n AND created_at >= now() - INTERVAL '6 hours'\n AND duration_nanos is not null\n ORDER BY created_at DESC\n ", "describe": { "columns": [ { @@ -48,5 +48,5 @@ true ] }, - "hash": "110727bf8db2b15e50a755397eef76df66ef4963fe7dd8e6818d778de132ab7c" + "hash": "bf7b19943512e8e3f242005934bec0b8b50553270e00decb2e849eda31eaca03" } diff --git a/src/routes/current_user/pages.rs b/src/routes/current_user/pages.rs index f27b69b..f928b77 100644 --- a/src/routes/current_user/pages.rs +++ b/src/routes/current_user/pages.rs @@ -5,8 +5,10 @@ use axum::{ response::{IntoResponse, Redirect}, Form, }; +use chrono::{DateTime, Utc}; use cja::{app_state::AppState as _, server::session::DBSession}; -use maud::html; +use maud::{html, Render}; +use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::{app_state::AppState, templates::IntoTemplate}; @@ -103,13 +105,15 @@ pub async fn show( .await .unwrap(); - let checkins = sqlx::query!( + let checkins = sqlx::query_as!( + Checkin, r#" SELECT * FROM Checkins WHERE page_id = $1 + AND created_at >= now() - INTERVAL '6 hours' + AND duration_nanos is not null ORDER BY created_at DESC - LIMIT 10 "#, page_id ) @@ -117,6 +121,12 @@ pub async fn show( .await .unwrap(); + let mut checkins_for_graph = checkins.clone(); + checkins_for_graph.reverse(); + let graph = CheckinGraph { + checkins: checkins_for_graph, + }; + html! { h1 { (page.name) } @@ -124,6 +134,8 @@ pub async fn show( h2 { "Checkins" } + (graph) + ul { @for checkin in checkins { li { @@ -143,3 +155,142 @@ pub async fn show( .await .unwrap() } + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct Checkin { + checkin_id: Uuid, + page_id: Uuid, + outcome: String, + status_code: Option, + duration_nanos: Option, + created_at: DateTime, +} + +struct CheckinGraph { + checkins: Vec, +} + +struct GraphPoint { + x: f64, + y: f64, + label: String, +} + +struct YAxisLine { + width: usize, + y_pos: usize, + label: String, +} + +impl Render for YAxisLine { + fn render(&self) -> maud::Markup { + let Self { + width, + y_pos, + label, + } = self; + + html! { + path d=(format!("M0 {0} L{width} {0}", y_pos)) fill="none" stroke="blue" stroke-dasharray="2" stroke-width="0.25" {} + text x=(0) y=(y_pos) font-size="5" fill="blue" { (label) } + } + } +} + +impl Render for CheckinGraph { + fn render(&self) -> maud::Markup { + let full_height = 100; + let height_padding = 10; + + let width = 200; + let height = full_height - height_padding * 2; + + let min_time = self.checkins.iter().map(|p| p.created_at).min().unwrap(); + let max_time = self.checkins.iter().map(|p| p.created_at).max().unwrap(); + + let total_time = max_time - min_time; + let total_time = total_time.to_std().unwrap(); + + let min_duration_nanos = self + .checkins + .iter() + .map(|p| p.duration_nanos.unwrap()) + .min() + .unwrap() + / 1_000_000 + * 1_000_000; + let min_label = + humantime::format_duration(Duration::from_nanos(min_duration_nanos as u64)).to_string(); + + let max_duration_nanos = self + .checkins + .iter() + .map(|p| p.duration_nanos.unwrap()) + .max() + .unwrap() + / 1_000_000 + * 1_000_000; + let max_label = + humantime::format_duration(Duration::from_nanos(max_duration_nanos as u64)).to_string(); + + let height_range = max_duration_nanos - min_duration_nanos; + + let points = self + .checkins + .iter() + .map(|p| GraphPoint { + x: (((p.created_at - min_time).to_std().unwrap().as_nanos() as f64 + / total_time.as_nanos() as f64) + * width as f64), + y: ((full_height - height_padding) as f64) + - (((p.duration_nanos.unwrap() as f64 - min_duration_nanos as f64) + / height_range as f64) + * height as f64), + label: humantime::format_duration(Duration::from_nanos( + p.duration_nanos.unwrap() as u64 + )) + .to_string(), + }) + .collect::>(); + + let svg_path = points + .iter() + .enumerate() + .map(|(i, GraphPoint { x, y, .. })| { + if i == 0 { + format!("M{x} {y}") + } else { + format!("L{x} {y}") + } + }) + .collect::>() + .join(" "); + + html! { + svg class="w-full" viewBox="0 0 200 100" { + + // y max line + (YAxisLine { width, y_pos: height_padding, label: format!("Max: {max_label}")}) + + // y max line + (YAxisLine { width, y_pos: full_height - height_padding, label: format!("Min: {min_label}")}) + + // This is the actual point line + path d=(svg_path) fill="none" stroke="black" {} + + @for GraphPoint { x, y, label } in points.iter() { + // Group for Hover State + g class="group" { + // Invisible Circle to make hover state bigger + circle cx=(x) cy=(y) r=(4) class="fill-transparent stroke-transparent" {} + // Point on line + circle cx=(x) cy=(y) r=(2) class="group-hover:fill-red-500" {} + + // Label, hidden till group hover + text x=(x) y=(y + 5.0) font-size=4 class="hidden group-hover:block fill-red-500" { (label) } + } + } + } + } + } +}