docs update, fix JSONL import (full-scan instead of line-offset tracking), sync button UX
This commit is contained in:
1 parent
9aa8b8a1c6
commit
90139430f7
6 files changed
+81
-23
No files matched your search
+19
-5
@@ -17,23 +17,37 @@ Minimal API mit Endpunkten für Kosten-Analyse:
|
|||||||
- `GET /api/cost-summary` - Gesamtkosten für Zeitraum
|
- `GET /api/cost-summary` - Gesamtkosten für Zeitraum
|
||||||
- `GET /api/entry-chain/{id}` - Volle Kette eines Eintrags (parentId vor/zurück)
|
- `GET /api/entry-chain/{id}` - Volle Kette eines Eintrags (parentId vor/zurück)
|
||||||
- `GET /api/entry-chains` - Einträge mit chainId-Zuordnung
|
- `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`
|
- `POST /api/sync` - Triggert JSONL-Import via `export.mjs`
|
||||||
|
|
||||||
DB-Zugriff über Dapper + `Microsoft.Data.Sqlite`. Connection per Request via Factory.
|
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`)
|
### JSONL Sync (`jsonl-sync/export.mjs`)
|
||||||
Node.js Script das OpenClaw Session-JSONL-Files in SQLite importiert:
|
Node.js Script das OpenClaw Session-JSONL-Files in SQLite importiert:
|
||||||
1. Liest alle `.jsonl` aus `/home/node/.openclaw/agents/main/sessions/`
|
1. Liest alle `.jsonl` aus dem Sessions-Verzeichnis
|
||||||
2. Parsed Einträge, speichert in `entries` Tabelle
|
2. Parsed Einträge, speichert in `entries` Tabelle via `INSERT OR IGNORE`
|
||||||
3. Berechnet `chainId` durch parentId-Walk bis zum nächsten User-Message
|
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/`)
|
### Frontend (`wwwroot/`)
|
||||||
SPA mit Zoom-Navigation durch Zeiträume:
|
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
|
- **Entry-View:** Einzelne API-Calls als Balken, farblich nach Konversations-Kette
|
||||||
- **Chain-Blocks:** Teuerste Konversationen als klickbare Blöcke
|
- **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
|
- State wird im URL-Hash gespeichert
|
||||||
|
|
||||||
## Datenfluss
|
## Datenfluss
|
||||||
|
|||||||
@@ -18,4 +18,8 @@ Cost-Analytics Dashboard für OpenClaw API-Nutzung. Importiert Session-JSONL-Fil
|
|||||||
|
|
||||||
## Notizen
|
## 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`
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
+5
-14
@@ -24,18 +24,12 @@ db.exec(`
|
|||||||
CREATE INDEX IF NOT EXISTS idx_type ON entries (type);
|
CREATE INDEX IF NOT EXISTS idx_type ON entries (type);
|
||||||
CREATE INDEX IF NOT EXISTS idx_timestamp ON entries (timestamp);
|
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)
|
// Add chainId column if missing (migration)
|
||||||
try { db.exec('ALTER TABLE entries ADD COLUMN chainId TEXT'); } catch {}
|
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 {}
|
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(`
|
const insertEntry = db.prepare(`
|
||||||
INSERT OR IGNORE INTO entries (id, session, type, timestamp, data)
|
INSERT OR IGNORE INTO entries (id, session, type, timestamp, data)
|
||||||
VALUES (@id, @session, @type, @timestamp, @data)
|
VALUES (@id, @session, @type, @timestamp, @data)
|
||||||
@@ -48,7 +42,7 @@ const getUnchained = db.prepare(`
|
|||||||
ORDER BY timestamp ASC
|
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)
|
const jsonlFiles = fs.readdirSync(SESSIONS_DIR)
|
||||||
.filter(f => f.endsWith('.jsonl') && !f.includes('.deleted'));
|
.filter(f => f.endsWith('.jsonl') && !f.includes('.deleted'));
|
||||||
|
|
||||||
@@ -59,25 +53,22 @@ const runImport = db.transaction(() => {
|
|||||||
const filePath = path.join(SESSIONS_DIR, file);
|
const filePath = path.join(SESSIONS_DIR, file);
|
||||||
const sessionName = file.replace('.jsonl', '');
|
const sessionName = file.replace('.jsonl', '');
|
||||||
const lines = fs.readFileSync(filePath, 'utf-8').split('\n').filter(l => l.trim());
|
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 {
|
try {
|
||||||
const entry = JSON.parse(lines[i]);
|
const entry = JSON.parse(lines[i]);
|
||||||
if (!entry.timestamp) continue;
|
if (!entry.timestamp) continue;
|
||||||
const id = entry.id || `${sessionName}-${i}`;
|
const id = entry.id || `${sessionName}-${i}`;
|
||||||
insertEntry.run({
|
const res = insertEntry.run({
|
||||||
id,
|
id,
|
||||||
session: sessionName,
|
session: sessionName,
|
||||||
type: entry.type ?? 'unknown',
|
type: entry.type ?? 'unknown',
|
||||||
timestamp: entry.timestamp,
|
timestamp: entry.timestamp,
|
||||||
data: JSON.stringify(entry),
|
data: JSON.stringify(entry),
|
||||||
});
|
});
|
||||||
exported++;
|
if (res.changes > 0) exported++;
|
||||||
} catch { /* skip */ }
|
} catch { /* skip */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
setState.run(file, lines.length);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -138,4 +129,4 @@ const runChaining = db.transaction(() => {
|
|||||||
runChaining();
|
runChaining();
|
||||||
db.close();
|
db.close();
|
||||||
|
|
||||||
console.log(`Exportiert: ${exported} neue, ${chained} verkettet → ${DB_FILE}`);
|
console.log(`${exported} neue, ${chained} verkettet`);
|
||||||
+2
-3
@@ -543,13 +543,12 @@ async function syncData() {
|
|||||||
try {
|
try {
|
||||||
const res = await api('/api/sync', { method: 'POST' });
|
const res = await api('/api/sync', { method: 'POST' });
|
||||||
btn.textContent = `↻ ${res.output}`;
|
btn.textContent = `↻ ${res.output}`;
|
||||||
setTimeout(() => { btn.textContent = '↻ Sync'; }, 3000);
|
|
||||||
await loadView();
|
await loadView();
|
||||||
|
setTimeout(() => { btn.textContent = '↻ Sync'; btn.classList.remove('syncing'); }, 3000);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
btn.textContent = '↻ Fehler!';
|
btn.textContent = '↻ Fehler!';
|
||||||
setTimeout(() => { btn.textContent = '↻ Sync'; }, 3000);
|
setTimeout(() => { btn.textContent = '↻ Sync'; btn.classList.remove('syncing'); }, 3000);
|
||||||
}
|
}
|
||||||
btn.classList.remove('syncing');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Modal ---
|
// --- Modal ---
|
||||||
|
|||||||
Reference in new issue
Block a user