From 4b8f1fc81421005ec52938757c91a605aad9d14c Mon Sep 17 00:00:00 2001 From: tolvitty Date: Thu, 12 Feb 2026 11:23:54 +0100 Subject: [PATCH 1/6] Query-History: automatisches Tracking aller Queries HistoryManager speichert ausgefuehrte Queries mit Metriken (Laufzeit, Zeilenanzahl), Deduplizierung, max 200 Eintraege, Favoriten-Schutz vor Loeschung, Suche. Co-Authored-By: Claude Opus 4.6 --- src/management/history.ts | 126 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 src/management/history.ts diff --git a/src/management/history.ts b/src/management/history.ts new file mode 100644 index 0000000..5e0554a --- /dev/null +++ b/src/management/history.ts @@ -0,0 +1,126 @@ +// --------------------------------------------------------------------------- +// Query History — automatisches Tracking aller ausgefuehrten Queries +// --------------------------------------------------------------------------- + +export interface HistoryEntry { + id: string; + sql: string; + executedAt: string; + executionTimeMs?: number; + rowCount?: number; + isFavorite: boolean; + name?: string; +} + +const STORAGE_KEY = 'logfire-query-history'; +const MAX_ENTRIES = 200; + +export class HistoryManager { + private entries = new Map(); + private listeners = new Set<() => void>(); + + constructor() { + this.load(); + } + + // --------------------------------------------------------------------------- + // Persistence + // --------------------------------------------------------------------------- + + private load(): void { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return; + try { + const arr: HistoryEntry[] = JSON.parse(raw); + for (const e of arr) this.entries.set(e.id, e); + } catch { /* ignore */ } + } + + private save(): void { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify(Array.from(this.entries.values())), + ); + for (const fn of this.listeners) fn(); + } + + onChange(fn: () => void): () => void { + this.listeners.add(fn); + return () => this.listeners.delete(fn); + } + + // --------------------------------------------------------------------------- + // CRUD + // --------------------------------------------------------------------------- + + addEntry(sql: string, executionTimeMs?: number, rowCount?: number): HistoryEntry { + // Deduplizieren: gleiche SQL aktualisiert vorhandenen Eintrag + for (const entry of this.entries.values()) { + if (entry.sql === sql) { + entry.executedAt = new Date().toISOString(); + entry.executionTimeMs = executionTimeMs; + entry.rowCount = rowCount; + this.save(); + return entry; + } + } + + const entry: HistoryEntry = { + id: `h-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + sql, + executedAt: new Date().toISOString(), + executionTimeMs, + rowCount, + isFavorite: false, + }; + this.entries.set(entry.id, entry); + + // Limit: aelteste nicht-favorisierte loeschen + if (this.entries.size > MAX_ENTRIES) { + const sorted = this.getAll(); + for (const e of sorted.slice(MAX_ENTRIES)) { + if (!e.isFavorite) this.entries.delete(e.id); + } + } + + this.save(); + return entry; + } + + getAll(searchTerm?: string): HistoryEntry[] { + let results = Array.from(this.entries.values()); + + if (searchTerm) { + const term = searchTerm.toLowerCase(); + results = results.filter( + e => e.sql.toLowerCase().includes(term) || e.name?.toLowerCase().includes(term), + ); + } + + return results.sort( + (a, b) => new Date(b.executedAt).getTime() - new Date(a.executedAt).getTime(), + ); + } + + toggleFavorite(id: string): void { + const e = this.entries.get(id); + if (e) { e.isFavorite = !e.isFavorite; this.save(); } + } + + setName(id: string, name: string): void { + const e = this.entries.get(id); + if (e) { e.name = name || undefined; this.save(); } + } + + delete(id: string): void { + this.entries.delete(id); + this.save(); + } + + clear(): void { + for (const [id, entry] of this.entries) { + if (!entry.isFavorite) this.entries.delete(id); + } + this.save(); + } +} From 6c7d239108392d8ebc5cfd1e9b60d025cc6df699 Mon Sep 17 00:00:00 2001 From: tolvitty Date: Thu, 12 Feb 2026 11:24:00 +0100 Subject: [PATCH 2/6] Favoriten: gespeicherte Queries mit Kategorien und Tags FavoritesManager mit Kategorien (Allgemein, Analyse, Wartung), Tags, Usage-Tracking. SaveFavoriteModal fuer Obsidian-UI. Co-Authored-By: Claude Opus 4.6 --- src/management/favorites.ts | 209 ++++++++++++++++++++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 src/management/favorites.ts diff --git a/src/management/favorites.ts b/src/management/favorites.ts new file mode 100644 index 0000000..09296a4 --- /dev/null +++ b/src/management/favorites.ts @@ -0,0 +1,209 @@ +import { Modal, Setting, Notice } from 'obsidian'; +import type LogfirePlugin from '../main'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface Favorite { + id: string; + name: string; + sql: string; + category: string; + tags: string[]; + createdAt: string; + lastUsedAt?: string; + usageCount: number; +} + +export interface FavoriteCategory { + id: string; + name: string; + color: string; +} + +const STORAGE_KEY = 'logfire-favorites'; + +const DEFAULT_CATEGORIES: FavoriteCategory[] = [ + { id: 'allgemein', name: 'Allgemein', color: '#4C78A8' }, + { id: 'analyse', name: 'Analyse', color: '#F58518' }, + { id: 'wartung', name: 'Wartung', color: '#E45756' }, +]; + +// --------------------------------------------------------------------------- +// FavoritesManager +// --------------------------------------------------------------------------- + +export class FavoritesManager { + private favorites = new Map(); + private categories = new Map(); + private listeners = new Set<() => void>(); + + constructor() { + this.load(); + } + + private load(): void { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) { + try { + const data = JSON.parse(raw) as { favorites: Favorite[]; categories: FavoriteCategory[] }; + for (const f of data.favorites) this.favorites.set(f.id, f); + for (const c of data.categories) this.categories.set(c.id, c); + } catch { /* ignore */ } + } + if (this.categories.size === 0) { + for (const c of DEFAULT_CATEGORIES) this.categories.set(c.id, c); + this.save(); + } + } + + private save(): void { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ + favorites: Array.from(this.favorites.values()), + categories: Array.from(this.categories.values()), + })); + for (const fn of this.listeners) fn(); + } + + onChange(fn: () => void): () => void { + this.listeners.add(fn); + return () => this.listeners.delete(fn); + } + + // --------------------------------------------------------------------------- + // Favorites CRUD + // --------------------------------------------------------------------------- + + add(name: string, sql: string, category = 'allgemein', tags: string[] = []): Favorite { + const fav: Favorite = { + id: `fav-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + name, + sql, + category, + tags, + createdAt: new Date().toISOString(), + usageCount: 0, + }; + this.favorites.set(fav.id, fav); + this.save(); + return fav; + } + + getAll(): Favorite[] { + return Array.from(this.favorites.values()).sort((a, b) => { + if (b.usageCount !== a.usageCount) return b.usageCount - a.usageCount; + return a.name.localeCompare(b.name); + }); + } + + getByCategory(categoryId: string): Favorite[] { + return this.getAll().filter(f => f.category === categoryId); + } + + search(term: string): Favorite[] { + const t = term.toLowerCase(); + return this.getAll().filter( + f => f.name.toLowerCase().includes(t) || f.sql.toLowerCase().includes(t) + || f.tags.some(tag => tag.toLowerCase().includes(t)), + ); + } + + incrementUsage(id: string): void { + const f = this.favorites.get(id); + if (f) { + f.usageCount++; + f.lastUsedAt = new Date().toISOString(); + this.save(); + } + } + + update(id: string, updates: Partial): void { + const f = this.favorites.get(id); + if (f) { Object.assign(f, updates); this.save(); } + } + + delete(id: string): void { + this.favorites.delete(id); + this.save(); + } + + // --------------------------------------------------------------------------- + // Categories + // --------------------------------------------------------------------------- + + getCategories(): FavoriteCategory[] { + return Array.from(this.categories.values()); + } + + addCategory(name: string, color: string): FavoriteCategory { + const id = name.toLowerCase().replace(/\s+/g, '-'); + const cat: FavoriteCategory = { id, name, color }; + this.categories.set(id, cat); + this.save(); + return cat; + } +} + +// --------------------------------------------------------------------------- +// SaveFavoriteModal +// --------------------------------------------------------------------------- + +export class SaveFavoriteModal extends Modal { + private plugin: LogfirePlugin; + private manager: FavoritesManager; + private sql: string; + private name = ''; + private category = 'allgemein'; + private tags: string[] = []; + + constructor(plugin: LogfirePlugin, manager: FavoritesManager, sql: string) { + super(plugin.app); + this.plugin = plugin; + this.manager = manager; + this.sql = sql; + } + + onOpen(): void { + const { contentEl } = this; + contentEl.addClass('logfire-save-fav-modal'); + contentEl.createEl('h3', { text: 'Als Favorit speichern' }); + + new Setting(contentEl).setName('Name').addText(text => + text.setPlaceholder('Meine Query').onChange(v => { this.name = v; }), + ); + + new Setting(contentEl).setName('Kategorie').addDropdown(dd => { + for (const cat of this.manager.getCategories()) dd.addOption(cat.id, cat.name); + dd.setValue(this.category); + dd.onChange(v => { this.category = v; }); + }); + + new Setting(contentEl).setName('Tags').setDesc('Komma-getrennt').addText(text => + text.setPlaceholder('select, events').onChange(v => { + this.tags = v.split(',').map(s => s.trim()).filter(Boolean); + }), + ); + + contentEl.createEl('h4', { text: 'SQL-Vorschau' }); + contentEl.createEl('pre', { + cls: 'logfire-sql-preview', + text: this.sql.length > 200 ? this.sql.slice(0, 200) + '...' : this.sql, + }); + + const btns = contentEl.createDiv({ cls: 'logfire-modal-buttons' }); + const saveBtn = btns.createEl('button', { text: 'Speichern', cls: 'mod-cta' }); + saveBtn.addEventListener('click', () => this.doSave()); + const cancelBtn = btns.createEl('button', { text: 'Abbrechen' }); + cancelBtn.addEventListener('click', () => this.close()); + } + + private doSave(): void { + if (!this.name.trim()) { new Notice('Bitte einen Namen eingeben.'); return; } + this.manager.add(this.name.trim(), this.sql, this.category, this.tags); + new Notice(`Favorit "${this.name}" gespeichert.`); + this.close(); + } + + onClose(): void { this.contentEl.empty(); } +} From b9732810f9423941d8a6cffd1ccbda4cb2588395 Mon Sep 17 00:00:00 2001 From: tolvitty Date: Thu, 12 Feb 2026 11:24:08 +0100 Subject: [PATCH 3/6] Templates: Built-in und Custom Query-Templates mit Parametern TemplateManager mit 9 Built-in Templates fuer Logfire-Schema (Events, Sessions, Stats, Dateien, Tags, verwaiste Notizen, defekte Links). Parameter-Substitution via {{name:default}}. Co-Authored-By: Claude Opus 4.6 --- src/management/templates.ts | 356 ++++++++++++++++++++++++++++++++++++ 1 file changed, 356 insertions(+) create mode 100644 src/management/templates.ts diff --git a/src/management/templates.ts b/src/management/templates.ts new file mode 100644 index 0000000..af9aa29 --- /dev/null +++ b/src/management/templates.ts @@ -0,0 +1,356 @@ +import { Modal, Setting, Notice } from 'obsidian'; +import type LogfirePlugin from '../main'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface QueryTemplate { + id: string; + name: string; + description: string; + sql: string; + parameters: TemplateParameter[]; + builtIn: boolean; +} + +export interface TemplateParameter { + name: string; + label: string; + defaultValue: string; + type: 'text' | 'number' | 'date'; +} + +const STORAGE_KEY = 'logfire-templates'; + +// --------------------------------------------------------------------------- +// TemplateManager +// --------------------------------------------------------------------------- + +export class TemplateManager { + private templates = new Map(); + + constructor() { + this.load(); + } + + private load(): void { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) { + try { + const arr: QueryTemplate[] = JSON.parse(raw); + for (const t of arr) this.templates.set(t.id, t); + } catch { /* ignore */ } + } + // Immer Built-in Templates sicherstellen + for (const t of BUILTIN_TEMPLATES) { + if (!this.templates.has(t.id)) this.templates.set(t.id, t); + } + this.save(); + } + + private save(): void { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify(Array.from(this.templates.values())), + ); + } + + getAll(): QueryTemplate[] { + return Array.from(this.templates.values()); + } + + get(id: string): QueryTemplate | undefined { + return this.templates.get(id); + } + + addCustom(name: string, description: string, sql: string): QueryTemplate { + const t: QueryTemplate = { + id: `tpl-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + name, + description, + sql, + parameters: parseParameters(sql), + builtIn: false, + }; + this.templates.set(t.id, t); + this.save(); + return t; + } + + delete(id: string): void { + const t = this.templates.get(id); + if (t && !t.builtIn) { + this.templates.delete(id); + this.save(); + } + } + + // --------------------------------------------------------------------------- + // Parameter handling + // --------------------------------------------------------------------------- + + static applyParameters(sql: string, values: Record): string { + return sql.replace(/\{\{(\w+)(?::([^}]*))?\}\}/g, (_match, name, defaultVal) => { + const v = values[name]; + if (v !== undefined && v !== '') return v; + return defaultVal ?? ''; + }); + } +} + +// --------------------------------------------------------------------------- +// Parameter detection +// --------------------------------------------------------------------------- + +function parseParameters(sql: string): TemplateParameter[] { + const params: TemplateParameter[] = []; + const seen = new Set(); + const re = /\{\{(\w+)(?::([^}]*))?\}\}/g; + let m: RegExpExecArray | null; + + while ((m = re.exec(sql)) !== null) { + if (seen.has(m[1])) continue; + seen.add(m[1]); + params.push({ + name: m[1], + label: m[1].charAt(0).toUpperCase() + m[1].slice(1).replace(/_/g, ' '), + defaultValue: m[2] ?? '', + type: 'text', + }); + } + return params; +} + +// --------------------------------------------------------------------------- +// Built-in Templates (angepasst an Logfire-Schema) +// --------------------------------------------------------------------------- + +const BUILTIN_TEMPLATES: QueryTemplate[] = [ + { + id: 'builtin-events-today', + name: 'Events heute', + description: 'Alle Events seit Mitternacht', + sql: `SELECT type, source, datetime(timestamp/1000, 'unixepoch', 'localtime') as zeit +FROM events +WHERE timestamp > (strftime('%s', 'now', 'start of day') * 1000) +ORDER BY timestamp DESC +LIMIT {{limit:100}}`, + parameters: [{ name: 'limit', label: 'Limit', defaultValue: '100', type: 'number' }], + builtIn: true, + }, + { + id: 'builtin-active-files', + name: 'Aktivste Dateien', + description: 'Dateien mit den meisten Events', + sql: `SELECT source as datei, COUNT(*) as events +FROM events +WHERE source IS NOT NULL +GROUP BY source +ORDER BY events DESC +LIMIT {{limit:20}}`, + parameters: [{ name: 'limit', label: 'Limit', defaultValue: '20', type: 'number' }], + builtIn: true, + }, + { + id: 'builtin-session-overview', + name: 'Sessions', + description: 'Letzte Sessions mit Dauer', + sql: `SELECT + id, + datetime(start_time/1000, 'unixepoch', 'localtime') as start, + CASE WHEN end_time IS NOT NULL + THEN ROUND((end_time - start_time) / 60000.0, 1) || ' min' + ELSE 'aktiv' + END as dauer +FROM sessions +ORDER BY start_time DESC +LIMIT {{limit:10}}`, + parameters: [{ name: 'limit', label: 'Limit', defaultValue: '10', type: 'number' }], + builtIn: true, + }, + { + id: 'builtin-daily-stats', + name: 'Tagesstatistik', + description: 'Aggregierte Statistiken pro Tag', + sql: `SELECT date as tag, SUM(events_count) as events, + SUM(words_added) as woerter_hinzu, SUM(words_removed) as woerter_entfernt +FROM daily_stats +GROUP BY date +ORDER BY date DESC +LIMIT {{limit:14}}`, + parameters: [{ name: 'limit', label: 'Tage', defaultValue: '14', type: 'number' }], + builtIn: true, + }, + { + id: 'builtin-recent-files', + name: 'Zuletzt geaenderte Dateien', + description: 'Dateien nach Aenderungsdatum', + sql: `SELECT name, extension, + datetime(modified/1000, 'unixepoch', 'localtime') as geaendert, + folder +FROM _files +WHERE extension = 'md' +ORDER BY modified DESC +LIMIT {{limit:20}}`, + parameters: [{ name: 'limit', label: 'Limit', defaultValue: '20', type: 'number' }], + builtIn: true, + }, + { + id: 'builtin-tag-stats', + name: 'Tag-Statistiken', + description: 'Notizen pro Tag zaehlen', + sql: `SELECT tag, COUNT(DISTINCT path) as notizen +FROM _tags +GROUP BY tag +ORDER BY notizen DESC +LIMIT {{limit:30}}`, + parameters: [{ name: 'limit', label: 'Limit', defaultValue: '30', type: 'number' }], + builtIn: true, + }, + { + id: 'builtin-orphan-notes', + name: 'Verwaiste Notizen', + description: 'Notizen ohne eingehende Links', + sql: `SELECT f.path, f.name, + datetime(f.modified/1000, 'unixepoch', 'localtime') as geaendert +FROM _files f +WHERE f.extension = 'md' + AND f.path NOT IN (SELECT DISTINCT to_path FROM _links) +ORDER BY f.modified DESC`, + parameters: [], + builtIn: true, + }, + { + id: 'builtin-broken-links', + name: 'Defekte Links', + description: 'Links zu nicht-existierenden Notizen', + sql: `SELECT l.from_path as von, l.to_path as nach, l.display_text +FROM _links l +WHERE l.to_path NOT IN (SELECT path FROM _files) + AND l.link_type = 'link'`, + parameters: [], + builtIn: true, + }, + { + id: 'builtin-event-types', + name: 'Event-Typen', + description: 'Verteilung der Event-Typen', + sql: `SELECT type as typ, category as kategorie, COUNT(*) as anzahl +FROM events +GROUP BY type, category +ORDER BY anzahl DESC`, + parameters: [], + builtIn: true, + }, +]; + +// --------------------------------------------------------------------------- +// TemplatePickerModal +// --------------------------------------------------------------------------- + +export class TemplatePickerModal extends Modal { + private manager: TemplateManager; + private onSelect: (sql: string) => void; + + constructor(plugin: LogfirePlugin, manager: TemplateManager, onSelect: (sql: string) => void) { + super(plugin.app); + this.manager = manager; + this.onSelect = onSelect; + } + + onOpen(): void { + const { contentEl } = this; + contentEl.addClass('logfire-template-picker'); + contentEl.createEl('h2', { text: 'Query-Templates' }); + + const list = contentEl.createDiv({ cls: 'logfire-template-list' }); + + for (const tpl of this.manager.getAll()) { + const item = list.createDiv({ cls: 'logfire-template-item' }); + + const header = item.createDiv({ cls: 'logfire-template-header' }); + header.createSpan({ cls: 'logfire-template-name', text: tpl.name }); + if (tpl.builtIn) { + header.createSpan({ cls: 'logfire-template-badge', text: 'Built-in' }); + } + + if (tpl.description) { + item.createDiv({ cls: 'logfire-template-desc', text: tpl.description }); + } + + item.createEl('pre', { + cls: 'logfire-template-preview', + text: tpl.sql.length > 120 ? tpl.sql.slice(0, 120) + '...' : tpl.sql, + }); + + const actions = item.createDiv({ cls: 'logfire-template-actions' }); + const useBtn = actions.createEl('button', { text: 'Verwenden', cls: 'mod-cta' }); + useBtn.addEventListener('click', () => { + if (tpl.parameters.length > 0) { + this.close(); + new ParameterModal(this.app, tpl, values => { + this.onSelect(TemplateManager.applyParameters(tpl.sql, values)); + }).open(); + } else { + this.onSelect(tpl.sql); + this.close(); + } + }); + + if (!tpl.builtIn) { + const delBtn = actions.createEl('button', { text: 'Entfernen' }); + delBtn.addEventListener('click', () => { + this.manager.delete(tpl.id); + item.remove(); + new Notice('Template entfernt.'); + }); + } + } + } + + onClose(): void { this.contentEl.empty(); } +} + +// --------------------------------------------------------------------------- +// ParameterModal +// --------------------------------------------------------------------------- + +class ParameterModal extends Modal { + private template: QueryTemplate; + private onSubmit: (values: Record) => void; + private values: Record = {}; + + constructor( + app: import('obsidian').App, + template: QueryTemplate, + onSubmit: (values: Record) => void, + ) { + super(app); + this.template = template; + this.onSubmit = onSubmit; + for (const p of template.parameters) this.values[p.name] = p.defaultValue; + } + + onOpen(): void { + const { contentEl } = this; + contentEl.createEl('h3', { text: `Parameter: ${this.template.name}` }); + + for (const param of this.template.parameters) { + new Setting(contentEl).setName(param.label).addText(text => { + text.setValue(param.defaultValue).setPlaceholder(param.defaultValue) + .onChange(v => { this.values[param.name] = v; }); + if (param.type === 'number') text.inputEl.type = 'number'; + if (param.type === 'date') text.inputEl.type = 'date'; + }); + } + + const btns = contentEl.createDiv({ cls: 'logfire-modal-buttons' }); + const runBtn = btns.createEl('button', { text: 'Ausfuehren', cls: 'mod-cta' }); + runBtn.addEventListener('click', () => { this.onSubmit(this.values); this.close(); }); + const cancelBtn = btns.createEl('button', { text: 'Abbrechen' }); + cancelBtn.addEventListener('click', () => this.close()); + } + + onClose(): void { this.contentEl.empty(); } +} From 3d0519db8f335c96b713ea1e0562eba776a55d25 Mon Sep 17 00:00:00 2001 From: tolvitty Date: Thu, 12 Feb 2026 11:24:14 +0100 Subject: [PATCH 4/6] Schema-Browser: Sidebar mit Tabellen, Spalten und Indizes SchemaView als ItemView zeigt alle Logfire-DB-Tabellen mit aufklappbaren Details (Spaltentypen, PK, Indizes, Zeilenanzahl). Rechtsklick kopiert SELECT-Statement. Co-Authored-By: Claude Opus 4.6 --- src/ui/schema-view.ts | 179 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 src/ui/schema-view.ts diff --git a/src/ui/schema-view.ts b/src/ui/schema-view.ts new file mode 100644 index 0000000..a2b844a --- /dev/null +++ b/src/ui/schema-view.ts @@ -0,0 +1,179 @@ +import { ItemView, WorkspaceLeaf, Notice } from 'obsidian'; +import { DatabaseManager } from '../core/database'; + +export const SCHEMA_VIEW_TYPE = 'logfire-schema-view'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface TableInfo { + name: string; + columns: ColumnInfo[]; + indexes: IndexInfo[]; + rowCount: number; +} + +interface ColumnInfo { + name: string; + type: string; + notnull: boolean; + pk: boolean; +} + +interface IndexInfo { + name: string; + unique: boolean; + columns: string[]; +} + +// --------------------------------------------------------------------------- +// SchemaView +// --------------------------------------------------------------------------- + +export class SchemaView extends ItemView { + private db: DatabaseManager; + private contentContainer!: HTMLElement; + private expandedTables = new Set(); + + constructor(leaf: WorkspaceLeaf, db: DatabaseManager) { + super(leaf); + this.db = db; + } + + getViewType(): string { + return SCHEMA_VIEW_TYPE; + } + + getDisplayText(): string { + return 'Schema-Browser'; + } + + getIcon(): string { + return 'database'; + } + + async onOpen(): Promise { + const container = this.containerEl.children[1] as HTMLElement; + container.empty(); + container.addClass('logfire-schema-view'); + + const header = container.createDiv({ cls: 'logfire-schema-header' }); + header.createSpan({ cls: 'logfire-schema-title', text: 'Logfire Schema' }); + + const refreshBtn = header.createEl('button', { + cls: 'logfire-dash-btn clickable-icon', + attr: { 'aria-label': 'Aktualisieren' }, + text: '\u21bb', + }); + refreshBtn.addEventListener('click', () => this.refresh()); + + this.contentContainer = container.createDiv({ cls: 'logfire-schema-content' }); + this.refresh(); + } + + refresh(): void { + this.contentContainer.empty(); + + const tables = this.introspect(); + if (tables.length === 0) { + this.contentContainer.createDiv({ cls: 'logfire-empty', text: 'Keine Tabellen gefunden.' }); + return; + } + + for (const table of tables) { + this.renderTableNode(table); + } + } + + // --------------------------------------------------------------------------- + // Introspection + // --------------------------------------------------------------------------- + + private introspect(): TableInfo[] { + const tableRows = this.db.queryReadOnly( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name", + ) as Array<{ name: string }>; + + return tableRows.map(({ name }) => { + const columns = (this.db.queryReadOnly(`PRAGMA table_info("${name}")`) as Array<{ + name: string; type: string; notnull: number; pk: number; + }>).map(c => ({ + name: c.name, + type: c.type || 'ANY', + notnull: c.notnull === 1, + pk: c.pk > 0, + })); + + const idxRows = this.db.queryReadOnly(`PRAGMA index_list("${name}")`) as Array<{ + name: string; unique: number; + }>; + const indexes: IndexInfo[] = []; + for (const idx of idxRows) { + if (idx.name.startsWith('sqlite_')) continue; + const cols = (this.db.queryReadOnly(`PRAGMA index_info("${idx.name}")`) as Array<{ + name: string; + }>).map(c => c.name); + indexes.push({ name: idx.name, unique: idx.unique === 1, columns: cols }); + } + + const countRow = this.db.queryReadOnly(`SELECT COUNT(*) as c FROM "${name}"`) as Array<{ c: number }>; + const rowCount = countRow[0]?.c ?? 0; + + return { name, columns, indexes, rowCount }; + }); + } + + // --------------------------------------------------------------------------- + // Rendering + // --------------------------------------------------------------------------- + + private renderTableNode(table: TableInfo): void { + const node = this.contentContainer.createDiv({ cls: 'logfire-schema-table' }); + const isExpanded = this.expandedTables.has(table.name); + + const header = node.createDiv({ cls: 'logfire-schema-table-header' }); + + const arrow = header.createSpan({ cls: 'logfire-schema-arrow', text: isExpanded ? '\u25bc' : '\u25b6' }); + header.createSpan({ cls: 'logfire-schema-table-name', text: table.name }); + header.createSpan({ cls: 'logfire-schema-row-count', text: `(${table.rowCount})` }); + + header.addEventListener('click', () => { + if (isExpanded) this.expandedTables.delete(table.name); + else this.expandedTables.add(table.name); + this.refresh(); + }); + + // Context menu: SQL kopieren + header.addEventListener('contextmenu', (e) => { + e.preventDefault(); + navigator.clipboard.writeText(`SELECT * FROM "${table.name}" LIMIT 100;`); + new Notice(`SELECT auf "${table.name}" kopiert.`); + }); + + if (!isExpanded) return; + + const details = node.createDiv({ cls: 'logfire-schema-details' }); + + // Columns + for (const col of table.columns) { + const row = details.createDiv({ cls: 'logfire-schema-col' }); + const nameText = col.pk ? `\u{1f511} ${col.name}` : col.name; + row.createSpan({ cls: 'logfire-schema-col-name', text: nameText }); + const typeText = col.type + (col.notnull ? '' : '?'); + row.createSpan({ cls: 'logfire-schema-col-type', text: typeText }); + } + + // Indexes + if (table.indexes.length > 0) { + details.createDiv({ cls: 'logfire-schema-section-header', text: 'Indizes' }); + for (const idx of table.indexes) { + const row = details.createDiv({ cls: 'logfire-schema-idx' }); + row.createSpan({ text: `${idx.unique ? 'UNIQUE ' : ''}${idx.name}` }); + row.createSpan({ cls: 'logfire-schema-idx-cols', text: `(${idx.columns.join(', ')})` }); + } + } + } + + async onClose(): Promise { /* cleanup */ } +} From df3bddeaebdb376325e6b35592e0120830f3f62f Mon Sep 17 00:00:00 2001 From: tolvitty Date: Thu, 12 Feb 2026 11:24:20 +0100 Subject: [PATCH 5/6] QueryModal: History-Integration und initiale SQL Query-Editor trackt jetzt alle Ausfuehrungen im HistoryManager (Laufzeit, Zeilenanzahl). Akzeptiert optionale initiale SQL fuer Template- und Favoriten-Integration. Co-Authored-By: Claude Opus 4.6 --- src/query/query-modal.ts | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/query/query-modal.ts b/src/query/query-modal.ts index e399a5e..db2973a 100644 --- a/src/query/query-modal.ts +++ b/src/query/query-modal.ts @@ -3,15 +3,20 @@ import { QueryConfig, TimeRange, EventType } from '../types'; import { buildQuery } from '../core/query-builder'; import { DatabaseManager } from '../core/database'; import { renderTable, toMarkdownTable } from '../viz/table-renderer'; +import { HistoryManager } from '../management/history'; export class QueryModal extends Modal { private editorEl!: HTMLTextAreaElement; private resultEl!: HTMLElement; private modeToggle!: HTMLButtonElement; private mode: 'shorthand' | 'sql' = 'shorthand'; + private historyManager?: HistoryManager; + private initialSql?: string; - constructor(app: App, private db: DatabaseManager) { + constructor(app: App, private db: DatabaseManager, historyManager?: HistoryManager, initialSql?: string) { super(app); + this.historyManager = historyManager; + this.initialSql = initialSql; } onOpen(): void { @@ -76,6 +81,14 @@ export class QueryModal extends Modal { // Results this.resultEl = contentEl.createDiv({ cls: 'logfire-qm-results' }); + + // Initial SQL setzen + if (this.initialSql) { + this.mode = 'sql'; + this.modeToggle.textContent = 'Modus: SQL'; + this.editorEl.value = this.initialSql; + this.editorEl.placeholder = 'SELECT * FROM events\nWHERE type = \'file:create\'\nORDER BY timestamp DESC\nLIMIT 50'; + } } onClose(): void { @@ -91,8 +104,11 @@ export class QueryModal extends Modal { this.resultEl.empty(); + const startTime = Date.now(); + try { let rows: Record[]; + let executedSql = input; if (this.mode === 'sql') { // Raw SQL mode @@ -109,9 +125,13 @@ export class QueryModal extends Modal { // Shorthand mode const config = parseShorthand(input); const { sql, params } = buildQuery(config); + executedSql = sql; rows = this.db.queryReadOnly(sql, params) as Record[]; } + const elapsed = Date.now() - startTime; + this.historyManager?.addEntry(executedSql, elapsed, rows.length); + this.lastRows = rows; this.lastKeys = rows.length > 0 ? Object.keys(rows[0]) : []; From 4936bfd94fad6711a5f7ca059b96c83dfa54b365 Mon Sep 17 00:00:00 2001 From: tolvitty Date: Thu, 12 Feb 2026 11:24:28 +0100 Subject: [PATCH 6/6] Query-Management in main.ts verdrahtet, Schema/Modal CSS History, Favorites, Templates und Schema-Browser initialisiert und mit Commands verknuepft. CSS fuer Schema-Browser-Sidebar, Template-Picker und Favoriten-Modal ergaenzt. Co-Authored-By: Claude Opus 4.6 --- src/main.ts | 61 +++++++++++- styles.css | 273 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 333 insertions(+), 1 deletion(-) diff --git a/src/main.ts b/src/main.ts index c6991db..fbbdcba 100644 --- a/src/main.ts +++ b/src/main.ts @@ -17,6 +17,10 @@ import { registerLogfireBlock, registerLogfireSqlBlock, registerLogfireDashboard import { QueryModal } from './query/query-modal'; import { VirtualTableManager } from './query/virtual-tables'; import { DashboardView, DASHBOARD_VIEW_TYPE } from './viz/dashboard'; +import { HistoryManager } from './management/history'; +import { FavoritesManager, SaveFavoriteModal } from './management/favorites'; +import { TemplateManager, TemplatePickerModal } from './management/templates'; +import { SchemaView, SCHEMA_VIEW_TYPE } from './ui/schema-view'; export default class LogfirePlugin extends Plugin { settings!: LogfireSettings; @@ -24,6 +28,9 @@ export default class LogfirePlugin extends Plugin { eventBus!: EventBus; sessionManager!: SessionManager; contentAnalyzer!: ContentAnalyzer; + historyManager!: HistoryManager; + favoritesManager!: FavoritesManager; + templateManager!: TemplateManager; private fileCollector!: FileCollector; private contentCollector!: ContentCollector; @@ -53,6 +60,11 @@ export default class LogfirePlugin extends Plugin { const vaultName = this.app.vault.getName(); this.sessionManager = new SessionManager(this.eventBus, this.db, vaultName); + // Query management + this.historyManager = new HistoryManager(); + this.favoritesManager = new FavoritesManager(); + this.templateManager = new TemplateManager(); + // Content analyzer this.contentAnalyzer = new ContentAnalyzer(); if (this.db.hasBaseline()) { @@ -97,6 +109,12 @@ export default class LogfirePlugin extends Plugin { (leaf) => new DashboardView(leaf, this), ); + // UI: Schema-Browser view + this.registerView( + SCHEMA_VIEW_TYPE, + (leaf) => new SchemaView(leaf, this.db), + ); + // UI: Status bar this.statusBar = new StatusBar(this); this.statusBar.start(); @@ -321,7 +339,31 @@ export default class LogfirePlugin extends Plugin { id: 'open-query', name: 'Query-Editor \u00f6ffnen', callback: () => { - new QueryModal(this.app, this.db).open(); + new QueryModal(this.app, this.db, this.historyManager).open(); + }, + }); + + this.addCommand({ + id: 'show-schema', + name: 'Schema-Browser anzeigen', + callback: () => this.activateSchema(), + }); + + this.addCommand({ + id: 'show-templates', + name: 'Query-Templates anzeigen', + callback: () => { + new TemplatePickerModal(this, this.templateManager, (sql) => { + new QueryModal(this.app, this.db, this.historyManager, sql).open(); + }).open(); + }, + }); + + this.addCommand({ + id: 'save-favorite', + name: 'Aktuelle Query als Favorit speichern', + callback: () => { + new SaveFavoriteModal(this, this.favoritesManager, '').open(); }, }); @@ -376,6 +418,23 @@ export default class LogfirePlugin extends Plugin { } } + // --------------------------------------------------------------------------- + // Schema view + // --------------------------------------------------------------------------- + + private async activateSchema(): Promise { + const existing = this.app.workspace.getLeavesOfType(SCHEMA_VIEW_TYPE); + if (existing.length > 0) { + this.app.workspace.revealLeaf(existing[0]); + return; + } + const leaf = this.app.workspace.getRightLeaf(false); + if (leaf) { + await leaf.setViewState({ type: SCHEMA_VIEW_TYPE, active: true }); + this.app.workspace.revealLeaf(leaf); + } + } + // --------------------------------------------------------------------------- // Settings // --------------------------------------------------------------------------- diff --git a/styles.css b/styles.css index d12233c..bdae9eb 100644 --- a/styles.css +++ b/styles.css @@ -924,3 +924,276 @@ padding: 0 0 8px 0; letter-spacing: -0.01em; } + +/* ═══════════════════════════════════════════════════════════════════════════ + Schema Browser — Database Introspection Sidebar + ═══════════════════════════════════════════════════════════════════════════ */ + +.logfire-schema-view { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + background: var(--background-primary); +} + +.logfire-schema-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 8px 10px; + border-bottom: 1px solid var(--background-modifier-border); + background: var(--background-secondary); + flex-shrink: 0; +} + +.logfire-schema-title { + font-family: var(--font-monospace); + font-size: 13px; + font-weight: 600; + color: var(--text-normal); + letter-spacing: -0.01em; +} + +.logfire-schema-content { + flex: 1; + overflow-y: auto; + padding: 4px 0; + font-family: var(--font-monospace); + font-size: 11.5px; +} + +.logfire-schema-content::-webkit-scrollbar { + width: 6px; +} + +.logfire-schema-content::-webkit-scrollbar-track { + background: transparent; +} + +.logfire-schema-content::-webkit-scrollbar-thumb { + background: var(--background-modifier-border); + border-radius: 3px; +} + +/* --------------------------------------------------------------------------- + Schema — Table Node + --------------------------------------------------------------------------- */ + +.logfire-schema-table { + border-bottom: 1px solid color-mix(in srgb, var(--background-modifier-border) 40%, transparent); +} + +.logfire-schema-table-header { + display: flex; + align-items: center; + gap: 5px; + padding: 4px 10px; + cursor: pointer; + transition: background 60ms ease; + user-select: none; +} + +.logfire-schema-table-header:hover { + background: var(--background-secondary); +} + +.logfire-schema-arrow { + color: var(--text-faint); + font-size: 9px; + width: 10px; + flex-shrink: 0; +} + +.logfire-schema-table-name { + font-weight: 600; + color: var(--text-accent); +} + +.logfire-schema-row-count { + color: var(--text-faint); + font-size: 10px; + margin-left: auto; + font-variant-numeric: tabular-nums; +} + +/* --------------------------------------------------------------------------- + Schema — Column & Index Details + --------------------------------------------------------------------------- */ + +.logfire-schema-details { + padding: 2px 0 4px 20px; +} + +.logfire-schema-col { + display: flex; + align-items: center; + gap: 6px; + padding: 1px 10px; + color: var(--text-normal); + line-height: 1.6; +} + +.logfire-schema-col:hover { + background: color-mix(in srgb, var(--background-secondary) 40%, transparent); +} + +.logfire-schema-col-name { + flex-shrink: 0; +} + +.logfire-schema-col-type { + color: var(--text-faint); + font-size: 10px; + margin-left: auto; + text-transform: uppercase; +} + +.logfire-schema-section-header { + padding: 4px 10px 1px; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.03em; + text-transform: uppercase; + color: var(--text-muted); + margin-top: 4px; +} + +.logfire-schema-idx { + display: flex; + align-items: center; + gap: 6px; + padding: 1px 10px; + color: var(--text-muted); + font-size: 10.5px; + line-height: 1.6; +} + +.logfire-schema-idx-cols { + color: var(--text-faint); + font-size: 10px; + margin-left: auto; +} + +/* ═══════════════════════════════════════════════════════════════════════════ + Template Picker & Favorite Modals + ═══════════════════════════════════════════════════════════════════════════ */ + +.logfire-template-picker h2, +.logfire-save-fav-modal h3 { + font-family: var(--font-monospace); + font-size: 16px; + font-weight: 700; + letter-spacing: -0.01em; + margin-bottom: 10px; + color: var(--text-normal); +} + +.logfire-template-list { + max-height: 400px; + overflow-y: auto; +} + +.logfire-template-item { + padding: 8px 10px; + border-bottom: 1px solid var(--background-modifier-border); + transition: background 60ms ease; +} + +.logfire-template-item:hover { + background: color-mix(in srgb, var(--background-secondary) 40%, transparent); +} + +.logfire-template-item:last-child { + border-bottom: none; +} + +.logfire-template-header { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 2px; +} + +.logfire-template-name { + font-family: var(--font-monospace); + font-size: 12px; + font-weight: 600; + color: var(--text-normal); +} + +.logfire-template-badge { + font-family: var(--font-monospace); + font-size: 9px; + letter-spacing: 0.04em; + text-transform: uppercase; + padding: 1px 5px; + border-radius: 3px; + background: color-mix(in srgb, var(--interactive-accent) 15%, transparent); + color: var(--interactive-accent); +} + +.logfire-template-desc { + font-family: var(--font-monospace); + font-size: 11px; + color: var(--text-muted); + margin-bottom: 4px; +} + +.logfire-template-preview { + font-family: var(--font-monospace); + font-size: 10.5px; + line-height: 1.4; + color: var(--text-faint); + background: var(--background-secondary); + border-radius: 3px; + padding: 4px 8px; + margin: 4px 0; + overflow: hidden; + white-space: pre-wrap; +} + +.logfire-template-actions { + display: flex; + gap: 6px; + margin-top: 6px; +} + +.logfire-template-actions button { + font-family: var(--font-monospace); + font-size: 11px; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +/* --------------------------------------------------------------------------- + Modal Shared — Buttons & Preview + --------------------------------------------------------------------------- */ + +.logfire-modal-buttons { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 16px; +} + +.logfire-modal-buttons button { + font-family: var(--font-monospace); + font-size: 12px; + letter-spacing: 0.02em; +} + +.logfire-sql-preview { + font-family: var(--font-monospace); + font-size: 11px; + line-height: 1.4; + color: var(--text-muted); + background: var(--background-secondary); + border-radius: 4px; + padding: 6px 10px; + margin: 4px 0; + white-space: pre-wrap; + max-height: 120px; + overflow: auto; +}