feat: Add unified logging infrastructure with web viewer

- Implement client-side logger that mirrors server interface
- Add /api/logs endpoints for client log submission and retrieval
- Create real-time log viewer component at /logs with filtering
- Update all client files to use new logging system
- Add responsive design for log viewer (mobile/desktop layouts)
- Implement smart auto-scroll that preserves reading position
- Add Mac-style auto-hiding scrollbars
- Configure Express to serve .html files with clean URLs
- Update spec.md with logging infrastructure documentation

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Mario Zechner 2025-06-23 00:05:43 +02:00
parent 04cfe992ee
commit 302063327e
17 changed files with 1059 additions and 98 deletions

View file

@ -0,0 +1,87 @@
# Frontend Logging Update Prompt
## Context
We've just implemented a structured logging system for the server-side code and created endpoints for frontend logging. Now we need to update all frontend components to use this system instead of console.log/error/warn.
## What's Already Done
1. **Server-side logging**: All server files now use the structured logger with proper log levels
2. **Log endpoints created**:
- `POST /api/logs/client` - Frontend can send logs to this endpoint
- The endpoint expects: `{ level: 'log'|'warn'|'error'|'debug', module: string, args: unknown[] }`
3. **Log viewer component**: Available at `/logs.html` to view all logs (both server and client)
4. **Style guide**: Created in `LOGGING_STYLE_GUIDE.md` with rules:
- No colors in error/warn
- Colors only in logger.log (green=success, yellow=warning, blue=info, gray=metadata)
- No prefixes or tags
- Lowercase start, no periods
- Always include error objects
## Your Task
Replace all `console.log`, `console.error`, and `console.warn` calls in frontend code (`src/client/`) with API calls to the logging endpoint.
### Step 1: Create a Frontend Logger Utility
Create `/src/client/utils/logger.ts` with:
```typescript
export function createLogger(moduleName: string) {
const sendLog = async (level: string, ...args: unknown[]) => {
try {
await fetch('/api/logs/client', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ level, module: moduleName, args })
});
} catch {
// Fallback to console on network error
console[level]?.(...args) || console.log(...args);
}
};
return {
log: (...args: unknown[]) => sendLog('log', ...args),
warn: (...args: unknown[]) => sendLog('warn', ...args),
error: (...args: unknown[]) => sendLog('error', ...args),
debug: (...args: unknown[]) => sendLog('debug', ...args)
};
}
```
### Step 2: Find All Console Calls
Use ripgrep to find all console.log/error/warn in frontend:
```bash
rg "console\.(log|error|warn)" src/client/ --type ts
```
### Step 3: Update Each File
For each file with console calls:
1. Import the logger: `import { createLogger } from '../utils/logger.js';`
2. Create logger instance: `const logger = createLogger('component-name');`
3. Replace console calls following the style guide
4. Remove superfluous logs (like "View transition ready")
5. Ensure essential logs use appropriate levels
### Step 4: Special Cases
- For error boundaries and critical failures, you may keep console.error as fallback
- WebSocket/SSE error handling should use logger but can keep console as fallback
- Remove all debug console.logs that were for development (like view transition logs)
### Step 5: Test
After updates, verify:
1. Navigate to `/logs.html`
2. Perform actions in the app
3. Confirm client logs appear with `CLIENT:` prefix
4. Check that log levels and messages follow the style guide
## Files to Update (based on previous analysis)
Key files that likely have console calls:
- `/src/client/app.ts`
- `/src/client/services/terminal-connection.ts`
- `/src/client/services/sse-client.ts`
- `/src/client/services/websocket-client.ts`
- `/src/client/components/*.ts` (all component files)
- Any other files in `/src/client/`
## Remember
- Module names should be descriptive (e.g., 'terminal-connection', 'session-list', 'app')
- Follow the same style guide as server logs (no prefixes, lowercase start)
- Essential logs only - remove debugging/development logs
- Include error objects when logging errors

View file

@ -112,6 +112,14 @@ web/
- `POST /api/remotes/register` (30-52): Remote registration
- `DELETE /api/remotes/:id` (55-69): Unregister remote
#### Logs (`logs.ts`)
- `POST /api/logs/client` (24-56): Client-side log submission
- Accepts: `{ level, module, args }`
- Prefixes module with `CLIENT:` for identification
- `GET /api/logs/raw` (59-76): Stream raw log file
- `GET /api/logs/info` (79-104): Log file metadata
- `DELETE /api/logs/clear` (107-121): Clear log file
### Binary Buffer Protocol
**Note**: "Buffer" refers to the current terminal display state (visible viewport) without scrollback history - just what's currently shown at the bottom of the terminal. This is used for rendering terminal previews in the session list.
@ -205,6 +213,23 @@ Monitors all terminal sessions for activity by watching `stream-out` file change
- Registration with HQ (29-58)
- Unregister on shutdown (60-72)
### Logging Infrastructure (`utils/logger.ts`)
#### Server Logger
- Unified logging with file and console output
- Log levels: log, warn, error, debug
- File output to `~/.vibetunnel/log.txt`
- Formatted timestamps and module names
- Debug mode toggle
- Style guide compliance (see LOGGING_STYLE_GUIDE.md)
#### Client Logger (`client/utils/logger.ts`)
- Mirrors server logger interface
- Logs to browser console
- Sends logs to `/api/logs/client` endpoint
- Objects formatted as JSON before sending
- Integrates with server logging system
## Client Architecture (`src/client/`)
### Core Components
@ -318,6 +343,22 @@ Modal file browser for navigating the filesystem and selecting files/directories
- `directory-selected` - When a directory is selected in 'select' mode (detail: string)
- `browser-cancel` - When the browser is cancelled or closed
##### `log-viewer.ts` - System log viewer (1-432)
Real-time log viewer with filtering and search capabilities.
- SSE-style polling every 2 seconds
- Client/server log distinction
- Log level filtering
- Relative timestamps
- Mobile-responsive layout
- Mac-style auto-hiding scrollbars
- **Features**:
- Filter by log level (error, warn, log, debug)
- Toggle client/server logs
- Search/filter by text
- Auto-scroll (smart - only when near bottom)
- Download logs
- Clear logs
##### Icon Components
- `vibe-logo.ts` - Application logo
- `terminal-icon.ts` - Terminal icon
@ -396,6 +437,7 @@ npx tsx src/fwd.ts [--session-id <id>] <command> [args...]
### Configuration
- Environment: `PORT`, `VIBETUNNEL_USERNAME`, `VIBETUNNEL_PASSWORD`
- CLI: `--port`, `--username`, `--password`, `--hq`, `--hq-url`, `--name`
- Express static: `.html` extension handling for clean URLs
### Protocols
- REST API: Session CRUD, terminal I/O, activity status
@ -419,6 +461,13 @@ Each session has a directory in `~/.vibetunnel/control/[sessionId]/` containing:
- Simplified fwd.ts - control pipe and stdin forwarding handled by PTY Manager
- Added proper TypeScript types throughout (removed all "as any" assertions)
- Cleaned up logging and added colorful output messages using chalk
- **Unified logging infrastructure**:
- Server-wide adoption of structured logger
- Client-side logger with server integration
- Centralized log viewer at `/logs`
- Consistent style guide (LOGGING_STYLE_GUIDE.md)
- **Express enhancements**:
- Auto `.html` extension resolution for static files
### Build System
- `npm run dev`: Auto-rebuilds TypeScript

