diff --git a/src/viz/dashboard.ts b/src/viz/dashboard.ts new file mode 100644 index 0000000..1140d5d --- /dev/null +++ b/src/viz/dashboard.ts @@ -0,0 +1,326 @@ +import { ItemView, WorkspaceLeaf, MarkdownRenderer, Notice } from 'obsidian'; +import { DatabaseManager } from '../core/database'; +import { renderChart, parseChartConfig, ChartConfig } from './chart-renderer'; +import { renderTable, renderMetric } from './table-renderer'; +import type LogfirePlugin from '../main'; + +export const DASHBOARD_VIEW_TYPE = 'logfire-dashboard-view'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface DashboardWidget { + id: string; + type: 'query' | 'chart' | 'stat' | 'text'; + title?: string; + sql?: string; + chartConfig?: ChartConfig; + text?: string; + refreshInterval?: number; + position: { row: number; col: number; width: number; height: number }; +} + +export interface Dashboard { + id: string; + name: string; + widgets: DashboardWidget[]; + columns: number; + refreshAll?: number; +} + +// --------------------------------------------------------------------------- +// Dashboard View +// --------------------------------------------------------------------------- + +export class DashboardView extends ItemView { + private plugin: LogfirePlugin; + private db: DatabaseManager; + private currentDashboard: Dashboard | null = null; + private contentContainer!: HTMLElement; + private refreshTimers = new Map>(); + private globalRefreshTimer: ReturnType | null = null; + + constructor(leaf: WorkspaceLeaf, plugin: LogfirePlugin) { + super(leaf); + this.plugin = plugin; + this.db = plugin.db; + } + + getViewType(): string { + return DASHBOARD_VIEW_TYPE; + } + + getDisplayText(): string { + return this.currentDashboard?.name ?? 'Dashboard'; + } + + getIcon(): string { + return 'layout-dashboard'; + } + + async onOpen(): Promise { + const container = this.containerEl.children[1] as HTMLElement; + container.empty(); + container.addClass('logfire-dashboard-view'); + + const header = container.createDiv({ cls: 'logfire-dash-header' }); + header.createSpan({ cls: 'logfire-dash-title', text: 'Dashboard' }); + + const actions = header.createDiv({ cls: 'logfire-dash-actions' }); + + const refreshBtn = actions.createEl('button', { + cls: 'logfire-dash-btn clickable-icon', + attr: { 'aria-label': 'Aktualisieren' }, + text: '\u21bb', + }); + refreshBtn.addEventListener('click', () => this.refreshAll()); + + this.contentContainer = container.createDiv({ cls: 'logfire-dash-content' }); + this.showEmptyState(); + } + + // --------------------------------------------------------------------------- + // Public: load a parsed dashboard + // --------------------------------------------------------------------------- + + loadDashboard(dashboard: Dashboard): void { + this.clearTimers(); + this.currentDashboard = dashboard; + + const titleEl = this.containerEl.querySelector('.logfire-dash-title'); + if (titleEl) titleEl.textContent = dashboard.name; + + this.render(); + + if (dashboard.refreshAll && dashboard.refreshAll > 0) { + this.globalRefreshTimer = setInterval(() => this.refreshAll(), dashboard.refreshAll * 1000); + } + } + + // --------------------------------------------------------------------------- + // Rendering + // --------------------------------------------------------------------------- + + private render(): void { + this.contentContainer.empty(); + if (!this.currentDashboard || this.currentDashboard.widgets.length === 0) { + this.showEmptyState(); + return; + } + + const grid = this.contentContainer.createDiv({ cls: 'logfire-dash-grid' }); + grid.style.gridTemplateColumns = `repeat(${this.currentDashboard.columns}, 1fr)`; + + for (const widget of this.currentDashboard.widgets) { + this.renderWidget(grid, widget); + } + } + + private showEmptyState(): void { + this.contentContainer.empty(); + this.contentContainer.createDiv({ + cls: 'logfire-empty', + text: 'Kein Dashboard geladen.', + }); + } + + private renderWidget(container: HTMLElement, widget: DashboardWidget): void { + const el = container.createDiv({ cls: 'logfire-dash-widget' }); + el.style.gridColumn = `${widget.position.col + 1} / span ${widget.position.width}`; + el.style.gridRow = `${widget.position.row + 1} / span ${widget.position.height}`; + + if (widget.title) { + el.createDiv({ cls: 'logfire-dash-widget-title', text: widget.title }); + } + + const content = el.createDiv({ cls: 'logfire-dash-widget-content' }); + this.renderWidgetContent(content, widget); + + if (widget.refreshInterval && widget.refreshInterval > 0) { + const timer = setInterval(() => { + if (!document.contains(el)) { + clearInterval(timer); + this.refreshTimers.delete(widget.id); + return; + } + content.empty(); + this.renderWidgetContent(content, widget); + }, widget.refreshInterval * 1000); + this.refreshTimers.set(widget.id, timer); + } + } + + private renderWidgetContent(container: HTMLElement, widget: DashboardWidget): void { + try { + switch (widget.type) { + case 'query': + this.renderQueryWidget(container, widget); + break; + case 'chart': + this.renderChartWidget(container, widget); + break; + case 'stat': + this.renderStatWidget(container, widget); + break; + case 'text': + this.renderTextWidget(container, widget); + break; + } + } catch (err) { + container.createDiv({ + cls: 'logfire-error', + text: `Fehler: ${err instanceof Error ? err.message : String(err)}`, + }); + } + } + + private renderQueryWidget(container: HTMLElement, widget: DashboardWidget): void { + if (!widget.sql) { + container.createDiv({ cls: 'logfire-empty', text: 'Keine Query konfiguriert.' }); + return; + } + const rows = this.db.queryReadOnly(widget.sql) as Record[]; + if (rows.length === 0) { + container.createDiv({ cls: 'logfire-empty', text: 'Keine Ergebnisse.' }); + return; + } + renderTable(container, rows); + } + + private renderChartWidget(container: HTMLElement, widget: DashboardWidget): void { + if (!widget.sql || !widget.chartConfig) { + container.createDiv({ cls: 'logfire-empty', text: 'Kein Chart konfiguriert.' }); + return; + } + const rows = this.db.queryReadOnly(widget.sql) as Record[]; + renderChart(container, rows, widget.chartConfig); + } + + private renderStatWidget(container: HTMLElement, widget: DashboardWidget): void { + if (!widget.sql) { + container.createDiv({ cls: 'logfire-empty', text: 'Keine Query konfiguriert.' }); + return; + } + const rows = this.db.queryReadOnly(widget.sql) as Record[]; + renderMetric(container, rows); + } + + private renderTextWidget(container: HTMLElement, widget: DashboardWidget): void { + if (widget.text) { + MarkdownRenderer.render(this.plugin.app, widget.text, container, '', this.plugin); + } + } + + // --------------------------------------------------------------------------- + // Refresh & cleanup + // --------------------------------------------------------------------------- + + private refreshAll(): void { + this.render(); + } + + private clearTimers(): void { + for (const timer of this.refreshTimers.values()) clearInterval(timer); + this.refreshTimers.clear(); + if (this.globalRefreshTimer) { + clearInterval(this.globalRefreshTimer); + this.globalRefreshTimer = null; + } + } + + async onClose(): Promise { + this.clearTimers(); + } +} + +// --------------------------------------------------------------------------- +// Code-block parser: ```logfire-dashboard +// --------------------------------------------------------------------------- + +export function parseDashboardBlock(source: string): Dashboard | null { + const lines = source.split('\n'); + const dashboard: Dashboard = { + id: `dash-${Date.now()}`, + name: 'Dashboard', + widgets: [], + columns: 12, + }; + + let currentWidget: Partial | null = null; + let widgetLines: string[] = []; + + for (const line of lines) { + // Dashboard metadata + const nameMatch = line.match(/^name:\s*(.+)$/i); + if (nameMatch) { dashboard.name = nameMatch[1].trim(); continue; } + + const colsMatch = line.match(/^columns:\s*(\d+)$/i); + if (colsMatch) { dashboard.columns = parseInt(colsMatch[1]); continue; } + + const refreshMatch = line.match(/^refresh:\s*(\d+)$/i); + if (refreshMatch) { dashboard.refreshAll = parseInt(refreshMatch[1]); continue; } + + // Widget header + const widgetMatch = line.match( + /^\[widget:(\w+)\s+row:(\d+)\s+col:(\d+)\s+width:(\d+)\s+height:(\d+)\]$/i, + ); + if (widgetMatch) { + if (currentWidget) finalizeWidget(currentWidget, widgetLines, dashboard.widgets); + currentWidget = { + id: `w-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + type: widgetMatch[1] as DashboardWidget['type'], + position: { + row: parseInt(widgetMatch[2]), + col: parseInt(widgetMatch[3]), + width: parseInt(widgetMatch[4]), + height: parseInt(widgetMatch[5]), + }, + }; + widgetLines = []; + continue; + } + + if (currentWidget) widgetLines.push(line); + } + + if (currentWidget) finalizeWidget(currentWidget, widgetLines, dashboard.widgets); + return dashboard.widgets.length > 0 ? dashboard : null; +} + +function finalizeWidget( + widget: Partial, + lines: string[], + widgets: DashboardWidget[], +): void { + const content = lines.join('\n').trim(); + + // Title directive + const titleMatch = content.match(/^--\s*title:\s*(.+)$/m); + if (titleMatch) widget.title = titleMatch[1].trim(); + + // Refresh directive + const refreshMatch = content.match(/^--\s*refresh:\s*(\d+)$/m); + if (refreshMatch) widget.refreshInterval = parseInt(refreshMatch[1]); + + // Chart config + const chartConfig = parseChartConfig(content); + if (chartConfig) widget.chartConfig = chartConfig; + + // Extract SQL (lines that are not directives) + const sqlLines = content + .split('\n') + .filter(l => !l.match(/^--\s*(title|refresh|chart):/i)); + const sql = sqlLines.join('\n').trim(); + if (sql) widget.sql = sql; + + // Text widget: use full content + if (widget.type === 'text') { + widget.text = content; + widget.sql = undefined; + } + + if (widget.id && widget.position) { + widgets.push(widget as DashboardWidget); + } +}