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