UNPKG

dragon-ui-claude

Version:

🐲 Ultra-fast, cross-platform Claude Code Max usage dashboard with dragon-inspired design, advanced background services, and multi-currency support

703 lines (600 loc) 23.2 kB
/** * CLI Data Adapter (CommonJS) * Uses the same services as Electron UI for identical data */ const path = require('path'); // Import CLI database service (compatible with Windows) const CLIDatabaseService = require('./database-cli.cjs'); // Import PathManager to scan for JSONL files like Electron UI const PathManagerService = require('../services/path-manager.cjs'); // Import model price service for dynamic pricing const { modelPriceService } = require('../services/model-price-service.cjs'); class CLIDataAdapter { constructor(settings) { this.settings = settings; this.lastUpdate = 0; this.cache = new Map(); // Use Windows-compatible database service this.db = new CLIDatabaseService(); // Use PathManager to scan for JSONL files like Electron UI this.pathManager = new PathManagerService(); } /** * Initialize Windows-compatible database service */ async init() { try { // Initialize database if (!this.db.db) { await this.db.init(); } return true; } catch (error) { return false; } } /** * Force refresh data like Electron UI (silent) */ async forceRefreshData() { try { // Add timeout to prevent hanging const refreshPromise = this.doForceRefreshData(); const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 5000) // 5 second timeout ); await Promise.race([refreshPromise, timeoutPromise]); } catch (error) { // Silent error - no logs, don't hang the CLI } } /** * Internal refresh implementation */ async doForceRefreshData() { // Silent scan and refresh like Electron UI const allPaths = this.pathManager.getAllPaths(); const activePaths = allPaths.active || []; if (activePaths.length > 0) { // Get last processed timestamp from database const lastTimestamp = this.db.getLastTimestamp ? this.db.getLastTimestamp() : null; // Process JSONL files and add new entries to database for (const basePath of activePaths) { try { // Find JSONL files let jsonlFiles = []; if (basePath.startsWith('\\\\wsl$')) { jsonlFiles = this.findJsonlFilesWSL(basePath); } else { const pattern = path.join(basePath, '**/*.jsonl').replace(/\\/g, '/'); const glob = require('glob'); jsonlFiles = glob.sync(pattern); } // Process each JSONL file for new entries (limit to prevent hanging) for (const file of jsonlFiles.slice(0, 10)) { // Limit to 10 files per refresh await this.processJsonlFileIncremental(file, lastTimestamp); } } catch (error) { // Silent error } } } // Silent database refresh await this.db.refreshIfNeeded(); this.clearCache(); } /** * WSL-compatible file finder (copied from data-loader) */ findJsonlFilesWSL(basePath) { const fs = require('fs'); const jsonlFiles = []; const scanDirectory = (dir) => { try { const entries = fs.readdirSync(dir); for (const entry of entries) { const fullPath = path.join(dir, entry); try { const stat = fs.statSync(fullPath); if (stat.isDirectory()) { scanDirectory(fullPath); } else if (entry.endsWith('.jsonl')) { jsonlFiles.push(fullPath); } } catch (statError) { continue; } } } catch (error) { // Silent error } }; scanDirectory(basePath); return jsonlFiles; } /** * Process a single JSONL file incrementally (only new entries) */ async processJsonlFileIncremental(file, lastTimestamp) { const fs = require('fs'); let newEntriesCount = 0; const newEntries = []; const processedHashes = new Set(); try { const content = fs.readFileSync(file, 'utf8'); const lines = content.trim().split('\n'); for (const line of lines) { if (!line.trim()) continue; try { const entry = JSON.parse(line); // Only process assistant messages with usage data if (!entry.timestamp || entry.type !== 'assistant') continue; if (!entry.message?.usage) continue; if (typeof entry.message.usage.input_tokens !== 'number') continue; if (typeof entry.message.usage.output_tokens !== 'number') continue; // Skip if entry is older than last processed timestamp if (lastTimestamp && entry.timestamp <= lastTimestamp) continue; // Duplicate detection using messageId + requestId const uniqueHash = this.createUniqueHash(entry); if (uniqueHash && processedHashes.has(uniqueHash)) { continue; // Skip duplicate } if (uniqueHash) { processedHashes.add(uniqueHash); } // Extract and process the entry const usageEntry = this.createUsageEntry(entry, file); // Store for batch insert newEntries.push(usageEntry); newEntriesCount++; } catch (parseError) { continue; // Skip invalid JSON } } // Batch insert all new entries into database if (newEntries.length > 0) { try { if (this.db.insertBatch) { this.db.insertBatch(newEntries); } } catch (dbError) { // Silent error - database might be locked } } } catch (fileError) { // Silent error } return newEntriesCount; } /** * Create unique hash for duplicate detection */ createUniqueHash(entry) { const messageId = entry.message?.id || entry.messageId; const requestId = entry.requestId; if (messageId && requestId) { return `${messageId}-${requestId}`; } else if (messageId) { return messageId; } else if (requestId) { return requestId; } // Fallback: use timestamp + session + tokens as hash return `${entry.timestamp}-${entry.sessionId}-${entry.message?.usage?.input_tokens}-${entry.message?.usage?.output_tokens}`; } /** * Create a standardized usage entry from raw JSONL entry */ createUsageEntry(entry, file) { // Extract project from file path or cwd const projectName = this.extractProjectName(file, entry); // Truncate session ID for consistent display format const sessionId = this.truncateSessionId(entry.sessionId); // Validate and fix timestamp let validatedTimestamp = entry.timestamp; if (!entry.timestamp || typeof entry.timestamp !== 'string') { validatedTimestamp = new Date().toISOString(); } else { const date = new Date(entry.timestamp); if (isNaN(date.getTime()) || date.getFullYear() < 2020) { validatedTimestamp = new Date().toISOString(); } } const usageEntry = { timestamp: validatedTimestamp, session_id: entry.sessionId, // Use full session ID for database model: entry.message.model || 'unknown', project: projectName, // Token counts input_tokens: entry.message.usage.input_tokens || 0, output_tokens: entry.message.usage.output_tokens || 0, cache_creation_input_tokens: entry.message.usage.cache_creation_input_tokens || 0, cache_read_input_tokens: entry.message.usage.cache_read_input_tokens || 0, // Calculate cost (simplified) cost: this.calculateCost(entry.message.usage, entry.message.model) }; return usageEntry; } /** * Truncate session ID for consistent display format */ truncateSessionId(fullSessionId) { if (!fullSessionId || typeof fullSessionId !== 'string') { return 'unknown'; } const parts = fullSessionId.split('-'); if (parts.length >= 2) { const secondLast = parts[parts.length - 2]; const last = parts[parts.length - 1].substring(0, 3); return `${secondLast}-${last}`; } return fullSessionId.substring(0, 10); } /** * Extract project name from file path or cwd */ extractProjectName(filePath, entry) { // First try to extract from cwd field if (entry.cwd && typeof entry.cwd === 'string') { const cwdParts = entry.cwd.split(path.sep); const projectName = cwdParts[cwdParts.length - 1]; if (projectName && projectName !== '.' && projectName !== '') { return projectName; } } // Fallback: extract from file path const pathParts = filePath.split(path.sep); for (let i = pathParts.length - 1; i >= 0; i--) { const part = pathParts[i]; if (part && part !== 'projects' && !part.endsWith('.jsonl')) { return part; } } return 'unknown'; } /** * Dynamic cost calculation using model price service (like Electron UI) */ calculateCost(usage, model = 'unknown') { const inputTokens = usage.input_tokens || 0; const outputTokens = usage.output_tokens || 0; const cacheCreateTokens = usage.cache_creation_input_tokens || 0; const cacheReadTokens = usage.cache_read_input_tokens || 0; // Get current pricing from model price service (with automatic updates) const pricing = modelPriceService.getModelPrices(model); const inputRate = pricing.input; const outputRate = pricing.output; const cacheCreateRate = pricing.cacheWrite; const cacheReadRate = pricing.cacheRead; const inputCost = (inputTokens / 1000000) * inputRate; const outputCost = (outputTokens / 1000000) * outputRate; const cacheCost = (cacheCreateTokens / 1000000) * cacheCreateRate; const cacheReadCost = (cacheReadTokens / 1000000) * cacheReadRate; return inputCost + outputCost + cacheCost + cacheReadCost; } /** * Get overview/dashboard data (Windows-compatible with real-time sync) */ async getOverviewData() { try { const cacheKey = 'overview'; const cached = this.getCachedData(cacheKey, 2000); // 2 seconds cache for real-time if (cached) return cached; // Get fresh data from database (with auto-refresh) const sessionStats = await this.db.getSessionStats(); const projectStats = await this.db.getProjectStats(); const dailyStats = await this.db.getDailyStats(7); // Calculate totals from arrays const totalCost = sessionStats.reduce((sum, s) => sum + (s.total_cost || 0), 0); const totalSessions = await this.db.getRealSessionCount(); const totalTokens = sessionStats.reduce((sum, s) => sum + (s.total_tokens || 0), 0); // Calculate averages const avgCostPerSession = totalSessions > 0 ? totalCost / totalSessions : 0; const avgTokensPerSession = totalSessions > 0 ? totalTokens / totalSessions : 0; // Get all unique models from database const models = await this.db.getAllModels(); // Get current session info using 30-minute logic (same as Electron) const currentSessionInfo = await this.db.getCurrentSessionInfo(); // Session is active if we found data (means activity in last 30 minutes) const sessionActive = !!currentSessionInfo; const status = sessionActive ? 'active' : 'inactive'; // Build current session object exactly like electron const currentSession = sessionActive ? { active: true, id: currentSessionInfo.session_id, started: currentSessionInfo.session_start, duration: currentSessionInfo.duration || 0, cost: currentSessionInfo.total_cost || 0 } : { active: false }; const data = { // Basic stats (exactly like Electron UI) totalCost: totalCost, totalSessions: totalSessions, totalProjects: projectStats.length || 0, totalTokens: totalTokens, // Averages and derived stats averageCostPerSession: avgCostPerSession, avgTokensPerSession: avgTokensPerSession, // Time-based stats activeDays: dailyStats.filter(d => d.total_cost > 0).length, lastActivity: currentSessionInfo ? currentSessionInfo.last_activity : null, // Status and models (exactly like Electron UI) status: status, models: models, // Current session (exactly like Electron UI) currentSession: currentSession, // Metadata currency: this.settings.currency, lastUpdated: new Date().toISOString() }; this.setCachedData(cacheKey, data); return data; } catch (error) { return this.getEmptyOverviewData(); } } /** * Get projects data (Windows-compatible) */ async getProjectsData() { try { const cacheKey = 'projects'; const cached = this.getCachedData(cacheKey); if (cached) return cached; const projectStats = await this.db.getProjectStats(); const data = { projects: projectStats.map(project => ({ name: project.project || 'Unknown', totalCost: project.total_cost || 0, sessionCount: project.session_count || 0, totalTokens: project.total_tokens || 0, lastActivity: project.last_activity, avgCostPerSession: project.session_count > 0 ? (project.total_cost / project.session_count) : 0, currency: this.settings.currency })), summary: { totalProjects: projectStats.length, totalCost: projectStats.reduce((sum, p) => sum + (p.total_cost || 0), 0), totalSessions: projectStats.reduce((sum, p) => sum + (p.session_count || 0), 0), totalTokens: projectStats.reduce((sum, p) => sum + (p.total_tokens || 0), 0), currency: this.settings.currency }, lastUpdated: new Date().toISOString() }; this.setCachedData(cacheKey, data); return data; } catch (error) { return { projects: [], summary: {}, lastUpdated: new Date().toISOString() }; } } /** * Get sessions data (Windows-compatible) */ async getSessionsData() { try { const cacheKey = 'sessions'; const cached = this.getCachedData(cacheKey); if (cached) return cached; const sessionStats = await this.db.getSessionStats(); const recentSessions = sessionStats; const data = { sessions: recentSessions.map(session => ({ sessionId: session.session_id, project: session.project || 'Unknown', totalCost: session.total_cost || 0, totalTokens: session.total_tokens || 0, duration: session.duration || 0, startTime: session.start_time, endTime: session.end_time, entryCount: session.entry_count || 0, models: session.models || [], currency: this.settings.currency })), summary: { totalSessions: await this.db.getRealSessionCount(), totalCost: sessionStats.reduce((sum, s) => sum + (s.total_cost || 0), 0), totalTokens: sessionStats.reduce((sum, s) => sum + (s.total_tokens || 0), 0), avgCostPerSession: sessionStats.length > 0 ? (sessionStats.reduce((sum, s) => sum + (s.total_cost || 0), 0) / sessionStats.length) : 0, currency: this.settings.currency }, lastUpdated: new Date().toISOString() }; this.setCachedData(cacheKey, data); return data; } catch (error) { return { sessions: [], summary: {}, lastUpdated: new Date().toISOString() }; } } /** * Get monthly data (Windows-compatible) */ async getMonthlyData() { try { const cacheKey = 'monthly'; const cached = this.getCachedData(cacheKey); if (cached) return cached; const monthlyStats = await this.db.getMonthlyStats(); const data = { months: monthlyStats.map(month => ({ month: month.month, totalCost: month.total_cost || 0, sessionCount: month.session_count || 0, totalTokens: month.total_tokens || 0, activeDays: month.active_days || 0, avgDailyCost: month.active_days > 0 ? (month.total_cost / month.active_days) : 0, currency: this.settings.currency })), summary: { totalMonths: monthlyStats.length, totalCost: monthlyStats.reduce((sum, m) => sum + (m.total_cost || 0), 0), totalSessions: monthlyStats.reduce((sum, m) => sum + (m.session_count || 0), 0), totalTokens: monthlyStats.reduce((sum, m) => sum + (m.total_tokens || 0), 0), currency: this.settings.currency }, lastUpdated: new Date().toISOString() }; this.setCachedData(cacheKey, data); return data; } catch (error) { return { months: [], summary: {}, lastUpdated: new Date().toISOString() }; } } /** * Get daily data (Windows-compatible) */ async getDailyData() { try { const cacheKey = 'daily'; const cached = this.getCachedData(cacheKey); if (cached) return cached; const dailyStats = await this.db.getDailyStats(30); const data = { days: dailyStats.map(day => ({ date: day.date, totalCost: day.total_cost || 0, sessionCount: day.session_count || 0, totalTokens: day.total_tokens || 0, models: day.models || [], modelCount: day.model_count || 0, entryCount: day.entry_count || 0, firstActivity: day.first_activity, lastActivity: day.last_activity, currency: this.settings.currency })), summary: { totalDays: dailyStats.length, activeDays: dailyStats.filter(d => d.total_cost > 0).length, totalCost: dailyStats.reduce((sum, d) => sum + (d.total_cost || 0), 0), totalSessions: dailyStats.reduce((sum, d) => sum + (d.session_count || 0), 0), totalTokens: dailyStats.reduce((sum, d) => sum + (d.total_tokens || 0), 0), avgDailyCost: dailyStats.length > 0 ? (dailyStats.reduce((sum, d) => sum + (d.total_cost || 0), 0) / dailyStats.length) : 0, currency: this.settings.currency }, lastUpdated: new Date().toISOString() }; this.setCachedData(cacheKey, data); return data; } catch (error) { return { days: [], summary: {}, lastUpdated: new Date().toISOString() }; } } /** * Get active session data (Windows-compatible) */ async getActiveData() { try { const cacheKey = 'active'; const cached = this.getCachedData(cacheKey, 5000); // Cache for 5 seconds only if (cached) return cached; // Use the same 30-minute logic as overview page for consistency const currentSessionInfo = await this.db.getCurrentSessionInfo(); const sessionActive = !!currentSessionInfo; const todayStats = await this.db.getDailyStats(1); const todayData = todayStats.length > 0 ? todayStats[0] : null; // Get models for the current session if it exists let sessionModels = []; if (currentSessionInfo) { sessionModels = await this.db.getSessionModels(currentSessionInfo.session_id); } const data = { activeSession: currentSessionInfo ? { sessionId: currentSessionInfo.session_id, project: currentSessionInfo.project || 'Unknown', totalCost: currentSessionInfo.total_cost || 0, totalTokens: currentSessionInfo.total_tokens || 0, duration: currentSessionInfo.duration || 0, startTime: currentSessionInfo.session_start, lastActivity: currentSessionInfo.last_activity, entryCount: currentSessionInfo.entry_count || 0, models: sessionModels, currency: this.settings.currency } : null, todayStats: todayData ? { totalCost: todayData.total_cost || 0, sessionCount: todayData.session_count || 0, totalTokens: todayData.total_tokens || 0, models: todayData.models || [], modelCount: todayData.model_count || 0, currency: this.settings.currency } : null, isActive: sessionActive, // Use same 30-minute logic as overview lastUpdated: new Date().toISOString() }; this.setCachedData(cacheKey, data, 5000); return data; } catch (error) { return { activeSession: null, todayStats: null, isActive: false, lastUpdated: new Date().toISOString() }; } } /** * Cache management */ getCachedData(key, maxAge = 10000) { const cached = this.cache.get(key); if (cached && (Date.now() - cached.timestamp) < maxAge) { return cached.data; } return null; } setCachedData(key, data, maxAge = 10000) { this.cache.set(key, { data, timestamp: Date.now(), maxAge }); } clearCache() { this.cache.clear(); } /** * Get empty overview data as fallback */ getEmptyOverviewData() { return { totalCost: 0, totalSessions: 0, totalProjects: 0, totalTokens: 0, totalEntries: 0, averageCostPerSession: 0, avgTokensPerSession: 0, activeDays: 0, lastActivity: null, status: 'inactive', models: [], currentSession: { active: false }, currentMonth: { total_cost: 0, session_count: 0, total_tokens: 0 }, currency: this.settings.currency, databaseSize: 0, lastUpdated: new Date().toISOString() }; } /** * Refresh all data (Windows-compatible) */ async refreshAll() { this.clearCache(); try { // Force refresh the database first await this.forceRefreshData(); // Then pre-load all tab data const promises = [ this.getOverviewData(), this.getProjectsData(), this.getSessionsData(), this.getMonthlyData(), this.getDailyData(), this.getActiveData() ]; await Promise.all(promises); return true; } catch (error) { return false; } } } module.exports = CLIDataAdapter;