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:
commit
4547e0606e
5 changed files with 1354 additions and 4 deletions
40
src/main.ts
40
src/main.ts
|
|
@ -13,9 +13,10 @@ import { LogfireSettingTab } from './ui/settings-tab';
|
||||||
import { InitialScanModal } from './ui/initial-scan-modal';
|
import { InitialScanModal } from './ui/initial-scan-modal';
|
||||||
import { StatusBar } from './ui/status-bar';
|
import { StatusBar } from './ui/status-bar';
|
||||||
import { EventStreamView, EVENT_STREAM_VIEW_TYPE } from './ui/event-stream-view';
|
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 { QueryModal } from './query/query-modal';
|
||||||
import { VirtualTableManager } from './query/virtual-tables';
|
import { VirtualTableManager } from './query/virtual-tables';
|
||||||
|
import { DashboardView, DASHBOARD_VIEW_TYPE } from './viz/dashboard';
|
||||||
|
|
||||||
export default class LogfirePlugin extends Plugin {
|
export default class LogfirePlugin extends Plugin {
|
||||||
settings!: LogfireSettings;
|
settings!: LogfireSettings;
|
||||||
|
|
@ -90,6 +91,12 @@ export default class LogfirePlugin extends Plugin {
|
||||||
(leaf) => new EventStreamView(leaf, this.eventBus),
|
(leaf) => new EventStreamView(leaf, this.eventBus),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// UI: Dashboard view
|
||||||
|
this.registerView(
|
||||||
|
DASHBOARD_VIEW_TYPE,
|
||||||
|
(leaf) => new DashboardView(leaf, this),
|
||||||
|
);
|
||||||
|
|
||||||
// UI: Status bar
|
// UI: Status bar
|
||||||
this.statusBar = new StatusBar(this);
|
this.statusBar = new StatusBar(this);
|
||||||
this.statusBar.start();
|
this.statusBar.start();
|
||||||
|
|
@ -101,11 +108,17 @@ export default class LogfirePlugin extends Plugin {
|
||||||
registerLogfireSqlBlock(this.db, (lang, handler) => {
|
registerLogfireSqlBlock(this.db, (lang, handler) => {
|
||||||
this.registerMarkdownCodeBlockProcessor(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.addRibbonIcon('activity', 'Logfire: Event-Stream', () => {
|
||||||
this.activateEventStream();
|
this.activateEventStream();
|
||||||
});
|
});
|
||||||
|
this.addRibbonIcon('layout-dashboard', 'Logfire: Dashboard', () => {
|
||||||
|
this.activateDashboard();
|
||||||
|
});
|
||||||
|
|
||||||
// Commands
|
// Commands
|
||||||
this.registerCommands();
|
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({
|
this.addCommand({
|
||||||
id: 'open-query',
|
id: 'open-query',
|
||||||
name: 'Query-Editor \u00f6ffnen',
|
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
|
// Settings
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@ import { QueryConfig, TimeRange, EventType, EventCategory } from '../types';
|
||||||
import { buildQuery } from '../core/query-builder';
|
import { buildQuery } from '../core/query-builder';
|
||||||
import { DatabaseManager } from '../core/database';
|
import { DatabaseManager } from '../core/database';
|
||||||
import { renderTable, renderTimeline, renderSummary, renderMetric, renderList, renderHeatmap, formatValue } from '../viz/table-renderer';
|
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
|
// Refresh timer management
|
||||||
|
|
@ -84,13 +87,20 @@ export function registerLogfireSqlBlock(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const chartConfig = parseChartConfig(source);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const rows = db.queryReadOnly(sql) as Record<string, unknown>[];
|
const rows = db.queryReadOnly(sql) as Record<string, unknown>[];
|
||||||
if (!Array.isArray(rows) || rows.length === 0) {
|
if (!Array.isArray(rows) || rows.length === 0) {
|
||||||
el.createEl('p', { text: 'Keine Ergebnisse.', cls: 'logfire-empty' });
|
el.createEl('p', { text: 'Keine Ergebnisse.', cls: 'logfire-empty' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
renderTable(el, rows);
|
|
||||||
|
if (chartConfig) {
|
||||||
|
renderChart(el, rows, chartConfig);
|
||||||
|
} else {
|
||||||
|
renderTable(el, rows);
|
||||||
|
}
|
||||||
|
|
||||||
if (refresh && refresh > 0) {
|
if (refresh && refresh > 0) {
|
||||||
setupRefreshTimer(el, () => {
|
setupRefreshTimer(el, () => {
|
||||||
|
|
@ -101,7 +111,11 @@ export function registerLogfireSqlBlock(
|
||||||
el.createEl('p', { text: 'Keine Ergebnisse.', cls: 'logfire-empty' });
|
el.createEl('p', { text: 'Keine Ergebnisse.', cls: 'logfire-empty' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
renderTable(el, freshRows);
|
if (chartConfig) {
|
||||||
|
renderChart(el, freshRows, chartConfig);
|
||||||
|
} else {
|
||||||
|
renderTable(el, freshRows);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
renderError(el, 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
|
// YAML config parsing
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
667
src/viz/chart-renderer.ts
Normal file
667
src/viz/chart-renderer.ts
Normal 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
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
245
styles.css
245
styles.css
|
|
@ -679,3 +679,248 @@
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue