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

513 lines (435 loc) 15 kB
/** * CLI Database Service using sql.js (CommonJS) * Pure JavaScript SQLite - no native compilation needed */ const initSqlJs = require('sql.js'); const path = require('path'); const os = require('os'); const fs = require('fs'); class CLIDatabaseService { constructor() { this.db = null; this.SQL = null; this.dbPath = this.getDbPath(); } getDbPath() { const fs = require('fs'); // Try multiple possible database locations, including npm global location const possiblePaths = [ // NPM Global package location (where Electron UI runs from) path.join(process.env.APPDATA || '', 'npm', 'node_modules', 'dragon-ui-claude', 'usage.db'), path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'node_modules', 'dragon-ui-claude', 'usage.db'), // Development locations path.join(process.cwd(), 'usage.db'), // Same directory as CLI path.join(__dirname, '..', 'usage.db'), // Parent directory (Dragon-Ui) path.join(process.cwd(), '..', 'usage.db'), // Parent of current directory 'usage.db', // Relative to current working directory // User data directories path.join(os.homedir(), 'usage.db'), // User home directory path.join(os.homedir(), '.dragon-ui', 'usage.db'), // User data directory path.join(process.env.APPDATA || '', 'dragon-ui', 'usage.db'), // Windows AppData path.join(process.env.LOCALAPPDATA || '', 'dragon-ui', 'usage.db'), // Windows LocalAppData // Other possible locations path.join(__dirname, '..', '..', 'usage.db'), // Two levels up path.join('C:', 'temp', 'usage.db'), // Temp directory path.join('C:', 'Users', process.env.USERNAME || '', 'Desktop', 'usage.db'), // Desktop // Try to find npm global prefix location dynamically ...((() => { try { const { execSync } = require('child_process'); const npmGlobalPrefix = execSync('npm config get prefix', { encoding: 'utf8' }).trim(); return [ path.join(npmGlobalPrefix, 'node_modules', 'dragon-ui-claude', 'usage.db'), path.join(npmGlobalPrefix, 'usage.db') ]; } catch (e) { return []; } })()) ]; let dbPath = null; let newestDb = null; let newestTime = 0; // Find the most recently modified database file (silent search) for (const testPath of possiblePaths) { const fullPath = path.resolve(testPath); if (fs.existsSync(fullPath)) { const stats = fs.statSync(fullPath); // Use the most recently modified database if (stats.mtime.getTime() > newestTime) { newestTime = stats.mtime.getTime(); newestDb = fullPath; this.lastDbModTime = newestTime; } } } if (newestDb) { return newestDb; } else { this.lastDbModTime = 0; return path.join(process.cwd(), 'usage.db'); // Fallback } } /** * Check if database has been updated since last read */ isDatabaseUpdated() { try { if (!fs.existsSync(this.dbPath)) return false; const stats = fs.statSync(this.dbPath); const currentModTime = stats.mtime.getTime(); if (currentModTime > this.lastDbModTime) { this.lastDbModTime = currentModTime; return true; } return false; } catch (error) { return false; } } /** * Refresh database if it has been updated */ async refreshIfNeeded() { if (this.isDatabaseUpdated()) { // Close current database if (this.db) { this.db.close(); this.db = null; } // Wait briefly for any file system sync await new Promise(resolve => setTimeout(resolve, 50)); // Reinitialize with fresh data await this.init(); } } async init() { try { // Initialize sql.js this.SQL = await initSqlJs(); // Read existing database file or create new let filebuffer; if (fs.existsSync(this.dbPath)) { filebuffer = fs.readFileSync(this.dbPath); } else { throw new Error('Database not found. Please run the Electron version first to create the database.'); } // Create database instance this.db = new this.SQL.Database(filebuffer); } catch (error) { throw error; } } async getSessionStats() { // Check for database updates before querying await this.refreshIfNeeded(); const query = ` WITH session_segments AS ( SELECT session_id, timestamp, cost, input_tokens, output_tokens, COALESCE(cache_creation_input_tokens, 0) as cache_creation_input_tokens, COALESCE(cache_read_input_tokens, 0) as cache_read_input_tokens, project, ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY timestamp) - 1 as row_num, CAST((julianday(timestamp) - julianday(MIN(timestamp) OVER (PARTITION BY session_id))) * 24 * 60 AS INTEGER) as minutes_from_start, CAST(((julianday(timestamp) - julianday(MIN(timestamp) OVER (PARTITION BY session_id))) * 24 * 60) / 300 AS INTEGER) as segment_num FROM usage_entries ) SELECT session_id || '_' || segment_num as session_id, COUNT(*) as entry_count, SUM(cost) as total_cost, SUM(input_tokens + output_tokens + cache_creation_input_tokens + cache_read_input_tokens) as total_tokens, MIN(timestamp) as start_time, MAX(timestamp) as end_time, project FROM session_segments GROUP BY session_id, segment_num ORDER BY start_time DESC `; const stmt = this.db.prepare(query); const result = []; while (stmt.step()) { const row = stmt.getAsObject(); result.push(row); } stmt.free(); return result; } async getProjectStats() { // Check for database updates before querying await this.refreshIfNeeded(); const query = ` SELECT project, COUNT(*) as entry_count, SUM(cost) as total_cost, SUM(input_tokens + output_tokens + COALESCE(cache_creation_input_tokens, 0) + COALESCE(cache_read_input_tokens, 0)) as total_tokens, COUNT(DISTINCT session_id) as session_count, MAX(timestamp) as last_activity FROM usage_entries WHERE project IS NOT NULL GROUP BY project ORDER BY total_cost DESC `; const stmt = this.db.prepare(query); const result = []; while (stmt.step()) { const row = stmt.getAsObject(); result.push(row); } stmt.free(); return result; } async getMonthlyStats() { // Check for database updates before querying await this.refreshIfNeeded(); const query = ` SELECT strftime('%Y-%m', timestamp) as month, SUM(cost) as total_cost, COUNT(*) as entry_count, SUM(input_tokens + output_tokens + COALESCE(cache_creation_input_tokens, 0) + COALESCE(cache_read_input_tokens, 0)) as total_tokens, COUNT(DISTINCT session_id) as session_count, COUNT(DISTINCT DATE(timestamp)) as active_days FROM usage_entries GROUP BY strftime('%Y-%m', timestamp) ORDER BY month DESC `; const stmt = this.db.prepare(query); const result = []; while (stmt.step()) { const row = stmt.getAsObject(); result.push(row); } stmt.free(); return result; } async getDailyStats(days = 30) { // Check for database updates before querying await this.refreshIfNeeded(); const query = ` SELECT DATE(timestamp) as date, SUM(cost) as total_cost, COUNT(*) as entry_count, SUM(input_tokens + output_tokens + COALESCE(cache_creation_input_tokens, 0) + COALESCE(cache_read_input_tokens, 0)) as total_tokens, COUNT(DISTINCT session_id) as session_count, MIN(timestamp) as first_activity, MAX(timestamp) as last_activity FROM usage_entries WHERE DATE(timestamp) >= DATE('now', '-${days} days') GROUP BY DATE(timestamp) ORDER BY date DESC `; const stmt = this.db.prepare(query); const result = []; while (stmt.step()) { const row = stmt.getAsObject(); result.push(row); } stmt.free(); return result; } async getDbInfo() { // Check for database updates before querying await this.refreshIfNeeded(); const results = {}; // Get entry count let stmt = this.db.prepare('SELECT COUNT(*) as entryCount FROM usage_entries'); stmt.step(); results.entryCount = stmt.getAsObject().entryCount; stmt.free(); // Get session count stmt = this.db.prepare('SELECT COUNT(DISTINCT session_id) as sessionCount FROM usage_entries'); stmt.step(); results.sessionCount = stmt.getAsObject().sessionCount; stmt.free(); // Get project count stmt = this.db.prepare('SELECT COUNT(DISTINCT project) as projectCount FROM usage_entries WHERE project IS NOT NULL'); stmt.step(); results.projectCount = stmt.getAsObject().projectCount; stmt.free(); // Get file size try { const stats = fs.statSync(this.dbPath); results.dbSizeMB = stats.size / (1024 * 1024); } catch (error) { results.dbSizeMB = 0; } return results; } async getRealSessionCount() { // Check for database updates before querying await this.refreshIfNeeded(); const stmt = this.db.prepare('SELECT COUNT(DISTINCT session_id) as count FROM usage_entries'); stmt.step(); const result = stmt.getAsObject().count; stmt.free(); return result; } async getSessionModels(sessionId) { // Extract the base session ID (remove segment suffix if present) const baseSessionId = sessionId.includes('_') ? sessionId.split('_')[0] : sessionId; const query = ` SELECT DISTINCT model FROM usage_entries WHERE session_id = ? AND model IS NOT NULL ORDER BY model `; const stmt = this.db.prepare(query); stmt.bind([baseSessionId]); const result = []; while (stmt.step()) { const row = stmt.getAsObject(); result.push(row.model); } stmt.free(); return result; } async getAllModels() { // Check for database updates before querying await this.refreshIfNeeded(); const query = ` SELECT DISTINCT model FROM usage_entries WHERE model IS NOT NULL ORDER BY model `; const stmt = this.db.prepare(query); const result = []; while (stmt.step()) { const row = stmt.getAsObject(); result.push(row.model); } stmt.free(); return result; } async getCurrentSessionInfo() { // Check for database updates before querying await this.refreshIfNeeded(); // Check for any entries in the last 30 minutes (same as electron) const query = ` SELECT session_id, project, SUM(cost) as total_cost, SUM(input_tokens + output_tokens + COALESCE(cache_creation_input_tokens, 0) + COALESCE(cache_read_input_tokens, 0)) as total_tokens, COUNT(*) as entry_count, MIN(timestamp) as session_start, MAX(timestamp) as last_activity FROM usage_entries WHERE timestamp > datetime('now', '-30 minutes') GROUP BY session_id ORDER BY last_activity DESC LIMIT 1 `; const stmt = this.db.prepare(query); let result = null; if (stmt.step()) { result = stmt.getAsObject(); // Also calculate duration in minutes if (result.session_start) { const startTime = new Date(result.session_start); const now = new Date(); result.duration = Math.floor((now - startTime) / 60000); // minutes } } stmt.free(); return result; } /** * Get last processed timestamp from database */ getLastTimestamp() { try { const stmt = this.db.prepare('SELECT MAX(timestamp) as last_timestamp FROM usage_entries'); stmt.step(); const result = stmt.getAsObject(); stmt.free(); return result.last_timestamp; } catch (error) { return null; } } /** * Insert a single entry into database */ insertEntry(entry) { try { // Use INSERT OR IGNORE to handle duplicates gracefully const stmt = this.db.prepare(` INSERT OR IGNORE INTO usage_entries ( timestamp, session_id, model, project, input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens, cost ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `); stmt.run([ entry.timestamp, entry.session_id, entry.model, entry.project, entry.input_tokens || 0, entry.output_tokens || 0, entry.cache_creation_input_tokens || 0, entry.cache_read_input_tokens || 0, entry.cost || 0 ]); stmt.free(); // Save to file after insert (for sql.js persistence) this.saveToFile(); return true; } catch (error) { // Silent error - might be database lock or other issue return false; } } /** * Insert batch of entries */ insertBatch(entries) { try { for (const entry of entries) { this.insertEntry(entry); } // Save database to file after batch insert (important for sql.js!) this.saveToFile(); return true; } catch (error) { return false; } } /** * Save database to file (required for sql.js persistence) */ saveToFile() { try { if (this.db && this.dbPath) { const data = this.db.export(); const fs = require('fs'); fs.writeFileSync(this.dbPath, data); } } catch (error) { // Silent error } } close() { if (this.db) { this.db.close(); } } } module.exports = CLIDatabaseService;