Verbrauch-Tab: Tokens/Kosten pro Pool und Projekt aus Transcripts
- usage_stats: streamt <pool>/projects/*/*.jsonl, zählt message.usage, Dedup über message.id+requestId, Zeitfilter (eigener RFC3339-Parser), Projektpfad-Mapping wie claudes Ordnerkodierung - Preistabelle je Modell (Fable/Opus/Sonnet/Haiku, Cache 1.25x/0.1x), Kosten als API-Gegenwert ausgewiesen - UI-Tab mit 7/30-Tage-Filter: Pool-Zeilen aggregiert, Projekte aufklappbar darunter, Summenzeile; i18n de/en - Tests: Aggregation+Dedup+Zeitfilter, leerer Fall, parse_ts-Referenz
This commit is contained in:
@@ -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<PathBuf, String> {
|
||||
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<i64> {
|
||||
if ts.len() < 19 {
|
||||
return None;
|
||||
}
|
||||
let num = |r: std::ops::Range<usize>| ts.get(r)?.parse::<i64>().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<String>,
|
||||
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 == "<synthetic>" {
|
||||
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:
|
||||
/// <pool>/projects/<kodierter-projektpfad>/*.jsonl → Pool × Projekt.
|
||||
/// Sichtfenster ist, was claude noch nicht weggeräumt hat (cleanupPeriodDays).
|
||||
fn usage_stats_in(paths: &Paths, days: u32) -> Result<Vec<UsageRow>, 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<UsageRow> = 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<Vec<UsageRow>, String> {
|
||||
usage_stats_in(&Paths::real(), days)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_terminal_config(
|
||||
project: String,
|
||||
@@ -885,6 +1076,7 @@ fn main_builder() -> tauri::Builder<tauri::Wry> {
|
||||
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]
|
||||
|
||||
+7
-2
@@ -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() {
|
||||
<button :class="{ active: tab === 'pools' }" @click="tab = 'pools'">
|
||||
{{ $t("app.pools") }}
|
||||
</button>
|
||||
<button :class="{ active: tab === 'usage' }" @click="tab = 'usage'">
|
||||
{{ $t("app.usage") }}
|
||||
</button>
|
||||
</nav>
|
||||
<div class="header-right">
|
||||
<div class="lang">
|
||||
@@ -48,6 +52,7 @@ async function toggleAutostart() {
|
||||
</header>
|
||||
<main>
|
||||
<ProjectList v-if="tab === 'projects'" />
|
||||
<PoolList v-else />
|
||||
<PoolList v-else-if="tab === 'pools'" />
|
||||
<UsageList v-else />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
interface UsageRow {
|
||||
pool: string;
|
||||
project: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheCreationTokens: number;
|
||||
cacheReadTokens: number;
|
||||
costUsd: number;
|
||||
}
|
||||
|
||||
interface PoolGroup {
|
||||
pool: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheCreationTokens: number;
|
||||
cacheReadTokens: number;
|
||||
costUsd: number;
|
||||
projects: UsageRow[];
|
||||
}
|
||||
|
||||
const { locale } = useI18n();
|
||||
const days = ref(30);
|
||||
const rows = ref<UsageRow[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
const open = ref<Set<string>>(new Set());
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true;
|
||||
try {
|
||||
rows.value = await invoke<UsageRow[]>("usage_stats", { days: days.value });
|
||||
error.value = "";
|
||||
} catch (e) {
|
||||
error.value = String(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(days, refresh);
|
||||
onMounted(refresh);
|
||||
|
||||
const groups = computed<PoolGroup[]>(() => {
|
||||
const byPool = new Map<string, PoolGroup>();
|
||||
for (const r of rows.value) {
|
||||
let g = byPool.get(r.pool);
|
||||
if (!g) {
|
||||
g = {
|
||||
pool: r.pool,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheCreationTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
costUsd: 0,
|
||||
projects: [],
|
||||
};
|
||||
byPool.set(r.pool, g);
|
||||
}
|
||||
g.inputTokens += r.inputTokens;
|
||||
g.outputTokens += r.outputTokens;
|
||||
g.cacheCreationTokens += r.cacheCreationTokens;
|
||||
g.cacheReadTokens += r.cacheReadTokens;
|
||||
g.costUsd += r.costUsd;
|
||||
g.projects.push(r);
|
||||
}
|
||||
return [...byPool.values()];
|
||||
});
|
||||
|
||||
const totalCost = computed(() => groups.value.reduce((s, g) => s + g.costUsd, 0));
|
||||
|
||||
function toggle(pool: string) {
|
||||
const next = new Set(open.value);
|
||||
if (next.has(pool)) {
|
||||
next.delete(pool);
|
||||
} else {
|
||||
next.add(pool);
|
||||
}
|
||||
open.value = next;
|
||||
}
|
||||
|
||||
function fmt(n: number): string {
|
||||
return new Intl.NumberFormat(locale.value, {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1,
|
||||
}).format(n);
|
||||
}
|
||||
|
||||
function cost(n: number): string {
|
||||
return new Intl.NumberFormat(locale.value, {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(n);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="toolbar">
|
||||
<div class="range">
|
||||
<button :class="{ active: days === 7 }" @click="days = 7">
|
||||
{{ $t("usage.days7") }}
|
||||
</button>
|
||||
<button :class="{ active: days === 30 }" @click="days = 30">
|
||||
{{ $t("usage.days30") }}
|
||||
</button>
|
||||
</div>
|
||||
<span class="usage-hint">{{ $t("usage.estimateNote") }}</span>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
|
||||
<table v-if="groups.length" class="grid">
|
||||
<colgroup>
|
||||
<col />
|
||||
<col class="col-num" />
|
||||
<col class="col-num" />
|
||||
<col class="col-num" />
|
||||
<col class="col-num" />
|
||||
<col class="col-cost" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t("usage.pool") }}</th>
|
||||
<th class="num">{{ $t("usage.input") }}</th>
|
||||
<th class="num">{{ $t("usage.output") }}</th>
|
||||
<th class="num">{{ $t("usage.cacheWrite") }}</th>
|
||||
<th class="num">{{ $t("usage.cacheRead") }}</th>
|
||||
<th class="num">{{ $t("usage.cost") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="g in groups" :key="g.pool">
|
||||
<tr class="pool-row" @click="toggle(g.pool)">
|
||||
<td class="cell-name">
|
||||
<span class="caret">{{ open.has(g.pool) ? "▾" : "▸" }}</span>
|
||||
<strong>{{ g.pool }}</strong>
|
||||
</td>
|
||||
<td class="num">{{ fmt(g.inputTokens) }}</td>
|
||||
<td class="num">{{ fmt(g.outputTokens) }}</td>
|
||||
<td class="num">{{ fmt(g.cacheCreationTokens) }}</td>
|
||||
<td class="num">{{ fmt(g.cacheReadTokens) }}</td>
|
||||
<td class="num cost">{{ cost(g.costUsd) }}</td>
|
||||
</tr>
|
||||
<template v-if="open.has(g.pool)">
|
||||
<tr v-for="r in g.projects" :key="g.pool + '/' + r.project" class="project-row">
|
||||
<td class="cell-name project-name">{{ r.project }}</td>
|
||||
<td class="num">{{ fmt(r.inputTokens) }}</td>
|
||||
<td class="num">{{ fmt(r.outputTokens) }}</td>
|
||||
<td class="num">{{ fmt(r.cacheCreationTokens) }}</td>
|
||||
<td class="num">{{ fmt(r.cacheReadTokens) }}</td>
|
||||
<td class="num cost">{{ cost(r.costUsd) }}</td>
|
||||
</tr>
|
||||
</template>
|
||||
</template>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="5" class="total-label">{{ $t("usage.total") }}</td>
|
||||
<td class="num cost">{{ cost(totalCost) }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
<p v-else-if="!loading" class="empty">{{ $t("usage.empty") }}</p>
|
||||
</template>
|
||||
+32
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user