Feature 8: Projections & Reports — Automatische Markdown-Reports
- types.ts: projections-Settings erweitert (enabled, outputFolder, dailyLog, sessionLog, weeklyDigest), heatmap zu BuiltinFormat - formatters.ts: Query-Results → Markdown (Timeline, Table, Summary, Metric, Heatmap, Custom) - Presets: daily-log (Tagesprotokoll), session-log (Session-Protokoll), weekly-digest (Wochenuebersicht) - template-registry.ts: Built-in + Custom ProjectionTemplate Verwaltung - projection-engine.ts: Kern-Engine mit Scheduling, Session-End-Listener, ProjectionPickerModal - main.ts: ProjectionEngine Integration, 2 neue Commands (run-projection, run-all-projections) - settings-tab.ts: Neue Sektion "Projektionen" mit Toggles und Konfiguration - styles.css: ProjectionPickerModal Styling Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c30320a7db
commit
ee599f42e7
10 changed files with 885 additions and 1 deletions
23
src/main.ts
23
src/main.ts
|
|
@ -21,6 +21,7 @@ import { HistoryManager } from './management/history';
|
||||||
import { FavoritesManager, SaveFavoriteModal } from './management/favorites';
|
import { FavoritesManager, SaveFavoriteModal } from './management/favorites';
|
||||||
import { TemplateManager, TemplatePickerModal } from './management/templates';
|
import { TemplateManager, TemplatePickerModal } from './management/templates';
|
||||||
import { SchemaView, SCHEMA_VIEW_TYPE } from './ui/schema-view';
|
import { SchemaView, SCHEMA_VIEW_TYPE } from './ui/schema-view';
|
||||||
|
import { ProjectionEngine, ProjectionPickerModal } from './projection/projection-engine';
|
||||||
|
|
||||||
export default class LogfirePlugin extends Plugin {
|
export default class LogfirePlugin extends Plugin {
|
||||||
settings!: LogfireSettings;
|
settings!: LogfireSettings;
|
||||||
|
|
@ -31,6 +32,7 @@ export default class LogfirePlugin extends Plugin {
|
||||||
historyManager!: HistoryManager;
|
historyManager!: HistoryManager;
|
||||||
favoritesManager!: FavoritesManager;
|
favoritesManager!: FavoritesManager;
|
||||||
templateManager!: TemplateManager;
|
templateManager!: TemplateManager;
|
||||||
|
projectionEngine!: ProjectionEngine;
|
||||||
|
|
||||||
private fileCollector!: FileCollector;
|
private fileCollector!: FileCollector;
|
||||||
private contentCollector!: ContentCollector;
|
private contentCollector!: ContentCollector;
|
||||||
|
|
@ -164,6 +166,10 @@ export default class LogfirePlugin extends Plugin {
|
||||||
// Virtual Tables
|
// Virtual Tables
|
||||||
this.virtualTables = new VirtualTableManager(this.app, this.db);
|
this.virtualTables = new VirtualTableManager(this.app, this.db);
|
||||||
this.virtualTables.initialize();
|
this.virtualTables.initialize();
|
||||||
|
|
||||||
|
// Projection Engine
|
||||||
|
this.projectionEngine = new ProjectionEngine(this.app, this.db, this.eventBus, this.settings);
|
||||||
|
this.projectionEngine.start();
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('[Logfire] Plugin geladen. Session:', this.sessionManager.currentSessionId);
|
console.log('[Logfire] Plugin geladen. Session:', this.sessionManager.currentSessionId);
|
||||||
|
|
@ -173,6 +179,7 @@ export default class LogfirePlugin extends Plugin {
|
||||||
console.log('[Logfire] Entlade Plugin...');
|
console.log('[Logfire] Entlade Plugin...');
|
||||||
|
|
||||||
cleanupAllRefreshTimers();
|
cleanupAllRefreshTimers();
|
||||||
|
this.projectionEngine?.destroy();
|
||||||
this.virtualTables?.destroy();
|
this.virtualTables?.destroy();
|
||||||
this.statusBar?.destroy();
|
this.statusBar?.destroy();
|
||||||
this.stopTracking();
|
this.stopTracking();
|
||||||
|
|
@ -382,6 +389,22 @@ export default class LogfirePlugin extends Plugin {
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.addCommand({
|
||||||
|
id: 'run-projection',
|
||||||
|
name: 'Projektion manuell ausführen',
|
||||||
|
callback: () => {
|
||||||
|
new ProjectionPickerModal(this.app, this.projectionEngine).open();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.addCommand({
|
||||||
|
id: 'run-all-projections',
|
||||||
|
name: 'Alle Projektionen ausführen',
|
||||||
|
callback: () => {
|
||||||
|
this.projectionEngine.runAllProjections();
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
180
src/projection/formatters.ts
Normal file
180
src/projection/formatters.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
import { SectionConfig, BuiltinFormat } from '../types';
|
||||||
|
import { toMarkdownTable } from '../viz/table-renderer';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Section → Markdown
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function formatSection(rows: Record<string, unknown>[], section: SectionConfig): string {
|
||||||
|
if (rows.length === 0) return `### ${section.heading}\n\n*Keine Daten.*\n`;
|
||||||
|
|
||||||
|
const heading = `### ${section.heading}\n\n`;
|
||||||
|
|
||||||
|
if (section.format.type === 'custom' && section.format.customTemplate) {
|
||||||
|
return heading + applyCustomTemplate(rows, section.format.customTemplate) + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmt = section.format.builtin;
|
||||||
|
if (!fmt) return heading + formatAsTable(rows) + '\n';
|
||||||
|
|
||||||
|
switch (fmt.name) {
|
||||||
|
case 'timeline':
|
||||||
|
return heading + formatAsTimeline(rows, fmt.showTimestamp, fmt.showPayload) + '\n';
|
||||||
|
case 'table':
|
||||||
|
return heading + formatAsTableWithColumns(rows, fmt.columns) + '\n';
|
||||||
|
case 'summary':
|
||||||
|
return heading + formatAsSummary(rows, fmt.metrics) + '\n';
|
||||||
|
case 'metric':
|
||||||
|
return heading + formatAsMetric(rows, fmt) + '\n';
|
||||||
|
case 'heatmap':
|
||||||
|
return heading + formatAsHeatmap(rows, fmt.labelField, fmt.valueField) + '\n';
|
||||||
|
default:
|
||||||
|
return heading + formatAsTable(rows) + '\n';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Timeline
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function formatAsTimeline(rows: Record<string, unknown>[], showTs: boolean, showPayload: boolean): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (showTs && row.timestamp != null) {
|
||||||
|
const ts = typeof row.timestamp === 'number'
|
||||||
|
? new Date(row.timestamp).toLocaleTimeString()
|
||||||
|
: String(row.timestamp);
|
||||||
|
parts.push(`**${ts}**`);
|
||||||
|
}
|
||||||
|
parts.push(String(row.type ?? ''));
|
||||||
|
if (row.source) parts.push(`\`${row.source}\``);
|
||||||
|
if (showPayload && row.payload) {
|
||||||
|
const p = typeof row.payload === 'string' ? row.payload : JSON.stringify(row.payload);
|
||||||
|
if (p !== '{}') parts.push(`— ${p}`);
|
||||||
|
}
|
||||||
|
lines.push(`- ${parts.join(' ')}`);
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Table
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function formatAsTable(rows: Record<string, unknown>[]): string {
|
||||||
|
const keys = Object.keys(rows[0]);
|
||||||
|
return toMarkdownTable(keys, rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAsTableWithColumns(
|
||||||
|
rows: Record<string, unknown>[],
|
||||||
|
columns: { header: string; value: string; align?: string }[],
|
||||||
|
): string {
|
||||||
|
const headers = columns.map(c => c.header);
|
||||||
|
const mappedRows = rows.map(row => {
|
||||||
|
const mapped: Record<string, unknown> = {};
|
||||||
|
for (const col of columns) {
|
||||||
|
mapped[col.header] = row[col.value] ?? '';
|
||||||
|
}
|
||||||
|
return mapped;
|
||||||
|
});
|
||||||
|
return toMarkdownTable(headers, mappedRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Summary
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function formatAsSummary(
|
||||||
|
rows: Record<string, unknown>[],
|
||||||
|
metrics: { label: string; aggregate: string; field?: string }[],
|
||||||
|
): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (const m of metrics) {
|
||||||
|
const values = rows.map(r => Number(r[m.field ?? 'count'] ?? 0));
|
||||||
|
let result: number;
|
||||||
|
switch (m.aggregate) {
|
||||||
|
case 'sum': result = values.reduce((a, b) => a + b, 0); break;
|
||||||
|
case 'avg': result = values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0; break;
|
||||||
|
case 'min': result = values.length > 0 ? Math.min(...values) : 0; break;
|
||||||
|
case 'max': result = values.length > 0 ? Math.max(...values) : 0; break;
|
||||||
|
default: result = rows.length;
|
||||||
|
}
|
||||||
|
lines.push(`- **${m.label}**: ${result.toLocaleString()}`);
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Metric
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function formatAsMetric(
|
||||||
|
rows: Record<string, unknown>[],
|
||||||
|
fmt: { aggregate: string; field?: string },
|
||||||
|
): string {
|
||||||
|
const values = rows.map(r => Number(r[fmt.field ?? Object.keys(r).pop()!] ?? 0));
|
||||||
|
let result: number;
|
||||||
|
switch (fmt.aggregate) {
|
||||||
|
case 'sum': result = values.reduce((a, b) => a + b, 0); break;
|
||||||
|
case 'avg': result = values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0; break;
|
||||||
|
case 'min': result = values.length > 0 ? Math.min(...values) : 0; break;
|
||||||
|
case 'max': result = values.length > 0 ? Math.max(...values) : 0; break;
|
||||||
|
default: result = rows.length;
|
||||||
|
}
|
||||||
|
return `**${result.toLocaleString()}**`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Heatmap
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function formatAsHeatmap(rows: Record<string, unknown>[], labelField: string, valueField: string): string {
|
||||||
|
const maxVal = Math.max(...rows.map(r => Number(r[valueField] ?? 0)), 1);
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const label = String(row[labelField] ?? '');
|
||||||
|
const val = Number(row[valueField] ?? 0);
|
||||||
|
const barLen = Math.round((val / maxVal) * 30);
|
||||||
|
const bar = '\u2588'.repeat(barLen);
|
||||||
|
lines.push(`\`${label.padEnd(12)}\` ${bar} ${val}`);
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Custom template
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function applyCustomTemplate(rows: Record<string, unknown>[], template: string): string {
|
||||||
|
return template.replace(/\{\{rows\}\}/g, JSON.stringify(rows, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Frontmatter
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function buildFrontmatter(data: Record<string, string>): string {
|
||||||
|
if (Object.keys(data).length === 0) return '';
|
||||||
|
const lines = ['---'];
|
||||||
|
for (const [key, value] of Object.entries(data)) {
|
||||||
|
lines.push(`${key}: ${value}`);
|
||||||
|
}
|
||||||
|
lines.push('---', '');
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Date placeholders
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function resolvePlaceholders(pattern: string, context: { date?: Date; sessionId?: string }): string {
|
||||||
|
const d = context.date ?? new Date();
|
||||||
|
return pattern
|
||||||
|
.replace(/\{\{date\}\}/g, d.toISOString().substring(0, 10))
|
||||||
|
.replace(/\{\{year\}\}/g, String(d.getFullYear()))
|
||||||
|
.replace(/\{\{month\}\}/g, String(d.getMonth() + 1).padStart(2, '0'))
|
||||||
|
.replace(/\{\{day\}\}/g, String(d.getDate()).padStart(2, '0'))
|
||||||
|
.replace(/\{\{sessionId\}\}/g, context.sessionId ?? '');
|
||||||
|
}
|
||||||
102
src/projection/presets/daily-log.ts
Normal file
102
src/projection/presets/daily-log.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
import { ProjectionTemplate } from '../../types';
|
||||||
|
|
||||||
|
export const dailyLogTemplate: ProjectionTemplate = {
|
||||||
|
id: 'builtin:daily-log',
|
||||||
|
name: 'Tagesprotokoll',
|
||||||
|
description: 'Sessions, aktive Dateien, Events und Zeitleiste des Tages.',
|
||||||
|
enabled: false,
|
||||||
|
trigger: { type: 'manual' },
|
||||||
|
output: {
|
||||||
|
folder: '',
|
||||||
|
filePattern: '{{date}}-daily-log.md',
|
||||||
|
mode: 'overwrite',
|
||||||
|
frontmatter: {
|
||||||
|
type: 'logfire/daily-log',
|
||||||
|
date: '{{date}}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
id: 'sessions',
|
||||||
|
heading: 'Sessions',
|
||||||
|
type: 'table',
|
||||||
|
query: {
|
||||||
|
timeRange: { type: 'relative', value: 'today' },
|
||||||
|
eventTypes: ['system:session-start', 'system:session-end'],
|
||||||
|
},
|
||||||
|
format: {
|
||||||
|
type: 'builtin',
|
||||||
|
builtin: {
|
||||||
|
name: 'table',
|
||||||
|
columns: [
|
||||||
|
{ header: 'Session', value: 'session' },
|
||||||
|
{ header: 'Typ', value: 'type' },
|
||||||
|
{ header: 'Zeit', value: 'timestamp' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'active-files',
|
||||||
|
heading: 'Aktive Dateien',
|
||||||
|
type: 'table',
|
||||||
|
query: {
|
||||||
|
timeRange: { type: 'relative', value: 'today' },
|
||||||
|
groupBy: 'file',
|
||||||
|
orderBy: 'count',
|
||||||
|
orderDirection: 'desc',
|
||||||
|
limit: 20,
|
||||||
|
},
|
||||||
|
format: {
|
||||||
|
type: 'builtin',
|
||||||
|
builtin: {
|
||||||
|
name: 'table',
|
||||||
|
columns: [
|
||||||
|
{ header: 'Datei', value: 'group' },
|
||||||
|
{ header: 'Events', value: 'count' },
|
||||||
|
{ header: 'Woerter+', value: 'words_added' },
|
||||||
|
{ header: 'Woerter-', value: 'words_removed' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'event-summary',
|
||||||
|
heading: 'Event-Uebersicht',
|
||||||
|
type: 'table',
|
||||||
|
query: {
|
||||||
|
timeRange: { type: 'relative', value: 'today' },
|
||||||
|
groupBy: 'type',
|
||||||
|
orderBy: 'count',
|
||||||
|
orderDirection: 'desc',
|
||||||
|
},
|
||||||
|
format: {
|
||||||
|
type: 'builtin',
|
||||||
|
builtin: {
|
||||||
|
name: 'table',
|
||||||
|
columns: [
|
||||||
|
{ header: 'Typ', value: 'group' },
|
||||||
|
{ header: 'Anzahl', value: 'count' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'timeline',
|
||||||
|
heading: 'Zeitleiste',
|
||||||
|
type: 'timeline',
|
||||||
|
query: {
|
||||||
|
timeRange: { type: 'relative', value: 'today' },
|
||||||
|
limit: 100,
|
||||||
|
},
|
||||||
|
format: {
|
||||||
|
type: 'builtin',
|
||||||
|
builtin: { name: 'timeline', showTimestamp: true, showPayload: false },
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
85
src/projection/presets/session-log.ts
Normal file
85
src/projection/presets/session-log.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
import { ProjectionTemplate } from '../../types';
|
||||||
|
|
||||||
|
export const sessionLogTemplate: ProjectionTemplate = {
|
||||||
|
id: 'builtin:session-log',
|
||||||
|
name: 'Session-Protokoll',
|
||||||
|
description: 'Einzelne Session: Dauer, bearbeitete Dateien, ausgefuehrte Befehle.',
|
||||||
|
enabled: false,
|
||||||
|
trigger: { type: 'on-session-end' },
|
||||||
|
output: {
|
||||||
|
folder: '',
|
||||||
|
filePattern: '{{date}}-session-{{sessionId}}.md',
|
||||||
|
mode: 'overwrite',
|
||||||
|
frontmatter: {
|
||||||
|
type: 'logfire/session-log',
|
||||||
|
date: '{{date}}',
|
||||||
|
session: '{{sessionId}}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
id: 'overview',
|
||||||
|
heading: 'Session-Uebersicht',
|
||||||
|
type: 'summary',
|
||||||
|
query: {
|
||||||
|
timeRange: { type: 'session' },
|
||||||
|
},
|
||||||
|
format: {
|
||||||
|
type: 'builtin',
|
||||||
|
builtin: {
|
||||||
|
name: 'summary',
|
||||||
|
metrics: [
|
||||||
|
{ label: 'Events gesamt', aggregate: 'count' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'files',
|
||||||
|
heading: 'Bearbeitete Dateien',
|
||||||
|
type: 'table',
|
||||||
|
query: {
|
||||||
|
timeRange: { type: 'session' },
|
||||||
|
groupBy: 'file',
|
||||||
|
orderBy: 'count',
|
||||||
|
orderDirection: 'desc',
|
||||||
|
limit: 20,
|
||||||
|
},
|
||||||
|
format: {
|
||||||
|
type: 'builtin',
|
||||||
|
builtin: {
|
||||||
|
name: 'table',
|
||||||
|
columns: [
|
||||||
|
{ header: 'Datei', value: 'group' },
|
||||||
|
{ header: 'Events', value: 'count' },
|
||||||
|
{ header: 'Woerter+', value: 'words_added' },
|
||||||
|
{ header: 'Woerter-', value: 'words_removed' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'commands',
|
||||||
|
heading: 'Ausgefuehrte Befehle',
|
||||||
|
type: 'table',
|
||||||
|
query: {
|
||||||
|
timeRange: { type: 'session' },
|
||||||
|
eventTypes: ['plugin:command-executed'],
|
||||||
|
limit: 50,
|
||||||
|
},
|
||||||
|
format: {
|
||||||
|
type: 'builtin',
|
||||||
|
builtin: {
|
||||||
|
name: 'table',
|
||||||
|
columns: [
|
||||||
|
{ header: 'Zeit', value: 'timestamp' },
|
||||||
|
{ header: 'Befehl', value: 'source' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
105
src/projection/presets/weekly-digest.ts
Normal file
105
src/projection/presets/weekly-digest.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
import { ProjectionTemplate } from '../../types';
|
||||||
|
|
||||||
|
export const weeklyDigestTemplate: ProjectionTemplate = {
|
||||||
|
id: 'builtin:weekly-digest',
|
||||||
|
name: 'Wochen-Digest',
|
||||||
|
description: 'Wochenuebersicht: Tages-Summary, Top-Dateien, Schreib-Statistiken, Heatmap.',
|
||||||
|
enabled: false,
|
||||||
|
trigger: { type: 'manual' },
|
||||||
|
output: {
|
||||||
|
folder: '',
|
||||||
|
filePattern: '{{date}}-weekly-digest.md',
|
||||||
|
mode: 'overwrite',
|
||||||
|
frontmatter: {
|
||||||
|
type: 'logfire/weekly-digest',
|
||||||
|
date: '{{date}}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
id: 'daily-summary',
|
||||||
|
heading: 'Tages-Uebersicht',
|
||||||
|
type: 'table',
|
||||||
|
query: {
|
||||||
|
timeRange: { type: 'relative', value: 'last-7-days' },
|
||||||
|
groupBy: 'day',
|
||||||
|
orderBy: 'timestamp',
|
||||||
|
orderDirection: 'asc',
|
||||||
|
},
|
||||||
|
format: {
|
||||||
|
type: 'builtin',
|
||||||
|
builtin: {
|
||||||
|
name: 'table',
|
||||||
|
columns: [
|
||||||
|
{ header: 'Tag', value: 'group' },
|
||||||
|
{ header: 'Events', value: 'count' },
|
||||||
|
{ header: 'Woerter+', value: 'words_added' },
|
||||||
|
{ header: 'Woerter-', value: 'words_removed' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'top-files',
|
||||||
|
heading: 'Top-Dateien',
|
||||||
|
type: 'table',
|
||||||
|
query: {
|
||||||
|
timeRange: { type: 'relative', value: 'last-7-days' },
|
||||||
|
groupBy: 'file',
|
||||||
|
orderBy: 'count',
|
||||||
|
orderDirection: 'desc',
|
||||||
|
limit: 15,
|
||||||
|
},
|
||||||
|
format: {
|
||||||
|
type: 'builtin',
|
||||||
|
builtin: {
|
||||||
|
name: 'table',
|
||||||
|
columns: [
|
||||||
|
{ header: 'Datei', value: 'group' },
|
||||||
|
{ header: 'Events', value: 'count' },
|
||||||
|
{ header: 'Woerter+', value: 'words_added' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'write-stats',
|
||||||
|
heading: 'Schreib-Statistiken',
|
||||||
|
type: 'summary',
|
||||||
|
query: {
|
||||||
|
timeRange: { type: 'relative', value: 'last-7-days' },
|
||||||
|
groupBy: 'day',
|
||||||
|
},
|
||||||
|
format: {
|
||||||
|
type: 'builtin',
|
||||||
|
builtin: {
|
||||||
|
name: 'summary',
|
||||||
|
metrics: [
|
||||||
|
{ label: 'Tage aktiv', aggregate: 'count' },
|
||||||
|
{ label: 'Woerter geschrieben', aggregate: 'sum', field: 'words_added' },
|
||||||
|
{ label: 'Woerter geloescht', aggregate: 'sum', field: 'words_removed' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'activity-heatmap',
|
||||||
|
heading: 'Aktivitaets-Heatmap',
|
||||||
|
type: 'chart-data',
|
||||||
|
query: {
|
||||||
|
timeRange: { type: 'relative', value: 'last-7-days' },
|
||||||
|
groupBy: 'hour',
|
||||||
|
orderBy: 'timestamp',
|
||||||
|
orderDirection: 'asc',
|
||||||
|
},
|
||||||
|
format: {
|
||||||
|
type: 'builtin',
|
||||||
|
builtin: { name: 'heatmap', labelField: 'group', valueField: 'count' },
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
238
src/projection/projection-engine.ts
Normal file
238
src/projection/projection-engine.ts
Normal file
|
|
@ -0,0 +1,238 @@
|
||||||
|
import { App, Modal, Notice } from 'obsidian';
|
||||||
|
import { ProjectionTemplate, LogfireSettings } from '../types';
|
||||||
|
import { DatabaseManager } from '../core/database';
|
||||||
|
import { EventBus } from '../core/event-bus';
|
||||||
|
import { buildQuery } from '../core/query-builder';
|
||||||
|
import { ProjectionTemplateRegistry } from './template-registry';
|
||||||
|
import { formatSection, buildFrontmatter, resolvePlaceholders } from './formatters';
|
||||||
|
|
||||||
|
export class ProjectionEngine {
|
||||||
|
private registry: ProjectionTemplateRegistry;
|
||||||
|
private schedulerTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private sessionEndUnsub: (() => void) | null = null;
|
||||||
|
private lastDailyRun = '';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private app: App,
|
||||||
|
private db: DatabaseManager,
|
||||||
|
private eventBus: EventBus,
|
||||||
|
private settings: LogfireSettings,
|
||||||
|
) {
|
||||||
|
this.registry = new ProjectionTemplateRegistry(settings.projections.templates);
|
||||||
|
}
|
||||||
|
|
||||||
|
getRegistry(): ProjectionTemplateRegistry {
|
||||||
|
return this.registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Lifecycle
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
if (!this.settings.projections.enabled) return;
|
||||||
|
|
||||||
|
// Session-End-Listener fuer session-log
|
||||||
|
if (this.settings.projections.sessionLog.enabled) {
|
||||||
|
this.sessionEndUnsub = this.eventBus.onEvent('system:session-end', (event) => {
|
||||||
|
const templates = this.registry.getByTrigger('on-session-end');
|
||||||
|
for (const t of templates) {
|
||||||
|
this.runProjection(t, { sessionId: event.session });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scheduler fuer daily + weekly (prueft alle 60s)
|
||||||
|
this.schedulerTimer = setInterval(() => this.checkSchedule(), 60_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
if (this.schedulerTimer !== null) {
|
||||||
|
clearInterval(this.schedulerTimer);
|
||||||
|
this.schedulerTimer = null;
|
||||||
|
}
|
||||||
|
this.sessionEndUnsub?.();
|
||||||
|
this.sessionEndUnsub = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Schedule check
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private checkSchedule(): void {
|
||||||
|
const now = new Date();
|
||||||
|
const todayStr = now.toISOString().substring(0, 10);
|
||||||
|
const currentTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
// Daily Log
|
||||||
|
if (
|
||||||
|
this.settings.projections.dailyLog.enabled &&
|
||||||
|
currentTime === this.settings.projections.dailyLog.time &&
|
||||||
|
this.lastDailyRun !== todayStr
|
||||||
|
) {
|
||||||
|
this.lastDailyRun = todayStr;
|
||||||
|
const template = this.registry.getById('builtin:daily-log');
|
||||||
|
if (template) this.runProjection(template);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weekly Digest (dayOfWeek: 0=So, 1=Mo, ...)
|
||||||
|
if (
|
||||||
|
this.settings.projections.weeklyDigest.enabled &&
|
||||||
|
now.getDay() === this.settings.projections.weeklyDigest.dayOfWeek &&
|
||||||
|
currentTime === '08:00' &&
|
||||||
|
this.lastDailyRun !== `week-${todayStr}`
|
||||||
|
) {
|
||||||
|
this.lastDailyRun = `week-${todayStr}`;
|
||||||
|
const template = this.registry.getById('builtin:weekly-digest');
|
||||||
|
if (template) this.runProjection(template);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Run projection
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async runProjection(
|
||||||
|
template: ProjectionTemplate,
|
||||||
|
context?: { sessionId?: string },
|
||||||
|
): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const sections: string[] = [];
|
||||||
|
|
||||||
|
// Frontmatter
|
||||||
|
const fmData: Record<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(template.output.frontmatter)) {
|
||||||
|
fmData[key] = resolvePlaceholders(value, { date: new Date(), sessionId: context?.sessionId });
|
||||||
|
}
|
||||||
|
sections.push(buildFrontmatter(fmData));
|
||||||
|
|
||||||
|
// Title
|
||||||
|
sections.push(`# ${template.name}\n`);
|
||||||
|
|
||||||
|
// Sections
|
||||||
|
for (const section of template.sections) {
|
||||||
|
if (!section.enabled) continue;
|
||||||
|
|
||||||
|
const queryConfig = { ...section.query };
|
||||||
|
|
||||||
|
// Session-Kontext einsetzen
|
||||||
|
if (queryConfig.timeRange.type === 'session' && context?.sessionId) {
|
||||||
|
queryConfig.timeRange = { ...queryConfig.timeRange, sessionId: context.sessionId };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { sql, params } = buildQuery(queryConfig);
|
||||||
|
const rows = this.db.queryReadOnly(sql, params) as Record<string, unknown>[];
|
||||||
|
sections.push(formatSection(rows, section));
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = sections.join('\n');
|
||||||
|
const outputFolder = template.output.folder || this.settings.projections.outputFolder;
|
||||||
|
const fileName = resolvePlaceholders(template.output.filePattern, {
|
||||||
|
date: new Date(),
|
||||||
|
sessionId: context?.sessionId,
|
||||||
|
});
|
||||||
|
const filePath = `${outputFolder}/${fileName}`;
|
||||||
|
|
||||||
|
// Ordner erstellen falls noetig
|
||||||
|
await this.ensureFolder(outputFolder);
|
||||||
|
|
||||||
|
// Datei schreiben
|
||||||
|
const existing = this.app.vault.getAbstractFileByPath(filePath);
|
||||||
|
if (existing) {
|
||||||
|
if (template.output.mode === 'append') {
|
||||||
|
const current = await this.app.vault.read(existing as any);
|
||||||
|
await this.app.vault.modify(existing as any, current + '\n' + content);
|
||||||
|
} else {
|
||||||
|
await this.app.vault.modify(existing as any, content);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await this.app.vault.create(filePath, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filePath;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Logfire] Projektion fehlgeschlagen:', err);
|
||||||
|
new Notice(`Logfire: Projektion "${template.name}" fehlgeschlagen.`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async runAllProjections(): Promise<void> {
|
||||||
|
const templates = this.registry.getEnabled();
|
||||||
|
let count = 0;
|
||||||
|
for (const t of templates) {
|
||||||
|
if (t.trigger.type !== 'on-session-end') {
|
||||||
|
const result = await this.runProjection(t);
|
||||||
|
if (result) count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
new Notice(`Logfire: ${count} Projektion(en) ausgefuehrt.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureFolder(path: string): Promise<void> {
|
||||||
|
const parts = path.split('/');
|
||||||
|
let current = '';
|
||||||
|
for (const part of parts) {
|
||||||
|
current = current ? `${current}/${part}` : part;
|
||||||
|
if (!this.app.vault.getAbstractFileByPath(current)) {
|
||||||
|
await this.app.vault.createFolder(current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Projection Picker Modal
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export class ProjectionPickerModal extends Modal {
|
||||||
|
constructor(
|
||||||
|
app: App,
|
||||||
|
private engine: ProjectionEngine,
|
||||||
|
) {
|
||||||
|
super(app);
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpen(): void {
|
||||||
|
const { contentEl } = this;
|
||||||
|
contentEl.empty();
|
||||||
|
contentEl.addClass('logfire-projection-picker');
|
||||||
|
|
||||||
|
contentEl.createEl('h2', { text: 'Projektion ausfuehren' });
|
||||||
|
|
||||||
|
const list = contentEl.createDiv({ cls: 'logfire-template-list' });
|
||||||
|
const templates = this.engine.getRegistry().getAll();
|
||||||
|
|
||||||
|
for (const template of templates) {
|
||||||
|
const item = list.createDiv({ cls: 'logfire-template-item' });
|
||||||
|
|
||||||
|
const header = item.createDiv({ cls: 'logfire-template-header' });
|
||||||
|
header.createEl('span', { text: template.name, cls: 'logfire-template-name' });
|
||||||
|
|
||||||
|
if (template.id.startsWith('builtin:')) {
|
||||||
|
header.createEl('span', { text: 'BUILTIN', cls: 'logfire-template-badge' });
|
||||||
|
}
|
||||||
|
|
||||||
|
item.createEl('p', { text: template.description, cls: 'logfire-template-desc' });
|
||||||
|
|
||||||
|
const triggerInfo = item.createEl('p', {
|
||||||
|
text: `Trigger: ${template.trigger.type} | Output: ${template.output.filePattern}`,
|
||||||
|
cls: 'logfire-template-desc',
|
||||||
|
});
|
||||||
|
|
||||||
|
const actions = item.createDiv({ cls: 'logfire-template-actions' });
|
||||||
|
const runBtn = actions.createEl('button', { text: 'Ausfuehren', cls: 'mod-cta' });
|
||||||
|
runBtn.addEventListener('click', async () => {
|
||||||
|
const path = await this.engine.runProjection(template);
|
||||||
|
if (path) {
|
||||||
|
new Notice(`Projektion geschrieben: ${path}`);
|
||||||
|
}
|
||||||
|
this.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose(): void {
|
||||||
|
this.contentEl.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/projection/template-registry.ts
Normal file
54
src/projection/template-registry.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { ProjectionTemplate } from '../types';
|
||||||
|
import { dailyLogTemplate } from './presets/daily-log';
|
||||||
|
import { sessionLogTemplate } from './presets/session-log';
|
||||||
|
import { weeklyDigestTemplate } from './presets/weekly-digest';
|
||||||
|
|
||||||
|
const BUILTIN_TEMPLATES: ProjectionTemplate[] = [
|
||||||
|
dailyLogTemplate,
|
||||||
|
sessionLogTemplate,
|
||||||
|
weeklyDigestTemplate,
|
||||||
|
];
|
||||||
|
|
||||||
|
export class ProjectionTemplateRegistry {
|
||||||
|
private customTemplates: ProjectionTemplate[] = [];
|
||||||
|
|
||||||
|
constructor(customTemplates: ProjectionTemplate[] = []) {
|
||||||
|
this.customTemplates = customTemplates;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAll(): ProjectionTemplate[] {
|
||||||
|
return [...BUILTIN_TEMPLATES, ...this.customTemplates];
|
||||||
|
}
|
||||||
|
|
||||||
|
getBuiltin(): ProjectionTemplate[] {
|
||||||
|
return BUILTIN_TEMPLATES;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCustom(): ProjectionTemplate[] {
|
||||||
|
return this.customTemplates;
|
||||||
|
}
|
||||||
|
|
||||||
|
getById(id: string): ProjectionTemplate | undefined {
|
||||||
|
return this.getAll().find(t => t.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
getEnabled(): ProjectionTemplate[] {
|
||||||
|
return this.getAll().filter(t => t.enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
getByTrigger(type: ProjectionTemplate['trigger']['type']): ProjectionTemplate[] {
|
||||||
|
return this.getEnabled().filter(t => t.trigger.type === type);
|
||||||
|
}
|
||||||
|
|
||||||
|
addCustom(template: ProjectionTemplate): void {
|
||||||
|
this.customTemplates.push(template);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeCustom(id: string): void {
|
||||||
|
this.customTemplates = this.customTemplates.filter(t => t.id !== id);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCustomTemplates(templates: ProjectionTemplate[]): void {
|
||||||
|
this.customTemplates = templates;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/types.ts
13
src/types.ts
|
|
@ -180,7 +180,8 @@ export type BuiltinFormat =
|
||||||
| { name: 'timeline'; showTimestamp: boolean; showPayload: boolean }
|
| { name: 'timeline'; showTimestamp: boolean; showPayload: boolean }
|
||||||
| { name: 'table'; columns: ColumnDef[] }
|
| { name: 'table'; columns: ColumnDef[] }
|
||||||
| { name: 'summary'; metrics: MetricDef[] }
|
| { name: 'summary'; metrics: MetricDef[] }
|
||||||
| { name: 'metric'; aggregate: 'count' | 'sum' | 'avg' | 'min' | 'max'; field?: string };
|
| { name: 'metric'; aggregate: 'count' | 'sum' | 'avg' | 'min' | 'max'; field?: string }
|
||||||
|
| { name: 'heatmap'; labelField: string; valueField: string };
|
||||||
|
|
||||||
export interface ColumnDef {
|
export interface ColumnDef {
|
||||||
header: string;
|
header: string;
|
||||||
|
|
@ -219,6 +220,11 @@ export interface LogfireSettings {
|
||||||
};
|
};
|
||||||
|
|
||||||
projections: {
|
projections: {
|
||||||
|
enabled: boolean;
|
||||||
|
outputFolder: string;
|
||||||
|
dailyLog: { enabled: boolean; time: string };
|
||||||
|
sessionLog: { enabled: boolean };
|
||||||
|
weeklyDigest: { enabled: boolean; dayOfWeek: number };
|
||||||
templates: ProjectionTemplate[];
|
templates: ProjectionTemplate[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -265,6 +271,11 @@ export const DEFAULT_SETTINGS: LogfireSettings = {
|
||||||
},
|
},
|
||||||
|
|
||||||
projections: {
|
projections: {
|
||||||
|
enabled: false,
|
||||||
|
outputFolder: 'Logfire',
|
||||||
|
dailyLog: { enabled: false, time: '23:00' },
|
||||||
|
sessionLog: { enabled: false },
|
||||||
|
weeklyDigest: { enabled: false, dayOfWeek: 0 },
|
||||||
templates: [],
|
templates: [],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,79 @@ export class LogfireSettingTab extends PluginSettingTab {
|
||||||
await this.plugin.saveSettings();
|
await this.plugin.saveSettings();
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// ----- Projections -----
|
||||||
|
containerEl.createEl('h2', { text: 'Projektionen' });
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName('Projektionen aktiviert')
|
||||||
|
.setDesc('Automatische Markdown-Reports aus Event-Daten.')
|
||||||
|
.addToggle(t => t
|
||||||
|
.setValue(this.plugin.settings.projections.enabled)
|
||||||
|
.onChange(async (v) => {
|
||||||
|
this.plugin.settings.projections.enabled = v;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
}));
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName('Output-Ordner')
|
||||||
|
.setDesc('Ordner für generierte Projektions-Dateien.')
|
||||||
|
.addText(t => t
|
||||||
|
.setPlaceholder('Logfire')
|
||||||
|
.setValue(this.plugin.settings.projections.outputFolder)
|
||||||
|
.onChange(async (v) => {
|
||||||
|
this.plugin.settings.projections.outputFolder = v || 'Logfire';
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
}));
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName('Tagesprotokoll')
|
||||||
|
.setDesc('Automatisches Tagesprotokoll generieren.')
|
||||||
|
.addToggle(t => t
|
||||||
|
.setValue(this.plugin.settings.projections.dailyLog.enabled)
|
||||||
|
.onChange(async (v) => {
|
||||||
|
this.plugin.settings.projections.dailyLog.enabled = v;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
}))
|
||||||
|
.addText(t => t
|
||||||
|
.setPlaceholder('23:00')
|
||||||
|
.setValue(this.plugin.settings.projections.dailyLog.time)
|
||||||
|
.onChange(async (v) => {
|
||||||
|
if (/^\d{2}:\d{2}$/.test(v)) {
|
||||||
|
this.plugin.settings.projections.dailyLog.time = v;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName('Session-Protokoll')
|
||||||
|
.setDesc('Protokoll bei Session-Ende generieren.')
|
||||||
|
.addToggle(t => t
|
||||||
|
.setValue(this.plugin.settings.projections.sessionLog.enabled)
|
||||||
|
.onChange(async (v) => {
|
||||||
|
this.plugin.settings.projections.sessionLog.enabled = v;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
}));
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName('Wochen-Digest')
|
||||||
|
.setDesc('Wöchentliche Zusammenfassung generieren.')
|
||||||
|
.addToggle(t => t
|
||||||
|
.setValue(this.plugin.settings.projections.weeklyDigest.enabled)
|
||||||
|
.onChange(async (v) => {
|
||||||
|
this.plugin.settings.projections.weeklyDigest.enabled = v;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
}))
|
||||||
|
.addDropdown(d => d
|
||||||
|
.addOptions({
|
||||||
|
'0': 'Sonntag', '1': 'Montag', '2': 'Dienstag', '3': 'Mittwoch',
|
||||||
|
'4': 'Donnerstag', '5': 'Freitag', '6': 'Samstag',
|
||||||
|
})
|
||||||
|
.setValue(String(this.plugin.settings.projections.weeklyDigest.dayOfWeek))
|
||||||
|
.onChange(async (v) => {
|
||||||
|
this.plugin.settings.projections.weeklyDigest.dayOfWeek = parseInt(v, 10);
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
}));
|
||||||
|
|
||||||
// ----- Advanced (collapsible) -----
|
// ----- Advanced (collapsible) -----
|
||||||
const advancedHeader = containerEl.createEl('details');
|
const advancedHeader = containerEl.createEl('details');
|
||||||
advancedHeader.createEl('summary', { text: 'Erweiterte Einstellungen' })
|
advancedHeader.createEl('summary', { text: 'Erweiterte Einstellungen' })
|
||||||
|
|
|
||||||
13
styles.css
13
styles.css
|
|
@ -1197,3 +1197,16 @@
|
||||||
max-height: 120px;
|
max-height: 120px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
Projection Picker Modal
|
||||||
|
═══════════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.logfire-projection-picker h2 {
|
||||||
|
font-family: var(--font-monospace);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: var(--text-normal);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue