claude-code-templates 1.8.0 → 1.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/analytics.js CHANGED
@@ -2,14 +2,36 @@ const chalk = require('chalk');
2
2
  const fs = require('fs-extra');
3
3
  const path = require('path');
4
4
  const express = require('express');
5
- const chokidar = require('chokidar');
6
5
  const open = require('open');
7
6
  const os = require('os');
7
+ const packageJson = require('../package.json');
8
+ const StateCalculator = require('./analytics/core/StateCalculator');
9
+ const ProcessDetector = require('./analytics/core/ProcessDetector');
10
+ const ConversationAnalyzer = require('./analytics/core/ConversationAnalyzer');
11
+ const FileWatcher = require('./analytics/core/FileWatcher');
12
+ const SessionAnalyzer = require('./analytics/core/SessionAnalyzer');
13
+ const DataCache = require('./analytics/data/DataCache');
14
+ const WebSocketServer = require('./analytics/notifications/WebSocketServer');
15
+ const NotificationManager = require('./analytics/notifications/NotificationManager');
16
+ const PerformanceMonitor = require('./analytics/utils/PerformanceMonitor');
8
17
 
9
18
  class ClaudeAnalytics {
10
19
  constructor() {
11
20
  this.app = express();
12
21
  this.port = 3333;
22
+ this.stateCalculator = new StateCalculator();
23
+ this.processDetector = new ProcessDetector();
24
+ this.fileWatcher = new FileWatcher();
25
+ this.sessionAnalyzer = new SessionAnalyzer();
26
+ this.dataCache = new DataCache();
27
+ this.performanceMonitor = new PerformanceMonitor({
28
+ enabled: true,
29
+ logInterval: 60000,
30
+ memoryThreshold: 300 * 1024 * 1024 // 300MB - more realistic for analytics dashboard
31
+ });
32
+ this.webSocketServer = null;
33
+ this.notificationManager = null;
34
+ this.httpServer = null;
13
35
  this.data = {
14
36
  conversations: [],
15
37
  summary: {},
@@ -21,47 +43,54 @@ class ClaudeAnalytics {
21
43
  lastActivity: null,
22
44
  },
23
45
  };
24
- this.watchers = [];
25
46
  }
26
47
 
27
48
  async initialize() {
28
49
  const homeDir = os.homedir();
29
50
  this.claudeDir = path.join(homeDir, '.claude');
30
51
  this.claudeDesktopDir = path.join(homeDir, 'Library', 'Application Support', 'Claude');
52
+ this.claudeStatsigDir = path.join(this.claudeDir, 'statsig');
31
53
 
32
54
  // Check if Claude directories exist
33
55
  if (!(await fs.pathExists(this.claudeDir))) {
34
56
  throw new Error(`Claude Code directory not found at ${this.claudeDir}`);
35
57
  }
36
58
 
59
+ // Initialize conversation analyzer with Claude directory and cache
60
+ this.conversationAnalyzer = new ConversationAnalyzer(this.claudeDir, this.dataCache);
61
+
37
62
  await this.loadInitialData();
38
63
  this.setupFileWatchers();
39
64
  this.setupWebServer();
40
65
  }
41
66
 
42
67
  async loadInitialData() {
43
- console.log(chalk.yellow('📊 Analyzing Claude Code data...'));
44
-
45
68
  try {
46
- // Load conversation files
47
- const conversations = await this.loadConversations();
48
- this.data.conversations = conversations;
49
-
50
- // Load active projects
51
- const projects = await this.loadActiveProjects();
52
- this.data.activeProjects = projects;
53
-
54
- // NEW: Detect active Claude processes and enrich data
55
- await this.enrichWithRunningProcesses();
56
-
57
- // Calculate summary statistics
58
- this.data.summary = this.calculateSummary(conversations, projects);
59
-
60
- // Update realtime stats
61
- this.updateRealtimeStats();
62
-
63
- console.log(chalk.green('✅ Data analysis complete'));
64
- console.log(chalk.gray(`Found ${conversations.length} conversations across ${projects.length} projects`));
69
+ // Store previous data for comparison
70
+ const previousData = this.data;
71
+
72
+ // Use ConversationAnalyzer to load and analyze all data
73
+ const analyzedData = await this.conversationAnalyzer.loadInitialData(
74
+ this.stateCalculator,
75
+ this.processDetector
76
+ );
77
+
78
+ // Update our data structure with analyzed data
79
+ this.data = analyzedData;
80
+
81
+ // Get Claude session information
82
+ const claudeSessionInfo = await this.getClaudeSessionInfo();
83
+
84
+ // Analyze session data for Max plan usage tracking with real Claude session info
85
+ this.data.sessionData = this.sessionAnalyzer.analyzeSessionData(this.data.conversations, claudeSessionInfo);
86
+
87
+ // Send real-time notifications if WebSocket is available
88
+ if (this.notificationManager) {
89
+ this.notificationManager.notifyDataRefresh(this.data, 'data_refresh');
90
+
91
+ // Check for conversation state changes
92
+ this.detectAndNotifyStateChanges(previousData, this.data);
93
+ }
65
94
 
66
95
  } catch (error) {
67
96
  console.error(chalk.red('Error loading Claude data:'), error.message);
@@ -69,102 +98,6 @@ class ClaudeAnalytics {
69
98
  }
70
99
  }
71
100
 
72
- async loadConversations() {
73
- const conversations = [];
74
-
75
- try {
76
- // Search for .jsonl files recursively in all subdirectories
77
- const findJsonlFiles = async (dir) => {
78
- const files = [];
79
- const items = await fs.readdir(dir);
80
-
81
- for (const item of items) {
82
- const itemPath = path.join(dir, item);
83
- const stats = await fs.stat(itemPath);
84
-
85
- if (stats.isDirectory()) {
86
- // Recursively search subdirectories
87
- const subFiles = await findJsonlFiles(itemPath);
88
- files.push(...subFiles);
89
- } else if (item.endsWith('.jsonl')) {
90
- files.push(itemPath);
91
- }
92
- }
93
-
94
- return files;
95
- };
96
-
97
- const jsonlFiles = await findJsonlFiles(this.claudeDir);
98
- console.log(chalk.blue(`Found ${jsonlFiles.length} conversation files`));
99
-
100
- for (const filePath of jsonlFiles) {
101
- const stats = await fs.stat(filePath);
102
- const filename = path.basename(filePath);
103
-
104
- try {
105
- const content = await fs.readFile(filePath, 'utf8');
106
- const lines = content.trim().split('\n').filter(line => line.trim());
107
- const messages = lines.map(line => {
108
- try {
109
- return JSON.parse(line);
110
- } catch {
111
- return null;
112
- }
113
- }).filter(Boolean);
114
-
115
- // Extract project name from path
116
- const projectFromPath = this.extractProjectFromPath(filePath);
117
-
118
- // Parse messages to get their content for status determination
119
- const parsedMessages = lines.map(line => {
120
- try {
121
- const item = JSON.parse(line);
122
- if (item.message && item.message.role) {
123
- return {
124
- role: item.message.role,
125
- timestamp: new Date(item.timestamp),
126
- content: item.message.content,
127
- model: item.message.model || null,
128
- usage: item.message.usage || null,
129
- };
130
- }
131
- } catch {}
132
- return null;
133
- }).filter(Boolean);
134
-
135
- // Calculate real token usage and extract model info
136
- const tokenUsage = this.calculateRealTokenUsage(parsedMessages);
137
- const modelInfo = this.extractModelInfo(parsedMessages);
138
-
139
- const conversation = {
140
- id: filename.replace('.jsonl', ''),
141
- filename: filename,
142
- filePath: filePath,
143
- messageCount: parsedMessages.length,
144
- fileSize: stats.size,
145
- lastModified: stats.mtime,
146
- created: stats.birthtime,
147
- tokens: tokenUsage.total > 0 ? tokenUsage.total : this.estimateTokens(content),
148
- tokenUsage: tokenUsage,
149
- modelInfo: modelInfo,
150
- project: projectFromPath || this.extractProjectFromConversation(parsedMessages),
151
- status: this.determineConversationStatus(parsedMessages, stats.mtime),
152
- conversationState: this.determineConversationState(parsedMessages, stats.mtime),
153
- statusSquares: this.generateStatusSquares(parsedMessages),
154
- };
155
-
156
- conversations.push(conversation);
157
- } catch (error) {
158
- console.warn(chalk.yellow(`Warning: Could not parse ${filename}:`, error.message));
159
- }
160
- }
161
-
162
- return conversations.sort((a, b) => b.lastModified - a.lastModified);
163
- } catch (error) {
164
- console.error(chalk.red('Error loading conversations:'), error.message);
165
- return [];
166
- }
167
- }
168
101
 
169
102
  async loadActiveProjects() {
170
103
  const projects = [];
@@ -231,7 +164,7 @@ class ClaudeAnalytics {
231
164
  });
232
165
 
233
166
  return {
234
- total: totalInputTokens + totalOutputTokens,
167
+ total: totalInputTokens + totalOutputTokens + totalCacheCreationTokens + totalCacheReadTokens,
235
168
  inputTokens: totalInputTokens,
236
169
  outputTokens: totalOutputTokens,
237
170
  cacheCreationTokens: totalCacheCreationTokens,
@@ -241,6 +174,42 @@ class ClaudeAnalytics {
241
174
  };
242
175
  }
243
176
 
177
+ calculateDetailedTokenUsage() {
178
+ if (!this.data || !this.data.conversations) {
179
+ return null;
180
+ }
181
+
182
+ let totalInputTokens = 0;
183
+ let totalOutputTokens = 0;
184
+ let totalCacheCreationTokens = 0;
185
+ let totalCacheReadTokens = 0;
186
+ let totalMessages = 0;
187
+ let messagesWithUsage = 0;
188
+
189
+ this.data.conversations.forEach(conversation => {
190
+ if (conversation.tokenUsage) {
191
+ totalInputTokens += conversation.tokenUsage.inputTokens || 0;
192
+ totalOutputTokens += conversation.tokenUsage.outputTokens || 0;
193
+ totalCacheCreationTokens += conversation.tokenUsage.cacheCreationTokens || 0;
194
+ totalCacheReadTokens += conversation.tokenUsage.cacheReadTokens || 0;
195
+ messagesWithUsage += conversation.tokenUsage.messagesWithUsage || 0;
196
+ totalMessages += conversation.tokenUsage.totalMessages || 0;
197
+ }
198
+ });
199
+
200
+ const total = totalInputTokens + totalOutputTokens + totalCacheCreationTokens + totalCacheReadTokens;
201
+
202
+ return {
203
+ total,
204
+ inputTokens: totalInputTokens,
205
+ outputTokens: totalOutputTokens,
206
+ cacheCreationTokens: totalCacheCreationTokens,
207
+ cacheReadTokens: totalCacheReadTokens,
208
+ messagesWithUsage,
209
+ totalMessages
210
+ };
211
+ }
212
+
244
213
  extractModelInfo(parsedMessages) {
245
214
  const models = new Set();
246
215
  const serviceTiers = new Set();
@@ -288,111 +257,8 @@ class ClaudeAnalytics {
288
257
  return null;
289
258
  }
290
259
 
291
- // NEW: Function to detect active Claude processes
292
- async detectRunningClaudeProcesses() {
293
- const { exec } = require('child_process');
294
-
295
- return new Promise((resolve) => {
296
- // Search for processes containing 'claude' but exclude our own analytics process and system processes
297
- exec('ps aux | grep -i claude | grep -v grep | grep -v analytics | grep -v "/Applications/Claude.app" | grep -v "npm start"', (error, stdout) => {
298
- if (error) {
299
- resolve([]);
300
- return;
301
- }
302
-
303
- const processes = stdout.split('\n')
304
- .filter(line => line.trim())
305
- .filter(line => {
306
- // Only include actual Claude CLI processes, not system processes
307
- const fullCommand = line.split(/\s+/).slice(10).join(' ');
308
- return fullCommand.includes('claude') &&
309
- !fullCommand.includes('chrome_crashpad_handler') &&
310
- !fullCommand.includes('create-claude-config') &&
311
- !fullCommand.includes('node bin/') &&
312
- fullCommand.trim() === 'claude'; // Only the basic claude command
313
- })
314
- .map(line => {
315
- const parts = line.split(/\s+/);
316
- const fullCommand = parts.slice(10).join(' ');
317
-
318
- // Extract useful information from command
319
- const cwdMatch = fullCommand.match(/--cwd[=\s]+([^\s]+)/);
320
- const workingDir = cwdMatch ? cwdMatch[1] : 'unknown';
321
-
322
- return {
323
- pid: parts[1],
324
- command: fullCommand,
325
- workingDir: workingDir,
326
- startTime: new Date(), // For now we use current time
327
- status: 'running',
328
- user: parts[0]
329
- };
330
- });
331
-
332
- resolve(processes);
333
- });
334
- });
335
- }
336
260
 
337
- // NEW: Enrich existing conversations with process information
338
- async enrichWithRunningProcesses() {
339
- try {
340
- const runningProcesses = await this.detectRunningClaudeProcesses();
341
-
342
- // Add active process information to each conversation
343
- for (const conversation of this.data.conversations) {
344
- // Look for active process for this project
345
- const matchingProcess = runningProcesses.find(process =>
346
- process.workingDir.includes(conversation.project) ||
347
- process.command.includes(conversation.project)
348
- );
349
-
350
- if (matchingProcess) {
351
- // ENRICH without changing existing logic
352
- conversation.runningProcess = {
353
- pid: matchingProcess.pid,
354
- startTime: matchingProcess.startTime,
355
- workingDir: matchingProcess.workingDir,
356
- hasActiveCommand: true
357
- };
358
-
359
- // Only change status if not already marked as active by existing logic
360
- if (conversation.status !== 'active') {
361
- conversation.status = 'active';
362
- conversation.statusReason = 'running_process';
363
- }
364
-
365
- // Recalculate conversation state with process information
366
- const conversationFile = path.join(this.claudeDir, conversation.fileName);
367
- try {
368
- const content = await fs.readFile(conversationFile, 'utf8');
369
- const parsedMessages = content.split('\n')
370
- .filter(line => line.trim())
371
- .map(line => JSON.parse(line));
372
-
373
- const stats = await fs.stat(conversationFile);
374
- conversation.conversationState = this.determineConversationState(
375
- parsedMessages,
376
- stats.mtime,
377
- conversation.runningProcess
378
- );
379
- } catch (error) {
380
- // If we can't read the file, keep the existing state
381
- }
382
- } else {
383
- conversation.runningProcess = null;
384
- }
385
- }
386
-
387
- // Disable orphan process detection to reduce noise
388
- this.data.orphanProcesses = [];
389
-
390
- console.log(chalk.blue(`🔍 Found ${runningProcesses.length} running Claude processes`));
391
-
392
- } catch (error) {
393
- console.warn(chalk.yellow('Warning: Could not detect running processes'), error.message);
394
- }
395
- }
261
+
396
262
 
397
263
  extractProjectFromConversation(messages) {
398
264
  // Try to extract project information from conversation
@@ -407,100 +273,7 @@ class ClaudeAnalytics {
407
273
  return 'Unknown';
408
274
  }
409
275
 
410
- determineConversationStatus(messages, lastModified) {
411
- const now = new Date();
412
- const timeDiff = now - lastModified;
413
- const minutesAgo = timeDiff / (1000 * 60);
414
-
415
- if (messages.length === 0) {
416
- return minutesAgo < 5 ? 'active' : 'inactive';
417
- }
418
-
419
- // Sort messages by timestamp to get the actual conversation flow
420
- const sortedMessages = messages.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
421
- const lastMessage = sortedMessages[sortedMessages.length - 1];
422
- const lastMessageTime = new Date(lastMessage.timestamp);
423
- const lastMessageMinutesAgo = (now - lastMessageTime) / (1000 * 60);
424
-
425
- // More balanced logic - active conversations and recent activity
426
- if (lastMessage.role === 'user' && lastMessageMinutesAgo < 3) {
427
- return 'active';
428
- } else if (lastMessage.role === 'assistant' && lastMessageMinutesAgo < 5) {
429
- return 'active';
430
- }
431
-
432
- // Use file modification time for recent activity
433
- if (minutesAgo < 5) return 'active';
434
- if (minutesAgo < 30) return 'recent';
435
- return 'inactive';
436
- }
437
-
438
- determineConversationState(messages, lastModified, runningProcess = null) {
439
- const now = new Date();
440
- const timeDiff = now - lastModified;
441
- const minutesAgo = timeDiff / (1000 * 60);
442
-
443
- // If there's an active process, use simpler and more responsive logic
444
- if (runningProcess && runningProcess.hasActiveCommand) {
445
- const fileTimeDiff = (now - lastModified) / 1000; // seconds
446
-
447
- // Very recent file activity = Claude working
448
- if (fileTimeDiff < 10) {
449
- return 'Claude Code working...';
450
- }
451
-
452
- // Check conversation flow if we have messages
453
- if (messages.length > 0) {
454
- const sortedMessages = messages.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
455
- const lastMessage = sortedMessages[sortedMessages.length - 1];
456
-
457
- if (lastMessage.role === 'assistant') {
458
- // Claude responded, user should be typing or thinking
459
- return 'User typing...';
460
- } else if (lastMessage.role === 'user') {
461
- // User sent message, Claude should be working
462
- return 'Claude Code working...';
463
- }
464
- }
465
-
466
- // Fallback for active process
467
- return 'Awaiting user input...';
468
- }
469
-
470
- if (messages.length === 0) {
471
- return minutesAgo < 5 ? 'Waiting for input...' : 'Idle';
472
- }
473
-
474
- // Sort messages by timestamp to get the actual conversation flow
475
- const sortedMessages = messages.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
476
- const lastMessage = sortedMessages[sortedMessages.length - 1];
477
- const lastMessageTime = new Date(lastMessage.timestamp);
478
- const lastMessageMinutesAgo = (now - lastMessageTime) / (1000 * 60);
479
-
480
- // Detailed conversation state logic
481
- if (lastMessage.role === 'user') {
482
- // User sent last message
483
- if (lastMessageMinutesAgo < 0.5) {
484
- return 'Claude Code working...';
485
- } else if (lastMessageMinutesAgo < 3) {
486
- return 'Awaiting response...';
487
- } else {
488
- return 'User typing...';
489
- }
490
- } else if (lastMessage.role === 'assistant') {
491
- // Assistant sent last message
492
- if (lastMessageMinutesAgo < 2) {
493
- return 'Awaiting user input...';
494
- } else if (lastMessageMinutesAgo < 5) {
495
- return 'User may be typing...';
496
- }
497
- }
498
276
 
499
- // Fallback states
500
- if (minutesAgo < 5) return 'Recently active';
501
- if (minutesAgo < 60) return 'Idle';
502
- return 'Inactive';
503
- }
504
277
 
505
278
  generateStatusSquares(messages) {
506
279
  if (!messages || messages.length === 0) {
@@ -722,76 +495,66 @@ class ClaudeAnalytics {
722
495
  }
723
496
 
724
497
  setupFileWatchers() {
725
- console.log(chalk.blue('👀 Setting up file watchers for real-time updates...'));
726
-
727
- // Watch conversation files recursively in all subdirectories
728
- const conversationWatcher = chokidar.watch([
729
- path.join(this.claudeDir, '**/*.jsonl')
730
- ], {
731
- persistent: true,
732
- ignoreInitial: true,
733
- });
734
-
735
- conversationWatcher.on('change', async () => {
736
- console.log(chalk.yellow('🔄 Conversation file changed, updating data...'));
737
- await this.loadInitialData();
738
- console.log(chalk.green('✅ Data updated'));
739
- });
740
-
741
- conversationWatcher.on('add', async () => {
742
- console.log(chalk.yellow('📝 New conversation file detected...'));
743
- await this.loadInitialData();
744
- console.log(chalk.green('✅ Data updated'));
745
- });
746
-
747
- this.watchers.push(conversationWatcher);
748
-
749
- // Watch project directories
750
- const projectWatcher = chokidar.watch(this.claudeDir, {
751
- persistent: true,
752
- ignoreInitial: true,
753
- depth: 2, // Increased depth to catch subdirectories
754
- });
755
-
756
- projectWatcher.on('addDir', async () => {
757
- console.log(chalk.yellow('📁 New project directory detected...'));
758
- await this.loadInitialData();
759
- console.log(chalk.green('✅ Data updated'));
760
- });
761
-
762
- projectWatcher.on('change', async () => {
763
- console.log(chalk.yellow('📁 Project directory changed...'));
764
- await this.loadInitialData();
765
- console.log(chalk.green('✅ Data updated'));
766
- });
767
-
768
- this.watchers.push(projectWatcher);
769
-
770
- // Also set up periodic refresh to catch any missed changes
771
- setInterval(async () => {
772
- console.log(chalk.blue('⏱️ Periodic data refresh...'));
773
- await this.loadInitialData();
774
- }, 30000); // Every 30 seconds
775
-
776
- // NEW: More frequent updates for active processes (every 10 seconds)
777
- setInterval(async () => {
778
- await this.enrichWithRunningProcesses();
779
- }, 10000);
498
+ // Setup file watchers using the FileWatcher module
499
+ this.fileWatcher.setupFileWatchers(
500
+ this.claudeDir,
501
+ // Data refresh callback
502
+ async () => {
503
+ await this.loadInitialData();
504
+ },
505
+ // Process refresh callback
506
+ async () => {
507
+ const enrichmentResult = await this.processDetector.enrichWithRunningProcesses(
508
+ this.data.conversations,
509
+ this.claudeDir,
510
+ this.stateCalculator
511
+ );
512
+ this.data.conversations = enrichmentResult.conversations;
513
+ this.data.orphanProcesses = enrichmentResult.orphanProcesses;
514
+ },
515
+ // DataCache for cache invalidation
516
+ this.dataCache
517
+ );
780
518
  }
781
519
 
782
520
  setupWebServer() {
521
+ // Add performance monitoring middleware
522
+ this.app.use(this.performanceMonitor.createExpressMiddleware());
523
+
783
524
  // Serve static files (we'll create the dashboard HTML)
784
525
  this.app.use(express.static(path.join(__dirname, 'analytics-web')));
785
526
 
786
527
  // API endpoints
787
528
  this.app.get('/api/data', async (req, res) => {
788
- // Add timestamp to verify data freshness
789
- const dataWithTimestamp = {
790
- ...this.data,
791
- timestamp: new Date().toISOString(),
792
- lastUpdate: new Date().toLocaleString(),
793
- };
794
- res.json(dataWithTimestamp);
529
+ try {
530
+ // Calculate detailed token usage
531
+ const detailedTokenUsage = this.calculateDetailedTokenUsage();
532
+
533
+ // Memory cleanup: limit conversation history to prevent memory buildup
534
+ if (this.data.conversations && this.data.conversations.length > 150) {
535
+ console.log(chalk.yellow(`🧹 Cleaning up conversation history: ${this.data.conversations.length} -> 150`));
536
+ this.data.conversations = this.data.conversations
537
+ .sort((a, b) => new Date(b.lastModified) - new Date(a.lastModified))
538
+ .slice(0, 150);
539
+ }
540
+
541
+ // Add timestamp to verify data freshness
542
+ const dataWithTimestamp = {
543
+ ...this.data,
544
+ detailedTokenUsage,
545
+ timestamp: new Date().toISOString(),
546
+ lastUpdate: new Date().toLocaleString(),
547
+ };
548
+ res.json(dataWithTimestamp);
549
+ } catch (error) {
550
+ console.error('Error calculating detailed token usage:', error);
551
+ res.json({
552
+ ...this.data,
553
+ detailedTokenUsage: null,
554
+ timestamp: new Date().toISOString(),
555
+ lastUpdate: new Date().toLocaleString(),
556
+ });
557
+ }
795
558
  });
796
559
 
797
560
  this.app.get('/api/realtime', async (req, res) => {
@@ -814,25 +577,98 @@ class ClaudeAnalytics {
814
577
  });
815
578
  });
816
579
 
817
- // Session detail endpoint
818
- this.app.get('/api/session/:sessionId', async (req, res) => {
819
- const sessionId = req.params.sessionId;
580
+ // NEW: Ultra-fast endpoint ONLY for conversation states
581
+ this.app.get('/api/conversation-state', async (req, res) => {
582
+ try {
583
+ // Only detect processes and calculate states - no file reading
584
+ const runningProcesses = await this.processDetector.detectRunningClaudeProcesses();
585
+ const activeStates = [];
586
+
587
+ // Quick state calculation for active conversations only
588
+ for (const conversation of this.data.conversations) {
589
+ if (conversation.runningProcess) {
590
+ // Use existing state calculation but faster
591
+ const state = this.stateCalculator.quickStateCalculation(conversation, runningProcesses);
592
+ if (state) {
593
+ activeStates.push({
594
+ id: conversation.id,
595
+ project: conversation.project,
596
+ state: state,
597
+ timestamp: Date.now()
598
+ });
599
+ }
600
+ }
601
+ }
602
+
603
+ res.json({ activeStates, timestamp: Date.now() });
604
+ } catch (error) {
605
+ res.status(500).json({ error: 'Failed to get conversation states' });
606
+ }
607
+ });
608
+
609
+ // Session data endpoint for Max plan usage tracking
610
+ this.app.get('/api/session/data', async (req, res) => {
611
+ try {
612
+ // Get real-time Claude session information
613
+ const claudeSessionInfo = await this.getClaudeSessionInfo();
614
+
615
+ if (!this.data.sessionData) {
616
+ // Generate session data if not available
617
+ this.data.sessionData = this.sessionAnalyzer.analyzeSessionData(this.data.conversations, claudeSessionInfo);
618
+ }
619
+
620
+ const timerData = this.sessionAnalyzer.getSessionTimerData(this.data.sessionData);
621
+
622
+ res.json({
623
+ ...this.data.sessionData,
624
+ timer: timerData,
625
+ claudeSessionInfo: claudeSessionInfo,
626
+ timestamp: Date.now()
627
+ });
628
+ } catch (error) {
629
+ console.error('Session data error:', error);
630
+ res.status(500).json({
631
+ error: 'Failed to get session data',
632
+ timestamp: Date.now()
633
+ });
634
+ }
635
+ });
820
636
 
637
+ // Get specific conversation history
638
+ this.app.get('/api/session/:id', async (req, res) => {
821
639
  try {
822
- const session = this.data.conversations.find(conv => conv.id === sessionId);
823
- if (!session) {
824
- return res.status(404).json({
825
- error: 'Session not found'
640
+ const conversationId = req.params.id;
641
+
642
+ // Find the conversation
643
+ const conversation = this.data.conversations.find(conv => conv.id === conversationId);
644
+ if (!conversation) {
645
+ return res.status(404).json({ error: 'Conversation not found' });
646
+ }
647
+
648
+ // Read the conversation file to get full message history
649
+ const conversationFile = conversation.filePath;
650
+
651
+ if (!conversationFile) {
652
+ return res.status(404).json({
653
+ error: 'Conversation file path not found',
654
+ conversationId: conversationId,
655
+ conversationKeys: Object.keys(conversation),
656
+ hasFilePath: !!conversation.filePath,
657
+ hasFileName: !!conversation.filename
826
658
  });
827
659
  }
660
+
661
+ if (!await fs.pathExists(conversationFile)) {
662
+ return res.status(404).json({ error: 'Conversation file not found', path: conversationFile });
663
+ }
828
664
 
829
- // Read the actual conversation file
830
- const content = await fs.readFile(session.filePath, 'utf8');
665
+ const content = await fs.readFile(conversationFile, 'utf8');
831
666
  const lines = content.trim().split('\n').filter(line => line.trim());
832
667
  const rawMessages = lines.map(line => {
833
668
  try {
834
669
  return JSON.parse(line);
835
- } catch {
670
+ } catch (error) {
671
+ console.warn('Error parsing message line:', error);
836
672
  return null;
837
673
  }
838
674
  }).filter(Boolean);
@@ -853,7 +689,7 @@ class ClaudeAnalytics {
853
689
  return block.content || '';
854
690
  })
855
691
  .join('\n');
856
- } else if (item.message.content && item.message.content.length) {
692
+ } else if (item.message.content && typeof item.message.content === 'object' && item.message.content.length) {
857
693
  content = item.message.content[0].text || '';
858
694
  }
859
695
 
@@ -862,59 +698,443 @@ class ClaudeAnalytics {
862
698
  content: content || 'No content',
863
699
  timestamp: item.timestamp,
864
700
  type: item.type,
701
+ stop_reason: item.message.stop_reason || null,
702
+ message_id: item.message.id || null,
703
+ model: item.message.model || null,
704
+ usage: item.message.usage || null,
705
+ hasToolUse: item.message.content && Array.isArray(item.message.content) &&
706
+ item.message.content.some(block => block.type === 'tool_use'),
707
+ hasToolResult: item.message.content && Array.isArray(item.message.content) &&
708
+ item.message.content.some(block => block.type === 'tool_result'),
709
+ contentBlocks: item.message.content && Array.isArray(item.message.content) ?
710
+ item.message.content.map(block => ({ type: block.type, name: block.name || null })) : [],
711
+ rawContent: item.message.content || null,
712
+ parentUuid: item.parentUuid || null,
713
+ uuid: item.uuid || null,
714
+ sessionId: item.sessionId || null,
715
+ userType: item.userType || null,
716
+ cwd: item.cwd || null,
717
+ version: item.version || null,
718
+ isCompactSummary: item.isCompactSummary || false,
719
+ isSidechain: item.isSidechain || false
865
720
  };
866
721
  }
867
722
  return null;
868
723
  }).filter(Boolean);
869
724
 
725
+
870
726
  res.json({
871
- session: session,
727
+ conversation: {
728
+ id: conversation.id,
729
+ project: conversation.project,
730
+ messageCount: conversation.messageCount,
731
+ tokens: conversation.tokens,
732
+ created: conversation.created,
733
+ lastModified: conversation.lastModified,
734
+ status: conversation.status
735
+ },
872
736
  messages: messages,
873
- timestamp: new Date().toISOString(),
737
+ timestamp: Date.now()
874
738
  });
875
739
 
876
740
  } catch (error) {
877
- console.error(chalk.red('Error loading session details:'), error.message);
878
- res.status(500).json({
879
- error: 'Failed to load session details'
741
+ console.error('Error getting conversation history:', error);
742
+ console.error('Error stack:', error.stack);
743
+ res.status(500).json({
744
+ error: 'Failed to load conversation history',
745
+ details: error.message,
746
+ stack: error.stack
880
747
  });
881
748
  }
882
749
  });
883
750
 
884
- // Main dashboard route
885
- this.app.get('/', (req, res) => {
886
- res.sendFile(path.join(__dirname, 'analytics-web', 'index.html'));
751
+ // Fast state update endpoint - only updates conversation states without full reload
752
+ this.app.get('/api/fast-update', async (req, res) => {
753
+ try {
754
+ // Update process information and conversation states
755
+ const enrichmentResult = await this.processDetector.enrichWithRunningProcesses(
756
+ this.data.conversations,
757
+ this.claudeDir,
758
+ this.stateCalculator
759
+ );
760
+ this.data.conversations = enrichmentResult.conversations;
761
+ this.data.orphanProcesses = enrichmentResult.orphanProcesses;
762
+
763
+ // For active conversations, re-read the files to get latest messages
764
+ const activeConversations = this.data.conversations.filter(c => c.runningProcess);
765
+
766
+ for (const conv of activeConversations) {
767
+ try {
768
+ const conversationFile = path.join(this.claudeDir, conv.fileName);
769
+ const content = await fs.readFile(conversationFile, 'utf8');
770
+ const parsedMessages = content.split('\n')
771
+ .filter(line => line.trim())
772
+ .map(line => JSON.parse(line));
773
+
774
+ const stats = await fs.stat(conversationFile);
775
+ conv.conversationState = this.stateCalculator.determineConversationState(
776
+ parsedMessages,
777
+ stats.mtime,
778
+ conv.runningProcess
779
+ );
780
+
781
+ } catch (error) {
782
+ // If we can't read the file, keep the existing state
783
+ }
784
+ }
785
+
786
+ // Only log when there are actually active conversations (reduce noise)
787
+ const activeConvs = this.data.conversations.filter(c => c.runningProcess);
788
+ if (activeConvs.length > 0) {
789
+ // Only log every 10th update to reduce spam, or when states change
790
+ if (!this.lastLoggedStates) this.lastLoggedStates = new Map();
791
+
792
+ let hasChanges = false;
793
+ activeConvs.forEach(conv => {
794
+ const lastState = this.lastLoggedStates.get(conv.id);
795
+ if (lastState !== conv.conversationState) {
796
+ hasChanges = true;
797
+ this.lastLoggedStates.set(conv.id, conv.conversationState);
798
+ }
799
+ });
800
+
801
+ if (hasChanges) {
802
+ console.log(chalk.gray(`⚡ State update: ${activeConvs.length} active conversations`));
803
+ activeConvs.forEach(conv => {
804
+ console.log(chalk.gray(` 📊 ${conv.project}: ${conv.conversationState}`));
805
+ });
806
+ }
807
+ }
808
+
809
+ // Memory cleanup: limit conversation history to prevent memory buildup
810
+ if (this.data.conversations.length > 100) {
811
+ console.log(chalk.yellow(`🧹 Cleaning up conversation history: ${this.data.conversations.length} -> 100`));
812
+ this.data.conversations = this.data.conversations
813
+ .sort((a, b) => new Date(b.lastModified) - new Date(a.lastModified))
814
+ .slice(0, 100);
815
+ }
816
+
817
+ // Force garbage collection hint
818
+ if (global.gc) {
819
+ global.gc();
820
+ }
821
+
822
+ const dataWithTimestamp = {
823
+ conversations: this.data.conversations,
824
+ summary: this.data.summary,
825
+ timestamp: new Date().toISOString(),
826
+ lastUpdate: new Date().toLocaleString(),
827
+ };
828
+ res.json(dataWithTimestamp);
829
+ } catch (error) {
830
+ console.error('Fast update error:', error);
831
+ res.status(500).json({ error: 'Failed to update states' });
832
+ }
887
833
  });
888
- }
889
834
 
890
- async startServer() {
891
- return new Promise((resolve) => {
892
- this.server = this.app.listen(this.port, () => {
893
- console.log(chalk.green(`🚀 Analytics dashboard started at http://localhost:${this.port}`));
894
- resolve();
895
- });
896
- });
897
- }
835
+ // Remove duplicate endpoint - this conflicts with the correct one above
898
836
 
899
- async openBrowser() {
900
- try {
901
- await open(`http://localhost:${this.port}`);
902
- console.log(chalk.blue('🌐 Opening browser...'));
903
- } catch (error) {
904
- console.log(chalk.yellow('Could not open browser automatically. Please visit:'));
905
- console.log(chalk.cyan(`http://localhost:${this.port}`));
906
- }
907
- }
837
+ // System health endpoint
838
+ this.app.get('/api/system/health', (req, res) => {
839
+ try {
840
+ const stats = this.performanceMonitor.getStats();
841
+ const systemHealth = {
842
+ status: 'healthy',
843
+ uptime: stats.uptime,
844
+ memory: stats.memory,
845
+ requests: stats.requests,
846
+ cache: {
847
+ ...stats.cache,
848
+ dataCache: this.dataCache.getStats()
849
+ },
850
+ errors: stats.errors,
851
+ counters: stats.counters,
852
+ timestamp: Date.now()
853
+ };
908
854
 
909
- stop() {
910
- // Clean up watchers
911
- this.watchers.forEach(watcher => watcher.close());
855
+ // Determine overall health status
856
+ if (stats.errors.total > 10) {
857
+ systemHealth.status = 'degraded';
858
+ }
859
+ if (stats.memory.current && stats.memory.current.heapUsed > this.performanceMonitor.options.memoryThreshold) {
860
+ systemHealth.status = 'warning';
861
+ }
912
862
 
913
- // Stop server
863
+ res.json(systemHealth);
864
+ } catch (error) {
865
+ res.status(500).json({
866
+ status: 'error',
867
+ message: 'Failed to get system health',
868
+ timestamp: Date.now()
869
+ });
870
+ }
871
+ });
872
+
873
+ // Version endpoint
874
+ this.app.get('/api/version', (req, res) => {
875
+ res.json({
876
+ version: packageJson.version,
877
+ name: packageJson.name,
878
+ description: packageJson.description,
879
+ timestamp: Date.now()
880
+ });
881
+ });
882
+
883
+ // Claude session information endpoint
884
+ this.app.get('/api/claude/session', async (req, res) => {
885
+ try {
886
+ const sessionInfo = await this.getClaudeSessionInfo();
887
+ res.json({
888
+ ...sessionInfo,
889
+ timestamp: Date.now()
890
+ });
891
+ } catch (error) {
892
+ console.error('Error getting Claude session info:', error);
893
+ res.status(500).json({
894
+ error: 'Failed to get Claude session info',
895
+ timestamp: Date.now()
896
+ });
897
+ }
898
+ });
899
+
900
+ // Performance metrics endpoint
901
+ this.app.get('/api/system/metrics', (req, res) => {
902
+ try {
903
+ const timeframe = parseInt(req.query.timeframe) || 300000; // 5 minutes default
904
+ const stats = this.performanceMonitor.getStats(timeframe);
905
+ res.json({
906
+ ...stats,
907
+ dataCache: this.dataCache.getStats(),
908
+ timestamp: Date.now()
909
+ });
910
+ } catch (error) {
911
+ res.status(500).json({
912
+ error: 'Failed to get performance metrics',
913
+ timestamp: Date.now()
914
+ });
915
+ }
916
+ });
917
+
918
+ // Main dashboard route
919
+ this.app.get('/', (req, res) => {
920
+ res.sendFile(path.join(__dirname, 'analytics-web', 'index.html'));
921
+ });
922
+ }
923
+
924
+ async startServer() {
925
+ return new Promise(async (resolve) => {
926
+ this.httpServer = this.app.listen(this.port, async () => {
927
+ console.log(chalk.green(`🚀 Analytics dashboard started at http://localhost:${this.port}`));
928
+
929
+ // Initialize WebSocket server
930
+ await this.initializeWebSocket();
931
+
932
+ resolve();
933
+ });
934
+ // Keep reference for compatibility
935
+ this.server = this.httpServer;
936
+ });
937
+ }
938
+
939
+ async openBrowser() {
940
+ try {
941
+ await open(`http://localhost:${this.port}`);
942
+ console.log(chalk.blue('🌐 Opening browser...'));
943
+ } catch (error) {
944
+ console.log(chalk.yellow('Could not open browser automatically. Please visit:'));
945
+ console.log(chalk.cyan(`http://localhost:${this.port}`));
946
+ }
947
+ }
948
+
949
+ /**
950
+ * Initialize WebSocket server and notification manager
951
+ */
952
+ async initializeWebSocket() {
953
+ try {
954
+ // Initialize WebSocket server with performance monitoring
955
+ this.webSocketServer = new WebSocketServer(this.httpServer, {
956
+ path: '/ws',
957
+ heartbeatInterval: 30000
958
+ }, this.performanceMonitor);
959
+ await this.webSocketServer.initialize();
960
+
961
+ // Initialize notification manager
962
+ this.notificationManager = new NotificationManager(this.webSocketServer);
963
+ await this.notificationManager.initialize();
964
+
965
+ // Setup notification subscriptions
966
+ this.setupNotificationSubscriptions();
967
+
968
+ console.log(chalk.green('✅ WebSocket and notifications initialized'));
969
+ } catch (error) {
970
+ console.error(chalk.red('❌ Failed to initialize WebSocket:'), error);
971
+ }
972
+ }
973
+
974
+ /**
975
+ * Setup notification subscriptions
976
+ */
977
+ setupNotificationSubscriptions() {
978
+ // Subscribe to refresh requests from WebSocket clients
979
+ this.notificationManager.subscribe('refresh_requested', async (notification) => {
980
+ console.log(chalk.blue('🔄 Refresh requested via WebSocket'));
981
+ await this.loadInitialData();
982
+
983
+ // Notify clients of the refreshed data
984
+ this.notificationManager.notifyDataRefresh(this.data, 'websocket_request');
985
+ });
986
+ }
987
+
988
+ /**
989
+ * Detect and notify conversation state changes
990
+ * @param {Object} previousData - Previous data state
991
+ * @param {Object} currentData - Current data state
992
+ */
993
+ detectAndNotifyStateChanges(previousData, currentData) {
994
+ if (!previousData || !previousData.conversations || !currentData || !currentData.conversations) {
995
+ return;
996
+ }
997
+
998
+ // Create maps for easier lookup
999
+ const previousConversations = new Map();
1000
+ previousData.conversations.forEach(conv => {
1001
+ previousConversations.set(conv.id, conv);
1002
+ });
1003
+
1004
+ // Check for state changes
1005
+ currentData.conversations.forEach(currentConv => {
1006
+ const previousConv = previousConversations.get(currentConv.id);
1007
+
1008
+ if (previousConv && previousConv.status !== currentConv.status) {
1009
+ // State changed - notify clients
1010
+ this.notificationManager.notifyConversationStateChange(
1011
+ currentConv.id,
1012
+ previousConv.status,
1013
+ currentConv.status,
1014
+ {
1015
+ project: currentConv.project,
1016
+ tokens: currentConv.tokens,
1017
+ lastModified: currentConv.lastModified
1018
+ }
1019
+ );
1020
+ }
1021
+ });
1022
+ }
1023
+
1024
+ /**
1025
+ * Get Claude session information from statsig files
1026
+ */
1027
+ async getClaudeSessionInfo() {
1028
+ try {
1029
+ if (!await fs.pathExists(this.claudeStatsigDir)) {
1030
+ return {
1031
+ hasSession: false,
1032
+ error: 'Claude statsig directory not found'
1033
+ };
1034
+ }
1035
+
1036
+ const files = await fs.readdir(this.claudeStatsigDir);
1037
+ const sessionFile = files.find(file => file.startsWith('statsig.session_id.'));
1038
+
1039
+ if (!sessionFile) {
1040
+ return {
1041
+ hasSession: false,
1042
+ error: 'No session file found'
1043
+ };
1044
+ }
1045
+
1046
+ const sessionFilePath = path.join(this.claudeStatsigDir, sessionFile);
1047
+ const sessionData = await fs.readFile(sessionFilePath, 'utf8');
1048
+ const sessionInfo = JSON.parse(sessionData);
1049
+
1050
+ const now = Date.now();
1051
+ const startTime = sessionInfo.startTime;
1052
+ const lastUpdate = sessionInfo.lastUpdate;
1053
+
1054
+ // Calculate session duration
1055
+ const sessionDuration = now - startTime;
1056
+ const sessionDurationMinutes = Math.floor(sessionDuration / (1000 * 60));
1057
+ const sessionDurationHours = Math.floor(sessionDurationMinutes / 60);
1058
+ const remainingMinutes = sessionDurationMinutes % 60;
1059
+
1060
+ // Calculate time since last update
1061
+ const timeSinceLastUpdate = now - lastUpdate;
1062
+ const timeSinceLastUpdateMinutes = Math.floor(timeSinceLastUpdate / (1000 * 60));
1063
+
1064
+ // Based on observed pattern: ~2 hours and 21 minutes session limit
1065
+ const sessionLimitMs = 2 * 60 * 60 * 1000 + 21 * 60 * 1000; // 2h 21m
1066
+ const timeRemaining = sessionLimitMs - sessionDuration;
1067
+ const timeRemainingMinutes = Math.floor(timeRemaining / (1000 * 60));
1068
+ const timeRemainingHours = Math.floor(timeRemainingMinutes / 60);
1069
+ const remainingMinutesDisplay = timeRemainingMinutes % 60;
1070
+
1071
+ return {
1072
+ hasSession: true,
1073
+ sessionId: sessionInfo.sessionID,
1074
+ startTime: startTime,
1075
+ lastUpdate: lastUpdate,
1076
+ sessionDuration: {
1077
+ ms: sessionDuration,
1078
+ minutes: sessionDurationMinutes,
1079
+ hours: sessionDurationHours,
1080
+ remainingMinutes: remainingMinutes,
1081
+ formatted: `${sessionDurationHours}h ${remainingMinutes}m`
1082
+ },
1083
+ timeSinceLastUpdate: {
1084
+ ms: timeSinceLastUpdate,
1085
+ minutes: timeSinceLastUpdateMinutes,
1086
+ formatted: `${timeSinceLastUpdateMinutes}m`
1087
+ },
1088
+ estimatedTimeRemaining: {
1089
+ ms: timeRemaining,
1090
+ minutes: timeRemainingMinutes,
1091
+ hours: timeRemainingHours,
1092
+ remainingMinutes: remainingMinutesDisplay,
1093
+ formatted: timeRemaining > 0 ? `${timeRemainingHours}h ${remainingMinutesDisplay}m` : 'Session expired',
1094
+ isExpired: timeRemaining <= 0
1095
+ },
1096
+ sessionLimit: {
1097
+ ms: sessionLimitMs,
1098
+ hours: 2,
1099
+ minutes: 21,
1100
+ formatted: '2h 21m'
1101
+ }
1102
+ };
1103
+ } catch (error) {
1104
+ return {
1105
+ hasSession: false,
1106
+ error: `Failed to read session info: ${error.message}`
1107
+ };
1108
+ }
1109
+ }
1110
+
1111
+ stop() {
1112
+ // Stop file watchers
1113
+ this.fileWatcher.stop();
1114
+
1115
+ // Stop server
1116
+ // Close WebSocket server
1117
+ if (this.webSocketServer) {
1118
+ this.webSocketServer.close();
1119
+ }
1120
+
1121
+ // Shutdown notification manager
1122
+ if (this.notificationManager) {
1123
+ this.notificationManager.shutdown();
1124
+ }
1125
+
1126
+ if (this.httpServer) {
1127
+ this.httpServer.close();
1128
+ }
1129
+
1130
+ // Keep compatibility
914
1131
  if (this.server) {
915
1132
  this.server.close();
916
1133
  }
917
1134
 
1135
+ // Log cache statistics before stopping
1136
+ this.dataCache.logStats();
1137
+
918
1138
  console.log(chalk.yellow('Analytics dashboard stopped'));
919
1139
  }
920
1140
  }
@@ -928,7 +1148,7 @@ async function runAnalytics(options = {}) {
928
1148
  await analytics.initialize();
929
1149
 
930
1150
  // Create web dashboard files
931
- await createWebDashboard();
1151
+ // Web dashboard files are now static in analytics-web directory
932
1152
 
933
1153
  await analytics.startServer();
934
1154
  await analytics.openBrowser();
@@ -952,1896 +1172,6 @@ async function runAnalytics(options = {}) {
952
1172
  }
953
1173
  }
954
1174
 
955
- async function createWebDashboard() {
956
- const webDir = path.join(__dirname, 'analytics-web');
957
- await fs.ensureDir(webDir);
958
-
959
- // Create the HTML dashboard
960
- const htmlContent = `<!DOCTYPE html>
961
- <html lang="en">
962
- <head>
963
- <meta charset="UTF-8">
964
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
965
- <title>Claude Code Analytics - Terminal</title>
966
- <script src="https://cdn.jsdelivr.net/npm/chart.js@3.9.1/dist/chart.min.js"></script>
967
- <style>
968
- * {
969
- margin: 0;
970
- padding: 0;
971
- box-sizing: border-box;
972
- }
973
-
974
- body {
975
- font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
976
- background: #0d1117;
977
- color: #c9d1d9;
978
- min-height: 100vh;
979
- line-height: 1.4;
980
- }
981
-
982
- .terminal {
983
- max-width: 1400px;
984
- margin: 0 auto;
985
- padding: 20px;
986
- }
987
-
988
- .terminal-header {
989
- border-bottom: 1px solid #30363d;
990
- padding-bottom: 20px;
991
- margin-bottom: 20px;
992
- position: relative;
993
- }
994
-
995
- .terminal-title {
996
- color: #d57455;
997
- font-size: 1.25rem;
998
- font-weight: normal;
999
- display: flex;
1000
- align-items: center;
1001
- gap: 8px;
1002
- }
1003
-
1004
- .status-dot {
1005
- width: 8px;
1006
- height: 8px;
1007
- border-radius: 50%;
1008
- background: #3fb950;
1009
- animation: pulse 2s infinite;
1010
- }
1011
-
1012
- @keyframes pulse {
1013
- 0%, 100% { opacity: 1; }
1014
- 50% { opacity: 0.6; }
1015
- }
1016
-
1017
- .terminal-subtitle {
1018
- color: #7d8590;
1019
- font-size: 0.875rem;
1020
- margin-top: 4px;
1021
- }
1022
-
1023
- .github-star-btn {
1024
- position: absolute;
1025
- top: 0;
1026
- right: 0;
1027
- background: #21262d;
1028
- border: 1px solid #30363d;
1029
- color: #c9d1d9;
1030
- padding: 8px 12px;
1031
- border-radius: 6px;
1032
- text-decoration: none;
1033
- font-family: inherit;
1034
- font-size: 0.875rem;
1035
- display: flex;
1036
- align-items: center;
1037
- gap: 6px;
1038
- transition: all 0.2s ease;
1039
- cursor: pointer;
1040
- }
1041
-
1042
- .github-star-btn:hover {
1043
- border-color: #d57455;
1044
- background: #30363d;
1045
- color: #d57455;
1046
- text-decoration: none;
1047
- }
1048
-
1049
- .github-star-btn .star-icon {
1050
- font-size: 0.75rem;
1051
- }
1052
-
1053
- .stats-bar {
1054
- display: flex;
1055
- gap: 40px;
1056
- margin: 20px 0;
1057
- flex-wrap: wrap;
1058
- }
1059
-
1060
- .stat {
1061
- display: flex;
1062
- align-items: center;
1063
- gap: 8px;
1064
- }
1065
-
1066
- .stat-label {
1067
- color: #7d8590;
1068
- font-size: 0.875rem;
1069
- }
1070
-
1071
- .stat-value {
1072
- color: #d57455;
1073
- font-weight: bold;
1074
- }
1075
-
1076
- .stat-sublabel {
1077
- color: #7d8590;
1078
- font-size: 0.75rem;
1079
- display: block;
1080
- margin-top: 2px;
1081
- }
1082
-
1083
- .chart-controls {
1084
- display: flex;
1085
- align-items: center;
1086
- justify-content: space-between;
1087
- gap: 16px;
1088
- margin: 20px 0;
1089
- padding: 12px 0;
1090
- border-top: 1px solid #21262d;
1091
- border-bottom: 1px solid #21262d;
1092
- }
1093
-
1094
- .chart-controls-left {
1095
- display: flex;
1096
- align-items: center;
1097
- gap: 16px;
1098
- }
1099
-
1100
- .chart-controls-right {
1101
- display: flex;
1102
- align-items: center;
1103
- gap: 12px;
1104
- }
1105
-
1106
- .date-control {
1107
- display: flex;
1108
- align-items: center;
1109
- gap: 8px;
1110
- }
1111
-
1112
- .date-label {
1113
- color: #7d8590;
1114
- font-size: 0.875rem;
1115
- }
1116
-
1117
- .date-input {
1118
- background: #21262d;
1119
- border: 1px solid #30363d;
1120
- color: #c9d1d9;
1121
- padding: 6px 12px;
1122
- border-radius: 4px;
1123
- font-family: inherit;
1124
- font-size: 0.875rem;
1125
- cursor: pointer;
1126
- }
1127
-
1128
- .date-input:focus {
1129
- outline: none;
1130
- border-color: #d57455;
1131
- }
1132
-
1133
- .refresh-btn {
1134
- background: none;
1135
- border: 1px solid #30363d;
1136
- color: #7d8590;
1137
- padding: 6px 12px;
1138
- border-radius: 4px;
1139
- cursor: pointer;
1140
- font-family: inherit;
1141
- font-size: 0.875rem;
1142
- transition: all 0.2s ease;
1143
- display: flex;
1144
- align-items: center;
1145
- gap: 6px;
1146
- }
1147
-
1148
- .refresh-btn:hover {
1149
- border-color: #d57455;
1150
- color: #d57455;
1151
- }
1152
-
1153
- .refresh-btn.loading {
1154
- opacity: 0.6;
1155
- cursor: not-allowed;
1156
- }
1157
-
1158
- .charts-container {
1159
- display: grid;
1160
- grid-template-columns: 2fr 1fr;
1161
- gap: 30px;
1162
- margin: 20px 0 30px 0;
1163
- }
1164
-
1165
- .chart-card {
1166
- background: #161b22;
1167
- border: 1px solid #30363d;
1168
- border-radius: 6px;
1169
- padding: 20px;
1170
- position: relative;
1171
- }
1172
-
1173
- .chart-title {
1174
- color: #d57455;
1175
- font-size: 0.875rem;
1176
- text-transform: uppercase;
1177
- margin-bottom: 16px;
1178
- display: flex;
1179
- align-items: center;
1180
- gap: 8px;
1181
- }
1182
-
1183
- .chart-canvas {
1184
- width: 100% !important;
1185
- height: 200px !important;
1186
- }
1187
-
1188
- .filter-bar {
1189
- display: flex;
1190
- align-items: center;
1191
- gap: 16px;
1192
- margin: 20px 0;
1193
- padding: 12px 0;
1194
- border-top: 1px solid #21262d;
1195
- border-bottom: 1px solid #21262d;
1196
- }
1197
-
1198
- .filter-label {
1199
- color: #7d8590;
1200
- font-size: 0.875rem;
1201
- }
1202
-
1203
- .filter-buttons {
1204
- display: flex;
1205
- gap: 8px;
1206
- }
1207
-
1208
- .filter-btn {
1209
- background: none;
1210
- border: 1px solid #30363d;
1211
- color: #7d8590;
1212
- padding: 4px 12px;
1213
- border-radius: 4px;
1214
- cursor: pointer;
1215
- font-family: inherit;
1216
- font-size: 0.875rem;
1217
- transition: all 0.2s ease;
1218
- }
1219
-
1220
- .filter-btn:hover {
1221
- border-color: #d57455;
1222
- color: #d57455;
1223
- }
1224
-
1225
- .filter-btn.active {
1226
- background: #d57455;
1227
- border-color: #d57455;
1228
- color: #0d1117;
1229
- }
1230
-
1231
- .sessions-table {
1232
- width: 100%;
1233
- border-collapse: collapse;
1234
- }
1235
-
1236
- .sessions-table th {
1237
- text-align: left;
1238
- padding: 8px 12px;
1239
- color: #7d8590;
1240
- font-size: 0.875rem;
1241
- font-weight: normal;
1242
- border-bottom: 1px solid #30363d;
1243
- }
1244
-
1245
- .sessions-table td {
1246
- padding: 8px 12px;
1247
- font-size: 0.875rem;
1248
- border-bottom: 1px solid #21262d;
1249
- }
1250
-
1251
- .sessions-table tr:hover {
1252
- background: #161b22;
1253
- }
1254
-
1255
- .session-id {
1256
- color: #d57455;
1257
- font-family: monospace;
1258
- display: flex;
1259
- align-items: center;
1260
- gap: 6px;
1261
- }
1262
-
1263
- .process-indicator {
1264
- display: inline-block;
1265
- width: 6px;
1266
- height: 6px;
1267
- background: #3fb950;
1268
- border-radius: 50%;
1269
- animation: pulse 2s infinite;
1270
- cursor: help;
1271
- }
1272
-
1273
- .process-indicator.orphan {
1274
- background: #f85149;
1275
- }
1276
-
1277
- .session-id-container {
1278
- display: flex;
1279
- flex-direction: column;
1280
- gap: 4px;
1281
- }
1282
-
1283
- .session-project {
1284
- color: #c9d1d9;
1285
- }
1286
-
1287
- .session-model {
1288
- color: #a5d6ff;
1289
- font-size: 0.8rem;
1290
- max-width: 150px;
1291
- white-space: nowrap;
1292
- overflow: hidden;
1293
- text-overflow: ellipsis;
1294
- }
1295
-
1296
- .session-messages {
1297
- color: #7d8590;
1298
- }
1299
-
1300
- .session-tokens {
1301
- color: #f85149;
1302
- }
1303
-
1304
- .session-time {
1305
- color: #7d8590;
1306
- font-size: 0.8rem;
1307
- }
1308
-
1309
- .status-active {
1310
- color: #3fb950;
1311
- font-weight: bold;
1312
- }
1313
-
1314
- .status-recent {
1315
- color: #d29922;
1316
- }
1317
-
1318
- .status-inactive {
1319
- color: #7d8590;
1320
- }
1321
-
1322
- .conversation-state {
1323
- color: #d57455;
1324
- font-style: italic;
1325
- font-size: 0.8rem;
1326
- }
1327
-
1328
- .conversation-state.working {
1329
- animation: working-pulse 1.5s infinite;
1330
- }
1331
-
1332
- .conversation-state.typing {
1333
- animation: typing-pulse 1.5s infinite;
1334
- }
1335
-
1336
- @keyframes working-pulse {
1337
- 0%, 100% { opacity: 1; }
1338
- 50% { opacity: 0.7; }
1339
- }
1340
-
1341
- @keyframes typing-pulse {
1342
- 0%, 100% { opacity: 1; }
1343
- 50% { opacity: 0.6; }
1344
- }
1345
-
1346
- .status-squares {
1347
- display: flex;
1348
- gap: 2px;
1349
- align-items: center;
1350
- flex-wrap: wrap;
1351
- margin: 0;
1352
- padding: 0;
1353
- }
1354
-
1355
- .status-square {
1356
- width: 10px !important;
1357
- height: 10px !important;
1358
- min-width: 10px !important;
1359
- min-height: 10px !important;
1360
- max-width: 10px !important;
1361
- max-height: 10px !important;
1362
- border-radius: 2px;
1363
- cursor: help;
1364
- position: relative;
1365
- flex-shrink: 0;
1366
- box-sizing: border-box;
1367
- }
1368
-
1369
- .status-square.success {
1370
- background: #d57455;
1371
- }
1372
-
1373
- .status-square.tool {
1374
- background: #f97316;
1375
- }
1376
-
1377
- .status-square.error {
1378
- background: #dc2626;
1379
- }
1380
-
1381
- .status-square.pending {
1382
- background: #6b7280;
1383
- }
1384
-
1385
- /* Additional specificity to override any table styling */
1386
- .sessions-table .status-squares .status-square {
1387
- width: 10px !important;
1388
- height: 10px !important;
1389
- min-width: 10px !important;
1390
- min-height: 10px !important;
1391
- max-width: 10px !important;
1392
- max-height: 10px !important;
1393
- display: inline-block !important;
1394
- font-size: 0 !important;
1395
- line-height: 0 !important;
1396
- border: none !important;
1397
- outline: none !important;
1398
- vertical-align: top !important;
1399
- }
1400
-
1401
- .status-square:hover::after {
1402
- content: attr(data-tooltip);
1403
- position: absolute;
1404
- bottom: 100%;
1405
- left: 50%;
1406
- transform: translateX(-50%);
1407
- background: #1c1c1c;
1408
- color: #fff;
1409
- padding: 4px 8px;
1410
- border-radius: 4px;
1411
- font-size: 0.75rem;
1412
- white-space: nowrap;
1413
- z-index: 1000;
1414
- margin-bottom: 4px;
1415
- border: 1px solid #30363d;
1416
- }
1417
-
1418
- .status-square:hover::before {
1419
- content: '';
1420
- position: absolute;
1421
- bottom: 100%;
1422
- left: 50%;
1423
- transform: translateX(-50%);
1424
- border: 4px solid transparent;
1425
- border-top-color: #30363d;
1426
- z-index: 1000;
1427
- }
1428
-
1429
- .loading, #error {
1430
- text-align: center;
1431
- padding: 40px;
1432
- color: #7d8590;
1433
- }
1434
-
1435
- #error {
1436
- color: #f85149;
1437
- }
1438
-
1439
- .no-sessions {
1440
- text-align: center;
1441
- padding: 40px;
1442
- color: #7d8590;
1443
- font-style: italic;
1444
- }
1445
-
1446
- .session-detail {
1447
- display: none;
1448
- margin-top: 20px;
1449
- }
1450
-
1451
- .session-detail.active {
1452
- display: block;
1453
- }
1454
-
1455
- .detail-header {
1456
- display: flex;
1457
- justify-content: space-between;
1458
- align-items: center;
1459
- padding: 16px 0;
1460
- border-bottom: 1px solid #30363d;
1461
- margin-bottom: 20px;
1462
- }
1463
-
1464
- .detail-title {
1465
- color: #d57455;
1466
- font-size: 1.1rem;
1467
- }
1468
-
1469
- .detail-actions {
1470
- display: flex;
1471
- gap: 12px;
1472
- align-items: center;
1473
- }
1474
-
1475
- .export-format-select {
1476
- background: #21262d;
1477
- border: 1px solid #30363d;
1478
- color: #c9d1d9;
1479
- padding: 6px 12px;
1480
- border-radius: 4px;
1481
- font-family: inherit;
1482
- font-size: 0.875rem;
1483
- cursor: pointer;
1484
- }
1485
-
1486
- .export-format-select:focus {
1487
- outline: none;
1488
- border-color: #d57455;
1489
- }
1490
-
1491
- .export-format-select option {
1492
- background: #21262d;
1493
- color: #c9d1d9;
1494
- }
1495
-
1496
- .btn {
1497
- background: none;
1498
- border: 1px solid #30363d;
1499
- color: #7d8590;
1500
- padding: 6px 12px;
1501
- border-radius: 4px;
1502
- cursor: pointer;
1503
- font-family: inherit;
1504
- font-size: 0.875rem;
1505
- transition: all 0.2s ease;
1506
- }
1507
-
1508
- .btn:hover {
1509
- border-color: #d57455;
1510
- color: #d57455;
1511
- }
1512
-
1513
- .btn-primary {
1514
- background: #d57455;
1515
- border-color: #d57455;
1516
- color: #0d1117;
1517
- }
1518
-
1519
- .btn-primary:hover {
1520
- background: #e8956f;
1521
- border-color: #e8956f;
1522
- }
1523
-
1524
- .session-info {
1525
- display: grid;
1526
- grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
1527
- gap: 20px;
1528
- margin-bottom: 30px;
1529
- }
1530
-
1531
- .info-item {
1532
- display: flex;
1533
- flex-direction: column;
1534
- gap: 4px;
1535
- }
1536
-
1537
- .info-label {
1538
- color: #7d8590;
1539
- font-size: 0.75rem;
1540
- text-transform: uppercase;
1541
- }
1542
-
1543
- .info-value {
1544
- color: #c9d1d9;
1545
- font-size: 0.875rem;
1546
- }
1547
-
1548
- .info-value.model {
1549
- color: #a5d6ff;
1550
- font-weight: bold;
1551
- }
1552
-
1553
- .search-input {
1554
- width: 100%;
1555
- background: #21262d;
1556
- border: 1px solid #30363d;
1557
- color: #c9d1d9;
1558
- padding: 8px 12px;
1559
- border-radius: 4px;
1560
- font-family: inherit;
1561
- font-size: 0.875rem;
1562
- margin-bottom: 16px;
1563
- }
1564
-
1565
- .search-input:focus {
1566
- outline: none;
1567
- border-color: #d57455;
1568
- }
1569
-
1570
- .conversation-history {
1571
- border: 1px solid #30363d;
1572
- border-radius: 6px;
1573
- max-height: 600px;
1574
- overflow-y: auto;
1575
- }
1576
-
1577
- .message {
1578
- padding: 16px;
1579
- border-bottom: 1px solid #21262d;
1580
- position: relative;
1581
- }
1582
-
1583
- .message:last-child {
1584
- border-bottom: none;
1585
- }
1586
-
1587
- .message-header {
1588
- display: flex;
1589
- justify-content: space-between;
1590
- align-items: center;
1591
- margin-bottom: 8px;
1592
- }
1593
-
1594
- .message-role {
1595
- color: #58a6ff;
1596
- font-size: 0.875rem;
1597
- font-weight: bold;
1598
- }
1599
-
1600
- .message-role.user {
1601
- color: #3fb950;
1602
- }
1603
-
1604
- .message-role.assistant {
1605
- color: #d57455;
1606
- }
1607
-
1608
- .message-time {
1609
- color: #7d8590;
1610
- font-size: 0.75rem;
1611
- }
1612
-
1613
- .message-content {
1614
- color: #c9d1d9;
1615
- font-size: 0.875rem;
1616
- line-height: 1.5;
1617
- white-space: pre-wrap;
1618
- word-wrap: break-word;
1619
- }
1620
-
1621
- .message-type-indicator {
1622
- position: absolute;
1623
- top: 8px;
1624
- right: 8px;
1625
- width: 8px;
1626
- height: 8px;
1627
- border-radius: 2px;
1628
- cursor: help;
1629
- }
1630
-
1631
- .message-type-indicator.success {
1632
- background: #d57455;
1633
- }
1634
-
1635
- .message-type-indicator.tool {
1636
- background: #f97316;
1637
- }
1638
-
1639
- .message-type-indicator.error {
1640
- background: #dc2626;
1641
- }
1642
-
1643
- .message-type-indicator.pending {
1644
- background: #6b7280;
1645
- }
1646
-
1647
- .message-type-indicator:hover::after {
1648
- content: attr(data-tooltip);
1649
- position: absolute;
1650
- top: 100%;
1651
- right: 0;
1652
- background: #1c1c1c;
1653
- color: #fff;
1654
- padding: 4px 8px;
1655
- border-radius: 4px;
1656
- font-size: 0.75rem;
1657
- white-space: nowrap;
1658
- z-index: 1000;
1659
- margin-top: 4px;
1660
- border: 1px solid #30363d;
1661
- }
1662
-
1663
- .back-btn {
1664
- margin-bottom: 20px;
1665
- }
1666
-
1667
- @media (max-width: 768px) {
1668
- .stats-bar {
1669
- gap: 20px;
1670
- }
1671
-
1672
- .chart-controls {
1673
- flex-direction: column;
1674
- gap: 12px;
1675
- align-items: stretch;
1676
- }
1677
-
1678
- .chart-controls-left {
1679
- flex-direction: column;
1680
- gap: 12px;
1681
- }
1682
-
1683
- .chart-controls-right {
1684
- justify-content: center;
1685
- }
1686
-
1687
- .charts-container {
1688
- grid-template-columns: 1fr;
1689
- gap: 20px;
1690
- margin: 20px 0;
1691
- }
1692
-
1693
- .chart-card {
1694
- padding: 16px;
1695
- }
1696
-
1697
- .chart-canvas {
1698
- height: 180px !important;
1699
- }
1700
-
1701
- .filter-bar {
1702
- flex-direction: column;
1703
- align-items: flex-start;
1704
- gap: 8px;
1705
- }
1706
-
1707
- .sessions-table {
1708
- font-size: 0.8rem;
1709
- }
1710
-
1711
- .sessions-table th,
1712
- .sessions-table td {
1713
- padding: 6px 8px;
1714
- }
1715
-
1716
- .github-star-btn {
1717
- position: relative;
1718
- margin-top: 12px;
1719
- align-self: flex-start;
1720
- }
1721
-
1722
- .terminal-header {
1723
- display: flex;
1724
- flex-direction: column;
1725
- }
1726
- }
1727
- </style>
1728
- </head>
1729
- <body>
1730
- <div class="terminal">
1731
- <div class="terminal-header">
1732
- <div class="terminal-title">
1733
- <span class="status-dot"></span>
1734
- claude-code-analytics
1735
- </div>
1736
- <div class="terminal-subtitle">real-time monitoring dashboard</div>
1737
- <div class="terminal-subtitle" id="lastUpdate"></div>
1738
-
1739
- <a href="https://github.com/davila7/claude-code-templates" target="_blank" class="github-star-btn" title="Give us a star on GitHub to support the project!">
1740
- <span class="star-icon">⭐</span>
1741
- <span>Star on GitHub</span>
1742
- </a>
1743
- </div>
1744
-
1745
- <div id="loading" class="loading">
1746
- loading claude code data...
1747
- </div>
1748
-
1749
- <div id="error" class="error" style="display: none;">
1750
- error: failed to load claude code data
1751
- </div>
1752
-
1753
- <div id="dashboard" style="display: none;">
1754
- <div class="stats-bar">
1755
- <div class="stat">
1756
- <span class="stat-label">conversations:</span>
1757
- <span class="stat-value" id="totalConversations">0</span>
1758
- </div>
1759
- <div class="stat">
1760
- <span class="stat-label">claude sessions:</span>
1761
- <span class="stat-value" id="claudeSessions">0</span>
1762
- <span class="stat-sublabel" id="claudeSessionsDetail"></span>
1763
- </div>
1764
- <div class="stat">
1765
- <span class="stat-label">tokens:</span>
1766
- <span class="stat-value" id="totalTokens">0</span>
1767
- </div>
1768
- <div class="stat">
1769
- <span class="stat-label">projects:</span>
1770
- <span class="stat-value" id="activeProjects">0</span>
1771
- </div>
1772
- <div class="stat">
1773
- <span class="stat-label">storage:</span>
1774
- <span class="stat-value" id="dataSize">0</span>
1775
- </div>
1776
- </div>
1777
-
1778
- <div class="chart-controls">
1779
- <div class="chart-controls-left">
1780
- <div class="date-control">
1781
- <span class="date-label">from:</span>
1782
- <input type="date" id="dateFrom" class="date-input">
1783
- </div>
1784
- <div class="date-control">
1785
- <span class="date-label">to:</span>
1786
- <input type="date" id="dateTo" class="date-input">
1787
- </div>
1788
- </div>
1789
- <div class="chart-controls-right">
1790
- <button class="refresh-btn" onclick="toggleNotifications()" id="notificationBtn">
1791
- enable notifications
1792
- </button>
1793
- <button class="refresh-btn" onclick="refreshCharts()" id="refreshBtn">
1794
- refresh charts
1795
- </button>
1796
- </div>
1797
- </div>
1798
-
1799
- <div class="charts-container">
1800
- <div class="chart-card">
1801
- <div class="chart-title">
1802
- 📊 token usage over time
1803
- </div>
1804
- <canvas id="tokenChart" class="chart-canvas"></canvas>
1805
- </div>
1806
-
1807
- <div class="chart-card">
1808
- <div class="chart-title">
1809
- 🎯 project activity distribution
1810
- </div>
1811
- <canvas id="projectChart" class="chart-canvas"></canvas>
1812
- </div>
1813
- </div>
1814
-
1815
- <div class="filter-bar">
1816
- <span class="filter-label">filter conversations:</span>
1817
- <div class="filter-buttons">
1818
- <button class="filter-btn active" data-filter="active">active</button>
1819
- <button class="filter-btn" data-filter="recent">recent</button>
1820
- <button class="filter-btn" data-filter="inactive">inactive</button>
1821
- <button class="filter-btn" data-filter="all">all</button>
1822
- </div>
1823
- </div>
1824
-
1825
- <table class="sessions-table">
1826
- <thead>
1827
- <tr>
1828
- <th>conversation id</th>
1829
- <th>project</th>
1830
- <th>model</th>
1831
- <th>messages</th>
1832
- <th>tokens</th>
1833
- <th>last activity</th>
1834
- <th>conversation state</th>
1835
- <th>status</th>
1836
- </tr>
1837
- </thead>
1838
- <tbody id="sessionsTable">
1839
- <!-- Sessions will be loaded here -->
1840
- </tbody>
1841
- </table>
1842
-
1843
- <div id="noSessions" class="no-sessions" style="display: none;">
1844
- no conversations found for current filter
1845
- </div>
1846
-
1847
- <div id="sessionDetail" class="session-detail">
1848
- <button class="btn back-btn" onclick="showSessionsList()">← back to conversations</button>
1849
-
1850
- <div class="detail-header">
1851
- <div class="detail-title" id="detailTitle">conversation details</div>
1852
- <div class="detail-actions">
1853
- <select id="exportFormat" class="export-format-select">
1854
- <option value="csv">CSV</option>
1855
- <option value="json">JSON</option>
1856
- </select>
1857
- <button class="btn" onclick="exportSession()">export</button>
1858
- <button class="btn btn-primary" onclick="refreshSessionDetail()">refresh</button>
1859
- </div>
1860
- </div>
1861
-
1862
- <div class="session-info" id="sessionInfo">
1863
- <!-- Session info will be loaded here -->
1864
- </div>
1865
-
1866
- <div>
1867
- <h3 style="color: #7d8590; margin-bottom: 16px; font-size: 0.875rem; text-transform: uppercase;">conversation history</h3>
1868
- <input type="text" id="conversationSearch" class="search-input" placeholder="Search messages...">
1869
- <div class="conversation-history" id="conversationHistory">
1870
- <!-- Conversation history will be loaded here -->
1871
- </div>
1872
- </div>
1873
- </div>
1874
- </div>
1875
- </div>
1876
-
1877
- <script>
1878
- let allConversations = [];
1879
- let currentFilter = 'active';
1880
- let currentSession = null;
1881
- let tokenChart = null;
1882
- let projectChart = null;
1883
- let allData = null;
1884
- let notificationsEnabled = false;
1885
- let previousConversationStates = new Map();
1886
-
1887
- async function loadData() {
1888
- try {
1889
- const response = await fetch('/api/data');
1890
- const data = await response.json();
1891
-
1892
- console.log('Data loaded:', data.timestamp);
1893
-
1894
- document.getElementById('loading').style.display = 'none';
1895
- document.getElementById('dashboard').style.display = 'block';
1896
-
1897
- // Update timestamp
1898
- document.getElementById('lastUpdate').textContent = \`last update: \${data.lastUpdate}\`;
1899
-
1900
- updateStats(data.summary);
1901
- allConversations = data.conversations;
1902
- allData = data; // Store data globally for access
1903
- window.allData = data; // Keep for backward compatibility
1904
-
1905
- // Initialize date inputs on first load
1906
- if (!document.getElementById('dateFrom').value) {
1907
- initializeDateInputs();
1908
- }
1909
-
1910
- updateCharts(data);
1911
- updateSessionsTable();
1912
-
1913
- // Check for conversation state changes and send notifications
1914
- checkForNotifications(data.conversations);
1915
-
1916
- } catch (error) {
1917
- document.getElementById('loading').style.display = 'none';
1918
- document.getElementById('error').style.display = 'block';
1919
- console.error('Failed to load data:', error);
1920
- }
1921
- }
1922
-
1923
- // Function to only update conversation data without refreshing charts
1924
- async function loadConversationData() {
1925
- try {
1926
- const response = await fetch('/api/data');
1927
- const data = await response.json();
1928
-
1929
-
1930
- // Update timestamp
1931
- document.getElementById('lastUpdate').textContent = \`last update: \${data.lastUpdate}\`;
1932
-
1933
- updateStats(data.summary);
1934
- allConversations = data.conversations;
1935
- allData = data; // Store data globally for access
1936
- window.allData = data; // Keep for backward compatibility
1937
-
1938
- // Only update sessions table, not charts
1939
- updateSessionsTable();
1940
-
1941
- // Check for conversation state changes and send notifications
1942
- checkForNotifications(data.conversations);
1943
-
1944
- } catch (error) {
1945
- console.error('Failed to refresh conversation data:', error);
1946
- }
1947
- }
1948
-
1949
- // Notification functions
1950
- async function requestNotificationPermission() {
1951
- if (!('Notification' in window)) {
1952
- console.log('This browser does not support notifications');
1953
- return false;
1954
- }
1955
-
1956
- if (Notification.permission === 'granted') {
1957
- notificationsEnabled = true;
1958
- return true;
1959
- }
1960
-
1961
- if (Notification.permission !== 'denied') {
1962
- const permission = await Notification.requestPermission();
1963
- notificationsEnabled = permission === 'granted';
1964
- return notificationsEnabled;
1965
- }
1966
-
1967
- return false;
1968
- }
1969
-
1970
- function sendNotification(title, body, conversationId) {
1971
- if (!notificationsEnabled) return;
1972
-
1973
- const notification = new Notification(title, {
1974
- body: body,
1975
- icon: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzIiIGhlaWdodD0iMzIiIHZpZXdCb3g9IjAgMCAzMiAzMiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjMyIiBoZWlnaHQ9IjMyIiByeD0iNCIgZmlsbD0iIzIxMjYyZCIvPgo8cGF0aCBkPSJNOCA4aDE2djE2SDh6IiBmaWxsPSIjZDU3NDU1Ii8+CjwvZGJnPgo=',
1976
- badge: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGNpcmNsZSBjeD0iOCIgY3k9IjgiIHI9IjgiIGZpbGw9IiNkNTc0NTUiLz4KPC9zdmc+',
1977
- tag: conversationId,
1978
- requireInteraction: true
1979
- });
1980
-
1981
- notification.onclick = function() {
1982
- window.focus();
1983
- this.close();
1984
- // Focus on the conversation if possible
1985
- if (conversationId) {
1986
- showSessionDetail(conversationId);
1987
- }
1988
- };
1989
-
1990
- // Auto close after 10 seconds
1991
- setTimeout(() => {
1992
- notification.close();
1993
- }, 10000);
1994
- }
1995
-
1996
- function checkForNotifications(conversations) {
1997
- if (!notificationsEnabled) return;
1998
-
1999
- conversations.forEach(conv => {
2000
- const currentState = conv.conversationState;
2001
- const prevState = previousConversationStates.get(conv.id);
2002
-
2003
- // Check if conversation state changed to "Awaiting user input..."
2004
- if (prevState && prevState !== currentState) {
2005
- if (currentState === 'Awaiting user input...' ||
2006
- currentState === 'User may be typing...' ||
2007
- currentState === 'Awaiting response...') {
2008
-
2009
- const title = 'Claude is waiting for you!';
2010
- const body = \`Project: \${conv.project} - Claude needs your input\`;
2011
-
2012
- sendNotification(title, body, conv.id);
2013
- }
2014
- }
2015
-
2016
- // Update previous state
2017
- previousConversationStates.set(conv.id, currentState);
2018
- });
2019
- }
2020
-
2021
- async function toggleNotifications() {
2022
- const btn = document.getElementById('notificationBtn');
2023
-
2024
- if (!notificationsEnabled) {
2025
- const granted = await requestNotificationPermission();
2026
- if (granted) {
2027
- btn.textContent = 'notifications on';
2028
- btn.style.borderColor = '#3fb950';
2029
- btn.style.color = '#3fb950';
2030
-
2031
- // Send a test notification
2032
- sendNotification(
2033
- 'Notifications enabled!',
2034
- 'You will now receive alerts when Claude is waiting for your input.',
2035
- null
2036
- );
2037
- } else {
2038
- btn.textContent = 'notifications denied';
2039
- btn.style.borderColor = '#f85149';
2040
- btn.style.color = '#f85149';
2041
- }
2042
- } else {
2043
- // Disable notifications
2044
- notificationsEnabled = false;
2045
- btn.textContent = 'enable notifications';
2046
- btn.style.borderColor = '#30363d';
2047
- btn.style.color = '#7d8590';
2048
- }
2049
- }
2050
-
2051
- function updateStats(summary) {
2052
- document.getElementById('totalConversations').textContent = summary.totalConversations.toLocaleString();
2053
- document.getElementById('totalTokens').textContent = summary.totalTokens.toLocaleString();
2054
- document.getElementById('activeProjects').textContent = summary.activeProjects;
2055
- document.getElementById('dataSize').textContent = summary.totalFileSize;
2056
-
2057
- // Update Claude sessions
2058
- if (summary.claudeSessions) {
2059
- document.getElementById('claudeSessions').textContent = summary.claudeSessions.total.toLocaleString();
2060
- document.getElementById('claudeSessionsDetail').textContent =
2061
- \`this month: \${summary.claudeSessions.currentMonth} • this week: \${summary.claudeSessions.thisWeek}\`;
2062
- }
2063
- }
2064
-
2065
- function initializeDateInputs() {
2066
- const today = new Date();
2067
- const sevenDaysAgo = new Date(today);
2068
- sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
2069
-
2070
- document.getElementById('dateFrom').value = sevenDaysAgo.toISOString().split('T')[0];
2071
- document.getElementById('dateTo').value = today.toISOString().split('T')[0];
2072
- }
2073
-
2074
- function getDateRange() {
2075
- const fromDate = new Date(document.getElementById('dateFrom').value);
2076
- const toDate = new Date(document.getElementById('dateTo').value);
2077
- toDate.setHours(23, 59, 59, 999); // Include the entire end date
2078
-
2079
- return { fromDate, toDate };
2080
- }
2081
-
2082
- function filterConversationsByDate(conversations) {
2083
- const { fromDate, toDate } = getDateRange();
2084
-
2085
- return conversations.filter(conv => {
2086
- const convDate = new Date(conv.lastModified);
2087
- return convDate >= fromDate && convDate <= toDate;
2088
- });
2089
- }
2090
-
2091
- function updateCharts(data) {
2092
- // Wait for Chart.js to load before creating charts
2093
- if (typeof Chart === 'undefined') {
2094
- console.log('Chart.js not loaded yet, retrying in 100ms...');
2095
- setTimeout(() => updateCharts(data), 100);
2096
- return;
2097
- }
2098
-
2099
- // Use ALL conversations but filter chart display by date range
2100
- // This maintains the original behavior
2101
-
2102
- // Update Token Usage Over Time Chart
2103
- updateTokenChart(data.conversations);
2104
-
2105
- // Update Project Activity Distribution Chart
2106
- updateProjectChart(data.conversations);
2107
- }
2108
-
2109
- async function refreshCharts() {
2110
- const refreshBtn = document.getElementById('refreshBtn');
2111
- refreshBtn.classList.add('loading');
2112
- refreshBtn.textContent = '🔄 refreshing...';
2113
-
2114
- try {
2115
- // Use existing data but re-filter and update charts
2116
- if (allData) {
2117
- updateCharts(allData);
2118
- }
2119
- } catch (error) {
2120
- console.error('Error refreshing charts:', error);
2121
- } finally {
2122
- refreshBtn.classList.remove('loading');
2123
- refreshBtn.textContent = 'refresh charts';
2124
- }
2125
- }
2126
-
2127
- function updateTokenChart(conversations) {
2128
- // Check if Chart.js is available
2129
- if (typeof Chart === 'undefined') {
2130
- console.warn('Chart.js not available for updateTokenChart');
2131
- return;
2132
- }
2133
-
2134
- // Prepare data for selected date range
2135
- const { fromDate, toDate } = getDateRange();
2136
- const dateRange = [];
2137
-
2138
- const currentDate = new Date(fromDate);
2139
- while (currentDate <= toDate) {
2140
- dateRange.push({
2141
- date: currentDate.toISOString().split('T')[0],
2142
- label: currentDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
2143
- tokens: 0
2144
- });
2145
- currentDate.setDate(currentDate.getDate() + 1);
2146
- }
2147
-
2148
- // Aggregate tokens by day
2149
- conversations.forEach(conv => {
2150
- const convDate = new Date(conv.lastModified).toISOString().split('T')[0];
2151
- const dayData = dateRange.find(day => day.date === convDate);
2152
- if (dayData) {
2153
- dayData.tokens += conv.tokens;
2154
- }
2155
- });
2156
-
2157
- const ctx = document.getElementById('tokenChart').getContext('2d');
2158
-
2159
- if (tokenChart) {
2160
- tokenChart.destroy();
2161
- }
2162
-
2163
- tokenChart = new Chart(ctx, {
2164
- type: 'line',
2165
- data: {
2166
- labels: dateRange.map(day => day.label),
2167
- datasets: [{
2168
- label: 'Tokens',
2169
- data: dateRange.map(day => day.tokens),
2170
- borderColor: '#d57455',
2171
- backgroundColor: 'rgba(213, 116, 85, 0.1)',
2172
- borderWidth: 2,
2173
- pointBackgroundColor: '#d57455',
2174
- pointBorderColor: '#d57455',
2175
- pointRadius: 4,
2176
- pointHoverRadius: 6,
2177
- fill: true,
2178
- tension: 0.3
2179
- }]
2180
- },
2181
- options: {
2182
- responsive: true,
2183
- maintainAspectRatio: false,
2184
- plugins: {
2185
- legend: {
2186
- display: false
2187
- },
2188
- tooltip: {
2189
- backgroundColor: '#161b22',
2190
- titleColor: '#d57455',
2191
- bodyColor: '#c9d1d9',
2192
- borderColor: '#30363d',
2193
- borderWidth: 1,
2194
- titleFont: {
2195
- family: 'Monaco, Menlo, Ubuntu Mono, monospace',
2196
- size: 12
2197
- },
2198
- bodyFont: {
2199
- family: 'Monaco, Menlo, Ubuntu Mono, monospace',
2200
- size: 11
2201
- },
2202
- callbacks: {
2203
- title: function(context) {
2204
- return context[0].label;
2205
- },
2206
- label: function(context) {
2207
- return \`Tokens: \${context.parsed.y.toLocaleString()}\`;
2208
- }
2209
- }
2210
- }
2211
- },
2212
- interaction: {
2213
- intersect: false,
2214
- mode: 'index'
2215
- },
2216
- hover: {
2217
- animationDuration: 200
2218
- },
2219
- scales: {
2220
- x: {
2221
- grid: {
2222
- color: '#30363d',
2223
- borderColor: '#30363d'
2224
- },
2225
- ticks: {
2226
- color: '#7d8590',
2227
- font: {
2228
- family: 'Monaco, Menlo, Ubuntu Mono, monospace',
2229
- size: 11
2230
- }
2231
- }
2232
- },
2233
- y: {
2234
- grid: {
2235
- color: '#30363d',
2236
- borderColor: '#30363d'
2237
- },
2238
- ticks: {
2239
- color: '#7d8590',
2240
- font: {
2241
- family: 'Monaco, Menlo, Ubuntu Mono, monospace',
2242
- size: 11
2243
- },
2244
- callback: function(value) {
2245
- return value.toLocaleString();
2246
- }
2247
- }
2248
- }
2249
- }
2250
- }
2251
- });
2252
- }
2253
-
2254
- function updateProjectChart(conversations) {
2255
- // Check if Chart.js is available
2256
- if (typeof Chart === 'undefined') {
2257
- console.warn('Chart.js not available for updateProjectChart');
2258
- return;
2259
- }
2260
-
2261
- // Aggregate data by project
2262
- const projectData = {};
2263
-
2264
- conversations.forEach(conv => {
2265
- if (!projectData[conv.project]) {
2266
- projectData[conv.project] = 0;
2267
- }
2268
- projectData[conv.project] += conv.tokens;
2269
- });
2270
-
2271
- // Get top 5 projects and group others
2272
- const sortedProjects = Object.entries(projectData)
2273
- .sort(([,a], [,b]) => b - a)
2274
- .slice(0, 5);
2275
-
2276
- const othersTotal = Object.entries(projectData)
2277
- .slice(5)
2278
- .reduce((sum, [,tokens]) => sum + tokens, 0);
2279
-
2280
- if (othersTotal > 0) {
2281
- sortedProjects.push(['others', othersTotal]);
2282
- }
2283
-
2284
- // Terminal-style colors
2285
- const colors = [
2286
- '#d57455', // Orange
2287
- '#3fb950', // Green
2288
- '#a5d6ff', // Blue
2289
- '#f97316', // Orange variant
2290
- '#c9d1d9', // Light gray
2291
- '#7d8590' // Gray
2292
- ];
2293
-
2294
- const ctx = document.getElementById('projectChart').getContext('2d');
2295
-
2296
- if (projectChart) {
2297
- projectChart.destroy();
2298
- }
2299
-
2300
- projectChart = new Chart(ctx, {
2301
- type: 'doughnut',
2302
- data: {
2303
- labels: sortedProjects.map(([project]) => project),
2304
- datasets: [{
2305
- data: sortedProjects.map(([,tokens]) => tokens),
2306
- backgroundColor: colors.slice(0, sortedProjects.length),
2307
- borderColor: '#161b22',
2308
- borderWidth: 2,
2309
- hoverBorderWidth: 3
2310
- }]
2311
- },
2312
- options: {
2313
- responsive: true,
2314
- maintainAspectRatio: false,
2315
- plugins: {
2316
- legend: {
2317
- position: 'bottom',
2318
- labels: {
2319
- color: '#7d8590',
2320
- font: {
2321
- family: 'Monaco, Menlo, Ubuntu Mono, monospace',
2322
- size: 10
2323
- },
2324
- padding: 15,
2325
- usePointStyle: true,
2326
- pointStyle: 'circle'
2327
- }
2328
- },
2329
- tooltip: {
2330
- backgroundColor: '#161b22',
2331
- titleColor: '#d57455',
2332
- bodyColor: '#c9d1d9',
2333
- borderColor: '#30363d',
2334
- borderWidth: 1,
2335
- titleFont: {
2336
- family: 'Monaco, Menlo, Ubuntu Mono, monospace'
2337
- },
2338
- bodyFont: {
2339
- family: 'Monaco, Menlo, Ubuntu Mono, monospace'
2340
- },
2341
- callbacks: {
2342
- label: function(context) {
2343
- const total = context.dataset.data.reduce((sum, value) => sum + value, 0);
2344
- const percentage = ((context.parsed / total) * 100).toFixed(1);
2345
- return \`\${context.label}: \${context.parsed.toLocaleString()} tokens (\${percentage}%)\`;
2346
- }
2347
- }
2348
- }
2349
- },
2350
- cutout: '60%'
2351
- }
2352
- });
2353
- }
2354
-
2355
- function updateSessionsTable() {
2356
- const tableBody = document.getElementById('sessionsTable');
2357
- const noSessionsDiv = document.getElementById('noSessions');
2358
-
2359
- // Filter conversations based on current filter
2360
- let filteredConversations = allConversations;
2361
- if (currentFilter !== 'all') {
2362
- filteredConversations = allConversations.filter(conv => conv.status === currentFilter);
2363
- }
2364
-
2365
- if (filteredConversations.length === 0) {
2366
- tableBody.innerHTML = '';
2367
- noSessionsDiv.style.display = 'block';
2368
- return;
2369
- }
2370
-
2371
- noSessionsDiv.style.display = 'none';
2372
-
2373
- tableBody.innerHTML = filteredConversations.map(conv => \`
2374
- <tr onclick="showSessionDetail('\${conv.id}')" style="cursor: pointer;">
2375
- <td>
2376
- <div class="session-id-container">
2377
- <div class="session-id">
2378
- \${conv.id.substring(0, 8)}...
2379
- \${conv.runningProcess ? \`<span class="process-indicator" title="Active claude process (PID: \${conv.runningProcess.pid})"></span>\` : ''}
2380
- </div>
2381
- <div class="status-squares">
2382
- \${generateStatusSquaresHTML(conv.statusSquares || [])}
2383
- </div>
2384
- </div>
2385
- </td>
2386
- <td class="session-project">\${conv.project}</td>
2387
- <td class="session-model" title="\${conv.modelInfo ? conv.modelInfo.primaryModel + ' (' + conv.modelInfo.currentServiceTier + ')' : 'N/A'}">\${conv.modelInfo ? conv.modelInfo.primaryModel : 'N/A'}</td>
2388
- <td class="session-messages">\${conv.messageCount}</td>
2389
- <td class="session-tokens">\${conv.tokens.toLocaleString()}</td>
2390
- <td class="session-time">\${formatTime(conv.lastModified)}</td>
2391
- <td class="conversation-state \${getStateClass(conv.conversationState)}">\${conv.conversationState}</td>
2392
- <td class="status-\${conv.status}">\${conv.status}</td>
2393
- </tr>
2394
- \`).join('');
2395
-
2396
- // NEW: Add orphan processes (active claude commands without conversation)
2397
- if (window.allData && window.allData.orphanProcesses && window.allData.orphanProcesses.length > 0) {
2398
- const orphanRows = window.allData.orphanProcesses.map(process => \`
2399
- <tr style="background: rgba(248, 81, 73, 0.1); cursor: default;">
2400
- <td>
2401
- <div class="session-id-container">
2402
- <div class="session-id">
2403
- orphan-\${process.pid}
2404
- <span class="process-indicator orphan" title="Orphan claude process (PID: \${process.pid})"></span>
2405
- </div>
2406
- </div>
2407
- </td>
2408
- <td class="session-project">\${process.workingDir}</td>
2409
- <td class="session-model">Unknown</td>
2410
- <td class="session-messages">-</td>
2411
- <td class="session-tokens">-</td>
2412
- <td class="session-time">Running</td>
2413
- <td class="conversation-state">Active process</td>
2414
- <td class="status-active">orphan</td>
2415
- </tr>
2416
- \`).join('');
2417
-
2418
- if (currentFilter === 'active' || currentFilter === 'all') {
2419
- tableBody.innerHTML += orphanRows;
2420
- }
2421
- }
2422
- }
2423
-
2424
- function formatTime(date) {
2425
- const now = new Date();
2426
- const diff = now - new Date(date);
2427
- const minutes = Math.floor(diff / (1000 * 60));
2428
- const hours = Math.floor(minutes / 60);
2429
- const days = Math.floor(hours / 24);
2430
-
2431
- if (minutes < 1) return 'now';
2432
- if (minutes < 60) return \`\${minutes}m ago\`;
2433
- if (hours < 24) return \`\${hours}h ago\`;
2434
- return \`\${days}d ago\`;
2435
- }
2436
-
2437
- function formatMessageTime(timestamp) {
2438
- const date = new Date(timestamp);
2439
- return date.toLocaleTimeString('en-US', {
2440
- hour12: false,
2441
- hour: '2-digit',
2442
- minute: '2-digit',
2443
- second: '2-digit'
2444
- });
2445
- }
2446
-
2447
- function getStateClass(conversationState) {
2448
- if (conversationState.includes('working') || conversationState.includes('Working')) {
2449
- return 'working';
2450
- }
2451
- if (conversationState.includes('typing') || conversationState.includes('Typing')) {
2452
- return 'typing';
2453
- }
2454
- return '';
2455
- }
2456
-
2457
- function generateStatusSquaresHTML(statusSquares) {
2458
- if (!statusSquares || statusSquares.length === 0) {
2459
- return '';
2460
- }
2461
-
2462
- return statusSquares.map(square =>
2463
- \`<div class="status-square \${square.type}" data-tooltip="\${square.tooltip}"></div>\`
2464
- ).join('');
2465
- }
2466
-
2467
- function getMessageType(message) {
2468
- if (message.role === 'user') {
2469
- return {
2470
- type: 'pending',
2471
- tooltip: 'User input'
2472
- };
2473
- } else if (message.role === 'assistant') {
2474
- const content = message.content || '';
2475
-
2476
- if (typeof content === 'string') {
2477
- if (content.includes('[Tool:') || content.includes('tool_use')) {
2478
- return {
2479
- type: 'tool',
2480
- tooltip: 'Tool execution'
2481
- };
2482
- } else if (content.includes('error') || content.includes('Error') || content.includes('failed')) {
2483
- return {
2484
- type: 'error',
2485
- tooltip: 'Error in response'
2486
- };
2487
- } else {
2488
- return {
2489
- type: 'success',
2490
- tooltip: 'Successful response'
2491
- };
2492
- }
2493
- }
2494
- }
2495
-
2496
- return {
2497
- type: 'success',
2498
- tooltip: 'Message'
2499
- };
2500
- }
2501
-
2502
- // Filter button handlers
2503
- document.addEventListener('DOMContentLoaded', function() {
2504
- const filterButtons = document.querySelectorAll('.filter-btn');
2505
-
2506
- filterButtons.forEach(button => {
2507
- button.addEventListener('click', function() {
2508
- // Remove active class from all buttons
2509
- filterButtons.forEach(btn => btn.classList.remove('active'));
2510
-
2511
- // Add active class to clicked button
2512
- this.classList.add('active');
2513
-
2514
- // Update current filter
2515
- currentFilter = this.dataset.filter;
2516
-
2517
- // Update table
2518
- updateSessionsTable();
2519
- });
2520
- });
2521
- });
2522
-
2523
- // Session detail functions
2524
- async function showSessionDetail(sessionId) {
2525
- currentSession = allConversations.find(conv => conv.id === sessionId);
2526
- if (!currentSession) return;
2527
-
2528
- // Hide sessions list and show detail
2529
- document.querySelector('.filter-bar').style.display = 'none';
2530
- document.querySelector('.sessions-table').style.display = 'none';
2531
- document.getElementById('noSessions').style.display = 'none';
2532
- document.getElementById('sessionDetail').classList.add('active');
2533
-
2534
- // Update title
2535
- document.getElementById('detailTitle').textContent = \`conversation: \${sessionId.substring(0, 8)}...\`;
2536
-
2537
- // Load session info
2538
- updateSessionInfo(currentSession);
2539
-
2540
- // Load conversation history
2541
- await loadConversationHistory(currentSession);
2542
- }
2543
-
2544
- function showSessionsList() {
2545
- document.getElementById('sessionDetail').classList.remove('active');
2546
- document.querySelector('.filter-bar').style.display = 'flex';
2547
- document.querySelector('.sessions-table').style.display = 'table';
2548
- updateSessionsTable();
2549
- currentSession = null;
2550
- }
2551
-
2552
- function updateSessionInfo(session) {
2553
- const container = document.getElementById('sessionInfo');
2554
-
2555
- container.innerHTML = \`
2556
- <div class="info-item">
2557
- <div class="info-label">conversation id</div>
2558
- <div class="info-value">\${session.id}</div>
2559
- </div>
2560
- <div class="info-item">
2561
- <div class="info-label">project</div>
2562
- <div class="info-value">\${session.project}</div>
2563
- </div>
2564
- <div class="info-item">
2565
- <div class="info-label">messages</div>
2566
- <div class="info-value">\${session.messageCount}</div>
2567
- </div>
2568
- <div class="info-item">
2569
- <div class="info-label">total tokens</div>
2570
- <div class="info-value">\${session.tokens.toLocaleString()}</div>
2571
- </div>
2572
- <div class="info-item">
2573
- <div class="info-label">model</div>
2574
- <div class="info-value model">\${session.modelInfo ? session.modelInfo.primaryModel : 'N/A'}</div>
2575
- </div>
2576
- <div class="info-item">
2577
- <div class="info-label">service tier</div>
2578
- <div class="info-value">\${session.modelInfo ? session.modelInfo.currentServiceTier : 'N/A'}</div>
2579
- </div>
2580
- <div class="info-item">
2581
- <div class="info-label">token details</div>
2582
- <div class="info-value">\${session.tokenUsage ? session.tokenUsage.inputTokens.toLocaleString() + ' in / ' + session.tokenUsage.outputTokens.toLocaleString() + ' out' : 'N/A'}</div>
2583
- </div>
2584
- \${session.tokenUsage && (session.tokenUsage.cacheCreationTokens > 0 || session.tokenUsage.cacheReadTokens > 0) ?
2585
- \`<div class="info-item">
2586
- <div class="info-label">cache tokens</div>
2587
- <div class="info-value">\${session.tokenUsage.cacheCreationTokens.toLocaleString()} created / \${session.tokenUsage.cacheReadTokens.toLocaleString()} read</div>
2588
- </div>\` : ''
2589
- }
2590
- <div class="info-item">
2591
- <div class="info-label">file size</div>
2592
- <div class="info-value">\${formatBytes(session.fileSize)}</div>
2593
- </div>
2594
- <div class="info-item">
2595
- <div class="info-label">created</div>
2596
- <div class="info-value">\${new Date(session.created).toLocaleString()}</div>
2597
- </div>
2598
- <div class="info-item">
2599
- <div class="info-label">last modified</div>
2600
- <div class="info-value">\${new Date(session.lastModified).toLocaleString()}</div>
2601
- </div>
2602
- <div class="info-item">
2603
- <div class="info-label">conversation state</div>
2604
- <div class="info-value conversation-state \${getStateClass(session.conversationState)}">\${session.conversationState}</div>
2605
- </div>
2606
- <div class="info-item">
2607
- <div class="info-label">status</div>
2608
- <div class="info-value status-\${session.status}">\${session.status}</div>
2609
- </div>
2610
- \`;
2611
- }
2612
-
2613
- async function loadConversationHistory(session) {
2614
- try {
2615
- const response = await fetch(\`/api/session/\${session.id}\`);
2616
- const sessionData = await response.json();
2617
-
2618
- const container = document.getElementById('conversationHistory');
2619
- const searchInput = document.getElementById('conversationSearch');
2620
-
2621
- if (!sessionData.messages || sessionData.messages.length === 0) {
2622
- container.innerHTML = '<div style="padding: 20px; text-align: center; color: #7d8590;">no messages found</div>';
2623
- searchInput.style.display = 'none';
2624
- return;
2625
- }
2626
- searchInput.style.display = 'block';
2627
-
2628
- const renderMessages = (filter = '') => {
2629
- const lowerCaseFilter = filter.toLowerCase();
2630
- const filteredMessages = sessionData.messages.filter(m =>
2631
- (m.content || '').toLowerCase().includes(lowerCaseFilter)
2632
- );
2633
-
2634
- if (filteredMessages.length === 0) {
2635
- container.innerHTML = '<div style="padding: 20px; text-align: center; color: #7d8590;">No messages match your search.</div>';
2636
- return;
2637
- }
2638
-
2639
- const reversedMessages = filteredMessages.slice().reverse();
2640
-
2641
- container.innerHTML = reversedMessages.map((message, index) => {
2642
- const messageType = getMessageType(message);
2643
- const messageNum = filteredMessages.length - index;
2644
- return \`
2645
- <div class="message">
2646
- <div class="message-type-indicator \${messageType.type}" data-tooltip="\${messageType.tooltip}"></div>
2647
- <div class="message-header">
2648
- <div class="message-role \${message.role}">\${message.role}</div>
2649
- <div class="message-time">
2650
- #\${messageNum} • \${message.timestamp ? formatMessageTime(message.timestamp) : 'unknown time'}
2651
- </div>
2652
- </div>
2653
- <div class="message-content">\${truncateContent(message.content || 'no content')}</div>
2654
- </div>
2655
- \`;
2656
- }).join('');
2657
- }
2658
-
2659
- renderMessages();
2660
-
2661
- searchInput.addEventListener('input', () => {
2662
- renderMessages(searchInput.value);
2663
- });
2664
-
2665
- } catch (error) {
2666
- document.getElementById('conversationHistory').innerHTML =
2667
- '<div style="padding: 20px; text-align: center; color: #f85149;">error loading conversation history</div>';
2668
- console.error('Failed to load conversation history:', error);
2669
- }
2670
- }
2671
-
2672
- function truncateContent(content, maxLength = 1000) {
2673
- if (typeof content !== 'string') return 'no content';
2674
- if (!content.trim()) return 'empty message';
2675
- if (content.length <= maxLength) return content;
2676
- return content.substring(0, maxLength) + '\\n\\n[... message truncated ...]';
2677
- }
2678
-
2679
- function formatBytes(bytes) {
2680
- if (bytes === 0) return '0 B';
2681
- const k = 1024;
2682
- const sizes = ['B', 'KB', 'MB', 'GB'];
2683
- const i = Math.floor(Math.log(bytes) / Math.log(k));
2684
- return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
2685
- }
2686
-
2687
- function exportSession() {
2688
- if (!currentSession) return;
2689
-
2690
- const format = document.getElementById('exportFormat').value;
2691
-
2692
- // Fetch conversation history and export
2693
- fetch(\`/api/session/\${currentSession.id}\`)
2694
- .then(response => response.json())
2695
- .then(sessionData => {
2696
- if (format === 'csv') {
2697
- exportSessionAsCSV(sessionData);
2698
- } else if (format === 'json') {
2699
- exportSessionAsJSON(sessionData);
2700
- }
2701
- })
2702
- .catch(error => {
2703
- console.error(\`Failed to export \${format.toUpperCase()}:\`, error);
2704
- alert(\`Failed to export \${format.toUpperCase()}. Please try again.\`);
2705
- });
2706
- }
2707
-
2708
- function exportSessionAsCSV(sessionData) {
2709
- // Create CSV content
2710
- let csvContent = 'Conversation ID,Project,Message Count,Tokens,File Size,Created,Last Modified,Conversation State,Status\\n';
2711
- csvContent += \`"\${currentSession.id}","\${currentSession.project}",\${currentSession.messageCount},\${currentSession.tokens},\${currentSession.fileSize},"\${new Date(currentSession.created).toISOString()}","\${new Date(currentSession.lastModified).toISOString()}","\${currentSession.conversationState}","\${currentSession.status}"\\n\\n\`;
2712
-
2713
- csvContent += 'Message #,Role,Timestamp,Content\\n';
2714
-
2715
- // Add conversation history
2716
- if (sessionData.messages) {
2717
- sessionData.messages.forEach((message, index) => {
2718
- const content = (message.content || 'no content').replace(/"/g, '""');
2719
- const timestamp = message.timestamp ? new Date(message.timestamp).toISOString() : 'unknown';
2720
- csvContent += \`\${index + 1},"\${message.role}","\${timestamp}","\${content}"\\n\`;
2721
- });
2722
- }
2723
-
2724
- // Download CSV
2725
- const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
2726
- downloadFile(blob, \`claude-conversation-\${currentSession.id.substring(0, 8)}.csv\`);
2727
- }
2728
-
2729
- function exportSessionAsJSON(sessionData) {
2730
- // Create comprehensive JSON export
2731
- const exportData = {
2732
- conversation: {
2733
- id: currentSession.id,
2734
- filename: currentSession.filename,
2735
- project: currentSession.project,
2736
- messageCount: currentSession.messageCount,
2737
- tokens: currentSession.tokens,
2738
- fileSize: currentSession.fileSize,
2739
- created: currentSession.created,
2740
- lastModified: currentSession.lastModified,
2741
- conversationState: currentSession.conversationState,
2742
- status: currentSession.status
2743
- },
2744
- messages: sessionData.messages || [],
2745
- metadata: {
2746
- exportedAt: new Date().toISOString(),
2747
- exportFormat: 'json',
2748
- toolVersion: '1.5.7'
2749
- }
2750
- };
2751
-
2752
- // Download JSON
2753
- const jsonString = JSON.stringify(exportData, null, 2);
2754
- const blob = new Blob([jsonString], { type: 'application/json;charset=utf-8;' });
2755
- downloadFile(blob, \`claude-conversation-\${currentSession.id.substring(0, 8)}.json\`);
2756
- }
2757
-
2758
- function downloadFile(blob, filename) {
2759
- const link = document.createElement('a');
2760
- const url = URL.createObjectURL(blob);
2761
- link.setAttribute('href', url);
2762
- link.setAttribute('download', filename);
2763
- link.style.visibility = 'hidden';
2764
- document.body.appendChild(link);
2765
- link.click();
2766
- document.body.removeChild(link);
2767
- URL.revokeObjectURL(url);
2768
- }
2769
-
2770
- function refreshSessionDetail() {
2771
- if (currentSession) {
2772
- loadConversationHistory(currentSession);
2773
- }
2774
- }
2775
-
2776
- // Manual refresh function
2777
- async function forceRefresh() {
2778
- try {
2779
- const response = await fetch('/api/refresh');
2780
- const result = await response.json();
2781
- console.log('Manual refresh:', result);
2782
- await loadData();
2783
- } catch (error) {
2784
- console.error('Failed to refresh:', error);
2785
- }
2786
- }
2787
-
2788
- // Wait for DOM and Chart.js to load
2789
- document.addEventListener('DOMContentLoaded', function() {
2790
- // Check if Chart.js is loaded
2791
- function initWhenReady() {
2792
- if (typeof Chart !== 'undefined') {
2793
- console.log('Chart.js loaded successfully');
2794
- loadData();
2795
-
2796
- // Automatic refresh for conversation data every 1 second for real-time updates
2797
- setInterval(() => {
2798
- loadConversationData();
2799
- }, 1000);
2800
- } else {
2801
- console.log('Waiting for Chart.js to load...');
2802
- setTimeout(initWhenReady, 100);
2803
- }
2804
- }
2805
-
2806
- initWhenReady();
2807
-
2808
- // Add event listeners for date inputs
2809
- document.getElementById('dateFrom').addEventListener('change', refreshCharts);
2810
- document.getElementById('dateTo').addEventListener('change', refreshCharts);
2811
-
2812
- // Initialize notification button state
2813
- updateNotificationButtonState();
2814
- });
2815
-
2816
- function updateNotificationButtonState() {
2817
- const btn = document.getElementById('notificationBtn');
2818
- if (!btn) return;
2819
-
2820
- if (Notification.permission === 'granted') {
2821
- notificationsEnabled = true;
2822
- btn.textContent = 'notifications on';
2823
- btn.style.borderColor = '#3fb950';
2824
- btn.style.color = '#3fb950';
2825
- } else if (Notification.permission === 'denied') {
2826
- btn.textContent = 'notifications denied';
2827
- btn.style.borderColor = '#f85149';
2828
- btn.style.color = '#f85149';
2829
- }
2830
- }
2831
-
2832
- // Add keyboard shortcut for refresh (F5 or Ctrl+R)
2833
- document.addEventListener('keydown', function(e) {
2834
- if (e.key === 'F5' || (e.ctrlKey && e.key === 'r')) {
2835
- e.preventDefault();
2836
- forceRefresh();
2837
- }
2838
- });
2839
- </script>
2840
- </body>
2841
- </html>`;
2842
-
2843
- await fs.writeFile(path.join(webDir, 'index.html'), htmlContent);
2844
- }
2845
1175
 
2846
1176
  module.exports = {
2847
1177
  runAnalytics