View file

@ -5,6 +5,9 @@ import { keyed } from 'lit/directives/keyed.js';
// Import shared types
import type { Session } from '../shared/types.js';
// Import logger
import { createLogger } from './utils/logger.js';
// Import components
import './components/app-header.js';
import './components/session-create-form.js';
@ -12,9 +15,12 @@ import './components/session-list.js';
import './components/session-view.js';
import './components/session-card.js';
import './components/file-browser.js';
import './components/log-viewer.js';
import type { SessionCard } from './components/session-card.js';
const logger = createLogger('app');
@customElement('vibetunnel-app')
export class VibeTunnelApp extends LitElement {
// Disable shadow DOM to use Tailwind
@ -129,7 +135,7 @@ export class VibeTunnelApp extends LitElement {
this.showError('Failed to load sessions');
}
} catch (error) {
console.error('Error loading sessions:', error);
logger.error('error loading sessions:', error);
this.showError('Failed to load sessions');
} finally {
this.loading = false;
@ -189,12 +195,12 @@ export class VibeTunnelApp extends LitElement {
}
// If we get here, session creation might have failed
console.log('Session not found after all attempts');
logger.log('session not found after all attempts');
this.showError('Session created but could not be found. Please refresh.');
}
private handleSessionKilled(e: CustomEvent) {
console.log('Session killed:', e.detail);
logger.log(`session ${e.detail} killed`);
this.loadSessions(); // Refresh the list
}
@ -239,9 +245,9 @@ export class VibeTunnelApp extends LitElement {
// Check if View Transitions API is supported
if ('startViewTransition' in document && typeof document.startViewTransition === 'function') {
// Debug: Check what elements have view-transition-name before transition
console.log('Before transition - elements with view-transition-name:');
logger.debug('before transition - elements with view-transition-name:');
document.querySelectorAll('[style*="view-transition-name"]').forEach((el) => {
console.log('Element:', el, 'Style:', el.getAttribute('style'));
logger.debug('element:', el, 'style:', el.getAttribute('style'));
});
// Use View Transitions API for smooth animation
@ -255,19 +261,19 @@ export class VibeTunnelApp extends LitElement {
await this.updateComplete;
// Debug: Check what elements have view-transition-name after transition
console.log('After transition - elements with view-transition-name:');
logger.debug('after transition - elements with view-transition-name:');
document.querySelectorAll('[style*="view-transition-name"]').forEach((el) => {
console.log('Element:', el, 'Style:', el.getAttribute('style'));
logger.debug('element:', el, 'style:', el.getAttribute('style'));
});
});
// Log if transition is ready
transition.ready
.then(() => {
console.log('View transition ready');
logger.debug('view transition ready');
})
.catch((err) => {
console.error('View transition failed:', err);
logger.error('view transition failed:', err);
});
} else {
// Fallback for browsers without View Transitions support
@ -349,7 +355,7 @@ export class VibeTunnelApp extends LitElement {
const saved = localStorage.getItem('hideExitedSessions');
return saved !== null ? saved === 'true' : true; // Default to true if not set
} catch (error) {
console.error('Error loading hideExited state:', error);
logger.error('error loading hideExited state:', error);
return true; // Default to true on error
}
}
@ -358,7 +364,7 @@ export class VibeTunnelApp extends LitElement {
try {
localStorage.setItem('hideExitedSessions', String(value));
} catch (error) {
console.error('Error saving hideExited state:', error);
logger.error('error saving hideExited state:', error);
}
}
@ -416,7 +422,7 @@ export class VibeTunnelApp extends LitElement {
}
};
} catch (error) {
console.log('Error setting up hot reload:', error);
logger.log('error setting up hot reload:', error);
}
}
}

View file

