Work in progress atmosphere stats viewer
1

Configure Feed

Select the types of activity you want to include in your feed.

feat: initial operations and dids on graph

+142 -24
+4
Cargo.lock
··· 404 404 checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" 405 405 dependencies = [ 406 406 "iana-time-zone", 407 + "js-sys", 407 408 "num-traits", 409 + "wasm-bindgen", 408 410 "windows-link", 409 411 ] 410 412 ··· 587 589 dependencies = [ 588 590 "arrow", 589 591 "cast", 592 + "chrono", 590 593 "comfy-table", 591 594 "fallible-iterator", 592 595 "fallible-streaming-iterator", ··· 2357 2360 version = "0.1.0" 2358 2361 dependencies = [ 2359 2362 "axum", 2363 + "chrono", 2360 2364 "color-eyre", 2361 2365 "duckdb", 2362 2366 "r2d2",
+22 -14
src/routes/plc/+page.svelte
··· 1 1 <script lang="ts"> 2 - import { getCounts, type Counts } from './plc.remote'; 2 + import { getCounts, getCountsGraph, type Counts } from './plc.remote'; 3 3 import LucideLoader from '~icons/lucide/loader'; 4 + import { LineChart } from 'layerchart'; 4 5 5 6 const formatter = new Intl.NumberFormat(); 7 + const countsGraph = getCountsGraph(); 6 8 const counts = getCounts(); 7 9 </script> 8 10 ··· 49 51 </div> 50 52 </div> 51 53 52 - <!-- <LineChart 53 - data={counts.map((c) => { 54 - c.date = new Date(c.date); 55 - return c; 56 - })} 57 - x="date" 58 - y="count" 59 - height={300} 60 - legend 61 - transform={{ mode: 'domain', axis: 'x' }} 62 - tooltipContext={{ mode: 'bisect-x' }} 63 - style="stroke-width: 1.5;" 64 - /> --> 54 + {#if countsGraph.loading} 55 + <p>Loading...</p> 56 + {:else if countsGraph.current} 57 + <LineChart 58 + data={countsGraph.current} 59 + x="date" 60 + height={300} 61 + series={[ 62 + { key: 'operations', label: 'Operations', color: 'var(--orange)' }, 63 + { key: 'dids', label: 'Dids', color: 'var(--green)' }, 64 + ]} 65 + legend 66 + transform={{ mode: 'domain', axis: 'x' }} 67 + tooltipContext={{ mode: 'bisect-x' }} 68 + style="stroke-width: 1.5;" 69 + /> 70 + {:else if countsGraph.error} 71 + <p>{countsGraph.error}</p> 72 + {/if} 65 73 66 74 <!-- <PieChart data={groupedServices} key="service" value="count" height={300} /> --> 67 75
+15
src/routes/plc/plc.remote.ts
··· 22 22 await new Promise((resolve) => setTimeout(resolve, 10_000)); 23 23 } 24 24 }); 25 + 26 + interface CountsGraphed { 27 + date: string; 28 + dids: number; 29 + operations: number; 30 + } 31 + 32 + export const getCountsGraph = query(async () => { 33 + const raw = await grab<CountsGraphed[]>('/v0/counts/history'); 34 + 35 + return raw.map((c) => { 36 + c.date = new Date(c.date); 37 + return c; 38 + }); 39 + });
+2 -1
uplc/Cargo.toml
··· 5 5 6 6 [dependencies] 7 7 color-eyre = "0.6.5" 8 - duckdb = { version = "1.10505.0", features = ["r2d2"] } 8 + duckdb = { version = "1.10505.0", features = ["chrono", "r2d2"] } 9 9 reqwest = "0.13.4" 10 10 serde = { version = "1.0.229", features = ["derive"] } 11 11 serde_json = "1.0.151" 12 12 tokio = { version = "1.53.1", features = ["full"] } 13 13 axum = "0.8.1" 14 14 r2d2 = "0.8.10" 15 + chrono = "0.4.45"
+99 -9
uplc/src/api.rs
··· 5 5 routing::get, 6 6 }; 7 7 use color_eyre::eyre::Result; 8 - use serde_json::json; 8 + use serde_json::{Value, json}; 9 9 use tokio::net::TcpListener; 10 10 11 11 struct AppError(color_eyre::eyre::Error); ··· 22 22 } 23 23 } 24 24 25 - fn get_counts(db: &DB) -> Result<Json<serde_json::Value>> { 25 + fn get_counts(db: &DB) -> Result<Json<Value>> { 26 26 Ok(db 27 27 .handle()? 28 28 .prepare("SELECT count(DISTINCT did) as dids, count(*) as operations FROM operations")? ··· 34 34 })?) 35 35 } 36 36 37 + /// Returns ~100 cumulative datapoints spanning the entire recorded history. 38 + /// 39 + /// The full time range is divided into 100 equal-width buckets; each point 40 + /// reports the cumulative distinct-DID count and total operation count up to 41 + /// that moment. When the table is empty the endpoint returns an empty array. 42 + /// 43 + /// (this SQL is unreviewed clanker output, this type of data work is above my pay grade rn) 44 + fn get_counts_history(db: &DB) -> Result<Json<Vec<Value>>> { 45 + let conn = db.handle()?; 46 + let mut stmt = conn.prepare( 47 + r#" 48 + WITH 49 + time_range AS ( 50 + SELECT 51 + MIN(created_at) AS start_time, 52 + MAX(created_at) AS end_time, 53 + (epoch(MAX(created_at)) - epoch(MIN(created_at))) 54 + / 99.0 * INTERVAL '1' SECOND AS bucket_interval 55 + FROM operations 56 + HAVING COUNT(*) > 0 57 + ), 58 + -- Bucket every operation; flag the first occurrence of each DID 59 + bucketed AS ( 60 + SELECT 61 + time_bucket(tr.bucket_interval, created_at, tr.start_time) AS bucket_time, 62 + ROW_NUMBER() OVER (PARTITION BY did ORDER BY created_at) = 1 AS is_new_did 63 + FROM operations, time_range tr 64 + ), 65 + -- Per-bucket deltas for both metrics in one pass 66 + bucket_stats AS ( 67 + SELECT 68 + bucket_time, 69 + COUNT(*) FILTER (WHERE is_new_did) AS new_dids, 70 + COUNT(*) AS new_ops 71 + FROM bucketed 72 + GROUP BY ALL 73 + ), 74 + -- Full 100-point series so empty buckets are preserved 75 + all_buckets AS ( 76 + SELECT range AS bucket_time 77 + FROM time_range, 78 + range(time_range.start_time, 79 + time_range.end_time + time_range.bucket_interval, 80 + time_range.bucket_interval) 81 + LIMIT 100 82 + ) 83 + SELECT 84 + strftime(bt.bucket_time, '%Y-%m-%dT%H:%M:%SZ') AS date, 85 + SUM(COALESCE(bs.new_dids, 0)) OVER ( 86 + ORDER BY bt.bucket_time 87 + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW 88 + ) AS dids, 89 + SUM(COALESCE(bs.new_ops, 0)) OVER ( 90 + ORDER BY bt.bucket_time 91 + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW 92 + ) AS operations 93 + FROM all_buckets bt 94 + LEFT JOIN bucket_stats bs ON bs.bucket_time = bt.bucket_time 95 + ORDER BY bt.bucket_time 96 + "#, 97 + )?; 98 + 99 + let rows = stmt.query_map([], |row| { 100 + let date: String = row.get(0)?; 101 + let dids: i64 = row.get(1)?; 102 + let operations: i64 = row.get(2)?; 103 + Ok((date, dids, operations)) 104 + })?; 105 + 106 + let mut results: Vec<Value> = Vec::with_capacity(100); 107 + for row in rows { 108 + let (date, dids, operations) = row?; 109 + results.push(json!({ 110 + "date": date, 111 + "dids": dids, 112 + "operations": operations, 113 + })); 114 + } 115 + 116 + Ok(Json(results)) 117 + } 118 + 37 119 pub async fn run_api(db: DB) -> Result<()> { 38 - let app = Router::new().route( 39 - "/v0/counts", 40 - get({ 41 - let db = db.clone(); 42 - move || async move { get_counts(&db).map_err(AppError::from) } 43 - }), 44 - ); 120 + let app = Router::new() 121 + .route( 122 + "/v0/counts", 123 + get({ 124 + let db = db.clone(); 125 + move || async move { get_counts(&db).map_err(AppError::from) } 126 + }), 127 + ) 128 + .route( 129 + "/v0/counts/history", 130 + get({ 131 + let db = db.clone(); 132 + move || async move { get_counts_history(&db).map_err(AppError::from) } 133 + }), 134 + ); 45 135 46 136 let listener = TcpListener::bind("127.0.0.1:7712").await?; 47 137 println!("HTTP server listening on http://127.0.0.1:7712");