351 lines
11 KiB
JavaScript
351 lines
11 KiB
JavaScript
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 = `
|
|
<span>Zeitraum: <span class="cost">$${s.totalCost.toFixed(2)}</span></span>
|
|
<span>Einträge: <span class="val">${s.totalEntries}</span></span>
|
|
`;
|
|
}
|
|
|
|
// --- 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 = `
|
|
<div class="bar-tooltip">$${cost.toFixed(3)} / ${count}</div>
|
|
<div class="bar" style="height:${Math.max(pct, cost > 0 ? 2 : 0)}%"></div>
|
|
`;
|
|
|
|
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 = `
|
|
<div class="bar-tooltip">$${cost.toFixed(4)} - ${e.model || '?'}</div>
|
|
<div class="bar" style="height:${Math.max(pct, 2)}%"></div>
|
|
`;
|
|
|
|
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 = `
|
|
<div class="block-header">
|
|
<span class="block-label">${fmtRange(bucketStart, bucketEnd)}</span>
|
|
<span class="block-stats">
|
|
<span class="block-count">${b.count} Einträge</span>
|
|
<span class="block-cost">$${b.cost.toFixed(4)}</span>
|
|
</span>
|
|
</div>
|
|
<div class="block-entries" id="be-${i}"></div>
|
|
`;
|
|
|
|
block.querySelector('.block-header').addEventListener('click', () =>
|
|
toggleBlock(i, bucketStart, bucketEnd));
|
|
|
|
container.appendChild(block);
|
|
}
|
|
|
|
if (!container.children.length) {
|
|
container.innerHTML = '<p style="color:var(--muted);padding:40px;text-align:center">Keine Einträge mit Kosten in diesem Zeitraum.</p>';
|
|
}
|
|
}
|
|
|
|
// --- Entry list (entry mode - flat, no expandable blocks) ---
|
|
function renderEntryList(entries) {
|
|
const container = $('blocks');
|
|
container.innerHTML = '';
|
|
|
|
if (!entries.length) {
|
|
container.innerHTML = '<p style="color:var(--muted);padding:40px;text-align:center">Keine Einträge mit Kosten in diesem Zeitraum.</p>';
|
|
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 = `
|
|
<span class="entry-time">${fmtTime(e.timestamp)}</span>
|
|
<span class="entry-model">${e.model || '-'}</span>
|
|
<span class="entry-tokens">${shortNum(e.inputTokens)}/${shortNum(e.outputTokens)}</span>
|
|
<span class="entry-cost">$${Number(e.cost).toFixed(4)}</span>
|
|
<span class="entry-type">${e.type}</span>
|
|
`;
|
|
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 => `
|
|
<div class="entry" data-json='${escAttr(e.data)}'>
|
|
<span class="entry-time">${fmtTime(e.timestamp)}</span>
|
|
<span class="entry-model">${e.model || '-'}</span>
|
|
<span class="entry-tokens">${shortNum(e.inputTokens)}/${shortNum(e.outputTokens)}</span>
|
|
<span class="entry-cost">$${Number(e.cost).toFixed(4)}</span>
|
|
<span class="entry-type">${e.type}</span>
|
|
</div>
|
|
`).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, '"');
|
|
}
|