@ -78,10 +78,14 @@ export class AppHeader extends LitElement {
<div class="flex flex-col gap-4 sm:hidden">
<!-- Centered VibeTunnel title with stats -->
<div class="text-center flex flex-col items-center gap-2">
<h1 class="text-2xl font-bold text-accent-green flex items-center gap-3 font-mono">
<a
href="/"
class="text-2xl font-bold text-accent-green flex items-center gap-3 font-mono hover:opacity-80 transition-opacity cursor-pointer group"
title="Go to home"
>
<terminal-icon size="28"></terminal-icon>
<span>VibeTunnel</span>
</h1>
<span class="group-hover:underline">VibeTunnel</span>
</a>
<p class="text-dark-text-muted text-sm font-mono">
${runningSessions.length} ${runningSessions.length === 1 ? 'session' : 'sessions'}
${exitedSessions.length > 0 ? `${exitedSessions.length} exited` : ''}
@ -153,16 +157,22 @@ export class AppHeader extends LitElement {
<!-- Desktop layout: single row -->
<div class="hidden sm:flex sm:items-center sm:justify-between">
<div class="flex items-center gap-3">
<a
href="/"
class="flex items-center gap-3 hover:opacity-80 transition-opacity cursor-pointer group"
title="Go to home"
>
<terminal-icon size="32"></terminal-icon>
<div>
<h1 class="text-xl font-bold text-accent-green font-mono">VibeTunnel</h1>
<h1 class="text-xl font-bold text-accent-green font-mono group-hover:underline">
VibeTunnel
</h1>
<p class="text-dark-text-muted text-sm font-mono">
${runningSessions.length} ${runningSessions.length === 1 ? 'session' : 'sessions'}
${exitedSessions.length > 0 ? `${exitedSessions.length} exited` : ''}
</p>
</div>
</div>
</a>
<div class="flex items-center gap-3">
${exitedSessions.length > 0
? html`

View file

@ -12,6 +12,9 @@
import { LitElement, html } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import type { Session } from './session-list.js';
import { createLogger } from '../utils/logger.js';
const logger = createLogger('file-browser');
interface FileInfo {
name: string;
@ -120,23 +123,23 @@ export class FileBrowser extends LitElement {
});
const url = `/api/fs/browse?${params}`;
console.log(`[FileBrowser] Loading directory: ${dirPath}`);
console.log(`[FileBrowser] Fetching URL: ${url}`);
logger.debug(`loading directory: ${dirPath}`);
logger.debug(`fetching URL: ${url}`);
const response = await fetch(url);
console.log(`[FileBrowser] Response status: ${response.status}`);
logger.debug(`response status: ${response.status}`);
if (response.ok) {
const data: DirectoryListing = await response.json();
console.log(`[FileBrowser] Received ${data.files?.length || 0} files`);
logger.debug(`received ${data.files?.length || 0} files`);
this.currentPath = data.path;
this.files = data.files || [];
this.gitStatus = data.gitStatus;
} else {
const errorData = await response.text();
console.error(`[FileBrowser] Failed to load directory: ${response.status}`, errorData);
logger.error(`failed to load directory: ${response.status}`, new Error(errorData));
}
} catch (error) {
console.error('[FileBrowser] Error loading directory:', error);
logger.error('error loading directory:', error);
} finally {
this.loading = false;
}
@ -150,8 +153,8 @@ export class FileBrowser extends LitElement {
this.showDiff = false;
try {
console.log('[FileBrowser] Loading preview for file:', file);
console.log('[FileBrowser] File path:', file.path);
logger.debug(`loading preview for file: ${file.name}`);
logger.debug(`file path: ${file.path}`);
const response = await fetch(`/api/fs/preview?path=${encodeURIComponent(file.path)}`);
if (response.ok) {
@ -161,10 +164,10 @@ export class FileBrowser extends LitElement {
this.updateMonacoContent();
}
} else {
console.error('[FileBrowser] Preview failed:', response.status, await response.text());
logger.error(`preview failed: ${response.status}`, new Error(await response.text()));
}
} catch (error) {
console.error('Error loading preview:', error);
logger.error('error loading preview:', error);
} finally {
this.previewLoading = false;
}
@ -182,7 +185,7 @@ export class FileBrowser extends LitElement {
this.diff = await response.json();
}
} catch (error) {
console.error('Error loading diff:', error);
logger.error('error loading diff:', error);
} finally {
this.previewLoading = false;
}
@ -223,9 +226,9 @@ export class FileBrowser extends LitElement {
private async copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text);
console.log('[FileBrowser] Copied to clipboard:', text);
logger.debug(`copied to clipboard: ${text}`);
} catch (err) {
console.error('[FileBrowser] Failed to copy to clipboard:', err);
logger.error('failed to copy to clipboard:', err);
}
}

View file

@ -0,0 +1,547 @@
import { LitElement, html, TemplateResult } from 'lit';
import { customElement, state } from 'lit/decorators.js';
interface LogEntry {
timestamp: string;
level: string;
module: string;
message: string;
isClient: boolean;
}
@customElement('log-viewer')
export class LogViewer extends LitElement {
// Disable shadow DOM to use Tailwind
createRenderRoot() {
return this;
}
@state() private logs: LogEntry[] = [];
@state() private loading = true;
@state() private error = '';
@state() private filter = '';
@state() private levelFilter: Set<string> = new Set(['error', 'warn', 'log', 'debug']);
@state() private autoScroll = true;
@state() private logSize = '';
@state() private showClient = true;
@state() private showServer = true;
private refreshInterval?: number;
private isFirstLoad = true;
override connectedCallback(): void {
super.connectedCallback();
this.loadLogs();
// Refresh logs every 2 seconds
this.refreshInterval = window.setInterval(() => this.loadLogs(), 2000);
}
override disconnectedCallback(): void {
super.disconnectedCallback();
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
}
}
private async loadLogs(): Promise<void> {
try {
// Get log info
const infoResponse = await fetch('/api/logs/info');
if (infoResponse.ok) {
const info = await infoResponse.json();
this.logSize = info.sizeHuman || '';
}
// Get raw logs
const response = await fetch('/api/logs/raw');
if (!response.ok) {
throw new Error('Failed to load logs');
}
const text = await response.text();
this.parseLogs(text);
this.loading = false;
// Auto-scroll to bottom if enabled and user is near bottom (or first load)
if (this.autoScroll) {
requestAnimationFrame(() => {
const container = this.querySelector('.log-container');
if (container) {
if (this.isFirstLoad) {
// Always scroll to bottom on first load
container.scrollTop = container.scrollHeight;
this.isFirstLoad = false;
} else {
// Only scroll if we're within 100px of the bottom
const isNearBottom =
container.scrollHeight - container.scrollTop - container.clientHeight < 100;
if (isNearBottom) {
container.scrollTop = container.scrollHeight;
}
}
}
});
}
} catch (err) {
this.error = err instanceof Error ? err.message : 'Failed to load logs';
this.loading = false;
}
}
private formatRelativeTime(timestamp: string): string {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
if (diffSec < 60) {
return `${diffSec}s ago`;
} else if (diffMin < 60) {
return `${diffMin}m ago`;
} else if (diffHour < 24) {
return `${diffHour}h ago`;
} else {
// For older logs, show HH:MM:SS
return date.toLocaleTimeString('en-US', { hour12: false });
}
}
private parseLogs(text: string): void {
const lines = text.split('\n');
const logs: LogEntry[] = [];
let currentLog: LogEntry | null = null;
for (const line of lines) {
if (!line.trim()) continue;
// Try to parse as a new log entry
const match = line.match(/^(\S+)\s+(\S+)\s+\[([^\]]+)\]\s+(.*)$/);
if (match) {
// If we have a current log, push it before starting a new one
if (currentLog) {
logs.push(currentLog);
}
const [, timestamp, level, module, message] = match;
const isClient = module.startsWith('CLIENT:');
currentLog = {
timestamp,
level: level.trim().toLowerCase(),
module: isClient ? module.substring(7) : module, // Remove CLIENT: prefix
message,
isClient,
};
} else if (currentLog) {
// This is a continuation line - append to the current log's message
currentLog.message += '\n' + line;
} else {
// Unparseable line with no current log - create a new entry
logs.push({
timestamp: '',
level: 'log',
module: 'unknown',
message: line,
isClient: false,
});
}
}
// Don't forget the last log
if (currentLog) {
logs.push(currentLog);
}
this.logs = logs;
}
private toggleLevel(level: string): void {
if (this.levelFilter.has(level)) {
this.levelFilter.delete(level);
} else {
this.levelFilter.add(level);
}
this.levelFilter = new Set(this.levelFilter); // Trigger re-render
}
private async clearLogs(): Promise<void> {
if (!confirm('Are you sure you want to clear all logs?')) {
return;
}
try {
const response = await fetch('/api/logs/clear', { method: 'DELETE' });
if (!response.ok) {
throw new Error('Failed to clear logs');
}
this.logs = [];
this.logSize = '0 Bytes';
} catch (err) {
this.error = err instanceof Error ? err.message : 'Failed to clear logs';
}
}
private async downloadLogs(): Promise<void> {
try {
const response = await fetch('/api/logs/raw');
if (!response.ok) {
throw new Error('Failed to download logs');
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `vibetunnel-logs-${new Date().toISOString().split('T')[0]}.txt`;
a.click();
URL.revokeObjectURL(url);
} catch (err) {
this.error = err instanceof Error ? err.message : 'Failed to download logs';
}
}
private get filteredLogs(): LogEntry[] {
return this.logs.filter((log) => {
// Filter by level
if (!this.levelFilter.has(log.level)) {
return false;
}
// Filter by client/server
if (!this.showClient && log.isClient) {
return false;
}
if (!this.showServer && !log.isClient) {
return false;
}
// Filter by search term
if (this.filter) {
const searchTerm = this.filter.toLowerCase();
return (
log.module.toLowerCase().includes(searchTerm) ||
log.message.toLowerCase().includes(searchTerm)
);
}
return true;
});
}
override render(): TemplateResult {
// Add custom scrollbar styles
const scrollbarStyles = html`
<style>
.log-container {
/* Hide scrollbar by default */
scrollbar-width: none; /* Firefox */
}
.log-container::-webkit-scrollbar {
width: 8px;
background: transparent;
}
.log-container::-webkit-scrollbar-track {
background: transparent;
}
.log-container::-webkit-scrollbar-thumb {
background: transparent;
border-radius: 4px;
}
/* Show scrollbar on hover */
.log-container:hover::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
}
.log-container::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
/* Firefox */
.log-container:hover {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
}
</style>
`;
if (this.loading) {
return html`
<div class="flex items-center justify-center h-screen bg-dark-bg text-dark-text">
<div class="text-center">
<div
class="animate-spin rounded-full h-12 w-12 border-4 border-accent-green border-t-transparent mb-4"
></div>
<div>Loading logs...</div>
</div>
</div>
`;
}
const levels = ['error', 'warn', 'log', 'debug'];
return html`
${scrollbarStyles}
<div class="flex flex-col h-full bg-dark-bg text-dark-text font-mono">
<!-- Header -->
<div
class="flex flex-col sm:flex-row items-start sm:items-center gap-3 p-4 bg-dark-bg-secondary border-b border-dark-border"
>
<h1 class="text-lg font-bold text-accent-green flex-1 flex items-center gap-2">
<terminal-icon size="24"></terminal-icon>
<span>System Logs</span>
</h1>
<div class="flex flex-wrap gap-2 items-center w-full sm:w-auto">
<!-- Search input -->
<input
type="text"
class="px-3 py-1.5 bg-dark-bg border border-dark-border rounded text-sm text-dark-text placeholder-dark-text-muted focus:outline-none focus:border-accent-green transition-colors flex-1 sm:flex-initial sm:w-64 md:w-80"
placeholder="Filter logs..."
.value=${this.filter}
@input=${(e: Event) => {
this.filter = (e.target as HTMLInputElement).value;
}}
/>
<!-- Level filters -->
<div class="flex gap-1">
${levels.map(
(level) => html`
<button
class="px-2 py-1 text-xs uppercase font-bold rounded transition-colors ${this.levelFilter.has(
level
)
? level === 'error'
? 'bg-status-error text-dark-bg'
: level === 'warn'
? 'bg-status-warning text-dark-bg'
: level === 'debug'
? 'bg-dark-text-muted text-dark-bg'
: 'bg-dark-text text-dark-bg'
: 'bg-dark-bg-tertiary text-dark-text-muted border border-dark-border'}"
@click=${() => this.toggleLevel(level)}
>
${level}
</button>
`
)}
</div>
<!-- Client/Server toggles -->
<div class="flex gap-1">
<button
class="px-2 py-1 text-xs uppercase font-bold rounded transition-colors ${this
.showClient
? 'bg-orange-500 text-dark-bg'
: 'bg-dark-bg-tertiary text-dark-text-muted border border-dark-border'}"
@click=${() => {
this.showClient = !this.showClient;
}}
>
CLIENT
</button>
<button
class="px-2 py-1 text-xs uppercase font-bold rounded transition-colors ${this
.showServer
? 'bg-accent-green text-dark-bg'
: 'bg-dark-bg-tertiary text-dark-text-muted border border-dark-border'}"
@click=${() => {
this.showServer = !this.showServer;
}}
>
SERVER
</button>
</div>
<!-- Auto-scroll toggle -->
<button
class="px-3 py-1 text-xs uppercase font-bold rounded transition-colors ${this
.autoScroll
? 'bg-accent-green text-dark-bg'
: 'bg-dark-bg-tertiary text-dark-text-muted border border-dark-border'}"
@click=${() => {
this.autoScroll = !this.autoScroll;
}}
>
AUTO SCROLL
</button>
</div>
</div>
<!-- Log container -->
<div
class="log-container flex-1 overflow-y-auto p-4 bg-dark-bg font-mono text-xs leading-relaxed"
>
${this.filteredLogs.length === 0
? html`
<div class="flex items-center justify-center h-full text-dark-text-muted">
<div class="text-center">
<div>No logs to display</div>
</div>
</div>
`
: this.filteredLogs.map((log) => {
const isMultiline = log.message.includes('\n');
const messageLines = log.message.split('\n');
return html`
<div
class="group hover:bg-dark-bg-secondary/50 transition-colors rounded ${log.isClient
? 'bg-orange-500/5 pl-2'
: 'pl-2'}"
>
<!-- Desktop layout (hidden on mobile) -->
<div class="hidden sm:flex items-start gap-2 py-0.5">
<!-- Timestamp -->
<span class="text-dark-text-muted w-16 flex-shrink-0 opacity-50"
>${this.formatRelativeTime(log.timestamp)}</span
>
<!-- Level -->
<span
class="w-10 text-center font-mono uppercase tracking-wider flex-shrink-0 ${log.level ===
'error'
? 'text-red-500 bg-red-500/20 px-1 rounded font-bold'
: log.level === 'warn'
? 'text-yellow-500 bg-yellow-500/20 px-1 rounded font-bold'
: log.level === 'debug'
? 'text-gray-600'
: 'text-gray-500'}"
>${log.level === 'error'
? 'ERR'
: log.level === 'warn'
? 'WRN'
: log.level === 'debug'
? 'DBG'
: 'LOG'}</span
>
<!-- Source indicator -->
<span
class="flex-shrink-0 ${log.isClient
? 'text-orange-400 font-bold'
: 'text-green-600'}"
>${log.isClient ? '◆ C' : '▸ S'}</span
>
<!-- Module -->
<span class="text-gray-600 flex-shrink-0 font-mono">${log.module}</span>
<!-- Separator -->
<span class="text-gray-700 flex-shrink-0"></span>
<!-- Message -->
<span
class="flex-1 ${log.level === 'error'
? 'text-red-400'
: log.level === 'warn'
? 'text-yellow-400'
: log.level === 'debug'
? 'text-gray-600'
: log.isClient
? 'text-orange-200'
: 'text-gray-300'}"
>${messageLines[0]}</span
>
</div>
<!-- Mobile layout (visible only on mobile) -->
<div class="sm:hidden py-1">
<div class="flex items-center gap-2 text-xs">
<span class="text-dark-text-muted opacity-50"
>${this.formatRelativeTime(log.timestamp)}</span
>
<span
class="${log.level === 'error'
? 'text-red-500 font-bold'
: log.level === 'warn'
? 'text-yellow-500 font-bold'
: log.level === 'debug'
? 'text-gray-600'
: 'text-gray-500'} uppercase"
>${log.level}</span
>
<span class="${log.isClient ? 'text-orange-400' : 'text-green-600'}"
>${log.isClient ? '[C]' : '[S]'}</span
>
<span class="text-gray-600">${log.module}</span>
</div>
<div
class="mt-1 ${log.level === 'error'
? 'text-red-400'
: log.level === 'warn'
? 'text-yellow-400'
: log.level === 'debug'
? 'text-gray-600'
: log.isClient
? 'text-orange-200'
: 'text-gray-300'}"
>
${messageLines[0]}
</div>
</div>
${isMultiline
? html`
<div
class="hidden sm:block ml-36 ${log.level === 'error'
? 'text-red-400'
: log.level === 'warn'
? 'text-yellow-400'
: 'text-gray-500'}"
>
${messageLines
.slice(1)
.map((line) => html`<div class="py-0.5">${line}</div>`)}
</div>
<div
class="sm:hidden mt-1 ${log.level === 'error'
? 'text-red-400'
: log.level === 'warn'
? 'text-yellow-400'
: 'text-gray-500'}"
>
${messageLines
.slice(1)
.map((line) => html`<div class="py-0.5">${line}</div>`)}
</div>
`
: ''}
</div>
`;
})}
</div>
<!-- Footer -->
<div
class="flex items-center justify-between p-3 bg-dark-bg-secondary border-t border-dark-border text-xs"
>
<div class="text-dark-text-muted">
${this.filteredLogs.length} / ${this.logs.length} logs
${this.logSize
? html` <span class="text-dark-text-muted">• ${this.logSize}</span>`
: ''}
</div>
<div class="flex gap-2">
<button
class="px-3 py-1 bg-dark-bg border border-dark-border rounded hover:border-accent-green hover:text-accent-green transition-colors"
@click=${this.downloadLogs}
>
Download
</button>
<button
class="px-3 py-1 bg-dark-bg border border-status-error text-status-error rounded hover:bg-status-error hover:text-dark-bg transition-colors"
@click=${this.clearLogs}
>
Clear
</button>
</div>
</div>
</div>
`;
}
}

View file

@ -13,6 +13,9 @@
import { LitElement, html } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import type { Session } from '../../shared/types.js';
import { createLogger } from '../utils/logger.js';
const logger = createLogger('session-card');
import './vibe-terminal-buffer.js';
import './copy-icon.js';
@ -105,7 +108,7 @@ export class SessionCard extends LitElement {
if (!response.ok) {
const errorData = await response.text();
console.error('Failed to kill session:', errorData);
logger.error('Failed to kill session', { errorData, sessionId: this.session.id });
throw new Error(`Kill failed: ${response.status}`);
}
@ -121,10 +124,10 @@ export class SessionCard extends LitElement {
})
);
console.log(`Session ${this.session.id} killed successfully`);
logger.log('Session killed successfully', { sessionId: this.session.id });
return true;
} catch (error) {
console.error('Error killing session:', error);
logger.error('Error killing session', { error, sessionId: this.session.id });
// Show error to user (keep animation to indicate something went wrong)
this.dispatchEvent(
@ -164,9 +167,9 @@ export class SessionCard extends LitElement {
if (this.session.pid) {
try {
await navigator.clipboard.writeText(this.session.pid.toString());
console.log('PID copied to clipboard:', this.session.pid);
logger.log('PID copied to clipboard', { pid: this.session.pid });
} catch (error) {
console.error('Failed to copy PID to clipboard:', error);
logger.error('Failed to copy PID to clipboard', { error, pid: this.session.pid });
// Fallback: select text manually
this.fallbackCopyToClipboard(this.session.pid.toString());
}
@ -181,9 +184,9 @@ export class SessionCard extends LitElement {
textArea.select();
try {
document.execCommand('copy');
console.log('Text copied to clipboard (fallback):', text);
logger.log('Text copied to clipboard (fallback)', { text });
} catch (error) {
console.error('Fallback copy failed:', error);
logger.error('Fallback copy failed', { error });
}
document.body.removeChild(textArea);
}
@ -194,9 +197,9 @@ export class SessionCard extends LitElement {
try {
await navigator.clipboard.writeText(this.session.workingDir);
console.log('Path copied to clipboard:', this.session.workingDir);
logger.log('Path copied to clipboard', { path: this.session.workingDir });
} catch (error) {
console.error('Failed to copy path to clipboard:', error);
logger.error('Failed to copy path to clipboard', { error, path: this.session.workingDir });
// Fallback: select text manually
this.fallbackCopyToClipboard(this.session.workingDir);
}

View file

@ -15,6 +15,9 @@ import { LitElement, html, PropertyValues } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import './file-browser.js';
import type { Session } from './session-list.js';
import { createLogger } from '../utils/logger.js';
const logger = createLogger('session-create-form');
export interface SessionCreateData {
command: string[];
@ -65,7 +68,9 @@ export class SessionCreateForm extends LitElement {
const savedWorkingDir = localStorage.getItem(this.STORAGE_KEY_WORKING_DIR);
const savedCommand = localStorage.getItem(this.STORAGE_KEY_COMMAND);
console.log('Loading from localStorage:', { savedWorkingDir, savedCommand });
logger.debug(
`loading from localStorage: workingDir=${savedWorkingDir}, command=${savedCommand}`
);
if (savedWorkingDir) {
this.workingDir = savedWorkingDir;
@ -76,8 +81,8 @@ export class SessionCreateForm extends LitElement {
// Force re-render to update the input values
this.requestUpdate();
} catch (error) {
console.warn('Failed to load from localStorage:', error);
} catch (_error) {
logger.warn('failed to load from localStorage');
}
}
@ -86,7 +91,7 @@ export class SessionCreateForm extends LitElement {
const workingDir = this.workingDir.trim();
const command = this.command.trim();
console.log('Saving to localStorage:', { workingDir, command });
logger.debug(`saving to localStorage: workingDir=${workingDir}, command=${command}`);
// Only save non-empty values
if (workingDir) {
@ -95,8 +100,8 @@ export class SessionCreateForm extends LitElement {
if (command) {
localStorage.setItem(this.STORAGE_KEY_COMMAND, command);
}
} catch (error) {
console.warn('Failed to save to localStorage:', error);
} catch (_error) {
logger.warn('failed to save to localStorage');
}
}
@ -203,7 +208,7 @@ export class SessionCreateForm extends LitElement {
);
}
} catch (error) {
console.error('Error creating session:', error);
logger.error('error creating session:', error);
this.dispatchEvent(
new CustomEvent('error', {
detail: 'Failed to create session',

View file

@ -22,6 +22,9 @@ import { repeat } from 'lit/directives/repeat.js';
import type { Session } from '../../shared/types.js';
import './session-create-form.js';
import './session-card.js';
import { createLogger } from '../utils/logger.js';
const logger = createLogger('session-list');
// Re-export Session type for backward compatibility
export type { Session };
@ -60,7 +63,7 @@ export class SessionList extends LitElement {
private handleSessionKilled(e: CustomEvent) {
const { sessionId } = e.detail;
console.log(`Session ${sessionId} killed, updating session list...`);
logger.debug(`session ${sessionId} killed, updating session list`);
// Immediately remove the session from the local state for instant UI feedback
this.sessions = this.sessions.filter((session) => session.id !== sessionId);
@ -71,7 +74,7 @@ export class SessionList extends LitElement {
private handleSessionKillError(e: CustomEvent) {
const { sessionId, error } = e.detail;
console.error(`Failed to kill session ${sessionId}:`, error);
logger.error(`failed to kill session ${sessionId}:`, error);
// Dispatch error event to parent for user notification
this.dispatchEvent(
@ -100,7 +103,7 @@ export class SessionList extends LitElement {
);
}
} catch (error) {
console.error('Error cleaning up exited sessions:', error);
logger.error('error cleaning up exited sessions:', error);
this.dispatchEvent(new CustomEvent('error', { detail: 'Failed to cleanup exited sessions' }));
} finally {
this.cleaningExited = false;

View file

@ -25,6 +25,9 @@ import {
TerminalPreferencesManager,
COMMON_TERMINAL_WIDTHS,
} from '../utils/terminal-preferences.js';
import { createLogger } from '../utils/logger.js';
const logger = createLogger('session-view');
@customElement('session-view')
export class SessionView extends LitElement {
@ -328,11 +331,11 @@ export class SessionView extends LitElement {
this.reconnectCount++;
lastErrorTime = now;
console.log(`Stream error #${this.reconnectCount} for session ${this.session?.id}`);
logger.log(`stream error #${this.reconnectCount} for session ${this.session?.id}`);
// If we've had too many reconnects, mark session as exited
if (this.reconnectCount >= reconnectThreshold) {
console.log(`Session ${this.session?.id} marked as exited due to excessive reconnections`);
logger.warn(`session ${this.session?.id} marked as exited due to excessive reconnections`);
if (this.session && this.session.status !== 'exited') {
this.session = { ...this.session, status: 'exited' };
@ -361,7 +364,7 @@ export class SessionView extends LitElement {
// Don't send input to exited sessions
if (this.session.status === 'exited') {
console.log('Ignoring keyboard input - session has exited');
logger.log('ignoring keyboard input - session has exited');
return;
}
@ -468,18 +471,18 @@ export class SessionView extends LitElement {
if (!response.ok) {
if (response.status === 400) {
console.log('Session no longer accepting input (likely exited)');
logger.log('session no longer accepting input (likely exited)');
// Update session status to exited if we get 400 error
if (this.session && (this.session.status as string) !== 'exited') {
this.session = { ...this.session, status: 'exited' };
this.requestUpdate();
}
} else {
console.error('Failed to send input to session:', response.status);
logger.error('failed to send input to session', { status: response.status });
}
}
} catch (error) {
console.error('Error sending input:', error);
logger.error('error sending input', error);
}
}
@ -495,7 +498,7 @@ export class SessionView extends LitElement {
private handleSessionExit(e: Event) {
const customEvent = e as CustomEvent;
console.log('Session exit event received:', customEvent.detail);
logger.log('session exit event received', customEvent.detail);
if (this.session && customEvent.detail.sessionId === this.session.id) {
// Update session status to exited
@ -531,7 +534,7 @@ export class SessionView extends LitElement {
}
});
} catch (error) {
console.error('Failed to load session snapshot:', error);
logger.error('failed to load session snapshot', error);
}
}
@ -551,15 +554,15 @@ export class SessionView extends LitElement {
this.resizeTimeout = window.setTimeout(async () => {
// Only send resize request if dimensions actually changed
if (cols === this.lastResizeWidth && rows === this.lastResizeHeight) {
console.log(`Skipping redundant resize request: ${cols}x${rows}`);
logger.debug(`skipping redundant resize request: ${cols}x${rows}`);
return;
}
// Send resize request to backend if session is active
if (this.session && this.session.status !== 'exited') {
try {
console.log(
`Sending resize request: ${cols}x${rows} (was ${this.lastResizeWidth}x${this.lastResizeHeight})`
logger.debug(
`sending resize request: ${cols}x${rows} (was ${this.lastResizeWidth}x${this.lastResizeHeight})`
);
const response = await fetch(`/api/sessions/${this.session.id}/resize`, {
@ -573,10 +576,10 @@ export class SessionView extends LitElement {
this.lastResizeWidth = cols;
this.lastResizeHeight = rows;
} else {
console.warn(`Failed to resize session: ${response.status}`);
logger.warn(`failed to resize session: ${response.status}`);
}
} catch (error) {
console.warn('Failed to send resize request:', error);
logger.warn('failed to send resize request', error);
}
}
}, 250) as unknown as number; // 250ms debounce delay
@ -737,7 +740,7 @@ export class SessionView extends LitElement {
// Refresh terminal scroll position after closing mobile input
this.refreshTerminalAfterMobileInput();
} catch (error) {
console.error('Error sending mobile input:', error);
logger.error('error sending mobile input', error);
// Don't hide the overlay if there was an error
}
}
@ -769,7 +772,7 @@ export class SessionView extends LitElement {
// Refresh terminal scroll position after closing mobile input
this.refreshTerminalAfterMobileInput();
} catch (error) {
console.error('Error sending mobile input:', error);
logger.error('error sending mobile input', error);
// Don't hide the overlay if there was an error
}
}
@ -889,7 +892,7 @@ export class SessionView extends LitElement {
// Send the path to the terminal
await this.sendInputText(escapedPath);
console.log(`[SessionView] Inserted ${type} path into terminal:`, escapedPath);
logger.log(`inserted ${type} path into terminal: ${escapedPath}`);
}
private async handleOpenInEditor(event: CustomEvent) {
@ -906,7 +909,7 @@ export class SessionView extends LitElement {
// Send the command to the terminal
await this.sendInputText(command + '\n');
console.log(`[SessionView] Opening file in editor:`, escapedPath);
logger.log(`opening file in editor: ${escapedPath}`);
}
private async sendInputText(text: string) {
@ -936,10 +939,10 @@ export class SessionView extends LitElement {
});
if (!response.ok) {
console.error('Failed to send input to session');
logger.error('failed to send input to session', { status: response.status });
}
} catch (error) {
console.error('Error sending input:', error);
logger.error('error sending input', error);
}
}

View file

@ -13,6 +13,9 @@ import { LitElement, html, PropertyValues } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { Terminal as XtermTerminal, IBufferLine, IBufferCell } from '@xterm/headless';
import { UrlHighlighter } from '../utils/url-highlighter.js';
import { createLogger } from '../utils/logger.js';
const logger = createLogger('terminal');
@customElement('vibe-terminal')
export class Terminal extends LitElement {
@ -163,7 +166,9 @@ export class Terminal extends LitElement {
this.container = this.querySelector('#terminal-container') as HTMLElement;
if (!this.container) {
throw new Error('Terminal container not found');
const error = new Error('Terminal container not found');
logger.error('terminal container not found', error);
throw error;
}
await this.setupTerminal();
@ -171,7 +176,8 @@ export class Terminal extends LitElement {
this.setupScrolling();
this.requestUpdate();
} catch (_error: unknown) {
} catch (error: unknown) {
logger.error('failed to initialize terminal:', error);
this.requestUpdate();
}
}
@ -236,7 +242,7 @@ export class Terminal extends LitElement {
// Set terminal size - don't call .open() to keep it headless
this.terminal.resize(this.cols, this.rows);
} catch (error) {
console.error('Failed to create terminal:', error);
logger.error('failed to create terminal:', error);
throw error;
}
}

View file

@ -1,4 +1,7 @@
import { BufferCell } from '../utils/terminal-renderer.js';
import { createLogger } from '../utils/logger.js';
const logger = createLogger('buffer-subscription-service');
interface BufferSnapshot {
cols: number;
@ -36,14 +39,14 @@ export class BufferSubscriptionService {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/buffers`;
console.log('[BufferSubscriptionService] Connecting to', wsUrl);
logger.log(`connecting to ${wsUrl}`);
try {
this.ws = new WebSocket(wsUrl);
this.ws.binaryType = 'arraybuffer';
this.ws.onopen = () => {
console.log('[BufferSubscriptionService] Connected');
logger.log('connected');
this.isConnecting = false;
this.reconnectAttempts = 0;
@ -69,18 +72,18 @@ export class BufferSubscriptionService {
};
this.ws.onerror = (error) => {
console.error('[BufferSubscriptionService] WebSocket error:', error);
logger.error('websocket error', error);
};
this.ws.onclose = () => {
console.log('[BufferSubscriptionService] Disconnected');
logger.log('disconnected');
this.isConnecting = false;
this.ws = null;
this.stopPingPong();
this.scheduleReconnect();
};
} catch (error) {
console.error('[BufferSubscriptionService] Failed to create WebSocket:', error);
logger.error('failed to create websocket', error);
this.isConnecting = false;
this.scheduleReconnect();
}
@ -92,9 +95,7 @@ export class BufferSubscriptionService {
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
this.reconnectAttempts++;
console.log(
`[BufferSubscriptionService] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`
);
logger.log(`reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
this.reconnectTimer = window.setTimeout(() => {
this.reconnectTimer = null;
@ -146,7 +147,7 @@ export class BufferSubscriptionService {
switch (message.type) {
case 'connected':
// Server confirmed connection, version info available in message.version
console.log('[BufferSubscriptionService] Connected to server, version:', message.version);
logger.log(`connected to server, version: ${message.version}`);
break;
case 'ping':
@ -154,14 +155,14 @@ export class BufferSubscriptionService {
break;
case 'error':
console.error('[BufferSubscriptionService] Server error:', message.message);
logger.error(`server error: ${message.message}`);
break;
default:
console.warn('[BufferSubscriptionService] Unknown message type:', message.type);
logger.warn(`unknown message type: ${message.type}`);
}
} catch (error) {
console.error('[BufferSubscriptionService] Failed to parse JSON message:', error);
logger.error('failed to parse JSON message', error);
}
}
@ -175,7 +176,7 @@ export class BufferSubscriptionService {
offset += 1;
if (magic !== BUFFER_MAGIC_BYTE) {
console.error('[BufferSubscriptionService] Invalid magic byte:', magic);
logger.error(`invalid magic byte: ${magic}`);
return;
}
@ -202,13 +203,13 @@ export class BufferSubscriptionService {
try {
handler(snapshot);
} catch (error) {
console.error('[BufferSubscriptionService] Error in update handler:', error);
logger.error('error in update handler', error);
}
});
}
});
} catch (error) {
console.error('[BufferSubscriptionService] Failed to parse binary message:', error);
logger.error('failed to parse binary message', error);
}
}

View file

@ -1,6 +1,10 @@
// Utility class to convert asciinema cast files to data for DOM terminal
// Converts cast format to string data that can be written via terminal.write()
import { createLogger } from './logger.js';
const logger = createLogger('cast-converter');
interface CastHeader {
version: number;
width: number;
@ -68,8 +72,8 @@ export class CastConverter {
outputChunks.push(event.data);
}
}
} catch (error) {
console.warn('Failed to parse cast line:', line, error);
} catch (_error) {
logger.warn('failed to parse cast line');
}
}
@ -398,26 +402,26 @@ export class CastConverter {
} else if (type === 'i') {
// Ignore 'i' (input) events - those are for sending to server, not displaying
} else {
console.error('Unknown stream message format:', data);
logger.error('unknown stream message format');
}
}
} catch (error) {
console.error('Failed to parse stream message:', event.data, error);
logger.error('failed to parse stream message:', error);
}
};
// Handle connection errors
eventSource.onerror = (error) => {
console.error('Stream connection error:', error);
logger.error('stream connection error:', error);
if (eventSource.readyState === EventSource.CLOSED) {
console.log('Stream connection closed');
logger.debug('stream connection closed');
}
};
// Handle connection open
eventSource.onopen = () => {
console.log('Stream connection established to:', streamUrl);
logger.debug(`stream connection established to: ${streamUrl}`);
};
return {

View file

@ -0,0 +1,88 @@
interface LogLevel {
log: 'log';
warn: 'warn';
error: 'error';
debug: 'debug';
}
type LogMethod = (...args: unknown[]) => void;
interface Logger {
log: LogMethod;
warn: LogMethod;
error: LogMethod;
debug: LogMethod;
}
let debugMode = false;
/**
* Enable or disable debug mode
*/
export function setDebugMode(enabled: boolean): void {
debugMode = enabled;
}
/**
* Format arguments for consistent logging
*/
function formatArgs(args: unknown[]): unknown[] {
return args.map((arg) => {
if (typeof arg === 'object' && arg !== null) {
try {
// Convert objects to formatted strings to match server logger behavior
return JSON.stringify(arg, null, 2);
} catch {
return String(arg);
}
}
return arg;
});
}
/**
* Send log to server endpoint
*/
async function sendToServer(level: keyof LogLevel, module: string, args: unknown[]): Promise<void> {
try {
await fetch('/api/logs/client', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
level,
module,
args: formatArgs(args),
}),
});
} catch {
// Silently ignore network errors to avoid infinite loops
}
}
/**
* Create a logger for a specific module
* This mirrors the server's createLogger interface
*/
export function createLogger(moduleName: string): Logger {
const createLogMethod = (level: keyof LogLevel): LogMethod => {
return (...args: unknown[]) => {
// Skip debug logs if debug mode is disabled
if (level === 'debug' && !debugMode) return;
// Log to browser console
console[level](`[${moduleName}]`, ...args);
// Send to server (fire and forget)
sendToServer(level, moduleName, args);
};
};
return {
log: createLogMethod('log'),
warn: createLogMethod('warn'),
error: createLogMethod('error'),
debug: createLogMethod('debug'),
};
}

