From 90139430f7e7258a352b9d8eb899d8e3751c2fb4 Mon Sep 17 00:00:00 2001 From: Meleki Date: Mon, 23 Feb 2026 21:54:19 +0100 Subject: [PATCH] docs update, fix JSONL import (full-scan instead of line-offset tracking), sync button UX --- ARCHITECTURE.md | 24 +++++++++++++++++++----- DEVY.md | 6 +++++- IDEAS.md | 14 ++++++++++++++ README.md | 36 ++++++++++++++++++++++++++++++++++++ jsonl-sync/export.mjs | 19 +++++-------------- wwwroot/app.js | 5 ++--- 6 files changed, 81 insertions(+), 23 deletions(-) create mode 100644 IDEAS.md create mode 100644 README.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3df5022..945f488 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -17,23 +17,37 @@ Minimal API mit Endpunkten für Kosten-Analyse: - `GET /api/cost-summary` - Gesamtkosten für Zeitraum - `GET /api/entry-chain/{id}` - Volle Kette eines Eintrags (parentId vor/zurück) - `GET /api/entry-chains` - Einträge mit chainId-Zuordnung +- `GET /api/config` - Dashboard-Konfiguration lesen +- `PUT /api/config` - Dashboard-Konfiguration speichern - `POST /api/sync` - Triggert JSONL-Import via `export.mjs` DB-Zugriff über Dapper + `Microsoft.Data.Sqlite`. Connection per Request via Factory. +Beim Start wird ein Index auf `json_extract(data, '$.parentId')` erstellt für schnelle Vorwärts-Ketten-Auflösung. + +### Konfiguration +Server-seitige Config als `dashboard-config.json` neben der DB. Einstellungen: +- `weeklyBudget` - Wochen-Budget in Dollar (Default: 500) +- `anchorDay` - Budget-Anker Wochentag (0=So, 6=Sa) +- `anchorHour` - Anker-Stunde (0-23) +- `minBarWidth` - Minimale Balkenbreite in Pixel (Default: 12) + ### JSONL Sync (`jsonl-sync/export.mjs`) Node.js Script das OpenClaw Session-JSONL-Files in SQLite importiert: -1. Liest alle `.jsonl` aus `/home/node/.openclaw/agents/main/sessions/` -2. Parsed Einträge, speichert in `entries` Tabelle +1. Liest alle `.jsonl` aus dem Sessions-Verzeichnis +2. Parsed Einträge, speichert in `entries` Tabelle via `INSERT OR IGNORE` 3. Berechnet `chainId` durch parentId-Walk bis zum nächsten User-Message -4. Inkrementell - merkt sich pro File die letzte verarbeitete Zeile +4. Full-Scan bei jedem Lauf - robust gegen Session-Kompaktierung durch OpenClaw ### Frontend (`wwwroot/`) SPA mit Zoom-Navigation durch Zeiträume: -- **Bucket-View:** Balkendiagramm mit klickbarem Drill-Down (Monat → Woche → Tag → Stunden) +- **Bucket-View:** Balkendiagramm mit klickbarem Drill-Down (Monat → Woche → Tag) - **Entry-View:** Einzelne API-Calls als Balken, farblich nach Konversations-Kette - **Chain-Blocks:** Teuerste Konversationen als klickbare Blöcke -- **Modal:** Konversations-Kontext mit User/Assistant Messages +- **Ketten-Modal:** Konversations-Kontext mit User/Assistant Messages + Kosten +- **Config-Modal:** Budget, Anker, Balkenbreite - serverseitig gespeichert +- Dynamische Balkenanzahl: Viewport-Breite / minBarWidth, gerundet auf Vielfache von 28 +- Automatisches Re-Render bei Resize - State wird im URL-Hash gespeichert ## Datenfluss diff --git a/DEVY.md b/DEVY.md index 7bf8934..e76b538 100644 --- a/DEVY.md +++ b/DEVY.md @@ -18,4 +18,8 @@ Cost-Analytics Dashboard für OpenClaw API-Nutzung. Importiert Session-JSONL-Fil ## Notizen -- Siehe ARCHITECTURE.md für Details +- Siehe ARCHITECTURE.md für technische Details +- Siehe IDEAS.md für offene Ideen +- Config wird server-seitig in `dashboard-config.json` gespeichert (nicht in Git) +- JSONL-Import ist Full-Scan (robust gegen OpenClaw Session-Kompaktierung) +- Balkenanzahl dynamisch: Vielfache von 28, basierend auf Viewport und `minBarWidth` diff --git a/IDEAS.md b/IDEAS.md new file mode 100644 index 0000000..031ec96 --- /dev/null +++ b/IDEAS.md @@ -0,0 +1,14 @@ +# Ideas + +## Offen + +- **Model-Breakdown:** Kosten pro Model aufschlüsseln (Opus vs Sonnet vs Haiku) +- **Tages-/Wochen-Vergleich:** Kosten der aktuellen Woche vs. vorherige Woche +- **Session-Filter:** Nach Session/Agent filtern +- **Export:** CSV/JSON Export der aktuellen Ansicht +- **Budget-Warnung:** Visuelle Warnung wenn Budget-Schwelle überschritten +- **Kosten-Prognose:** Hochrechnung basierend auf bisherigem Verbrauch +- **Token-Breakdown:** Input/Output/Cache-Read/Cache-Write separat visualisieren +- **Dark/Light Theme Toggle** +- **Auto-Sync:** Periodischer Sync statt manuell +- **Mobile-Optimierung:** Touch-Gesten für Zoom, responsive Layout diff --git a/README.md b/README.md new file mode 100644 index 0000000..761c73d --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +# Token Analytics + +Cost-Analytics Dashboard für OpenClaw API-Nutzung. + +Importiert Session-JSONL-Files in SQLite, bietet eine REST API und ein interaktives Frontend mit Drill-Down von Monats- bis Einzeleintrags-Ebene. Konversations-Ketten werden über parentId-Walks rekonstruiert. + +## Stack + +- **Backend:** .NET 10 Minimal API + Dapper +- **Frontend:** Vanilla HTML/CSS/JS (SPA, kein Framework) +- **Datenbank:** SQLite (better-sqlite3 für Import, Microsoft.Data.Sqlite für API) +- **Port:** 18684 + +## Features + +- Zoom-Navigation: Monat → 2 Wochen → 1 Woche → 1 Tag → Einzeleinträge +- Konversations-Ketten farblich gruppiert +- Ketten-Modal mit User/Assistant Messages und Kosten-Aufschlüsselung +- Konfigurierbares Wochen-Budget mit Anker-Wochentag/-Stunde +- Dynamische Balkenanzahl basierend auf Viewport-Breite (Vielfache von 28) +- Server-seitige Config (Budget, Anker, Balkenbreite) +- JSONL-Sync mit automatischer Deduplizierung (robust gegen Session-Kompaktierung) +- parentId-Index für schnelle Ketten-Auflösung + +## Deployment + +```bash +./deploy.sh +``` + +Baut Release, kopiert nach `publish/`, restartet den systemd Service auf stein2. + +## Dokumentation + +- [ARCHITECTURE.md](ARCHITECTURE.md) - Technische Details +- [IDEAS.md](IDEAS.md) - Offene Ideen diff --git a/jsonl-sync/export.mjs b/jsonl-sync/export.mjs index 452dbe5..ceea085 100644 --- a/jsonl-sync/export.mjs +++ b/jsonl-sync/export.mjs @@ -24,18 +24,12 @@ db.exec(` CREATE INDEX IF NOT EXISTS idx_type ON entries (type); CREATE INDEX IF NOT EXISTS idx_timestamp ON entries (timestamp); - CREATE TABLE IF NOT EXISTS state ( - file TEXT PRIMARY KEY, - lines INTEGER NOT NULL DEFAULT 0 - ); `); // Add chainId column if missing (migration) try { db.exec('ALTER TABLE entries ADD COLUMN chainId TEXT'); } catch {} try { db.exec('CREATE INDEX IF NOT EXISTS idx_chainId ON entries (chainId)'); } catch {} -const getState = db.prepare('SELECT lines FROM state WHERE file = ?'); -const setState = db.prepare('INSERT OR REPLACE INTO state (file, lines) VALUES (?, ?)'); const insertEntry = db.prepare(` INSERT OR IGNORE INTO entries (id, session, type, timestamp, data) VALUES (@id, @session, @type, @timestamp, @data) @@ -48,7 +42,7 @@ const getUnchained = db.prepare(` ORDER BY timestamp ASC `); -// --- Step 1: Import new entries --- +// --- Step 1: Import entries (full scan, INSERT OR IGNORE deduplicates) --- const jsonlFiles = fs.readdirSync(SESSIONS_DIR) .filter(f => f.endsWith('.jsonl') && !f.includes('.deleted')); @@ -59,25 +53,22 @@ const runImport = db.transaction(() => { const filePath = path.join(SESSIONS_DIR, file); const sessionName = file.replace('.jsonl', ''); const lines = fs.readFileSync(filePath, 'utf-8').split('\n').filter(l => l.trim()); - const lastProcessed = getState.get(file)?.lines ?? 0; - for (let i = lastProcessed; i < lines.length; i++) { + for (let i = 0; i < lines.length; i++) { try { const entry = JSON.parse(lines[i]); if (!entry.timestamp) continue; const id = entry.id || `${sessionName}-${i}`; - insertEntry.run({ + const res = insertEntry.run({ id, session: sessionName, type: entry.type ?? 'unknown', timestamp: entry.timestamp, data: JSON.stringify(entry), }); - exported++; + if (res.changes > 0) exported++; } catch { /* skip */ } } - - setState.run(file, lines.length); } }); @@ -138,4 +129,4 @@ const runChaining = db.transaction(() => { runChaining(); db.close(); -console.log(`Exportiert: ${exported} neue, ${chained} verkettet → ${DB_FILE}`); +console.log(`${exported} neue, ${chained} verkettet`); diff --git a/wwwroot/app.js b/wwwroot/app.js index d0fe6dd..cbd9d57 100644 --- a/wwwroot/app.js +++ b/wwwroot/app.js @@ -543,13 +543,12 @@ async function syncData() { try { const res = await api('/api/sync', { method: 'POST' }); btn.textContent = `↻ ${res.output}`; - setTimeout(() => { btn.textContent = '↻ Sync'; }, 3000); await loadView(); + setTimeout(() => { btn.textContent = '↻ Sync'; btn.classList.remove('syncing'); }, 3000); } catch (e) { btn.textContent = '↻ Fehler!'; - setTimeout(() => { btn.textContent = '↻ Sync'; }, 3000); + setTimeout(() => { btn.textContent = '↻ Sync'; btn.classList.remove('syncing'); }, 3000); } - btn.classList.remove('syncing'); } // --- Modal ---