Merge feature/visualisierung: Charts und Dashboards

SVG-Chart-Renderer (10 Typen), Dashboard-View und Code-Block,
Chart-Support in logfire-sql, vollstaendiges CSS-Theming.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Luca Oelfke 2026-02-12 11:17:34 +01:00
commit 4547e0606e
5 changed files with 1354 additions and 4 deletions

View file

@ -13,9 +13,10 @@ import { LogfireSettingTab } from './ui/settings-tab';
import { InitialScanModal } from './ui/initial-scan-modal';
import { StatusBar } from './ui/status-bar';
import { EventStreamView, EVENT_STREAM_VIEW_TYPE } from './ui/event-stream-view';
import { registerLogfireBlock, registerLogfireSqlBlock, cleanupAllRefreshTimers } from './query/processor';
import { registerLogfireBlock, registerLogfireSqlBlock, registerLogfireDashboardBlock, cleanupAllRefreshTimers } from './query/processor';
import { QueryModal } from './query/query-modal';
import { VirtualTableManager } from './query/virtual-tables';
import { DashboardView, DASHBOARD_VIEW_TYPE } from './viz/dashboard';
export default class LogfirePlugin extends Plugin {
settings!: LogfireSettings;
@ -90,6 +91,12 @@ export default class LogfirePlugin extends Plugin {
(leaf) => new EventStreamView(leaf, this.eventBus),
);
// UI: Dashboard view
this.registerView(
DASHBOARD_VIEW_TYPE,
(leaf) => new DashboardView(leaf, this),
);
// UI: Status bar
this.statusBar = new StatusBar(this);
this.statusBar.start();
@ -101,11 +108,17 @@ export default class LogfirePlugin extends Plugin {
registerLogfireSqlBlock(this.db, (lang, handler) => {
this.registerMarkdownCodeBlockProcessor(lang, handler);
});
registerLogfireDashboardBlock(this, this.db, (lang, handler) => {
this.registerMarkdownCodeBlockProcessor(lang, handler);
});
// Ribbon icon
// Ribbon icons
this.addRibbonIcon('activity', 'Logfire: Event-Stream', () => {
this.activateEventStream();
});
this.addRibbonIcon('layout-dashboard', 'Logfire: Dashboard', () => {
this.activateDashboard();
});
// Commands
this.registerCommands();
@ -298,6 +311,12 @@ export default class LogfirePlugin extends Plugin {
},
});
this.addCommand({
id: 'show-dashboard',
name: 'Dashboard anzeigen',
callback: () => this.activateDashboard(),
});
this.addCommand({
id: 'open-query',
name: 'Query-Editor \u00f6ffnen',
@ -340,6 +359,23 @@ export default class LogfirePlugin extends Plugin {
}
}
// ---------------------------------------------------------------------------
// Dashboard view
// ---------------------------------------------------------------------------
private async activateDashboard(): Promise<void> {
const existing = this.app.workspace.getLeavesOfType(DASHBOARD_VIEW_TYPE);
if (existing.length > 0) {
this.app.workspace.revealLeaf(existing[0]);
return;
}
const leaf = this.app.workspace.getRightLeaf(false);
if (leaf) {
await leaf.setViewState({ type: DASHBOARD_VIEW_TYPE, active: true });
this.app.workspace.revealLeaf(leaf);
}
}
// ---------------------------------------------------------------------------
// Settings
// ---------------------------------------------------------------------------

View file

@ -3,6 +3,9 @@ import { QueryConfig, TimeRange, EventType, EventCategory } from '../types';
import { buildQuery } from '../core/query-builder';
import { DatabaseManager } from '../core/database';
import { renderTable, renderTimeline, renderSummary, renderMetric, renderList, renderHeatmap, formatValue } from '../viz/table-renderer';
import { renderChart, parseChartConfig } from '../viz/chart-renderer';
import { parseDashboardBlock, DashboardView, DASHBOARD_VIEW_TYPE } from '../viz/dashboard';
import type LogfirePlugin from '../main';
// ---------------------------------------------------------------------------
// Refresh timer management
@ -84,13 +87,20 @@ export function registerLogfireSqlBlock(
return;
}
const chartConfig = parseChartConfig(source);
try {
const rows = db.queryReadOnly(sql) as Record<string, unknown>[];
if (!Array.isArray(rows) || rows.length === 0) {
el.createEl('p', { text: 'Keine Ergebnisse.', cls: 'logfire-empty' });
return;
}
if (chartConfig) {
renderChart(el, rows, chartConfig);
} else {
renderTable(el, rows);
}
if (refresh && refresh > 0) {
setupRefreshTimer(el, () => {
@ -101,7 +111,11 @@ export function registerLogfireSqlBlock(
el.createEl('p', { text: 'Keine Ergebnisse.', cls: 'logfire-empty' });
return;
}
if (chartConfig) {
renderChart(el, freshRows, chartConfig);
} else {
renderTable(el, freshRows);
}
} catch (err) {
renderError(el, err);
}
@ -113,6 +127,68 @@ export function registerLogfireSqlBlock(
});
}
// ---------------------------------------------------------------------------
// `logfire-dashboard` block — Dashboard Code-Block
// ---------------------------------------------------------------------------
export function registerLogfireDashboardBlock(
plugin: LogfirePlugin,
db: DatabaseManager,
registerFn: (language: string, handler: (source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext) => void) => void,
): void {
registerFn('logfire-dashboard', (source, el, ctx) => {
const dashboard = parseDashboardBlock(source);
if (!dashboard) {
renderError(el, new Error('Ungültige Dashboard-Definition.'));
return;
}
// Render dashboard inline
const wrapper = el.createDiv({ cls: 'logfire-dash-inline' });
if (dashboard.name) {
wrapper.createDiv({ cls: 'logfire-dash-inline-title', text: dashboard.name });
}
const grid = wrapper.createDiv({ cls: 'logfire-dash-grid' });
grid.style.gridTemplateColumns = `repeat(${dashboard.columns}, 1fr)`;
for (const widget of dashboard.widgets) {
const widgetEl = grid.createDiv({ cls: 'logfire-dash-widget' });
widgetEl.style.gridColumn = `${widget.position.col + 1} / span ${widget.position.width}`;
widgetEl.style.gridRow = `${widget.position.row + 1} / span ${widget.position.height}`;
if (widget.title) {
widgetEl.createDiv({ cls: 'logfire-dash-widget-title', text: widget.title });
}
const content = widgetEl.createDiv({ cls: 'logfire-dash-widget-content' });
try {
if (widget.type === 'text' && widget.text) {
content.createDiv({ text: widget.text });
} else if (widget.sql) {
const rows = db.queryReadOnly(widget.sql) as Record<string, unknown>[];
if (rows.length === 0) {
content.createDiv({ cls: 'logfire-empty', text: 'Keine Ergebnisse.' });
} else if (widget.type === 'chart' && widget.chartConfig) {
renderChart(content, rows, widget.chartConfig);
} else if (widget.type === 'stat') {
renderMetric(content, rows);
} else {
renderTable(content, rows);
}
}
} catch (err) {
content.createDiv({
cls: 'logfire-error',
text: `Fehler: ${err instanceof Error ? err.message : String(err)}`,
});
}
}
});
}
// ---------------------------------------------------------------------------
// YAML config parsing
// ---------------------------------------------------------------------------

667
src/viz/chart-renderer.ts Normal file
View file

@ -0,0 +1,667 @@
export type ChartType =
| 'bar'
| 'line'
| 'pie'
| 'doughnut'
| 'horizontalBar'
| 'scatter'
| 'area'
| 'stackedBar'
| 'radar'
| 'gauge';
export interface ChartConfig {
type: ChartType;
title?: string;
labelColumn?: string;
valueColumn?: string;
valueColumns?: string[];
xColumn?: string;
yColumn?: string;
width?: number;
height?: number;
minValue?: number;
maxValue?: number;
}
const SVG_NS = 'http://www.w3.org/2000/svg';
// ---------------------------------------------------------------------------
// Main entry
// ---------------------------------------------------------------------------
export function renderChart(
el: HTMLElement,
rows: Record<string, unknown>[],
config: ChartConfig,
): void {
if (rows.length === 0) {
el.createEl('p', { text: 'Keine Daten.', cls: 'logfire-empty' });
return;
}
const keys = Object.keys(rows[0]);
if (keys.length < 1) return;
const wrapper = el.createDiv({ cls: 'logfire-chart-wrapper' });
if (config.title) {
wrapper.createDiv({ cls: 'logfire-chart-title', text: config.title });
}
const w = config.width ?? 560;
const h = config.height ?? 280;
const labelCol = config.labelColumn ?? keys[0];
const valueCol = config.valueColumn ?? keys[1] ?? keys[0];
switch (config.type) {
case 'bar':
barChart(wrapper, rows, labelCol, valueCol, w, h);
break;
case 'horizontalBar':
horizontalBarChart(wrapper, rows, labelCol, valueCol, w, h);
break;
case 'line':
lineChart(wrapper, rows, labelCol, valueCol, w, h);
break;
case 'area':
areaChart(wrapper, rows, labelCol, valueCol, w, h);
break;
case 'pie':
case 'doughnut':
pieChart(wrapper, rows, labelCol, valueCol, w, h, config.type === 'doughnut');
break;
case 'scatter':
scatterChart(wrapper, rows, config.xColumn ?? keys[0], config.yColumn ?? keys[1], w, h);
break;
case 'stackedBar':
stackedBarChart(wrapper, rows, labelCol, config.valueColumns ?? keys.slice(1), w, h);
break;
case 'radar':
radarChart(wrapper, rows, labelCol, config.valueColumns ?? [valueCol], w, h);
break;
case 'gauge':
gaugeChart(wrapper, rows, valueCol, w, h, config.minValue ?? 0, config.maxValue ?? 100);
break;
}
}
// ---------------------------------------------------------------------------
// Bar
// ---------------------------------------------------------------------------
function barChart(
container: HTMLElement, rows: Record<string, unknown>[],
labelCol: string, valueCol: string, width: number, height: number,
): void {
const { labels, values } = extract(rows, labelCol, valueCol);
const svg = createSVG(container, width, height);
const maxVal = Math.max(...values, 1);
const pad = { t: 20, r: 20, b: 56, l: 48 };
const cw = width - pad.l - pad.r;
const ch = height - pad.t - pad.b;
const bw = (cw / labels.length) * 0.78;
const gap = (cw / labels.length) * 0.22;
const colors = palette(labels.length);
drawYAxis(svg, maxVal, pad, width, height, ch);
drawAxisLine(svg, pad.l, height - pad.b, width - pad.r, height - pad.b);
values.forEach((v, i) => {
const bh = (v / maxVal) * ch;
const x = pad.l + i * (bw + gap) + gap / 2;
const y = height - pad.b - bh;
const rect = svgEl('rect', {
x, y, width: bw, height: bh, fill: colors[i], class: 'logfire-chart-bar',
});
addTooltip(rect, `${labels[i]}: ${v}`);
svg.appendChild(rect);
svgText(svg, x + bw / 2, height - pad.b + 14, truncate(labels[i], 10), 'middle', 'logfire-chart-label');
});
legend(container, labels, values, colors);
}
// ---------------------------------------------------------------------------
// Horizontal Bar
// ---------------------------------------------------------------------------
function horizontalBarChart(
container: HTMLElement, rows: Record<string, unknown>[],
labelCol: string, valueCol: string, width: number, height: number,
): void {
const { labels, values } = extract(rows, labelCol, valueCol);
const svg = createSVG(container, width, height);
const maxVal = Math.max(...values, 1);
const pad = { t: 20, r: 20, b: 24, l: 100 };
const cw = width - pad.l - pad.r;
const ch = height - pad.t - pad.b;
const bh = (ch / labels.length) * 0.78;
const gap = (ch / labels.length) * 0.22;
const colors = palette(labels.length);
values.forEach((v, i) => {
const w = (v / maxVal) * cw;
const y = pad.t + i * (bh + gap) + gap / 2;
const rect = svgEl('rect', {
x: pad.l, y, width: w, height: bh, fill: colors[i], class: 'logfire-chart-bar',
});
addTooltip(rect, `${labels[i]}: ${v}`);
svg.appendChild(rect);
svgText(svg, pad.l - 6, y + bh / 2 + 4, truncate(labels[i], 14), 'end', 'logfire-chart-label');
svgText(svg, pad.l + w + 5, y + bh / 2 + 4, String(v), 'start', 'logfire-chart-value');
});
}
// ---------------------------------------------------------------------------
// Line
// ---------------------------------------------------------------------------
function lineChart(
container: HTMLElement, rows: Record<string, unknown>[],
labelCol: string, valueCol: string, width: number, height: number,
): void {
const { labels, values } = extract(rows, labelCol, valueCol);
const svg = createSVG(container, width, height);
const maxVal = Math.max(...values, 1);
const minVal = Math.min(...values, 0);
const range = maxVal - minVal || 1;
const pad = { t: 20, r: 20, b: 56, l: 48 };
const cw = width - pad.l - pad.r;
const ch = height - pad.t - pad.b;
drawYAxis(svg, maxVal, pad, width, height, ch);
drawAxisLine(svg, pad.l, height - pad.b, width - pad.r, height - pad.b);
const pts = values.map((v, i) => ({
x: pad.l + (i / (labels.length - 1 || 1)) * cw,
y: height - pad.b - ((v - minVal) / range) * ch,
}));
const d = pts.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ');
svg.appendChild(svgEl('path', {
d, fill: 'none', stroke: 'var(--interactive-accent)', 'stroke-width': 2, class: 'logfire-chart-line',
}));
pts.forEach((p, i) => {
const c = svgEl('circle', {
cx: p.x, cy: p.y, r: 3.5, fill: 'var(--interactive-accent)', class: 'logfire-chart-point',
});
addTooltip(c, `${labels[i]}: ${values[i]}`);
svg.appendChild(c);
if (i % Math.ceil(labels.length / 10) === 0) {
svgText(svg, p.x, height - pad.b + 14, truncate(labels[i], 8), 'middle', 'logfire-chart-label');
}
});
}
// ---------------------------------------------------------------------------
// Area
// ---------------------------------------------------------------------------
function areaChart(
container: HTMLElement, rows: Record<string, unknown>[],
labelCol: string, valueCol: string, width: number, height: number,
): void {
const { labels, values } = extract(rows, labelCol, valueCol);
const svg = createSVG(container, width, height);
const maxVal = Math.max(...values, 1);
const minVal = Math.min(...values, 0);
const range = maxVal - minVal || 1;
const pad = { t: 20, r: 20, b: 56, l: 48 };
const cw = width - pad.l - pad.r;
const ch = height - pad.t - pad.b;
drawYAxis(svg, maxVal, pad, width, height, ch);
drawAxisLine(svg, pad.l, height - pad.b, width - pad.r, height - pad.b);
const pts = values.map((v, i) => ({
x: pad.l + (i / (labels.length - 1 || 1)) * cw,
y: height - pad.b - ((v - minVal) / range) * ch,
}));
// Gradient
const gid = `lg-${Math.random().toString(36).slice(2, 9)}`;
const defs = document.createElementNS(SVG_NS, 'defs');
const grad = document.createElementNS(SVG_NS, 'linearGradient');
grad.id = gid;
grad.setAttribute('x1', '0%'); grad.setAttribute('y1', '0%');
grad.setAttribute('x2', '0%'); grad.setAttribute('y2', '100%');
const s1 = document.createElementNS(SVG_NS, 'stop');
s1.setAttribute('offset', '0%');
s1.setAttribute('style', 'stop-color:var(--interactive-accent);stop-opacity:0.35');
const s2 = document.createElementNS(SVG_NS, 'stop');
s2.setAttribute('offset', '100%');
s2.setAttribute('style', 'stop-color:var(--interactive-accent);stop-opacity:0.04');
grad.appendChild(s1); grad.appendChild(s2);
defs.appendChild(grad); svg.appendChild(defs);
const base = height - pad.b;
const areaPts = pts.map(p => `${p.x},${p.y}`);
svg.appendChild(svgEl('path', {
d: `M ${pts[0].x} ${base} L ${areaPts.join(' L ')} L ${pts[pts.length - 1].x} ${base} Z`,
fill: `url(#${gid})`, class: 'logfire-chart-area',
}));
const d = pts.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ');
svg.appendChild(svgEl('path', {
d, fill: 'none', stroke: 'var(--interactive-accent)', 'stroke-width': 2, class: 'logfire-chart-line',
}));
pts.forEach((p, i) => {
const c = svgEl('circle', {
cx: p.x, cy: p.y, r: 3.5, fill: 'var(--interactive-accent)', class: 'logfire-chart-point',
});
addTooltip(c, `${labels[i]}: ${values[i]}`);
svg.appendChild(c);
});
}
// ---------------------------------------------------------------------------
// Pie / Doughnut
// ---------------------------------------------------------------------------
function pieChart(
container: HTMLElement, rows: Record<string, unknown>[],
labelCol: string, valueCol: string, width: number, height: number,
isDoughnut: boolean,
): void {
const { labels, values } = extract(rows, labelCol, valueCol);
const svg = createSVG(container, width, height);
const total = values.reduce((a, b) => a + b, 0) || 1;
const cx = width / 2;
const cy = height / 2;
const r = Math.min(width, height) / 2 - 36;
const ir = isDoughnut ? r * 0.5 : 0;
const colors = palette(labels.length);
let angle = -Math.PI / 2;
values.forEach((v, i) => {
const sa = (v / total) * 2 * Math.PI;
const ea = angle + sa;
const lg = sa > Math.PI ? 1 : 0;
const x1 = cx + r * Math.cos(angle);
const y1 = cy + r * Math.sin(angle);
const x2 = cx + r * Math.cos(ea);
const y2 = cy + r * Math.sin(ea);
let d: string;
if (isDoughnut) {
const ix1 = cx + ir * Math.cos(angle);
const iy1 = cy + ir * Math.sin(angle);
const ix2 = cx + ir * Math.cos(ea);
const iy2 = cy + ir * Math.sin(ea);
d = `M ${x1} ${y1} A ${r} ${r} 0 ${lg} 1 ${x2} ${y2} L ${ix2} ${iy2} A ${ir} ${ir} 0 ${lg} 0 ${ix1} ${iy1} Z`;
} else {
d = `M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${lg} 1 ${x2} ${y2} Z`;
}
const path = svgEl('path', {
d, fill: colors[i], stroke: 'var(--background-primary)', 'stroke-width': 2,
class: 'logfire-chart-slice',
});
addTooltip(path, `${labels[i]}: ${v} (${((v / total) * 100).toFixed(1)}%)`);
svg.appendChild(path);
angle = ea;
});
if (isDoughnut) {
svgText(svg, cx, cy, String(total), 'middle', 'logfire-chart-center');
}
legend(container, labels, values, colors);
}
// ---------------------------------------------------------------------------
// Scatter
// ---------------------------------------------------------------------------
function scatterChart(
container: HTMLElement, rows: Record<string, unknown>[],
xCol: string, yCol: string, width: number, height: number,
): void {
const svg = createSVG(container, width, height);
const pad = { t: 20, r: 20, b: 56, l: 56 };
const cw = width - pad.l - pad.r;
const ch = height - pad.t - pad.b;
const xs = rows.map(r => num(r[xCol]));
const ys = rows.map(r => num(r[yCol]));
const xMin = Math.min(...xs); const xMax = Math.max(...xs);
const yMin = Math.min(...ys); const yMax = Math.max(...ys);
const xRange = xMax - xMin || 1;
const yRange = yMax - yMin || 1;
drawYAxis(svg, yMax, pad, width, height, ch);
drawAxisLine(svg, pad.l, height - pad.b, width - pad.r, height - pad.b);
xs.forEach((xv, i) => {
const x = pad.l + ((xv - xMin) / xRange) * cw;
const y = height - pad.b - ((ys[i] - yMin) / yRange) * ch;
const c = svgEl('circle', {
cx: x, cy: y, r: 5, fill: 'var(--interactive-accent)', opacity: 0.7,
class: 'logfire-chart-point',
});
addTooltip(c, `(${xv}, ${ys[i]})`);
svg.appendChild(c);
});
}
// ---------------------------------------------------------------------------
// Stacked Bar
// ---------------------------------------------------------------------------
function stackedBarChart(
container: HTMLElement, rows: Record<string, unknown>[],
labelCol: string, valueCols: string[], width: number, height: number,
): void {
const svg = createSVG(container, width, height);
const pad = { t: 20, r: 20, b: 56, l: 48 };
const cw = width - pad.l - pad.r;
const ch = height - pad.t - pad.b;
const labels = rows.map(r => String(r[labelCol] ?? ''));
const series = valueCols.map(col => rows.map(r => num(r[col])));
const totals = labels.map((_, i) => series.reduce((s, d) => s + d[i], 0));
const maxVal = Math.max(...totals, 1);
const bw = (cw / labels.length) * 0.78;
const gap = (cw / labels.length) * 0.22;
const colors = palette(valueCols.length);
drawYAxis(svg, maxVal, pad, width, height, ch);
drawAxisLine(svg, pad.l, height - pad.b, width - pad.r, height - pad.b);
labels.forEach((label, i) => {
let offset = 0;
const x = pad.l + i * (bw + gap) + gap / 2;
series.forEach((ds, si) => {
const bh = (ds[i] / maxVal) * ch;
const y = height - pad.b - offset - bh;
const rect = svgEl('rect', {
x, y, width: bw, height: bh, fill: colors[si], class: 'logfire-chart-bar',
});
addTooltip(rect, `${label}${valueCols[si]}: ${ds[i]}`);
svg.appendChild(rect);
offset += bh;
});
svgText(svg, x + bw / 2, height - pad.b + 14, truncate(label, 10), 'middle', 'logfire-chart-label');
});
seriesLegend(container, valueCols, colors);
}
// ---------------------------------------------------------------------------
// Radar
// ---------------------------------------------------------------------------
function radarChart(
container: HTMLElement, rows: Record<string, unknown>[],
labelCol: string, valueCols: string[], width: number, height: number,
): void {
const svg = createSVG(container, width, height);
const cx = width / 2;
const cy = height / 2;
const r = Math.min(width, height) / 2 - 50;
const labels = rows.map(row => String(row[labelCol] ?? ''));
const series = valueCols.map(col => rows.map(row => num(row[col])));
const maxVal = Math.max(...series.flat(), 1);
const step = (2 * Math.PI) / labels.length;
const colors = palette(valueCols.length);
// Grid
for (let lvl = 1; lvl <= 5; lvl++) {
const lr = (r / 5) * lvl;
const pts = labels.map((_, i) => {
const a = i * step - Math.PI / 2;
return `${cx + lr * Math.cos(a)},${cy + lr * Math.sin(a)}`;
}).join(' ');
svg.appendChild(svgEl('polygon', {
points: pts, fill: 'none', stroke: 'var(--background-modifier-border)', 'stroke-width': 1,
}));
}
// Axes + labels
labels.forEach((label, i) => {
const a = i * step - Math.PI / 2;
svg.appendChild(svgEl('line', {
x1: cx, y1: cy, x2: cx + r * Math.cos(a), y2: cy + r * Math.sin(a),
stroke: 'var(--background-modifier-border)', 'stroke-width': 1,
}));
svgText(svg, cx + (r + 16) * Math.cos(a), cy + (r + 16) * Math.sin(a),
truncate(label, 10), 'middle', 'logfire-chart-label');
});
// Data polygons
series.forEach((ds, si) => {
const pts = ds.map((v, i) => {
const a = i * step - Math.PI / 2;
const vr = (v / maxVal) * r;
return `${cx + vr * Math.cos(a)},${cy + vr * Math.sin(a)}`;
}).join(' ');
const poly = svgEl('polygon', {
points: pts, fill: colors[si], 'fill-opacity': 0.25,
stroke: colors[si], 'stroke-width': 2, class: 'logfire-chart-radar',
});
addTooltip(poly, valueCols[si]);
svg.appendChild(poly);
});
if (valueCols.length > 1) seriesLegend(container, valueCols, colors);
}
// ---------------------------------------------------------------------------
// Gauge
// ---------------------------------------------------------------------------
function gaugeChart(
container: HTMLElement, rows: Record<string, unknown>[],
valueCol: string, width: number, height: number,
minVal: number, maxVal: number,
): void {
const svg = createSVG(container, width, height);
const cx = width / 2;
const cy = height * 0.65;
const r = Math.min(width, height) * 0.38;
const ir = r * 0.68;
const value = num(rows[0]?.[valueCol]);
const range = maxVal - minVal || 1;
const pct = Math.max(0, Math.min(1, (value - minVal) / range));
// Background arc
svg.appendChild(svgEl('path', {
d: arc(cx, cy, r, ir, Math.PI, 2 * Math.PI),
fill: 'var(--background-modifier-border)',
}));
// Value arc
const color = pct < 0.33 ? '#E45756' : pct < 0.66 ? '#EECA3B' : '#54A24B';
svg.appendChild(svgEl('path', {
d: arc(cx, cy, r, ir, Math.PI, Math.PI + pct * Math.PI),
fill: color,
}));
svgText(svg, cx, cy - 8, fmtNum(value), 'middle', 'logfire-chart-gauge-value');
svgText(svg, cx, cy + 20, `${(pct * 100).toFixed(1)}%`, 'middle', 'logfire-chart-label');
svgText(svg, cx - r + 8, cy + 16, String(minVal), 'start', 'logfire-chart-label');
svgText(svg, cx + r - 8, cy + 16, String(maxVal), 'end', 'logfire-chart-label');
}
// ---------------------------------------------------------------------------
// Chart config parsing from comment directives
// ---------------------------------------------------------------------------
export function parseChartConfig(source: string): ChartConfig | null {
const match = source.match(/^--\s*chart:\s*(\w+)(?:\s+(.*))?$/im);
if (!match) return null;
const type = match[1].toLowerCase() as ChartType;
const valid: ChartType[] = [
'bar', 'line', 'pie', 'doughnut', 'horizontalBar',
'scatter', 'area', 'stackedBar', 'radar', 'gauge',
];
if (!valid.includes(type)) return null;
const opts = match[2] ?? '';
const config: ChartConfig = { type };
const str = (re: RegExp) => { const m = opts.match(re); return m ? m[1] : undefined; };
const n = (re: RegExp) => { const m = opts.match(re); return m ? parseFloat(m[1]) : undefined; };
config.title = str(/title:"([^"]+)"/);
config.labelColumn = str(/label:(\S+)/);
const vc = str(/value:(\S+)/);
if (vc?.includes(',')) {
config.valueColumns = vc.split(',');
} else if (vc) {
config.valueColumn = vc;
}
config.xColumn = str(/x:(\S+)/);
config.yColumn = str(/y:(\S+)/);
config.width = n(/width:(\d+)/);
config.height = n(/height:(\d+)/);
config.minValue = n(/min:(-?\d+(?:\.\d+)?)/);
config.maxValue = n(/max:(-?\d+(?:\.\d+)?)/);
return config;
}
// ---------------------------------------------------------------------------
// SVG helpers
// ---------------------------------------------------------------------------
function createSVG(container: HTMLElement, w: number, h: number): SVGSVGElement {
const svg = document.createElementNS(SVG_NS, 'svg');
svg.setAttribute('width', String(w));
svg.setAttribute('height', String(h));
svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
svg.setAttribute('class', 'logfire-chart-svg');
container.appendChild(svg);
return svg;
}
function svgEl(tag: string, attrs: Record<string, unknown>): SVGElement {
const el = document.createElementNS(SVG_NS, tag);
for (const [k, v] of Object.entries(attrs)) {
if (v !== undefined) el.setAttribute(k, String(v));
}
return el;
}
function svgText(
svg: SVGSVGElement, x: number, y: number, text: string,
anchor: string, cls: string,
): void {
const el = document.createElementNS(SVG_NS, 'text');
el.setAttribute('x', String(x));
el.setAttribute('y', String(y));
el.setAttribute('text-anchor', anchor);
if (cls.includes('center')) el.setAttribute('dominant-baseline', 'middle');
el.setAttribute('class', cls);
el.textContent = text;
svg.appendChild(el);
}
function addTooltip(el: SVGElement, text: string): void {
const title = document.createElementNS(SVG_NS, 'title');
title.textContent = text;
el.appendChild(title);
}
function drawAxisLine(svg: SVGSVGElement, x1: number, y1: number, x2: number, y2: number): void {
svg.appendChild(svgEl('line', {
x1, y1, x2, y2, stroke: 'var(--text-muted)', 'stroke-width': 1,
}));
}
function drawYAxis(
svg: SVGSVGElement, maxVal: number,
pad: { t: number; r: number; b: number; l: number },
width: number, height: number, ch: number,
): void {
drawAxisLine(svg, pad.l, pad.t, pad.l, height - pad.b);
for (let i = 0; i <= 5; i++) {
const v = (maxVal / 5) * i;
const y = height - pad.b - (i / 5) * ch;
svgText(svg, pad.l - 6, y + 4, fmtNum(v), 'end', 'logfire-chart-axis-label');
svg.appendChild(svgEl('line', {
x1: pad.l, y1: y, x2: width - pad.r, y2: y,
stroke: 'var(--background-modifier-border)', 'stroke-dasharray': '2,2',
}));
}
}
function arc(cx: number, cy: number, or: number, ir: number, sa: number, ea: number): string {
const lg = ea - sa > Math.PI ? 1 : 0;
const so = { x: cx + or * Math.cos(sa), y: cy + or * Math.sin(sa) };
const eo = { x: cx + or * Math.cos(ea), y: cy + or * Math.sin(ea) };
const si = { x: cx + ir * Math.cos(ea), y: cy + ir * Math.sin(ea) };
const ei = { x: cx + ir * Math.cos(sa), y: cy + ir * Math.sin(sa) };
return `M ${so.x} ${so.y} A ${or} ${or} 0 ${lg} 1 ${eo.x} ${eo.y} L ${si.x} ${si.y} A ${ir} ${ir} 0 ${lg} 0 ${ei.x} ${ei.y} Z`;
}
// ---------------------------------------------------------------------------
// Data helpers
// ---------------------------------------------------------------------------
function extract(rows: Record<string, unknown>[], labelCol: string, valueCol: string) {
return {
labels: rows.map(r => String(r[labelCol] ?? '')),
values: rows.map(r => num(r[valueCol])),
};
}
function num(v: unknown): number {
if (typeof v === 'number') return v;
return parseFloat(String(v)) || 0;
}
function fmtNum(v: number): string {
if (v >= 1e6) return (v / 1e6).toFixed(1) + 'M';
if (v >= 1e3) return (v / 1e3).toFixed(1) + 'K';
return v % 1 === 0 ? String(v) : v.toFixed(1);
}
function truncate(s: string, max: number): string {
return s.length <= max ? s : s.slice(0, max - 1) + '\u2026';
}
function palette(n: number): string[] {
const base = ['#4C78A8', '#F58518', '#E45756', '#72B7B2', '#54A24B', '#EECA3B', '#B279A2', '#FF9DA6', '#9D755D', '#BAB0AC'];
return Array.from({ length: n }, (_, i) => base[i % base.length]);
}
// ---------------------------------------------------------------------------
// Legend
// ---------------------------------------------------------------------------
function legend(container: HTMLElement, labels: string[], values: number[], colors: string[]): void {
const total = values.reduce((a, b) => a + b, 0) || 1;
const el = container.createDiv({ cls: 'logfire-chart-legend' });
labels.forEach((label, i) => {
const item = el.createDiv({ cls: 'logfire-legend-item' });
const swatch = item.createDiv({ cls: 'logfire-legend-swatch' });
swatch.style.backgroundColor = colors[i];
item.createSpan({ text: `${label}: ${values[i]} (${((values[i] / total) * 100).toFixed(1)}%)` });
});
}
function seriesLegend(container: HTMLElement, names: string[], colors: string[]): void {
const el = container.createDiv({ cls: 'logfire-chart-legend' });
names.forEach((name, i) => {
const item = el.createDiv({ cls: 'logfire-legend-item' });
const swatch = item.createDiv({ cls: 'logfire-legend-swatch' });
swatch.style.backgroundColor = colors[i];
item.createSpan({ text: name });
});
}

326
src/viz/dashboard.ts Normal file
View 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);
}
}

