Skip to main content

tokenburn_web/
components.rs

1//! Server-rendered page components.
2
3use tokenburn_core::utils::fmt::{compact, thousands};
4use tokenburn_core::{
5    Bucket, CURRENCIES, Currency, Metric, Query, ReportRow, Summary, ToolFilter, Window,
6};
7use topcoat::{
8    Result,
9    runtime::{Event, Signal},
10    view::{View, component, view},
11};
12
13use crate::charts::{self, Bars, Col, SliceGeom};
14use crate::data::{Dashboard, share_percent};
15use crate::params::WINDOW_CHOICES;
16use crate::theme;
17use tokenburn_core::Selection;
18
19/// Title with the version, plus the Dashboard / Settings / About tabs.
20#[component]
21pub async fn brand(active: String) -> Result<impl View> {
22    let pick = |name: &str| if active == name { "tab active" } else { "tab" };
23    let (c_dash, c_set, c_about) = (pick("dashboard"), pick("settings"), pick("about"));
24    Ok(view! {
25        <div class="brand">
26            <h1><a href="/" style="color:inherit">"🔥 TokenBurn"</a> <small class="ver">(tokenburn_core::appinfo::version_label())</small></h1>
27            <nav class="tabs" aria-label="Pages">
28                <a class=(c_dash) href="/">"Dashboard"</a>
29                <a class=(c_set) href="/settings">"⚙ Settings"</a>
30                <a class=(c_about) href="/about">"ℹ About"</a>
31            </nav>
32        </div>
33    })
34}
35
36/// Row of headline totals.
37#[component]
38pub async fn summary_cards(total: Summary, currency: Currency) -> Result<impl View> {
39    // "cost ≈" when the exchange rate is a built-in estimate.
40    let cost_label = format!("cost{}", currency.approx_mark());
41    Ok(view! {
42        <section class="cards">
43            <div class="card"><div class="label">"turns"</div><div class="value">(thousands(total.turns))</div></div>
44            <div class="card"><div class="label">"total tokens"</div><div class="value">(compact(total.total_tokens))</div></div>
45            <div class="card"><div class="label">"input"</div><div class="value">(compact(total.input))</div></div>
46            <div class="card"><div class="label">"output"</div><div class="value">(compact(total.output))</div></div>
47            <div class="card"><div class="label">"cache read"</div><div class="value">(compact(total.cache_read))</div></div>
48            <div class="card"><div class="label">"cache write"</div><div class="value">(compact(total.cache_write))</div></div>
49            <div class="card cost"><div class="label">(cost_label)</div><div class="value">(currency.format(total.cost_usd))</div></div>
50        </section>
51    })
52}
53
54/// GET form that drives the query string.
55#[component]
56pub async fn filters(
57    query: Query,
58    theme: usize,
59    metric: Metric,
60    currency: Currency,
61) -> Result<impl View> {
62    Ok(view! {
63        <form class="filters" method="get" action="/">
64            <label>"tool "
65                <select name="tool" onchange="this.form.submit()">
66                    for t in ToolFilter::ALL {
67                        <option value=(t.as_str()) selected=(t == query.tool)>(t.as_str())</option>
68                    }
69                </select>
70            </label>
71            <label>"view "
72                <select name="bucket" onchange="this.form.submit()">
73                    for b in Bucket::ALL {
74                        <option value=(b.label()) selected=(b == query.bucket)>(b.label())</option>
75                    }
76                </select>
77            </label>
78            <label>"window "
79                <select name="window" onchange="this.form.submit()">
80                    for w in Window::ALL {
81                        <option value=(w.label()) selected=(w == query.window)>(w.label())</option>
82                    }
83                </select>
84            </label>
85            <label>"chart "
86                <select name="metric" onchange="this.form.submit()">
87                    for m in Metric::ALL {
88                        <option value=(m.label()) selected=(m == metric)>(m.label())</option>
89                    }
90                </select>
91            </label>
92            <label>"currency "
93                <select name="currency" onchange="this.form.submit()">
94                    for cur in CURRENCIES {
95                        <option value=(cur.code) selected=(cur.code == currency.code)>(format!("{} · {}", cur.code, cur.name))</option>
96                    }
97                </select>
98            </label>
99            <label>"theme "
100                <select name="theme" onchange="this.form.submit()">
101                    for n in theme::names() {
102                        <option value=(n) selected=(n == theme::name(theme))>(n)</option>
103                    }
104                </select>
105            </label>
106            <noscript><button type="submit">"Apply"</button></noscript>
107        </form>
108    })
109}
110
111/// Aggregated report table with a share-of-tokens bar per line.
112#[component]
113pub async fn report_table(report: &[ReportRow], currency: Currency) -> Result<impl View> {
114    let bucketed = report.iter().any(|r| r.bucket.is_some());
115    let max = report
116        .iter()
117        .filter(|r| r.tool != "TOTAL")
118        .map(|r| r.summary.total_tokens)
119        .max()
120        .unwrap_or(0);
121
122    Ok(view! {
123        <div class="table-wrap"><table>
124            <thead>
125                <tr>
126                    if bucketed { <th>"bucket"</th> }
127                    <th class="left">"tool"</th>
128                    <th class="opt2">"turns"</th>
129                    <th class="opt2">"input"</th>
130                    <th class="opt2">"output"</th>
131                    <th class="opt1">"cache read"</th>
132                    <th class="opt1">"cache write"</th>
133                    <th>"total"</th>
134                    <th>"cost"</th>
135                    <th class="left opt2">"share"</th>
136                </tr>
137            </thead>
138            <tbody>
139                for r in report {
140                    <tr class=(if r.tool == "TOTAL" { "total" } else { "" })>
141                        if bucketed { <td>(r.bucket.clone().unwrap_or_default())</td> }
142                        <td class="left">(r.tool.clone())</td>
143                        <td class="opt2">(thousands(r.summary.turns))</td>
144                        <td class="opt2">(thousands(r.summary.input))</td>
145                        <td class="opt2">(thousands(r.summary.output))</td>
146                        <td class="opt1">(thousands(r.summary.cache_read))</td>
147                        <td class="opt1">(thousands(r.summary.cache_write))</td>
148                        <td>(thousands(r.summary.total_tokens))</td>
149                        <td class="cost">(currency.format(r.summary.cost_usd))</td>
150                        <td class="left opt2">
151                            if r.tool != "TOTAL" {
152                                <span class="bar"><span style=(format!("width:{}%;background:{}", share_percent(r.summary.total_tokens, max), theme::series_var(&r.tool)))></span></span>
153                            }
154                        </td>
155                    </tr>
156                }
157            </tbody>
158        </table></div>
159    })
160}
161
162/// Per-project / per-model breakdown, most expensive first.
163#[component]
164pub async fn projects_table(
165    projects: &[(String, Summary)],
166    currency: Currency,
167) -> Result<impl View> {
168    let max = projects
169        .iter()
170        .map(|(_, s)| (s.cost_usd * 10_000.0) as u64)
171        .max()
172        .unwrap_or(0);
173
174    Ok(view! {
175        <div class="table-wrap"><table>
176            <thead>
177                <tr>
178                    <th class="left">"tool:project/model"</th>
179                    <th class="opt2">"turns"</th>
180                    <th>"total tokens"</th>
181                    <th>"cost"</th>
182                    <th class="left opt2">"share"</th>
183                </tr>
184            </thead>
185            <tbody>
186                for (name, s) in projects {
187                    <tr>
188                        <td class="left">(name.clone())</td>
189                        <td class="opt2">(thousands(s.turns))</td>
190                        <td>(thousands(s.total_tokens))</td>
191                        <td class="cost">(currency.format(s.cost_usd))</td>
192                        <td class="left opt2">
193                            <span class="bar"><span style=(format!("width:{}%;background:{}", share_percent((s.cost_usd * 10_000.0) as u64, max), theme::series_var(name.split(':').next().unwrap_or(""))))></span></span>
194                        </td>
195                    </tr>
196                }
197            </tbody>
198        </table></div>
199    })
200}
201
202/// Selects bound to signals: changing one updates the signal in the browser
203/// and (because the enclosing shard reads it) re-renders the shard.
204#[component]
205pub async fn live_filters(
206    tool: &Signal<String>,
207    bucket: &Signal<String>,
208    window: &Signal<String>,
209    theme: &Signal<String>,
210    metric: &Signal<String>,
211    currency: &Signal<String>,
212) -> Result<impl View> {
213    let cur = currency.get_untracked();
214    let (t, b, w, th, m) = (
215        tool.get_untracked(),
216        bucket.get_untracked(),
217        window.get_untracked(),
218        theme.get_untracked(),
219        metric.get_untracked(),
220    );
221    Ok(view! {
222        <div class="filters">
223            <label>"tool "
224                <select :value=$(tool.get()) @change=$(|e: Event| tool.set(e.target.value))>
225                    for x in ToolFilter::ALL {
226                        <option value=(x.as_str()) selected=(x.as_str() == t)>(x.as_str())</option>
227                    }
228                </select>
229            </label>
230            <label>"view "
231                <select :value=$(bucket.get()) @change=$(|e: Event| bucket.set(e.target.value))>
232                    for x in Bucket::ALL {
233                        <option value=(x.label()) selected=(x.label() == b)>(x.label())</option>
234                    }
235                </select>
236            </label>
237            <label>"window "
238                <select :value=$(window.get()) @change=$(|e: Event| window.set(e.target.value))>
239                    for x in WINDOW_CHOICES {
240                        <option value=(x) selected=(x == w)>(x)</option>
241                    }
242                </select>
243            </label>
244            <label>"chart "
245                <select :value=$(metric.get()) @change=$(|e: Event| metric.set(e.target.value))>
246                    for x in Metric::ALL {
247                        <option value=(x.label()) selected=(x.label() == m)>(x.label())</option>
248                    }
249                </select>
250            </label>
251            <label>"currency "
252                <select :value=$(currency.get()) @change=$(|e: Event| currency.set(e.target.value))>
253                    for cc in CURRENCIES {
254                        <option value=(cc.code) selected=(cc.code == cur)>(format!("{} · {}", cc.code, cc.name))</option>
255                    }
256                </select>
257            </label>
258            <label>"theme "
259                <select :value=$(theme.get()) @change=$(|e: Event| theme.set(e.target.value))>
260                    for n in theme::names() {
261                        <option value=(n) selected=(n == th)>(n)</option>
262                    }
263                </select>
264            </label>
265        </div>
266    })
267}
268
269/// Cards, report and project tables for one [`Dashboard`] snapshot.
270///
271/// `live` shows the "live" badge (the page has an open connection and is
272/// refreshing itself).
273#[component]
274pub async fn dashboard_body(
275    d: &Dashboard,
276    live: bool,
277    metric: Metric,
278    base: &str,
279    click_bucket: Option<&Signal<String>>,
280    click_project: Option<&Signal<String>>,
281) -> Result<impl View> {
282    Ok(view! {
283        for w in &d.warnings {
284            <div class="warn">"⚠ " (w.clone())</div>
285        }
286
287        summary_cards(total: d.total, currency: d.currency)
288
289        charts_panel(d: d, metric: metric, base: base, animate: live, click_bucket: click_bucket, click_project: click_project)
290
291        <section>
292            <h2>"Report · " (d.query.bucket.label())</h2>
293            if d.report.is_empty() {
294                <div class="empty">
295                    "No tokenburn results found for tool=" (d.query.tool.as_str())
296                    ", window=" (d.query.window.label()) "."
297                </div>
298            } else {
299                report_table(report: &d.report, currency: d.currency)
300            }
301        </section>
302
303        if !d.projects.is_empty() {
304            <section>
305                <h2>"Projects / models (by cost)"</h2>
306                projects_table(projects: &d.projects, currency: d.currency)
307            </section>
308        }
309
310        <footer>
311            if live {
312                <span class="badge live">"● live"</span>
313            } else {
314                <span class="badge">"○ static"</span>
315            }
316            " "
317            (d.row_count) " rows · updated " (d.updated_at.clone())
318            " · local logs only · JSON at "
319            <a href="/api/report">"/api/report"</a>
320        </footer>
321    })
322}
323
324/// The active theme's CSS variables, as a `<style>` element.
325#[component]
326pub async fn theme_style(index: usize) -> Result<impl View> {
327    let css = theme::css(index);
328    Ok(view! { <style>(css)</style> })
329}
330
331/// What a click on a bar or slice does.
332///
333/// * **Live** (runtime available): the handler sets the page's selection signals; the
334///   shard re-renders in place, so CSS transitions animate the change.
335/// * **Static**: every bar and slice is a link to the same page with the new
336///   selection in the query string (`base` is that page's URL without it).
337#[derive(Clone, Copy)]
338pub struct Clicks<'a> {
339    pub bucket: Option<&'a Signal<String>>,
340    pub project: Option<&'a Signal<String>>,
341}
342
343/// One bar: native tooltip, full-height hit area, the stacked segments and a
344/// selection outline. The wrapper (`<g>` + click handler/link) is added by the caller.
345#[component]
346pub async fn bar_col(col: &Col) -> Result<impl View> {
347    Ok(view! {
348        <title>(col.title.clone())</title>
349        <rect class="hit" x=(col.slot_x.clone()) y=(col.plot_y.clone()) width=(col.slot_w.clone()) height=(col.plot_h.clone())></rect>
350        for seg in &col.segs {
351            <rect class="seg" x=(seg.x.clone()) y=(seg.y.clone()) width=(seg.w.clone()) height=(seg.h.clone()) fill=(seg.var.clone())></rect>
352        }
353        <rect class="outline" x=(col.slot_x.clone()) y=(col.plot_y.clone()) width=(col.slot_w.clone()) height=(col.plot_h.clone())></rect>
354    })
355}
356
357/// One donut slice (the ring segment with its tooltip).
358#[component]
359pub async fn slice_path(slice: &SliceGeom) -> Result<impl View> {
360    Ok(view! {
361        <path d=(slice.path.clone()) fill=(slice.var.clone())>
362            <title>(slice.title.clone())</title>
363        </path>
364    })
365}
366
367fn col_class(c: &Col) -> String {
368    if c.selected {
369        "col sel".into()
370    } else {
371        "col".into()
372    }
373}
374
375fn slice_class(s: &SliceGeom) -> String {
376    let mut c = String::from("slice");
377    if s.selected {
378        c.push_str(" sel");
379    }
380    if s.key.is_none() {
381        c.push_str(" other");
382    }
383    c
384}
385
386/// Stagger so the whole sweep takes ~0.6 s however many bars there are.
387fn bar_delay(index: usize, n: usize) -> String {
388    let step = (600.0 / n.max(1) as f32).min(14.0);
389    format!("--d:{:.0}ms", step * index as f32)
390}
391
392/// The bar chart (`<svg>`).
393#[component]
394pub async fn bars_chart(
395    bars: &Bars,
396    metric: Metric,
397    base: &str,
398    sel: &Selection,
399    animate: bool,
400    click: Option<&Signal<String>>,
401) -> Result<impl View> {
402    let n = bars.cols.len();
403    // The selection each column would produce when clicked (toggle).
404    let cols: Vec<(Col, String, String, String)> = bars
405        .cols
406        .iter()
407        .map(|c| {
408            let mut next = sel.clone();
409            next.toggle_bucket(&c.label);
410            (
411                c.clone(),
412                charts::href(base, &next),
413                next.bucket.clone().unwrap_or_default(),
414                format!("{} {}", col_class(c), if animate { "anim" } else { "" }),
415            )
416        })
417        .collect();
418    let class = format!(
419        "chart bars{}{}",
420        if bars.has_selection { " has-sel" } else { "" },
421        if animate { " animate" } else { "" }
422    );
423    let label = format!("{} over time", metric.label());
424
425    Ok(view! {
426        <svg class=(class) viewBox=(format!("0 0 {} {}", charts::BARS_W, charts::BARS_H)) width="100%" role="img" aria-label=(label)>
427            if bars.empty {
428                <text class="empty-note" x=(charts::BARS_W / 2.0) y=(charts::BARS_H / 2.0) text-anchor="middle">"No data in this window"</text>
429            }
430            for g in &bars.grid {
431                <line class="grid" x1=(bars.left.clone()) x2=(bars.right.clone()) y1=(g.y.clone()) y2=(g.y.clone())></line>
432                <text class="axis" x=(charts::LEFT - 6.0) y=(g.text_y.clone()) text-anchor="end">(g.label.clone())</text>
433            }
434            for (c, href, next, class) in cols {
435                if let Some(sig) = click {
436                    <g class=(class) style=(bar_delay(c.index, n)) @click=$(|_e| sig.set(next))>
437                        bar_col(col: &c)
438                    </g>
439                } else {
440                    <a href=(href)>
441                        <g class=(class) style=(bar_delay(c.index, n))>
442                            bar_col(col: &c)
443                        </g>
444                    </a>
445                }
446            }
447            for l in &bars.x_labels {
448                <text class=(if l.alt { "axis alt" } else { "axis" }) x=(l.x.clone()) y=(l.y.clone()) text-anchor="middle">(l.text.clone())</text>
449            }
450        </svg>
451    })
452}
453
454/// The share donut (`<svg>`), its slices sweeping in through a mask.
455#[component]
456pub async fn donut_chart(
457    donut: &charts::Donut,
458    base: &str,
459    sel: &Selection,
460    animate: bool,
461    click: Option<&Signal<String>>,
462) -> Result<impl View> {
463    let slices: Vec<(SliceGeom, String, String, String)> = donut
464        .slices
465        .iter()
466        .map(|s| {
467            let mut next = sel.clone();
468            if let Some(k) = &s.key {
469                next.toggle_project(k);
470            }
471            (
472                s.clone(),
473                charts::href(base, &next),
474                next.project.clone().unwrap_or_default(),
475                format!("--dx:{};--dy:{}", s.dx, s.dy),
476            )
477        })
478        .collect();
479    let class = format!(
480        "chart donut{}{}",
481        if donut.has_selection { " has-sel" } else { "" },
482        if animate { " animate" } else { "" }
483    );
484    let total = donut.total_label.clone();
485
486    Ok(view! {
487        <svg class=(class) viewBox="0 0 200 200" width="100%" role="img" aria-label="share by project">
488            if donut.empty {
489                <text class="empty-note" x="100" y="104" text-anchor="middle">"No data"</text>
490            } else {
491                <defs>
492                    <mask id="sweep" maskUnits="userSpaceOnUse" x="0" y="0" width="200" height="200">
493                        <circle class="sweep" cx="100" cy="100" r="74" fill="none" stroke="white" stroke-width="60" pathLength="1" transform="rotate(-90 100 100)"></circle>
494                    </mask>
495                </defs>
496                <g mask="url(#sweep)">
497                    for (s, href, next, vars) in slices {
498                        if s.key.is_none() {
499                            <g class=(slice_class(&s)) style=(vars)>
500                                slice_path(slice: &s)
501                            </g>
502                        } else if let Some(sig) = click {
503                            <g class=(slice_class(&s)) style=(vars) @click=$(|_e| sig.set(next))>
504                                slice_path(slice: &s)
505                            </g>
506                        } else {
507                            <a href=(href)>
508                                <g class=(slice_class(&s)) style=(vars)>
509                                    slice_path(slice: &s)
510                                </g>
511                            </a>
512                        }
513                    }
514                </g>
515                <text class="total" x="100" y="106" text-anchor="middle">(total)</text>
516            }
517        </svg>
518    })
519}
520
521/// "Showing …" chips with a clear control, or a hint when nothing is selected.
522#[component]
523pub async fn selection_bar(
524    sel: &Selection,
525    base: &str,
526    click_bucket: Option<&Signal<String>>,
527    click_project: Option<&Signal<String>>,
528) -> Result<impl View> {
529    let chips = sel.describe();
530    let clear = charts::href(base, &Selection::default());
531    // Runtime expressions only take single-identifier paths, so the empty values are passed in.
532    let (blank_a, blank_b) = (String::new(), String::new());
533    Ok(view! {
534        <div class="selbar">
535            if chips.is_empty() {
536                <span class="hint">"click a bar or a slice to drill down"</span>
537            } else {
538                <span class="hint">"Showing"</span>
539                for chip in chips {
540                    <span class="chip">(chip)</span>
541                }
542                if let (Some(b), Some(p)) = (click_bucket, click_project) {
543                    <button class="clear" @click=$(|_e| { b.set(blank_a); p.set(blank_b); })>"✕ clear"</button>
544                } else {
545                    <a class="clear" href=(clear)>"✕ clear"</a>
546                }
547            }
548        </div>
549    })
550}
551
552/// Stacked bars over time and the share donut, both clickable.
553#[component]
554pub async fn charts_panel(
555    d: &Dashboard,
556    metric: Metric,
557    base: &str,
558    animate: bool,
559    click_bucket: Option<&Signal<String>>,
560    click_project: Option<&Signal<String>>,
561) -> Result<impl View> {
562    let cur = d.currency;
563    let bars = charts::bars(
564        &d.series.in_currency(&cur),
565        metric,
566        d.selection.bucket.as_deref(),
567        &cur,
568    );
569    let donut = charts::donut(
570        &d.pie.in_currency(&cur),
571        d.selection.project.as_deref(),
572        &cur,
573    );
574    let sel = d.selection.clone();
575    let pie_metric = d.pie.metric;
576
577    // Legend rows (clickable like the slices): swatch, label, share.
578    let legend: Vec<(SliceGeom, String, String)> = donut
579        .slices
580        .iter()
581        .map(|s| {
582            let mut next = sel.clone();
583            if let Some(k) = &s.key {
584                next.toggle_project(k);
585            }
586            (
587                s.clone(),
588                charts::href(base, &next),
589                next.project.clone().unwrap_or_default(),
590            )
591        })
592        .collect();
593    let swatches: Vec<(String, String)> = d
594        .series
595        .active_tools(metric)
596        .into_iter()
597        .map(|t| (charts::tool_var(t), t.display_name().to_string()))
598        .collect();
599
600    Ok(view! {
601        <section class="charts">
602            <div class="panel">
603                <h2>(if metric == Metric::Cost { "Cost over time" } else { "Tokens over time" })</h2>
604                bars_chart(bars: &bars, metric: metric, base: base, sel: &sel, animate: animate, click: click_bucket)
605                <ul class="legend">
606                    for (var, name) in swatches {
607                        <li><span class="sw" style=(format!("background:{var}"))></span>(name)</li>
608                    }
609                </ul>
610            </div>
611            <div class="panel">
612                <h2>"Share by project · " (pie_metric.label())</h2>
613                <div class="donut-row">
614                    <div class="donut-box">
615                        donut_chart(donut: &donut, base: base, sel: &sel, animate: animate, click: click_project)
616                    </div>
617                    <ul class=(if donut.has_selection { "legend vertical has-sel" } else { "legend vertical" })>
618                        for (s, href, next) in legend {
619                            if s.key.is_none() {
620                                <li class="item other"><span class="sw" style=(format!("background:{}", s.var))></span>(s.label.clone()) <span class="pct">(s.pct.clone())</span></li>
621                            } else if let Some(sig) = click_project {
622                                <li class=(if s.selected { "item sel" } else { "item" }) @click=$(|_e| sig.set(next))>
623                                    <span class="sw" style=(format!("background:{}", s.var))></span>(s.label.clone()) <span class="pct">(s.pct.clone())</span>
624                                </li>
625                            } else {
626                                <li class=(if s.selected { "item sel" } else { "item" })>
627                                    <a href=(href)><span class="sw" style=(format!("background:{}", s.var))></span>(s.label.clone()) <span class="pct">(s.pct.clone())</span></a>
628                                </li>
629                            }
630                        }
631                    </ul>
632                </div>
633            </div>
634        </section>
635        selection_bar(sel: &sel, base: base, click_bucket: click_bucket, click_project: click_project)
636    })
637}