feat: implement structured logging system with UI controls

Implements core logging infrastructure for task #15 (Housekeeping.md).
This is a foundational implementation that provides the backend and
settings UI. Full log viewer UI remains as future work.

## Changes

### Main Process
- Created `src/main/utils/logger.ts` with full logging system
  - Supports debug, info, warn, error log levels
  - Stores last 1000 log entries in memory
  - Filters based on configured minimum log level
  - Outputs to console with timestamps and context

### IPC Layer
- Added logger API to preload.ts with 5 operations:
  - `logger:log` - Send log entry from renderer
  - `logger:getLogs` - Retrieve filtered logs
  - `logger:clearLogs` - Clear log storage
  - `logger:setLogLevel` - Update minimum log level
  - `logger:getLogLevel` - Get current log level
- Added IPC handlers in main/index.ts
- Log level persists to electron-store (maestro-settings.json)

### Renderer Process
- Created `src/renderer/utils/logger.ts` for renderer logging
- Integrated with useSettings hook for persistence
- Added Log Level selector to Settings > General tab
  - Color-coded pills: Debug (indigo), Info (blue), Warn (amber), Error (red)
  - Setting persists across app restarts

## Remaining Work (Task #15)
- Create LogViewer component with search functionality
- Integrate LogViewer with Command-K menu
- Replace 33 console.log/error calls with new logger
- Add color-coded log display in viewer
- Implement `/` search in log viewer

## Testing
- Build verified successful
- No TypeScript errors
- Log level setting integrated with electron-store

Related: tmp/Housekeeping.md #15
This commit is contained in:
Pedram Amini
2025-11-23 23:32:47 -06:00
parent 68c4b032b1
commit 3347eadbf8
6 changed files with 314 additions and 0 deletions

View File

@@ -5,6 +5,7 @@ import { ProcessManager } from './process-manager';
import { WebServer } from './web-server';
import { AgentDetector } from './agent-detector';
import { execFileNoThrow } from './utils/execFile';
import { logger } from './utils/logger';
import Store from 'electron-store';
// Type definitions
@@ -20,6 +21,7 @@ interface MaestroSettings {
fontSize: number;
fontFamily: string;
customFonts: string[];
logLevel: 'debug' | 'info' | 'warn' | 'error';
}
const store = new Store<MaestroSettings>({
@@ -36,6 +38,7 @@ const store = new Store<MaestroSettings>({
fontSize: 14,
fontFamily: 'Roboto Mono, Menlo, "Courier New", monospace',
customFonts: [],
logLevel: 'info',
},
});
@@ -106,6 +109,10 @@ app.whenReady().then(() => {
webServer = new WebServer(8000);
agentDetector = new AgentDetector();
// Load logger settings
const logLevel = store.get('logLevel', 'info');
logger.setLogLevel(logLevel);
// Set up IPC handlers
setupIpcHandlers();
@@ -334,6 +341,48 @@ function setupIpcHandlers() {
}
}
});
// Logger operations
ipcMain.handle('logger:log', async (_event, level: string, message: string, context?: string, data?: unknown) => {
const logLevel = level as 'debug' | 'info' | 'warn' | 'error';
switch (logLevel) {
case 'debug':
logger.debug(message, context, data);
break;
case 'info':
logger.info(message, context, data);
break;
case 'warn':
logger.warn(message, context, data);
break;
case 'error':
logger.error(message, context, data);
break;
}
});
ipcMain.handle('logger:getLogs', async (_event, filter?: { level?: string; context?: string; limit?: number }) => {
const typedFilter = filter ? {
level: filter.level as 'debug' | 'info' | 'warn' | 'error' | undefined,
context: filter.context,
limit: filter.limit,
} : undefined;
return logger.getLogs(typedFilter);
});
ipcMain.handle('logger:clearLogs', async () => {
logger.clearLogs();
});
ipcMain.handle('logger:setLogLevel', async (_event, level: string) => {
const logLevel = level as 'debug' | 'info' | 'warn' | 'error';
logger.setLogLevel(logLevel);
store.set('logLevel', logLevel);
});
ipcMain.handle('logger:getLogLevel', async () => {
return logger.getLogLevel();
});
}
// Handle process output streaming (set up after initialization)

View File