View file

@ -679,3 +679,248 @@
white-space: pre-wrap;
word-break: break-word;
}
/*
Charts SVG Visualization
*/
.logfire-chart-wrapper {
padding: 8px 0;
}
.logfire-chart-title {
font-family: var(--font-monospace);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
color: var(--text-muted);
padding: 0 0 6px 0;
}
.logfire-chart-svg {
display: block;
max-width: 100%;
height: auto;
}
.logfire-chart-svg text {
font-family: var(--font-monospace);
font-size: 10px;
fill: var(--text-muted);
}
.logfire-chart-svg .logfire-chart-label {
font-size: 9.5px;
fill: var(--text-faint);
}
.logfire-chart-svg .logfire-chart-axis-label {
font-size: 9px;
fill: var(--text-faint);
font-variant-numeric: tabular-nums;
}
.logfire-chart-svg .logfire-chart-value {
font-size: 10px;
fill: var(--text-normal);
font-variant-numeric: tabular-nums;
}
.logfire-chart-svg .logfire-chart-center {
font-size: 18px;
font-weight: 700;
fill: var(--text-normal);
}
.logfire-chart-svg .logfire-chart-gauge-value {
font-size: 22px;
font-weight: 700;
fill: var(--text-normal);
}
.logfire-chart-svg .logfire-chart-bar {
transition: opacity 100ms ease;
}
.logfire-chart-svg .logfire-chart-bar:hover {
opacity: 0.8;
}
.logfire-chart-svg .logfire-chart-point {
transition: r 100ms ease;
}
.logfire-chart-svg .logfire-chart-point:hover {
r: 5.5;
}
.logfire-chart-svg .logfire-chart-slice {
transition: opacity 100ms ease;
}
.logfire-chart-svg .logfire-chart-slice:hover {
opacity: 0.85;
}
/* ---------------------------------------------------------------------------
Chart Legend
--------------------------------------------------------------------------- */
.logfire-chart-legend {
display: flex;
flex-wrap: wrap;
gap: 4px 12px;
padding: 6px 0 0 0;
font-family: var(--font-monospace);
font-size: 10px;
color: var(--text-muted);
}
.logfire-legend-item {
display: inline-flex;
align-items: center;
gap: 4px;
}
.logfire-legend-swatch {
width: 8px;
height: 8px;
border-radius: 2px;
flex-shrink: 0;
}
/*
Dashboard View & Inline
*/
.logfire-dashboard-view {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
background: var(--background-primary);
}
/* ---------------------------------------------------------------------------
Dashboard Header
--------------------------------------------------------------------------- */
.logfire-dash-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 10px;
border-bottom: 1px solid var(--background-modifier-border);
background: var(--background-secondary);
flex-shrink: 0;
}
.logfire-dash-title {
font-family: var(--font-monospace);
font-size: 13px;
font-weight: 600;
color: var(--text-normal);
letter-spacing: -0.01em;
}
.logfire-dash-actions {
display: flex;
gap: 4px;
}
.logfire-dash-btn {
padding: 3px 8px;
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
background: var(--background-primary);
color: var(--text-muted);
font-family: var(--font-monospace);
font-size: 13px;
cursor: pointer;
transition: background 100ms ease, color 100ms ease;
}
.logfire-dash-btn:hover {
background: var(--background-modifier-hover);
color: var(--text-normal);
}
/* ---------------------------------------------------------------------------
Dashboard Content & Grid
--------------------------------------------------------------------------- */
.logfire-dash-content {
flex: 1;
overflow: auto;
padding: 10px;
}
.logfire-dash-grid {
display: grid;
gap: 8px;
min-height: 0;
}
/* ---------------------------------------------------------------------------
Dashboard Widget
--------------------------------------------------------------------------- */
.logfire-dash-widget {
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
background: var(--background-primary);
overflow: hidden;
display: flex;
flex-direction: column;
}
.logfire-dash-widget-title {
padding: 5px 10px;
font-family: var(--font-monospace);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-muted);
background: var(--background-secondary);
border-bottom: 1px solid var(--background-modifier-border);
}
.logfire-dash-widget-content {
flex: 1;
overflow: auto;
min-height: 0;
}
.logfire-dash-widget-content::-webkit-scrollbar {
width: 4px;
height: 4px;
}
.logfire-dash-widget-content::-webkit-scrollbar-track {
background: transparent;
}
.logfire-dash-widget-content::-webkit-scrollbar-thumb {
background: var(--background-modifier-border);
border-radius: 2px;
}
/* ---------------------------------------------------------------------------
Dashboard Inline (Code-Block Rendering)
--------------------------------------------------------------------------- */
.logfire-dash-inline {
padding: 4px 0;
}
.logfire-dash-inline-title {
font-family: var(--font-monospace);
font-size: 13px;
font-weight: 600;
color: var(--text-normal);
padding: 0 0 8px 0;
letter-spacing: -0.01em;
}