@qiaolei81/copilot-session-viewer 0.3.1 → 0.3.3
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/AGENTS.md +109 -0
- package/CHANGELOG.md +33 -0
- package/CONTRIBUTING.md +104 -0
- package/RELEASE.md +146 -0
- package/docs/API.md +471 -0
- package/docs/DEVELOPMENT.md +556 -0
- package/docs/INSTALLATION.md +329 -0
- package/docs/README.md +102 -0
- package/docs/TROUBLESHOOTING.md +630 -0
- package/docs/images/homepage.png +0 -0
- package/docs/images/session-detail.png +0 -0
- package/docs/images/time-analysis.png +0 -0
- package/docs/unified-event-format-design.md +844 -0
- package/docs/unified-event-format-implementation.md +350 -0
- package/eslint.config.mjs +133 -0
- package/package.json +10 -4
- package/public/js/homepage.min.js +35 -0
- package/public/js/session-detail.min.js +461 -0
- package/public/js/telemetry-browser.min.js +1 -0
- package/public/js/time-analyze.min.js +518 -0
- package/scripts/release.sh +43 -0
- package/server.js +3 -0
- package/src/app.js +2 -1
- package/src/controllers/insightController.js +31 -0
- package/src/controllers/sessionController.js +56 -0
- package/src/controllers/tagController.js +8 -0
- package/src/controllers/uploadController.js +32 -1
- package/src/middleware/common.js +20 -1
- package/src/models/Session.js +12 -2
- package/src/services/sessionRepository.js +98 -108
- package/src/telemetry.js +152 -0
- package/src/utils/fileUtils.js +2 -1
- package/views/index.ejs +9 -494
- package/views/session-vue.ejs +166 -1869
- package/views/telemetry-snippet.ejs +26 -0
- package/views/time-analyze.ejs +2 -2217
- package/.env.example +0 -14
package/src/telemetry.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Application Insights Telemetry Module
|
|
3
|
+
*
|
|
4
|
+
* This module initializes and configures Application Insights for telemetry tracking.
|
|
5
|
+
* Must be required BEFORE any other modules (especially Express) in server.js.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Auto-collection of requests, dependencies, exceptions, and performance counters
|
|
9
|
+
* - Custom event and metric tracking
|
|
10
|
+
* - Automatic disabling in test environments
|
|
11
|
+
* - Support for manual disabling via DISABLE_TELEMETRY env var
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const appInsights = require('applicationinsights');
|
|
15
|
+
|
|
16
|
+
// Determine if telemetry should be disabled
|
|
17
|
+
const isTestEnvironment = process.env.NODE_ENV === 'test';
|
|
18
|
+
const isDisabled = process.env.DISABLE_TELEMETRY === 'true' || isTestEnvironment;
|
|
19
|
+
|
|
20
|
+
// Default connection string (can be overridden via env var)
|
|
21
|
+
const DEFAULT_CONNECTION_STRING = 'InstrumentationKey=39f4fbf1-d82f-42c3-b4ef-ea92a1fd82cb;IngestionEndpoint=https://eastus-8.in.applicationinsights.azure.com/;LiveEndpoint=https://eastus.livediagnostics.monitor.azure.com/;ApplicationId=7d4bb432-f2f5-4526-a5e6-31901e5a2db2';
|
|
22
|
+
|
|
23
|
+
let client = null;
|
|
24
|
+
|
|
25
|
+
if (!isDisabled) {
|
|
26
|
+
try {
|
|
27
|
+
// Get connection string from environment or use default
|
|
28
|
+
const connectionString = process.env.APPLICATIONINSIGHTS_CONNECTION_STRING || DEFAULT_CONNECTION_STRING;
|
|
29
|
+
|
|
30
|
+
// Setup and start Application Insights
|
|
31
|
+
appInsights.setup(connectionString)
|
|
32
|
+
.setAutoDependencyCorrelation(true)
|
|
33
|
+
.setAutoCollectRequests(true)
|
|
34
|
+
.setAutoCollectPerformance(true, true)
|
|
35
|
+
.setAutoCollectExceptions(true)
|
|
36
|
+
.setAutoCollectDependencies(true)
|
|
37
|
+
.setAutoCollectConsole(false) // Disable console tracking to avoid noise
|
|
38
|
+
.setUseDiskRetryCaching(true)
|
|
39
|
+
.setSendLiveMetrics(false) // Disable live metrics for local dev tool
|
|
40
|
+
.setDistributedTracingMode(appInsights.DistributedTracingModes.AI_AND_W3C)
|
|
41
|
+
.start();
|
|
42
|
+
|
|
43
|
+
client = appInsights.defaultClient;
|
|
44
|
+
|
|
45
|
+
// Set context properties
|
|
46
|
+
client.context.tags[client.context.keys.cloudRole] = 'copilot-session-viewer';
|
|
47
|
+
client.context.tags[client.context.keys.cloudRoleInstance] = require('os').hostname();
|
|
48
|
+
|
|
49
|
+
console.log('✅ Application Insights telemetry initialized');
|
|
50
|
+
} catch (error) {
|
|
51
|
+
console.error('❌ Failed to initialize Application Insights:', error.message);
|
|
52
|
+
// Continue without telemetry rather than crashing
|
|
53
|
+
client = createNoOpClient();
|
|
54
|
+
}
|
|
55
|
+
} else {
|
|
56
|
+
// Return no-op client for test environment or when disabled
|
|
57
|
+
client = createNoOpClient();
|
|
58
|
+
|
|
59
|
+
if (isTestEnvironment) {
|
|
60
|
+
console.log('📊 Telemetry disabled (test environment)');
|
|
61
|
+
} else {
|
|
62
|
+
console.log('📊 Telemetry disabled (DISABLE_TELEMETRY=true)');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Creates a no-op client that safely ignores all telemetry calls
|
|
68
|
+
* Used when telemetry is disabled or in test environments
|
|
69
|
+
*/
|
|
70
|
+
function createNoOpClient() {
|
|
71
|
+
return {
|
|
72
|
+
trackEvent: () => {},
|
|
73
|
+
trackMetric: () => {},
|
|
74
|
+
trackException: () => {},
|
|
75
|
+
trackTrace: () => {},
|
|
76
|
+
trackDependency: () => {},
|
|
77
|
+
trackRequest: () => {},
|
|
78
|
+
flush: (callback) => {
|
|
79
|
+
if (callback) callback();
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Track a custom event
|
|
86
|
+
* @param {string} name - Event name
|
|
87
|
+
* @param {Object} properties - Event properties
|
|
88
|
+
*/
|
|
89
|
+
function trackEvent(name, properties = {}) {
|
|
90
|
+
if (client && client.trackEvent) {
|
|
91
|
+
client.trackEvent({
|
|
92
|
+
name,
|
|
93
|
+
properties
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Track a custom metric
|
|
100
|
+
* @param {string} name - Metric name
|
|
101
|
+
* @param {number} value - Metric value
|
|
102
|
+
* @param {Object} properties - Additional properties
|
|
103
|
+
*/
|
|
104
|
+
function trackMetric(name, value, properties = {}) {
|
|
105
|
+
if (client && client.trackMetric) {
|
|
106
|
+
client.trackMetric({
|
|
107
|
+
name,
|
|
108
|
+
value,
|
|
109
|
+
properties
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Track an exception
|
|
116
|
+
* @param {Error} error - Error object
|
|
117
|
+
* @param {Object} properties - Additional properties
|
|
118
|
+
*/
|
|
119
|
+
function trackException(error, properties = {}) {
|
|
120
|
+
if (client && client.trackException) {
|
|
121
|
+
client.trackException({
|
|
122
|
+
exception: error,
|
|
123
|
+
properties
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Flush telemetry data (useful for short-lived processes)
|
|
130
|
+
* @returns {Promise<void>}
|
|
131
|
+
*/
|
|
132
|
+
function flush() {
|
|
133
|
+
return new Promise((resolve) => {
|
|
134
|
+
if (client && client.flush) {
|
|
135
|
+
client.flush({
|
|
136
|
+
callback: () => resolve()
|
|
137
|
+
});
|
|
138
|
+
} else {
|
|
139
|
+
resolve();
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Export the client and helper functions
|
|
145
|
+
module.exports = {
|
|
146
|
+
client,
|
|
147
|
+
trackEvent,
|
|
148
|
+
trackMetric,
|
|
149
|
+
trackException,
|
|
150
|
+
flush,
|
|
151
|
+
isEnabled: !isDisabled
|
|
152
|
+
};
|
package/src/utils/fileUtils.js
CHANGED
|
@@ -146,7 +146,8 @@ async function getSessionMetadataOptimized(filePath, maxMessageLength = 200) {
|
|
|
146
146
|
copilotVersion: copilotVersion || null,
|
|
147
147
|
selectedModel: selectedModel || null,
|
|
148
148
|
hasSessionEnd,
|
|
149
|
-
lastEventTime: lastTimestamp
|
|
149
|
+
lastEventTime: lastTimestamp,
|
|
150
|
+
firstEventTime: firstTimestamp
|
|
150
151
|
};
|
|
151
152
|
} catch (err) {
|
|
152
153
|
console.error(`Error reading session metadata from ${filePath}:`, err.message);
|
package/views/index.ejs
CHANGED
|
@@ -485,6 +485,8 @@
|
|
|
485
485
|
to { transform: rotate(360deg); }
|
|
486
486
|
}
|
|
487
487
|
</style>
|
|
488
|
+
|
|
489
|
+
<%- include('telemetry-snippet') %>
|
|
488
490
|
</head>
|
|
489
491
|
<body>
|
|
490
492
|
<div class="container">
|
|
@@ -537,503 +539,16 @@
|
|
|
537
539
|
</div>
|
|
538
540
|
<% } %>
|
|
539
541
|
</div>
|
|
540
|
-
|
|
541
|
-
<!-- JSON data embedded safely -->
|
|
542
|
-
<script type="application/json" id="sessions-data"><%- JSON.stringify(sessions || []) %></script>
|
|
543
|
-
<script type="application/json" id="meta-data"><%- JSON.stringify({ totalSessions: totalSessions || 0, hasMore: hasMore || false }) %></script>
|
|
544
542
|
|
|
545
543
|
<script>
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
// Infinite scroll state — per-source
|
|
553
|
-
let allSessions = [...initialSessions];
|
|
554
|
-
// Per-source pagination state
|
|
555
|
-
const sourceState = {};
|
|
556
|
-
// Initialize from initial server load (copilot is default active pill)
|
|
557
|
-
sourceState['copilot'] = { offset: initialSessions.length, hasMore: hasMoreFromServer };
|
|
558
|
-
|
|
559
|
-
let isLoading = false;
|
|
560
|
-
|
|
561
|
-
// Filter state
|
|
562
|
-
let currentSourceFilter = 'copilot';
|
|
563
|
-
|
|
564
|
-
function currentState() {
|
|
565
|
-
if (!sourceState[currentSourceFilter]) {
|
|
566
|
-
sourceState[currentSourceFilter] = { offset: 0, hasMore: true };
|
|
567
|
-
}
|
|
568
|
-
return sourceState[currentSourceFilter];
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
// Load more sessions for current source
|
|
572
|
-
async function loadMoreSessions() {
|
|
573
|
-
const state = currentState();
|
|
574
|
-
if (isLoading || !state.hasMore) return;
|
|
575
|
-
|
|
576
|
-
isLoading = true;
|
|
577
|
-
const loadingIndicator = document.getElementById('loading-indicator');
|
|
578
|
-
loadingIndicator.style.display = 'block';
|
|
579
|
-
|
|
580
|
-
try {
|
|
581
|
-
const response = await fetch(`/api/sessions/load-more?offset=${currentState().offset}&limit=20&source=${encodeURIComponent(currentSourceFilter)}`);
|
|
582
|
-
if (!response.ok) throw new Error('Failed to load more sessions');
|
|
583
|
-
|
|
584
|
-
const data = await response.json();
|
|
585
|
-
const existingIds = new Set(allSessions.map(s => s.id));
|
|
586
|
-
const newSessions = [];
|
|
587
|
-
for (const s of data.sessions) {
|
|
588
|
-
if (!existingIds.has(s.id)) {
|
|
589
|
-
allSessions.push(s);
|
|
590
|
-
newSessions.push(s);
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
|
-
currentState().offset += data.sessions.length;
|
|
594
|
-
currentState().hasMore = data.hasMore;
|
|
595
|
-
|
|
596
|
-
// Load tags for new sessions
|
|
597
|
-
await attachTagsToSessions(newSessions);
|
|
598
|
-
|
|
599
|
-
renderAllSessions();
|
|
600
|
-
} catch (err) {
|
|
601
|
-
console.error('Error loading more sessions:', err);
|
|
602
|
-
} finally {
|
|
603
|
-
isLoading = false;
|
|
604
|
-
loadingIndicator.style.display = 'none';
|
|
605
|
-
}
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
// (load more button removed — pure infinite scroll)
|
|
609
|
-
|
|
610
|
-
// Get filtered sessions based on current filter
|
|
611
|
-
function getFilteredSessions() {
|
|
612
|
-
return allSessions.filter(session => session.source === currentSourceFilter);
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
// Render all sessions (grouped by date)
|
|
616
|
-
function renderAllSessions() {
|
|
617
|
-
const container = document.getElementById('sessions-container');
|
|
618
|
-
container.innerHTML = ''; // Clear existing
|
|
619
|
-
|
|
620
|
-
const filteredSessions = getFilteredSessions();
|
|
621
|
-
|
|
622
|
-
if (filteredSessions.length === 0) {
|
|
623
|
-
container.innerHTML = '<div style="text-align: center; color: #6e7681; padding: 40px; font-size: 14px;">No sessions found for this filter.</div>';
|
|
624
|
-
return;
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
const grouped = groupSessionsByDate(filteredSessions);
|
|
628
|
-
const sortedDates = Object.keys(grouped).sort((a, b) => b.localeCompare(a)); // Descending
|
|
629
|
-
|
|
630
|
-
sortedDates.forEach(dateKey => {
|
|
631
|
-
const dateHeader = document.createElement('div');
|
|
632
|
-
dateHeader.className = 'date-group-header';
|
|
633
|
-
dateHeader.textContent = formatDateHeader(grouped[dateKey][0].createdAt);
|
|
634
|
-
container.appendChild(dateHeader);
|
|
635
|
-
|
|
636
|
-
const grid = document.createElement('div');
|
|
637
|
-
grid.className = 'recent-list';
|
|
638
|
-
grouped[dateKey].forEach(session => {
|
|
639
|
-
grid.innerHTML += renderSessionCard(session);
|
|
640
|
-
});
|
|
641
|
-
container.appendChild(grid);
|
|
642
|
-
});
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
// Check if user has scrolled near bottom
|
|
646
|
-
function checkScrollPosition() {
|
|
647
|
-
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
|
|
648
|
-
const windowHeight = window.innerHeight;
|
|
649
|
-
const docHeight = document.documentElement.scrollHeight;
|
|
650
|
-
|
|
651
|
-
// Load more when user is within 500px of bottom
|
|
652
|
-
if (scrollTop + windowHeight >= docHeight - 500 && currentState().hasMore && !isLoading) {
|
|
653
|
-
loadMoreSessions();
|
|
654
|
-
}
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
// Throttle scroll events for performance
|
|
658
|
-
let scrollTimeout;
|
|
659
|
-
function throttledScroll() {
|
|
660
|
-
if (scrollTimeout) return;
|
|
661
|
-
scrollTimeout = setTimeout(() => {
|
|
662
|
-
checkScrollPosition();
|
|
663
|
-
scrollTimeout = null;
|
|
664
|
-
}, 100);
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
function viewSession(e) {
|
|
668
|
-
e.preventDefault();
|
|
669
|
-
const sessionId = document.getElementById('sessionInput').value.trim();
|
|
670
|
-
if (sessionId) {
|
|
671
|
-
window.location.href = `/session/${sessionId}`;
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
// Bind form submit event
|
|
676
|
-
document.getElementById('sessionForm').addEventListener('submit', viewSession);
|
|
677
|
-
|
|
678
|
-
// File import handling
|
|
679
|
-
const fileInput = document.getElementById('fileInput');
|
|
680
|
-
const importLink = document.getElementById('importLink');
|
|
681
|
-
const importStatus = document.getElementById('importStatus');
|
|
682
|
-
|
|
683
|
-
// Click import link to select file
|
|
684
|
-
importLink.addEventListener('click', (e) => {
|
|
685
|
-
e.preventDefault();
|
|
686
|
-
fileInput.click();
|
|
687
|
-
});
|
|
688
|
-
|
|
689
|
-
// Auto-upload when file is selected
|
|
690
|
-
fileInput.addEventListener('change', async (e) => {
|
|
691
|
-
const file = e.target.files[0];
|
|
692
|
-
if (!file) return;
|
|
693
|
-
|
|
694
|
-
if (!file.name.endsWith('.zip')) {
|
|
695
|
-
showStatus('error', '❌ Please select a .zip file');
|
|
696
|
-
return;
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
importLink.style.pointerEvents = 'none';
|
|
700
|
-
importLink.style.opacity = '0.5';
|
|
701
|
-
importLink.textContent = 'Importing...';
|
|
702
|
-
showStatus('loading', 'Uploading and extracting session...');
|
|
703
|
-
|
|
704
|
-
try {
|
|
705
|
-
const formData = new FormData();
|
|
706
|
-
formData.append('sessionZip', file);
|
|
707
|
-
|
|
708
|
-
const response = await fetch('/session/import', {
|
|
709
|
-
method: 'POST',
|
|
710
|
-
body: formData
|
|
711
|
-
});
|
|
712
|
-
|
|
713
|
-
const result = await response.json();
|
|
714
|
-
|
|
715
|
-
if (response.ok) {
|
|
716
|
-
showStatus('success', `✅ Session ${result.sessionId} imported successfully!`);
|
|
717
|
-
|
|
718
|
-
// Reload page after 1.5 seconds to show new session
|
|
719
|
-
setTimeout(() => {
|
|
720
|
-
window.location.reload();
|
|
721
|
-
}, 1500);
|
|
722
|
-
} else {
|
|
723
|
-
showStatus('error', `❌ Import failed: ${result.error}`);
|
|
724
|
-
importLink.style.pointerEvents = 'auto';
|
|
725
|
-
importLink.style.opacity = '1';
|
|
726
|
-
importLink.textContent = 'Import session from zip';
|
|
727
|
-
}
|
|
728
|
-
} catch (err) {
|
|
729
|
-
showStatus('error', `❌ Import failed: ${err.message}`);
|
|
730
|
-
importLink.style.pointerEvents = 'auto';
|
|
731
|
-
importLink.style.opacity = '1';
|
|
732
|
-
importLink.textContent = 'Import session from zip';
|
|
733
|
-
} finally {
|
|
734
|
-
// Reset file input
|
|
735
|
-
fileInput.value = '';
|
|
736
|
-
}
|
|
737
|
-
});
|
|
738
|
-
|
|
739
|
-
function showStatus(type, message) {
|
|
740
|
-
importStatus.className = `import-status ${type}`;
|
|
741
|
-
importStatus.textContent = message;
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
// Format duration from milliseconds to human-readable format
|
|
745
|
-
function formatDuration(ms) {
|
|
746
|
-
if (!ms || ms < 0) return '—';
|
|
747
|
-
|
|
748
|
-
const seconds = Math.floor(ms / 1000);
|
|
749
|
-
const minutes = Math.floor(seconds / 60);
|
|
750
|
-
const hours = Math.floor(minutes / 60);
|
|
751
|
-
|
|
752
|
-
if (hours > 0) {
|
|
753
|
-
const remainingMinutes = minutes % 60;
|
|
754
|
-
return `${hours}h ${remainingMinutes}m`;
|
|
755
|
-
} else if (minutes > 0) {
|
|
756
|
-
const remainingSeconds = seconds % 60;
|
|
757
|
-
return `${minutes}m ${remainingSeconds}s`;
|
|
758
|
-
} else {
|
|
759
|
-
return `${seconds}s`;
|
|
760
|
-
}
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
// Format date to YYYY/MM/DD
|
|
764
|
-
function formatDateHeader(dateStr) {
|
|
765
|
-
const date = new Date(dateStr);
|
|
766
|
-
const year = date.getFullYear();
|
|
767
|
-
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
768
|
-
const day = String(date.getDate()).padStart(2, '0');
|
|
769
|
-
return `${year}/${month}/${day}`;
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
// Get date key from timestamp (YYYY-MM-DD for grouping)
|
|
773
|
-
function getDateKey(timestamp) {
|
|
774
|
-
if (!timestamp) return 'Unknown';
|
|
775
|
-
const date = new Date(timestamp);
|
|
776
|
-
const year = date.getFullYear();
|
|
777
|
-
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
778
|
-
const day = String(date.getDate()).padStart(2, '0');
|
|
779
|
-
return `${year}-${month}-${day}`;
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
// Group sessions by date
|
|
783
|
-
function groupSessionsByDate(sessions) {
|
|
784
|
-
const groups = {};
|
|
785
|
-
sessions.forEach(session => {
|
|
786
|
-
const dateKey = getDateKey(session.createdAt);
|
|
787
|
-
if (!groups[dateKey]) {
|
|
788
|
-
groups[dateKey] = [];
|
|
789
|
-
}
|
|
790
|
-
groups[dateKey].push(session);
|
|
791
|
-
});
|
|
792
|
-
return groups;
|
|
793
|
-
}
|
|
794
|
-
|
|
795
|
-
// Render session card HTML
|
|
796
|
-
function renderSessionCard(session) {
|
|
797
|
-
// Add status badges
|
|
798
|
-
let badges = '';
|
|
799
|
-
|
|
800
|
-
// Add source badge (use backend-provided metadata - Violation #3 & #5 fix)
|
|
801
|
-
const sourceClass = session.sourceBadgeClass || 'source-copilot';
|
|
802
|
-
const sourceLabel = session.sourceName || 'Copilot';
|
|
803
|
-
badges += `<span class="status-badge ${sourceClass}" title="${sourceLabel}">${sourceLabel}</span>`;
|
|
804
|
-
|
|
805
|
-
if (session.sessionStatus === 'wip') {
|
|
806
|
-
badges += '<span class="status-badge wip" title="Session in progress">🔄 WIP</span>';
|
|
807
|
-
}
|
|
808
|
-
if (session.isImported) {
|
|
809
|
-
badges += '<span class="status-badge imported" title="Imported session">📥</span>';
|
|
810
|
-
}
|
|
811
|
-
if (session.hasInsight) {
|
|
812
|
-
badges += '<span class="status-badge insight" title="Has Agent Review">💡</span>';
|
|
813
|
-
}
|
|
814
|
-
// Add model and version badges
|
|
815
|
-
if (session.selectedModel) {
|
|
816
|
-
const modelShort = session.selectedModel.replace('claude-', '').replace('gpt-', '').replace('gemini-', '');
|
|
817
|
-
let modelClass = 'model-other';
|
|
818
|
-
if (session.selectedModel.includes('claude')) {
|
|
819
|
-
modelClass = 'model-claude';
|
|
820
|
-
} else if (session.selectedModel.includes('gpt')) {
|
|
821
|
-
modelClass = 'model-gpt';
|
|
822
|
-
} else if (session.selectedModel.includes('gemini')) {
|
|
823
|
-
modelClass = 'model-gemini';
|
|
824
|
-
}
|
|
825
|
-
badges += `<span class="status-badge model ${modelClass}" title="Model: ${escapeHtml(session.selectedModel)}">${escapeHtml(modelShort)}</span>`;
|
|
826
|
-
}
|
|
827
|
-
if (session.copilotVersion) {
|
|
828
|
-
badges += `<span class="status-badge version" title="CLI version">${escapeHtml(session.copilotVersion)}</span>`;
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
let summaryHtml = '';
|
|
832
|
-
if (session.summary && session.summary !== 'No summary' && session.summary !== 'Legacy session') {
|
|
833
|
-
summaryHtml = `<div class="session-summary">${escapeHtml(session.summary)}</div>`;
|
|
834
|
-
} else {
|
|
835
|
-
summaryHtml = '<div class="session-summary" style="color: #6e7681; font-style: italic;">No summary available</div>';
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
let workspaceHtml = '';
|
|
839
|
-
if (session.workspace && session.workspace.cwd) {
|
|
840
|
-
workspaceHtml = `
|
|
841
|
-
<div class="session-info-item workspace" title="${escapeHtml(session.workspace.cwd)}">
|
|
842
|
-
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M1.75 1A1.75 1.75 0 000 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0016 13.25v-8.5A1.75 1.75 0 0014.25 3H7.5a.25.25 0 01-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75z"></path></svg>
|
|
843
|
-
<span class="session-info-value">${escapeHtml(session.workspace.cwd.replace(/^\/Users\/[^/]+/, '~'))}</span>
|
|
844
|
-
</div>
|
|
845
|
-
`;
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
const createdAtStr = session.createdAt
|
|
849
|
-
? new Date(session.createdAt).toLocaleString('en-US', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false })
|
|
850
|
-
: 'unknown';
|
|
851
|
-
|
|
852
|
-
let durationHtml = '';
|
|
853
|
-
if (session.duration) {
|
|
854
|
-
durationHtml = `
|
|
855
|
-
<div class="session-info-item">
|
|
856
|
-
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM7.25 12.5v-5A.75.75 0 0 1 8 6.75h2.5a.75.75 0 0 1 0 1.5H8.75v4.25a.75.75 0 0 1-1.5 0Z"></path></svg>
|
|
857
|
-
<span class="session-info-value">${formatDuration(session.duration)}</span>
|
|
858
|
-
</div>
|
|
859
|
-
`;
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
const wipClass = session.sessionStatus === 'wip' ? ' recent-item-wip' : '';
|
|
863
|
-
|
|
864
|
-
// Render tags
|
|
865
|
-
let tagsHtml = '';
|
|
866
|
-
if (session.tags && session.tags.length > 0) {
|
|
867
|
-
const tagsItems = session.tags.map(tag => {
|
|
868
|
-
const color = getTagColor(tag);
|
|
869
|
-
return `<span class="session-tag" style="background-color: ${color}" title="${escapeHtml(tag)}">${escapeHtml(tag)}</span>`;
|
|
870
|
-
}).join('');
|
|
871
|
-
tagsHtml = `<div class="session-tags">${tagsItems}</div>`;
|
|
872
|
-
}
|
|
873
|
-
|
|
874
|
-
return `
|
|
875
|
-
<a href="/session/${session.id}" class="recent-item${wipClass}">
|
|
876
|
-
<div class="session-id">
|
|
877
|
-
<span class="session-id-text" title="${escapeHtml(session.id)}">${escapeHtml(session.id)}</span>
|
|
878
|
-
</div>
|
|
879
|
-
<div class="session-badges-tags">
|
|
880
|
-
<div class="session-badges">${badges}</div>
|
|
881
|
-
${tagsHtml}
|
|
882
|
-
</div>
|
|
883
|
-
${summaryHtml}
|
|
884
|
-
<div class="session-divider"></div>
|
|
885
|
-
<div class="session-info">
|
|
886
|
-
${workspaceHtml}
|
|
887
|
-
<div class="session-info-item">
|
|
888
|
-
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm7-3.25v2.992l2.028.812a.75.75 0 0 1-.557 1.392l-2.5-1A.751.751 0 0 1 7 8.25v-3.5a.75.75 0 0 1 1.5 0Z"></path></svg>
|
|
889
|
-
<span class="session-info-value">${createdAtStr}</span>
|
|
890
|
-
</div>
|
|
891
|
-
${durationHtml}
|
|
892
|
-
<div class="session-info-item">
|
|
893
|
-
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M7.72.72a.75.75 0 0 1 1.06 0l1.5 1.5a.75.75 0 0 1-1.06 1.06l-.22-.22v1.69a.75.75 0 0 1-1.5 0V3.06l-.22.22a.75.75 0 0 1-1.06-1.06ZM2 7a.75.75 0 0 0 0 1.5h3.69l-.22.22a.75.75 0 1 0 1.06 1.06l1.5-1.5a.75.75 0 0 0 0-1.06l-1.5-1.5a.75.75 0 0 0-1.06 1.06l.22.22Zm8.53-.28a.75.75 0 0 0 0 1.06l1.5 1.5a.75.75 0 1 0 1.06-1.06l-.22-.22H16a.75.75 0 0 0 0-1.5h-3.13l.22-.22a.75.75 0 0 0-1.06-1.06ZM7.72 12.22a.75.75 0 0 1 1.06 0l1.5 1.5a.75.75 0 1 1-1.06 1.06l-.22-.22v1.69a.75.75 0 0 1-1.5 0v-1.69l-.22.22a.75.75 0 0 1-1.06-1.06Z"></path></svg>
|
|
894
|
-
<span class="session-info-value">${session.eventCount || 0} events</span>
|
|
895
|
-
</div>
|
|
896
|
-
</div>
|
|
897
|
-
</a>
|
|
898
|
-
`;
|
|
899
|
-
}
|
|
900
|
-
|
|
901
|
-
function escapeHtml(text) {
|
|
902
|
-
const div = document.createElement('div');
|
|
903
|
-
div.textContent = text;
|
|
904
|
-
return div.innerHTML;
|
|
905
|
-
}
|
|
906
|
-
|
|
907
|
-
// Tag colors (same as session-vue.ejs)
|
|
908
|
-
const tagColors = [
|
|
909
|
-
'#3b82f6', // blue
|
|
910
|
-
'#10b981', // green
|
|
911
|
-
'#f59e0b', // amber
|
|
912
|
-
'#ef4444', // red
|
|
913
|
-
'#8b5cf6', // purple
|
|
914
|
-
'#ec4899', // pink
|
|
915
|
-
'#06b6d4', // cyan
|
|
916
|
-
'#f97316' // orange
|
|
917
|
-
];
|
|
918
|
-
|
|
919
|
-
function getTagColor(tag) {
|
|
920
|
-
let hash = 0;
|
|
921
|
-
for (let i = 0; i < tag.length; i++) {
|
|
922
|
-
hash = tag.charCodeAt(i) + ((hash << 5) - hash);
|
|
923
|
-
}
|
|
924
|
-
return tagColors[Math.abs(hash) % tagColors.length];
|
|
925
|
-
}
|
|
926
|
-
|
|
927
|
-
// Load tags for sessions
|
|
928
|
-
async function loadSessionTags(sessionIds) {
|
|
929
|
-
try {
|
|
930
|
-
const tagPromises = sessionIds.map(id =>
|
|
931
|
-
fetch(`/api/sessions/${id}/tags`)
|
|
932
|
-
.then(r => r.ok ? r.json() : { tags: [] })
|
|
933
|
-
.then(data => ({ id, tags: data.tags || [] }))
|
|
934
|
-
.catch(() => ({ id, tags: [] }))
|
|
935
|
-
);
|
|
936
|
-
const results = await Promise.all(tagPromises);
|
|
937
|
-
const tagsMap = {};
|
|
938
|
-
results.forEach(({ id, tags }) => {
|
|
939
|
-
tagsMap[id] = tags;
|
|
940
|
-
});
|
|
941
|
-
return tagsMap;
|
|
942
|
-
} catch (err) {
|
|
943
|
-
console.error('Error loading session tags:', err);
|
|
944
|
-
return {};
|
|
945
|
-
}
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
// Attach tags to sessions
|
|
949
|
-
async function attachTagsToSessions(sessions) {
|
|
950
|
-
const sessionIds = sessions.map(s => s.id);
|
|
951
|
-
const tagsMap = await loadSessionTags(sessionIds);
|
|
952
|
-
sessions.forEach(session => {
|
|
953
|
-
session.tags = tagsMap[session.id] || [];
|
|
954
|
-
});
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
// Source directory hints (from server, platform-aware)
|
|
958
|
-
const sourceHints = <%- sourceHints || '{}' %>;
|
|
959
|
-
|
|
960
|
-
function updateSourceHint(source) {
|
|
961
|
-
const hint = document.getElementById('sourceHint');
|
|
962
|
-
if (hint && sourceHints[source]) {
|
|
963
|
-
hint.innerHTML = 'Sessions from <span class="hint-code">' + sourceHints[source] + '</span>';
|
|
964
|
-
} else if (hint) {
|
|
965
|
-
hint.textContent = '';
|
|
966
|
-
}
|
|
967
|
-
}
|
|
968
|
-
|
|
969
|
-
// Filter pill click handler
|
|
970
|
-
function setupFilterPills() {
|
|
971
|
-
const filterPills = document.querySelectorAll('.filter-pill');
|
|
972
|
-
// Show hint for initial active pill
|
|
973
|
-
updateSourceHint(currentSourceFilter);
|
|
974
|
-
filterPills.forEach(pill => {
|
|
975
|
-
pill.addEventListener('click', async () => {
|
|
976
|
-
filterPills.forEach(p => p.classList.remove('active'));
|
|
977
|
-
pill.classList.add('active');
|
|
978
|
-
currentSourceFilter = pill.getAttribute('data-source');
|
|
979
|
-
updateSourceHint(currentSourceFilter);
|
|
980
|
-
|
|
981
|
-
// Init per-source state if first visit
|
|
982
|
-
if (!sourceState[currentSourceFilter]) {
|
|
983
|
-
sourceState[currentSourceFilter] = { offset: 0, hasMore: true };
|
|
984
|
-
}
|
|
985
|
-
|
|
986
|
-
// Always fetch first batch when switching to a new source (backend-filtered)
|
|
987
|
-
if (sourceState[currentSourceFilter].offset === 0 && !isLoading) {
|
|
988
|
-
isLoading = true;
|
|
989
|
-
// Show loading state immediately (clear old results)
|
|
990
|
-
const container = document.getElementById('sessions-container');
|
|
991
|
-
container.innerHTML = '<div style="text-align: center; color: #6e7681; padding: 40px; font-size: 14px;">⏳ Loading...</div>';
|
|
992
|
-
document.getElementById('loading-indicator').style.display = 'none';
|
|
993
|
-
try {
|
|
994
|
-
const resp = await fetch(`/api/sessions/load-more?offset=0&limit=20&source=${encodeURIComponent(currentSourceFilter)}`);
|
|
995
|
-
if (resp.ok) {
|
|
996
|
-
const data = await resp.json();
|
|
997
|
-
const existingIds = new Set(allSessions.map(s => s.id));
|
|
998
|
-
const newSessions = [];
|
|
999
|
-
for (const s of (data.sessions || [])) {
|
|
1000
|
-
if (!existingIds.has(s.id)) {
|
|
1001
|
-
allSessions.push(s);
|
|
1002
|
-
newSessions.push(s);
|
|
1003
|
-
}
|
|
1004
|
-
}
|
|
1005
|
-
sourceState[currentSourceFilter].offset = (data.sessions || []).length;
|
|
1006
|
-
sourceState[currentSourceFilter].hasMore = data.hasMore;
|
|
1007
|
-
|
|
1008
|
-
// Load tags for new sessions
|
|
1009
|
-
await attachTagsToSessions(newSessions);
|
|
1010
|
-
}
|
|
1011
|
-
} catch (e) {
|
|
1012
|
-
console.error('Failed to load sessions for source:', currentSourceFilter, e);
|
|
1013
|
-
} finally {
|
|
1014
|
-
isLoading = false;
|
|
1015
|
-
}
|
|
1016
|
-
}
|
|
1017
|
-
|
|
1018
|
-
renderAllSessions();
|
|
1019
|
-
});
|
|
1020
|
-
});
|
|
1021
|
-
}
|
|
1022
|
-
|
|
1023
|
-
// Render grouped sessions
|
|
1024
|
-
document.addEventListener('DOMContentLoaded', async function() {
|
|
1025
|
-
// Load tags for initial sessions
|
|
1026
|
-
await attachTagsToSessions(allSessions);
|
|
1027
|
-
|
|
1028
|
-
renderAllSessions();
|
|
1029
|
-
|
|
1030
|
-
// Infinite scroll
|
|
1031
|
-
window.addEventListener('scroll', throttledScroll);
|
|
1032
|
-
|
|
1033
|
-
// Setup filter pills
|
|
1034
|
-
setupFilterPills();
|
|
1035
|
-
});
|
|
544
|
+
window.__PAGE_DATA = {
|
|
545
|
+
sessions: <%- JSON.stringify(sessions || []) %>,
|
|
546
|
+
totalSessions: <%= totalSessions || 0 %>,
|
|
547
|
+
hasMore: <%= hasMore || false %>,
|
|
548
|
+
sourceHints: <%- sourceHints || '{}' %>
|
|
549
|
+
};
|
|
1036
550
|
</script>
|
|
551
|
+
<script src="/public/js/homepage.min.js"></script>
|
|
1037
552
|
|
|
1038
553
|
<style>
|
|
1039
554
|
.date-group-header {
|