Merge feature/sql-engine: SQL-Query-Engine

This commit is contained in:
Luca Oelfke 2026-02-12 11:03:06 +01:00
commit 263c50c8fd
6 changed files with 1170 additions and 0 deletions

168
src/core/query-builder.ts Normal file
View file

@ -0,0 +1,168 @@
import { QueryConfig, TimeRange } from '../types';
export interface BuiltQuery {
sql: string;
params: unknown[];
}
export function buildQuery(config: QueryConfig): BuiltQuery {
const conditions: string[] = [];
const params: unknown[] = [];
// Time range
const { from, to } = resolveTimeRange(config.timeRange);
conditions.push('timestamp >= ?');
params.push(from);
conditions.push('timestamp <= ?');
params.push(to);
// Event types
if (config.eventTypes && config.eventTypes.length > 0) {
const placeholders = config.eventTypes.map(() => '?').join(', ');
conditions.push(`type IN (${placeholders})`);
params.push(...config.eventTypes);
}
// Categories
if (config.categories && config.categories.length > 0) {
const placeholders = config.categories.map(() => '?').join(', ');
conditions.push(`category IN (${placeholders})`);
params.push(...config.categories);
}
// File paths (glob patterns -> SQL LIKE)
if (config.filePaths && config.filePaths.length > 0) {
const likeConditions = config.filePaths.map(pattern => {
params.push(globToLike(pattern));
return 'source LIKE ?';
});
conditions.push(`(${likeConditions.join(' OR ')})`);
}
// Session filter
if (config.timeRange.type === 'session' && config.timeRange.sessionId) {
conditions.push('session = ?');
params.push(config.timeRange.sessionId);
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
// GROUP BY
if (config.groupBy) {
return buildGroupedQuery(config, where, params);
}
// Simple query
const orderCol = config.orderBy === 'count' ? 'timestamp' : (config.orderBy ?? 'timestamp');
const orderDir = config.orderDirection ?? 'desc';
const limit = config.limit ? `LIMIT ${config.limit}` : '';
const sql = `SELECT * FROM events ${where} ORDER BY ${orderCol} ${orderDir} ${limit}`;
return { sql, params };
}
function buildGroupedQuery(config: QueryConfig, where: string, params: unknown[]): BuiltQuery {
const orderDir = config.orderDirection ?? 'desc';
const limit = config.limit ? `LIMIT ${config.limit}` : '';
let groupExpr: string;
let selectExpr: string;
let orderExpr: string;
switch (config.groupBy) {
case 'file':
groupExpr = 'source';
selectExpr = `source as "group", COUNT(*) as count,
SUM(CASE WHEN json_extract(payload, '$.wordsAdded') IS NOT NULL THEN json_extract(payload, '$.wordsAdded') ELSE 0 END) as words_added,
SUM(CASE WHEN json_extract(payload, '$.wordsRemoved') IS NOT NULL THEN json_extract(payload, '$.wordsRemoved') ELSE 0 END) as words_removed`;
break;
case 'type':
groupExpr = 'type';
selectExpr = `type as "group", COUNT(*) as count`;
break;
case 'hour':
groupExpr = "strftime('%Y-%m-%d %H:00', timestamp / 1000, 'unixepoch', 'localtime')";
selectExpr = `${groupExpr} as "group", COUNT(*) as count`;
break;
case 'day':
groupExpr = "strftime('%Y-%m-%d', timestamp / 1000, 'unixepoch', 'localtime')";
selectExpr = `${groupExpr} as "group", COUNT(*) as count,
SUM(CASE WHEN json_extract(payload, '$.wordsAdded') IS NOT NULL THEN json_extract(payload, '$.wordsAdded') ELSE 0 END) as words_added,
SUM(CASE WHEN json_extract(payload, '$.wordsRemoved') IS NOT NULL THEN json_extract(payload, '$.wordsRemoved') ELSE 0 END) as words_removed`;
break;
case 'session':
groupExpr = 'session';
selectExpr = `session as "group", COUNT(*) as count, MIN(timestamp) as first_event, MAX(timestamp) as last_event`;
break;
default:
groupExpr = 'type';
selectExpr = `type as "group", COUNT(*) as count`;
}
switch (config.orderBy) {
case 'count':
orderExpr = `count ${orderDir}`;
break;
case 'words':
orderExpr = `words_added ${orderDir}`;
break;
default:
orderExpr = `"group" ${orderDir}`;
}
const sql = `SELECT ${selectExpr} FROM events ${where} GROUP BY ${groupExpr} ORDER BY ${orderExpr} ${limit}`;
return { sql, params };
}
// ---------------------------------------------------------------------------
// Time range resolution
// ---------------------------------------------------------------------------
function resolveTimeRange(range: TimeRange): { from: number; to: number } {
if (range.type === 'absolute') {
return { from: range.from, to: range.to };
}
if (range.type === 'session') {
return { from: 0, to: Date.now() };
}
const now = new Date();
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const msPerDay = 86400000;
switch (range.value) {
case 'today':
return { from: startOfToday, to: Date.now() };
case 'yesterday':
return { from: startOfToday - msPerDay, to: startOfToday - 1 };
case 'this-week': {
const dayOfWeek = now.getDay() || 7;
const startOfWeek = startOfToday - (dayOfWeek - 1) * msPerDay;
return { from: startOfWeek, to: Date.now() };
}
case 'this-month': {
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1).getTime();
return { from: startOfMonth, to: Date.now() };
}
case 'last-7-days':
return { from: startOfToday - 7 * msPerDay, to: Date.now() };
case 'last-30-days':
return { from: startOfToday - 30 * msPerDay, to: Date.now() };
default:
return { from: startOfToday, to: Date.now() };
}
}
// ---------------------------------------------------------------------------
// Glob to SQL LIKE
// ---------------------------------------------------------------------------
function globToLike(pattern: string): string {
return pattern
.replace(/%/g, '\\%')
.replace(/_/g, '\\_')
.replace(/\*\*/g, '%')
.replace(/\*/g, '%')
.replace(/\?/g, '_');
}

