1use 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#[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#[component]
38pub async fn summary_cards(total: Summary, currency: Currency) -> Result<impl View> {
39 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#[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#[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#[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#[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#[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#[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#[derive(Clone, Copy)]
338pub struct Clicks<'a> {
339 pub bucket: Option<&'a Signal<String>>,
340 pub project: Option<&'a Signal<String>>,
341}
342
343#[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#[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
386fn 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#[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 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#[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#[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 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#[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 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}