Files
codecraft/server/src/digest.rs
T
Richard ZinkandClaude Opus 5.5 01c1484033 Add backups with S3, daily cost push, automation templates and a browser tool
- Daily backups (database + config) into <data_dir>/backups, keep N,
  optional upload to any S3-compatible bucket (SigV4, bucket pruning);
  settings panel with run-now, list and download
- Daily cost summary push at 20:00 (opt-in per user, preview button)
- Automations from templates (dependency check, security review, test
  health, daily summary, TODO report, docs freshness) via POST /jobs/create
- browser tool: headless Chromium over CDP – click, type, keys, waits,
  JavaScript, navigation, screenshots, console errors; requests to private
  network addresses are blocked (sub-resources and redirects included)
- Secrets masked in the config view: S3 secret key, metrics token
- Replace the remaining text arrows/refresh glyphs with SVG icons

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 06:07:03 +01:00

81 lines
3.4 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Daily cost summary as a push notification (20:00 local time, opt-in per
//! user via the user setting "cost_push").
use crate::app::App;
use chrono::{Local, Timelike};
use serde_json::{Value, json};
use std::sync::Arc;
use std::time::Duration;
const HOUR: u32 = 20;
fn money(v: f64, currency: &str, de: bool) -> String {
let s = format!("{v:.2}");
let s = if de { s.replace('.', ",") } else { s };
match currency {
"" | "USD" => if de { format!("{s} $") } else { format!("${s}") },
"EUR" => format!("{s} €"),
c => format!("{s} {c}"),
}
}
/// Today's summary for one user (None when nothing was used).
pub fn summary(app: &App, user_id: &str) -> Option<Value> {
let now = Local::now();
let start = now.with_hour(0)?.with_minute(0)?.with_second(0)?.timestamp_millis();
let offset = now.offset().local_minus_utc() as i64 / 60;
let stats = app.store.global_stats(start, now.timestamp_millis() + 1, offset, Some(user_id)).ok()?;
let requests = stats["total"]["requests"].as_i64().unwrap_or(0);
if requests == 0 {
return None;
}
let prefs: Value = app.store.user_setting(user_id, "prefs").unwrap_or_default();
let de = prefs["lang"].as_str().unwrap_or("").starts_with("de");
let currency = app.config().server.currency.clone();
let cost = stats["total"]["cost"].as_f64().unwrap_or(0.0);
let top = stats["by_model"]
.as_array()
.and_then(|m| m.iter().max_by(|a, b| a["cost"].as_f64().unwrap_or(0.0).total_cmp(&b["cost"].as_f64().unwrap_or(0.0))))
.and_then(|m| m["model"].as_str())
.unwrap_or("")
.to_string();
let sessions = stats["top_sessions"].as_array().map(|a| a.len()).unwrap_or(0);
let body = if de {
format!("{} · {requests} Anfragen in {sessions} Chats{}", money(cost, &currency, true), if top.is_empty() { String::new() } else { format!(" · meist {top}") })
} else {
format!("{} · {requests} requests in {sessions} chats{}", money(cost, &currency, false), if top.is_empty() { String::new() } else { format!(" · mostly {top}") })
};
Some(json!({
"kind": "digest",
"title": if de { "CodeCraft – Kosten heute" } else { "CodeCraft – today's costs" },
"body": body,
"tag": "cost-digest",
"url": "/#/stats",
}))
}
pub fn spawn(app: Arc<App>) {
tokio::spawn(async move {
loop {
tokio::time::sleep(Duration::from_secs(60)).await;
let now = Local::now();
if now.hour() != HOUR {
continue;
}
let today = now.format("%Y-%m-%d").to_string();
for user in app.store.users().unwrap_or_default() {
let on: bool = app.store.user_setting(&user.id, "cost_push").unwrap_or(false);
let last: String = app.store.user_setting(&user.id, "cost_push_last").unwrap_or_default();
if !on || user.disabled || last == today {
continue;
}
let _ = app.store.set_user_setting(&user.id, "cost_push_last", &today);
if let Some(payload) = summary(&app, &user.id) {
let (sent, errors) = crate::push::send_to_user(&app, &user.id, &payload).await;
tracing::info!(user = %user.username, sent, errors = errors.len(), "daily cost summary");
}
}
}
});
}