@@ -106,6 +106,17 @@ contextBridge.exposeInMainWorld('maestro', {
close: () => ipcRenderer.invoke('devtools:close'),
toggle: () => ipcRenderer.invoke('devtools:toggle'),
},
// Logger API
logger: {
log: (level: string, message: string, context?: string, data?: unknown) =>
ipcRenderer.invoke('logger:log', level, message, context, data),
getLogs: (filter?: { level?: string; context?: string; limit?: number }) =>
ipcRenderer.invoke('logger:getLogs', filter),
clearLogs: () => ipcRenderer.invoke('logger:clearLogs'),
setLogLevel: (level: string) => ipcRenderer.invoke('logger:setLogLevel', level),
getLogLevel: () => ipcRenderer.invoke('logger:getLogLevel'),
},
});
// Type definitions for TypeScript
@@ -161,6 +172,19 @@ export interface MaestroAPI {
close: () => Promise<void>;
toggle: () => Promise<void>;
};
logger: {
log: (level: string, message: string, context?: string, data?: unknown) => Promise<void>;
getLogs: (filter?: { level?: string; context?: string; limit?: number }) => Promise<Array<{
timestamp: number;
level: string;
message: string;
context?: string;
data?: unknown;
}>>;
clearLogs: () => Promise<void>;
setLogLevel: (level: string) => Promise<void>;
getLogLevel: () => Promise<string>;
};
}
declare global {

138
src/main/utils/logger.ts Normal file
View File

@@ -0,0 +1,138 @@
/**
* Structured logging utility for the main process
* Logs are stored in memory and can be retrieved via IPC
*/
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
export interface LogEntry {
timestamp: number;
level: LogLevel;
message: string;
context?: string;
data?: unknown;
}
class Logger {
private logs: LogEntry[] = [];
private maxLogs = 1000; // Keep last 1000 log entries
private minLevel: LogLevel = 'info'; // Default log level
private levelPriority: Record<LogLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3,
};
setLogLevel(level: LogLevel): void {
this.minLevel = level;
}
getLogLevel(): LogLevel {
return this.minLevel;
}
private shouldLog(level: LogLevel): boolean {
return this.levelPriority[level] >= this.levelPriority[this.minLevel];
}
private addLog(entry: LogEntry): void {
this.logs.push(entry);
// Keep only the last maxLogs entries
if (this.logs.length > this.maxLogs) {
this.logs = this.logs.slice(-this.maxLogs);
}
// Also output to console for development
const timestamp = new Date(entry.timestamp).toISOString();
const prefix = `[${timestamp}] [${entry.level.toUpperCase()}]${entry.context ? ` [${entry.context}]` : ''}`;
const message = `${prefix} ${entry.message}`;
switch (entry.level) {
case 'error':
console.error(message, entry.data || '');
break;
case 'warn':
console.warn(message, entry.data || '');
break;
case 'info':
console.info(message, entry.data || '');
break;
case 'debug':
console.log(message, entry.data || '');
break;
}
}
debug(message: string, context?: string, data?: unknown): void {
if (!this.shouldLog('debug')) return;
this.addLog({
timestamp: Date.now(),
level: 'debug',
message,
context,
data,
});
}
info(message: string, context?: string, data?: unknown): void {
if (!this.shouldLog('info')) return;
this.addLog({
timestamp: Date.now(),
level: 'info',
message,
context,
data,
});
}
warn(message: string, context?: string, data?: unknown): void {
if (!this.shouldLog('warn')) return;
this.addLog({
timestamp: Date.now(),
level: 'warn',
message,
context,
data,
});
}
error(message: string, context?: string, data?: unknown): void {
if (!this.shouldLog('error')) return;
this.addLog({
timestamp: Date.now(),
level: 'error',
message,
context,
data,
});
}
getLogs(filter?: { level?: LogLevel; context?: string; limit?: number }): LogEntry[] {
let filtered = [...this.logs];
if (filter?.level) {
const minPriority = this.levelPriority[filter.level];
filtered = filtered.filter(log => this.levelPriority[log.level] >= minPriority);
}
if (filter?.context) {
filtered = filtered.filter(log => log.context === filter.context);
}
if (filter?.limit) {
filtered = filtered.slice(-filter.limit);
}
return filtered;
}
clearLogs(): void {
this.logs = [];
}
}
// Export singleton instance
export const logger = new Logger();

View File

