Merge feature/sql-engine: SQL-Query-Engine
This commit is contained in:
commit
263c50c8fd
6 changed files with 1170 additions and 0 deletions
168
src/core/query-builder.ts
Normal file
168
src/core/query-builder.ts
Normal 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, '_');
|
||||
}
|
||||
19
src/main.ts
19
src/main.ts
|
|
@ -13,6 +13,8 @@ import { LogfireSettingTab } from './ui/settings-tab';
|
|||
import { InitialScanModal } from './ui/initial-scan-modal';
|
||||
import { StatusBar } from './ui/status-bar';
|
||||
import { EventStreamView, EVENT_STREAM_VIEW_TYPE } from './ui/event-stream-view';
|
||||
import { registerLogfireBlock, registerLogfireSqlBlock, cleanupAllRefreshTimers } from './query/processor';
|
||||
import { QueryModal } from './query/query-modal';
|
||||
|
||||
export default class LogfirePlugin extends Plugin {
|
||||
settings!: LogfireSettings;
|
||||
|
|
@ -90,6 +92,14 @@ export default class LogfirePlugin extends Plugin {
|
|||
this.statusBar = new StatusBar(this);
|
||||
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
|
||||
this.addRibbonIcon('activity', 'Logfire: Event-Stream', () => {
|
||||
this.activateEventStream();
|
||||
|
|
@ -125,6 +135,7 @@ export default class LogfirePlugin extends Plugin {
|
|||
async onunload(): Promise<void> {
|
||||
console.log('[Logfire] Entlade Plugin...');
|
||||
|
||||
cleanupAllRefreshTimers();
|
||||
this.statusBar?.destroy();
|
||||
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({
|
||||
id: 'debug-info',
|
||||
name: 'Debug-Info',
|
||||
|
|
|
|||
268
src/query/processor.ts
Normal file
268
src/query/processor.ts
Normal 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
225
src/query/query-modal.ts
Normal 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
125
src/viz/table-renderer.ts
Normal 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);
|
||||
}
|
||||
365
styles.css
365
styles.css
|
|
@ -314,3 +314,368 @@
|
|||
font-size: 12px;
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue