Merge feature/query-management: History, Favoriten, Templates, Schema

Query-History mit Metriken, Favoriten mit Kategorien/Tags,
9 Built-in Templates fuer Logfire-Schema, Schema-Browser Sidebar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Luca Oelfke 2026-02-12 11:24:35 +01:00
commit c30320a7db
7 changed files with 1224 additions and 2 deletions

View file

@ -17,6 +17,10 @@ import { registerLogfireBlock, registerLogfireSqlBlock, registerLogfireDashboard
import { QueryModal } from './query/query-modal'; import { QueryModal } from './query/query-modal';
import { VirtualTableManager } from './query/virtual-tables'; import { VirtualTableManager } from './query/virtual-tables';
import { DashboardView, DASHBOARD_VIEW_TYPE } from './viz/dashboard'; 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 { export default class LogfirePlugin extends Plugin {
settings!: LogfireSettings; settings!: LogfireSettings;
@ -24,6 +28,9 @@ export default class LogfirePlugin extends Plugin {
eventBus!: EventBus; eventBus!: EventBus;
sessionManager!: SessionManager; sessionManager!: SessionManager;
contentAnalyzer!: ContentAnalyzer; contentAnalyzer!: ContentAnalyzer;
historyManager!: HistoryManager;
favoritesManager!: FavoritesManager;
templateManager!: TemplateManager;
private fileCollector!: FileCollector; private fileCollector!: FileCollector;
private contentCollector!: ContentCollector; private contentCollector!: ContentCollector;
@ -53,6 +60,11 @@ export default class LogfirePlugin extends Plugin {
const vaultName = this.app.vault.getName(); const vaultName = this.app.vault.getName();
this.sessionManager = new SessionManager(this.eventBus, this.db, vaultName); 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 // Content analyzer
this.contentAnalyzer = new ContentAnalyzer(); this.contentAnalyzer = new ContentAnalyzer();
if (this.db.hasBaseline()) { if (this.db.hasBaseline()) {
@ -97,6 +109,12 @@ export default class LogfirePlugin extends Plugin {
(leaf) => new DashboardView(leaf, this), (leaf) => new DashboardView(leaf, this),
); );
// UI: Schema-Browser view
this.registerView(
SCHEMA_VIEW_TYPE,
(leaf) => new SchemaView(leaf, this.db),
);
// UI: Status bar // UI: Status bar
this.statusBar = new StatusBar(this); this.statusBar = new StatusBar(this);
this.statusBar.start(); this.statusBar.start();
@ -321,7 +339,31 @@ export default class LogfirePlugin extends Plugin {
id: 'open-query', id: 'open-query',
name: 'Query-Editor \u00f6ffnen', name: 'Query-Editor \u00f6ffnen',
callback: () => { 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<void> {
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 // Settings
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

209
src/management/favorites.ts Normal file
View file

@ -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<string, Favorite>();
private categories = new Map<string, FavoriteCategory>();
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<Favorite>): 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(); }
}

126
src/management/history.ts Normal file
View file

@ -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<string, HistoryEntry>();
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();
}
}

356
src/management/templates.ts Normal file
View file

@ -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<string, QueryTemplate>();
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, string>): 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<string>();
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<string, string>) => void;
private values: Record<string, string> = {};
constructor(
app: import('obsidian').App,
template: QueryTemplate,
onSubmit: (values: Record<string, string>) => 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(); }
}

View file

@ -3,15 +3,20 @@ import { QueryConfig, TimeRange, EventType } from '../types';
import { buildQuery } from '../core/query-builder'; import { buildQuery } from '../core/query-builder';
import { DatabaseManager } from '../core/database'; import { DatabaseManager } from '../core/database';
import { renderTable, toMarkdownTable } from '../viz/table-renderer'; import { renderTable, toMarkdownTable } from '../viz/table-renderer';
import { HistoryManager } from '../management/history';
export class QueryModal extends Modal { export class QueryModal extends Modal {
private editorEl!: HTMLTextAreaElement; private editorEl!: HTMLTextAreaElement;
private resultEl!: HTMLElement; private resultEl!: HTMLElement;
private modeToggle!: HTMLButtonElement; private modeToggle!: HTMLButtonElement;
private mode: 'shorthand' | 'sql' = 'shorthand'; 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); super(app);
this.historyManager = historyManager;
this.initialSql = initialSql;
} }
onOpen(): void { onOpen(): void {
@ -76,6 +81,14 @@ export class QueryModal extends Modal {
// Results // Results
this.resultEl = contentEl.createDiv({ cls: 'logfire-qm-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 { onClose(): void {
@ -91,8 +104,11 @@ export class QueryModal extends Modal {
this.resultEl.empty(); this.resultEl.empty();
const startTime = Date.now();
try { try {
let rows: Record<string, unknown>[]; let rows: Record<string, unknown>[];
let executedSql = input;
if (this.mode === 'sql') { if (this.mode === 'sql') {
// Raw SQL mode // Raw SQL mode
@ -109,9 +125,13 @@ export class QueryModal extends Modal {
// Shorthand mode // Shorthand mode
const config = parseShorthand(input); const config = parseShorthand(input);
const { sql, params } = buildQuery(config); const { sql, params } = buildQuery(config);
executedSql = sql;
rows = this.db.queryReadOnly(sql, params) as Record<string, unknown>[]; rows = this.db.queryReadOnly(sql, params) as Record<string, unknown>[];
} }
const elapsed = Date.now() - startTime;
this.historyManager?.addEntry(executedSql, elapsed, rows.length);
this.lastRows = rows; this.lastRows = rows;
this.lastKeys = rows.length > 0 ? Object.keys(rows[0]) : []; this.lastKeys = rows.length > 0 ? Object.keys(rows[0]) : [];

179
src/ui/schema-view.ts Normal file
View file

@ -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<string>();
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<void> {
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<void> { /* cleanup */ }
}

View file

@ -924,3 +924,276 @@
padding: 0 0 8px 0; padding: 0 0 8px 0;
letter-spacing: -0.01em; 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;
}