View file

@ -3,6 +3,10 @@
* Handles saving and loading terminal-related user preferences
*/
import { createLogger } from './logger.js';
const logger = createLogger('terminal-preferences');
export interface TerminalPreferences {
maxCols: number; // 0 means no limit, positive numbers set max width
fontSize: number;
@ -51,7 +55,7 @@ export class TerminalPreferencesManager {
return { ...DEFAULT_PREFERENCES, ...parsed };
}
} catch (error) {
console.warn('Failed to load terminal preferences:', error);
logger.warn('Failed to load terminal preferences', { error });
}
return { ...DEFAULT_PREFERENCES };
}
@ -60,7 +64,7 @@ export class TerminalPreferencesManager {
try {
localStorage.setItem(STORAGE_KEY_TERMINAL_PREFS, JSON.stringify(this.preferences));
} catch (error) {
console.warn('Failed to save terminal preferences:', error);
logger.warn('Failed to save terminal preferences', { error });
}
}

View file

@ -0,0 +1,133 @@
import { Router, Request, Response } from 'express';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { logFromModule } from '../utils/logger.js';
import { createLogger } from '../utils/logger.js';
const logger = createLogger('logs');
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface LogRoutesConfig {
// Add any config if needed
}
interface ClientLogRequest {
level: 'log' | 'warn' | 'error' | 'debug';
module: string;
args: unknown[];
}
export function createLogRoutes(_config?: LogRoutesConfig): Router {
const router = Router();
// Client-side logging endpoint
router.post('/logs/client', (req: Request, res: Response) => {
try {
const { level, module, args } = req.body as ClientLogRequest;
// Validate input
if (!level || !module || !Array.isArray(args)) {
return res.status(400).json({
error: 'Invalid log request. Required: level, module, args[]',
});
}
// Validate level
if (!['log', 'warn', 'error', 'debug'].includes(level)) {
return res.status(400).json({
error: 'Invalid log level. Must be: log, warn, error, or debug',
});
}
// Add [CLIENT] prefix to module name to distinguish from server logs
const clientModule = `CLIENT:${module}`;
// Map client levels to server levels (uppercase)
const serverLevel = level.toUpperCase();
// Log to server log file via logFromModule
logFromModule(serverLevel === 'LOG' ? 'LOG' : serverLevel, clientModule, args);
res.json({ success: true });
} catch (error) {
logger.error('Failed to process client log:', error);
res.status(500).json({ error: 'Failed to process log' });
}
});
// Get raw log file
router.get('/logs/raw', (req: Request, res: Response) => {
try {
const logPath = path.join(os.homedir(), '.vibetunnel', 'log.txt');
// Check if log file exists
if (!fs.existsSync(logPath)) {
return res.status(404).json({ error: 'Log file not found' });
}
// Stream the log file
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
const stream = fs.createReadStream(logPath);
stream.pipe(res);
} catch (error) {
logger.error('Failed to read log file:', error);
res.status(500).json({ error: 'Failed to read log file' });
}
});
// Get log stats/info
router.get('/logs/info', (req: Request, res: Response) => {
try {
const logPath = path.join(os.homedir(), '.vibetunnel', 'log.txt');
if (!fs.existsSync(logPath)) {
return res.json({
exists: false,
size: 0,
path: logPath,
});
}
const stats = fs.statSync(logPath);
res.json({
exists: true,
size: stats.size,
sizeHuman: formatBytes(stats.size),
modified: stats.mtime,
path: logPath,
});
} catch (error) {
logger.error('Failed to get log info:', error);
res.status(500).json({ error: 'Failed to get log info' });
}
});
// Clear log file (for development/debugging)
router.delete('/logs/clear', (req: Request, res: Response) => {
try {
const logPath = path.join(os.homedir(), '.vibetunnel', 'log.txt');
if (fs.existsSync(logPath)) {
fs.truncateSync(logPath, 0);
logger.log('Log file cleared');
}
res.json({ success: true });
} catch (error) {
logger.error('Failed to clear log file:', error);
res.status(500).json({ error: 'Failed to clear log file' });
}
});
return router;
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}

