From 9803f8c7276ac1bc997c34176a161e6dfc34c9d9 Mon Sep 17 00:00:00 2001 From: Meleki Date: Fri, 20 Feb 2026 22:09:05 +0100 Subject: [PATCH] =?UTF-8?q?Initial:=20JSONL=E2=86=92SQLite=20Exporter=20+?= =?UTF-8?q?=20Cost=20Analytics=20Web=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 21 + DEVY.md | 13 + INFO.md | 29 ++ export.mjs | 81 +++ package-lock.json | 468 ++++++++++++++++++ package.json | 15 + session-analytics-web/Program.cs | 96 ++++ .../Properties/launchSettings.json | 23 + .../appsettings.Development.json | 9 + session-analytics-web/appsettings.json | 11 + .../session-analytics-web.csproj | 15 + session-analytics-web/wwwroot/app.js | 350 +++++++++++++ session-analytics-web/wwwroot/index.html | 39 ++ session-analytics-web/wwwroot/style.css | 279 +++++++++++ 14 files changed, 1449 insertions(+) create mode 100644 .gitignore create mode 100644 DEVY.md create mode 100644 INFO.md create mode 100644 export.mjs create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 session-analytics-web/Program.cs create mode 100644 session-analytics-web/Properties/launchSettings.json create mode 100644 session-analytics-web/appsettings.Development.json create mode 100644 session-analytics-web/appsettings.json create mode 100644 session-analytics-web/session-analytics-web.csproj create mode 100644 session-analytics-web/wwwroot/app.js create mode 100644 session-analytics-web/wwwroot/index.html create mode 100644 session-analytics-web/wwwroot/style.css diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..081b449 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Node +node_modules/ + +# .NET +bin/ +obj/ +*.user + +# Data +sessions.db +state.json + +# IDE +.vs/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db diff --git a/DEVY.md b/DEVY.md new file mode 100644 index 0000000..8dedbec --- /dev/null +++ b/DEVY.md @@ -0,0 +1,13 @@ +# JSONL Exporter + +## Projektbeschreibung + +Exportiert OpenClaw Session-JSONL-Dateien als einzelne JSON-Files nach `workspace/logs/`. Inkrementell via `state.json`. + +## Pfad + +`/home/node/.openclaw/workspace/projects/jsonl-exporter` + +## Notizen + +- diff --git a/INFO.md b/INFO.md new file mode 100644 index 0000000..a9f1e28 --- /dev/null +++ b/INFO.md @@ -0,0 +1,29 @@ +# JSONL Exporter + +Liest alle Session-JSONL-Dateien aus dem OpenClaw Sessions-Verzeichnis und exportiert jeden Eintrag als einzelne JSON-Datei nach `workspace/logs/`. + +## Funktionsweise + +- Scannt `/home/node/.openclaw/agents/main/sessions/*.jsonl` +- Pro Eintrag wird eine Datei mit lesbarem Namen angelegt: + `YYYY-MM-DD HH-mm-ss ms - (type).json` +- Speichert den Fortschritt in `state.json` (nur neue Einträge werden exportiert) +- Überspringt gelöschte Sessions (`.deleted`) und fehlerhafte Zeilen + +## Dateien + +| Datei | Zweck | +|-------|-------| +| `export.mjs` | Hauptskript (ESM) | +| `export.js` | Gleiche Logik als CommonJS | +| `state.json` | Fortschritts-State (verarbeitete Zeilenzahl pro JSONL) | + +## Ausführen + +```bash +node export.mjs +# oder +node export.js +``` + +Output landet in: `workspace/logs/` diff --git a/export.mjs b/export.mjs new file mode 100644 index 0000000..53001a2 --- /dev/null +++ b/export.mjs @@ -0,0 +1,81 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import Database from 'better-sqlite3'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const SESSIONS_DIR = '/home/node/.openclaw/agents/main/sessions'; +const DB_FILE = path.join(__dirname, 'sessions.db'); + +// DB öffnen + Schema anlegen +const db = new Database(DB_FILE); + +db.exec(` + CREATE TABLE IF NOT EXISTS entries ( + id TEXT PRIMARY KEY, + session TEXT NOT NULL, + type TEXT, + timestamp TEXT, + data TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_session ON entries (session); + 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 + ); +`); + +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) +`); + +// Find all JSONL files +const jsonlFiles = fs.readdirSync(SESSIONS_DIR) + .filter(f => f.endsWith('.jsonl') && !f.includes('.deleted')); + +let exported = 0; + +const runImport = db.transaction(() => { + for (const file of jsonlFiles) { + 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++) { + try { + const entry = JSON.parse(lines[i]); + if (!entry.timestamp) continue; + + const id = entry.id || `${sessionName}-${i}`; + + insertEntry.run({ + id, + session: sessionName, + type: entry.type ?? 'unknown', + timestamp: entry.timestamp, + data: JSON.stringify(entry), + }); + + exported++; + } catch { + // skip malformed lines + } + } + + setState.run(file, lines.length); + } +}); + +runImport(); +db.close(); + +console.log(`Exportiert: ${exported} neue Einträge → ${DB_FILE}`); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..72cffa5 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,468 @@ +{ + "name": "jsonl-exporter", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "jsonl-exporter", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "better-sqlite3": "^12.6.2" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "12.6.2", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz", + "integrity": "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.87.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", + "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7747609 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "jsonl-exporter", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "better-sqlite3": "^12.6.2" + } +} diff --git a/session-analytics-web/Program.cs b/session-analytics-web/Program.cs new file mode 100644 index 0000000..7a333c6 --- /dev/null +++ b/session-analytics-web/Program.cs @@ -0,0 +1,96 @@ +using Dapper; +using Microsoft.Data.Sqlite; + +var builder = WebApplication.CreateBuilder(args); + +var dbPath = builder.Configuration["DbPath"] + ?? Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "sessions.db"); + +builder.Services.AddSingleton>(() => + new SqliteConnection($"Data Source={Path.GetFullPath(dbPath)}")); + +var app = builder.Build(); + +app.UseDefaultFiles(); +app.UseStaticFiles(); + +// GET /api/cost-buckets?from=&to=&bucketMinutes= +// Returns N buckets of aggregated cost +app.MapGet("/api/cost-buckets", (Func factory, string from, string to, int bucketMinutes) => +{ + using var db = factory(); + var rows = db.Query(@" + SELECT + CAST((strftime('%s', timestamp) - strftime('%s', @from)) / (@bucketSec) AS INTEGER) AS bucket, + SUM(CAST(json_extract(data, '$.message.usage.cost.total') AS REAL)) AS cost, + COUNT(*) AS count, + MIN(timestamp) AS bucketStart, + MAX(timestamp) AS bucketEnd + FROM entries + WHERE json_extract(data, '$.message.usage.cost.total') IS NOT NULL + AND timestamp >= @from AND timestamp < @to + GROUP BY bucket + ORDER BY bucket ASC", + new { from, to, bucketSec = bucketMinutes * 60 }); + + return Results.Ok(rows); +}); + +// GET /api/cost-entries?from=&to= +// Returns all cost entries in a time range +app.MapGet("/api/cost-entries", (Func factory, string from, string to) => +{ + using var db = factory(); + var rows = db.Query(@" + SELECT id, session, type, timestamp, + json_extract(data, '$.message.usage.cost.total') AS cost, + json_extract(data, '$.message.usage.input') AS inputTokens, + json_extract(data, '$.message.usage.output') AS outputTokens, + json_extract(data, '$.message.model') AS model, + data + FROM entries + WHERE json_extract(data, '$.message.usage.cost.total') IS NOT NULL + AND timestamp >= @from AND timestamp < @to + ORDER BY timestamp ASC", + new { from, to }); + + return Results.Ok(rows); +}); + +// GET /api/cost-summary?from=&to= +app.MapGet("/api/cost-summary", (Func factory, string? from, string? to) => +{ + using var db = factory(); + var conditions = new List { "json_extract(data, '$.message.usage.cost.total') IS NOT NULL" }; + if (from != null) conditions.Add("timestamp >= @from"); + if (to != null) conditions.Add("timestamp < @to"); + var where = string.Join(" AND ", conditions); + + var row = db.QuerySingle($@" + SELECT COALESCE(SUM(CAST(json_extract(data, '$.message.usage.cost.total') AS REAL)), 0) AS totalCost, + COUNT(*) AS totalEntries + FROM entries + WHERE {where}", + new { from, to }); + + return Results.Ok(row); +}); + +// POST /api/sync - run export.mjs to import new entries +app.MapPost("/api/sync", async () => +{ + var scriptPath = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "export.mjs")); + var psi = new System.Diagnostics.ProcessStartInfo("node", scriptPath) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + WorkingDirectory = Path.GetDirectoryName(scriptPath)! + }; + var proc = System.Diagnostics.Process.Start(psi)!; + var stdout = await proc.StandardOutput.ReadToEndAsync(); + var stderr = await proc.StandardError.ReadToEndAsync(); + await proc.WaitForExitAsync(); + return Results.Ok(new { exitCode = proc.ExitCode, output = (stdout + stderr).Trim() }); +}); + +app.Run(); diff --git a/session-analytics-web/Properties/launchSettings.json b/session-analytics-web/Properties/launchSettings.json new file mode 100644 index 0000000..f97c4b6 --- /dev/null +++ b/session-analytics-web/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5169", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7160;http://localhost:5169", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/session-analytics-web/appsettings.Development.json b/session-analytics-web/appsettings.Development.json new file mode 100644 index 0000000..770d3e9 --- /dev/null +++ b/session-analytics-web/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "DetailedErrors": true, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/session-analytics-web/appsettings.json b/session-analytics-web/appsettings.json new file mode 100644 index 0000000..b2a59b6 --- /dev/null +++ b/session-analytics-web/appsettings.json @@ -0,0 +1,11 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Urls": "http://0.0.0.0:18684", + "DbPath": "../sessions.db" +} diff --git a/session-analytics-web/session-analytics-web.csproj b/session-analytics-web/session-analytics-web.csproj new file mode 100644 index 0000000..4c8a29c --- /dev/null +++ b/session-analytics-web/session-analytics-web.csproj @@ -0,0 +1,15 @@ + + + + net10.0 + enable + enable + session_analytics_web + + + + + + + + diff --git a/session-analytics-web/wwwroot/app.js b/session-analytics-web/wwwroot/app.js new file mode 100644 index 0000000..6702e00 --- /dev/null +++ b/session-analytics-web/wwwroot/app.js @@ -0,0 +1,350 @@ +const $ = id => document.getElementById(id); + +const ZOOM_LEVELS = [ + { name: '1 Monat', days: 30 }, + { name: '2 Wochen', days: 14 }, + { name: '1 Woche', days: 7 }, + { name: '3 Tage', days: 3 }, + { name: '1 Tag', days: 1 }, + { name: '12 Stunden', days: 0.5 }, + { name: '6 Stunden', days: 0.25 }, +]; +const BAR_COUNT = 30; + +let zoomIndex = 0; +let viewFrom, viewTo; +let zoomStack = []; +let entryMode = false; // true = max zoom, individual entries as bars +let entryData = []; // cached entries for entry mode + +init(); + +async function init() { + $('btn-zoom-out').addEventListener('click', zoomOut); + $('btn-sync').addEventListener('click', syncData); + $('modal-close').addEventListener('click', closeModal); + $('modal').addEventListener('click', e => { if (e.target === $('modal')) closeModal(); }); + document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); }); + + viewTo = new Date(); + viewFrom = new Date(viewTo); + viewFrom.setDate(viewFrom.getDate() - ZOOM_LEVELS[0].days); + zoomIndex = 0; + + await loadView(); +} + +// --- Main load --- +async function loadView() { + const from = viewFrom.toISOString(); + const to = viewTo.toISOString(); + + $('range-label').textContent = + `${fmtDate(viewFrom)} - ${fmtDate(viewTo)} (${entryMode ? 'Einträge' : ZOOM_LEVELS[zoomIndex].name})`; + $('btn-zoom-out').disabled = zoomStack.length === 0; + + if (entryMode) { + const [entries, summary] = await Promise.all([ + api(`/api/cost-entries?from=${enc(from)}&to=${enc(to)}`), + api(`/api/cost-summary?from=${enc(from)}&to=${enc(to)}`), + ]); + entryData = entries; + renderSummary(summary); + renderEntryChart(entries); + renderEntryList(entries); + } else { + const totalMinutes = (viewTo - viewFrom) / 60000; + const bucketMinutes = Math.max(1, Math.round(totalMinutes / BAR_COUNT)); + + const [buckets, summary] = await Promise.all([ + api(`/api/cost-buckets?from=${enc(from)}&to=${enc(to)}&bucketMinutes=${bucketMinutes}`), + api(`/api/cost-summary?from=${enc(from)}&to=${enc(to)}`), + ]); + + renderSummary(summary); + renderBucketChart(buckets, bucketMinutes); + renderBlocks(buckets, bucketMinutes); + } +} + +function renderSummary(s) { + $('summary').innerHTML = ` + Zeitraum: $${s.totalCost.toFixed(2)} + Einträge: ${s.totalEntries} + `; +} + +// --- Bucket chart (normal zoom levels) --- +function renderBucketChart(buckets, bucketMinutes) { + const chart = $('chart'); + const labels = $('chart-labels'); + chart.innerHTML = ''; + labels.innerHTML = ''; + + const bucketMap = {}; + buckets.forEach(b => { bucketMap[b.bucket] = b; }); + const maxCost = Math.max(...buckets.map(b => b.cost), 0.001); + const isMaxZoom = zoomIndex >= ZOOM_LEVELS.length - 1; + + for (let i = 0; i < BAR_COUNT; i++) { + const b = bucketMap[i]; + const cost = b ? b.cost : 0; + const count = b ? b.count : 0; + const pct = (cost / maxCost) * 100; + + const bucketStart = new Date(viewFrom.getTime() + i * bucketMinutes * 60000); + const bucketEnd = new Date(viewFrom.getTime() + (i + 1) * bucketMinutes * 60000); + + const wrapper = document.createElement('div'); + wrapper.className = 'bar-wrapper'; + wrapper.innerHTML = ` +
$${cost.toFixed(3)} / ${count}
+
+ `; + + if (isMaxZoom) { + // At max zoom: click switches to entry mode centered on this bucket + wrapper.addEventListener('click', () => enterEntryMode(bucketStart, bucketEnd)); + } else { + wrapper.addEventListener('click', () => zoomInto(bucketStart, bucketEnd)); + } + + chart.appendChild(wrapper); + + const lbl = document.createElement('div'); + lbl.className = 'chart-label'; + if (i % Math.ceil(BAR_COUNT / 6) === 0) { + lbl.textContent = fmtShort(bucketStart); + } + labels.appendChild(lbl); + } +} + +// --- Entry chart (individual entries as bars) --- +function renderEntryChart(entries) { + const chart = $('chart'); + const labels = $('chart-labels'); + chart.innerHTML = ''; + labels.innerHTML = ''; + + if (!entries.length) return; + + const maxCost = Math.max(...entries.map(e => Number(e.cost)), 0.001); + + entries.forEach((e, i) => { + const cost = Number(e.cost); + const pct = (cost / maxCost) * 100; + + const wrapper = document.createElement('div'); + wrapper.className = 'bar-wrapper'; + wrapper.innerHTML = ` +
$${cost.toFixed(4)} - ${e.model || '?'}
+
+ `; + + wrapper.addEventListener('click', () => openModal(e.data)); + chart.appendChild(wrapper); + + const lbl = document.createElement('div'); + lbl.className = 'chart-label'; + if (entries.length <= 15 || i % Math.ceil(entries.length / 8) === 0) { + lbl.textContent = fmtTime(e.timestamp); + } + labels.appendChild(lbl); + }); +} + +// --- Blocks (normal zoom) --- +function renderBlocks(buckets, bucketMinutes) { + const container = $('blocks'); + container.innerHTML = ''; + + const bucketMap = {}; + buckets.forEach(b => { bucketMap[b.bucket] = b; }); + + const sortedBuckets = []; + for (let i = 0; i < BAR_COUNT; i++) { + const b = bucketMap[i]; + if (!b || b.cost === 0) continue; + sortedBuckets.push({ ...b, idx: i }); + } + sortedBuckets.sort((a, b) => b.cost - a.cost); + + for (const { idx: i, ...b } of sortedBuckets) { + const bucketStart = new Date(viewFrom.getTime() + i * bucketMinutes * 60000); + const bucketEnd = new Date(viewFrom.getTime() + (i + 1) * bucketMinutes * 60000); + + const block = document.createElement('div'); + block.className = 'block'; + block.innerHTML = ` +
+ ${fmtRange(bucketStart, bucketEnd)} + + ${b.count} Einträge + $${b.cost.toFixed(4)} + +
+
+ `; + + block.querySelector('.block-header').addEventListener('click', () => + toggleBlock(i, bucketStart, bucketEnd)); + + container.appendChild(block); + } + + if (!container.children.length) { + container.innerHTML = '

Keine Einträge mit Kosten in diesem Zeitraum.

'; + } +} + +// --- Entry list (entry mode - flat, no expandable blocks) --- +function renderEntryList(entries) { + const container = $('blocks'); + container.innerHTML = ''; + + if (!entries.length) { + container.innerHTML = '

Keine Einträge mit Kosten in diesem Zeitraum.

'; + return; + } + + // Sort by cost descending + const sorted = [...entries].sort((a, b) => Number(b.cost) - Number(a.cost)); + + sorted.forEach(e => { + const row = document.createElement('div'); + row.className = 'entry entry-flat'; + row.innerHTML = ` + ${fmtTime(e.timestamp)} + ${e.model || '-'} + ${shortNum(e.inputTokens)}/${shortNum(e.outputTokens)} + $${Number(e.cost).toFixed(4)} + ${e.type} + `; + row.addEventListener('click', () => openModal(e.data)); + container.appendChild(row); + }); +} + +// --- Toggle block entries --- +async function toggleBlock(idx, from, to) { + const el = $(`be-${idx}`); + if (el.classList.contains('open')) { + el.classList.remove('open'); + return; + } + + if (!el.dataset.loaded) { + const entries = await api(`/api/cost-entries?from=${enc(from.toISOString())}&to=${enc(to.toISOString())}`); + el.innerHTML = entries.map(e => ` +
+ ${fmtTime(e.timestamp)} + ${e.model || '-'} + ${shortNum(e.inputTokens)}/${shortNum(e.outputTokens)} + $${Number(e.cost).toFixed(4)} + ${e.type} +
+ `).join(''); + + el.querySelectorAll('.entry').forEach(row => { + row.addEventListener('click', () => openModal(row.dataset.json)); + }); + + el.dataset.loaded = '1'; + } + + el.classList.add('open'); +} + +// --- Zoom --- +function zoomInto(clickStart, clickEnd) { + if (zoomIndex >= ZOOM_LEVELS.length - 1) return; + + zoomStack.push({ from: new Date(viewFrom), to: new Date(viewTo), zoom: zoomIndex, entry: false }); + + const nextLevel = ZOOM_LEVELS[zoomIndex + 1]; + const clickMid = new Date((clickStart.getTime() + clickEnd.getTime()) / 2); + const halfMs = (nextLevel.days * 86400000) / 2; + + viewFrom = new Date(clickMid.getTime() - halfMs); + viewTo = new Date(clickMid.getTime() + halfMs); + zoomIndex++; + entryMode = false; + + loadView(); +} + +function enterEntryMode(bucketStart, bucketEnd) { + zoomStack.push({ from: new Date(viewFrom), to: new Date(viewTo), zoom: zoomIndex, entry: false }); + + viewFrom = bucketStart; + viewTo = bucketEnd; + entryMode = true; + + loadView(); +} + +function zoomOut() { + if (!zoomStack.length) return; + const prev = zoomStack.pop(); + viewFrom = prev.from; + viewTo = prev.to; + zoomIndex = prev.zoom; + entryMode = prev.entry; + loadView(); +} + +// --- Sync --- +async function syncData() { + const btn = $('btn-sync'); + btn.classList.add('syncing'); + btn.textContent = '↻ Sync...'; + try { + const res = await api('/api/sync', { method: 'POST' }); + btn.textContent = `↻ ${res.output}`; + setTimeout(() => { btn.textContent = '↻ Sync'; }, 3000); + await loadView(); + } catch (e) { + btn.textContent = '↻ Fehler!'; + setTimeout(() => { btn.textContent = '↻ Sync'; }, 3000); + } + btn.classList.remove('syncing'); +} + +// --- Modal --- +function openModal(dataStr) { + try { + $('modal-content').textContent = JSON.stringify(JSON.parse(dataStr), null, 2); + } catch { $('modal-content').textContent = dataStr; } + $('modal').classList.remove('hidden'); +} + +function closeModal() { $('modal').classList.add('hidden'); } + +// --- Helpers --- +async function api(url, opts) { return (await fetch(url, opts)).json(); } +function enc(s) { return encodeURIComponent(s); } + +function fmtDate(d) { + return d.toLocaleDateString('de-AT', { day: '2-digit', month: '2-digit', year: 'numeric' }); +} + +function fmtShort(d) { + return d.toLocaleDateString('de-AT', { day: '2-digit', month: '2-digit' }) + + ' ' + d.toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' }); +} + +function fmtRange(a, b) { return fmtShort(a) + ' - ' + fmtShort(b); } + +function fmtTime(ts) { + return ts ? new Date(ts).toLocaleTimeString('de-AT', { hour:'2-digit', minute:'2-digit', second:'2-digit' }) : ''; +} + +function shortNum(n) { + if (n == null) return '-'; + return n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n); +} + +function escAttr(s) { + return String(s).replace(/'/g, ''').replace(/"/g, '"'); +} diff --git a/session-analytics-web/wwwroot/index.html b/session-analytics-web/wwwroot/index.html new file mode 100644 index 0000000..41125ff --- /dev/null +++ b/session-analytics-web/wwwroot/index.html @@ -0,0 +1,39 @@ + + + + + + Session Cost Analytics + + + +
+

Cost Analytics

+
+ +
+ +
+ + +
+ +
+
+
+
+ +
+
+
+ + + + + + diff --git a/session-analytics-web/wwwroot/style.css b/session-analytics-web/wwwroot/style.css new file mode 100644 index 0000000..6ac4842 --- /dev/null +++ b/session-analytics-web/wwwroot/style.css @@ -0,0 +1,279 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg: #0f1117; + --surface: #1a1d27; + --border: #2a2d3a; + --accent: #6c8ef5; + --text: #e2e4f0; + --muted: #6b7280; + --green: #4ade80; + --orange: #fb923c; + --red: #f87171; +} + +body { + font-family: 'Segoe UI', system-ui, sans-serif; + background: var(--bg); + color: var(--text); + height: 100vh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +header { + padding: 14px 24px; + background: var(--surface); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + gap: 24px; + flex-shrink: 0; +} + +header h1 { font-size: 1.1rem; font-weight: 600; color: var(--accent); } + +#btn-sync { + background: var(--border); + color: var(--text); + border: none; + padding: 5px 14px; + border-radius: 6px; + cursor: pointer; + font-size: 0.85rem; + margin-left: auto; + transition: background 0.15s; +} +#btn-sync:hover { background: var(--accent); } +#btn-sync.syncing { opacity: 0.5; pointer-events: none; } + +#summary { + font-size: 0.85rem; + color: var(--muted); + display: flex; + gap: 20px; +} + +#summary .val { color: var(--text); font-weight: 600; } +#summary .cost { color: var(--green); font-weight: 700; font-size: 1rem; } + +#toolbar { + padding: 10px 24px; + display: flex; + align-items: center; + gap: 16px; + background: var(--surface); + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +#btn-zoom-out { + background: var(--border); + color: var(--text); + border: none; + padding: 5px 14px; + border-radius: 6px; + cursor: pointer; + font-size: 0.8rem; +} + +#btn-zoom-out:hover:not(:disabled) { background: var(--accent); } +#btn-zoom-out:disabled { opacity: 0.3; cursor: default; } + +#range-label { + font-size: 0.85rem; + color: var(--muted); + font-family: monospace; +} + +/* Chart */ +#chart-area { + padding: 16px 24px 4px; + flex-shrink: 0; + background: var(--surface); + border-bottom: 1px solid var(--border); +} + +#chart { + display: flex; + align-items: flex-end; + gap: 2px; + height: 140px; + width: 100%; +} + +.bar-wrapper { + flex: 1; + height: 100%; + display: flex; + flex-direction: column; + justify-content: flex-end; + align-items: center; + cursor: pointer; + position: relative; +} + +.bar { + width: 100%; + min-width: 4px; + background: var(--green); + border-radius: 3px 3px 0 0; + opacity: 0.7; + transition: opacity 0.15s, background 0.15s; +} + +.bar-wrapper:hover .bar { + opacity: 1; + background: #6ee7b7; +} + +.bar-tooltip { + display: none; + position: absolute; + top: -32px; + background: var(--surface); + border: 1px solid var(--border); + padding: 3px 8px; + border-radius: 4px; + font-size: 0.7rem; + white-space: nowrap; + z-index: 10; + color: var(--green); + font-weight: 600; + pointer-events: none; +} + +.bar-wrapper:hover .bar-tooltip { display: block; } + +#chart-labels { + display: flex; + gap: 2px; + padding: 4px 0 8px; +} + +.chart-label { + flex: 1; + font-size: 0.55rem; + color: var(--muted); + text-align: center; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Content - blocks below chart */ +#content { + flex: 1; + overflow-y: auto; + padding: 12px 24px; +} + +.block { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + margin-bottom: 8px; + overflow: hidden; +} + +.block-header { + padding: 12px 16px; + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + transition: background 0.15s; +} + +.block-header:hover { background: #22263a; } + +.block-label { + font-family: monospace; + font-size: 0.85rem; + color: var(--text); +} + +.block-stats { + display: flex; + gap: 16px; + font-size: 0.8rem; +} + +.block-cost { color: var(--green); font-weight: 700; } +.block-count { color: var(--muted); } + +.block-entries { + display: none; + border-top: 1px solid var(--border); +} + +.block-entries.open { display: block; } + +.entry { + padding: 8px 16px 8px 28px; + display: grid; + grid-template-columns: 90px 1fr 80px 70px 60px; + gap: 10px; + align-items: center; + font-size: 0.78rem; + border-bottom: 1px solid var(--border); + cursor: pointer; + transition: background 0.1s; +} + +.entry:last-child { border-bottom: none; } +.entry:hover { background: #1e2d5a; } + +.entry-flat { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + margin-bottom: 4px; + padding: 10px 16px; +} + +.entry-time { font-family: monospace; color: var(--muted); } +.entry-model { color: var(--accent); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.entry-tokens { color: var(--muted); text-align: right; } +.entry-cost { color: var(--green); font-weight: 600; text-align: right; } +.entry-type { font-size: 0.65rem; color: var(--muted); text-transform: uppercase; text-align: right; } + +/* Modal */ +.modal { + position: fixed; inset: 0; + background: rgba(0,0,0,0.7); + display: flex; align-items: center; justify-content: center; + z-index: 100; +} + +.modal.hidden { display: none; } + +.modal-box { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 20px; + max-width: 850px; width: 90%; max-height: 80vh; + overflow-y: auto; + position: relative; +} + +#modal-close { + position: absolute; top: 12px; right: 14px; + background: none; border: none; + color: var(--muted); font-size: 1.2rem; cursor: pointer; +} + +#modal-content { + font-family: monospace; + font-size: 0.78rem; + white-space: pre-wrap; + word-break: break-all; + color: var(--text); + line-height: 1.5; +} + +::-webkit-scrollbar { width: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }