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:
commit
c30320a7db
7 changed files with 1224 additions and 2 deletions
61
src/main.ts
61
src/main.ts
|
|
@ -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
209
src/management/favorites.ts
Normal 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
126
src/management/history.ts
Normal 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
356
src/management/templates.ts
Normal 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(); }
|
||||||
|
}
|
||||||
|
|
@ -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
179
src/ui/schema-view.ts
Normal 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 */ }
|
||||||
|
}
|
||||||
273
styles.css
273
styles.css
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue