mirror of
https://github.com/davila7/claude-code-templates.git
synced 2026-09-19 01:30:23 +08:00
119bb1d0f6
Analytics Dashboard (Phase 1 + Phase 2): - Replace 8663-line vanilla JS monolith with Astro static build - New bento-grid layout with Geist design system (dark/light themes) - ECharts (offline, tree-shaken) replaces Chart.js CDN dependency - React island with KPI cards, token timeline, activity heatmap, agent BarList - WebSocket client with reconnect backoff; polling fallback when WS disconnects - API client with 30s cache; normalizes backend field names (inputTokens→totalInput) - Build source in analytics-ui/, output committed to src/analytics-web/ - Fix: /api/activity now uses in-memory cache (was re-scanning all files on each request) - Fix: remove duplicate /api/agents route (dead code since Express uses first match) - Fix: /api/data and /api/fast-update no longer mutate this.data.conversations in place TUI Modernization: - Add src/tui.js with @clack/prompts-powered banner and helpers - Replace inline showBanner() in bin/create-claude-config.js with tui.showBanner() - Migrate showMainMenu() in src/index.js to @clack select() (replaces inquirer) - Add @clack/prompts ^1.5.1 and gradient-string ^2.0.2 to dependencies Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2390 lines
81 KiB
JavaScript
2390 lines
81 KiB
JavaScript
const chalk = require('chalk');
|
|
const fs = require('fs-extra');
|
|
const path = require('path');
|
|
const express = require('express');
|
|
const open = require('open');
|
|
const os = require('os');
|
|
const inquirer = require('inquirer');
|
|
const boxen = require('boxen');
|
|
const { spawn } = require('child_process');
|
|
const packageJson = require('../package.json');
|
|
const StateCalculator = require('./analytics/core/StateCalculator');
|
|
const ProcessDetector = require('./analytics/core/ProcessDetector');
|
|
const ConversationAnalyzer = require('./analytics/core/ConversationAnalyzer');
|
|
const FileWatcher = require('./analytics/core/FileWatcher');
|
|
const SessionAnalyzer = require('./analytics/core/SessionAnalyzer');
|
|
const AgentAnalyzer = require('./analytics/core/AgentAnalyzer');
|
|
const YearInReview2025 = require('./analytics/core/YearInReview2025');
|
|
const DataCache = require('./analytics/data/DataCache');
|
|
const WebSocketServer = require('./analytics/notifications/WebSocketServer');
|
|
const NotificationManager = require('./analytics/notifications/NotificationManager');
|
|
const PerformanceMonitor = require('./analytics/utils/PerformanceMonitor');
|
|
const ConsoleBridge = require('./console-bridge');
|
|
const ClaudeAPIProxy = require('./claude-api-proxy');
|
|
|
|
class ClaudeAnalytics {
|
|
constructor(options = {}) {
|
|
this.options = options;
|
|
this.verbose = options.verbose || false;
|
|
this.app = express();
|
|
this.port = 3333;
|
|
this.stateCalculator = new StateCalculator();
|
|
this.processDetector = new ProcessDetector();
|
|
this.fileWatcher = new FileWatcher();
|
|
this.sessionAnalyzer = new SessionAnalyzer();
|
|
this.agentAnalyzer = new AgentAnalyzer();
|
|
this.yearInReview2025 = new YearInReview2025();
|
|
this.dataCache = new DataCache();
|
|
this.performanceMonitor = new PerformanceMonitor({
|
|
enabled: true,
|
|
logInterval: 60000,
|
|
memoryThreshold: 300 * 1024 * 1024 // 300MB - more realistic for analytics dashboard
|
|
});
|
|
this.webSocketServer = null;
|
|
this.notificationManager = null;
|
|
this.httpServer = null;
|
|
this.consoleBridge = null;
|
|
this.cloudflareProcess = null;
|
|
this.publicUrl = null;
|
|
this.claudeApiProxy = null;
|
|
this.data = {
|
|
conversations: [],
|
|
summary: {},
|
|
activeProjects: [],
|
|
realtimeStats: {
|
|
totalConversations: 0,
|
|
totalTokens: 0,
|
|
activeProjects: 0,
|
|
lastActivity: null,
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Log messages only if verbose mode is enabled
|
|
* @param {string} level - Log level ('info', 'warn', 'error')
|
|
* @param {string} message - Message to log
|
|
* @param {...any} args - Additional arguments
|
|
*/
|
|
log(level, message, ...args) {
|
|
if (!this.verbose) return;
|
|
|
|
switch (level) {
|
|
case 'error':
|
|
console.error(message, ...args);
|
|
break;
|
|
case 'warn':
|
|
console.warn(message, ...args);
|
|
break;
|
|
case 'info':
|
|
default:
|
|
console.log(message, ...args);
|
|
break;
|
|
}
|
|
}
|
|
|
|
async initialize() {
|
|
const homeDir = os.homedir();
|
|
this.claudeDir = path.join(homeDir, '.claude');
|
|
this.claudeDesktopDir = path.join(homeDir, 'Library', 'Application Support', 'Claude');
|
|
this.claudeStatsigDir = path.join(this.claudeDir, 'statsig');
|
|
|
|
// Check if Claude directories exist
|
|
if (!(await fs.pathExists(this.claudeDir))) {
|
|
throw new Error(`Claude Code directory not found at ${this.claudeDir}`);
|
|
}
|
|
|
|
// Initialize conversation analyzer with Claude directory and cache
|
|
this.conversationAnalyzer = new ConversationAnalyzer(this.claudeDir, this.dataCache);
|
|
|
|
await this.loadInitialData();
|
|
this.setupFileWatchers();
|
|
this.setupWebServer();
|
|
}
|
|
|
|
async loadInitialData() {
|
|
try {
|
|
// Store previous data for comparison
|
|
const previousData = this.data;
|
|
|
|
// Use ConversationAnalyzer to load and analyze all data
|
|
const analyzedData = await this.conversationAnalyzer.loadInitialData(
|
|
this.stateCalculator,
|
|
this.processDetector
|
|
);
|
|
|
|
// Update our data structure with analyzed data
|
|
this.data = analyzedData;
|
|
|
|
// Get Claude session information
|
|
const claudeSessionInfo = await this.getClaudeSessionInfo();
|
|
|
|
// Analyze session data for Max plan usage tracking with real Claude session info
|
|
this.data.sessionData = this.sessionAnalyzer.analyzeSessionData(this.data.conversations, claudeSessionInfo);
|
|
|
|
// Send real-time notifications if WebSocket is available
|
|
if (this.notificationManager) {
|
|
this.notificationManager.notifyDataRefresh(this.data, 'data_refresh');
|
|
|
|
// Check for conversation state changes
|
|
this.detectAndNotifyStateChanges(previousData, this.data);
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error(chalk.red('Error loading Claude data:'), error.message);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
|
|
async loadActiveProjects() {
|
|
const projects = [];
|
|
|
|
try {
|
|
const files = await fs.readdir(this.claudeDir);
|
|
|
|
for (const file of files) {
|
|
const filePath = path.join(this.claudeDir, file);
|
|
const stats = await fs.stat(filePath);
|
|
|
|
if (stats.isDirectory() && !file.startsWith('.')) {
|
|
const projectPath = filePath;
|
|
const todoFiles = await this.findTodoFiles(projectPath);
|
|
|
|
const project = {
|
|
name: file,
|
|
path: projectPath,
|
|
lastActivity: stats.mtime,
|
|
todoFiles: todoFiles.length,
|
|
status: this.determineProjectStatus(stats.mtime),
|
|
};
|
|
|
|
projects.push(project);
|
|
}
|
|
}
|
|
|
|
return projects.sort((a, b) => b.lastActivity - a.lastActivity);
|
|
} catch (error) {
|
|
console.error(chalk.red('Error loading projects:'), error.message);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async findTodoFiles(projectPath) {
|
|
try {
|
|
const files = await fs.readdir(projectPath);
|
|
return files.filter(file => file.includes('todo') || file.includes('TODO'));
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
estimateTokens(text) {
|
|
// Simple token estimation (roughly 4 characters per token)
|
|
return Math.ceil(text.length / 4);
|
|
}
|
|
|
|
calculateRealTokenUsage(parsedMessages) {
|
|
let totalInputTokens = 0;
|
|
let totalOutputTokens = 0;
|
|
let totalCacheCreationTokens = 0;
|
|
let totalCacheReadTokens = 0;
|
|
let messagesWithUsage = 0;
|
|
|
|
parsedMessages.forEach(message => {
|
|
if (message.usage) {
|
|
totalInputTokens += message.usage.input_tokens || 0;
|
|
totalOutputTokens += message.usage.output_tokens || 0;
|
|
totalCacheCreationTokens += message.usage.cache_creation_input_tokens || 0;
|
|
totalCacheReadTokens += message.usage.cache_read_input_tokens || 0;
|
|
messagesWithUsage++;
|
|
}
|
|
});
|
|
|
|
return {
|
|
total: totalInputTokens + totalOutputTokens + totalCacheCreationTokens + totalCacheReadTokens,
|
|
inputTokens: totalInputTokens,
|
|
outputTokens: totalOutputTokens,
|
|
cacheCreationTokens: totalCacheCreationTokens,
|
|
cacheReadTokens: totalCacheReadTokens,
|
|
messagesWithUsage: messagesWithUsage,
|
|
totalMessages: parsedMessages.length,
|
|
};
|
|
}
|
|
|
|
calculateDetailedTokenUsage() {
|
|
if (!this.data || !this.data.conversations) {
|
|
return null;
|
|
}
|
|
|
|
let totalInputTokens = 0;
|
|
let totalOutputTokens = 0;
|
|
let totalCacheCreationTokens = 0;
|
|
let totalCacheReadTokens = 0;
|
|
let totalMessages = 0;
|
|
let messagesWithUsage = 0;
|
|
|
|
this.data.conversations.forEach(conversation => {
|
|
if (conversation.tokenUsage) {
|
|
totalInputTokens += conversation.tokenUsage.inputTokens || 0;
|
|
totalOutputTokens += conversation.tokenUsage.outputTokens || 0;
|
|
totalCacheCreationTokens += conversation.tokenUsage.cacheCreationTokens || 0;
|
|
totalCacheReadTokens += conversation.tokenUsage.cacheReadTokens || 0;
|
|
messagesWithUsage += conversation.tokenUsage.messagesWithUsage || 0;
|
|
totalMessages += conversation.tokenUsage.totalMessages || 0;
|
|
}
|
|
});
|
|
|
|
const total = totalInputTokens + totalOutputTokens + totalCacheCreationTokens + totalCacheReadTokens;
|
|
|
|
return {
|
|
total,
|
|
inputTokens: totalInputTokens,
|
|
outputTokens: totalOutputTokens,
|
|
cacheCreationTokens: totalCacheCreationTokens,
|
|
cacheReadTokens: totalCacheReadTokens,
|
|
messagesWithUsage,
|
|
totalMessages
|
|
};
|
|
}
|
|
|
|
extractModelInfo(parsedMessages) {
|
|
const models = new Set();
|
|
const serviceTiers = new Set();
|
|
let lastModel = null;
|
|
let lastServiceTier = null;
|
|
|
|
parsedMessages.forEach(message => {
|
|
if (message.model) {
|
|
models.add(message.model);
|
|
lastModel = message.model;
|
|
}
|
|
if (message.usage && message.usage.service_tier) {
|
|
serviceTiers.add(message.usage.service_tier);
|
|
lastServiceTier = message.usage.service_tier;
|
|
}
|
|
});
|
|
|
|
return {
|
|
models: Array.from(models),
|
|
primaryModel: lastModel || models.values().next().value || 'Unknown',
|
|
serviceTiers: Array.from(serviceTiers),
|
|
currentServiceTier: lastServiceTier || serviceTiers.values().next().value || 'Unknown',
|
|
hasMultipleModels: models.size > 1,
|
|
};
|
|
}
|
|
|
|
async extractProjectFromPath(filePath) {
|
|
// First try to read cwd from the conversation file itself
|
|
try {
|
|
const content = await fs.readFile(filePath, 'utf8');
|
|
const lines = content.trim().split('\n').filter(line => line.trim());
|
|
|
|
for (const line of lines.slice(0, 10)) { // Check first 10 lines
|
|
try {
|
|
const item = JSON.parse(line);
|
|
|
|
// Look for cwd field in the message
|
|
if (item.cwd) {
|
|
return path.basename(item.cwd);
|
|
}
|
|
|
|
// Also check if it's in nested objects
|
|
if (item.message && item.message.cwd) {
|
|
return path.basename(item.message.cwd);
|
|
}
|
|
} catch (parseError) {
|
|
// Skip invalid JSON lines
|
|
continue;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.warn(chalk.yellow(`Warning: Could not extract project from conversation ${filePath}:`, error.message));
|
|
}
|
|
|
|
// Fallback: Extract project name from file path like:
|
|
// /Users/user/.claude/projects/-Users-user-Projects-MyProject/conversation.jsonl
|
|
const pathParts = filePath.split('/');
|
|
const projectIndex = pathParts.findIndex(part => part === 'projects');
|
|
|
|
if (projectIndex !== -1 && projectIndex + 1 < pathParts.length) {
|
|
const projectDir = pathParts[projectIndex + 1];
|
|
// Clean up the project directory name
|
|
const cleanName = projectDir
|
|
.replace(/^-/, '')
|
|
.replace(/-/g, '/')
|
|
.split('/')
|
|
.pop() || 'Unknown';
|
|
|
|
return cleanName;
|
|
}
|
|
|
|
return 'Unknown';
|
|
}
|
|
|
|
|
|
|
|
|
|
extractProjectFromConversation(messages) {
|
|
// Try to extract project information from conversation
|
|
for (const message of messages.slice(0, 5)) {
|
|
if (message.content && typeof message.content === 'string') {
|
|
const pathMatch = message.content.match(/\/([^\/\s]+)$/);
|
|
if (pathMatch) {
|
|
return pathMatch[1];
|
|
}
|
|
}
|
|
}
|
|
return 'Unknown';
|
|
}
|
|
|
|
|
|
|
|
generateStatusSquares(messages) {
|
|
if (!messages || messages.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
// Sort messages by timestamp and take last 10 for status squares
|
|
const sortedMessages = messages.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
|
|
const recentMessages = sortedMessages.slice(-10);
|
|
|
|
return recentMessages.map((message, index) => {
|
|
const messageNum = sortedMessages.length - recentMessages.length + index + 1;
|
|
|
|
// Determine status based on message content and role
|
|
if (message.role === 'user') {
|
|
return {
|
|
type: 'pending',
|
|
tooltip: `Message #${messageNum}: User input`,
|
|
};
|
|
} else if (message.role === 'assistant') {
|
|
// Check if the message contains tool usage or errors
|
|
const content = message.content || '';
|
|
|
|
if (typeof content === 'string') {
|
|
if (content.includes('[Tool:') || content.includes('tool_use')) {
|
|
return {
|
|
type: 'tool',
|
|
tooltip: `Message #${messageNum}: Tool execution`,
|
|
};
|
|
} else if (content.includes('error') || content.includes('Error') || content.includes('failed')) {
|
|
return {
|
|
type: 'error',
|
|
tooltip: `Message #${messageNum}: Error in response`,
|
|
};
|
|
} else {
|
|
return {
|
|
type: 'success',
|
|
tooltip: `Message #${messageNum}: Successful response`,
|
|
};
|
|
}
|
|
} else if (Array.isArray(content)) {
|
|
// Check for tool_use blocks in array content
|
|
const hasToolUse = content.some(block => block.type === 'tool_use');
|
|
const hasError = content.some(block =>
|
|
block.type === 'text' && (block.text ?.includes('error') || block.text ?.includes('Error'))
|
|
);
|
|
|
|
if (hasError) {
|
|
return {
|
|
type: 'error',
|
|
tooltip: `Message #${messageNum}: Error in response`,
|
|
};
|
|
} else if (hasToolUse) {
|
|
return {
|
|
type: 'tool',
|
|
tooltip: `Message #${messageNum}: Tool execution`,
|
|
};
|
|
} else {
|
|
return {
|
|
type: 'success',
|
|
tooltip: `Message #${messageNum}: Successful response`,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
type: 'pending',
|
|
tooltip: `Message #${messageNum}: Unknown status`,
|
|
};
|
|
});
|
|
}
|
|
|
|
determineProjectStatus(lastActivity) {
|
|
const now = new Date();
|
|
const timeDiff = now - lastActivity;
|
|
const hoursAgo = timeDiff / (1000 * 60 * 60);
|
|
|
|
if (hoursAgo < 1) return 'active';
|
|
if (hoursAgo < 24) return 'recent';
|
|
return 'inactive';
|
|
}
|
|
|
|
calculateSummary(conversations, projects) {
|
|
const totalTokens = conversations.reduce((sum, conv) => sum + conv.tokens, 0);
|
|
const totalConversations = conversations.length;
|
|
const activeConversations = conversations.filter(c => c.status === 'active').length;
|
|
const activeProjects = projects.filter(p => p.status === 'active').length;
|
|
|
|
const avgTokensPerConversation = totalConversations > 0 ? Math.round(totalTokens / totalConversations) : 0;
|
|
const totalFileSize = conversations.reduce((sum, conv) => sum + conv.fileSize, 0);
|
|
|
|
// Calculate real Claude sessions (5-hour periods)
|
|
const claudeSessions = this.calculateClaudeSessions(conversations);
|
|
|
|
return {
|
|
totalConversations,
|
|
totalTokens,
|
|
activeConversations,
|
|
activeProjects,
|
|
avgTokensPerConversation,
|
|
totalFileSize: this.formatBytes(totalFileSize),
|
|
lastActivity: conversations.length > 0 ? conversations[0].lastModified : null,
|
|
claudeSessions,
|
|
};
|
|
}
|
|
|
|
calculateClaudeSessions(conversations) {
|
|
// Collect all message timestamps across all conversations
|
|
const allMessages = [];
|
|
|
|
conversations.forEach(conv => {
|
|
// Parse the conversation file to get message timestamps
|
|
try {
|
|
const fs = require('fs-extra');
|
|
const content = fs.readFileSync(conv.filePath, 'utf8');
|
|
const lines = content.trim().split('\n').filter(line => line.trim());
|
|
|
|
lines.forEach(line => {
|
|
try {
|
|
const item = JSON.parse(line);
|
|
if (item.timestamp && item.message && item.message.role === 'user') {
|
|
// Only count user messages as session starters
|
|
allMessages.push({
|
|
timestamp: new Date(item.timestamp),
|
|
conversationId: conv.id,
|
|
});
|
|
}
|
|
} catch {}
|
|
});
|
|
} catch {}
|
|
});
|
|
|
|
if (allMessages.length === 0) return {
|
|
total: 0,
|
|
currentMonth: 0,
|
|
thisWeek: 0
|
|
};
|
|
|
|
// Sort messages by timestamp
|
|
allMessages.sort((a, b) => a.timestamp - b.timestamp);
|
|
|
|
// Calculate sessions (5-hour periods)
|
|
const sessions = [];
|
|
let currentSession = null;
|
|
|
|
allMessages.forEach(message => {
|
|
if (!currentSession) {
|
|
// Start first session
|
|
currentSession = {
|
|
start: message.timestamp,
|
|
end: new Date(message.timestamp.getTime() + 5 * 60 * 60 * 1000), // +5 hours
|
|
messageCount: 1,
|
|
conversations: new Set([message.conversationId]),
|
|
};
|
|
} else if (message.timestamp <= currentSession.end) {
|
|
// Message is within current session
|
|
currentSession.messageCount++;
|
|
currentSession.conversations.add(message.conversationId);
|
|
// Update session end if this message extends beyond current session
|
|
const potentialEnd = new Date(message.timestamp.getTime() + 5 * 60 * 60 * 1000);
|
|
if (potentialEnd > currentSession.end) {
|
|
currentSession.end = potentialEnd;
|
|
}
|
|
} else {
|
|
// Message is outside current session, start new session
|
|
sessions.push(currentSession);
|
|
currentSession = {
|
|
start: message.timestamp,
|
|
end: new Date(message.timestamp.getTime() + 5 * 60 * 60 * 1000),
|
|
messageCount: 1,
|
|
conversations: new Set([message.conversationId]),
|
|
};
|
|
}
|
|
});
|
|
|
|
// Add the last session
|
|
if (currentSession) {
|
|
sessions.push(currentSession);
|
|
}
|
|
|
|
// Calculate statistics
|
|
const now = new Date();
|
|
const currentMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
const thisWeek = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
|
|
|
const currentMonthSessions = sessions.filter(s => s.start >= currentMonth).length;
|
|
const thisWeekSessions = sessions.filter(s => s.start >= thisWeek).length;
|
|
|
|
return {
|
|
total: sessions.length,
|
|
currentMonth: currentMonthSessions,
|
|
thisWeek: thisWeekSessions,
|
|
sessions: sessions.map(s => ({
|
|
start: s.start,
|
|
end: s.end,
|
|
messageCount: s.messageCount,
|
|
conversationCount: s.conversations.size,
|
|
duration: Math.round((s.end - s.start) / (1000 * 60 * 60) * 10) / 10, // hours with 1 decimal
|
|
})),
|
|
};
|
|
}
|
|
|
|
updateRealtimeStats() {
|
|
this.data.realtimeStats = {
|
|
totalConversations: this.data.conversations.length,
|
|
totalTokens: this.data.conversations.reduce((sum, conv) => sum + conv.tokens, 0),
|
|
activeProjects: this.data.activeProjects.filter(p => p.status === 'active').length,
|
|
lastActivity: this.data.summary.lastActivity,
|
|
};
|
|
}
|
|
|
|
formatBytes(bytes) {
|
|
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];
|
|
}
|
|
|
|
/**
|
|
* Handle conversation file changes and detect new messages
|
|
* @param {string} conversationId - Conversation ID that changed
|
|
* @param {string} filePath - Path to the conversation file
|
|
*/
|
|
async handleConversationChange(conversationId, filePath) {
|
|
try {
|
|
|
|
// Get the latest messages from the file
|
|
const messages = await this.conversationAnalyzer.getParsedConversation(filePath);
|
|
|
|
if (messages && messages.length > 0) {
|
|
// Get the most recent message
|
|
const latestMessage = messages[messages.length - 1];
|
|
|
|
|
|
// Send WebSocket notification for new message
|
|
if (this.notificationManager) {
|
|
this.notificationManager.notifyNewMessage(conversationId, latestMessage, {
|
|
totalMessages: messages.length,
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
}
|
|
|
|
}
|
|
} catch (error) {
|
|
console.error(chalk.red(`Error handling conversation change for ${conversationId}:`), error);
|
|
}
|
|
}
|
|
|
|
setupFileWatchers() {
|
|
// Setup file watchers using the FileWatcher module
|
|
this.fileWatcher.setupFileWatchers(
|
|
this.claudeDir,
|
|
// Data refresh callback
|
|
async () => {
|
|
await this.loadInitialData();
|
|
},
|
|
// Process refresh callback
|
|
async () => {
|
|
const enrichmentResult = await this.processDetector.enrichWithRunningProcesses(
|
|
this.data.conversations,
|
|
this.claudeDir,
|
|
this.stateCalculator
|
|
);
|
|
this.data.conversations = enrichmentResult.conversations;
|
|
this.data.orphanProcesses = enrichmentResult.orphanProcesses;
|
|
},
|
|
// DataCache for cache invalidation
|
|
this.dataCache,
|
|
// Conversation change callback for real-time message updates
|
|
async (conversationId, filePath) => {
|
|
await this.handleConversationChange(conversationId, filePath);
|
|
}
|
|
);
|
|
}
|
|
|
|
setupWebServer() {
|
|
// Add CORS middleware
|
|
this.app.use((req, res, next) => {
|
|
res.header('Access-Control-Allow-Origin', '*');
|
|
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
|
res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization');
|
|
|
|
// Handle preflight requests
|
|
if (req.method === 'OPTIONS') {
|
|
res.sendStatus(200);
|
|
return;
|
|
}
|
|
|
|
next();
|
|
});
|
|
|
|
// Add performance monitoring middleware
|
|
this.app.use(this.performanceMonitor.createExpressMiddleware());
|
|
|
|
// Serve static files (we'll create the dashboard HTML)
|
|
this.app.use(express.static(path.join(__dirname, 'analytics-web')));
|
|
|
|
// API endpoints
|
|
this.app.get('/api/data', async (req, res) => {
|
|
try {
|
|
// Calculate detailed token usage
|
|
const detailedTokenUsage = this.calculateDetailedTokenUsage();
|
|
|
|
// Slice for response only — do not mutate this.data.conversations
|
|
const responseConversations = this.data.conversations
|
|
? this.data.conversations
|
|
.slice()
|
|
.sort((a, b) => new Date(b.lastModified) - new Date(a.lastModified))
|
|
.slice(0, 150)
|
|
: [];
|
|
|
|
const dataWithTimestamp = {
|
|
...this.data,
|
|
conversations: responseConversations,
|
|
detailedTokenUsage,
|
|
timestamp: new Date().toISOString(),
|
|
lastUpdate: new Date().toLocaleString(),
|
|
};
|
|
res.json(dataWithTimestamp);
|
|
} catch (error) {
|
|
console.error('Error calculating detailed token usage:', error);
|
|
res.json({
|
|
...this.data,
|
|
detailedTokenUsage: null,
|
|
timestamp: new Date().toISOString(),
|
|
lastUpdate: new Date().toLocaleString(),
|
|
});
|
|
}
|
|
});
|
|
|
|
// Paginated conversations endpoint
|
|
this.app.get('/api/conversations', async (req, res) => {
|
|
try {
|
|
const page = parseInt(req.query.page) || 0;
|
|
const limit = parseInt(req.query.limit) || 10;
|
|
const offset = page * limit;
|
|
|
|
// Sort conversations by lastModified (most recent first)
|
|
const sortedConversations = [...this.data.conversations]
|
|
.sort((a, b) => new Date(b.lastModified) - new Date(a.lastModified));
|
|
|
|
const paginatedConversations = sortedConversations.slice(offset, offset + limit);
|
|
const totalCount = this.data.conversations.length;
|
|
const hasMore = offset + limit < totalCount;
|
|
|
|
res.json({
|
|
conversations: paginatedConversations,
|
|
pagination: {
|
|
page,
|
|
limit,
|
|
offset,
|
|
totalCount,
|
|
hasMore,
|
|
currentCount: paginatedConversations.length
|
|
},
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
} catch (error) {
|
|
console.error('Error getting paginated conversations:', error);
|
|
res.status(500).json({ error: 'Failed to get conversations' });
|
|
}
|
|
});
|
|
|
|
// Agent usage analytics endpoint
|
|
this.app.get('/api/agents', async (req, res) => {
|
|
try {
|
|
const startDate = req.query.startDate;
|
|
const endDate = req.query.endDate;
|
|
const dateRange = (startDate || endDate) ? { startDate, endDate } : null;
|
|
|
|
const agentAnalysis = await this.agentAnalyzer.analyzeAgentUsage(this.data.conversations, dateRange);
|
|
const agentSummary = this.agentAnalyzer.generateSummary(agentAnalysis);
|
|
|
|
res.json({
|
|
...agentAnalysis,
|
|
summary: agentSummary,
|
|
dateRange,
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
} catch (error) {
|
|
console.error('Error getting agent analytics:', error);
|
|
res.status(500).json({ error: 'Failed to get agent analytics' });
|
|
}
|
|
});
|
|
|
|
this.app.get('/api/realtime', async (req, res) => {
|
|
const realtimeWithTimestamp = {
|
|
...this.data.realtimeStats,
|
|
timestamp: new Date().toISOString(),
|
|
lastUpdate: new Date().toLocaleString(),
|
|
};
|
|
res.json(realtimeWithTimestamp);
|
|
});
|
|
|
|
// Force refresh endpoint
|
|
this.app.get('/api/refresh', async (req, res) => {
|
|
await this.loadInitialData();
|
|
res.json({
|
|
success: true,
|
|
message: 'Data refreshed',
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
});
|
|
|
|
// NEW: Ultra-fast endpoint for ALL conversation states
|
|
this.app.get('/api/conversation-state', async (req, res) => {
|
|
try {
|
|
// Detect running processes for accurate state calculation
|
|
const runningProcesses = await this.processDetector.detectRunningClaudeProcesses();
|
|
const activeStates = {};
|
|
|
|
// Calculate states for ALL conversations, not just those with runningProcess
|
|
for (const conversation of this.data.conversations) {
|
|
try {
|
|
let state;
|
|
|
|
// First try quick calculation if there's a running process
|
|
if (conversation.runningProcess) {
|
|
state = this.stateCalculator.quickStateCalculation(conversation, runningProcesses);
|
|
}
|
|
|
|
// If no quick state found, use full state calculation
|
|
if (!state) {
|
|
// For conversations without running processes, use basic heuristics
|
|
const now = new Date();
|
|
const timeDiff = (now - new Date(conversation.lastModified)) / (1000 * 60); // minutes
|
|
|
|
if (timeDiff < 5) {
|
|
state = 'Recently active';
|
|
} else if (timeDiff < 60) {
|
|
state = 'Idle';
|
|
} else if (timeDiff < 1440) { // 24 hours
|
|
state = 'Inactive';
|
|
} else {
|
|
state = 'Old';
|
|
}
|
|
}
|
|
|
|
// Store state with conversation ID as key
|
|
activeStates[conversation.id] = state;
|
|
|
|
} catch (error) {
|
|
activeStates[conversation.id] = 'unknown';
|
|
}
|
|
}
|
|
|
|
res.json({ activeStates, timestamp: Date.now() });
|
|
} catch (error) {
|
|
console.error('Error getting conversation states:', error);
|
|
res.status(500).json({ error: 'Failed to get conversation states' });
|
|
}
|
|
});
|
|
|
|
// Conversation messages endpoint with optional pagination
|
|
this.app.get('/api/conversations/:id/messages', async (req, res) => {
|
|
try {
|
|
const conversationId = req.params.id;
|
|
const page = parseInt(req.query.page);
|
|
const limit = parseInt(req.query.limit);
|
|
|
|
const conversation = this.data.conversations.find(conv => conv.id === conversationId);
|
|
|
|
if (!conversation) {
|
|
return res.status(404).json({ error: 'Conversation not found' });
|
|
}
|
|
|
|
// Read all messages from the JSONL file
|
|
const allMessages = await this.conversationAnalyzer.getParsedConversation(conversation.filePath);
|
|
|
|
// If pagination parameters are provided, use pagination
|
|
if (!isNaN(page) && !isNaN(limit)) {
|
|
// Sort messages by timestamp (newest first for reverse pagination)
|
|
const sortedMessages = allMessages.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
|
|
|
|
// Calculate pagination
|
|
const totalCount = sortedMessages.length;
|
|
const offset = page * limit;
|
|
const hasMore = offset + limit < totalCount;
|
|
|
|
// Get page of messages (reverse order - newest first)
|
|
const paginatedMessages = sortedMessages.slice(offset, offset + limit);
|
|
|
|
// For display, we want messages in chronological order (oldest first)
|
|
const messagesInDisplayOrder = [...paginatedMessages].reverse();
|
|
|
|
res.json({
|
|
conversationId,
|
|
messages: messagesInDisplayOrder,
|
|
pagination: {
|
|
page,
|
|
limit,
|
|
offset,
|
|
totalCount,
|
|
hasMore,
|
|
currentCount: paginatedMessages.length
|
|
},
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
} else {
|
|
// Non-paginated response (backward compatibility)
|
|
res.json({
|
|
conversationId,
|
|
messages: allMessages,
|
|
messageCount: allMessages.length,
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading conversation messages:', error);
|
|
res.status(500).json({ error: 'Failed to load conversation messages' });
|
|
}
|
|
});
|
|
|
|
// Session data endpoint for Max plan usage tracking
|
|
this.app.get('/api/session/data', async (req, res) => {
|
|
try {
|
|
// Get real-time Claude session information
|
|
const claudeSessionInfo = await this.getClaudeSessionInfo();
|
|
|
|
if (!this.data.sessionData) {
|
|
// Generate session data if not available
|
|
this.data.sessionData = this.sessionAnalyzer.analyzeSessionData(this.data.conversations, claudeSessionInfo);
|
|
}
|
|
|
|
const timerData = this.sessionAnalyzer.getSessionTimerData(this.data.sessionData);
|
|
|
|
res.json({
|
|
...this.data.sessionData,
|
|
timer: timerData,
|
|
claudeSessionInfo: claudeSessionInfo,
|
|
timestamp: Date.now()
|
|
});
|
|
} catch (error) {
|
|
console.error('Session data error:', error);
|
|
res.status(500).json({
|
|
error: 'Failed to get session data',
|
|
timestamp: Date.now()
|
|
});
|
|
}
|
|
});
|
|
|
|
// Get specific conversation history
|
|
this.app.get('/api/session/:id', async (req, res) => {
|
|
try {
|
|
const conversationId = req.params.id;
|
|
|
|
// Find the conversation
|
|
const conversation = this.data.conversations.find(conv => conv.id === conversationId);
|
|
if (!conversation) {
|
|
return res.status(404).json({ error: 'Conversation not found' });
|
|
}
|
|
|
|
// Read the conversation file to get full message history
|
|
const conversationFile = conversation.filePath;
|
|
|
|
if (!conversationFile) {
|
|
return res.status(404).json({
|
|
error: 'Conversation file path not found',
|
|
conversationId: conversationId,
|
|
conversationKeys: Object.keys(conversation),
|
|
hasFilePath: !!conversation.filePath,
|
|
hasFileName: !!conversation.filename
|
|
});
|
|
}
|
|
|
|
if (!await fs.pathExists(conversationFile)) {
|
|
return res.status(404).json({ error: 'Conversation file not found', path: conversationFile });
|
|
}
|
|
|
|
const content = await fs.readFile(conversationFile, 'utf8');
|
|
const lines = content.trim().split('\n').filter(line => line.trim());
|
|
const rawMessages = lines.map(line => {
|
|
try {
|
|
return JSON.parse(line);
|
|
} catch (error) {
|
|
console.warn('Error parsing message line:', error);
|
|
return null;
|
|
}
|
|
}).filter(Boolean);
|
|
|
|
// Extract actual messages from Claude Code format
|
|
const messages = rawMessages.map(item => {
|
|
if (item.message && item.message.role) {
|
|
let content = '';
|
|
|
|
if (typeof item.message.content === 'string') {
|
|
content = item.message.content;
|
|
} else if (Array.isArray(item.message.content)) {
|
|
content = item.message.content
|
|
.map(block => {
|
|
if (block.type === 'text') return block.text;
|
|
if (block.type === 'tool_use') return `[Tool: ${block.name}]`;
|
|
if (block.type === 'tool_result') return '[Tool Result]';
|
|
return block.content || '';
|
|
})
|
|
.join('\n');
|
|
} else if (item.message.content && typeof item.message.content === 'object' && item.message.content.length) {
|
|
content = item.message.content[0].text || '';
|
|
}
|
|
|
|
return {
|
|
role: item.message.role,
|
|
content: content || 'No content',
|
|
timestamp: item.timestamp,
|
|
type: item.type,
|
|
stop_reason: item.message.stop_reason || null,
|
|
message_id: item.message.id || null,
|
|
model: item.message.model || null,
|
|
usage: item.message.usage || null,
|
|
hasToolUse: item.message.content && Array.isArray(item.message.content) &&
|
|
item.message.content.some(block => block.type === 'tool_use'),
|
|
hasToolResult: item.message.content && Array.isArray(item.message.content) &&
|
|
item.message.content.some(block => block.type === 'tool_result'),
|
|
contentBlocks: item.message.content && Array.isArray(item.message.content) ?
|
|
item.message.content.map(block => ({ type: block.type, name: block.name || null })) : [],
|
|
rawContent: item.message.content || null,
|
|
parentUuid: item.parentUuid || null,
|
|
uuid: item.uuid || null,
|
|
sessionId: item.sessionId || null,
|
|
userType: item.userType || null,
|
|
cwd: item.cwd || null,
|
|
version: item.version || null,
|
|
isCompactSummary: item.isCompactSummary || false,
|
|
isSidechain: item.isSidechain || false
|
|
};
|
|
}
|
|
return null;
|
|
}).filter(Boolean);
|
|
|
|
|
|
res.json({
|
|
conversation: {
|
|
id: conversation.id,
|
|
project: conversation.project,
|
|
messageCount: conversation.messageCount,
|
|
tokens: conversation.tokens,
|
|
created: conversation.created,
|
|
lastModified: conversation.lastModified,
|
|
status: conversation.status
|
|
},
|
|
messages: messages,
|
|
timestamp: Date.now()
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('Error getting conversation history:', error);
|
|
console.error('Error stack:', error.stack);
|
|
res.status(500).json({
|
|
error: 'Failed to load conversation history',
|
|
details: error.message,
|
|
stack: error.stack
|
|
});
|
|
}
|
|
});
|
|
|
|
// Fast state update endpoint - only updates conversation states without full reload
|
|
this.app.get('/api/fast-update', async (req, res) => {
|
|
try {
|
|
// Update process information and conversation states
|
|
const enrichmentResult = await this.processDetector.enrichWithRunningProcesses(
|
|
this.data.conversations,
|
|
this.claudeDir,
|
|
this.stateCalculator
|
|
);
|
|
this.data.conversations = enrichmentResult.conversations;
|
|
this.data.orphanProcesses = enrichmentResult.orphanProcesses;
|
|
|
|
// For active conversations, re-read the files to get latest messages
|
|
const activeConversations = this.data.conversations.filter(c => c.runningProcess);
|
|
|
|
for (const conv of activeConversations) {
|
|
try {
|
|
const conversationFile = path.join(this.claudeDir, conv.fileName);
|
|
const content = await fs.readFile(conversationFile, 'utf8');
|
|
const parsedMessages = content.split('\n')
|
|
.filter(line => line.trim())
|
|
.map(line => JSON.parse(line));
|
|
|
|
const stats = await fs.stat(conversationFile);
|
|
conv.conversationState = this.stateCalculator.determineConversationState(
|
|
parsedMessages,
|
|
stats.mtime,
|
|
conv.runningProcess
|
|
);
|
|
|
|
} catch (error) {
|
|
// If we can't read the file, keep the existing state
|
|
}
|
|
}
|
|
|
|
// Only log when there are actually active conversations (reduce noise)
|
|
const activeConvs = this.data.conversations.filter(c => c.runningProcess);
|
|
if (activeConvs.length > 0) {
|
|
// Only log every 10th update to reduce spam, or when states change
|
|
if (!this.lastLoggedStates) this.lastLoggedStates = new Map();
|
|
|
|
let hasChanges = false;
|
|
activeConvs.forEach(conv => {
|
|
const lastState = this.lastLoggedStates.get(conv.id);
|
|
if (lastState !== conv.conversationState) {
|
|
hasChanges = true;
|
|
this.lastLoggedStates.set(conv.id, conv.conversationState);
|
|
}
|
|
});
|
|
|
|
}
|
|
|
|
// Force garbage collection hint
|
|
if (global.gc) {
|
|
global.gc();
|
|
}
|
|
|
|
// Slice for response only — do not mutate this.data.conversations
|
|
const responseConversations = this.data.conversations
|
|
? this.data.conversations
|
|
.slice()
|
|
.sort((a, b) => new Date(b.lastModified) - new Date(a.lastModified))
|
|
.slice(0, 150)
|
|
: [];
|
|
|
|
const dataWithTimestamp = {
|
|
conversations: responseConversations,
|
|
summary: this.data.summary,
|
|
timestamp: new Date().toISOString(),
|
|
lastUpdate: new Date().toLocaleString(),
|
|
};
|
|
res.json(dataWithTimestamp);
|
|
} catch (error) {
|
|
console.error('Fast update error:', error);
|
|
res.status(500).json({ error: 'Failed to update states' });
|
|
}
|
|
});
|
|
|
|
// Remove duplicate endpoint - this conflicts with the correct one above
|
|
|
|
// System health endpoint
|
|
this.app.get('/api/system/health', (req, res) => {
|
|
try {
|
|
const stats = this.performanceMonitor.getStats();
|
|
const systemHealth = {
|
|
status: 'healthy',
|
|
uptime: stats.uptime,
|
|
memory: stats.memory,
|
|
requests: stats.requests,
|
|
cache: {
|
|
...stats.cache,
|
|
dataCache: this.dataCache.getStats()
|
|
},
|
|
errors: stats.errors,
|
|
counters: stats.counters,
|
|
timestamp: Date.now()
|
|
};
|
|
|
|
// Determine overall health status
|
|
if (stats.errors.total > 10) {
|
|
systemHealth.status = 'degraded';
|
|
}
|
|
if (stats.memory.current && stats.memory.current.heapUsed > this.performanceMonitor.options.memoryThreshold) {
|
|
systemHealth.status = 'warning';
|
|
}
|
|
|
|
res.json(systemHealth);
|
|
} catch (error) {
|
|
res.status(500).json({
|
|
status: 'error',
|
|
message: 'Failed to get system health',
|
|
timestamp: Date.now()
|
|
});
|
|
}
|
|
});
|
|
|
|
// Version endpoint
|
|
this.app.get('/api/version', (req, res) => {
|
|
res.json({
|
|
version: packageJson.version,
|
|
name: packageJson.name,
|
|
description: packageJson.description,
|
|
timestamp: Date.now()
|
|
});
|
|
});
|
|
|
|
// Claude session information endpoint
|
|
this.app.get('/api/claude/session', async (req, res) => {
|
|
try {
|
|
const sessionInfo = await this.getClaudeSessionInfo();
|
|
res.json({
|
|
...sessionInfo,
|
|
timestamp: Date.now()
|
|
});
|
|
} catch (error) {
|
|
console.error('Error getting Claude session info:', error);
|
|
res.status(500).json({
|
|
error: 'Failed to get Claude session info',
|
|
timestamp: Date.now()
|
|
});
|
|
}
|
|
});
|
|
|
|
// Performance metrics endpoint
|
|
this.app.get('/api/system/metrics', (req, res) => {
|
|
try {
|
|
const timeframe = parseInt(req.query.timeframe) || 300000; // 5 minutes default
|
|
const stats = this.performanceMonitor.getStats(timeframe);
|
|
res.json({
|
|
...stats,
|
|
dataCache: this.dataCache.getStats(),
|
|
timestamp: Date.now()
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({
|
|
error: 'Failed to get performance metrics',
|
|
timestamp: Date.now()
|
|
});
|
|
}
|
|
});
|
|
|
|
// Cache management endpoint
|
|
this.app.post('/api/cache/clear', (req, res) => {
|
|
try {
|
|
// Clear specific cache types or all
|
|
const { type } = req.body;
|
|
|
|
if (!type || type === 'all') {
|
|
// Clear all caches
|
|
this.dataCache.invalidateComputations();
|
|
this.dataCache.caches.parsedConversations.clear();
|
|
this.dataCache.caches.fileContent.clear();
|
|
this.dataCache.caches.fileStats.clear();
|
|
res.json({ success: true, message: 'All caches cleared' });
|
|
} else if (type === 'conversations') {
|
|
// Clear only conversation-related caches
|
|
this.dataCache.caches.parsedConversations.clear();
|
|
this.dataCache.caches.fileContent.clear();
|
|
res.json({ success: true, message: 'Conversation caches cleared' });
|
|
} else {
|
|
res.status(400).json({ error: 'Invalid cache type. Use "all" or "conversations"' });
|
|
}
|
|
} catch (error) {
|
|
console.error('Error clearing cache:', error);
|
|
res.status(500).json({ error: 'Failed to clear cache' });
|
|
}
|
|
});
|
|
|
|
// Clear cache endpoint
|
|
this.app.post('/api/clear-cache', async (req, res) => {
|
|
try {
|
|
console.log('🔥 Clear cache request received');
|
|
|
|
// Clear DataCache
|
|
if (this.dataCache && typeof this.dataCache.clear === 'function') {
|
|
this.dataCache.clear();
|
|
console.log('🔥 Server DataCache cleared');
|
|
} else {
|
|
console.log('⚠️ DataCache not available or no clear method');
|
|
}
|
|
|
|
// Also clear ConversationAnalyzer cache if available
|
|
if (this.conversationAnalyzer && typeof this.conversationAnalyzer.clearCache === 'function') {
|
|
this.conversationAnalyzer.clearCache();
|
|
console.log('🔥 ConversationAnalyzer cache cleared');
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
message: 'Cache cleared successfully',
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
} catch (error) {
|
|
console.error('❌ Error clearing cache:', error);
|
|
res.status(500).json({
|
|
error: 'Failed to clear cache',
|
|
details: error.message
|
|
});
|
|
}
|
|
});
|
|
|
|
// Activity heatmap data endpoint — uses in-memory cached conversations
|
|
this.app.get('/api/activity', async (req, res) => {
|
|
try {
|
|
const conversations = this.data.conversations ?? [];
|
|
const activityData = this.generateActivityDataFromConversations(conversations);
|
|
res.json({
|
|
...activityData,
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
} catch (error) {
|
|
console.error('Error generating activity data:', error);
|
|
res.status(500).json({ error: 'Failed to generate activity data' });
|
|
}
|
|
});
|
|
|
|
// Year in Review 2025 API endpoint
|
|
this.app.get('/api/2025', async (req, res) => {
|
|
try {
|
|
console.log('📅 Generating 2025 Year in Review...');
|
|
|
|
// Load all conversations for analysis
|
|
const allConversations = await this.conversationAnalyzer.loadConversations(this.stateCalculator);
|
|
|
|
// Generate year in review statistics
|
|
const yearInReview = await this.yearInReview2025.generateYearInReview(allConversations, this.claudeDir);
|
|
|
|
console.log(`✅ 2025 Year in Review generated with ${yearInReview.totalConversations} conversations`);
|
|
|
|
res.json({
|
|
...yearInReview,
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
} catch (error) {
|
|
console.error('Error generating 2025 year in review:', error);
|
|
res.status(500).json({
|
|
error: 'Failed to generate year in review',
|
|
message: error.message
|
|
});
|
|
}
|
|
});
|
|
|
|
// Year in Review 2025 page route
|
|
this.app.get('/2025', (req, res) => {
|
|
res.sendFile(path.join(__dirname, 'analytics-web', '2025.html'));
|
|
});
|
|
|
|
// Main dashboard route
|
|
this.app.get('/', (req, res) => {
|
|
res.sendFile(path.join(__dirname, 'analytics-web', 'index.html'));
|
|
});
|
|
}
|
|
|
|
async startServer() {
|
|
return new Promise(async (resolve) => {
|
|
this.httpServer = this.app.listen(this.port, async () => {
|
|
console.log(chalk.green(`🚀 Analytics dashboard started at http://localhost:${this.port}`));
|
|
|
|
// Initialize WebSocket server
|
|
await this.initializeWebSocket();
|
|
|
|
resolve();
|
|
});
|
|
// Keep reference for compatibility
|
|
this.server = this.httpServer;
|
|
});
|
|
}
|
|
|
|
async openBrowser(openTo = null) {
|
|
const baseUrl = this.publicUrl || `http://localhost:${this.port}`;
|
|
let fullUrl = baseUrl;
|
|
|
|
// Add fragment/hash or path for specific page
|
|
if (openTo === 'agents') {
|
|
fullUrl = `${baseUrl}/#agents`;
|
|
console.log(chalk.blue('🌐 Opening browser to Claude Code Chats...'));
|
|
} else if (openTo === '2025') {
|
|
fullUrl = `${baseUrl}/2025`;
|
|
console.log(chalk.blue('🎉 Opening browser to 2025 Year in Review...'));
|
|
} else {
|
|
console.log(chalk.blue('🌐 Opening browser to Claude Code Analytics...'));
|
|
}
|
|
|
|
try {
|
|
await open(fullUrl);
|
|
} catch (error) {
|
|
console.log(chalk.yellow('Could not open browser automatically. Please visit:'));
|
|
console.log(chalk.cyan(fullUrl));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Prompt user if they want to use Cloudflare Tunnel
|
|
*/
|
|
async promptCloudflareSetup() {
|
|
console.log('');
|
|
console.log(chalk.yellow('🌐 Analytics Dashboard Access Options'));
|
|
console.log('');
|
|
console.log(chalk.cyan('🔒 About Cloudflare Tunnel:'));
|
|
console.log(chalk.gray('• Creates a secure connection between your localhost and the web'));
|
|
console.log(chalk.gray('• Only you will have access to the generated URL (not public)'));
|
|
console.log(chalk.gray('• The connection is end-to-end encrypted'));
|
|
console.log(chalk.gray('• Automatically closes when you end the session'));
|
|
console.log(chalk.gray('• No firewall or port configuration required'));
|
|
console.log('');
|
|
console.log(chalk.green('✅ It is completely secure - only you can access the dashboard'));
|
|
console.log('');
|
|
|
|
const { useCloudflare } = await inquirer.prompt([{
|
|
type: 'confirm',
|
|
name: 'useCloudflare',
|
|
message: 'Enable Cloudflare Tunnel for secure remote access?',
|
|
default: true
|
|
}]);
|
|
|
|
return useCloudflare;
|
|
}
|
|
|
|
/**
|
|
* Start Cloudflare Tunnel
|
|
*/
|
|
async startCloudflareTunnel() {
|
|
try {
|
|
console.log(chalk.blue('🔧 Starting Cloudflare Tunnel...'));
|
|
|
|
// Check if cloudflared is installed
|
|
const checkProcess = spawn('cloudflared', ['version'], { stdio: 'pipe' });
|
|
|
|
return new Promise((resolve, reject) => {
|
|
checkProcess.on('error', (error) => {
|
|
console.log(chalk.red('❌ Cloudflared is not installed.'));
|
|
console.log('');
|
|
console.log(chalk.yellow('📥 To install Cloudflare Tunnel:'));
|
|
console.log(chalk.gray('• macOS: brew install cloudflared'));
|
|
console.log(chalk.gray('• Windows: winget install --id Cloudflare.cloudflared'));
|
|
console.log(chalk.gray('• Linux: apt-get install cloudflared'));
|
|
console.log('');
|
|
console.log(chalk.blue('💡 More info: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/'));
|
|
resolve(false);
|
|
});
|
|
|
|
checkProcess.on('close', (code) => {
|
|
if (code === 0) {
|
|
this.createCloudflareTunnel();
|
|
resolve(true);
|
|
} else {
|
|
resolve(false);
|
|
}
|
|
});
|
|
});
|
|
} catch (error) {
|
|
console.log(chalk.red(`❌ Error checking Cloudflare Tunnel: ${error.message}`));
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create the actual Cloudflare Tunnel
|
|
*/
|
|
async createCloudflareTunnel() {
|
|
try {
|
|
console.log(chalk.blue('🚀 Creating secure tunnel...'));
|
|
|
|
// Start cloudflared tunnel normally, but filter the output to capture URL
|
|
this.cloudflareProcess = spawn('cloudflared', [
|
|
'tunnel',
|
|
'--url', `http://localhost:${this.port}`
|
|
], {
|
|
stdio: ['pipe', 'pipe', 'pipe']
|
|
});
|
|
|
|
let tunnelEstablished = false;
|
|
|
|
return new Promise((resolve) => {
|
|
// Monitor stderr for the tunnel URL (cloudflared outputs most info to stderr)
|
|
this.cloudflareProcess.stderr.on('data', (data) => {
|
|
const output = data.toString();
|
|
|
|
// Use the cleaner regex to extract URL from the logs
|
|
const urlMatch = output.match(/https:\/\/[a-zA-Z0-9.-]+\.trycloudflare\.com/);
|
|
|
|
if (urlMatch && !tunnelEstablished) {
|
|
tunnelEstablished = true;
|
|
this.publicUrl = urlMatch[0];
|
|
|
|
// Create a prominent, boxed display for the tunnel URL
|
|
const tunnelMessage = chalk.green.bold('🌍 CLOUDFLARE TUNNEL ACTIVE') + '\n\n' +
|
|
chalk.cyan.bold('Public URL: ') + chalk.white.underline(this.publicUrl) + '\n\n' +
|
|
chalk.yellow('🔗 Share this URL to access your dashboard remotely') + '\n' +
|
|
chalk.gray('🔒 This tunnel is private and secure - only accessible by you');
|
|
|
|
console.log('\n');
|
|
console.log(boxen(tunnelMessage, {
|
|
padding: 1,
|
|
margin: 1,
|
|
borderStyle: 'double',
|
|
borderColor: 'cyan',
|
|
backgroundColor: '#1a1a1a'
|
|
}));
|
|
console.log('\n');
|
|
resolve(true);
|
|
}
|
|
});
|
|
|
|
// Also check stdout just in case
|
|
this.cloudflareProcess.stdout.on('data', (data) => {
|
|
const output = data.toString();
|
|
const urlMatch = output.match(/https:\/\/[a-zA-Z0-9.-]+\.trycloudflare\.com/);
|
|
|
|
if (urlMatch && !tunnelEstablished) {
|
|
tunnelEstablished = true;
|
|
this.publicUrl = urlMatch[0];
|
|
|
|
// Create a prominent, boxed display for the tunnel URL
|
|
const tunnelMessage = chalk.green.bold('🌍 CLOUDFLARE TUNNEL ACTIVE') + '\n\n' +
|
|
chalk.cyan.bold('Public URL: ') + chalk.white.underline(this.publicUrl) + '\n\n' +
|
|
chalk.yellow('🔗 Share this URL to access your dashboard remotely') + '\n' +
|
|
chalk.gray('🔒 This tunnel is private and secure - only accessible by you');
|
|
|
|
console.log('\n');
|
|
console.log(boxen(tunnelMessage, {
|
|
padding: 1,
|
|
margin: 1,
|
|
borderStyle: 'double',
|
|
borderColor: 'cyan',
|
|
backgroundColor: '#1a1a1a'
|
|
}));
|
|
console.log('\n');
|
|
resolve(true);
|
|
}
|
|
});
|
|
|
|
this.cloudflareProcess.on('close', (code) => {
|
|
if (code !== 0) {
|
|
console.log(chalk.red(`❌ Cloudflare Tunnel terminated with code: ${code}`));
|
|
}
|
|
this.publicUrl = null;
|
|
this.cloudflareProcess = null;
|
|
if (!tunnelEstablished) {
|
|
resolve(false);
|
|
}
|
|
});
|
|
|
|
this.cloudflareProcess.on('error', (error) => {
|
|
console.log(chalk.red(`❌ Error with Cloudflare Tunnel: ${error.message}`));
|
|
this.publicUrl = null;
|
|
this.cloudflareProcess = null;
|
|
resolve(false);
|
|
});
|
|
|
|
// Timeout after 15 seconds if tunnel doesn't establish
|
|
setTimeout(() => {
|
|
if (!tunnelEstablished) {
|
|
console.log(chalk.red('❌ Timeout waiting for Cloudflare Tunnel to establish'));
|
|
resolve(false);
|
|
}
|
|
}, 15000);
|
|
});
|
|
} catch (error) {
|
|
console.log(chalk.red(`❌ Error creating Cloudflare Tunnel: ${error.message}`));
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stop Cloudflare Tunnel
|
|
*/
|
|
stopCloudflareTunnel() {
|
|
if (this.cloudflareProcess) {
|
|
console.log(chalk.yellow('🛑 Closing Cloudflare Tunnel...'));
|
|
this.cloudflareProcess.kill('SIGTERM');
|
|
this.cloudflareProcess = null;
|
|
this.publicUrl = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize WebSocket server and notification manager
|
|
*/
|
|
async initializeWebSocket() {
|
|
try {
|
|
// Initialize WebSocket server with performance monitoring
|
|
this.webSocketServer = new WebSocketServer(this.httpServer, {
|
|
path: '/ws',
|
|
heartbeatInterval: 30000
|
|
}, this.performanceMonitor);
|
|
await this.webSocketServer.initialize();
|
|
|
|
// Initialize notification manager
|
|
this.notificationManager = new NotificationManager(this.webSocketServer);
|
|
await this.notificationManager.initialize();
|
|
|
|
// Connect notification manager to file watcher for typing detection
|
|
this.fileWatcher.setNotificationManager(this.notificationManager);
|
|
|
|
// Initialize Claude API Proxy for bidirectional communication
|
|
console.log(chalk.blue('🌉 Initializing Claude API Proxy...'));
|
|
this.claudeApiProxy = new ClaudeAPIProxy();
|
|
await this.claudeApiProxy.start();
|
|
console.log(chalk.green('✅ Claude API Proxy initialized on port 3335'));
|
|
|
|
// Setup notification subscriptions
|
|
this.setupNotificationSubscriptions();
|
|
|
|
// Initialize Console Bridge for Claude Code interaction
|
|
await this.initializeConsoleBridge();
|
|
|
|
console.log(chalk.green('✅ WebSocket, notifications, and console bridge initialized'));
|
|
} catch (error) {
|
|
console.error(chalk.red('❌ Failed to initialize WebSocket:'), error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize Console Bridge for Claude Code interaction
|
|
*/
|
|
async initializeConsoleBridge() {
|
|
try {
|
|
console.log(chalk.blue('🌉 Initializing Console Bridge...'));
|
|
|
|
// Create console bridge on a different port (3334)
|
|
this.consoleBridge = new ConsoleBridge({
|
|
port: 3334,
|
|
debug: false // Set to true for detailed debugging
|
|
});
|
|
|
|
// Initialize the bridge
|
|
const success = await this.consoleBridge.initialize();
|
|
|
|
if (success) {
|
|
console.log(chalk.green('✅ Console Bridge initialized on port 3334'));
|
|
console.log(chalk.cyan('🔌 Web interface can connect to ws://localhost:3334 for console interactions'));
|
|
|
|
// Bridge console interactions to main WebSocket
|
|
this.setupConsoleBridgeIntegration();
|
|
} else {
|
|
console.warn(chalk.yellow('⚠️ Console Bridge failed to initialize - console interactions disabled'));
|
|
}
|
|
|
|
} catch (error) {
|
|
console.warn(chalk.yellow('⚠️ Console Bridge initialization failed:'), error.message);
|
|
console.log(chalk.gray('Console interactions will not be available, but analytics will continue normally'));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Setup integration between Console Bridge and main WebSocket
|
|
*/
|
|
setupConsoleBridgeIntegration() {
|
|
if (!this.consoleBridge || !this.webSocketServer) return;
|
|
|
|
// Forward console interactions from bridge to main WebSocket
|
|
this.consoleBridge.on('console_interaction', (interactionData) => {
|
|
console.log(chalk.blue('📡 Forwarding console interaction to web interface'));
|
|
|
|
// Broadcast to main WebSocket clients
|
|
this.webSocketServer.broadcast({
|
|
type: 'console_interaction',
|
|
data: interactionData
|
|
});
|
|
});
|
|
|
|
// Listen for responses from main WebSocket and forward to bridge
|
|
this.webSocketServer.on('console_response', (responseData) => {
|
|
console.log(chalk.blue('📱 Forwarding console response to Claude Code'));
|
|
|
|
if (this.consoleBridge) {
|
|
this.consoleBridge.handleWebMessage({
|
|
type: 'console_response',
|
|
data: responseData
|
|
});
|
|
}
|
|
});
|
|
|
|
console.log(chalk.green('🔗 Console Bridge integration established'));
|
|
}
|
|
|
|
/**
|
|
* Setup notification subscriptions
|
|
*/
|
|
setupNotificationSubscriptions() {
|
|
// Subscribe to refresh requests from WebSocket clients
|
|
this.notificationManager.subscribe('refresh_requested', async (notification) => {
|
|
await this.loadInitialData();
|
|
|
|
// Notify clients of the refreshed data
|
|
this.notificationManager.notifyDataRefresh(this.data, 'websocket_request');
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Detect and notify conversation state changes
|
|
* @param {Object} previousData - Previous data state
|
|
* @param {Object} currentData - Current data state
|
|
*/
|
|
detectAndNotifyStateChanges(previousData, currentData) {
|
|
if (!previousData || !previousData.conversations || !currentData || !currentData.conversations) {
|
|
return;
|
|
}
|
|
|
|
// Create maps for easier lookup
|
|
const previousConversations = new Map();
|
|
previousData.conversations.forEach(conv => {
|
|
previousConversations.set(conv.id, conv);
|
|
});
|
|
|
|
// Check for state changes
|
|
currentData.conversations.forEach(currentConv => {
|
|
const previousConv = previousConversations.get(currentConv.id);
|
|
|
|
if (previousConv && previousConv.status !== currentConv.status) {
|
|
// State changed - notify clients
|
|
this.notificationManager.notifyConversationStateChange(
|
|
currentConv.id,
|
|
previousConv.status,
|
|
currentConv.status,
|
|
{
|
|
project: currentConv.project,
|
|
tokens: currentConv.tokens,
|
|
lastModified: currentConv.lastModified
|
|
}
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Load available agents from .claude/agents directories (project and user level)
|
|
* @returns {Promise<Array>} Array of agent objects
|
|
*/
|
|
async loadAgents() {
|
|
const agents = [];
|
|
const homeDir = os.homedir();
|
|
|
|
// Define agent paths (user level and project level)
|
|
const userAgentsDir = path.join(homeDir, '.claude', 'agents');
|
|
const projectAgentsDirs = [];
|
|
|
|
try {
|
|
// 1. Check current working directory for .claude/agents
|
|
const currentProjectAgentsDir = path.join(process.cwd(), '.claude', 'agents');
|
|
if (await fs.pathExists(currentProjectAgentsDir)) {
|
|
const currentProjectName = path.basename(process.cwd());
|
|
projectAgentsDirs.push({
|
|
path: currentProjectAgentsDir,
|
|
projectName: currentProjectName
|
|
});
|
|
}
|
|
|
|
// 2. Check parent directories for .claude/agents (for monorepo/nested projects)
|
|
let currentDir = process.cwd();
|
|
let parentDir = path.dirname(currentDir);
|
|
|
|
// Search up to 3 levels up for .claude/agents
|
|
for (let i = 0; i < 3 && parentDir !== currentDir; i++) {
|
|
const parentProjectAgentsDir = path.join(parentDir, '.claude', 'agents');
|
|
|
|
if (await fs.pathExists(parentProjectAgentsDir)) {
|
|
const parentProjectName = path.basename(parentDir);
|
|
|
|
// Avoid duplicates
|
|
const exists = projectAgentsDirs.some(p => p.path === parentProjectAgentsDir);
|
|
if (!exists) {
|
|
projectAgentsDirs.push({
|
|
path: parentProjectAgentsDir,
|
|
projectName: parentProjectName
|
|
});
|
|
}
|
|
break; // Found one, no need to go further up
|
|
}
|
|
currentDir = parentDir;
|
|
parentDir = path.dirname(currentDir);
|
|
}
|
|
|
|
// 3. Find all project directories that might have agents (in ~/.claude/projects)
|
|
const projectsDir = path.join(this.claudeDir, 'projects');
|
|
if (await fs.pathExists(projectsDir)) {
|
|
const projectDirs = await fs.readdir(projectsDir);
|
|
for (const projectDir of projectDirs) {
|
|
const projectAgentsDir = path.join(projectsDir, projectDir, '.claude', 'agents');
|
|
if (await fs.pathExists(projectAgentsDir)) {
|
|
projectAgentsDirs.push({
|
|
path: projectAgentsDir,
|
|
projectName: this.cleanProjectName(projectDir)
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Load user-level agents
|
|
if (await fs.pathExists(userAgentsDir)) {
|
|
const userAgents = await this.loadAgentsFromDirectory(userAgentsDir, 'user');
|
|
agents.push(...userAgents);
|
|
}
|
|
|
|
// Load project-level agents
|
|
for (const projectInfo of projectAgentsDirs) {
|
|
const projectAgents = await this.loadAgentsFromDirectory(
|
|
projectInfo.path,
|
|
'project',
|
|
projectInfo.projectName
|
|
);
|
|
agents.push(...projectAgents);
|
|
}
|
|
|
|
// Log agents summary
|
|
console.log(chalk.blue('🤖 Agents loaded:'), agents.length);
|
|
if (agents.length > 0) {
|
|
const projectAgents = agents.filter(a => a.level === 'project').length;
|
|
const userAgents = agents.filter(a => a.level === 'user').length;
|
|
console.log(chalk.gray(` 📦 Project agents: ${projectAgents}, 👤 User agents: ${userAgents}`));
|
|
}
|
|
|
|
// Sort agents by name and prioritize project agents over user agents
|
|
return agents.sort((a, b) => {
|
|
if (a.level !== b.level) {
|
|
return a.level === 'project' ? -1 : 1;
|
|
}
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error(chalk.red('Error loading agents:'), error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load agents from a specific directory
|
|
* @param {string} agentsDir - Directory containing agent files
|
|
* @param {string} level - 'user' or 'project'
|
|
* @param {string} projectName - Name of project (if project level)
|
|
* @returns {Promise<Array>} Array of agent objects
|
|
*/
|
|
async loadAgentsFromDirectory(agentsDir, level, projectName = null) {
|
|
const agents = [];
|
|
|
|
try {
|
|
const files = await fs.readdir(agentsDir);
|
|
|
|
for (const file of files) {
|
|
if (file.endsWith('.md')) {
|
|
const filePath = path.join(agentsDir, file);
|
|
const agentData = await this.parseAgentFile(filePath, level, projectName);
|
|
if (agentData) {
|
|
agents.push(agentData);
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.warn(chalk.yellow(`Warning: Could not read agents directory ${agentsDir}:`, error.message));
|
|
}
|
|
|
|
return agents;
|
|
}
|
|
|
|
/**
|
|
* Parse agent markdown file
|
|
* @param {string} filePath - Path to agent file
|
|
* @param {string} level - 'user' or 'project'
|
|
* @param {string} projectName - Name of project (if project level)
|
|
* @returns {Promise<Object|null>} Agent object or null if parsing failed
|
|
*/
|
|
async parseAgentFile(filePath, level, projectName = null) {
|
|
try {
|
|
const content = await fs.readFile(filePath, 'utf8');
|
|
const stats = await fs.stat(filePath);
|
|
|
|
// Parse YAML frontmatter
|
|
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
if (!frontmatterMatch) {
|
|
console.warn(chalk.yellow(`Agent file ${path.basename(filePath)} missing frontmatter`));
|
|
return null;
|
|
}
|
|
|
|
const frontmatter = {};
|
|
const yamlContent = frontmatterMatch[1];
|
|
|
|
// Simple YAML parser for the fields we need
|
|
const yamlLines = yamlContent.split('\n');
|
|
for (const line of yamlLines) {
|
|
const match = line.match(/^(\w+):\s*(.*)$/);
|
|
if (match) {
|
|
const [, key, value] = match;
|
|
frontmatter[key] = value.trim();
|
|
}
|
|
}
|
|
|
|
// Log parsed frontmatter for debugging
|
|
console.log(chalk.blue(`📋 Parsed agent frontmatter for ${path.basename(filePath)}:`), frontmatter);
|
|
|
|
if (!frontmatter.name || !frontmatter.description) {
|
|
console.warn(chalk.yellow(`Agent file ${path.basename(filePath)} missing required fields`));
|
|
return null;
|
|
}
|
|
|
|
// Extract system prompt (content after frontmatter)
|
|
const systemPrompt = content.substring(frontmatterMatch[0].length).trim();
|
|
|
|
// Parse tools if specified
|
|
let tools = [];
|
|
if (frontmatter.tools) {
|
|
tools = frontmatter.tools.split(',').map(tool => tool.trim()).filter(Boolean);
|
|
}
|
|
|
|
// Use color from frontmatter if available, otherwise generate one
|
|
const color = frontmatter.color ? this.convertColorToHex(frontmatter.color) : this.generateAgentColor(frontmatter.name);
|
|
|
|
return {
|
|
name: frontmatter.name,
|
|
description: frontmatter.description,
|
|
systemPrompt,
|
|
tools,
|
|
level,
|
|
projectName,
|
|
filePath,
|
|
lastModified: stats.mtime,
|
|
color,
|
|
isActive: true // All loaded agents are considered active
|
|
};
|
|
|
|
} catch (error) {
|
|
console.warn(chalk.yellow(`Warning: Could not parse agent file ${filePath}:`, error.message));
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate consistent color for agent based on name
|
|
* @param {string} agentName - Name of the agent
|
|
* @returns {string} Hex color code
|
|
*/
|
|
generateAgentColor(agentName) {
|
|
// Simple hash function to generate consistent colors
|
|
let hash = 0;
|
|
for (let i = 0; i < agentName.length; i++) {
|
|
const char = agentName.charCodeAt(i);
|
|
hash = ((hash << 5) - hash) + char;
|
|
hash = hash & hash; // Convert to 32-bit integer
|
|
}
|
|
|
|
// Generate RGB values with good contrast and visibility
|
|
const hue = Math.abs(hash) % 360;
|
|
const saturation = 70 + (Math.abs(hash) % 30); // 70-100%
|
|
const lightness = 45 + (Math.abs(hash) % 20); // 45-65%
|
|
|
|
// Convert HSL to RGB
|
|
const hslToRgb = (h, s, l) => {
|
|
h /= 360;
|
|
s /= 100;
|
|
l /= 100;
|
|
|
|
const hue2rgb = (p, q, t) => {
|
|
if (t < 0) t += 1;
|
|
if (t > 1) t -= 1;
|
|
if (t < 1/6) return p + (q - p) * 6 * t;
|
|
if (t < 1/2) return q;
|
|
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
|
|
return p;
|
|
};
|
|
|
|
let r, g, b;
|
|
if (s === 0) {
|
|
r = g = b = l;
|
|
} else {
|
|
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
|
const p = 2 * l - q;
|
|
r = hue2rgb(p, q, h + 1/3);
|
|
g = hue2rgb(p, q, h);
|
|
b = hue2rgb(p, q, h - 1/3);
|
|
}
|
|
|
|
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
|
|
};
|
|
|
|
const [r, g, b] = hslToRgb(hue, saturation, lightness);
|
|
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
|
|
}
|
|
|
|
/**
|
|
* Convert color names to hex values
|
|
* @param {string} color - Color name or hex value
|
|
* @returns {string} Hex color code
|
|
*/
|
|
convertColorToHex(color) {
|
|
if (!color) return '#007acc';
|
|
|
|
// If already hex, return as-is
|
|
if (color.startsWith('#')) return color;
|
|
|
|
// Convert common color names to hex
|
|
const colorMap = {
|
|
'red': '#ff4444',
|
|
'blue': '#4444ff',
|
|
'green': '#44ff44',
|
|
'yellow': '#ffff44',
|
|
'orange': '#ff8844',
|
|
'purple': '#8844ff',
|
|
'pink': '#ff44ff',
|
|
'cyan': '#44ffff',
|
|
'brown': '#8b4513',
|
|
'gray': '#888888',
|
|
'grey': '#888888',
|
|
'black': '#333333',
|
|
'white': '#ffffff',
|
|
'teal': '#008080',
|
|
'navy': '#000080',
|
|
'lime': '#00ff00',
|
|
'maroon': '#800000',
|
|
'olive': '#808000',
|
|
'silver': '#c0c0c0'
|
|
};
|
|
|
|
return colorMap[color.toLowerCase()] || '#007acc';
|
|
}
|
|
|
|
/**
|
|
* Clean project name for display
|
|
* @param {string} projectDir - Raw project directory name
|
|
* @returns {string} Cleaned project name
|
|
*/
|
|
cleanProjectName(projectDir) {
|
|
// Convert encoded project paths like "-Users-user-Projects-MyProject" to "MyProject"
|
|
const parts = projectDir.split('-').filter(Boolean);
|
|
return parts[parts.length - 1] || projectDir;
|
|
}
|
|
|
|
/**
|
|
* Get Claude session information from statsig files
|
|
*/
|
|
async getClaudeSessionInfo() {
|
|
try {
|
|
if (!await fs.pathExists(this.claudeStatsigDir)) {
|
|
return {
|
|
hasSession: false,
|
|
error: 'Claude statsig directory not found'
|
|
};
|
|
}
|
|
|
|
const files = await fs.readdir(this.claudeStatsigDir);
|
|
const sessionFile = files.find(file => file.startsWith('statsig.session_id.'));
|
|
|
|
if (!sessionFile) {
|
|
return {
|
|
hasSession: false,
|
|
error: 'No session file found'
|
|
};
|
|
}
|
|
|
|
const sessionFilePath = path.join(this.claudeStatsigDir, sessionFile);
|
|
const sessionData = await fs.readFile(sessionFilePath, 'utf8');
|
|
const sessionInfo = JSON.parse(sessionData);
|
|
|
|
const now = Date.now();
|
|
const startTime = sessionInfo.startTime;
|
|
const lastUpdate = sessionInfo.lastUpdate;
|
|
|
|
// Calculate session duration
|
|
const sessionDuration = now - startTime;
|
|
const sessionDurationMinutes = Math.floor(sessionDuration / (1000 * 60));
|
|
const sessionDurationHours = Math.floor(sessionDurationMinutes / 60);
|
|
const remainingMinutes = sessionDurationMinutes % 60;
|
|
|
|
// Calculate time since last update
|
|
const timeSinceLastUpdate = now - lastUpdate;
|
|
const timeSinceLastUpdateMinutes = Math.floor(timeSinceLastUpdate / (1000 * 60));
|
|
|
|
// CORRECTED: Calculate next reset time based on scheduled reset hours
|
|
// Claude sessions reset at specific times, not fixed durations
|
|
const resetHours = [1, 7, 13, 19]; // 1am, 7am, 1pm, 7pm local time
|
|
const sessionStartDate = new Date(startTime);
|
|
|
|
// Find next reset time after session start
|
|
let nextResetTime = new Date(sessionStartDate);
|
|
nextResetTime.setMinutes(0, 0, 0); // Set to exact hour
|
|
|
|
// Find the next reset hour
|
|
let foundReset = false;
|
|
for (let i = 0; i < resetHours.length * 2; i++) { // Check up to 2 full days
|
|
const currentDay = Math.floor(i / resetHours.length);
|
|
const hourIndex = i % resetHours.length;
|
|
const resetHour = resetHours[hourIndex];
|
|
|
|
const testResetTime = new Date(sessionStartDate);
|
|
testResetTime.setDate(testResetTime.getDate() + currentDay);
|
|
testResetTime.setHours(resetHour, 0, 0, 0);
|
|
|
|
if (testResetTime > sessionStartDate) {
|
|
nextResetTime = testResetTime;
|
|
foundReset = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!foundReset) {
|
|
// Fallback: assume next day 7am if no pattern found
|
|
nextResetTime.setDate(nextResetTime.getDate() + 1);
|
|
nextResetTime.setHours(7, 0, 0, 0);
|
|
}
|
|
|
|
const sessionLimitMs = nextResetTime.getTime() - startTime;
|
|
let timeRemaining = nextResetTime.getTime() - now;
|
|
let timeRemainingMinutes = Math.floor(timeRemaining / (1000 * 60));
|
|
let timeRemainingHours = Math.floor(timeRemainingMinutes / 60);
|
|
let remainingMinutesDisplay = timeRemainingMinutes % 60;
|
|
|
|
// If session is expired but has recent activity, calculate next reset time
|
|
if (timeRemaining <= 0) {
|
|
const RECENT_ACTIVITY_THRESHOLD = 5 * 60 * 1000; // 5 minutes
|
|
const timeSinceLastUpdate = now - lastUpdate;
|
|
|
|
if (timeSinceLastUpdate < RECENT_ACTIVITY_THRESHOLD) {
|
|
// Session was renewed, find the NEXT reset time
|
|
let nextNextResetTime = new Date(nextResetTime);
|
|
let foundNextReset = false;
|
|
|
|
for (let i = 0; i < resetHours.length; i++) {
|
|
const resetHour = resetHours[i];
|
|
const testResetTime = new Date(nextResetTime);
|
|
|
|
if (resetHour > nextResetTime.getHours()) {
|
|
// Same day, later hour
|
|
testResetTime.setHours(resetHour, 0, 0, 0);
|
|
} else {
|
|
// Next day
|
|
testResetTime.setDate(testResetTime.getDate() + 1);
|
|
testResetTime.setHours(resetHour, 0, 0, 0);
|
|
}
|
|
|
|
if (testResetTime > nextResetTime) {
|
|
nextNextResetTime = testResetTime;
|
|
foundNextReset = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!foundNextReset) {
|
|
// Default to next day 1am
|
|
nextNextResetTime.setDate(nextNextResetTime.getDate() + 1);
|
|
nextNextResetTime.setHours(1, 0, 0, 0);
|
|
}
|
|
|
|
timeRemaining = nextNextResetTime.getTime() - now;
|
|
timeRemainingMinutes = Math.floor(timeRemaining / (1000 * 60));
|
|
timeRemainingHours = Math.floor(timeRemainingMinutes / 60);
|
|
remainingMinutesDisplay = timeRemainingMinutes % 60;
|
|
nextResetTime = nextNextResetTime;
|
|
}
|
|
}
|
|
|
|
return {
|
|
hasSession: true,
|
|
sessionId: sessionInfo.sessionID,
|
|
startTime: startTime,
|
|
lastUpdate: lastUpdate,
|
|
sessionDuration: {
|
|
ms: sessionDuration,
|
|
minutes: sessionDurationMinutes,
|
|
hours: sessionDurationHours,
|
|
remainingMinutes: remainingMinutes,
|
|
formatted: `${sessionDurationHours}h ${remainingMinutes}m`
|
|
},
|
|
timeSinceLastUpdate: {
|
|
ms: timeSinceLastUpdate,
|
|
minutes: timeSinceLastUpdateMinutes,
|
|
formatted: `${timeSinceLastUpdateMinutes}m`
|
|
},
|
|
estimatedTimeRemaining: {
|
|
ms: timeRemaining,
|
|
minutes: timeRemainingMinutes,
|
|
hours: timeRemainingHours,
|
|
remainingMinutes: remainingMinutesDisplay,
|
|
formatted: timeRemaining > 0 ? `${timeRemainingHours}h ${remainingMinutesDisplay}m` : 'Session expired',
|
|
isExpired: timeRemaining <= 0 && timeSinceLastUpdate >= (5 * 60 * 1000) // Expired only if past reset time AND no recent activity
|
|
},
|
|
sessionLimit: {
|
|
ms: sessionLimitMs,
|
|
hours: Math.floor(sessionLimitMs / (1000 * 60 * 60)),
|
|
minutes: Math.floor((sessionLimitMs % (1000 * 60 * 60)) / (1000 * 60)),
|
|
formatted: `${Math.floor(sessionLimitMs / (1000 * 60 * 60))}h ${Math.floor((sessionLimitMs % (1000 * 60 * 60)) / (1000 * 60))}m`,
|
|
nextResetTime: nextResetTime.toISOString(),
|
|
resetHour: nextResetTime.getHours()
|
|
}
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
hasSession: false,
|
|
error: `Failed to read session info: ${error.message}`
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate activity data for the heatmap using complete conversation set
|
|
* @param {Array} conversations - Complete conversation array (not limited)
|
|
* @returns {Object} Activity data including daily contributions and stats
|
|
*/
|
|
generateActivityDataFromConversations(conversations) {
|
|
const dailyActivity = new Map();
|
|
const oneYearAgo = new Date();
|
|
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
|
|
oneYearAgo.setHours(0, 0, 0, 0);
|
|
|
|
const today = new Date();
|
|
const todayEnd = new Date();
|
|
todayEnd.setHours(23, 59, 59, 999);
|
|
|
|
console.log(`🔥 Generating activity data for ${conversations.length} conversations (FULL DATASET)...`);
|
|
|
|
// Process conversations for daily activity
|
|
conversations.forEach(conversation => {
|
|
if (!conversation.lastModified) return;
|
|
|
|
const date = new Date(conversation.lastModified);
|
|
if (date < oneYearAgo || date > todayEnd) return;
|
|
|
|
const dateKey = date.toISOString().split('T')[0]; // YYYY-MM-DD
|
|
|
|
const current = dailyActivity.get(dateKey) || {
|
|
conversations: 0,
|
|
tokens: 0,
|
|
messages: 0,
|
|
tools: 0,
|
|
date: dateKey
|
|
};
|
|
|
|
current.conversations += 1;
|
|
current.tokens += conversation.tokens || 0;
|
|
current.messages += conversation.messageCount || 0;
|
|
current.tools += (conversation.toolUsage?.totalToolCalls || 0);
|
|
|
|
dailyActivity.set(dateKey, current);
|
|
});
|
|
|
|
// Convert to array and sort by date
|
|
const activityArray = Array.from(dailyActivity.values())
|
|
.sort((a, b) => a.date.localeCompare(b.date));
|
|
|
|
// Calculate stats
|
|
const totalContributions = activityArray.reduce((sum, day) => sum + day.conversations, 0);
|
|
const totalTools = activityArray.reduce((sum, day) => sum + day.tools, 0);
|
|
const { longestStreak, currentStreak } = this.calculateStreaks(activityArray);
|
|
|
|
console.log(`🔥 Activity data generated: ${activityArray.length} active days, ${totalContributions} total contributions, ${totalTools} tool calls`);
|
|
|
|
return {
|
|
dailyActivity: activityArray,
|
|
totalContributions,
|
|
totalTools,
|
|
longestStreak,
|
|
currentStreak,
|
|
activeDays: activityArray.length,
|
|
startDate: oneYearAgo.toISOString().split('T')[0],
|
|
endDate: today.toISOString().split('T')[0]
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Generate activity data for the heatmap (legacy method for backward compatibility)
|
|
* @returns {Object} Activity data including daily contributions and stats
|
|
*/
|
|
generateActivityData() {
|
|
if (!this.data || !this.data.conversations) {
|
|
return {
|
|
dailyActivity: [],
|
|
totalContributions: 0,
|
|
longestStreak: 0,
|
|
currentStreak: 0
|
|
};
|
|
}
|
|
|
|
return this.generateActivityDataFromConversations(this.data.conversations);
|
|
}
|
|
|
|
/**
|
|
* Calculate contribution streaks
|
|
* @param {Array} activityArray - Sorted array of daily activity
|
|
* @returns {Object} Longest and current streak counts
|
|
*/
|
|
calculateStreaks(activityArray) {
|
|
if (activityArray.length === 0) {
|
|
return { longestStreak: 0, currentStreak: 0 };
|
|
}
|
|
|
|
let longestStreak = 0;
|
|
let currentStreak = 0;
|
|
let tempStreak = 0;
|
|
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
|
|
// Create a map of active dates for quick lookup
|
|
const activeDates = new Set(activityArray.map(day => day.date));
|
|
|
|
// Check streak going backwards from today
|
|
let checkDate = new Date(today);
|
|
let foundToday = false;
|
|
|
|
// First check if today or yesterday has activity
|
|
for (let i = 0; i < 2; i++) {
|
|
const dateKey = checkDate.toISOString().split('T')[0];
|
|
if (activeDates.has(dateKey)) {
|
|
foundToday = true;
|
|
break;
|
|
}
|
|
checkDate.setDate(checkDate.getDate() - 1);
|
|
}
|
|
|
|
if (foundToday) {
|
|
// Calculate current streak
|
|
checkDate = new Date(today);
|
|
while (true) {
|
|
const dateKey = checkDate.toISOString().split('T')[0];
|
|
if (activeDates.has(dateKey)) {
|
|
currentStreak++;
|
|
checkDate.setDate(checkDate.getDate() - 1);
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Calculate longest streak by checking all consecutive days
|
|
const oneYearAgo = new Date(today);
|
|
oneYearAgo.setFullYear(today.getFullYear() - 1);
|
|
|
|
checkDate = new Date(oneYearAgo);
|
|
while (checkDate <= today) {
|
|
const dateKey = checkDate.toISOString().split('T')[0];
|
|
|
|
if (activeDates.has(dateKey)) {
|
|
tempStreak++;
|
|
longestStreak = Math.max(longestStreak, tempStreak);
|
|
} else {
|
|
tempStreak = 0;
|
|
}
|
|
|
|
checkDate.setDate(checkDate.getDate() + 1);
|
|
}
|
|
|
|
return { longestStreak, currentStreak };
|
|
}
|
|
|
|
stop() {
|
|
// Stop file watchers
|
|
this.fileWatcher.stop();
|
|
|
|
// Stop Cloudflare Tunnel
|
|
this.stopCloudflareTunnel();
|
|
|
|
// Stop server
|
|
// Close WebSocket server
|
|
if (this.webSocketServer) {
|
|
this.webSocketServer.close();
|
|
}
|
|
|
|
// Shutdown notification manager
|
|
if (this.notificationManager) {
|
|
this.notificationManager.shutdown();
|
|
}
|
|
|
|
// Shutdown console bridge
|
|
if (this.consoleBridge) {
|
|
this.consoleBridge.shutdown();
|
|
}
|
|
|
|
// Stop Claude API Proxy
|
|
if (this.claudeApiProxy) {
|
|
this.claudeApiProxy.stop();
|
|
}
|
|
|
|
if (this.httpServer) {
|
|
this.httpServer.close();
|
|
}
|
|
|
|
// Keep compatibility
|
|
if (this.server) {
|
|
this.server.close();
|
|
}
|
|
|
|
// Log cache statistics before stopping
|
|
this.dataCache.logStats();
|
|
|
|
console.log(chalk.yellow('Analytics dashboard stopped'));
|
|
}
|
|
}
|
|
|
|
async function runAnalytics(options = {}) {
|
|
// Determine if we're opening to a specific page
|
|
const openTo = options.openTo;
|
|
|
|
if (openTo === 'agents') {
|
|
console.log(chalk.blue('💬 Starting Claude Code Chats Dashboard...'));
|
|
} else {
|
|
console.log(chalk.blue('📊 Starting Claude Code Analytics Dashboard...'));
|
|
}
|
|
|
|
const analytics = new ClaudeAnalytics(options);
|
|
|
|
try {
|
|
// Handle Cloudflare Tunnel prompt BEFORE initializing anything
|
|
let useCloudflare = false;
|
|
|
|
if (options.tunnel) {
|
|
useCloudflare = await analytics.promptCloudflareSetup();
|
|
}
|
|
|
|
await analytics.initialize();
|
|
|
|
// Create web dashboard files
|
|
// Web dashboard files are now static in analytics-web directory
|
|
|
|
await analytics.startServer();
|
|
|
|
// Start Cloudflare Tunnel BEFORE other services if requested and confirmed
|
|
if (useCloudflare) {
|
|
const cloudflareStarted = await analytics.startCloudflareTunnel();
|
|
if (!cloudflareStarted) {
|
|
console.log(chalk.yellow('⚠️ Continuing with localhost only...'));
|
|
}
|
|
// Wait a bit longer for tunnel to stabilize
|
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
}
|
|
|
|
await analytics.openBrowser(openTo);
|
|
|
|
const accessUrl = analytics.publicUrl || `http://localhost:${analytics.port}`;
|
|
|
|
if (openTo === 'agents') {
|
|
console.log(chalk.green('✅ Claude Code Chats dashboard is running!'));
|
|
console.log(chalk.cyan(`📱 Access at: ${accessUrl}/#agents`));
|
|
} else {
|
|
console.log(chalk.green('✅ Analytics dashboard is running!'));
|
|
console.log(chalk.cyan(`📱 Access at: ${accessUrl}`));
|
|
}
|
|
|
|
if (analytics.publicUrl) {
|
|
console.log(chalk.gray('🔒 Secure access via Cloudflare Tunnel'));
|
|
} else {
|
|
console.log(chalk.gray('🏠 Local access only'));
|
|
}
|
|
|
|
console.log(chalk.gray('Press Ctrl+C to stop the server'));
|
|
|
|
// Handle graceful shutdown
|
|
process.on('SIGINT', () => {
|
|
console.log(chalk.yellow('\n🛑 Shutting down analytics dashboard...'));
|
|
analytics.stop();
|
|
process.exit(0);
|
|
});
|
|
|
|
// Keep the process running
|
|
await new Promise(() => {});
|
|
|
|
} catch (error) {
|
|
console.error(chalk.red('❌ Failed to start analytics dashboard:'), error.message);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
|
|
// If this file is executed directly, run analytics
|
|
if (require.main === module) {
|
|
runAnalytics().catch(error => {
|
|
console.error(chalk.red('❌ Analytics startup failed:'), error);
|
|
process.exit(1);
|
|
});
|
|
}
|
|
|
|
module.exports = {
|
|
runAnalytics
|
|
}; |