View file

@ -13,6 +13,8 @@ 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 { QueryModal } from './query/query-modal';
export default class LogfirePlugin extends Plugin { export default class LogfirePlugin extends Plugin {
settings!: LogfireSettings; settings!: LogfireSettings;
@ -90,6 +92,14 @@ export default class LogfirePlugin extends Plugin {
this.statusBar = new StatusBar(this); this.statusBar = new StatusBar(this);
this.statusBar.start(); this.statusBar.start();
// Query: Code-Block-Prozessoren
registerLogfireBlock(this.db, (lang, handler) => {
this.registerMarkdownCodeBlockProcessor(lang, handler);
});
registerLogfireSqlBlock(this.db, (lang, handler) => {
this.registerMarkdownCodeBlockProcessor(lang, handler);
});
// Ribbon icon // Ribbon icon
this.addRibbonIcon('activity', 'Logfire: Event-Stream', () => { this.addRibbonIcon('activity', 'Logfire: Event-Stream', () => {
this.activateEventStream(); this.activateEventStream();
@ -125,6 +135,7 @@ export default class LogfirePlugin extends Plugin {
async onunload(): Promise<void> { async onunload(): Promise<void> {
console.log('[Logfire] Entlade Plugin...'); console.log('[Logfire] Entlade Plugin...');
cleanupAllRefreshTimers();
this.statusBar?.destroy(); this.statusBar?.destroy();
this.stopTracking(); this.stopTracking();
@ -271,6 +282,14 @@ export default class LogfirePlugin extends Plugin {
}, },
}); });
this.addCommand({
id: 'open-query',
name: 'Query-Editor \u00f6ffnen',
callback: () => {
new QueryModal(this.app, this.db).open();
},
});
this.addCommand({ this.addCommand({
id: 'debug-info', id: 'debug-info',
name: 'Debug-Info', name: 'Debug-Info',

268
src/query/processor.ts Normal file
View file

@ -0,0 +1,268 @@
import { MarkdownPostProcessorContext } from 'obsidian';
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';
// ---------------------------------------------------------------------------
// Refresh timer management
// ---------------------------------------------------------------------------
const refreshTimers = new Map<HTMLElement, ReturnType<typeof setInterval>>();
function cleanupRefreshTimer(el: HTMLElement): void {
const timer = refreshTimers.get(el);
if (timer) {
clearInterval(timer);
refreshTimers.delete(el);
}
}
export function cleanupAllRefreshTimers(): void {
for (const timer of refreshTimers.values()) {
clearInterval(timer);
}
refreshTimers.clear();
}
// ---------------------------------------------------------------------------
// `logfire` block — YAML-config-basierte Queries
// ---------------------------------------------------------------------------
export function registerLogfireBlock(
db: DatabaseManager,
registerFn: (language: string, handler: (source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext) => void) => void,
): void {
registerFn('logfire', (source, el, ctx) => {
cleanupRefreshTimer(el);
try {
const config = parseYamlConfig(source);
const { sql, params } = buildQuery(config.query);
const rows = db.queryReadOnly(sql, params) as Record<string, unknown>[];
renderResult(el, rows, config.format, config.columns);
if (config.refresh && config.refresh > 0) {
setupRefreshTimer(el, () => {
el.empty();
try {
const freshRows = db.queryReadOnly(sql, params) as Record<string, unknown>[];
renderResult(el, freshRows, config.format, config.columns);
} catch (err) {
renderError(el, err);
}
}, config.refresh);
}
} catch (err) {
renderError(el, err);
}
});
}
// ---------------------------------------------------------------------------
// `logfire-sql` block — Raw SQL Queries
// ---------------------------------------------------------------------------
export function registerLogfireSqlBlock(
db: DatabaseManager,
registerFn: (language: string, handler: (source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext) => void) => void,
): void {
registerFn('logfire-sql', (source, el, ctx) => {
cleanupRefreshTimer(el);
const { sql, refresh } = parseSqlBlock(source);
if (!sql) {
renderError(el, new Error('Leere Query.'));
return;
}
// Safety: only SELECT/WITH
const firstWord = sql.split(/\s+/)[0].toUpperCase();
if (firstWord !== 'SELECT' && firstWord !== 'WITH') {
renderError(el, new Error('Nur SELECT- und WITH-Queries sind erlaubt.'));
return;
}
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;
}
renderTable(el, rows);
if (refresh && refresh > 0) {
setupRefreshTimer(el, () => {
el.empty();
try {
const freshRows = db.queryReadOnly(sql) as Record<string, unknown>[];
if (!Array.isArray(freshRows) || freshRows.length === 0) {
el.createEl('p', { text: 'Keine Ergebnisse.', cls: 'logfire-empty' });
return;
}
renderTable(el, freshRows);
} catch (err) {
renderError(el, err);
}
}, refresh);
}
} catch (err) {
renderError(el, err);
}
});
}
// ---------------------------------------------------------------------------
// YAML config parsing
// ---------------------------------------------------------------------------
interface BlockConfig {
query: QueryConfig;
format: 'table' | 'timeline' | 'summary' | 'metric' | 'list' | 'heatmap';
columns?: string[];
refresh?: number;
}
function parseYamlConfig(source: string): BlockConfig {
const lines = source.trim().split('\n');
const kv: Record<string, string> = {};
for (const line of lines) {
const colonIdx = line.indexOf(':');
if (colonIdx < 0) continue;
const key = line.substring(0, colonIdx).trim();
const value = line.substring(colonIdx + 1).trim();
kv[key] = value;
}
const rangeValue = kv['range'] ?? 'today';
const timeRange = parseTimeRange(rangeValue);
const eventTypes = kv['events']
? parseArrayValue(kv['events']) as EventType[]
: undefined;
const categories = kv['categories']
? parseArrayValue(kv['categories']) as EventCategory[]
: undefined;
const filePaths = kv['files']
? parseArrayValue(kv['files'])
: undefined;
const groupBy = kv['group'] as QueryConfig['groupBy'] | undefined;
const orderBy = kv['order'] as QueryConfig['orderBy'] | undefined;
const orderDirection = kv['direction'] as QueryConfig['orderDirection'] | undefined;
const limit = kv['limit'] ? parseInt(kv['limit'], 10) : undefined;
const format = (kv['format'] ?? 'table') as BlockConfig['format'];
const columns = kv['columns'] ? parseArrayValue(kv['columns']) : undefined;
const refresh = kv['refresh'] ? parseInt(kv['refresh'], 10) : undefined;
return {
query: {
timeRange,
eventTypes,
categories,
filePaths,
groupBy,
orderBy,
orderDirection,
limit,
},
format,
columns,
refresh,
};
}
function parseTimeRange(value: string): TimeRange {
const relative = ['today', 'yesterday', 'this-week', 'this-month', 'last-7-days', 'last-30-days'];
if (relative.includes(value)) {
return { type: 'relative', value: value as TimeRange & { type: 'relative' } extends { value: infer V } ? V : never };
}
return { type: 'relative', value: 'today' };
}
function parseArrayValue(value: string): string[] {
const cleaned = value.replace(/^\[/, '').replace(/\]$/, '');
return cleaned.split(',').map(s => s.trim()).filter(s => s.length > 0);
}
// ---------------------------------------------------------------------------
// SQL block parsing (with comment-based config)
// ---------------------------------------------------------------------------
function parseSqlBlock(source: string): { sql: string; refresh?: number } {
const lines = source.trim().split('\n');
let refresh: number | undefined;
const sqlLines: string[] = [];
for (const line of lines) {
const refreshMatch = line.match(/^--\s*refresh:\s*(\d+)$/i);
if (refreshMatch) {
refresh = parseInt(refreshMatch[1], 10);
} else {
sqlLines.push(line);
}
}
return { sql: sqlLines.join('\n').trim(), refresh };
}
// ---------------------------------------------------------------------------
// Render dispatch
// ---------------------------------------------------------------------------
function renderResult(el: HTMLElement, rows: Record<string, unknown>[], format: string, columns?: string[]): void {
if (rows.length === 0) {
el.createEl('p', { text: 'Keine Events gefunden.', cls: 'logfire-empty' });
return;
}
switch (format) {
case 'table':
renderTable(el, rows, columns);
break;
case 'timeline':
renderTimeline(el, rows);
break;
case 'summary':
renderSummary(el, rows);
break;
case 'metric':
renderMetric(el, rows);
break;
case 'list':
renderList(el, rows);
break;
case 'heatmap':
renderHeatmap(el, rows);
break;
default:
renderTable(el, rows, columns);
}
}
function renderError(el: HTMLElement, err: unknown): void {
el.createEl('pre', {
text: `Logfire Error: ${err instanceof Error ? err.message : String(err)}`,
cls: 'logfire-error',
});
}
// ---------------------------------------------------------------------------
// Auto-refresh
// ---------------------------------------------------------------------------
function setupRefreshTimer(el: HTMLElement, refreshFn: () => void, intervalSeconds: number): void {
const timer = setInterval(() => {
if (!document.contains(el)) {
cleanupRefreshTimer(el);
return;
}
refreshFn();
}, intervalSeconds * 1000);
refreshTimers.set(el, timer);
}

225
src/query/query-modal.ts Normal file
View file

@ -0,0 +1,225 @@
import { App, Modal, MarkdownView, Notice } from 'obsidian';
import { QueryConfig, TimeRange, EventType } from '../types';
import { buildQuery } from '../core/query-builder';
import { DatabaseManager } from '../core/database';
import { renderTable, toMarkdownTable } from '../viz/table-renderer';
export class QueryModal extends Modal {
private editorEl!: HTMLTextAreaElement;
private resultEl!: HTMLElement;
private modeToggle!: HTMLButtonElement;
private mode: 'shorthand' | 'sql' = 'shorthand';
constructor(app: App, private db: DatabaseManager) {
super(app);
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass('logfire-query-modal');
contentEl.createEl('h2', { text: 'Logfire Query' });
// Mode toggle
const toolbar = contentEl.createDiv({ cls: 'logfire-qm-toolbar' });
this.modeToggle = toolbar.createEl('button', { text: 'Modus: Shorthand' });
this.modeToggle.addEventListener('click', () => {
this.mode = this.mode === 'shorthand' ? 'sql' : 'shorthand';
this.modeToggle.textContent = this.mode === 'shorthand' ? 'Modus: Shorthand' : 'Modus: SQL';
this.editorEl.placeholder = this.mode === 'shorthand'
? 'events today\nstats this-week group by file\nfiles modified yesterday'
: 'SELECT * FROM events\nWHERE type = \'file:create\'\nORDER BY timestamp DESC\nLIMIT 50';
});
const helpSpan = toolbar.createEl('span', {
text: 'Ctrl+Enter: Ausf\u00fchren',
cls: 'logfire-qm-hint',
});
// Editor
this.editorEl = contentEl.createEl('textarea', {
cls: 'logfire-qm-editor',
attr: {
placeholder: 'events today\nstats this-week group by file\nfiles modified yesterday',
spellcheck: 'false',
},
});
this.editorEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
this.executeQuery();
}
});
// Buttons
const buttonRow = contentEl.createDiv({ cls: 'logfire-qm-buttons' });
const runBtn = buttonRow.createEl('button', { text: 'Ausf\u00fchren', cls: 'mod-cta' });
runBtn.addEventListener('click', () => this.executeQuery());
const copyBtn = buttonRow.createEl('button', { text: 'Als Markdown kopieren' });
copyBtn.addEventListener('click', () => this.copyAsMarkdown());
const insertBtn = buttonRow.createEl('button', { text: 'In Notiz einf\u00fcgen' });
insertBtn.addEventListener('click', () => this.insertInNote());
const clearBtn = buttonRow.createEl('button', { text: 'Leeren' });
clearBtn.addEventListener('click', () => {
this.editorEl.value = '';
this.resultEl.empty();
this.lastRows = [];
this.lastKeys = [];
});
// Results
this.resultEl = contentEl.createDiv({ cls: 'logfire-qm-results' });
}
onClose(): void {
this.contentEl.empty();
}
private lastRows: Record<string, unknown>[] = [];
private lastKeys: string[] = [];
private executeQuery(): void {
const input = this.editorEl.value.trim();
if (!input) return;
this.resultEl.empty();
try {
let rows: Record<string, unknown>[];
if (this.mode === 'sql') {
// Raw SQL mode
const firstWord = input.split(/\s+/)[0].toUpperCase();
if (firstWord !== 'SELECT' && firstWord !== 'WITH') {
this.resultEl.createEl('pre', {
text: 'Nur SELECT- und WITH-Queries sind erlaubt.',
cls: 'logfire-error',
});
return;
}
rows = this.db.queryReadOnly(input) as Record<string, unknown>[];
} else {
// Shorthand mode
const config = parseShorthand(input);
const { sql, params } = buildQuery(config);
rows = this.db.queryReadOnly(sql, params) as Record<string, unknown>[];
}
this.lastRows = rows;
this.lastKeys = rows.length > 0 ? Object.keys(rows[0]) : [];
this.renderResults(rows);
} catch (err) {
this.resultEl.createEl('pre', {
text: `Fehler: ${err instanceof Error ? err.message : String(err)}`,
cls: 'logfire-error',
});
}
}
private renderResults(rows: Record<string, unknown>[]): void {
this.resultEl.empty();
if (rows.length === 0) {
this.resultEl.createEl('p', { text: 'Keine Ergebnisse.' });
return;
}
const displayRows = rows.slice(0, 200);
renderTable(this.resultEl, displayRows);
if (rows.length > 200) {
this.resultEl.createEl('p', {
text: `${rows.length} Ergebnisse, 200 angezeigt.`,
cls: 'logfire-qm-truncated',
});
}
}
private copyAsMarkdown(): void {
if (this.lastRows.length === 0) {
new Notice('Keine Ergebnisse zum Kopieren.');
return;
}
const md = toMarkdownTable(this.lastKeys, this.lastRows);
navigator.clipboard.writeText(md);
new Notice('In Zwischenablage kopiert.');
}
private insertInNote(): void {
if (this.lastRows.length === 0) {
new Notice('Keine Ergebnisse zum Einf\u00fcgen.');
return;
}
const view = this.app.workspace.getActiveViewOfType(MarkdownView);
if (!view) {
new Notice('Kein aktiver Editor zum Einf\u00fcgen.');
return;
}
const md = toMarkdownTable(this.lastKeys, this.lastRows);
view.editor.replaceSelection(md + '\n');
this.close();
}
}
// ---------------------------------------------------------------------------
// Shorthand parser
// ---------------------------------------------------------------------------
function parseShorthand(input: string): QueryConfig {
const lower = input.toLowerCase().trim();
let timeRange: TimeRange = { type: 'relative', value: 'today' };
let eventTypes: EventType[] | undefined;
let groupBy: QueryConfig['groupBy'];
let limit = 50;
const timeRanges: Record<string, TimeRange> = {
'today': { type: 'relative', value: 'today' },
'yesterday': { type: 'relative', value: 'yesterday' },
'this-week': { type: 'relative', value: 'this-week' },
'this week': { type: 'relative', value: 'this-week' },
'this-month': { type: 'relative', value: 'this-month' },
'this month': { type: 'relative', value: 'this-month' },
'last-7-days': { type: 'relative', value: 'last-7-days' },
'last 7 days': { type: 'relative', value: 'last-7-days' },
'last-30-days': { type: 'relative', value: 'last-30-days' },
'last 30 days': { type: 'relative', value: 'last-30-days' },
};
for (const [key, range] of Object.entries(timeRanges)) {
if (lower.includes(key)) {
timeRange = range;
break;
}
}
const groupMatch = lower.match(/group\s+by\s+(\w+)/);
if (groupMatch) {
groupBy = groupMatch[1] as QueryConfig['groupBy'];
}
const limitMatch = lower.match(/limit\s+(\d+)/);
if (limitMatch) {
limit = parseInt(limitMatch[1], 10);
}
if (lower.includes('stats')) {
groupBy = groupBy ?? 'day';
}
if (lower.includes('files modified')) {
eventTypes = ['file:modify'];
}
if (lower.includes('files created')) {
eventTypes = ['file:create'];
}
return { timeRange, eventTypes, groupBy, limit };
}

125
src/viz/table-renderer.ts Normal file
View file

@ -0,0 +1,125 @@
// ---------------------------------------------------------------------------
// Table
// ---------------------------------------------------------------------------
export function renderTable(el: HTMLElement, rows: Record<string, unknown>[], columns?: string[]): void {
const table = el.createEl('table', { cls: 'logfire-table' });
const allKeys = columns ?? Object.keys(rows[0]);
const thead = table.createEl('thead');
const headerRow = thead.createEl('tr');
for (const key of allKeys) {
headerRow.createEl('th', { text: key });
}
const tbody = table.createEl('tbody');
for (const row of rows) {
const tr = tbody.createEl('tr');
for (const key of allKeys) {
tr.createEl('td', { text: formatValue(row[key]) });
}
}
}
// ---------------------------------------------------------------------------
// Timeline
// ---------------------------------------------------------------------------
export function renderTimeline(el: HTMLElement, rows: Record<string, unknown>[]): void {
const list = el.createEl('ul', { cls: 'logfire-timeline' });
for (const row of rows) {
const ts = typeof row.timestamp === 'number'
? new Date(row.timestamp).toLocaleTimeString()
: '';
const type = String(row.type ?? '');
const source = String(row.source ?? '');
list.createEl('li', { text: `${ts} ${type} ${source}` });
}
}
// ---------------------------------------------------------------------------
// Summary
// ---------------------------------------------------------------------------
export function renderSummary(el: HTMLElement, rows: Record<string, unknown>[]): void {
const container = el.createDiv({ cls: 'logfire-summary' });
for (const row of rows) {
for (const [key, value] of Object.entries(row)) {
const line = container.createDiv();
line.createEl('strong', { text: `${key}: ` });
line.appendText(formatValue(value));
}
}
}
// ---------------------------------------------------------------------------
// Metric (single big number)
// ---------------------------------------------------------------------------
export function renderMetric(el: HTMLElement, rows: Record<string, unknown>[]): void {
const record = rows[0];
const values = Object.values(record);
const value = values.length > 0 ? values[values.length - 1] : 0;
const div = el.createEl('div', {
text: formatValue(value),
cls: 'logfire-metric',
});
div.style.fontSize = '2em';
}
// ---------------------------------------------------------------------------
// List
// ---------------------------------------------------------------------------
export function renderList(el: HTMLElement, rows: Record<string, unknown>[]): void {
const list = el.createEl('ul', { cls: 'logfire-list' });
for (const row of rows) {
const text = Object.values(row).map(formatValue).join(' | ');
list.createEl('li', { text });
}
}
// ---------------------------------------------------------------------------
// Heatmap (text-based bar chart)
// ---------------------------------------------------------------------------
export function renderHeatmap(el: HTMLElement, rows: Record<string, unknown>[]): void {
const container = el.createDiv({ cls: 'logfire-heatmap' });
for (const row of rows) {
const group = String(row.group ?? '');
const count = Number(row.count ?? 0);
const bar = '\u2588'.repeat(Math.min(count, 50));
container.createDiv({ text: `${group} ${bar} ${count}` });
}
}
// ---------------------------------------------------------------------------
// Markdown table generation (for copy/export)
// ---------------------------------------------------------------------------
export function toMarkdownTable(keys: string[], rows: Record<string, unknown>[]): string {
const header = `| ${keys.join(' | ')} |`;
const separator = `| ${keys.map(() => '---').join(' | ')} |`;
const body = rows.map(row =>
`| ${keys.map(k => {
const v = row[k];
return v === null || v === undefined ? '' : String(v);
}).join(' | ')} |`
).join('\n');
return `${header}\n${separator}\n${body}`;
}
// ---------------------------------------------------------------------------
// Value formatting
// ---------------------------------------------------------------------------
export function formatValue(value: unknown): string {
if (value === null || value === undefined) return '';
if (typeof value === 'number') {
if (value > 1e12) return new Date(value).toLocaleString();
return value.toLocaleString();
}
return String(value);
}

View file

@ -314,3 +314,368 @@
font-size: 12px; font-size: 12px;
letter-spacing: 0.02em; letter-spacing: 0.02em;
} }
/*
Query Modal SQL Console
*/
.logfire-query-modal {
font-family: var(--font-monospace);
}
.logfire-query-modal h2 {
font-size: 16px;
font-weight: 700;
letter-spacing: -0.01em;
margin-bottom: 10px;
color: var(--text-normal);
}
/* ---------------------------------------------------------------------------
Query Modal Toolbar
--------------------------------------------------------------------------- */
.logfire-qm-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.logfire-qm-toolbar button {
padding: 3px 10px;
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: 11px;
letter-spacing: 0.02em;
text-transform: uppercase;
cursor: pointer;
transition: background 100ms ease, color 100ms ease, border-color 100ms ease;
}
.logfire-qm-toolbar button:hover {
background: var(--background-modifier-hover);
color: var(--text-normal);
border-color: var(--text-faint);
}
.logfire-qm-hint {
color: var(--text-faint);
font-size: 10px;
letter-spacing: 0.03em;
text-transform: uppercase;
}
/* ---------------------------------------------------------------------------
Query Modal Editor
--------------------------------------------------------------------------- */
.logfire-qm-editor {
display: block;
width: 100%;
min-height: 100px;
max-height: 200px;
padding: 8px 10px;
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
background: var(--background-primary);
color: var(--text-normal);
font-family: var(--font-monospace);
font-size: 12px;
line-height: 1.5;
resize: vertical;
outline: none;
tab-size: 2;
transition: border-color 120ms ease;
}
.logfire-qm-editor:focus {
border-color: var(--interactive-accent);
}
.logfire-qm-editor::placeholder {
color: var(--text-faint);
font-style: italic;
}
/* ---------------------------------------------------------------------------
Query Modal Buttons
--------------------------------------------------------------------------- */
.logfire-qm-buttons {
display: flex;
gap: 6px;
margin-top: 8px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.logfire-qm-buttons button {
padding: 4px 12px;
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: 11px;
letter-spacing: 0.02em;
text-transform: uppercase;
cursor: pointer;
transition: background 100ms ease, color 100ms ease, border-color 100ms ease;
}
.logfire-qm-buttons button:hover {
background: var(--background-modifier-hover);
color: var(--text-normal);
border-color: var(--text-faint);
}
.logfire-qm-buttons button.mod-cta {
background: var(--interactive-accent);
color: var(--text-on-accent);
border-color: var(--interactive-accent);
}
.logfire-qm-buttons button.mod-cta:hover {
filter: brightness(1.1);
}
/* ---------------------------------------------------------------------------
Query Modal Results
--------------------------------------------------------------------------- */
.logfire-qm-results {
max-height: 360px;
overflow: auto;
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
background: var(--background-primary);
}
.logfire-qm-results:empty {
display: none;
}
.logfire-qm-results::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.logfire-qm-results::-webkit-scrollbar-track {
background: transparent;
}
.logfire-qm-results::-webkit-scrollbar-thumb {
background: var(--background-modifier-border);
border-radius: 3px;
}
.logfire-qm-results::-webkit-scrollbar-thumb:hover {
background: var(--text-faint);
}
.logfire-qm-truncated {
padding: 4px 10px;
font-size: 10px;
color: var(--text-faint);
letter-spacing: 0.02em;
text-transform: uppercase;
border-top: 1px solid var(--background-modifier-border);
}
/*
Data Table Inline Query Results
*/
.logfire-table {
width: 100%;
border-collapse: collapse;
font-family: var(--font-monospace);
font-size: 11.5px;
line-height: 1.4;
font-variant-numeric: tabular-nums;
}
.logfire-table thead th {
padding: 5px 10px;
text-align: left;
font-weight: 600;
font-size: 10px;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-muted);
background: var(--background-secondary);
border-bottom: 2px solid var(--background-modifier-border);
position: sticky;
top: 0;
z-index: 1;
white-space: nowrap;
}
.logfire-table tbody td {
padding: 3px 10px;
color: var(--text-normal);
border-bottom: 1px solid color-mix(in srgb, var(--background-modifier-border) 50%, transparent);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 300px;
}
.logfire-table tbody tr:nth-child(even) {
background: color-mix(in srgb, var(--background-secondary) 30%, transparent);
}
.logfire-table tbody tr:hover {
background: var(--background-secondary);
}
.logfire-table tbody tr:hover td {
border-bottom-color: var(--background-modifier-border);
}
/* ---------------------------------------------------------------------------
Timeline
--------------------------------------------------------------------------- */
.logfire-timeline {
list-style: none;
padding: 0;
margin: 0;
font-family: var(--font-monospace);
font-size: 11.5px;
line-height: 1.5;
}
.logfire-timeline li {
padding: 2px 10px;
color: var(--text-normal);
border-left: 2px solid var(--background-modifier-border);
margin-left: 6px;
transition: border-color 80ms ease;
}
.logfire-timeline li:hover {
border-left-color: var(--interactive-accent);
background: color-mix(in srgb, var(--background-secondary) 40%, transparent);
}
/* ---------------------------------------------------------------------------
Summary Key-Value Readout
--------------------------------------------------------------------------- */
.logfire-summary {
font-family: var(--font-monospace);
font-size: 12px;
line-height: 1.6;
padding: 6px 0;
}
.logfire-summary > div {
padding: 2px 10px;
border-bottom: 1px solid color-mix(in srgb, var(--background-modifier-border) 40%, transparent);
}
.logfire-summary > div:last-child {
border-bottom: none;
}
.logfire-summary strong {
color: var(--text-muted);
font-weight: 600;
letter-spacing: 0.01em;
}
/* ---------------------------------------------------------------------------
Metric Single Value Readout
--------------------------------------------------------------------------- */
.logfire-metric {
font-family: var(--font-monospace);
font-variant-numeric: tabular-nums;
color: var(--text-accent);
font-weight: 700;
letter-spacing: -0.02em;
padding: 8px 10px;
line-height: 1;
}
/* ---------------------------------------------------------------------------
List
--------------------------------------------------------------------------- */
.logfire-list {
list-style: none;
padding: 0;
margin: 0;
font-family: var(--font-monospace);
font-size: 11.5px;
line-height: 1.5;
}
.logfire-list li {
padding: 2px 10px;
color: var(--text-normal);
border-bottom: 1px solid color-mix(in srgb, var(--background-modifier-border) 40%, transparent);
}
.logfire-list li:last-child {
border-bottom: none;
}
.logfire-list li:hover {
background: color-mix(in srgb, var(--background-secondary) 40%, transparent);
}
/* ---------------------------------------------------------------------------
Heatmap Text-Based Bar Readout
--------------------------------------------------------------------------- */
.logfire-heatmap {
font-family: var(--font-monospace);
font-size: 11px;
line-height: 1.6;
padding: 4px 0;
font-variant-numeric: tabular-nums;
}
.logfire-heatmap > div {
padding: 1px 10px;
color: var(--text-normal);
white-space: pre;
transition: background 60ms ease;
}
.logfire-heatmap > div:hover {
background: color-mix(in srgb, var(--background-secondary) 40%, transparent);
}
/* ---------------------------------------------------------------------------
Empty State & Error
--------------------------------------------------------------------------- */
.logfire-empty {
padding: 12px 10px;
color: var(--text-faint);
font-family: var(--font-monospace);
font-size: 11.5px;
font-style: italic;
}
.logfire-error {
padding: 8px 10px;
margin: 0;
font-family: var(--font-monospace);
font-size: 11.5px;
line-height: 1.5;
color: var(--text-error, #e5534b);
background: color-mix(in srgb, var(--text-error, #e5534b) 8%, var(--background-primary));
border-left: 3px solid var(--text-error, #e5534b);
border-radius: 0 4px 4px 0;
white-space: pre-wrap;
word-break: break-word;
}