1use 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#[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#[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#[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#[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#[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#[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#[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#[derive(Clone, Copy)]
293pub struct Clicks<'a> {
294 pub bucket: Option<&'a Signal<String>>,
295 pub project: Option<&'a Signal<String>>,
296}
297
298#[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#[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
341fn 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#[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 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#[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#[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 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#[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 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}