@@ -27,6 +27,8 @@ interface SettingsModalProps {
setFontFamily: (font: string) => void;
fontSize: number;
setFontSize: (size: number) => void;
logLevel: string;
setLogLevel: (level: string) => void;
initialTab?: 'general' | 'llm' | 'shortcuts' | 'theme' | 'network';
}
@@ -596,6 +598,64 @@ export function SettingsModal(props: SettingsModalProps) {
</button>
</div>
</div>
{/* Log Level */}
<div>
<label className="block text-xs font-bold opacity-70 uppercase mb-2">System Log Level</label>
<div className="flex gap-2">
<button
onClick={() => props.setLogLevel('debug')}
className={`flex-1 py-2 px-3 rounded border transition-all ${props.logLevel === 'debug' ? 'ring-2' : ''}`}
style={{
borderColor: theme.colors.border,
backgroundColor: props.logLevel === 'debug' ? '#6366f1' : 'transparent',
ringColor: '#6366f1',
color: props.logLevel === 'debug' ? 'white' : theme.colors.textMain
}}
>
Debug
</button>
<button
onClick={() => props.setLogLevel('info')}
className={`flex-1 py-2 px-3 rounded border transition-all ${props.logLevel === 'info' ? 'ring-2' : ''}`}
style={{
borderColor: theme.colors.border,
backgroundColor: props.logLevel === 'info' ? '#3b82f6' : 'transparent',
ringColor: '#3b82f6',
color: props.logLevel === 'info' ? 'white' : theme.colors.textMain
}}
>
Info
</button>
<button
onClick={() => props.setLogLevel('warn')}
className={`flex-1 py-2 px-3 rounded border transition-all ${props.logLevel === 'warn' ? 'ring-2' : ''}`}
style={{
borderColor: theme.colors.border,
backgroundColor: props.logLevel === 'warn' ? '#f59e0b' : 'transparent',
ringColor: '#f59e0b',
color: props.logLevel === 'warn' ? 'white' : theme.colors.textMain
}}
>
Warn
</button>
<button
onClick={() => props.setLogLevel('error')}
className={`flex-1 py-2 px-3 rounded border transition-all ${props.logLevel === 'error' ? 'ring-2' : ''}`}
style={{
borderColor: theme.colors.border,
backgroundColor: props.logLevel === 'error' ? '#ef4444' : 'transparent',
ringColor: '#ef4444',
color: props.logLevel === 'error' ? 'white' : theme.colors.textMain
}}
>
Error
</button>
</div>
<p className="text-xs opacity-50 mt-2">
Higher levels show fewer logs. Debug shows all logs, Error shows only errors.
</p>
</div>
</div>
)}

View File

@@ -41,6 +41,10 @@ export interface UseSettingsReturn {
setRightPanelWidth: (value: number) => void;
setMarkdownRawMode: (value: boolean) => void;
// Logging settings
logLevel: string;
setLogLevel: (value: string) => void;
// Shortcuts
shortcuts: Record<string, Shortcut>;
setShortcuts: (value: Record<string, Shortcut>) => void;
@@ -71,6 +75,9 @@ export function useSettings(): UseSettingsReturn {
const [rightPanelWidth, setRightPanelWidthState] = useState(384);
const [markdownRawMode, setMarkdownRawModeState] = useState(false);
// Logging Config
const [logLevel, setLogLevelState] = useState('info');
// Shortcuts
const [shortcuts, setShortcutsState] = useState<Record<string, Shortcut>>(DEFAULT_SHORTCUTS);
@@ -150,6 +157,11 @@ export function useSettings(): UseSettingsReturn {
window.maestro.settings.set('shortcuts', value);
};
const setLogLevel = async (value: string) => {
setLogLevelState(value);
await window.maestro.logger.setLogLevel(value);
};
// Load settings from electron-store on mount
useEffect(() => {
const loadSettings = async () => {
@@ -168,6 +180,7 @@ export function useSettings(): UseSettingsReturn {
const savedMarkdownRawMode = await window.maestro.settings.get('markdownRawMode');
const savedShortcuts = await window.maestro.settings.get('shortcuts');
const savedActiveThemeId = await window.maestro.settings.get('activeThemeId');
const savedLogLevel = await window.maestro.logger.getLogLevel();
if (savedEnterToSend !== undefined) setEnterToSendState(savedEnterToSend);
if (savedLlmProvider !== undefined) setLlmProviderState(savedLlmProvider);
@@ -183,6 +196,7 @@ export function useSettings(): UseSettingsReturn {
if (savedRightPanelWidth !== undefined) setRightPanelWidthState(savedRightPanelWidth);
if (savedMarkdownRawMode !== undefined) setMarkdownRawModeState(savedMarkdownRawMode);
if (savedActiveThemeId !== undefined) setActiveThemeIdState(savedActiveThemeId);
if (savedLogLevel !== undefined) setLogLevelState(savedLogLevel);
// Merge saved shortcuts with defaults (in case new shortcuts were added)
if (savedShortcuts !== undefined) {
@@ -226,6 +240,8 @@ export function useSettings(): UseSettingsReturn {
setLeftSidebarWidth,
setRightPanelWidth,
setMarkdownRawMode,
logLevel,
setLogLevel,
shortcuts,
setShortcuts,
};

View File

@@ -0,0 +1,27 @@
/**
* Structured logging utility for the renderer process
* Sends logs to the main process via IPC
*/
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
class RendererLogger {
debug(message: string, context?: string, data?: unknown): void {
window.maestro?.logger?.log('debug', message, context, data);
}
info(message: string, context?: string, data?: unknown): void {
window.maestro?.logger?.log('info', message, context, data);
}
warn(message: string, context?: string, data?: unknown): void {
window.maestro?.logger?.log('warn', message, context, data);
}
error(message: string, context?: string, data?: unknown): void {
window.maestro?.logger?.log('error', message, context, data);
}
}
// Export singleton instance
export const logger = new RendererLogger();