Dashboard: View und Code-Block-Parser fuer Multi-Widget-Layouts
DashboardView als Obsidian-Sidebar, parseDashboardBlock fuer logfire-dashboard Code-Bloecke. Grid-Layout mit query, chart, stat und text Widgets. Unabhaengige Refresh-Intervalle pro Widget. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
794cb42b72
commit
eec66b738d
1 changed files with 326 additions and 0 deletions
326
src/viz/dashboard.ts
Normal file
326
src/viz/dashboard.ts
Normal file
|
|
@ -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<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': '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<string, unknown>[];
|
||||||
|
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<string, unknown>[];
|
||||||
|
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<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue