mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-11 12:15:53 +00:00
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:
parent
04cfe992ee
commit
302063327e
17 changed files with 1059 additions and 98 deletions
87
web/FRONTEND_LOGGING_UPDATE_PROMPT.md
Normal file
87
web/FRONTEND_LOGGING_UPDATE_PROMPT.md
Normal 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
|
||||
49
web/spec.md
49
web/spec.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
547
web/src/client/components/log-viewer.ts
Normal file
547
web/src/client/components/log-viewer.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
88
web/src/client/utils/logger.ts
Normal file
88
web/src/client/utils/logger.ts
Normal 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'),
|
||||
};
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
133
web/src/server/routes/logs.ts
Normal file
133
web/src/server/routes/logs.ts
Normal 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];
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue