1use tokenburn_core::utils::fmt::thousands;
4use tokenburn_core::{
5 Bucket, BucketSavings, CURRENCIES, Currency, Metric, Query, ReportRow, Savings, Summary,
6 ToolFilter, Window,
7};
8use topcoat::{
9 Result,
10 runtime::{Event, Signal},
11 view::{View, component, view},
12};
13
14use crate::charts::{self, Bars, Col, SliceGeom};
15use crate::data::{
16 Dashboard, MAX_BUCKET_ROWS, bucket_noun, bucket_rows, savings_rows, share_percent,
17};
18use crate::params::WINDOW_CHOICES;
19use crate::theme;
20use tokenburn_core::Selection;
21
22#[component]
24pub async fn brand(active: String) -> Result<impl View> {
25 let pick = |name: &str| if active == name { "tab active" } else { "tab" };
26 let (c_dash, c_set, c_about) = (pick("dashboard"), pick("settings"), pick("about"));
27 Ok(view! {
28 <div class="brand">
29 <h1><a href="/" style="color:inherit">"🔥 TokenBurn"</a> <small class="ver">(tokenburn_core::appinfo::version_label())</small></h1>
30 <nav class="tabs" aria-label="Pages">
31 <a class=(c_dash) href="/">"Dashboard"</a>
32 <a class=(c_set) href="/settings">"⚙ Settings"</a>
33 <a class=(c_about) href="/about">"ℹ About"</a>
34 </nav>
35 </div>
36 })
37}
38
39#[component]
41pub async fn summary_cards(
42 metric: Metric,
43 total: Summary,
44 currency: Currency,
45 savings: &Savings,
46) -> Result<impl View> {
47 let cards = tokenburn_core::cards(metric, &total, savings, ¤cy);
49 Ok(view! {
50 <section class="cards">
51 for c in &cards {
52 <div class=(card_class(c.kind))><div class="label">(c.label.clone())</div><div class="value">(c.value.clone())</div></div>
53 }
54 </section>
55 })
56}
57
58pub fn card_class(kind: tokenburn_core::CardKind) -> &'static str {
60 match kind {
61 tokenburn_core::CardKind::Plain => "card",
62 tokenburn_core::CardKind::Cost => "card cost",
63 tokenburn_core::CardKind::Saved => "card saved",
64 }
65}
66
67pub fn chart_title(metric: Metric) -> &'static str {
69 match metric {
70 Metric::Tokens => "Tokens over time",
71 Metric::Cost => "Cost over time",
72 Metric::Saved => "Saved over time (cache + compaction)",
73 }
74}
75
76#[component]
79pub async fn savings_panel(
80 savings: &Savings,
81 currency: Currency,
82 spent_usd: f64,
83 input_price: f64,
84 buckets: &[BucketSavings],
85 noun: &str,
86) -> Result<impl View> {
87 let rows = savings_rows(savings, currency, spent_usd);
88 let per_bucket = bucket_rows(buckets, currency, MAX_BUCKET_ROWS);
89 let per_title = format!("Saved per {noun}");
90 let basis = format!(
91 "estimate · valued at {}/M input tokens",
92 currency.format(input_price)
93 );
94 Ok(view! {
95 if !rows.is_empty() {
96 <section class="savings">
97 <h2>"Saved " <small class="ver">(basis)</small></h2>
98 <div class="table-wrap"><table>
99 <thead>
100 <tr>
101 <th class="left">"source"</th>
102 <th>"tokens"</th>
103 <th>"saved"</th>
104 <th class="left opt2">"basis"</th>
105 </tr>
106 </thead>
107 <tbody>
108 for r in &rows {
109 <tr class=(if r.total { "total" } else { "" })>
110 <td class="left">(r.label.clone())</td>
111 <td>(r.tokens.clone())</td>
112 <td class="cost">(r.money.clone())</td>
113 <td class="left opt2">(r.basis.clone())</td>
114 </tr>
115 }
116 </tbody>
117 </table></div>
118 if !per_bucket.is_empty() {
119 <h2>(per_title)</h2>
120 <div class="table-wrap"><table class="per-bucket">
121 <thead>
122 <tr>
123 <th class="left">"interval"</th>
124 <th class="opt2">"tokens"</th>
125 <th>"saved"</th>
126 <th class="left opt2">"by source"</th>
127 </tr>
128 </thead>
129 <tbody>
130 for b in &per_bucket {
131 <tr>
132 <td class="left">(b.label.clone())</td>
133 <td class="opt2">(b.tokens.clone())</td>
134 <td class="cost">(b.money.clone())</td>
135 <td class="left opt2">(b.split.clone())</td>
136 </tr>
137 }
138 </tbody>
139 </table></div>
140 }
141 </section>
142 }
143 })
144}
145
146#[component]
148pub async fn filters(
149 query: Query,
150 theme: usize,
151 metric: Metric,
152 currency: Currency,
153) -> Result<impl View> {
154 Ok(view! {
155 <form class="filters" method="get" action="/">
156 <label>"chart "
157 <select name="metric" onchange="this.form.submit()">
158 for m in Metric::ALL {
159 <option value=(m.label()) selected=(m == metric)>(m.label())</option>
160 }
161 </select>
162 </label>
163 <label>"tool "
164 <select name="tool" onchange="this.form.submit()">
165 for t in ToolFilter::ALL {
166 <option value=(t.as_str()) selected=(t == query.tool)>(t.as_str())</option>
167 }
168 </select>
169 </label>
170 <label>"view "
171 <select name="bucket" onchange="this.form.submit()">
172 for b in Bucket::ALL {
173 <option value=(b.label()) selected=(b == query.bucket)>(b.label())</option>
174 }
175 </select>
176 </label>
177 <label>"window "
178 <select name="window" onchange="this.form.submit()">
179 for w in Window::ALL {
180 <option value=(w.label()) selected=(w == query.window)>(w.label())</option>
181 }
182 </select>
183 </label>
184
185 <label>"currency "
186 <select name="currency" onchange="this.form.submit()">
187 for cur in CURRENCIES {
188 <option value=(cur.code) selected=(cur.code == currency.code)>(format!("{} · {}", cur.code, cur.name))</option>
189 }
190 </select>
191 </label>
192 <label>"theme "
193 <select name="theme" onchange="this.form.submit()">
194 for n in theme::names() {
195 <option value=(n) selected=(n == theme::name(theme))>(n)</option>
196 }
197 </select>
198 </label>
199 <noscript><button type="submit">"Apply"</button></noscript>
200 </form>
201 })
202}
203
204#[component]
206pub async fn report_table(report: &[ReportRow], currency: Currency) -> Result<impl View> {
207 let bucketed = report.iter().any(|r| r.bucket.is_some());
208 let max = report
209 .iter()
210 .filter(|r| r.tool != "TOTAL")
211 .map(|r| r.summary.total_tokens)
212 .max()
213 .unwrap_or(0);
214
215 Ok(view! {
216 <div class="table-wrap"><table>
217 <thead>
218 <tr>
219 if bucketed { <th>"bucket"</th> }
220 <th class="left">"tool"</th>
221 <th class="opt2">"turns"</th>
222 <th class="opt2">"input"</th>
223 <th class="opt2">"output"</th>
224 <th class="opt1">"cache read"</th>
225 <th class="opt1">"cache write"</th>
226 <th>"total"</th>
227 <th>"cost"</th>
228 <th class="left opt2">"share"</th>
229 </tr>
230 </thead>
231 <tbody>
232 for r in report {
233 <tr class=(if r.tool == "TOTAL" { "total" } else { "" })>
234 if bucketed { <td>(r.bucket.clone().unwrap_or_default())</td> }
235 <td class="left">(r.tool.clone())</td>
236 <td class="opt2">(thousands(r.summary.turns))</td>
237 <td class="opt2">(thousands(r.summary.input))</td>
238 <td class="opt2">(thousands(r.summary.output))</td>
239 <td class="opt1">(thousands(r.summary.cache_read))</td>
240 <td class="opt1">(thousands(r.summary.cache_write))</td>
241 <td>(thousands(r.summary.total_tokens))</td>
242 <td class="cost">(currency.format(r.summary.cost_usd))</td>
243 <td class="left opt2">
244 if r.tool != "TOTAL" {
245 <span class="bar"><span style=(format!("width:{}%;background:{}", share_percent(r.summary.total_tokens, max), theme::series_var(&r.tool)))></span></span>
246 }
247 </td>
248 </tr>
249 }
250 </tbody>
251 </table></div>
252 })
253}
254
255#[component]
257pub async fn projects_table(
258 projects: &[(String, Summary)],
259 currency: Currency,
260) -> Result<impl View> {
261 let max = projects
262 .iter()
263 .map(|(_, s)| (s.cost_usd * 10_000.0) as u64)
264 .max()
265 .unwrap_or(0);
266
267 Ok(view! {
268 <div class="table-wrap"><table>
269 <thead>
270 <tr>
271 <th class="left">"tool:project/model"</th>
272 <th class="opt2">"turns"</th>
273 <th>"total tokens"</th>
274 <th>"cost"</th>
275 <th class="left opt2">"share"</th>
276 </tr>
277 </thead>
278 <tbody>
279 for (name, s) in projects {
280 <tr>
281 <td class="left">(name.clone())</td>
282 <td class="opt2">(thousands(s.turns))</td>
283 <td>(thousands(s.total_tokens))</td>
284 <td class="cost">(currency.format(s.cost_usd))</td>
285 <td class="left opt2">
286 <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>
287 </td>
288 </tr>
289 }
290 </tbody>
291 </table></div>
292 })
293}
294
295#[component]
298pub async fn live_filters(
299 tool: &Signal<String>,
300 bucket: &Signal<String>,
301 window: &Signal<String>,
302 theme: &Signal<String>,
303 metric: &Signal<String>,
304 currency: &Signal<String>,
305) -> Result<impl View> {
306 let cur = currency.get_untracked();
307 let (t, b, w, th, m) = (
308 tool.get_untracked(),
309 bucket.get_untracked(),
310 window.get_untracked(),
311 theme.get_untracked(),
312 metric.get_untracked(),
313 );
314 Ok(view! {
315 <div class="filters">
316 <label>"chart "
317 <select :value=$(metric.get()) @change=$(|e: Event| metric.set(e.target.value))>
318 for x in Metric::ALL {
319 <option value=(x.label()) selected=(x.label() == m)>(x.label())</option>
320 }
321 </select>
322 </label>
323 <label>"tool "
324 <select :value=$(tool.get()) @change=$(|e: Event| tool.set(e.target.value))>
325 for x in ToolFilter::ALL {
326 <option value=(x.as_str()) selected=(x.as_str() == t)>(x.as_str())</option>
327 }
328 </select>
329 </label>
330 <label>"view "
331 <select :value=$(bucket.get()) @change=$(|e: Event| bucket.set(e.target.value))>
332 for x in Bucket::ALL {
333 <option value=(x.label()) selected=(x.label() == b)>(x.label())</option>
334 }
335 </select>
336 </label>
337 <label>"window "
338 <select :value=$(window.get()) @change=$(|e: Event| window.set(e.target.value))>
339 for x in WINDOW_CHOICES {
340 <option value=(x) selected=(x == w)>(x)</option>
341 }
342 </select>
343 </label>
344
345 <label>"currency "
346 <select :value=$(currency.get()) @change=$(|e: Event| currency.set(e.target.value))>
347 for cc in CURRENCIES {
348 <option value=(cc.code) selected=(cc.code == cur)>(format!("{} · {}", cc.code, cc.name))</option>
349 }
350 </select>
351 </label>
352 <label>"theme "
353 <select :value=$(theme.get()) @change=$(|e: Event| theme.set(e.target.value))>
354 for n in theme::names() {
355 <option value=(n) selected=(n == th)>(n)</option>
356 }
357 </select>
358 </label>
359 </div>
360 })
361}
362
363#[component]
368pub async fn dashboard_body(
369 d: &Dashboard,
370 live: bool,
371 metric: Metric,
372 base: &str,
373 click_bucket: Option<&Signal<String>>,
374 click_project: Option<&Signal<String>>,
375) -> Result<impl View> {
376 Ok(view! {
377 for w in &d.warnings {
378 <div class="warn">"⚠ " (w.clone())</div>
379 }
380
381 summary_cards(metric: metric, total: d.total, currency: d.currency, savings: &d.savings)
382
383
384 charts_panel(d: d, metric: metric, base: base, animate: live, click_bucket: click_bucket, click_project: click_project)
385
386 if metric == Metric::Saved {
387 savings_panel(savings: &d.savings, currency: d.currency, spent_usd: d.total.cost_usd, input_price: d.input_price, buckets: &d.savings_by_bucket, noun: bucket_noun(&d.query))
388 }
389
390 <section>
391 <h2>"Report · " (d.query.bucket.label())</h2>
392 if d.report.is_empty() {
393 <div class="empty">
394 "No tokenburn results found for tool=" (d.query.tool.as_str())
395 ", window=" (d.query.window.label()) "."
396 </div>
397 } else {
398 report_table(report: &d.report, currency: d.currency)
399 }
400 </section>
401
402 if tokenburn_core::projects_add_detail(&d.report, &d.projects) {
403 <section>
404 <h2>"Projects / models (by cost)"</h2>
405 projects_table(projects: &d.projects, currency: d.currency)
406 </section>
407 }
408
409 <footer>
410 if live {
411 <span class="badge live">"● live"</span>
412 } else {
413 <span class="badge">"○ static"</span>
414 }
415 " "
416 (d.row_count) " rows · updated " (d.updated_at.clone())
417 " · local logs only · JSON at "
418 <a href="/api/report">"/api/report"</a>
419 </footer>
420 })
421}
422
423#[component]
425pub async fn theme_style(index: usize) -> Result<impl View> {
426 let css = theme::css(index);
427 Ok(view! { <style>(css)</style> })
428}
429
430#[derive(Clone, Copy)]
437pub struct Clicks<'a> {
438 pub bucket: Option<&'a Signal<String>>,
439 pub project: Option<&'a Signal<String>>,
440}
441
442#[component]
445pub async fn bar_col(col: &Col) -> Result<impl View> {
446 Ok(view! {
447 <title>(col.title.clone())</title>
448 <rect class="hit" x=(col.slot_x.clone()) y=(col.plot_y.clone()) width=(col.slot_w.clone()) height=(col.plot_h.clone())></rect>
449 for seg in &col.segs {
450 <rect class="seg" x=(seg.x.clone()) y=(seg.y.clone()) width=(seg.w.clone()) height=(seg.h.clone()) fill=(seg.var.clone())></rect>
451 }
452 <rect class="outline" x=(col.slot_x.clone()) y=(col.plot_y.clone()) width=(col.slot_w.clone()) height=(col.plot_h.clone())></rect>
453 })
454}
455
456#[component]
458pub async fn slice_path(slice: &SliceGeom) -> Result<impl View> {
459 Ok(view! {
460 <path d=(slice.path.clone()) fill=(slice.var.clone())>
461 <title>(slice.title.clone())</title>
462 </path>
463 })
464}
465
466fn col_class(c: &Col) -> String {
467 if c.selected {
468 "col sel".into()
469 } else {
470 "col".into()
471 }
472}
473
474fn slice_class(s: &SliceGeom) -> String {
475 let mut c = String::from("slice");
476 if s.selected {
477 c.push_str(" sel");
478 }
479 if s.key.is_none() {
480 c.push_str(" other");
481 }
482 c
483}
484
485fn bar_delay(index: usize, n: usize) -> String {
487 let step = (600.0 / n.max(1) as f32).min(14.0);
488 format!("--d:{:.0}ms", step * index as f32)
489}
490
491#[component]
493pub async fn bars_chart(
494 bars: &Bars,
495 metric: Metric,
496 base: &str,
497 sel: &Selection,
498 animate: bool,
499 click: Option<&Signal<String>>,
500) -> Result<impl View> {
501 let n = bars.cols.len();
502 let cols: Vec<(Col, String, String, String)> = bars
504 .cols
505 .iter()
506 .map(|c| {
507 let mut next = sel.clone();
508 next.toggle_bucket(&c.label);
509 (
510 c.clone(),
511 charts::href(base, &next),
512 next.bucket.clone().unwrap_or_default(),
513 format!("{} {}", col_class(c), if animate { "anim" } else { "" }),
514 )
515 })
516 .collect();
517 let class = format!(
518 "chart bars{}{}",
519 if bars.has_selection { " has-sel" } else { "" },
520 if animate { " animate" } else { "" }
521 );
522 let label = format!("{} over time", metric.label());
523
524 Ok(view! {
525 <svg class=(class) viewBox=(format!("0 0 {} {}", charts::BARS_W, charts::BARS_H)) width="100%" role="img" aria-label=(label)>
526 if bars.empty {
527 <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>
528 }
529 for g in &bars.grid {
530 <line class="grid" x1=(bars.left.clone()) x2=(bars.right.clone()) y1=(g.y.clone()) y2=(g.y.clone())></line>
531 <text class="axis" x=(charts::LEFT - 6.0) y=(g.text_y.clone()) text-anchor="end">(g.label.clone())</text>
532 }
533 for (c, href, next, class) in cols {
534 if let Some(sig) = click {
535 <g class=(class) style=(bar_delay(c.index, n)) @click=$(|_e| sig.set(next))>
536 bar_col(col: &c)
537 </g>
538 } else {
539 <a href=(href)>
540 <g class=(class) style=(bar_delay(c.index, n))>
541 bar_col(col: &c)
542 </g>
543 </a>
544 }
545 }
546 for l in &bars.x_labels {
547 <text class=(if l.alt { "axis alt" } else { "axis" }) x=(l.x.clone()) y=(l.y.clone()) text-anchor="middle">(l.text.clone())</text>
548 }
549 </svg>
550 })
551}
552
553#[component]
555pub async fn donut_chart(
556 donut: &charts::Donut,
557 base: &str,
558 sel: &Selection,
559 animate: bool,
560 click: Option<&Signal<String>>,
561) -> Result<impl View> {
562 let slices: Vec<(SliceGeom, String, String, String)> = donut
563 .slices
564 .iter()
565 .map(|s| {
566 let mut next = sel.clone();
567 if let Some(k) = &s.key {
568 next.toggle_project(k);
569 }
570 (
571 s.clone(),
572 charts::href(base, &next),
573 next.project.clone().unwrap_or_default(),
574 format!("--dx:{};--dy:{}", s.dx, s.dy),
575 )
576 })
577 .collect();
578 let class = format!(
579 "chart donut{}{}",
580 if donut.has_selection { " has-sel" } else { "" },
581 if animate { " animate" } else { "" }
582 );
583 let total = donut.total_label.clone();
584
585 Ok(view! {
586 <svg class=(class) viewBox="0 0 200 200" width="100%" role="img" aria-label="share by project">
587 if donut.empty {
588 <text class="empty-note" x="100" y="104" text-anchor="middle">"No data"</text>
589 } else {
590 <defs>
591 <mask id="sweep" maskUnits="userSpaceOnUse" x="0" y="0" width="200" height="200">
592 <circle class="sweep" cx="100" cy="100" r="74" fill="none" stroke="white" stroke-width="60" pathLength="1" transform="rotate(-90 100 100)"></circle>
593 </mask>
594 </defs>
595 <g mask="url(#sweep)">
596 for (s, href, next, vars) in slices {
597 if s.key.is_none() {
598 <g class=(slice_class(&s)) style=(vars)>
599 slice_path(slice: &s)
600 </g>
601 } else if let Some(sig) = click {
602 <g class=(slice_class(&s)) style=(vars) @click=$(|_e| sig.set(next))>
603 slice_path(slice: &s)
604 </g>
605 } else {
606 <a href=(href)>
607 <g class=(slice_class(&s)) style=(vars)>
608 slice_path(slice: &s)
609 </g>
610 </a>
611 }
612 }
613 </g>
614 <text class="total" x="100" y="106" text-anchor="middle">(total)</text>
615 }
616 </svg>
617 })
618}
619
620#[component]
622pub async fn selection_bar(
623 sel: &Selection,
624 base: &str,
625 click_bucket: Option<&Signal<String>>,
626 click_project: Option<&Signal<String>>,
627) -> Result<impl View> {
628 let chips = sel.describe();
629 let clear = charts::href(base, &Selection::default());
630 let (blank_a, blank_b) = (String::new(), String::new());
632 Ok(view! {
633 <div class="selbar">
634 if chips.is_empty() {
635 <span class="hint">"click a bar or a slice to drill down"</span>
636 } else {
637 <span class="hint">"Showing"</span>
638 for chip in chips {
639 <span class="chip">(chip)</span>
640 }
641 if let (Some(b), Some(p)) = (click_bucket, click_project) {
642 <button class="clear" @click=$(|_e| { b.set(blank_a); p.set(blank_b); })>"✕ clear"</button>
643 } else {
644 <a class="clear" href=(clear)>"✕ clear"</a>
645 }
646 }
647 </div>
648 })
649}
650
651#[component]
653pub async fn charts_panel(
654 d: &Dashboard,
655 metric: Metric,
656 base: &str,
657 animate: bool,
658 click_bucket: Option<&Signal<String>>,
659 click_project: Option<&Signal<String>>,
660) -> Result<impl View> {
661 let cur = d.currency;
662 let bars = charts::bars(
663 &d.series.in_currency(&cur),
664 metric,
665 d.selection.bucket.as_deref(),
666 &cur,
667 );
668 let donut = charts::donut(
669 &d.pie.in_currency(&cur),
670 d.selection.project.as_deref(),
671 &cur,
672 );
673 let sel = d.selection.clone();
674 let pie_metric = d.pie.metric;
675
676 let legend: Vec<(SliceGeom, String, String)> = donut
678 .slices
679 .iter()
680 .map(|s| {
681 let mut next = sel.clone();
682 if let Some(k) = &s.key {
683 next.toggle_project(k);
684 }
685 (
686 s.clone(),
687 charts::href(base, &next),
688 next.project.clone().unwrap_or_default(),
689 )
690 })
691 .collect();
692 let swatches: Vec<(String, String)> = d
693 .series
694 .active_tools(metric)
695 .into_iter()
696 .map(|t| (charts::tool_var(t), t.display_name().to_string()))
697 .collect();
698
699 Ok(view! {
700 <section class="charts">
701 <div class="panel">
702 <h2>(chart_title(metric))</h2>
703 bars_chart(bars: &bars, metric: metric, base: base, sel: &sel, animate: animate, click: click_bucket)
704 <ul class="legend">
705 for (var, name) in swatches {
706 <li><span class="sw" style=(format!("background:{var}"))></span>(name)</li>
707 }
708 </ul>
709 </div>
710 <div class="panel">
711 <h2>"Share by project · " (pie_metric.label())</h2>
712 <div class="donut-row">
713 <div class="donut-box">
714 donut_chart(donut: &donut, base: base, sel: &sel, animate: animate, click: click_project)
715 </div>
716 <ul class=(if donut.has_selection { "legend vertical has-sel" } else { "legend vertical" })>
717 for (s, href, next) in legend {
718 if s.key.is_none() {
719 <li class="item other"><span class="sw" style=(format!("background:{}", s.var))></span>(s.label.clone()) <span class="pct">(s.pct.clone())</span></li>
720 } else if let Some(sig) = click_project {
721 <li class=(if s.selected { "item sel" } else { "item" }) @click=$(|_e| sig.set(next))>
722 <span class="sw" style=(format!("background:{}", s.var))></span>(s.label.clone()) <span class="pct">(s.pct.clone())</span>
723 </li>
724 } else {
725 <li class=(if s.selected { "item sel" } else { "item" })>
726 <a href=(href)><span class="sw" style=(format!("background:{}", s.var))></span>(s.label.clone()) <span class="pct">(s.pct.clone())</span></a>
727 </li>
728 }
729 }
730 </ul>
731 </div>
732 </div>
733 </section>
734 selection_bar(sel: &sel, base: base, click_bucket: click_bucket, click_project: click_project)
735 })
736}