obsidian-logfire/src/viz/dashboard.ts
tolvitty 3c8c22ee07 Localize UI to English across all 22 source files
Translates all German user-facing strings (command names, notices,
settings, modal labels, template names/descriptions, error messages,
status bar, and code comments) to English.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 12:17:24 +01:00

326 lines
10 KiB
TypeScript

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<string, ReturnType<typeof setInterval>>();
private globalRefreshTimer: ReturnType<typeof setInterval> | 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<void> {
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<string, unknown>[];
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<string, unknown>[];
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<string, unknown>[];
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<void> {
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<DashboardWidget> | 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<DashboardWidget>,
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);
}
}