@lim324/my-claude-code-viewer 0.0.1
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/README.md +80 -0
- package/bin/my-claude-code-viewer +3 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +47 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/cost-calculator.d.ts +25 -0
- package/dist/lib/cost-calculator.d.ts.map +1 -0
- package/dist/lib/cost-calculator.js +75 -0
- package/dist/lib/cost-calculator.js.map +1 -0
- package/dist/lib/jsonl-parser.d.ts +47 -0
- package/dist/lib/jsonl-parser.d.ts.map +1 -0
- package/dist/lib/jsonl-parser.js +68 -0
- package/dist/lib/jsonl-parser.js.map +1 -0
- package/dist/lib/pricing.d.ts +47 -0
- package/dist/lib/pricing.d.ts.map +1 -0
- package/dist/lib/pricing.js +119 -0
- package/dist/lib/pricing.js.map +1 -0
- package/dist/routes/api.d.ts +4 -0
- package/dist/routes/api.d.ts.map +1 -0
- package/dist/routes/api.js +103 -0
- package/dist/routes/api.js.map +1 -0
- package/dist/routes/views.d.ts +4 -0
- package/dist/routes/views.d.ts.map +1 -0
- package/dist/routes/views.js +737 -0
- package/dist/routes/views.js.map +1 -0
- package/dist/server/project-scanner.d.ts +63 -0
- package/dist/server/project-scanner.d.ts.map +1 -0
- package/dist/server/project-scanner.js +220 -0
- package/dist/server/project-scanner.js.map +1 -0
- package/dist/server/session-analyzer.d.ts +42 -0
- package/dist/server/session-analyzer.d.ts.map +1 -0
- package/dist/server/session-analyzer.js +185 -0
- package/dist/server/session-analyzer.js.map +1 -0
- package/package.json +47 -0
|
@@ -0,0 +1,737 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const express_1 = require("express");
|
|
7
|
+
const fuse_js_1 = __importDefault(require("fuse.js"));
|
|
8
|
+
const escape_html_1 = __importDefault(require("escape-html"));
|
|
9
|
+
const project_scanner_1 = require("../server/project-scanner");
|
|
10
|
+
const session_analyzer_1 = require("../server/session-analyzer");
|
|
11
|
+
const router = (0, express_1.Router)();
|
|
12
|
+
// Helper function to format currency
|
|
13
|
+
function formatCurrency(amount) {
|
|
14
|
+
return `$${amount.toFixed(4)}`;
|
|
15
|
+
}
|
|
16
|
+
// Helper function to format number
|
|
17
|
+
function formatNumber(num) {
|
|
18
|
+
return num.toLocaleString();
|
|
19
|
+
}
|
|
20
|
+
// Helper function to format date
|
|
21
|
+
function formatDate(date) {
|
|
22
|
+
return new Date(date).toLocaleString();
|
|
23
|
+
}
|
|
24
|
+
// Helper function to escape HTML to prevent XSS
|
|
25
|
+
function e(text) {
|
|
26
|
+
if (text === null || text === undefined)
|
|
27
|
+
return "";
|
|
28
|
+
return (0, escape_html_1.default)(String(text));
|
|
29
|
+
}
|
|
30
|
+
// Helper function to highlight search matches (with XSS protection)
|
|
31
|
+
function highlightMatch(text, query) {
|
|
32
|
+
if (!query)
|
|
33
|
+
return e(text);
|
|
34
|
+
const safeText = e(text);
|
|
35
|
+
const safeQuery = e(query);
|
|
36
|
+
// Use a simple case-insensitive replace for highlighting
|
|
37
|
+
const regex = new RegExp(`(${escapeRegExp(safeQuery)})`, 'gi');
|
|
38
|
+
return safeText.replace(regex, '<span class="highlight">$1</span>');
|
|
39
|
+
}
|
|
40
|
+
// Helper function to escape special regex characters
|
|
41
|
+
function escapeRegExp(string) {
|
|
42
|
+
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
43
|
+
}
|
|
44
|
+
// HTML layout template
|
|
45
|
+
function renderLayout(title, content) {
|
|
46
|
+
return `
|
|
47
|
+
<!DOCTYPE html>
|
|
48
|
+
<html lang="en">
|
|
49
|
+
<head>
|
|
50
|
+
<meta charset="UTF-8">
|
|
51
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
52
|
+
<title>${e(title)} | Claude Code Viewer</title>
|
|
53
|
+
<style>
|
|
54
|
+
* {
|
|
55
|
+
box-sizing: border-box;
|
|
56
|
+
margin: 0;
|
|
57
|
+
padding: 0;
|
|
58
|
+
}
|
|
59
|
+
body {
|
|
60
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
|
61
|
+
background: #0d1117;
|
|
62
|
+
color: #c9d1d9;
|
|
63
|
+
line-height: 1.6;
|
|
64
|
+
}
|
|
65
|
+
.container {
|
|
66
|
+
max-width: 1200px;
|
|
67
|
+
margin: 0 auto;
|
|
68
|
+
padding: 20px;
|
|
69
|
+
}
|
|
70
|
+
header {
|
|
71
|
+
background: #161b22;
|
|
72
|
+
border-bottom: 1px solid #30363d;
|
|
73
|
+
padding: 20px 0;
|
|
74
|
+
margin-bottom: 30px;
|
|
75
|
+
}
|
|
76
|
+
header h1 {
|
|
77
|
+
color: #58a6ff;
|
|
78
|
+
font-size: 1.8rem;
|
|
79
|
+
}
|
|
80
|
+
header h1 a {
|
|
81
|
+
color: inherit;
|
|
82
|
+
text-decoration: none;
|
|
83
|
+
}
|
|
84
|
+
.stats-grid {
|
|
85
|
+
display: grid;
|
|
86
|
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
87
|
+
gap: 20px;
|
|
88
|
+
margin-bottom: 30px;
|
|
89
|
+
}
|
|
90
|
+
.stat-card {
|
|
91
|
+
background: #161b22;
|
|
92
|
+
border: 1px solid #30363d;
|
|
93
|
+
border-radius: 8px;
|
|
94
|
+
padding: 20px;
|
|
95
|
+
text-align: center;
|
|
96
|
+
}
|
|
97
|
+
.stat-card h3 {
|
|
98
|
+
color: #8b949e;
|
|
99
|
+
font-size: 0.9rem;
|
|
100
|
+
font-weight: normal;
|
|
101
|
+
margin-bottom: 8px;
|
|
102
|
+
text-transform: uppercase;
|
|
103
|
+
}
|
|
104
|
+
.stat-card .value {
|
|
105
|
+
color: #58a6ff;
|
|
106
|
+
font-size: 2rem;
|
|
107
|
+
font-weight: bold;
|
|
108
|
+
}
|
|
109
|
+
.section {
|
|
110
|
+
background: #161b22;
|
|
111
|
+
border: 1px solid #30363d;
|
|
112
|
+
border-radius: 8px;
|
|
113
|
+
padding: 20px;
|
|
114
|
+
margin-bottom: 20px;
|
|
115
|
+
}
|
|
116
|
+
.section h2 {
|
|
117
|
+
color: #e6edf3;
|
|
118
|
+
font-size: 1.3rem;
|
|
119
|
+
margin-bottom: 15px;
|
|
120
|
+
padding-bottom: 10px;
|
|
121
|
+
border-bottom: 1px solid #30363d;
|
|
122
|
+
}
|
|
123
|
+
table {
|
|
124
|
+
width: 100%;
|
|
125
|
+
border-collapse: collapse;
|
|
126
|
+
}
|
|
127
|
+
th, td {
|
|
128
|
+
padding: 12px;
|
|
129
|
+
text-align: left;
|
|
130
|
+
border-bottom: 1px solid #30363d;
|
|
131
|
+
}
|
|
132
|
+
th {
|
|
133
|
+
color: #8b949e;
|
|
134
|
+
font-weight: 600;
|
|
135
|
+
font-size: 0.85rem;
|
|
136
|
+
text-transform: uppercase;
|
|
137
|
+
}
|
|
138
|
+
tr:hover {
|
|
139
|
+
background: #1c2128;
|
|
140
|
+
}
|
|
141
|
+
a {
|
|
142
|
+
color: #58a6ff;
|
|
143
|
+
text-decoration: none;
|
|
144
|
+
}
|
|
145
|
+
a:hover {
|
|
146
|
+
text-decoration: underline;
|
|
147
|
+
}
|
|
148
|
+
.badge {
|
|
149
|
+
display: inline-block;
|
|
150
|
+
padding: 2px 8px;
|
|
151
|
+
border-radius: 12px;
|
|
152
|
+
font-size: 0.8rem;
|
|
153
|
+
font-weight: 500;
|
|
154
|
+
}
|
|
155
|
+
.badge-blue {
|
|
156
|
+
background: rgba(56, 139, 253, 0.15);
|
|
157
|
+
color: #58a6ff;
|
|
158
|
+
}
|
|
159
|
+
.badge-green {
|
|
160
|
+
background: rgba(35, 197, 94, 0.15);
|
|
161
|
+
color: #3fb950;
|
|
162
|
+
}
|
|
163
|
+
.cost {
|
|
164
|
+
color: #f0883e;
|
|
165
|
+
font-weight: 600;
|
|
166
|
+
}
|
|
167
|
+
.empty-state {
|
|
168
|
+
text-align: center;
|
|
169
|
+
padding: 40px;
|
|
170
|
+
color: #8b949e;
|
|
171
|
+
}
|
|
172
|
+
.breadcrumb {
|
|
173
|
+
margin-bottom: 20px;
|
|
174
|
+
color: #8b949e;
|
|
175
|
+
}
|
|
176
|
+
.breadcrumb a {
|
|
177
|
+
color: #58a6ff;
|
|
178
|
+
}
|
|
179
|
+
.message-preview {
|
|
180
|
+
max-width: 300px;
|
|
181
|
+
overflow: hidden;
|
|
182
|
+
text-overflow: ellipsis;
|
|
183
|
+
white-space: nowrap;
|
|
184
|
+
color: #8b949e;
|
|
185
|
+
font-size: 0.9rem;
|
|
186
|
+
}
|
|
187
|
+
.token-bar {
|
|
188
|
+
display: flex;
|
|
189
|
+
height: 20px;
|
|
190
|
+
border-radius: 4px;
|
|
191
|
+
overflow: hidden;
|
|
192
|
+
margin-top: 8px;
|
|
193
|
+
}
|
|
194
|
+
.token-bar-input {
|
|
195
|
+
background: #58a6ff;
|
|
196
|
+
}
|
|
197
|
+
.token-bar-output {
|
|
198
|
+
background: #3fb950;
|
|
199
|
+
}
|
|
200
|
+
.token-bar-cache {
|
|
201
|
+
background: #a371f7;
|
|
202
|
+
}
|
|
203
|
+
.conversation-list {
|
|
204
|
+
max-height: 500px;
|
|
205
|
+
overflow-y: auto;
|
|
206
|
+
}
|
|
207
|
+
.conversation-item {
|
|
208
|
+
padding: 15px;
|
|
209
|
+
border-bottom: 1px solid #30363d;
|
|
210
|
+
}
|
|
211
|
+
.conversation-item:last-child {
|
|
212
|
+
border-bottom: none;
|
|
213
|
+
}
|
|
214
|
+
.conversation-type {
|
|
215
|
+
display: inline-block;
|
|
216
|
+
padding: 2px 8px;
|
|
217
|
+
border-radius: 4px;
|
|
218
|
+
font-size: 0.75rem;
|
|
219
|
+
font-weight: 600;
|
|
220
|
+
text-transform: uppercase;
|
|
221
|
+
margin-bottom: 8px;
|
|
222
|
+
}
|
|
223
|
+
.type-user {
|
|
224
|
+
background: rgba(35, 197, 94, 0.15);
|
|
225
|
+
color: #3fb950;
|
|
226
|
+
}
|
|
227
|
+
.type-assistant {
|
|
228
|
+
background: rgba(56, 139, 253, 0.15);
|
|
229
|
+
color: #58a6ff;
|
|
230
|
+
}
|
|
231
|
+
.type-system {
|
|
232
|
+
background: rgba(139, 148, 158, 0.15);
|
|
233
|
+
color: #8b949e;
|
|
234
|
+
}
|
|
235
|
+
.timestamp {
|
|
236
|
+
color: #6e7681;
|
|
237
|
+
font-size: 0.8rem;
|
|
238
|
+
margin-left: 10px;
|
|
239
|
+
}
|
|
240
|
+
.cost-breakdown {
|
|
241
|
+
display: grid;
|
|
242
|
+
grid-template-columns: repeat(2, 1fr);
|
|
243
|
+
gap: 10px;
|
|
244
|
+
margin-top: 10px;
|
|
245
|
+
}
|
|
246
|
+
.cost-item {
|
|
247
|
+
background: #0d1117;
|
|
248
|
+
padding: 10px;
|
|
249
|
+
border-radius: 4px;
|
|
250
|
+
font-size: 0.9rem;
|
|
251
|
+
}
|
|
252
|
+
.cost-item-label {
|
|
253
|
+
color: #8b949e;
|
|
254
|
+
font-size: 0.8rem;
|
|
255
|
+
}
|
|
256
|
+
.search-container {
|
|
257
|
+
margin-bottom: 20px;
|
|
258
|
+
}
|
|
259
|
+
.search-input {
|
|
260
|
+
width: 100%;
|
|
261
|
+
padding: 12px 16px;
|
|
262
|
+
font-size: 1rem;
|
|
263
|
+
background: #0d1117;
|
|
264
|
+
border: 1px solid #30363d;
|
|
265
|
+
border-radius: 8px;
|
|
266
|
+
color: #c9d1d9;
|
|
267
|
+
outline: none;
|
|
268
|
+
transition: border-color 0.2s;
|
|
269
|
+
}
|
|
270
|
+
.search-input:focus {
|
|
271
|
+
border-color: #58a6ff;
|
|
272
|
+
}
|
|
273
|
+
.search-input::placeholder {
|
|
274
|
+
color: #6e7681;
|
|
275
|
+
}
|
|
276
|
+
.search-results-info {
|
|
277
|
+
color: #8b949e;
|
|
278
|
+
font-size: 0.9rem;
|
|
279
|
+
margin-top: 10px;
|
|
280
|
+
}
|
|
281
|
+
.highlight {
|
|
282
|
+
background: rgba(56, 139, 253, 0.3);
|
|
283
|
+
padding: 0 2px;
|
|
284
|
+
border-radius: 2px;
|
|
285
|
+
}
|
|
286
|
+
.no-results {
|
|
287
|
+
text-align: center;
|
|
288
|
+
padding: 40px;
|
|
289
|
+
color: #8b949e;
|
|
290
|
+
}
|
|
291
|
+
</style>
|
|
292
|
+
</head>
|
|
293
|
+
<body>
|
|
294
|
+
<header>
|
|
295
|
+
<div class="container">
|
|
296
|
+
<h1><a href="/">🔍 Claude Code Viewer</a></h1>
|
|
297
|
+
</div>
|
|
298
|
+
</header>
|
|
299
|
+
<div class="container">
|
|
300
|
+
${content}
|
|
301
|
+
</div>
|
|
302
|
+
</body>
|
|
303
|
+
</html>
|
|
304
|
+
`;
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* GET /
|
|
308
|
+
* Dashboard with all projects
|
|
309
|
+
*/
|
|
310
|
+
router.get("/", (req, res) => {
|
|
311
|
+
try {
|
|
312
|
+
const stats = (0, project_scanner_1.getGlobalStats)();
|
|
313
|
+
const projects = (0, project_scanner_1.scanProjects)();
|
|
314
|
+
const searchQuery = req.query.search || "";
|
|
315
|
+
// Setup Fuse.js for fuzzy search
|
|
316
|
+
let filteredProjects = projects;
|
|
317
|
+
if (searchQuery) {
|
|
318
|
+
const fuse = new fuse_js_1.default(projects, {
|
|
319
|
+
keys: ["name", "id"],
|
|
320
|
+
threshold: 0.4,
|
|
321
|
+
includeScore: true,
|
|
322
|
+
});
|
|
323
|
+
const results = fuse.search(searchQuery);
|
|
324
|
+
filteredProjects = results.map((r) => r.item);
|
|
325
|
+
}
|
|
326
|
+
const content = `
|
|
327
|
+
<div class="stats-grid">
|
|
328
|
+
<div class="stat-card">
|
|
329
|
+
<h3>Total Projects</h3>
|
|
330
|
+
<div class="value">${formatNumber(stats.totalProjects)}</div>
|
|
331
|
+
</div>
|
|
332
|
+
<div class="stat-card">
|
|
333
|
+
<h3>Total Sessions</h3>
|
|
334
|
+
<div class="value">${formatNumber(stats.totalSessions)}</div>
|
|
335
|
+
</div>
|
|
336
|
+
<div class="stat-card">
|
|
337
|
+
<h3>Total Messages</h3>
|
|
338
|
+
<div class="value">${formatNumber(stats.totalMessages)}</div>
|
|
339
|
+
</div>
|
|
340
|
+
<div class="stat-card">
|
|
341
|
+
<h3>Total Cost</h3>
|
|
342
|
+
<div class="value cost">${formatCurrency(stats.totalCost)}</div>
|
|
343
|
+
</div>
|
|
344
|
+
</div>
|
|
345
|
+
|
|
346
|
+
<div class="section">
|
|
347
|
+
<h2>Projects</h2>
|
|
348
|
+
<div class="search-container">
|
|
349
|
+
<input
|
|
350
|
+
type="text"
|
|
351
|
+
id="project-search"
|
|
352
|
+
class="search-input"
|
|
353
|
+
placeholder="🔍 Search projects by name..."
|
|
354
|
+
value="${e(searchQuery)}"
|
|
355
|
+
autocomplete="off"
|
|
356
|
+
/>
|
|
357
|
+
${searchQuery ? `<div class="search-results-info">Showing ${filteredProjects.length} of ${projects.length} projects</div>` : ""}
|
|
358
|
+
</div>
|
|
359
|
+
${projects.length === 0
|
|
360
|
+
? `<div class="empty-state">
|
|
361
|
+
No projects found in ${e(stats.projectsPath)}
|
|
362
|
+
</div>`
|
|
363
|
+
: filteredProjects.length === 0
|
|
364
|
+
? `<div class="no-results">No projects match "${e(searchQuery)}"</div>`
|
|
365
|
+
: `<table id="projects-table">
|
|
366
|
+
<thead>
|
|
367
|
+
<tr>
|
|
368
|
+
<th>Project Name</th>
|
|
369
|
+
<th>Sessions</th>
|
|
370
|
+
<th>Messages</th>
|
|
371
|
+
<th>Total Cost</th>
|
|
372
|
+
<th>Last Modified</th>
|
|
373
|
+
</tr>
|
|
374
|
+
</thead>
|
|
375
|
+
<tbody>
|
|
376
|
+
${filteredProjects
|
|
377
|
+
.map((p) => `
|
|
378
|
+
<tr data-project-name="${e(p.name.toLowerCase())}" data-project-id="${e(p.id.toLowerCase())}">
|
|
379
|
+
<td><a href="/projects/${e(p.id)}">${highlightMatch(p.name, searchQuery)}</a></td>
|
|
380
|
+
<td><span class="badge badge-blue">${formatNumber(p.sessionCount)}</span></td>
|
|
381
|
+
<td>${formatNumber(p.totalMessageCount)}</td>
|
|
382
|
+
<td class="cost">${formatCurrency(p.totalCost)}</td>
|
|
383
|
+
<td>${formatDate(p.lastModifiedAt)}</td>
|
|
384
|
+
</tr>
|
|
385
|
+
`)
|
|
386
|
+
.join("")}
|
|
387
|
+
</tbody>
|
|
388
|
+
</table>`}
|
|
389
|
+
</div>
|
|
390
|
+
|
|
391
|
+
<script>
|
|
392
|
+
// Client-side search for instant feedback
|
|
393
|
+
const searchInput = document.getElementById('project-search');
|
|
394
|
+
const projectsTable = document.getElementById('projects-table');
|
|
395
|
+
|
|
396
|
+
if (searchInput && projectsTable) {
|
|
397
|
+
const rows = projectsTable.querySelectorAll('tbody tr');
|
|
398
|
+
|
|
399
|
+
searchInput.addEventListener('input', (e) => {
|
|
400
|
+
const query = e.target.value.toLowerCase().trim();
|
|
401
|
+
|
|
402
|
+
rows.forEach(row => {
|
|
403
|
+
const name = row.getAttribute('data-project-name');
|
|
404
|
+
const id = row.getAttribute('data-project-id');
|
|
405
|
+
|
|
406
|
+
if (!query || name.includes(query) || id.includes(query)) {
|
|
407
|
+
row.style.display = '';
|
|
408
|
+
} else {
|
|
409
|
+
row.style.display = 'none';
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
// Update URL without page reload
|
|
414
|
+
const url = new URL(window.location);
|
|
415
|
+
if (query) {
|
|
416
|
+
url.searchParams.set('search', query);
|
|
417
|
+
} else {
|
|
418
|
+
url.searchParams.delete('search');
|
|
419
|
+
}
|
|
420
|
+
window.history.replaceState({}, '', url);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
// Handle Enter key to submit search
|
|
424
|
+
searchInput.addEventListener('keypress', (e) => {
|
|
425
|
+
if (e.key === 'Enter') {
|
|
426
|
+
const url = new URL(window.location);
|
|
427
|
+
const query = e.target.value.trim();
|
|
428
|
+
if (query) {
|
|
429
|
+
url.searchParams.set('search', query);
|
|
430
|
+
} else {
|
|
431
|
+
url.searchParams.delete('search');
|
|
432
|
+
}
|
|
433
|
+
window.location.href = url.toString();
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
</script>
|
|
438
|
+
`;
|
|
439
|
+
res.send(renderLayout("Dashboard", content));
|
|
440
|
+
}
|
|
441
|
+
catch (error) {
|
|
442
|
+
res.status(500).send(`Error: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
/**
|
|
446
|
+
* GET /projects/:id
|
|
447
|
+
* Project detail with sessions
|
|
448
|
+
*/
|
|
449
|
+
router.get("/projects/:id", (req, res) => {
|
|
450
|
+
try {
|
|
451
|
+
const project = (0, project_scanner_1.getProjectDetail)(req.params.id);
|
|
452
|
+
if (!project) {
|
|
453
|
+
return res.status(404).send("Project not found");
|
|
454
|
+
}
|
|
455
|
+
const searchQuery = req.query.search || "";
|
|
456
|
+
// Setup Fuse.js for fuzzy search on sessionId
|
|
457
|
+
let filteredSessions = project.sessions;
|
|
458
|
+
if (searchQuery) {
|
|
459
|
+
const fuse = new fuse_js_1.default(project.sessions, {
|
|
460
|
+
keys: ["id"],
|
|
461
|
+
threshold: 0.4,
|
|
462
|
+
includeScore: true,
|
|
463
|
+
});
|
|
464
|
+
const results = fuse.search(searchQuery);
|
|
465
|
+
filteredSessions = results.map((r) => r.item);
|
|
466
|
+
}
|
|
467
|
+
const content = `
|
|
468
|
+
<div class="breadcrumb">
|
|
469
|
+
<a href="/">← Back to Dashboard</a>
|
|
470
|
+
</div>
|
|
471
|
+
|
|
472
|
+
<div class="stats-grid">
|
|
473
|
+
<div class="stat-card">
|
|
474
|
+
<h3>Project</h3>
|
|
475
|
+
<div class="value">${e(project.name)}</div>
|
|
476
|
+
</div>
|
|
477
|
+
<div class="stat-card">
|
|
478
|
+
<h3>Sessions</h3>
|
|
479
|
+
<div class="value">${formatNumber(project.sessionCount)}</div>
|
|
480
|
+
</div>
|
|
481
|
+
<div class="stat-card">
|
|
482
|
+
<h3>Messages</h3>
|
|
483
|
+
<div class="value">${formatNumber(project.totalMessageCount)}</div>
|
|
484
|
+
</div>
|
|
485
|
+
<div class="stat-card">
|
|
486
|
+
<h3>Total Cost</h3>
|
|
487
|
+
<div class="value cost">${formatCurrency(project.totalCost)}</div>
|
|
488
|
+
</div>
|
|
489
|
+
</div>
|
|
490
|
+
|
|
491
|
+
<div class="section">
|
|
492
|
+
<h2>Sessions</h2>
|
|
493
|
+
<div class="search-container">
|
|
494
|
+
<input
|
|
495
|
+
type="text"
|
|
496
|
+
id="session-search"
|
|
497
|
+
class="search-input"
|
|
498
|
+
placeholder="🔍 Search sessions by ID..."
|
|
499
|
+
value="${e(searchQuery)}"
|
|
500
|
+
autocomplete="off"
|
|
501
|
+
/>
|
|
502
|
+
${searchQuery ? `<div class="search-results-info">Showing ${filteredSessions.length} of ${project.sessions.length} sessions</div>` : ""}
|
|
503
|
+
</div>
|
|
504
|
+
${project.sessions.length === 0
|
|
505
|
+
? `<div class="empty-state">No sessions found</div>`
|
|
506
|
+
: filteredSessions.length === 0
|
|
507
|
+
? `<div class="no-results">No sessions match "${e(searchQuery)}"</div>`
|
|
508
|
+
: `<table id="sessions-table">
|
|
509
|
+
<thead>
|
|
510
|
+
<tr>
|
|
511
|
+
<th>Session ID</th>
|
|
512
|
+
<th>Messages</th>
|
|
513
|
+
<th>Tokens</th>
|
|
514
|
+
<th>Cost</th>
|
|
515
|
+
<th>First Message</th>
|
|
516
|
+
<th>Last Modified</th>
|
|
517
|
+
</tr>
|
|
518
|
+
</thead>
|
|
519
|
+
<tbody>
|
|
520
|
+
${filteredSessions
|
|
521
|
+
.map((s) => `
|
|
522
|
+
<tr data-session-id="${e(s.id.toLowerCase())}">
|
|
523
|
+
<td><a href="/projects/${e(project.id)}/sessions/${e(s.id)}">${highlightMatch(s.id, searchQuery)}</a></td>
|
|
524
|
+
<td><span class="badge badge-blue">${formatNumber(s.meta.messageCount)}</span></td>
|
|
525
|
+
<td>${formatNumber(s.meta.cost.tokenUsage.inputTokens +
|
|
526
|
+
s.meta.cost.tokenUsage.outputTokens +
|
|
527
|
+
s.meta.cost.tokenUsage.cacheCreationTokens +
|
|
528
|
+
s.meta.cost.tokenUsage.cacheReadTokens)}</td>
|
|
529
|
+
<td class="cost">${formatCurrency(s.meta.cost.totalUsd)}</td>
|
|
530
|
+
<td><div class="message-preview">${s.meta.firstUserMessage ? e(s.meta.firstUserMessage.substring(0, 100)) : "N/A"}</div></td>
|
|
531
|
+
<td>${formatDate(s.lastModifiedAt)}</td>
|
|
532
|
+
</tr>
|
|
533
|
+
`)
|
|
534
|
+
.join("")}
|
|
535
|
+
</tbody>
|
|
536
|
+
</table>`}
|
|
537
|
+
</div>
|
|
538
|
+
|
|
539
|
+
<script>
|
|
540
|
+
// Client-side search for instant feedback
|
|
541
|
+
const searchInput = document.getElementById('session-search');
|
|
542
|
+
const sessionsTable = document.getElementById('sessions-table');
|
|
543
|
+
|
|
544
|
+
if (searchInput && sessionsTable) {
|
|
545
|
+
const rows = sessionsTable.querySelectorAll('tbody tr');
|
|
546
|
+
|
|
547
|
+
searchInput.addEventListener('input', (e) => {
|
|
548
|
+
const query = e.target.value.toLowerCase().trim();
|
|
549
|
+
|
|
550
|
+
rows.forEach(row => {
|
|
551
|
+
const sessionId = row.getAttribute('data-session-id');
|
|
552
|
+
|
|
553
|
+
if (!query || sessionId.includes(query)) {
|
|
554
|
+
row.style.display = '';
|
|
555
|
+
} else {
|
|
556
|
+
row.style.display = 'none';
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
// Update URL without page reload
|
|
561
|
+
const url = new URL(window.location);
|
|
562
|
+
if (query) {
|
|
563
|
+
url.searchParams.set('search', query);
|
|
564
|
+
} else {
|
|
565
|
+
url.searchParams.delete('search');
|
|
566
|
+
}
|
|
567
|
+
window.history.replaceState({}, '', url);
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
// Handle Enter key to submit search
|
|
571
|
+
searchInput.addEventListener('keypress', (e) => {
|
|
572
|
+
if (e.key === 'Enter') {
|
|
573
|
+
const url = new URL(window.location);
|
|
574
|
+
const query = e.target.value.trim();
|
|
575
|
+
if (query) {
|
|
576
|
+
url.searchParams.set('search', query);
|
|
577
|
+
} else {
|
|
578
|
+
url.searchParams.delete('search');
|
|
579
|
+
}
|
|
580
|
+
window.location.href = url.toString();
|
|
581
|
+
}
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
</script>
|
|
585
|
+
`;
|
|
586
|
+
res.send(renderLayout(project.name, content));
|
|
587
|
+
}
|
|
588
|
+
catch (error) {
|
|
589
|
+
res.status(500).send(`Error: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
590
|
+
}
|
|
591
|
+
});
|
|
592
|
+
/**
|
|
593
|
+
* GET /projects/:projectId/sessions/:sessionId
|
|
594
|
+
* Session detail view
|
|
595
|
+
*/
|
|
596
|
+
router.get("/projects/:projectId/sessions/:sessionId", (req, res) => {
|
|
597
|
+
try {
|
|
598
|
+
const project = (0, project_scanner_1.getProjectDetail)(req.params.projectId);
|
|
599
|
+
if (!project) {
|
|
600
|
+
return res.status(404).send("Project not found");
|
|
601
|
+
}
|
|
602
|
+
const projectPath = (0, project_scanner_1.getSafeProjectPath)(req.params.projectId);
|
|
603
|
+
if (!projectPath) {
|
|
604
|
+
return res.status(404).send("Invalid project path");
|
|
605
|
+
}
|
|
606
|
+
const session = (0, session_analyzer_1.getSessionDetail)(req.params.projectId, req.params.sessionId, projectPath);
|
|
607
|
+
if (!session) {
|
|
608
|
+
return res.status(404).send("Session not found");
|
|
609
|
+
}
|
|
610
|
+
const totalTokens = session.meta.cost.tokenUsage.inputTokens +
|
|
611
|
+
session.meta.cost.tokenUsage.outputTokens +
|
|
612
|
+
session.meta.cost.tokenUsage.cacheCreationTokens +
|
|
613
|
+
session.meta.cost.tokenUsage.cacheReadTokens;
|
|
614
|
+
const content = `
|
|
615
|
+
<div class="breadcrumb">
|
|
616
|
+
<a href="/">Dashboard</a> /
|
|
617
|
+
<a href="/projects/${e(project.id)}">${e(project.name)}</a> /
|
|
618
|
+
Session
|
|
619
|
+
</div>
|
|
620
|
+
|
|
621
|
+
<div class="stats-grid">
|
|
622
|
+
<div class="stat-card">
|
|
623
|
+
<h3>Session</h3>
|
|
624
|
+
<div class="value" style="font-size: 1rem; word-break: break-all;">${e(session.id)}</div>
|
|
625
|
+
</div>
|
|
626
|
+
<div class="stat-card">
|
|
627
|
+
<h3>Total Messages</h3>
|
|
628
|
+
<div class="value">${formatNumber(session.meta.messageCount)}</div>
|
|
629
|
+
</div>
|
|
630
|
+
<div class="stat-card">
|
|
631
|
+
<h3>Total Tokens</h3>
|
|
632
|
+
<div class="value">${formatNumber(totalTokens)}</div>
|
|
633
|
+
</div>
|
|
634
|
+
<div class="stat-card">
|
|
635
|
+
<h3>Total Cost</h3>
|
|
636
|
+
<div class="value cost">${formatCurrency(session.meta.cost.totalUsd)}</div>
|
|
637
|
+
</div>
|
|
638
|
+
</div>
|
|
639
|
+
|
|
640
|
+
<div class="section">
|
|
641
|
+
<h2>Token Usage Breakdown</h2>
|
|
642
|
+
<div class="cost-breakdown">
|
|
643
|
+
<div class="cost-item">
|
|
644
|
+
<div class="cost-item-label">Input Tokens</div>
|
|
645
|
+
<div>${formatNumber(session.meta.cost.tokenUsage.inputTokens)}</div>
|
|
646
|
+
<div class="cost">${formatCurrency(session.meta.cost.breakdown.inputTokensUsd)}</div>
|
|
647
|
+
</div>
|
|
648
|
+
<div class="cost-item">
|
|
649
|
+
<div class="cost-item-label">Output Tokens</div>
|
|
650
|
+
<div>${formatNumber(session.meta.cost.tokenUsage.outputTokens)}</div>
|
|
651
|
+
<div class="cost">${formatCurrency(session.meta.cost.breakdown.outputTokensUsd)}</div>
|
|
652
|
+
</div>
|
|
653
|
+
<div class="cost-item">
|
|
654
|
+
<div class="cost-item-label">Cache Creation</div>
|
|
655
|
+
<div>${formatNumber(session.meta.cost.tokenUsage.cacheCreationTokens)}</div>
|
|
656
|
+
<div class="cost">${formatCurrency(session.meta.cost.breakdown.cacheCreationUsd)}</div>
|
|
657
|
+
</div>
|
|
658
|
+
<div class="cost-item">
|
|
659
|
+
<div class="cost-item-label">Cache Read</div>
|
|
660
|
+
<div>${formatNumber(session.meta.cost.tokenUsage.cacheReadTokens)}</div>
|
|
661
|
+
<div class="cost">${formatCurrency(session.meta.cost.breakdown.cacheReadUsd)}</div>
|
|
662
|
+
</div>
|
|
663
|
+
</div>
|
|
664
|
+
${totalTokens > 0
|
|
665
|
+
? `<div class="token-bar">
|
|
666
|
+
<div class="token-bar-input" style="width: ${(session.meta.cost.tokenUsage.inputTokens / totalTokens) * 100}%"></div>
|
|
667
|
+
<div class="token-bar-output" style="width: ${(session.meta.cost.tokenUsage.outputTokens / totalTokens) * 100}%"></div>
|
|
668
|
+
<div class="token-bar-cache" style="width: ${((session.meta.cost.tokenUsage.cacheCreationTokens +
|
|
669
|
+
session.meta.cost.tokenUsage.cacheReadTokens) /
|
|
670
|
+
totalTokens) *
|
|
671
|
+
100}%"></div>
|
|
672
|
+
</div>`
|
|
673
|
+
: ""}
|
|
674
|
+
</div>
|
|
675
|
+
|
|
676
|
+
<div class="section">
|
|
677
|
+
<h2>Message Counts</h2>
|
|
678
|
+
<div class="cost-breakdown">
|
|
679
|
+
<div class="cost-item">
|
|
680
|
+
<div class="cost-item-label">User Messages</div>
|
|
681
|
+
<div>${formatNumber(session.userMessageCount)}</div>
|
|
682
|
+
</div>
|
|
683
|
+
<div class="cost-item">
|
|
684
|
+
<div class="cost-item-label">Assistant Messages</div>
|
|
685
|
+
<div>${formatNumber(session.assistantMessageCount)}</div>
|
|
686
|
+
</div>
|
|
687
|
+
<div class="cost-item">
|
|
688
|
+
<div class="cost-item-label">System Messages</div>
|
|
689
|
+
<div>${formatNumber(session.systemMessageCount)}</div>
|
|
690
|
+
</div>
|
|
691
|
+
<div class="cost-item">
|
|
692
|
+
<div class="cost-item-label">Model</div>
|
|
693
|
+
<div>${e(session.meta.modelName)}</div>
|
|
694
|
+
</div>
|
|
695
|
+
</div>
|
|
696
|
+
</div>
|
|
697
|
+
|
|
698
|
+
<div class="section">
|
|
699
|
+
<h2>First User Message</h2>
|
|
700
|
+
<div style="background: #0d1117; padding: 15px; border-radius: 4px; font-family: monospace; white-space: pre-wrap;">
|
|
701
|
+
${session.meta.firstUserMessage ? e(session.meta.firstUserMessage) : "No user message found"}
|
|
702
|
+
</div>
|
|
703
|
+
</div>
|
|
704
|
+
|
|
705
|
+
<div class="section">
|
|
706
|
+
<h2>Conversations (${session.conversations.length})</h2>
|
|
707
|
+
<div class="conversation-list">
|
|
708
|
+
${session.conversations
|
|
709
|
+
.filter((c) => c.type !== "x-error")
|
|
710
|
+
.map((c) => {
|
|
711
|
+
const typeClass = c.type === "user"
|
|
712
|
+
? "type-user"
|
|
713
|
+
: c.type === "assistant"
|
|
714
|
+
? "type-assistant"
|
|
715
|
+
: "type-system";
|
|
716
|
+
return `
|
|
717
|
+
<div class="conversation-item">
|
|
718
|
+
<span class="conversation-type ${typeClass}">${e(c.type)}</span>
|
|
719
|
+
<span class="timestamp">${e(new Date(c.timestamp).toLocaleString())}</span>
|
|
720
|
+
${c.message?.usage
|
|
721
|
+
? `<span class="badge badge-green" style="margin-left: 10px;">${c.message.usage.input_tokens + c.message.usage.output_tokens} tokens</span>`
|
|
722
|
+
: ""}
|
|
723
|
+
</div>
|
|
724
|
+
`;
|
|
725
|
+
})
|
|
726
|
+
.join("")}
|
|
727
|
+
</div>
|
|
728
|
+
</div>
|
|
729
|
+
`;
|
|
730
|
+
res.send(renderLayout(`Session: ${session.id}`, content));
|
|
731
|
+
}
|
|
732
|
+
catch (error) {
|
|
733
|
+
res.status(500).send(`Error: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
734
|
+
}
|
|
735
|
+
});
|
|
736
|
+
exports.default = router;
|
|
737
|
+
//# sourceMappingURL=views.js.map
|