Skip to main content

tokenburn_web/
data.rs

1//! Loading and shaping the data shown on the dashboard.
2
3use chrono::Local;
4use tokenburn_core::utils::fmt::{compact, percent, thousands};
5use tokenburn_core::{
6    BucketSavings, Collected, Currency, Pie, Query, ReportRow, SavedInput, Savings, Selection,
7    Summary, TimeSeries, collect, drill_priced,
8};
9
10/// Everything a page render needs.
11#[derive(Debug, Clone)]
12pub struct Dashboard {
13    pub query: Query,
14    pub total: Summary,
15    /// What prompt caching, rtk and pi compaction saved in this selection.
16    pub savings: Savings,
17    /// Savings per chart bucket (minute, hour, day or month), oldest first.
18    pub savings_by_bucket: Vec<BucketSavings>,
19    /// US dollars per million input tokens the savings are valued at.
20    pub input_price: f64,
21    pub report: Vec<ReportRow>,
22    pub projects: Vec<(String, Summary)>,
23    pub warnings: Vec<String>,
24    pub row_count: usize,
25    /// Stacked per-tool totals over time (the bar chart).
26    pub series: TimeSeries,
27    /// Share by project (the donut).
28    pub pie: Pie,
29    /// The clicked bar and/or slice that everything above is scoped to (stale
30    /// parts already dropped).
31    pub selection: Selection,
32    /// The display currency (costs in the data stay US dollars).
33    pub currency: Currency,
34    /// Local wall-clock time the snapshot was taken (`HH:MM:SS`).
35    pub updated_at: String,
36    /// Spending limits and how much of each is used (filled by [`load`]).
37    pub limits: tokenburn_core::LimitCheck,
38}
39
40/// Shape collected rows into a [`Dashboard`] scoped to `selection` (pure — no I/O).
41pub fn build_dashboard(
42    query: Query,
43    collected: Collected,
44    selection: &Selection,
45    currency: Currency,
46    input_price: f64,
47) -> Dashboard {
48    let d = drill_priced(
49        &collected.rows,
50        &query,
51        selection,
52        &SavedInput {
53            price: input_price,
54            external: Some(&collected.external),
55        },
56    );
57    Dashboard {
58        query,
59        total: d.total,
60        savings: d.savings,
61        savings_by_bucket: d.savings_by_bucket,
62        input_price,
63        report: d.report,
64        series: d.series,
65        pie: d.pie,
66        projects: d.projects,
67        selection: d.selection,
68        currency,
69        row_count: collected.rows.len(),
70        updated_at: Local::now().format("%H:%M:%S").to_string(),
71        warnings: collected.warnings,
72        limits: Default::default(),
73    }
74}
75
76/// How often a connected page re-reads the logs.
77pub const REFRESH_EVERY: std::time::Duration = std::time::Duration::from_secs(5);
78
79/// Read the logs on a blocking thread and build the [`Dashboard`].
80pub async fn load(query: Query, selection: &Selection, currency: Currency) -> Dashboard {
81    let collected =
82        tokio::task::spawn_blocking(move || collect(query.tool, query.window.start(Local::now())))
83            .await
84            .unwrap_or_else(|e| Collected {
85                rows: Vec::new(),
86                warnings: vec![format!("loader failed: {e}")],
87                ..Default::default()
88            });
89    let mut d = build_dashboard(
90        query,
91        collected,
92        selection,
93        currency,
94        tokenburn_core::settings::input_price(),
95    );
96    d.limits = check_limits(currency).await;
97    d
98}
99
100/// Check the spending limits on a blocking thread, read-only: pages only *show* how
101/// much is used. The alarm (log + desktop notification) is the server's watcher.
102pub async fn check_limits(currency: Currency) -> tokenburn_core::LimitCheck {
103    tokio::task::spawn_blocking(move || {
104        tokenburn_core::run_limit_check(false, |v| currency.format(v))
105    })
106    .await
107    .unwrap_or_default()
108}
109
110/// How often the server looks for newly crossed limits, with or without a page open.
111pub const WATCH_EVERY: std::time::Duration = std::time::Duration::from_secs(30);
112
113/// One watcher pass: raise alerts nobody raised yet (logged, so the TUI, GUI and CLI
114/// stay quiet about them) as desktop notifications. Returns the alerts.
115pub async fn watch_limits() -> Vec<tokenburn_core::Alert> {
116    tokio::task::spawn_blocking(|| {
117        let currency = tokenburn_core::settings::currency();
118        tokenburn_core::run_limit_check(true, |v| currency.format(v)).alerts
119    })
120    .await
121    .unwrap_or_default()
122}
123
124/// The percent of a limit used, for a progress bar (0–100).
125pub fn bar_percent(s: &tokenburn_core::LimitStatus) -> u32 {
126    s.percent().min(100)
127}
128
129/// CSS class of a limit's state.
130pub fn level_class(level: tokenburn_core::Level) -> &'static str {
131    match level {
132        tokenburn_core::Level::Ok => "limit ok",
133        tokenburn_core::Level::Warn => "limit warn",
134        tokenburn_core::Level::Over => "limit over",
135    }
136}
137
138/// Apply submitted fields to `current`: empty or `0` clears a limit, anything that
139/// is not a non-negative number is an error (nothing changes).
140pub fn apply_limit_params(
141    current: &tokenburn_core::Limits,
142    p: &crate::params::LimitParams,
143) -> Result<tokenburn_core::Limits, String> {
144    use tokenburn_core::Period;
145    let mut out = current.clone();
146    for (period, text) in [
147        (Period::Day, &p.limit_daily),
148        (Period::Week, &p.limit_weekly),
149        (Period::Month, &p.limit_monthly),
150    ] {
151        let t = text.as_deref().unwrap_or("").trim();
152        if t.is_empty() {
153            out.set(period, None);
154        } else {
155            match t.parse::<f64>() {
156                Ok(v) if v.is_finite() && v >= 0.0 => out.set(period, Some(v)),
157                _ => {
158                    return Err(format!(
159                        "{} limit must be a number of US dollars (got '{t}')",
160                        period.label()
161                    ));
162                }
163            }
164        }
165    }
166    out.notify = p.notify.is_some();
167    Ok(out)
168}
169
170/// One printed row of the savings table.
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct SavingsRow {
173    pub label: String,
174    pub tokens: String,
175    pub money: String,
176    pub basis: String,
177    pub total: bool,
178}
179
180/// The savings table: a row per source and a closing total (empty when nothing
181/// was saved). `spent_usd` gives the share of the would-be cost.
182pub fn savings_rows(sv: &Savings, currency: Currency, spent_usd: f64) -> Vec<SavingsRow> {
183    if sv.is_empty() {
184        return Vec::new();
185    }
186    let mut rows: Vec<SavingsRow> = sv
187        .sources
188        .iter()
189        .map(|s| SavingsRow {
190            label: s.source.label().to_string(),
191            tokens: thousands(s.tokens),
192            money: currency.format(s.usd),
193            basis: format!("{} {}", thousands(s.events), s.source.event_noun()),
194            total: false,
195        })
196        .collect();
197    rows.push(SavingsRow {
198        label: "total".to_string(),
199        tokens: thousands(sv.tokens),
200        money: currency.format(sv.usd),
201        basis: sv
202            .percent_of_would_be(spent_usd)
203            .map(|p| format!("{} of the would-be cost", percent(p)))
204            .unwrap_or_default(),
205        total: true,
206    });
207    rows
208}
209
210/// One printed row of the per-interval savings table.
211#[derive(Debug, Clone, PartialEq, Eq)]
212pub struct BucketRow {
213    pub label: String,
214    pub tokens: String,
215    pub money: String,
216    /// `Prompt cache $9.00 · rtk $1.00`
217    pub split: String,
218}
219
220/// The per-interval savings table, newest first, at most `limit` rows. The
221/// interval is the chart's: a minute, hour, day or month.
222pub fn bucket_rows(buckets: &[BucketSavings], currency: Currency, limit: usize) -> Vec<BucketRow> {
223    buckets
224        .iter()
225        .rev()
226        .take(limit)
227        .map(|b| BucketRow {
228            label: b.label.clone(),
229            tokens: thousands(b.savings.tokens),
230            money: currency.format(b.savings.usd),
231            split: b
232                .savings
233                .sources
234                .iter()
235                .map(|s| format!("{} {}", s.source.label(), currency.format(s.usd)))
236                .collect::<Vec<_>>()
237                .join(" · "),
238        })
239        .collect()
240}
241
242/// `minute`, `hour`, `day` or `month`: the interval a query's savings are grouped by.
243pub fn bucket_noun(query: &Query) -> &'static str {
244    match tokenburn_core::chart_bucket(query) {
245        tokenburn_core::Bucket::Minute => "minute",
246        tokenburn_core::Bucket::Hour => "hour",
247        tokenburn_core::Bucket::Day => "day",
248        _ => "month",
249    }
250}
251
252/// How many intervals the savings table lists.
253pub const MAX_BUCKET_ROWS: usize = 24;
254
255/// `compact` tokens + money, for the headline card's sub-label.
256pub fn savings_headline(sv: &Savings, currency: Currency) -> String {
257    format!("{} tok · {}", compact(sv.tokens), currency.format(sv.usd))
258}
259
260/// Width (0–100) of a share bar for `value` against `max`.
261pub fn share_percent(value: u64, max: u64) -> u32 {
262    if max == 0 {
263        0
264    } else {
265        ((value as f64 / max as f64) * 100.0)
266            .round()
267            .clamp(0.0, 100.0) as u32
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274    use tokenburn_core::{Row, Tool};
275
276    fn row(cost: f64) -> Row {
277        Row {
278            tool: Tool::Pi,
279            project: "p".into(),
280            id: "i".into(),
281            ts: "2026-05-01T10:00:00Z".parse().unwrap(),
282            input: 10,
283            output: 5,
284            cache_read: 0,
285            cache_write: 0,
286            cost,
287        }
288    }
289
290    #[test]
291    fn dashboard_totals_and_counts() {
292        let c = Collected {
293            rows: vec![row(0.5), row(0.25)],
294            warnings: vec!["w".into()],
295            ..Default::default()
296        };
297        let d = build_dashboard(
298            Query::default(),
299            c,
300            &Selection::default(),
301            Currency::USD,
302            3.0,
303        );
304        assert_eq!(d.row_count, 2);
305        assert_eq!(d.total.input, 20);
306        assert_eq!(d.warnings.len(), 1);
307        assert_eq!(d.updated_at.len(), 8, "HH:MM:SS");
308        assert_eq!(d.report.last().unwrap().tool, "TOTAL");
309    }
310
311    #[test]
312    fn empty_dashboard_has_no_report() {
313        let d = build_dashboard(
314            Query::default(),
315            Collected::default(),
316            &Selection::default(),
317            Currency::USD,
318            3.0,
319        );
320        assert!(d.report.is_empty() && d.projects.is_empty());
321    }
322
323    #[test]
324    fn a_selection_scopes_the_dashboard_and_stale_ones_are_dropped() {
325        let c = || Collected {
326            rows: vec![row(0.5), row(0.25)],
327            warnings: vec![],
328            ..Default::default()
329        };
330        let sel = Selection {
331            project: Some("pi:p".into()),
332            ..Selection::default()
333        };
334        let d = build_dashboard(Query::default(), c(), &sel, Currency::USD, 3.0);
335        assert_eq!(d.selection, sel);
336        assert_eq!(d.total.turns, 2);
337
338        let stale = Selection {
339            project: Some("pi:ghost".into()),
340            bucket: Some("nope".into()),
341        };
342        let d = build_dashboard(Query::default(), c(), &stale, Currency::USD, 3.0);
343        assert!(d.selection.is_empty());
344        assert_eq!(d.total.turns, 2);
345    }
346
347    #[test]
348    fn share_percent_bounds() {
349        assert_eq!(share_percent(0, 0), 0);
350        assert_eq!(share_percent(50, 100), 50);
351        assert_eq!(share_percent(200, 100), 100);
352    }
353
354    fn cached(read: u64) -> Row {
355        Row {
356            cache_read: read,
357            ..row(0.5)
358        }
359    }
360
361    fn external() -> tokenburn_core::ExternalSavings {
362        tokenburn_core::ExternalSavings {
363            rtk_tokens: 1_000_000,
364            rtk_commands: 5,
365            compaction_tokens: 0,
366            compactions: 0,
367            ..Default::default()
368        }
369    }
370
371    #[test]
372    fn the_dashboard_carries_savings_valued_at_the_price() {
373        let c = Collected {
374            rows: vec![cached(1_000_000), row(0.5)],
375            external: external(),
376            ..Default::default()
377        };
378        let d = build_dashboard(
379            Query::default(),
380            c,
381            &Selection::default(),
382            Currency::USD,
383            10.0,
384        );
385        assert_eq!(d.input_price, 10.0);
386        assert_eq!(d.savings.tokens, 2_000_000);
387        assert!((d.savings.usd - 19.0).abs() < 1e-9, "9 cache + 10 rtk");
388    }
389
390    #[test]
391    fn a_project_selection_keeps_only_that_projects_cache() {
392        let mut other = cached(1_000_000);
393        other.project = "other".into();
394        let c = Collected {
395            rows: vec![cached(2_000_000), other],
396            external: external(),
397            ..Default::default()
398        };
399        let sel = Selection {
400            project: Some("pi:other".into()),
401            ..Selection::default()
402        };
403        let d = build_dashboard(Query::default(), c, &sel, Currency::USD, 10.0);
404        assert_eq!(
405            d.savings.tokens, 1_000_000,
406            "only the project's cache; rtk is not per project"
407        );
408    }
409
410    #[test]
411    fn savings_rows_have_a_line_per_source_and_a_total() {
412        let c = Collected {
413            rows: vec![cached(1_000_000)],
414            external: external(),
415            ..Default::default()
416        };
417        let d = build_dashboard(
418            Query::default(),
419            c,
420            &Selection::default(),
421            Currency::USD,
422            10.0,
423        );
424        let rows = savings_rows(&d.savings, d.currency, d.total.cost_usd);
425        let labels: Vec<&str> = rows.iter().map(|r| r.label.as_str()).collect();
426        assert_eq!(labels, ["Prompt cache", "rtk", "total"]);
427        assert_eq!(rows[0].money, "$9.00");
428        assert_eq!(
429            (rows[1].tokens.as_str(), rows[1].basis.as_str()),
430            ("1,000,000", "5 commands")
431        );
432        assert!(rows[2].total && !rows[0].total);
433        assert_eq!(rows[2].money, "$19.00");
434        assert!(
435            rows[2].basis.contains("97% of the would-be cost"),
436            "{}",
437            rows[2].basis
438        );
439        assert_eq!(
440            savings_headline(&d.savings, d.currency),
441            "2.00M tok · $19.00"
442        );
443        let eur = tokenburn_core::currency_by_code("EUR").unwrap();
444        assert_eq!(savings_rows(&d.savings, eur, 0.5)[2].money, "€17.48");
445    }
446
447    #[test]
448    fn no_savings_no_rows() {
449        let d = build_dashboard(
450            Query::default(),
451            Collected {
452                rows: vec![row(0.5)],
453                ..Default::default()
454            },
455            &Selection::default(),
456            Currency::USD,
457            3.0,
458        );
459        assert!(d.savings.is_empty());
460        assert!(savings_rows(&d.savings, d.currency, 0.5).is_empty());
461    }
462
463    fn at(ts: &str) -> chrono::DateTime<chrono::Utc> {
464        ts.parse().unwrap()
465    }
466
467    /// Day 1: 1M cache tokens + a compaction; day 2: rtk.
468    fn two_days() -> Collected {
469        let mut day1 = cached(1_000_000);
470        day1.ts = at("2026-05-01T10:00:00Z");
471        let mut day2 = row(0.5);
472        day2.ts = at("2026-05-02T10:00:00Z");
473        Collected {
474            rows: vec![day1, day2],
475            external: tokenburn_core::ExternalSavings {
476                rtk_tokens: 1_000_000,
477                rtk_commands: 4,
478                compaction_tokens: 1_000_000,
479                compactions: 1,
480                rtk_events: vec![(at("2026-05-02T10:00:00Z"), 1_000_000, 4)],
481                compaction_events: vec![(at("2026-05-01T10:00:00Z"), 1_000_000)],
482            },
483            ..Default::default()
484        }
485    }
486
487    #[test]
488    fn savings_are_listed_per_interval_newest_first() {
489        let d = build_dashboard(
490            Query::default(),
491            two_days(),
492            &Selection::default(),
493            Currency::USD,
494            10.0,
495        );
496        let rows = bucket_rows(&d.savings_by_bucket, d.currency, 10);
497        assert_eq!(rows.len(), 2);
498        assert!(rows[0].label > rows[1].label, "newest first");
499        assert_eq!(
500            (rows[0].money.as_str(), rows[0].split.as_str()),
501            ("$10.00", "rtk $10.00")
502        );
503        assert_eq!(rows[1].money, "$19.00");
504        assert_eq!(rows[1].split, "Prompt cache $9.00 · pi compaction $10.00");
505        assert_eq!(
506            bucket_rows(&d.savings_by_bucket, d.currency, 1).len(),
507            1,
508            "limit"
509        );
510        assert_eq!(bucket_noun(&d.query), "day", "month window charts by day");
511    }
512
513    #[test]
514    fn the_interval_follows_the_bucket_filter() {
515        let q = |bucket| Query {
516            bucket,
517            ..Query::default()
518        };
519        for (bucket, noun, rows) in [
520            (tokenburn_core::Bucket::Minute, "minute", 2),
521            (tokenburn_core::Bucket::Hour, "hour", 2),
522            (tokenburn_core::Bucket::Day, "day", 2),
523            (tokenburn_core::Bucket::Month, "month", 1),
524        ] {
525            let d = build_dashboard(
526                q(bucket),
527                two_days(),
528                &Selection::default(),
529                Currency::USD,
530                10.0,
531            );
532            assert_eq!(bucket_noun(&d.query), noun);
533            assert_eq!(d.savings_by_bucket.len(), rows, "{noun}");
534        }
535    }
536
537    #[test]
538    fn a_selected_bar_narrows_every_source_to_that_interval() {
539        let day1 = tokenburn_core::bucket_label_at(
540            at("2026-05-01T10:00:00Z"),
541            tokenburn_core::Bucket::Day,
542        );
543        let sel = Selection {
544            bucket: Some(day1),
545            ..Selection::default()
546        };
547        let d = build_dashboard(Query::default(), two_days(), &sel, Currency::USD, 10.0);
548        assert!(
549            (d.savings.usd - 19.0).abs() < 1e-9,
550            "cache + compaction, no rtk: {}",
551            d.savings.usd
552        );
553        assert_eq!(
554            d.savings_by_bucket.len(),
555            2,
556            "the list keeps every interval, like the bars"
557        );
558    }
559
560    #[test]
561    fn a_selected_project_drops_the_unattributed_sources() {
562        let sel = Selection {
563            project: Some("pi:p".into()),
564            ..Selection::default()
565        };
566        let d = build_dashboard(Query::default(), two_days(), &sel, Currency::USD, 10.0);
567        assert_eq!(d.savings.sources.len(), 1);
568        assert!((d.savings.usd - 9.0).abs() < 1e-9);
569    }
570
571    fn params(d: &str, w: &str, m: &str, notify: bool) -> crate::params::LimitParams {
572        crate::params::LimitParams {
573            save: Some("1".into()),
574            limit_daily: Some(d.into()),
575            limit_weekly: Some(w.into()),
576            limit_monthly: Some(m.into()),
577            notify: notify.then(|| "on".into()),
578        }
579    }
580
581    #[test]
582    fn limit_fields_parse_clear_and_reject() {
583        use tokenburn_core::{Limits, Period};
584        let cur = Limits::default();
585        let l = apply_limit_params(&cur, &params("20", " 100.5 ", "", true)).unwrap();
586        assert_eq!(
587            (
588                l.get(Period::Day),
589                l.get(Period::Week),
590                l.get(Period::Month)
591            ),
592            (Some(20.0), Some(100.5), None)
593        );
594        assert!(l.notify);
595        let l = apply_limit_params(&l, &params("0", "", "", false)).unwrap();
596        assert!(
597            l.is_empty() && !l.notify,
598            "0 and empty clear; unchecked = off"
599        );
600        for bad in ["abc", "-5", "inf", "NaN", "1e999"] {
601            let e = apply_limit_params(&cur, &params("1", bad, "", true)).unwrap_err();
602            assert!(e.contains("weekly limit must be a number"), "{bad}: {e}");
603        }
604        assert!(
605            apply_limit_params(&cur, &params("x", "", "", true))
606                .unwrap_err()
607                .contains("daily")
608        );
609    }
610
611    #[test]
612    fn bars_are_capped_and_levels_have_classes() {
613        use tokenburn_core::{Level, LimitStatus, Period};
614        let s = |spent: f64| LimitStatus {
615            period: Period::Day,
616            limit_usd: 10.0,
617            spent_usd: spent,
618            level: Level::Ok,
619        };
620        assert_eq!(
621            (
622                bar_percent(&s(0.0)),
623                bar_percent(&s(5.0)),
624                bar_percent(&s(99.0))
625            ),
626            (0, 50, 100)
627        );
628        assert_eq!(level_class(Level::Ok), "limit ok");
629        assert_eq!(level_class(Level::Warn), "limit warn");
630        assert_eq!(level_class(Level::Over), "limit over");
631    }
632}