zernel_dashboard/
routes.rs

1// Copyright (C) 2026 Dyber, Inc. — Proprietary
2
3//! HTTP routes for the Zernel web dashboard.
4
5use 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
21/// Build the complete router with all dashboard routes.
22pub 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
32// ============================================================
33// Page handlers
34// ============================================================
35
36async 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 &lt;script&gt;</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 &lt;script&gt;</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 &lt;path&gt;</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
208// ============================================================
209// SSE handler
210// ============================================================
211
212async 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
224// ============================================================
225// Page template
226// ============================================================
227
228fn 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 &mdash; Copyright &copy; 2026 Dyber, Inc.</footer>
273</body>
274</html>"#
275    )
276}