View file

@ -14,6 +14,7 @@ import { createAuthMiddleware } from './middleware/auth.js';
import { createSessionRoutes } from './routes/sessions.js';
import { createRemoteRoutes } from './routes/remotes.js';
import { createFilesystemRoutes } from './routes/filesystem.js';
import { createLogRoutes } from './routes/logs.js';
import { ControlDirWatcher } from './services/control-dir-watcher.js';
import { BufferAggregator } from './services/buffer-aggregator.js';
import { ActivityMonitor } from './services/activity-monitor.js';
@ -357,9 +358,13 @@ export function createApp(): AppInstance {
app.use('/api', authMiddleware);
logger.debug('Applied authentication middleware to /api routes');
// Serve static files
// Serve static files with .html extension handling
const publicPath = path.join(process.cwd(), 'public');
app.use(express.static(publicPath));
app.use(
express.static(publicPath, {
extensions: ['html'], // This allows /logs to resolve to /logs.html
})
);
logger.debug(`Serving static files from: ${publicPath}`);
// Health check endpoint (no auth required)
@ -403,6 +408,10 @@ export function createApp(): AppInstance {
app.use('/api', createFilesystemRoutes());
logger.debug('Mounted filesystem routes');
// Mount log routes
app.use('/api', createLogRoutes());
logger.debug('Mounted log routes');
// WebSocket endpoint for buffer updates
wss.on('connection', (ws, _req) => {
if (bufferAggregator) {