search for standard sites pub-search.waow.tech
search zig blog atproto
11
fork

Configure Feed

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

dashboard: simplify styling, add favicon, reorganize stack

- simplified dashboard to match main site aesthetic
- removed overwrought terminal effects (CRT lines, box-drawing)
- added bar chart favicon for stats page
- moved tap primary_region from ewr to iad
- extracted dashboard data fetching to dashboard_data.zig
- removed backend/notes directory
- reordered stack in README

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

zzstoatzz 49e8bedc 420ceb12

+359 -536
+3 -3
README.md
··· 25 25 26 26 ## stack 27 27 28 - - [Zig](https://ziglang.org) backend 29 - - [Tap](https://github.com/bluesky-social/indigo/tree/main/cmd/tap) for sync 30 - - [Turso](https://turso.tech) for SQLite + FTS5 31 28 - [Fly.io](https://fly.io) for hosting 29 + - [Turso](https://turso.tech) for SQLite + FTS5 30 + - [Tap](https://github.com/bluesky-social/indigo/tree/main/cmd/tap) for sync 31 + - [Zig](https://ziglang.org) backend 32 32 - [Cloudflare Pages](https://pages.cloudflare.com) for frontend
-221
backend/notes/libsql-zig-design.md
··· 1 - # libsql-zig design sketch 2 - 3 - a zig client for turso/libsql with nice ergonomics and comptime validation. 4 - 5 - ## API sketch 6 - 7 - ### basic usage 8 - 9 - ```zig 10 - const db = try libsql.connect("libsql://mydb.turso.io", token); 11 - 12 - // simple query with positional args 13 - var result = try db.query("SELECT * FROM users WHERE id = ?", .{42}); 14 - defer result.deinit(); 15 - 16 - for (result.rows()) |row| { 17 - const name = row.get("name", .string); 18 - const age = row.get("age", .int); 19 - } 20 - ``` 21 - 22 - ### named parameters 23 - 24 - ```zig 25 - // named params - comptime validates struct fields match :placeholders 26 - try db.exec( 27 - "INSERT INTO users (name, age) VALUES (:name, :age)", 28 - .{ .name = "bob", .age = 30 }, 29 - ); 30 - 31 - // comptime error if you typo a param name: 32 - try db.exec( 33 - "INSERT INTO users (name, age) VALUES (:name, :age)", 34 - .{ .naem = "bob", .age = 30 }, // error: param :naem not found in SQL 35 - ); 36 - ``` 37 - 38 - ### struct mapping 39 - 40 - ```zig 41 - const User = struct { 42 - id: i64, 43 - name: []const u8, 44 - age: ?i64, // nullable 45 - }; 46 - 47 - // query directly into structs 48 - const users = try db.queryAs(User, "SELECT id, name, age FROM users", .{}); 49 - defer users.deinit(); 50 - 51 - for (users.items) |user| { 52 - std.debug.print("{}: {s}, {?}\n", .{ user.id, user.name, user.age }); 53 - } 54 - 55 - // comptime validates struct fields exist (if we parse SELECT columns) 56 - // or runtime validation against response column names 57 - ``` 58 - 59 - ### transactions 60 - 61 - ```zig 62 - // turso pipeline API supports batched statements 63 - try db.transaction(.{}, struct { 64 - fn run(tx: *Transaction) !void { 65 - try tx.exec("INSERT INTO users (name) VALUES (?)", .{"alice"}); 66 - try tx.exec("INSERT INTO logs (msg) VALUES (?)", .{"created alice"}); 67 - } 68 - }.run); 69 - // auto-rollback on error, auto-commit on success 70 - ``` 71 - 72 - ### connection options 73 - 74 - ```zig 75 - const db = try libsql.connect(.{ 76 - .url = "libsql://mydb.turso.io", 77 - .token = token, 78 - .timeout_ms = 5000, 79 - .retry_count = 3, 80 - }); 81 - ``` 82 - 83 - ## comptime features 84 - 85 - ### 1. parameter count (already have this) 86 - ```zig 87 - db.query("SELECT * FROM users WHERE id = ?", .{1, 2}); 88 - // error: SQL has 1 placeholders but 2 args provided 89 - ``` 90 - 91 - ### 2. named parameter validation 92 - ```zig 93 - fn query(comptime sql: []const u8, args: anytype) !Result { 94 - comptime { 95 - const placeholders = parseNamedPlaceholders(sql); // [":name", ":age"] 96 - const fields = @typeInfo(@TypeOf(args)).@"struct".fields; 97 - 98 - for (placeholders) |p| { 99 - if (!hasField(fields, p[1..])) { // strip leading ':' 100 - @compileError("param " ++ p ++ " not found in args struct"); 101 - } 102 - } 103 - } 104 - } 105 - ``` 106 - 107 - ### 3. struct field validation (partial) 108 - for `queryAs`, we can validate at comptime that the struct is well-formed: 109 - - all fields are valid SQL types (i64, []const u8, ?T for nullables) 110 - - no unsupported types 111 - 112 - full column name validation would require either: 113 - - parsing SELECT clause at comptime (doable but complex) 114 - - runtime validation against response cols (simpler, still catches bugs) 115 - 116 - ### 4. SQL syntax hints (stretch goal) 117 - basic comptime SQL parsing could catch obvious errors: 118 - - unclosed quotes 119 - - mismatched parens 120 - - obviously malformed statements 121 - 122 - not a full parser, just sanity checks. 123 - 124 - ## implementation notes 125 - 126 - ### turso HTTP API 127 - 128 - endpoint: `POST https://{host}/v2/pipeline` 129 - 130 - request format: 131 - ```json 132 - { 133 - "requests": [ 134 - {"type": "execute", "stmt": {"sql": "...", "args": [...]}}, 135 - {"type": "execute", "stmt": {"sql": "...", "args": [...]}}, 136 - {"type": "close"} 137 - ] 138 - } 139 - ``` 140 - 141 - response format: 142 - ```json 143 - { 144 - "results": [{ 145 - "response": { 146 - "type": "execute", 147 - "result": { 148 - "cols": [{"name": "id", "decltype": "INTEGER"}, ...], 149 - "rows": [[1, "bob", 30], ...] 150 - } 151 - } 152 - }] 153 - } 154 - ``` 155 - 156 - the `cols` array gives us column names and types at runtime - we use this for: 157 - - named column access: `row.get("name", .string)` 158 - - struct mapping validation 159 - - optional runtime type checking 160 - 161 - ### arg serialization 162 - 163 - turso args format: 164 - ```json 165 - {"args": [ 166 - {"type": "integer", "value": "42"}, 167 - {"type": "text", "value": "hello"}, 168 - {"type": "null"}, 169 - {"type": "blob", "base64": "..."} 170 - ]} 171 - ``` 172 - 173 - we need to map zig types to these: 174 - - `i64`, `u64`, etc → integer 175 - - `[]const u8` → text 176 - - `null`, `?T` when null → null 177 - - `[]const u8` (blob flag?) → blob 178 - 179 - ### named param parsing 180 - 181 - parse `:name` at comptime: 182 - ```zig 183 - fn parseNamedParams(comptime sql: []const u8) []const []const u8 { 184 - // find all :identifier patterns 185 - // return slice of param names 186 - } 187 - 188 - fn substituteParams(comptime sql: []const u8) []const u8 { 189 - // replace :name with ? for the actual query 190 - // "WHERE id = :id" → "WHERE id = ?" 191 - } 192 - ``` 193 - 194 - ## repo structure 195 - 196 - ``` 197 - libsql-zig/ 198 - ├── src/ 199 - │ ├── root.zig # public API 200 - │ ├── client.zig # HTTP client, connection management 201 - │ ├── query.zig # query building, param substitution 202 - │ ├── result.zig # result parsing, row access 203 - │ ├── types.zig # type mapping, serialization 204 - │ └── comptime/ 205 - │ ├── params.zig # named param parsing 206 - │ └── sql.zig # SQL parsing helpers 207 - ├── build.zig 208 - └── README.md 209 - ``` 210 - 211 - ## open questions 212 - 213 - 1. **naming**: `libsql-zig`? `turso-zig`? `zsql`? 214 - 215 - 2. **local libsql support**: turso also has an embedded mode. support that too, or HTTP-only? 216 - 217 - 3. **async**: zig's async is in flux. start with blocking, add async later? 218 - 219 - 4. **allocator strategy**: arena per query? caller provides? configurable? 220 - 221 - 5. **error handling**: rich error types with SQL context, or simple error union?
-203
backend/notes/sqlx-research.md
··· 1 - # sqlx research notes 2 - 3 - ## what sqlx (rust) does 4 - 5 - 1. **compile-time query validation** via procedural macros 6 - - `query!("SELECT * FROM users WHERE id = $1", user_id)` 7 - - at compile time, connects to DATABASE_URL and runs `PREPARE` 8 - - validates: syntax, column existence, parameter types 9 - - returns anonymous struct with typed fields matching columns 10 - 11 - 2. **offline mode** for CI/builds without DB 12 - - `cargo sqlx prepare` connects to DB, caches query metadata to `.sqlx/` 13 - - cached JSON files contain: query hash, column names, column types, param types 14 - - at compile time, macro reads from cache instead of live DB 15 - - `cargo sqlx prepare --check` validates cache is up-to-date 16 - 17 - 3. **query_as!** for named structs 18 - ```rust 19 - struct User { id: i64, name: String } 20 - let users = sqlx::query_as!(User, "SELECT id, name FROM users").fetch_all(&pool).await?; 21 - ``` 22 - 23 - ## what zig-sqlite does 24 - 25 - 1. **comptime parameter count checking** 26 - - parses SQL string at comptime to count `?` markers 27 - - validates args tuple length matches 28 - - compile error if mismatch 29 - 30 - 2. **optional type annotations** via custom syntax 31 - ```zig 32 - db.prepare("SELECT * FROM users WHERE age > ?{usize}") 33 - ``` 34 - - the `{usize}` is parsed at comptime 35 - - validates that bound value is correct type 36 - - compile error if type mismatch 37 - 38 - 3. **no schema validation** - doesn't connect to DB at compile time 39 - 40 - ## our current situation 41 - 42 - - turso HTTP API (not local sqlite) 43 - - no compile-time checking at all 44 - - manual JSON building in `turso.zig` 45 - - manual response parsing in `result.zig` 46 - - column access by index: `row.text(0)`, `row.int(1)` 47 - 48 - ## what we could build 49 - 50 - ### option A: comptime parameter checking (easy) 51 - 52 - add to turso.zig: 53 - ```zig 54 - pub fn query(comptime sql: []const u8, args: anytype) !Result { 55 - comptime { 56 - const expected = countPlaceholders(sql); 57 - const provided = @typeInfo(@TypeOf(args)).Struct.fields.len; 58 - if (expected != provided) { 59 - @compileError("wrong number of parameters"); 60 - } 61 - } 62 - // ... existing code 63 - } 64 - ``` 65 - 66 - pros: 67 - - catches "wrong number of args" at compile time 68 - - minimal effort 69 - - no external dependencies 70 - 71 - cons: 72 - - doesn't validate types 73 - - doesn't validate SQL syntax 74 - - doesn't validate column existence 75 - 76 - ### option B: comptime type annotations (medium) 77 - 78 - custom syntax like zig-sqlite: 79 - ```zig 80 - client.query( 81 - "SELECT * FROM users WHERE age > ?{i64} AND name = ?{text}", 82 - .{ age, name } 83 - ) 84 - ``` 85 - 86 - parse `?{type}` at comptime, validate args match. 87 - 88 - pros: 89 - - type safety for parameters 90 - - self-documenting queries 91 - 92 - cons: 93 - - non-standard SQL 94 - - still no schema validation 95 - 96 - ### option C: offline mode like sqlx (hard) 97 - 98 - 1. write CLI tool that: 99 - - connects to turso 100 - - finds all queries in codebase (grep for `client.query`) 101 - - runs each query with `EXPLAIN` or similar 102 - - caches column info to `sqlx-cache.json` 103 - 104 - 2. at comptime, read cache and generate typed result structs 105 - 106 - pros: 107 - - full type safety for results 108 - - validates against real schema 109 - 110 - cons: 111 - - requires CLI tool 112 - - need to re-run on schema changes 113 - - turso's HTTP API might not expose enough metadata 114 - - significant complexity 115 - 116 - ### option D: named parameters (easy ergonomic win) 117 - 118 - instead of: 119 - ```zig 120 - client.query("SELECT * FROM users WHERE id = ? AND age > ?", &.{id, age}) 121 - ``` 122 - 123 - allow: 124 - ```zig 125 - client.query("SELECT * FROM users WHERE id = :id AND age > :age", .{ .id = id, .age = age }) 126 - ``` 127 - 128 - at comptime, parse `:name` markers and match to struct field names. 129 - 130 - pros: 131 - - more readable 132 - - self-documenting 133 - - catches typos at compile time 134 - 135 - cons: 136 - - non-standard SQL (but common pattern) 137 - 138 - ## recommendation 139 - 140 - start with A + D: 141 - 1. comptime parameter count checking 142 - 2. named parameters with `:name` syntax 143 - 144 - these give us: 145 - - compile-time error for wrong arg count 146 - - compile-time error for misnamed parameters 147 - - more readable queries 148 - - minimal implementation effort 149 - 150 - then evaluate if we need B or C based on pain points. 151 - 152 - ## turso API notes 153 - 154 - turso HTTP API (`/v2/pipeline`) returns: 155 - ```json 156 - { 157 - "results": [{ 158 - "response": { 159 - "type": "execute", 160 - "result": { 161 - "cols": [{"name": "id", "decltype": "INTEGER"}, ...], 162 - "rows": [[1], [2], ...] 163 - } 164 - } 165 - }] 166 - } 167 - ``` 168 - 169 - the `cols` array has column metadata! we could potentially: 170 - - cache this on first query execution 171 - - use for runtime column name lookup 172 - - or fetch at build time for comptime generation 173 - 174 - ## implementation status 175 - 176 - ### option A: comptime parameter count checking ✓ 177 - 178 - implemented in `turso.zig`: 179 - 180 - ```zig 181 - pub fn query(self: *Client, comptime sql: []const u8, args: anytype) !Result { 182 - const expected = comptime countPlaceholders(sql); 183 - const provided = comptime countArgsType(@TypeOf(args)); 184 - if (expected != provided) { 185 - @compileError(std.fmt.comptimePrint( 186 - "SQL has {} placeholders but {} args provided", 187 - .{ expected, provided }, 188 - )); 189 - } 190 - // ... 191 - } 192 - ``` 193 - 194 - this gives compile-time errors like: 195 - ``` 196 - error: SQL has 1 placeholders but 2 args provided 197 - ``` 198 - 199 - ### next steps 200 - 201 - 1. ~~implement option A (parameter count checking)~~ ✓ 202 - 2. implement option D (named parameters) - if needed 203 - 3. evaluate if we need more based on pain points
+199 -96
backend/src/dashboard.zig
··· 1 1 const std = @import("std"); 2 + const dashboard_data = @import("dashboard_data.zig"); 2 3 3 - /// Generate dashboard HTML with live stats 4 - pub fn render( 5 - alloc: std.mem.Allocator, 6 - started_at: i64, 7 - searches: u64, 8 - errors: u64, 9 - documents: i64, 10 - publications: i64, 11 - tags_json: []const u8, 12 - ) ![]const u8 { 4 + pub const DashboardData = dashboard_data.DashboardData; 5 + 6 + /// Generate dashboard HTML with stats and charts 7 + pub fn render(alloc: std.mem.Allocator, data: DashboardData) ![]const u8 { 13 8 var buf: std.ArrayList(u8) = .{}; 14 9 const w = buf.writer(alloc); 15 10 ··· 19 14 \\<head> 20 15 \\ <meta charset="UTF-8"> 21 16 \\ <meta name="viewport" content="width=device-width, initial-scale=1.0"> 22 - \\ <title>leaflet search stats</title> 17 + \\ <title>leaflet search / stats</title> 18 + \\ <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect x='4' y='18' width='6' height='10' fill='%231B7340'/><rect x='13' y='12' width='6' height='16' fill='%231B7340'/><rect x='22' y='6' width='6' height='22' fill='%231B7340'/></svg>"> 23 19 \\ <style> 24 20 \\ * { box-sizing: border-box; margin: 0; padding: 0; } 25 21 \\ body { ··· 27 23 \\ background: #0a0a0a; 28 24 \\ color: #ccc; 29 25 \\ min-height: 100vh; 30 - \\ padding: 2rem; 26 + \\ padding: 1rem; 27 + \\ font-size: 14px; 31 28 \\ line-height: 1.6; 32 29 \\ } 33 - \\ .container { max-width: 800px; margin: 0 auto; } 34 - \\ h1 { font-size: 14px; color: #888; margin-bottom: 2rem; font-weight: normal; } 35 - \\ h1 a { color: #1B7340; text-decoration: none; } 36 - \\ h1 a:hover { color: #2a9d5c; } 37 - \\ h2 { font-size: 12px; color: #666; margin: 2rem 0 1rem; font-weight: normal; text-transform: uppercase; letter-spacing: 1px; } 38 - \\ .stats-grid { 39 - \\ display: grid; 40 - \\ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); 41 - \\ gap: 1rem; 42 - \\ margin-bottom: 2rem; 30 + \\ .container { max-width: 600px; margin: 0 auto; } 31 + \\ a { color: #1B7340; text-decoration: none; } 32 + \\ a:hover { color: #2a9d5c; } 33 + \\ h1 { 34 + \\ font-size: 12px; 35 + \\ font-weight: normal; 36 + \\ margin-bottom: 1.5rem; 37 + \\ } 38 + \\ h1 a.title { color: #888; } 39 + \\ h1 a.title:hover { color: #fff; } 40 + \\ h1 .dim { color: #555; } 41 + \\ section { margin-bottom: 2rem; } 42 + \\ .section-title { 43 + \\ font-size: 11px; 44 + \\ color: #555; 45 + \\ margin-bottom: 0.75rem; 46 + \\ } 47 + \\ .metrics { 48 + \\ display: flex; 49 + \\ gap: 2rem; 50 + \\ margin-bottom: 1.5rem; 51 + \\ } 52 + \\ .metric-value { 53 + \\ font-size: 24px; 54 + \\ color: #fff; 43 55 \\ } 44 - \\ .stat { 56 + \\ .metric-label { 57 + \\ font-size: 11px; 58 + \\ color: #555; 59 + \\ } 60 + \\ .chart-box { 45 61 \\ background: #111; 46 62 \\ border: 1px solid #222; 47 63 \\ padding: 1rem; 48 - \\ border-radius: 4px; 64 + \\ margin-bottom: 1rem; 49 65 \\ } 50 - \\ .stat-value { 51 - \\ font-size: 24px; 52 - \\ color: #fff; 53 - \\ margin-bottom: 0.25rem; 66 + \\ .chart-header { 67 + \\ display: flex; 68 + \\ justify-content: space-between; 69 + \\ font-size: 11px; 70 + \\ color: #666; 71 + \\ margin-bottom: 0.75rem; 54 72 \\ } 55 - \\ .stat-value.uptime { color: #2a9d5c; } 56 - \\ .stat-label { font-size: 11px; color: #666; } 57 - \\ .tags-grid { 73 + \\ .timeline { 74 + \\ display: flex; 75 + \\ align-items: flex-end; 76 + \\ gap: 2px; 77 + \\ height: 60px; 78 + \\ } 79 + \\ .bar { 80 + \\ flex: 1; 81 + \\ background: #1B7340; 82 + \\ min-height: 2px; 83 + \\ } 84 + \\ .bar:hover { background: #2a9d5c; } 85 + \\ .doc-row { 86 + \\ display: flex; 87 + \\ justify-content: space-between; 88 + \\ font-size: 12px; 89 + \\ padding: 0.25rem 0; 90 + \\ border-bottom: 1px solid #1a1a1a; 91 + \\ } 92 + \\ .doc-row:last-child { border-bottom: none; } 93 + \\ .doc-type { color: #888; } 94 + \\ .doc-count { color: #ccc; } 95 + \\ .pub-row { 96 + \\ display: flex; 97 + \\ justify-content: space-between; 98 + \\ font-size: 12px; 99 + \\ padding: 0.25rem 0; 100 + \\ border-bottom: 1px solid #1a1a1a; 101 + \\ } 102 + \\ .pub-row:last-child { border-bottom: none; } 103 + \\ .pub-name { color: #888; } 104 + \\ .pub-count { color: #666; } 105 + \\ .tags { 58 106 \\ display: flex; 59 107 \\ flex-wrap: wrap; 60 108 \\ gap: 0.5rem; 61 109 \\ } 62 110 \\ .tag { 111 + \\ font-size: 11px; 112 + \\ padding: 3px 8px; 63 113 \\ background: #151515; 64 114 \\ border: 1px solid #252525; 65 - \\ padding: 0.5rem 0.75rem; 66 - \\ border-radius: 4px; 67 - \\ font-size: 12px; 68 - \\ color: #888; 69 - \\ text-decoration: none; 115 + \\ border-radius: 3px; 116 + \\ color: #777; 117 + \\ } 118 + \\ .tag:hover { 119 + \\ background: #1a1a1a; 120 + \\ border-color: #333; 121 + \\ color: #aaa; 122 + \\ } 123 + \\ .tag .n { color: #444; margin-left: 4px; } 124 + \\ footer { 125 + \\ margin-top: 2rem; 126 + \\ padding-top: 1rem; 127 + \\ border-top: 1px solid #222; 128 + \\ font-size: 11px; 129 + \\ color: #444; 70 130 \\ } 71 - \\ .tag:hover { background: #1a1a1a; border-color: #333; color: #aaa; } 72 - \\ .tag .count { color: #555; margin-left: 0.5rem; } 73 - \\ .footer { margin-top: 3rem; font-size: 11px; color: #444; } 74 - \\ .footer a { color: #555; } 131 + \\ footer a { color: #555; } 132 + \\ footer a:hover { color: #888; } 75 133 \\ </style> 76 134 \\</head> 77 135 \\<body> 78 136 \\ <div class="container"> 79 - \\ <h1><a href="https://leaflet-search.pages.dev">leaflet search</a> / stats</h1> 137 + \\ <h1><a href="https://leaflet-search.pages.dev" class="title">leaflet search</a> <span class="dim">/ stats</span></h1> 80 138 \\ 81 - \\ <div class="stats-grid"> 82 - \\ <div class="stat"> 83 - \\ <div class="stat-value uptime" id="age">--</div> 84 - \\ <div class="stat-label">service age</div> 85 - \\ </div> 86 - \\ <div class="stat"> 87 - \\ <div class="stat-value"> 139 + \\ <section> 140 + \\ <div class="metrics"> 141 + \\ <div> 142 + \\ <div class="metric-value" id="age">--</div> 143 + \\ <div class="metric-label">uptime</div> 144 + \\ </div> 145 + \\ <div> 146 + \\ <div class="metric-value"> 88 147 ); 89 148 90 - try w.print("{d}", .{searches}); 149 + try w.print("{d}", .{data.searches}); 91 150 try w.writeAll( 92 151 \\</div> 93 - \\ <div class="stat-label">searches</div> 94 - \\ </div> 95 - \\ <div class="stat"> 96 - \\ <div class="stat-value"> 152 + \\ <div class="metric-label">searches</div> 153 + \\ </div> 154 + \\ <div> 155 + \\ <div class="metric-value"> 97 156 ); 98 157 99 - try w.print("{d}", .{documents}); 158 + try w.print("{d}", .{data.publications}); 100 159 try w.writeAll( 101 160 \\</div> 102 - \\ <div class="stat-label">documents indexed</div> 161 + \\ <div class="metric-label">publications</div> 162 + \\ </div> 103 163 \\ </div> 104 - \\ <div class="stat"> 105 - \\ <div class="stat-value"> 164 + \\ </section> 165 + \\ 166 + \\ <section> 167 + \\ <div class="section-title">documents</div> 168 + \\ <div class="chart-box"> 169 + \\ <div class="doc-row"> 170 + \\ <span class="doc-type">articles</span> 171 + \\ <span class="doc-count"> 106 172 ); 107 173 108 - try w.print("{d}", .{publications}); 174 + try w.print("{d}", .{data.articles}); 109 175 try w.writeAll( 110 - \\</div> 111 - \\ <div class="stat-label">publications indexed</div> 112 - \\ </div> 113 - \\ <div class="stat"> 114 - \\ <div class="stat-value"> 176 + \\</span> 177 + \\ </div> 178 + \\ <div class="doc-row"> 179 + \\ <span class="doc-type">looseleafs</span> 180 + \\ <span class="doc-count"> 115 181 ); 116 182 117 - try w.print("{d}", .{errors}); 183 + try w.print("{d}", .{data.looseleafs}); 118 184 try w.writeAll( 119 - \\</div> 120 - \\ <div class="stat-label">errors</div> 185 + \\</span> 186 + \\ </div> 187 + \\ </div> 188 + \\ </section> 189 + \\ 190 + \\ <section> 191 + \\ <div class="section-title">activity (last 30 days)</div> 192 + \\ <div class="chart-box"> 193 + \\ <div class="timeline" id="timeline"></div> 194 + \\ </div> 195 + \\ </section> 196 + \\ 197 + \\ <section> 198 + \\ <div class="section-title">top publications</div> 199 + \\ <div class="chart-box"> 200 + \\ <div id="pubs"></div> 121 201 \\ </div> 122 - \\ </div> 202 + \\ </section> 123 203 \\ 124 - \\ <h2>top tags</h2> 125 - \\ <div class="tags-grid" id="tags"></div> 204 + \\ <section> 205 + \\ <div class="section-title">tags</div> 206 + \\ <div class="tags" id="tags"></div> 207 + \\ </section> 126 208 \\ 127 - \\ <div class="footer"> 128 - \\ <a href="https://tangled.sh/@zzstoatzz.io/leaflet-search" target="_blank">source</a> 129 - \\ </div> 209 + \\ <footer> 210 + \\ <a href="https://leaflet-search.pages.dev">← back</a> · source on <a href="https://tangled.sh/@zzstoatzz.io/leaflet-search">tangled</a> 211 + \\ </footer> 130 212 \\ </div> 131 213 \\ 132 214 \\ <script> 133 215 \\ const startedAt = 134 216 ); 135 217 136 - try w.print("{d}", .{started_at}); 218 + try w.print("{d}", .{data.started_at}); 219 + try w.writeAll(" * 1000;\n const tags = "); 220 + try w.writeAll(data.tags_json); 221 + try w.writeAll(";\n const timeline = "); 222 + try w.writeAll(data.timeline_json); 223 + try w.writeAll(";\n const pubs = "); 224 + try w.writeAll(data.top_pubs_json); 137 225 try w.writeAll( 138 - \\ * 1000; 226 + \\; 139 227 \\ 140 228 \\ function formatAge(ms) { 141 - \\ const secs = Math.floor(ms / 1000); 142 - \\ const d = Math.floor(secs / 86400); 143 - \\ const h = Math.floor((secs % 86400) / 3600); 144 - \\ const m = Math.floor((secs % 3600) / 60); 145 - \\ if (d > 0) return `${d}d ${h}h`; 146 - \\ if (h > 0) return `${h}h ${m}m`; 147 - \\ if (m > 0) return `${m}m`; 148 - \\ return `${secs}s`; 229 + \\ const s = Math.floor(ms / 1000); 230 + \\ const d = Math.floor(s / 86400); 231 + \\ const h = Math.floor((s % 86400) / 3600); 232 + \\ const m = Math.floor((s % 3600) / 60); 233 + \\ const sec = s % 60; 234 + \\ if (d > 0) return d + 'd ' + h + 'h ' + m + 'm ' + sec + 's'; 235 + \\ if (h > 0) return h + 'h ' + m + 'm ' + sec + 's'; 236 + \\ return m + 'm ' + sec + 's'; 237 + \\ } 238 + \\ function updateAge() { 239 + \\ document.getElementById('age').textContent = formatAge(Date.now() - startedAt); 149 240 \\ } 241 + \\ updateAge(); 242 + \\ setInterval(updateAge, 1000); 150 243 \\ 151 - \\ function updateAge() { 152 - \\ const age = Date.now() - startedAt; 153 - \\ document.getElementById('age').textContent = formatAge(age); 244 + \\ // timeline 245 + \\ const timelineEl = document.getElementById('timeline'); 246 + \\ if (timeline.length > 0) { 247 + \\ const max = Math.max(...timeline.map(d => d.count)); 248 + \\ [...timeline].reverse().forEach(d => { 249 + \\ const h = max > 0 ? (d.count / max * 100) : 0; 250 + \\ const bar = document.createElement('div'); 251 + \\ bar.className = 'bar'; 252 + \\ bar.style.height = Math.max(h, 3) + '%'; 253 + \\ bar.title = d.date + ': ' + d.count; 254 + \\ timelineEl.appendChild(bar); 255 + \\ }); 154 256 \\ } 155 257 \\ 156 - \\ updateAge(); 157 - \\ setInterval(updateAge, 60000); 258 + \\ // publications 259 + \\ const pubsEl = document.getElementById('pubs'); 260 + \\ pubs.forEach(p => { 261 + \\ const row = document.createElement('div'); 262 + \\ row.className = 'pub-row'; 263 + \\ row.innerHTML = '<span class="pub-name">' + p.name + '</span><span class="pub-count">' + p.count + '</span>'; 264 + \\ pubsEl.appendChild(row); 265 + \\ }); 158 266 \\ 159 - \\ const tags = 160 - ); 161 - 162 - try w.writeAll(tags_json); 163 - try w.writeAll( 164 - \\; 165 - \\ const tagsHtml = tags.slice(0, 20).map(t => 166 - \\ `<a class="tag" href="https://leaflet-search.pages.dev/?tag=${encodeURIComponent(t.tag)}">${t.tag}<span class="count">${t.count}</span></a>` 267 + \\ // tags 268 + \\ document.getElementById('tags').innerHTML = tags.slice(0, 20).map(t => 269 + \\ '<a class="tag" href="https://leaflet-search.pages.dev/?tag=' + encodeURIComponent(t.tag) + '">' + 270 + \\ t.tag + '<span class="n">' + t.count + '</span></a>' 167 271 \\ ).join(''); 168 - \\ document.getElementById('tags').innerHTML = tagsHtml; 169 272 \\ </script> 170 273 \\</body> 171 274 \\</html>
+145
backend/src/dashboard_data.zig
··· 1 + const std = @import("std"); 2 + const json = std.json; 3 + const Allocator = std.mem.Allocator; 4 + const db = @import("db/mod.zig"); 5 + const zql = @import("zql"); 6 + 7 + /// All data needed to render the dashboard 8 + pub const DashboardData = struct { 9 + started_at: i64, 10 + searches: i64, 11 + publications: i64, 12 + articles: i64, 13 + looseleafs: i64, 14 + tags_json: []const u8, 15 + timeline_json: []const u8, 16 + top_pubs_json: []const u8, 17 + }; 18 + 19 + pub fn fetch(alloc: Allocator) !DashboardData { 20 + const stats = db.getStats(); 21 + const doc_types = getDocTypeStats(); 22 + 23 + return .{ 24 + .started_at = stats.started_at, 25 + .searches = stats.searches, 26 + .publications = stats.publications, 27 + .articles = doc_types.articles, 28 + .looseleafs = doc_types.looseleafs, 29 + .tags_json = db.getTags(alloc) catch "[]", 30 + .timeline_json = getDocsByDate(alloc) catch "[]", 31 + .top_pubs_json = getTopPublications(alloc) catch "[]", 32 + }; 33 + } 34 + 35 + fn getDocTypeStats() struct { articles: i64, looseleafs: i64 } { 36 + const client = db.getClient() orelse return .{ .articles = 0, .looseleafs = 0 }; 37 + 38 + var res = client.query( 39 + \\SELECT 40 + \\ SUM(CASE WHEN publication_uri != '' THEN 1 ELSE 0 END) as articles, 41 + \\ SUM(CASE WHEN publication_uri = '' OR publication_uri IS NULL THEN 1 ELSE 0 END) as looseleafs 42 + \\FROM documents 43 + , &.{}) catch return .{ .articles = 0, .looseleafs = 0 }; 44 + defer res.deinit(); 45 + 46 + const row = res.first() orelse return .{ .articles = 0, .looseleafs = 0 }; 47 + return .{ .articles = row.int(0), .looseleafs = row.int(1) }; 48 + } 49 + 50 + const DateCount = struct { 51 + date: []const u8, 52 + count: i64, 53 + 54 + fn fromRow(row: db.Row) DateCount { 55 + return .{ .date = row.text(0), .count = row.int(1) }; 56 + } 57 + }; 58 + 59 + const DocsByDateQuery = zql.Query( 60 + \\SELECT DATE(created_at) as date, COUNT(*) as count 61 + \\FROM documents 62 + \\WHERE created_at IS NOT NULL AND created_at != '' 63 + \\GROUP BY DATE(created_at) 64 + \\ORDER BY date DESC 65 + \\LIMIT 30 66 + ); 67 + 68 + fn getDocsByDate(alloc: Allocator) ![]const u8 { 69 + const client = db.getClient() orelse return error.NotInitialized; 70 + 71 + var output: std.Io.Writer.Allocating = .init(alloc); 72 + errdefer output.deinit(); 73 + 74 + var res = client.query(DocsByDateQuery.positional, &.{}) catch { 75 + try output.writer.writeAll("[]"); 76 + return try output.toOwnedSlice(); 77 + }; 78 + defer res.deinit(); 79 + 80 + var jw: json.Stringify = .{ .writer = &output.writer }; 81 + try jw.beginArray(); 82 + 83 + for (res.rows) |row| { 84 + const dc = DateCount.fromRow(row); 85 + try jw.beginObject(); 86 + try jw.objectField("date"); 87 + try jw.write(dc.date); 88 + try jw.objectField("count"); 89 + try jw.write(dc.count); 90 + try jw.endObject(); 91 + } 92 + 93 + try jw.endArray(); 94 + return try output.toOwnedSlice(); 95 + } 96 + 97 + const TopPub = struct { 98 + name: []const u8, 99 + base_path: []const u8, 100 + count: i64, 101 + 102 + fn fromRow(row: db.Row) TopPub { 103 + return .{ .name = row.text(0), .base_path = row.text(1), .count = row.int(2) }; 104 + } 105 + }; 106 + 107 + const TopPubsQuery = zql.Query( 108 + \\SELECT p.name, p.base_path, COUNT(d.uri) as doc_count 109 + \\FROM publications p 110 + \\JOIN documents d ON d.publication_uri = p.uri 111 + \\GROUP BY p.uri 112 + \\ORDER BY doc_count DESC 113 + \\LIMIT 8 114 + ); 115 + 116 + fn getTopPublications(alloc: Allocator) ![]const u8 { 117 + const client = db.getClient() orelse return error.NotInitialized; 118 + 119 + var output: std.Io.Writer.Allocating = .init(alloc); 120 + errdefer output.deinit(); 121 + 122 + var res = client.query(TopPubsQuery.positional, &.{}) catch { 123 + try output.writer.writeAll("[]"); 124 + return try output.toOwnedSlice(); 125 + }; 126 + defer res.deinit(); 127 + 128 + var jw: json.Stringify = .{ .writer = &output.writer }; 129 + try jw.beginArray(); 130 + 131 + for (res.rows) |row| { 132 + const p = TopPub.fromRow(row); 133 + try jw.beginObject(); 134 + try jw.objectField("name"); 135 + try jw.write(p.name); 136 + try jw.objectField("basePath"); 137 + try jw.write(p.base_path); 138 + try jw.objectField("count"); 139 + try jw.write(p.count); 140 + try jw.endObject(); 141 + } 142 + 143 + try jw.endArray(); 144 + return try output.toOwnedSlice(); 145 + }
+5
backend/src/db/mod.zig
··· 19 19 try schema.init(&client.?); 20 20 } 21 21 22 + pub fn getClient() ?*turso.Client { 23 + if (client) |*c| return c; 24 + return null; 25 + } 26 + 22 27 pub fn insertDocument( 23 28 uri: []const u8, 24 29 did: []const u8,
+6 -12
backend/src/http.zig
··· 3 3 const http = std.http; 4 4 const mem = std.mem; 5 5 const db = @import("db/mod.zig"); 6 - const stats = @import("stats.zig"); 7 6 const dashboard = @import("dashboard.zig"); 7 + const dashboard_data = @import("dashboard_data.zig"); 8 8 9 9 const HTTP_BUF_SIZE = 8192; 10 10 const QUERY_PARAM_BUF_SIZE = 64; ··· 173 173 defer arena.deinit(); 174 174 const alloc = arena.allocator(); 175 175 176 - const db_stats = db.getStats(); 177 - const tags_json = db.getTags(alloc) catch "[]"; 176 + const data = dashboard_data.fetch(alloc) catch { 177 + try sendNotFound(request); 178 + return; 179 + }; 178 180 179 - const html = dashboard.render( 180 - alloc, 181 - db_stats.started_at, 182 - @intCast(db_stats.searches), 183 - @intCast(db_stats.errors), 184 - db_stats.documents, 185 - db_stats.publications, 186 - tags_json, 187 - ) catch { 181 + const html = dashboard.render(alloc, data) catch { 188 182 try sendNotFound(request); 189 183 return; 190 184 };
+1 -1
tap/fly.toml
··· 1 1 app = 'leaflet-search-tap' 2 - primary_region = 'ewr' 2 + primary_region = 'iad' 3 3 4 4 [build] 5 5 image = 'ghcr.io/bluesky-social/indigo/tap:latest'