/** * Hermes technical KPI dashboard + bucket explorer popup */ function hermesKpiDashboard() { const SOURCE_LABELS = { telegram: 'Telegram', api_server: 'Cockpit API', tui: 'TUI', cron: 'Cron', subagent: 'Subagent', }; return { loading: false, kpis: { online: false, overview: {}, buckets: [], tools_top: [], sources: [], herman_cockpit: [] }, chartTools: null, chartSources: null, _timer: null, modal: { open: false, loading: false, previewLoading: false, key: null, title: '', tab: 'activity', toolFilter: null, detail: { events: [], files: [], parts: {}, count: 0 }, explorer: { path: '', parent: null, entries: [] }, selectedEvent: null, preview: null, }, get ov() { return this.kpis.overview || {}; }, fmt(n) { const v = Number(n || 0); return v.toLocaleString('nl-NL'); }, fmtBytes(n) { if (n == null || n === '') return ''; const v = Number(n); if (v < 1024) return v + ' B'; if (v < 1024 * 1024) return (v / 1024).toFixed(1) + ' KB'; return (v / (1024 * 1024)).toFixed(1) + ' MB'; }, formatTime(iso) { if (!iso) return ''; try { return new Date(iso).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' }); } catch (e) { return String(iso).slice(0, 19); } }, partsLine(parts) { if (!parts) return ''; return Object.keys(parts) .filter((k) => parts[k] > 0) .map((k) => parts[k] + ' ' + k.replace(/_/g, ' ')) .join(' · '); }, sourceLabel(s) { return SOURCE_LABELS[s] || s || '—'; }, async init() { await this.refresh(); this._timer = setInterval(() => this.refresh(true), 15000); }, async refresh(quiet) { if (!quiet) this.loading = true; try { const data = await Cockpit.api('/hermes/kpis'); this.kpis = data || this.kpis; this.$nextTick(() => this.renderCharts()); } catch (e) { if (!quiet && window.Cockpit) Cockpit.toast(e.message || 'KPI laden mislukt', 'error'); this.kpis.online = false; } this.loading = false; }, async openBucket(b) { this.modal.open = true; this.modal.key = b.key; this.modal.title = b.label; this.modal.tab = b.key === 'files' ? 'touched' : 'activity'; this.modal.toolFilter = null; this.modal.selectedEvent = null; this.modal.preview = null; this.modal.detail = { events: [], files: [], parts: b.parts || {}, count: b.count || 0 }; await this.loadBucketDetail(); if (b.key === 'files') { // preload roots quietly this.loadExplorer('').catch(() => {}); } }, closeModal() { this.modal.open = false; this.modal.preview = null; this.modal.selectedEvent = null; }, async setToolFilter(tool) { this.modal.toolFilter = tool; await this.loadBucketDetail(); }, async loadBucketDetail() { if (!this.modal.key) return; this.modal.loading = true; try { const q = new URLSearchParams({ limit: '80' }); if (this.modal.toolFilter) q.set('tool', this.modal.toolFilter); const data = await Cockpit.api('/hermes/bucket/' + encodeURIComponent(this.modal.key) + '?' + q.toString()); this.modal.detail = data || this.modal.detail; if (!(this.modal.detail.events || []).length) { this.modal.selectedEvent = null; } } catch (e) { if (window.Cockpit) Cockpit.toast(e.message || 'Detail laden mislukt', 'error'); } this.modal.loading = false; }, selectEvent(ev) { this.modal.selectedEvent = ev; this.modal.preview = null; if (ev.paths && ev.paths.length === 1 && this.modal.key === 'files') { this.openFile(ev.paths[0]); } }, openExplorerTab() { this.modal.tab = 'explorer'; if (!(this.modal.explorer.entries || []).length) { this.loadExplorer(this.modal.explorer.path || ''); } }, async loadExplorer(path) { this.modal.loading = true; try { const q = path ? ('?path=' + encodeURIComponent(path)) : ''; const data = await Cockpit.api('/hermes/explorer' + q); this.modal.explorer = data || { path: '', parent: null, entries: [] }; } catch (e) { if (window.Cockpit) Cockpit.toast(e.message || 'Verkenner laden mislukt', 'error'); } this.modal.loading = false; }, explorerUp() { if (this.modal.explorer.parent) this.loadExplorer(this.modal.explorer.parent); else this.loadExplorer(''); }, async openFile(path) { if (!path) return; this.modal.previewLoading = true; this.modal.selectedEvent = null; try { const data = await Cockpit.api('/hermes/explorer/file?path=' + encodeURIComponent(path)); this.modal.preview = data || { path, error: 'Geen data', name: path.split('/').pop() }; if (this.modal.key === 'files' && this.modal.tab === 'activity') { // keep tab; preview still shows } } catch (e) { this.modal.preview = { path, name: path.split('/').pop(), error: e.message || 'Laden mislukt', content: null }; } this.modal.previewLoading = false; }, renderCharts() { if (typeof Chart === 'undefined') return; const tools = (this.kpis.tools_top || []).slice(0, 8); const sources = this.kpis.sources || []; const toolsCtx = document.getElementById('hm-chart-tools'); if (toolsCtx) { if (this.chartTools) this.chartTools.destroy(); this.chartTools = new Chart(toolsCtx, { type: 'bar', data: { labels: tools.map((t) => t.name), datasets: [{ label: 'Calls', data: tools.map((t) => t.count), backgroundColor: 'rgba(14, 165, 233, 0.55)', borderColor: 'rgba(56, 189, 248, 0.9)', borderWidth: 1, }], }, options: { indexAxis: 'y', responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { ticks: { color: '#94a3b8' }, grid: { color: 'rgba(148,163,184,0.12)' } }, y: { ticks: { color: '#cbd5e1', font: { size: 10 } }, grid: { display: false } }, }, }, }); } const srcCtx = document.getElementById('hm-chart-sources'); if (srcCtx) { if (this.chartSources) this.chartSources.destroy(); this.chartSources = new Chart(srcCtx, { type: 'doughnut', data: { labels: sources.map((s) => this.sourceLabel(s.source)), datasets: [{ data: sources.map((s) => s.tools || 0), backgroundColor: ['#0ea5e9', '#a855f7', '#f59e0b', '#22c55e', '#f43f5e', '#64748b'], }], }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'bottom', labels: { color: '#94a3b8', boxWidth: 10, font: { size: 10 } } }, }, }, }); } }, }; } function hermesControl() { return hermesKpiDashboard(); }