zernel_dashboard/
routes.rs1use crate::state::AppState;
6use axum::{
7 extract::State,
8 response::{
9 sse::{Event, KeepAlive, Sse},
10 Html,
11 },
12 routing::get,
13 Router,
14};
15use futures_util::stream::Stream;
16use std::convert::Infallible;
17use std::sync::Arc;
18use tokio_stream::wrappers::BroadcastStream;
19use tokio_stream::StreamExt;
20
21pub fn build_router(state: Arc<AppState>) -> Router {
23 Router::new()
24 .route("/", get(index))
25 .route("/experiments", get(experiments))
26 .route("/jobs", get(jobs))
27 .route("/models", get(models))
28 .route("/api/sse", get(sse_handler))
29 .with_state(state)
30}
31
32async fn index() -> Html<String> {
37 Html(page(
38 "Overview",
39 r#"
40 <h2>GPU Telemetry</h2>
41 <div id="gpu-section" hx-ext="sse" sse-connect="/api/sse" sse-swap="telemetry">
42 <p class="muted">Connecting to zerneld...</p>
43 </div>
44
45 <h2>eBPF Metrics</h2>
46 <div id="telemetry-section">
47 <p class="muted">Waiting for data...</p>
48 </div>
49 "#,
50 ))
51}
52
53async fn experiments(State(state): State<Arc<AppState>>) -> Html<String> {
54 let rows = if state.experiments_db.exists() {
55 match rusqlite::Connection::open(&state.experiments_db) {
56 Ok(conn) => {
57 let mut stmt = conn
58 .prepare("SELECT id, name, status, metrics, created_at, duration_secs FROM experiments ORDER BY created_at DESC LIMIT 50")
59 .unwrap_or_else(|_| conn.prepare("SELECT 1").unwrap());
60
61 let mut rows_html = String::new();
62 let mut query_rows = stmt.query([]).unwrap();
63 while let Ok(Some(row)) = query_rows.next() {
64 let id: String = row.get(0).unwrap_or_default();
65 let name: String = row.get(1).unwrap_or_default();
66 let status: String = row.get(2).unwrap_or_default();
67 let metrics: String = row.get(3).unwrap_or_default();
68 let created: String = row.get(4).unwrap_or_default();
69 let duration: Option<f64> = row.get(5).unwrap_or(None);
70
71 let dur_str = duration
72 .map(|d| format!("{d:.1}s"))
73 .unwrap_or_else(|| "-".into());
74 let loss = serde_json::from_str::<serde_json::Value>(&metrics)
75 .ok()
76 .and_then(|m| m["loss"].as_f64())
77 .map(|v| format!("{v:.4}"))
78 .unwrap_or_else(|| "-".into());
79
80 let status_class = match status.trim_matches('"') {
81 "\"Done\"" | "Done" => "status-done",
82 "\"Failed\"" | "Failed" => "status-failed",
83 "\"Running\"" | "Running" => "status-running",
84 _ => "",
85 };
86
87 rows_html.push_str(&format!(
88 "<tr><td>{id}</td><td>{name}</td><td class=\"{status_class}\">{status}</td><td>{loss}</td><td>{dur_str}</td><td>{}</td></tr>",
89 &created[..19.min(created.len())]
90 ));
91 }
92 rows_html
93 }
94 Err(_) => "<tr><td colspan='6'>Could not open database</td></tr>".into(),
95 }
96 } else {
97 "<tr><td colspan='6'>No experiments yet. Run: zernel run <script></td></tr>".into()
98 };
99
100 Html(page(
101 "Experiments",
102 &format!(
103 r#"
104 <h2>Experiments</h2>
105 <table class="data-table">
106 <thead><tr><th>ID</th><th>Name</th><th>Status</th><th>Loss</th><th>Duration</th><th>Created</th></tr></thead>
107 <tbody>{rows}</tbody>
108 </table>
109 "#
110 ),
111 ))
112}
113
114async fn jobs(State(state): State<Arc<AppState>>) -> Html<String> {
115 let rows = if state.jobs_db.exists() {
116 match rusqlite::Connection::open(&state.jobs_db) {
117 Ok(conn) => {
118 let mut rows_html = String::new();
119 if let Ok(mut stmt) = conn.prepare("SELECT id, script, status, gpus_per_node, nodes, framework, exit_code FROM jobs ORDER BY submitted_at DESC LIMIT 50") {
120 let mut query_rows = stmt.query([]).unwrap();
121 while let Ok(Some(row)) = query_rows.next() {
122 let id: String = row.get(0).unwrap_or_default();
123 let script: String = row.get(1).unwrap_or_default();
124 let status: String = row.get(2).unwrap_or_default();
125 let gpus: u32 = row.get(3).unwrap_or(0);
126 let nodes: u32 = row.get(4).unwrap_or(0);
127 let fw: String = row.get(5).unwrap_or_default();
128 let exit: Option<i32> = row.get(6).unwrap_or(None);
129 let exit_str = exit.map(|e| e.to_string()).unwrap_or_else(|| "-".into());
130
131 rows_html.push_str(&format!(
132 "<tr><td>{id}</td><td>{script}</td><td>{status}</td><td>{gpus}</td><td>{nodes}</td><td>{fw}</td><td>{exit_str}</td></tr>"
133 ));
134 }
135 }
136 if rows_html.is_empty() {
137 "<tr><td colspan='7'>No jobs yet</td></tr>".into()
138 } else {
139 rows_html
140 }
141 }
142 Err(_) => "<tr><td colspan='7'>Could not open database</td></tr>".into(),
143 }
144 } else {
145 "<tr><td colspan='7'>No jobs yet. Run: zernel job submit <script></td></tr>".into()
146 };
147
148 Html(page(
149 "Jobs",
150 &format!(
151 r#"
152 <h2>Jobs</h2>
153 <table class="data-table">
154 <thead><tr><th>ID</th><th>Script</th><th>Status</th><th>GPUs</th><th>Nodes</th><th>Framework</th><th>Exit</th></tr></thead>
155 <tbody>{rows}</tbody>
156 </table>
157 "#
158 ),
159 ))
160}
161
162async fn models(State(state): State<Arc<AppState>>) -> Html<String> {
163 let rows = if state.models_registry.exists() {
164 match std::fs::read_to_string(&state.models_registry) {
165 Ok(data) => {
166 let entries: Vec<serde_json::Value> =
167 serde_json::from_str(&data).unwrap_or_default();
168 let mut rows_html = String::new();
169 for entry in &entries {
170 let name = entry["name"].as_str().unwrap_or("-");
171 let version = entry["version"].as_str().unwrap_or("-");
172 let tag = entry["tag"].as_str().unwrap_or("-");
173 let size = entry["size_bytes"].as_u64().unwrap_or(0);
174 let saved = entry["saved_at"].as_str().unwrap_or("-");
175 let size_str = format!("{:.1} MB", size as f64 / (1024.0 * 1024.0));
176
177 rows_html.push_str(&format!(
178 "<tr><td>{name}</td><td>{version}</td><td>{tag}</td><td>{size_str}</td><td>{}</td></tr>",
179 &saved[..10.min(saved.len())]
180 ));
181 }
182 if rows_html.is_empty() {
183 "<tr><td colspan='5'>No models yet</td></tr>".into()
184 } else {
185 rows_html
186 }
187 }
188 Err(_) => "<tr><td colspan='5'>Could not read registry</td></tr>".into(),
189 }
190 } else {
191 "<tr><td colspan='5'>No models yet. Run: zernel model save <path></td></tr>".into()
192 };
193
194 Html(page(
195 "Models",
196 &format!(
197 r#"
198 <h2>Models</h2>
199 <table class="data-table">
200 <thead><tr><th>Name</th><th>Version</th><th>Tag</th><th>Size</th><th>Saved</th></tr></thead>
201 <tbody>{rows}</tbody>
202 </table>
203 "#
204 ),
205 ))
206}
207
208async fn sse_handler(
213 State(state): State<Arc<AppState>>,
214) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
215 let rx = state.sse_tx.subscribe();
216 let stream = BroadcastStream::new(rx).filter_map(|msg| match msg {
217 Ok(html) => Some(Ok(Event::default().event("telemetry").data(html))),
218 Err(_) => None,
219 });
220
221 Sse::new(stream).keep_alive(KeepAlive::default())
222}
223
224fn page(title: &str, content: &str) -> String {
229 format!(
230 r#"<!DOCTYPE html>
231<html lang="en">
232<head>
233 <meta charset="utf-8">
234 <meta name="viewport" content="width=device-width, initial-scale=1">
235 <title>Zernel — {title}</title>
236 <script src="https://unpkg.com/htmx.org@2.0.4"></script>
237 <script src="https://unpkg.com/htmx-ext-sse@2.2.2/sse.js"></script>
238 <style>
239 * {{ margin: 0; padding: 0; box-sizing: border-box; }}
240 body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f172a; color: #e2e8f0; }}
241 nav {{ background: #1e293b; padding: 12px 24px; display: flex; gap: 24px; align-items: center; border-bottom: 1px solid #334155; }}
242 nav a {{ color: #94a3b8; text-decoration: none; font-size: 14px; }} nav a:hover {{ color: #f1f5f9; }}
243 nav .brand {{ color: #22d3ee; font-weight: 700; font-size: 18px; margin-right: 16px; }}
244 main {{ max-width: 1200px; margin: 24px auto; padding: 0 24px; }}
245 h2 {{ color: #f1f5f9; margin: 24px 0 12px; font-size: 20px; }}
246 .muted {{ color: #64748b; }}
247 .data-table {{ width: 100%; border-collapse: collapse; font-size: 13px; }}
248 .data-table th {{ text-align: left; padding: 8px 12px; background: #1e293b; color: #94a3b8; border-bottom: 1px solid #334155; }}
249 .data-table td {{ padding: 8px 12px; border-bottom: 1px solid #1e293b; }}
250 .data-table tr:hover {{ background: #1e293b; }}
251 .status-done {{ color: #22c55e; }} .status-failed {{ color: #ef4444; }} .status-running {{ color: #eab308; }}
252 .gpu-card {{ display: inline-block; width: 280px; margin: 8px; padding: 12px; background: #1e293b; border-radius: 8px; }}
253 .gpu-label {{ font-size: 13px; color: #94a3b8; margin-bottom: 6px; }}
254 .gpu-bar-bg {{ height: 20px; background: #334155; border-radius: 4px; overflow: hidden; }}
255 .gpu-bar {{ height: 100%; border-radius: 4px; transition: width 0.5s; }}
256 .gpu-pct {{ font-size: 24px; font-weight: 700; margin-top: 4px; }}
257 table {{ border-collapse: collapse; }} table td {{ padding: 4px 16px 4px 0; font-size: 14px; }}
258 footer {{ text-align: center; color: #475569; font-size: 12px; margin-top: 48px; padding: 24px; }}
259 </style>
260</head>
261<body>
262 <nav>
263 <span class="brand">Zernel</span>
264 <a href="/">Overview</a>
265 <a href="/experiments">Experiments</a>
266 <a href="/jobs">Jobs</a>
267 <a href="/models">Models</a>
268 </nav>
269 <main>
270 {content}
271 </main>
272 <footer>Zernel Dashboard — Copyright © 2026 Dyber, Inc.</footer>
273</body>
274</html>"#
275 )
276}