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 { 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
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;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue