Files
second-brain/templates/dashboard.html

505 lines
20 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width; initial-scale=1.0; maximum-scale=1.0; user-scalable=no">
<title>🧠 Second Brain</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="app">
<!-- Stats Header -->
<header class="stats-bar" id="statsBar">
<div class="stat"><span class="stat-num" id="statTotal">-</span><span class="stat-label">Total</span></div>
<div class="stat"><span class="stat-num" id="statConfirmed">-</span><span class="stat-label">OK</span></div>
<div class="stat"><span class="stat-num" id="statPending">-</span><span class="stat-label">Pending</span></div>
<div class="stat"><span class="stat-num" id="statErrors">-</span><span class="stat-label">Err</span></div>
</header>
<div class="tabs-bar">
<button class="tab-btn active" id="tabCards" onclick="setView('cards')">Cards</button>
<button class="tab-btn" id="tabGraph" onclick="setView('graph')">Graph</button>
<button class="tab-btn" id="tabStatus" onclick="setView('status')">Status</button>
</div>
<!-- Search -->
<div class="search-box">
<input type="text" id="searchInput" placeholder="🔍 Suche..." />
<select id="filterSelect">
<option value="all">Alle</option>
<option value="pending">Pending</option>
<option value="confirmed">Confirmed</option>
<option value="rejected">Rejected</option>
<option value="errors">Errors</option>
</select>
</div>
<!-- New Engram -->
<div class="new-engram">
<textarea id="newContent" placeholder="Neues Engramm..."></textarea>
<input type="text" id="newTags" placeholder="Tags (comma sep)" />
<button onclick="createEngram()"> Speichern</button>
</div>
<!-- Cards -->
<div class="cards" id="cards"></div>
<!-- Graph -->
<div class="graph" id="graph" style="display:none;">
<canvas id="graphCanvas" width="440" height="520"></canvas>
<div class="muted small" id="graphHint">Lade Graph…</div>
</div>
<!-- Status -->
<div class="status" id="status" style="display:none;"></div>
<!-- Pagination -->
<div class="pagination" id="pagination">
<button id="btnPrev" onclick="prevPage()"></button>
<span id="pageNum">1</span>
<button id="btnNext" onclick="nextPage()"></button>
</div>
<div class="footer">
<span id="lastUpdate">--:--</span>
<button onclick="manualRefresh()" class="refresh-btn"></button>
</div>
</div>
<!-- Detail Modal -->
<div class="modal" id="detailModal">
<div class="modal-content">
<button class="close-btn" onclick="closeModal()">×</button>
<div id="modalBody"></div>
</div>
</div>
<script>
// ─── State ──────────────────────────────────────────────────────────────────
let state = {
items: [],
offset: 0,
limit: 10,
filter: 'all',
search: '',
autoRefresh: true,
view: 'cards',
lastEvent: null,
};
// ─── Fetch ──────────────────────────────────────────────────────────────────
async function api(path, opts = {}) {
const r = await fetch(path, opts);
if (!r.ok) throw new Error((await r.json()).error || r.statusText);
return r.json();
}
async function loadStats() {
const s = await api('/api/stats');
document.getElementById('statTotal').textContent = s.total;
document.getElementById('statConfirmed').textContent = s.confirmed;
document.getElementById('statPending').textContent = s.pending;
document.getElementById('statErrors').textContent = s.errors;
}
function updateStatsFromEvent(ev) {
if (!ev || !ev.stats) return;
const s = ev.stats;
document.getElementById('statTotal').textContent = s.total;
document.getElementById('statConfirmed').textContent = s.confirmed;
document.getElementById('statPending').textContent = s.pending;
document.getElementById('statErrors').textContent = s.errors;
}
function setView(view) {
state.view = view;
document.getElementById('tabCards').classList.toggle('active', view === 'cards');
document.getElementById('tabGraph').classList.toggle('active', view === 'graph');
document.getElementById('tabStatus').classList.toggle('active', view === 'status');
document.getElementById('cards').style.display = view === 'cards' ? '' : 'none';
document.getElementById('pagination').style.display = view === 'cards' ? '' : 'none';
document.getElementById('graph').style.display = view === 'graph' ? '' : 'none';
document.getElementById('status').style.display = view === 'status' ? '' : 'none';
if (view === 'graph') loadGraph();
if (view === 'status') loadStatus();
}
async function loadCards() {
let url = `/api/engrams?limit=${state.limit}&offset=${state.offset}`;
if (state.search) url += `&search=${encodeURIComponent(state.search)}`;
if (state.filter === 'confirmed') url += '&confirmed=1';
if (state.filter === 'pending') url += '&confirmed=0';
if (state.filter === 'rejected') url += '&verdict=confirmed_false';
if (state.filter === 'errors') url += '&tag=error';
const data = await api(url);
state.items = data.items;
renderCards();
document.getElementById('pageNum').textContent = Math.floor(state.offset / state.limit) + 1;
document.getElementById('btnPrev').disabled = state.offset === 0;
document.getElementById('btnNext').disabled = data.items.length < state.limit;
}
async function loadStatus() {
const [cfg, db, jobs, ins, stor] = await Promise.all([
api('/api/config'),
api('/api/db_info'),
api('/api/jobs'),
api('/api/insights?limit=8'),
api('/api/storage_stats'),
]);
const pend = await api('/api/pending?limit=20&offset=0');
const el = document.getElementById('status');
const jobsHtml = (jobs.items || []).map(j => `
<div class="kv-row">
<div class="kv-key">${j.unit}</div>
<div class="kv-val">${j.error ? ('ERR: ' + j.error) : (j.active + '/' + j.sub)}</div>
</div>
`).join('');
const topTags = (ins.top_tags || []).map(t => `<span class="pill">${t.key} (${t.count})</span>`).join(' ');
const topHosts = (ins.top_hosts || []).map(t => `<span class="pill">${t.key} (${t.count})</span>`).join(' ');
const bySource = Object.entries((stor.sql && stor.sql.by_source) ? stor.sql.by_source : {})
.slice(0, 8)
.map(([k,v]) => `<span class="pill">${k}: ${v}</span>`)
.join(' ');
const pendItems = (pend.items || []);
const pendHtml = pendItems.map(p => `
<div class="kv-row" onclick="showDetail('${p.id}')">
<div class="kv-key">${(p.source||'').slice(0,12)}</div>
<div class="kv-val">
<span class="pill">${p.id.substring(0,8)}</span>
${escapeHtml((p.content||'').substring(0,120))}${(p.content||'').length>120?'…':''}
<div class="actions" style="margin-top:6px" onclick="event.stopPropagation()">
<button class="btn-ok" onclick="confirm('${p.id}', event)">✅</button>
<button class="btn-no" onclick="reject('${p.id}', event)">❌</button>
</div>
</div>
</div>
`).join('');
el.innerHTML = `
<div class="panel">
<div class="panel-title">Config</div>
<div class="kv-row"><div class="kv-key">workspace</div><div class="kv-val">${cfg.workspace}</div></div>
<div class="kv-row"><div class="kv-key">db</div><div class="kv-val">${db.db_path}</div></div>
<div class="kv-row"><div class="kv-key">db mtime</div><div class="kv-val">${new Date(db.mtime).toLocaleString()}</div></div>
</div>
<div class="panel">
<div class="panel-title">Storage</div>
<div class="kv-row"><div class="kv-key">SQL</div><div class="kv-val">${stor.sql.total_engrams} engrams (ok ${stor.sql.confirmed}, pending ${stor.sql.pending})</div></div>
<div class="kv-row"><div class="kv-key">Vector</div><div class="kv-val">chroma ${(stor.vector.chroma_size_bytes/1024/1024).toFixed(1)} MB, cache ${stor.vector.embedding_cache_files} files</div></div>
<div class="kv-row"><div class="kv-key">Obsidian</div><div class="kv-val">${stor.obsidian.configured ? 'configured' : 'not configured'}</div></div>
<div class="kv-row"><div class="kv-key">By source</div><div class="kv-val">${bySource || '-'}</div></div>
</div>
<div class="panel">
<div class="panel-title">Jobs</div>
${jobsHtml || '<div class="muted">Keine Daten</div>'}
</div>
<div class="panel">
<div class="panel-title">Insights</div>
<div class="kv-row"><div class="kv-key">pending</div><div class="kv-val">${ins.pending}</div></div>
<div class="kv-row"><div class="kv-key">top tags</div><div class="kv-val">${topTags || '-'}</div></div>
<div class="kv-row"><div class="kv-key">top hosts</div><div class="kv-val">${topHosts || '-'}</div></div>
</div>
<div class="panel">
<div class="panel-title">Pending Queue (latest)</div>
${pendHtml || '<div class="muted">Keine Pendings</div>'}
</div>
`;
}
async function loadGraph() {
const g = await api('/api/graph?limit_nodes=200');
renderGraph(g.nodes || [], g.edges || []);
}
function renderGraph(nodes, edges) {
const canvas = document.getElementById('graphCanvas');
const hint = document.getElementById('graphHint');
const ctx = canvas.getContext('2d');
// Fit canvas to container width (mobile)
const w = canvas.parentElement.clientWidth - 24;
canvas.width = Math.max(320, Math.min(520, w));
canvas.height = 520;
if (!nodes.length || !edges.length) {
hint.textContent = 'Graph: keine Kanten (noch keine Links/Tags/Hosts im Sample).';
ctx.clearRect(0,0,canvas.width,canvas.height);
return;
}
hint.textContent = `nodes=${nodes.length} edges=${edges.length}`;
const nodeById = new Map(nodes.map(n => [n.id, n]));
const sim = nodes.map(n => ({
id: n.id,
kind: n.kind,
label: n.label || n.id,
x: Math.random()*canvas.width,
y: Math.random()*canvas.height,
vx: 0, vy: 0,
}));
const simById = new Map(sim.map(n => [n.id, n]));
const links = edges
.map(e => ({a: simById.get(e.from), b: simById.get(e.to), kind: e.kind}))
.filter(l => l.a && l.b);
// Simple force layout (few iterations)
for (let iter=0; iter<180; iter++) {
// repulsion
for (let i=0; i<sim.length; i++) {
for (let j=i+1; j<sim.length; j++) {
const a = sim[i], b = sim[j];
const dx = a.x - b.x, dy = a.y - b.y;
const d2 = dx*dx + dy*dy + 0.01;
const f = 120 / d2;
a.vx += dx*f; a.vy += dy*f;
b.vx -= dx*f; b.vy -= dy*f;
}
}
// springs
for (const l of links) {
const a = l.a, b = l.b;
const dx = b.x - a.x, dy = b.y - a.y;
const dist = Math.sqrt(dx*dx + dy*dy) || 1;
const target = 60;
const k = 0.02;
const f = (dist - target) * k;
const fx = (dx/dist)*f, fy = (dy/dist)*f;
a.vx += fx; a.vy += fy;
b.vx -= fx; b.vy -= fy;
}
// integrate + bounds
for (const n of sim) {
n.vx *= 0.85; n.vy *= 0.85;
n.x += n.vx; n.y += n.vy;
n.x = Math.max(10, Math.min(canvas.width-10, n.x));
n.y = Math.max(10, Math.min(canvas.height-10, n.y));
}
}
ctx.clearRect(0,0,canvas.width,canvas.height);
// edges
ctx.globalAlpha = 0.5;
ctx.strokeStyle = '#3a3a55';
ctx.lineWidth = 1;
for (const l of links) {
ctx.beginPath();
ctx.moveTo(l.a.x, l.a.y);
ctx.lineTo(l.b.x, l.b.y);
ctx.stroke();
}
ctx.globalAlpha = 1.0;
// nodes
for (const n of sim) {
let r = 5;
let fill = '#6c8af5';
if (n.kind === 'tag') { fill = '#8a9aff'; r = 4; }
if (n.kind === 'host') { fill = '#f5b46c'; r = 4; }
if (n.kind === 'engram') { fill = '#6c8af5'; r = 5; }
ctx.beginPath();
ctx.fillStyle = fill;
ctx.arc(n.x, n.y, r, 0, Math.PI*2);
ctx.fill();
}
}
// Real-time updates via SSE
function startEvents() {
try {
const es = new EventSource('/api/events');
es.onmessage = (msg) => {
try {
state.lastEvent = JSON.parse(msg.data);
updateStatsFromEvent(state.lastEvent);
if (state.view === 'status') {
// refresh status panels without heavy re-render: just rerun loadStatus occasionally
loadStatus();
}
if (state.view === 'graph') {
// fetch graph less often (every ~15s)
const t = Date.now();
if (!state._lastGraphFetch || (t - state._lastGraphFetch) > 15000) {
state._lastGraphFetch = t;
loadGraph();
}
}
} catch (e) {}
};
es.onerror = () => {
// keep UI usable even if SSE drops
};
} catch (e) {}
}
startEvents();
function renderCards() {
const el = document.getElementById('cards');
el.innerHTML = state.items.map(item => `
<div class="card ${item.confirmed ? 'confirmed' : ''} ${item.rejections > 0 ? 'rejected' : ''}" data-id="${item.id}">
<div class="card-header">
<span class="conf-badge" style="background:hsl(${item.confidence*120},70%,40%)">${Math.round(item.confidence*100)}%</span>
${renderVerdictPill(item)}
<span class="tags">${item.tags.map(t => '<span class="tag">'+t+'</span>').join('')}</span>
<span class="date">${fmtDate(item.created)}</span>
</div>
<div class="card-body" onclick="showDetail('${item.id}')">
${escapeHtml(item.content.substring(0, 200))}${item.content.length>200?'...':''}
</div>
<div class="card-footer">
<input type="text" class="reason-input" placeholder="Grund (optional)" id="reason-${item.id}"/>
<div class="actions">
<button class="btn-ok" onclick="confirm('${item.id}', event)">✅</button>
<button class="btn-no" onclick="reject('${item.id}', event)">❌</button>
<button class="btn-archive" onclick="refresh('${item.id}', event)">🔄</button>
</div>
</div>
</div>
`).join('');
}
function renderVerdictPill(item) {
const v = (item.verdict || '').toString();
if (!v) return '';
let cls = 'v-unknown';
let label = v;
if (v === 'confirmed_true') { cls = 'v-true'; label = 'TRUE'; }
else if (v === 'confirmed_false') { cls = 'v-false'; label = 'FALSE'; }
else if (v === 'probable_true') { cls = 'v-prob-true'; label = 'LIKELY'; }
else if (v === 'probable_false') { cls = 'v-prob-false'; label = 'UNLIKELY'; }
else if (v === 'unknown') { cls = 'v-unknown'; label = 'UNKNOWN'; }
return `<span class="verdict-pill ${cls}">${label}</span>`;
}
function fmtDate(iso) {
const d = new Date(iso);
return `${d.getDate().toString().padStart(2,'0')}.${(d.getMonth()+1).toString().padStart(2,'0')} ${d.getHours().toString().padStart(2,'0')}:${d.getMinutes().toString().padStart(2,'0')}`;
}
function escapeHtml(t) {
const d = document.createElement('div');
d.textContent = t;
return d.innerHTML;
}
// ─── Actions ────────────────────────────────────────────────────────────────
async function confirm(id, ev) {
ev.stopPropagation();
const reason = document.getElementById('reason-'+id).value;
await api(`/api/engrams/${id}/confirm`, {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({reason})
});
await loadCards(); await loadStats();
}
async function reject(id, ev) {
ev.stopPropagation();
const reason = document.getElementById('reason-'+id).value;
await api(`/api/engrams/${id}/reject`, {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({reason})
});
await loadCards(); await loadStats();
}
async function refresh(id, ev) {
ev.stopPropagation();
await api(`/api/engrams/${id}/refresh`, {method: 'POST'});
await loadCards();
}
async function createEngram() {
const content = document.getElementById('newContent').value;
const tags = document.getElementById('newTags').value;
if (!content.trim()) return;
await api('/api/engrams', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({content, tags})
});
document.getElementById('newContent').value = '';
document.getElementById('newTags').value = '';
await loadCards(); await loadStats();
}
async function showDetail(id) {
const item = await api(`/api/engrams/${id}`);
const body = document.getElementById('modalBody');
body.innerHTML = `
<h3>Engramm ${item.id.substring(0,8)}</h3>
<p><b>Confidence:</b> ${Math.round(item.confidence*100)}%</p>
<p><b>Confirmed:</b> ${item.confirmed ? '✅' : '❌'}</p>
<p><b>Tags:</b> ${item.tags.map(t => '<span class="tag">'+t+'</span>').join(' ')}</p>
<p><b>Content:</b></p>
<div class="detail-content">${escapeHtml(item.content)}</div>
<p><b>History:</b></p>
<ul class="history">
${(item.review_history || []).map(h => `<li>${fmtDate(h.at)}${h.action} (${h.note})</li>`).join('')}
</ul>
<p><b>Links:</b> ${item.links?.join(', ') || 'none'}</p>
`;
document.getElementById('detailModal').classList.add('open');
}
function closeModal() {
document.getElementById('detailModal').classList.remove('open');
}
// ─── Pagination ─────────────────────────────────────────────────────────────
function nextPage() {
state.offset += state.limit;
loadCards();
}
function prevPage() {
state.offset = Math.max(0, state.offset - state.limit);
loadCards();
}
function manualRefresh() {
loadCards(); loadStats();
}
// ─── Search ─────────────────────────────────────────────────────────────────
document.getElementById('searchInput').addEventListener('input', (e) => {
state.search = e.target.value;
state.offset = 0;
loadCards();
});
document.getElementById('filterSelect').addEventListener('change', (e) => {
state.filter = e.target.value;
state.offset = 0;
loadCards();
});
// ─── Auto Refresh ───────────────────────────────────────────────────────────
setInterval(() => {
if (!state.autoRefresh) return;
loadStats();
loadCards();
const now = new Date();
document.getElementById('lastUpdate').textContent =
`${now.getHours().toString().padStart(2,'0')}:${now.getMinutes().toString().padStart(2,'0')}`;
}, 5000);
// ─── Init ───────────────────────────────────────────────────────────────────
loadStats();
loadCards();
</script>
</body>
</html>