Skip to main content

tokenburn_web/
components.rs

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