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': 'Refresh' }, 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: 'No dashboard loaded.', }); } 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: `Error: ${err instanceof Error ? err.message : String(err)}`, }); } } private renderQueryWidget(container: HTMLElement, widget: DashboardWidget): void { if (!widget.sql) { container.createDiv({ cls: 'logfire-empty', text: 'No query configured.' }); return; } const rows = this.db.queryReadOnly(widget.sql) as Record[]; if (rows.length === 0) { container.createDiv({ cls: 'logfire-empty', text: 'No results.' }); return; } renderTable(container, rows); } private renderChartWidget(container: HTMLElement, widget: DashboardWidget): void { if (!widget.sql || !widget.chartConfig) { container.createDiv({ cls: 'logfire-empty', text: 'No chart configured.' }); 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: 'No query configured.' }); 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); } }