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>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-09 06:07:03 +01:00
1 parent 873447ed17
commit 01c1484033
25 files changed
+1720 -47

No files matched your search

+80
View File
@@ -0,0 +1,80 @@
//! 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");
}
}
}
});
}