diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index abed315..33a8c1c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ mod terminal; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +use std::collections::{HashMap, HashSet}; use std::fs; use std::io::{BufRead, BufReader, Write}; use std::net::TcpListener; @@ -391,6 +392,191 @@ fn oauth_credential_path(paths: &Paths, pool: &str) -> Result { Ok(paths.pool_dir(pool).join(CREDENTIALS_FILE)) } +// ---------- Verbrauch ---------- + +#[derive(Serialize, Default)] +struct UsageTotals { + #[serde(rename = "inputTokens")] + input_tokens: u64, + #[serde(rename = "outputTokens")] + output_tokens: u64, + #[serde(rename = "cacheCreationTokens")] + cache_creation_tokens: u64, + #[serde(rename = "cacheReadTokens")] + cache_read_tokens: u64, + #[serde(rename = "costUsd")] + cost_usd: f64, +} + +#[derive(Serialize)] +struct UsageRow { + pool: String, + project: String, + #[serde(flatten)] + totals: UsageTotals, +} + +/// USD pro MTok (input, output, cache_write, cache_read). +/// Cache: write 1.25× / read 0.1× des Input-Preises. +fn model_rates(model: &str) -> (f64, f64, f64, f64) { + let (input, output) = if model.contains("fable") || model.contains("mythos") { + (10.0, 50.0) + } else if model.contains("opus") { + (5.0, 25.0) + } else if model.contains("haiku") { + (1.0, 5.0) + } else { + // sonnet und Unbekanntes + (3.0, 15.0) + }; + (input, output, input * 1.25, input * 0.1) +} + +/// Tage seit Unix-Epoche für ein Kalenderdatum (Howard Hinnant, days_from_civil). +fn days_from_civil(y: i64, m: i64, d: i64) -> i64 { + let y = if m <= 2 { y - 1 } else { y }; + let era = if y >= 0 { y } else { y - 399 } / 400; + let yoe = y - era * 400; + let mp = (m + 9) % 12; + let doy = (153 * mp + 2) / 5 + d - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + era * 146097 + doe - 719468 +} + +/// "YYYY-MM-DDTHH:MM:SS(.mmm)Z" (UTC) → Unix-Sekunden. +fn parse_ts(ts: &str) -> Option { + if ts.len() < 19 { + return None; + } + let num = |r: std::ops::Range| ts.get(r)?.parse::().ok(); + let (y, m, d) = (num(0..4)?, num(5..7)?, num(8..10)?); + let (h, mi, s) = (num(11..13)?, num(14..16)?, num(17..19)?); + Some(days_from_civil(y, m, d) * 86400 + h * 3600 + mi * 60 + s) +} + +/// Projektpfad so kodieren, wie claude die Transcript-Ordner benennt +/// (alles außer [a-zA-Z0-9] wird '-'). +fn encode_project_path(path: &str) -> String { + path + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect() +} + +/// Eine Transcript-Zeile auswerten: nur Zeilen mit message.usage zählen, +/// Zeitfilter, Dedup über message.id+requestId (Retries doppeln sonst). +fn add_usage_line( + line: &str, + cutoff: i64, + seen: &mut HashSet, + totals: &mut UsageTotals, +) { + let v: serde_json::Value = match serde_json::from_str(line) { + Ok(v) => v, + // Nicht-JSON (z. B. angeschnittene letzte Zeile einer laufenden Session) + Err(_) => return, + }; + let usage = &v["message"]["usage"]; + if !usage.is_object() { + return; + } + match v["timestamp"].as_str().and_then(parse_ts) { + Some(t) if t >= cutoff => {} + _ => return, + } + if let Some(id) = v["message"]["id"].as_str() { + let key = format!("{id}:{}", v["requestId"].as_str().unwrap_or("")); + if !seen.insert(key) { + return; + } + } + let model = v["message"]["model"].as_str().unwrap_or(""); + if model == "" { + return; + } + let input = usage["input_tokens"].as_u64().unwrap_or(0); + let output = usage["output_tokens"].as_u64().unwrap_or(0); + let cache_write = usage["cache_creation_input_tokens"].as_u64().unwrap_or(0); + let cache_read = usage["cache_read_input_tokens"].as_u64().unwrap_or(0); + let (ri, ro, rw, rr) = model_rates(model); + totals.input_tokens += input; + totals.output_tokens += output; + totals.cache_creation_tokens += cache_write; + totals.cache_read_tokens += cache_read; + totals.cost_usd += input as f64 / 1e6 * ri + + output as f64 / 1e6 * ro + + cache_write as f64 / 1e6 * rw + + cache_read as f64 / 1e6 * rr; +} + +/// Aggregiert message.usage aus den Transcript-JSONLs aller Pools: +/// /projects//*.jsonl → Pool × Projekt. +/// Sichtfenster ist, was claude noch nicht weggeräumt hat (cleanupPeriodDays). +fn usage_stats_in(paths: &Paths, days: u32) -> Result, String> { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| e.to_string())? + .as_secs() as i64; + let cutoff = now - days as i64 * 86400; + + // kodierter Projektpfad → Projektname + let mut names = HashMap::new(); + for entry in fs::read_dir(paths.projects_dir()).map_err(|e| e.to_string())? { + let entry = entry.map_err(|e| e.to_string())?; + if !entry.path().join(".claude").is_dir() { + continue; + } + let name = entry.file_name().to_string_lossy().into_owned(); + names.insert(encode_project_path(&entry.path().to_string_lossy()), name); + } + + let mut seen = HashSet::new(); + let mut rows: HashMap<(String, String), UsageTotals> = HashMap::new(); + + for pool_entry in fs::read_dir(paths.pools_dir()).map_err(|e| e.to_string())? { + let pool_entry = pool_entry.map_err(|e| e.to_string())?; + let pool = pool_entry.file_name().to_string_lossy().into_owned(); + let projects_root = pool_entry.path().join("projects"); + if !projects_root.is_dir() { + continue; + } + for proj_entry in fs::read_dir(&projects_root).map_err(|e| e.to_string())? { + let proj_entry = proj_entry.map_err(|e| e.to_string())?; + if !proj_entry.path().is_dir() { + continue; + } + let encoded = proj_entry.file_name().to_string_lossy().into_owned(); + // Unbekannte Ordner (Sessions außerhalb der Projekte) unter dem + // kodierten Namen ausweisen statt verschlucken. + let project = names.get(&encoded).cloned().unwrap_or(encoded); + let totals = rows.entry((pool.clone(), project)).or_default(); + for file in fs::read_dir(proj_entry.path()).map_err(|e| e.to_string())? { + let file = file.map_err(|e| e.to_string())?; + let path = file.path(); + if path.extension().and_then(|e| e.to_str()) != Some("jsonl") { + continue; + } + let f = fs::File::open(&path).map_err(|e| format!("{}: {e}", path.display()))?; + for line in BufReader::new(f).lines() { + let line = line.map_err(|e| format!("{}: {e}", path.display()))?; + add_usage_line(&line, cutoff, &mut seen, totals); + } + } + } + } + + let mut out: Vec = rows + .into_iter() + .map(|((pool, project), totals)| UsageRow { pool, project, totals }) + .collect(); + out.sort_by(|a, b| { + a.pool + .cmp(&b.pool) + .then(b.totals.cost_usd.total_cmp(&a.totals.cost_usd)) + }); + Ok(out) +} + // ---------- Tauri-Commands ---------- #[tauri::command] @@ -438,6 +624,11 @@ fn unassign_pool(project: String) -> Result<(), String> { unassign_pool_in(&Paths::real(), &project) } +#[tauri::command] +fn usage_stats(days: u32) -> Result, String> { + usage_stats_in(&Paths::real(), days) +} + #[tauri::command] fn set_terminal_config( project: String, @@ -885,6 +1076,7 @@ fn main_builder() -> tauri::Builder { assign_pool, unassign_pool, set_terminal_config, + usage_stats, start_project, stop_project, restart_project, @@ -1084,6 +1276,79 @@ mod tests { assert!(!p.pool_dir("kunde").exists()); } + // -- Verbrauch -- + + fn usage_line(id: &str, req: &str, ts: &str, model: &str, input: u64, output: u64) -> String { + serde_json::json!({ + "type": "assistant", + "timestamp": ts, + "requestId": req, + "message": { + "id": id, + "model": model, + "usage": { + "input_tokens": input, + "output_tokens": output, + "cache_creation_input_tokens": 1_000_000, + "cache_read_input_tokens": 2_000_000 + } + } + }) + .to_string() + } + + #[test] + fn verbrauch_aggregation_dedup_und_zeitfilter() { + let p = tmp_paths(); + create_apikey_pool_in(&p, "kunde", "sk-1").unwrap(); + create_project_in(&p, "proj").unwrap(); + + let proj_path = p.projects_dir().join("proj"); + let encoded = encode_project_path(&proj_path.to_string_lossy()); + let dir = p.pool_dir("kunde").join("projects").join(&encoded); + fs::create_dir_all(&dir).unwrap(); + + let lines = [ + // Sonnet: 1M in ($3) + 1M out ($15) + 1M cache-write ($3.75) + 2M cache-read ($0.60) + usage_line("msg_1", "req_1", "2099-01-01T10:00:00.000Z", "claude-sonnet-5", 1_000_000, 1_000_000), + // Retry-Duplikat: gleiche message.id + requestId → zählt nicht + usage_line("msg_1", "req_1", "2099-01-01T10:00:01.000Z", "claude-sonnet-5", 1_000_000, 1_000_000), + // außerhalb des Zeitfensters → zählt nicht + usage_line("msg_2", "req_2", "2020-01-01T00:00:00.000Z", "claude-sonnet-5", 5, 5), + // kein usage-Objekt → ignoriert + r#"{"type":"user","timestamp":"2099-01-01T10:00:02.000Z"}"#.to_string(), + // kaputte Zeile (laufende Session) → ignoriert + r#"{"type":"assist"#.to_string(), + ]; + fs::write(dir.join("session.jsonl"), lines.join("\n")).unwrap(); + + // Cutoff = jetzt − 365 Tage: schließt 2020 aus, 2099 ein — datumsunabhängig + let rows = usage_stats_in(&p, 365).unwrap(); + assert_eq!(rows.len(), 1); + let r = &rows[0]; + assert_eq!(r.pool, "kunde"); + assert_eq!(r.project, "proj"); + assert_eq!(r.totals.input_tokens, 1_000_000); + assert_eq!(r.totals.output_tokens, 1_000_000); + assert_eq!(r.totals.cache_creation_tokens, 1_000_000); + assert_eq!(r.totals.cache_read_tokens, 2_000_000); + assert!((r.totals.cost_usd - 22.35).abs() < 1e-9); + } + + #[test] + fn verbrauch_leer_ohne_transcripts() { + let p = tmp_paths(); + create_apikey_pool_in(&p, "kunde", "sk-1").unwrap(); + assert!(usage_stats_in(&p, 30).unwrap().is_empty()); + } + + #[test] + fn parse_ts_referenz() { + // 2026-01-01 = 1767225600; +181 Tage bis 01.07. +10 h = 1782900000 + assert_eq!(parse_ts("2026-07-01T10:00:00.000Z"), Some(1_782_900_000)); + assert_eq!(parse_ts("kein datum"), None); + } + /// Referenzwert vom echten System: privateDefault → 096c4ef9 /// (verifiziert 2026-07-03 gegen den von claude angelegten Eintrag). #[test] diff --git a/src/App.vue b/src/App.vue index c9ddc06..df15058 100644 --- a/src/App.vue +++ b/src/App.vue @@ -4,9 +4,10 @@ import { enable, disable, isEnabled } from "@tauri-apps/plugin-autostart"; import { useI18n } from "vue-i18n"; import ProjectList from "./components/ProjectList.vue"; import PoolList from "./components/PoolList.vue"; +import UsageList from "./components/UsageList.vue"; import { setLocale } from "./i18n"; -const tab = ref<"projects" | "pools">("projects"); +const tab = ref<"projects" | "pools" | "usage">("projects"); const autostart = ref(false); const { locale } = useI18n(); @@ -34,6 +35,9 @@ async function toggleAutostart() { +
@@ -48,6 +52,7 @@ async function toggleAutostart() {
- + +
diff --git a/src/components/UsageList.vue b/src/components/UsageList.vue new file mode 100644 index 0000000..7280d2c --- /dev/null +++ b/src/components/UsageList.vue @@ -0,0 +1,168 @@ + + + diff --git a/src/i18n.ts b/src/i18n.ts index fa41f6e..ea90978 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -4,8 +4,24 @@ const de = { app: { projects: "Projekte", pools: "Pools", + usage: "Verbrauch", autostart: "Autostart", }, + usage: { + days7: "7 Tage", + days30: "30 Tage", + pool: "Pool", + project: "Projekt", + input: "Input", + output: "Output", + cacheWrite: "Cache ↑", + cacheRead: "Cache ↓", + cost: "≈ Kosten", + total: "Summe", + empty: "Keine Verbrauchsdaten im Zeitraum.", + estimateNote: + "API-Gegenwert aus den lokalen Transcripts — keine Abrechnung, Historie nur solange claude sie vorhält.", + }, projects: { project: "Projekt", pool: "Pool", @@ -76,8 +92,24 @@ const en: typeof de = { app: { projects: "Projects", pools: "Pools", + usage: "Usage", autostart: "Autostart", }, + usage: { + days7: "7 days", + days30: "30 days", + pool: "Pool", + project: "Project", + input: "Input", + output: "Output", + cacheWrite: "Cache ↑", + cacheRead: "Cache ↓", + cost: "≈ cost", + total: "Total", + empty: "No usage data in this period.", + estimateNote: + "API equivalent from local transcripts — not a bill; history only as long as claude keeps it.", + }, projects: { project: "Project", pool: "Pool", diff --git a/src/style.css b/src/style.css index 67c416c..7c0c195 100644 --- a/src/style.css +++ b/src/style.css @@ -220,10 +220,90 @@ button.gear { .toolbar { display: flex; + align-items: center; gap: 0.6rem; margin-bottom: 1.5rem; } +/* ---------- Verbrauch ---------- */ + +.range { + display: flex; + gap: 2px; + padding: 2px; + background: var(--crust); + border-radius: 8px; +} + +.range button { + background: transparent; + border: none; + border-radius: 6px; + color: var(--subtext); + padding: 0.3rem 0.9rem; +} + +.range button:hover:not(:disabled), +.range button.active { + background: var(--surface0); + color: var(--text); +} + +.usage-hint { + margin-left: auto; + color: var(--overlay); + font-size: 0.75rem; + text-align: right; + max-width: 32rem; +} + +.pool-row { + cursor: pointer; +} + +.pool-row .caret { + display: inline-block; + width: 1.1rem; + color: var(--overlay); +} + +.project-row td { + border-bottom-color: color-mix(in srgb, var(--surface0) 25%, transparent); +} + +.project-row .project-name { + padding-left: 2rem; + font-family: var(--mono); + font-size: 0.8rem; + color: var(--subtext); +} + +td.num, +th.num { + text-align: right; +} + +td.num { + font-family: var(--mono); + font-size: 0.8rem; + color: var(--subtext); +} + +td.num.cost { + color: var(--text); +} + +tfoot td { + border-bottom: none; + border-top: 1px solid var(--surface0); + font-weight: 600; +} + +tfoot .total-label { + text-align: right; + color: var(--subtext); +} + /* ---------- Tabellen ---------- */ table.grid { @@ -250,6 +330,12 @@ col.col-type { col.col-projects { width: 10rem; } +col.col-num { + width: 6.5rem; +} +col.col-cost { + width: 7.5rem; +} th { text-align: left;