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:
marcus.hinz
2026-07-04 14:10:53 +02:00
parent 62ce802465
commit f8fcd8d033
5 changed files with 558 additions and 2 deletions
+7 -2
View File
@@ -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>
+168
View File
@@ -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
View File
@@ -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",
+86
View File
@@ -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;