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 summary_cards(total: Summary, currency: Currency) -> Result<impl View> {
22 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#[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#[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#[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#[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#[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#[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#[derive(Clone, Copy)]
321pub struct Clicks<'a> {
322 pub bucket: Option<&'a Signal<String>>,
323 pub project: Option<&'a Signal<String>>,
324}
325
326#[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#[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
369fn 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#[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 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#[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#[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 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#[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 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}