@jeet427/claude-sessions-dashboard 1.0.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/bin/cli.js +96 -0
- package/package.json +29 -0
- package/public/css/theme.css +35 -0
- package/public/index.html +539 -0
- package/public/js/app.js +395 -0
- package/public/js/charts.js +518 -0
- package/public/js/projects.js +76 -0
- package/public/js/sessions.js +668 -0
- package/public/js/utils.js +114 -0
- package/scripts/build-mac-app.sh +123 -0
- package/src/analyzer.js +332 -0
- package/src/cost.js +56 -0
- package/src/insights.js +525 -0
- package/src/native/launcher.swift +143 -0
- package/src/notes.js +35 -0
- package/src/parser.js +177 -0
- package/src/pins.js +28 -0
- package/src/report.js +225 -0
- package/src/server.js +207 -0
package/public/js/app.js
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
// Claude Sessions Dashboard — app
|
|
2
|
+
|
|
3
|
+
// ─── Tab Switching ────────────────────────────────────
|
|
4
|
+
function switchTab(tabName) {
|
|
5
|
+
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
|
|
6
|
+
document.querySelectorAll('[data-tab]').forEach(el => {
|
|
7
|
+
el.className = el.dataset.tab === tabName
|
|
8
|
+
? 'tab-active py-2 text-sm font-medium transition-colors'
|
|
9
|
+
: 'tab-inactive py-2 text-sm font-medium transition-colors';
|
|
10
|
+
});
|
|
11
|
+
const target = document.getElementById('tab-' + tabName);
|
|
12
|
+
target.classList.remove('hidden');
|
|
13
|
+
target.classList.add('fade-in');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// ─── Time Range Filter ────────────────────────────────
|
|
17
|
+
function getCutoffDate(range) {
|
|
18
|
+
const now = new Date();
|
|
19
|
+
if (range === 'today') {
|
|
20
|
+
const d = new Date(now); d.setHours(0, 0, 0, 0); return d;
|
|
21
|
+
}
|
|
22
|
+
if (range === '7d') { const d = new Date(now); d.setDate(d.getDate() - 7); return d; }
|
|
23
|
+
if (range === '30d') { const d = new Date(now); d.setDate(d.getDate() - 30); return d; }
|
|
24
|
+
if (range === '90d') { const d = new Date(now); d.setDate(d.getDate() - 90); return d; }
|
|
25
|
+
return null; // 'all'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function filterAndAggregate(data, cutoffDate) {
|
|
29
|
+
if (!cutoffDate) return data;
|
|
30
|
+
|
|
31
|
+
const cutoff = cutoffDate.toISOString();
|
|
32
|
+
const sessions = (data.sessionDetails || []).filter(s => s.startTime && s.startTime >= cutoff);
|
|
33
|
+
|
|
34
|
+
// Re-derive overview
|
|
35
|
+
const overview = {
|
|
36
|
+
totalSessions: sessions.length,
|
|
37
|
+
totalProjects: new Set(sessions.map(s => s.projectName)).size,
|
|
38
|
+
totalCost: 0,
|
|
39
|
+
totalInputTokens: 0,
|
|
40
|
+
totalOutputTokens: 0,
|
|
41
|
+
totalCacheWriteTokens: 0,
|
|
42
|
+
totalCacheReadTokens: 0,
|
|
43
|
+
totalMessages: 0,
|
|
44
|
+
totalHumanMessages: 0,
|
|
45
|
+
totalAssistantMessages: 0,
|
|
46
|
+
totalToolCalls: 0,
|
|
47
|
+
firstSessionDate: null,
|
|
48
|
+
lastSessionDate: null,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const perProjectMap = {};
|
|
52
|
+
const perModelMap = {};
|
|
53
|
+
const toolUsageMap = {};
|
|
54
|
+
const toolTokenMap = {};
|
|
55
|
+
const hourly = new Array(24).fill(0);
|
|
56
|
+
const weekday = new Array(7).fill(0);
|
|
57
|
+
|
|
58
|
+
for (const s of sessions) {
|
|
59
|
+
overview.totalCost += s.cost || 0;
|
|
60
|
+
overview.totalInputTokens += s.inputTokens || 0;
|
|
61
|
+
overview.totalOutputTokens += s.outputTokens || 0;
|
|
62
|
+
overview.totalCacheWriteTokens += s.cacheWriteTokens || 0;
|
|
63
|
+
overview.totalCacheReadTokens += s.cacheReadTokens || 0;
|
|
64
|
+
overview.totalHumanMessages += s.humanMessages || 0;
|
|
65
|
+
overview.totalAssistantMessages += s.assistantMessages || 0;
|
|
66
|
+
overview.totalMessages += (s.humanMessages || 0) + (s.assistantMessages || 0);
|
|
67
|
+
overview.totalToolCalls += s.toolCalls || 0;
|
|
68
|
+
|
|
69
|
+
if (s.startTime) {
|
|
70
|
+
if (!overview.firstSessionDate || s.startTime < overview.firstSessionDate) overview.firstSessionDate = s.startTime;
|
|
71
|
+
if (!overview.lastSessionDate || s.startTime > overview.lastSessionDate) overview.lastSessionDate = s.startTime;
|
|
72
|
+
const d = new Date(s.startTime);
|
|
73
|
+
hourly[d.getHours()]++;
|
|
74
|
+
weekday[d.getDay()]++;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Per-project
|
|
78
|
+
const pn = s.projectName;
|
|
79
|
+
if (!perProjectMap[pn]) perProjectMap[pn] = { sessions: 0, cost: 0, messages: 0, toolCalls: 0, inputTokens: 0, outputTokens: 0 };
|
|
80
|
+
perProjectMap[pn].sessions++;
|
|
81
|
+
perProjectMap[pn].cost += s.cost || 0;
|
|
82
|
+
perProjectMap[pn].messages += (s.humanMessages || 0) + (s.assistantMessages || 0);
|
|
83
|
+
perProjectMap[pn].toolCalls += s.toolCalls || 0;
|
|
84
|
+
perProjectMap[pn].inputTokens += s.inputTokens || 0;
|
|
85
|
+
perProjectMap[pn].outputTokens += s.outputTokens || 0;
|
|
86
|
+
|
|
87
|
+
// Per-model
|
|
88
|
+
for (const [model, count] of Object.entries(s.models || {})) {
|
|
89
|
+
if (!perModelMap[model]) perModelMap[model] = { messages: 0, inputTokens: 0, outputTokens: 0, cost: 0 };
|
|
90
|
+
perModelMap[model].messages += count;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Tool usage
|
|
94
|
+
for (const [tool, count] of Object.entries(s.tools || {})) {
|
|
95
|
+
toolUsageMap[tool] = (toolUsageMap[tool] || 0) + count;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Cache hit rate
|
|
100
|
+
const totalCacheAttempts = overview.totalCacheWriteTokens + overview.totalCacheReadTokens;
|
|
101
|
+
const cacheHitRate = totalCacheAttempts > 0
|
|
102
|
+
? parseFloat(((overview.totalCacheReadTokens / totalCacheAttempts) * 100).toFixed(1))
|
|
103
|
+
: 0;
|
|
104
|
+
|
|
105
|
+
// Filter date-keyed arrays
|
|
106
|
+
const cutoffStr = cutoffDate.toISOString().slice(0, 10);
|
|
107
|
+
|
|
108
|
+
const dailyCost = (data.dailyCost || []).filter(d => d.date >= cutoffStr);
|
|
109
|
+
const dailyActivity = (data.dailyActivity || []).filter(d => d.date >= cutoffStr);
|
|
110
|
+
const modelTimeline = (data.modelTimeline || []).filter(d => d.date >= cutoffStr);
|
|
111
|
+
const toolTokenAttribution = (data.toolTokenAttribution || []);
|
|
112
|
+
|
|
113
|
+
// Agent delegations & skill usage — recount from sessions not easily possible without raw data,
|
|
114
|
+
// so we scale proportionally or just pass through (sessions filtered already)
|
|
115
|
+
const agentDelegations = data.agentDelegations || [];
|
|
116
|
+
const skillUsage = data.skillUsage || [];
|
|
117
|
+
const gitBranches = data.gitBranches || [];
|
|
118
|
+
const entrypoints = data.entrypoints || [];
|
|
119
|
+
const historyCommands = data.historyCommands || [];
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
overview: {
|
|
123
|
+
...overview,
|
|
124
|
+
cacheHitRate,
|
|
125
|
+
firstSessionDate: overview.firstSessionDate || null,
|
|
126
|
+
lastSessionDate: overview.lastSessionDate || null,
|
|
127
|
+
totalTokens: overview.totalInputTokens + overview.totalOutputTokens + overview.totalCacheWriteTokens,
|
|
128
|
+
},
|
|
129
|
+
perProject: Object.entries(perProjectMap)
|
|
130
|
+
.map(([name, d]) => ({ name, ...d }))
|
|
131
|
+
.sort((a, b) => b.cost - a.cost),
|
|
132
|
+
perModel: perModelMap,
|
|
133
|
+
toolUsage: Object.entries(toolUsageMap)
|
|
134
|
+
.map(([name, count]) => ({ name, count }))
|
|
135
|
+
.sort((a, b) => b.count - a.count),
|
|
136
|
+
dailyCost,
|
|
137
|
+
dailyActivity,
|
|
138
|
+
hourlyActivity: hourly.map((count, hour) => ({ hour, count })),
|
|
139
|
+
weekdayActivity: ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'].map((day, i) => ({ day, count: weekday[i] })),
|
|
140
|
+
modelTimeline,
|
|
141
|
+
toolTokenAttribution,
|
|
142
|
+
agentDelegations,
|
|
143
|
+
skillUsage,
|
|
144
|
+
gitBranches,
|
|
145
|
+
entrypoints,
|
|
146
|
+
historyCommands,
|
|
147
|
+
sessionDetails: sessions,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function applyTimeRange() {
|
|
152
|
+
if (!DATA) return;
|
|
153
|
+
const range = document.getElementById('time-range').value;
|
|
154
|
+
const cutoff = getCutoffDate(range);
|
|
155
|
+
FILTERED_DATA = filterAndAggregate(DATA, cutoff);
|
|
156
|
+
renderAll(FILTERED_DATA);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ─── Feature 9: Theme Toggle ──────────────────────────
|
|
160
|
+
const THEME_KEY = 'csd_theme';
|
|
161
|
+
|
|
162
|
+
function toggleTheme() {
|
|
163
|
+
const html = document.documentElement;
|
|
164
|
+
const btn = document.getElementById('theme-toggle');
|
|
165
|
+
if (html.classList.contains('light-mode')) {
|
|
166
|
+
html.classList.remove('light-mode');
|
|
167
|
+
html.classList.add('dark');
|
|
168
|
+
btn.textContent = '🌙';
|
|
169
|
+
localStorage.setItem(THEME_KEY, 'dark');
|
|
170
|
+
} else {
|
|
171
|
+
html.classList.add('light-mode');
|
|
172
|
+
html.classList.remove('dark');
|
|
173
|
+
btn.textContent = '☀️';
|
|
174
|
+
localStorage.setItem(THEME_KEY, 'light');
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function restoreTheme() {
|
|
179
|
+
const saved = localStorage.getItem(THEME_KEY);
|
|
180
|
+
if (saved === 'light') {
|
|
181
|
+
document.documentElement.classList.add('light-mode');
|
|
182
|
+
document.documentElement.classList.remove('dark');
|
|
183
|
+
const btn = document.getElementById('theme-toggle');
|
|
184
|
+
if (btn) btn.textContent = '☀️';
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ─── CSV Export ───────────────────────────────────────
|
|
189
|
+
function downloadCsv(filename, rows) {
|
|
190
|
+
const csv = rows.map(r => r.map(cell => {
|
|
191
|
+
const s = String(cell ?? '');
|
|
192
|
+
return s.includes(',') || s.includes('"') || s.includes('\n') ? `"${s.replace(/"/g, '""')}"` : s;
|
|
193
|
+
}).join(',')).join('\n');
|
|
194
|
+
const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }));
|
|
195
|
+
const a = document.createElement('a');
|
|
196
|
+
a.href = url;
|
|
197
|
+
a.download = filename;
|
|
198
|
+
a.click();
|
|
199
|
+
URL.revokeObjectURL(url);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function exportSessionsCsv() {
|
|
203
|
+
const sessions = _currentFilteredSessions.length > 0 ? _currentFilteredSessions : (FILTERED_DATA?.sessionDetails || []);
|
|
204
|
+
const header = ['Date', 'Project', 'Branch', 'Slug', 'Messages', 'Tools', 'Duration(min)', 'Input Tokens', 'Output Tokens', 'Cost'];
|
|
205
|
+
const rows = sessions.map(s => [
|
|
206
|
+
s.startTime ? new Date(s.startTime).toISOString().slice(0, 10) : '',
|
|
207
|
+
s.projectName || '',
|
|
208
|
+
s.gitBranch || '',
|
|
209
|
+
s.slug || '',
|
|
210
|
+
(s.humanMessages || 0) + (s.assistantMessages || 0),
|
|
211
|
+
s.toolCalls || 0,
|
|
212
|
+
s.duration ? Math.floor(s.duration / 60000) : 0,
|
|
213
|
+
s.inputTokens || 0,
|
|
214
|
+
s.outputTokens || 0,
|
|
215
|
+
(s.cost || 0).toFixed(4),
|
|
216
|
+
]);
|
|
217
|
+
downloadCsv('sessions.csv', [header, ...rows]);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function exportProjectsCsv() {
|
|
221
|
+
const projects = FILTERED_DATA?.perProject || [];
|
|
222
|
+
const header = ['Project', 'Sessions', 'Messages', 'Tool Calls', 'Input Tokens', 'Output Tokens', 'Cost'];
|
|
223
|
+
const rows = projects.map(p => [
|
|
224
|
+
p.name, p.sessions, p.messages, p.toolCalls, p.inputTokens, p.outputTokens, (p.cost || 0).toFixed(4),
|
|
225
|
+
]);
|
|
226
|
+
downloadCsv('projects.csv', [header, ...rows]);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ─── Daily Budget Alert ───────────────────────────────
|
|
230
|
+
async function checkBudgetAlert(data) {
|
|
231
|
+
try {
|
|
232
|
+
const res = await fetch('/api/config');
|
|
233
|
+
const config = await res.json();
|
|
234
|
+
if (!config.dailyBudget) return;
|
|
235
|
+
|
|
236
|
+
const now = new Date();
|
|
237
|
+
const sevenDaysAgo = new Date(now); sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
|
238
|
+
const sevenDayStr = sevenDaysAgo.toISOString().slice(0, 10);
|
|
239
|
+
|
|
240
|
+
const recentCosts = (data.dailyCost || []).filter(d => d.date >= sevenDayStr);
|
|
241
|
+
if (recentCosts.length === 0) return;
|
|
242
|
+
|
|
243
|
+
const avg7 = recentCosts.reduce((sum, d) => sum + d.cost, 0) / recentCosts.length;
|
|
244
|
+
|
|
245
|
+
if (avg7 > config.dailyBudget) {
|
|
246
|
+
const banner = document.getElementById('budget-banner');
|
|
247
|
+
document.getElementById('budget-banner-text').textContent =
|
|
248
|
+
`⚠️ Daily budget alert: 7-day avg (${fmtCost(avg7)}/day) exceeds ${fmtCost(config.dailyBudget)} budget`;
|
|
249
|
+
banner.classList.remove('hidden');
|
|
250
|
+
}
|
|
251
|
+
} catch {
|
|
252
|
+
// Budget check is non-critical — swallow silently
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ─── Auto-Refresh ─────────────────────────────────────
|
|
257
|
+
const AUTO_REFRESH_KEY = 'csd_autorefresh';
|
|
258
|
+
|
|
259
|
+
function toggleAutoRefresh() {
|
|
260
|
+
const isOn = !!AUTO_REFRESH_INTERVAL;
|
|
261
|
+
if (isOn) {
|
|
262
|
+
clearInterval(AUTO_REFRESH_INTERVAL);
|
|
263
|
+
AUTO_REFRESH_INTERVAL = null;
|
|
264
|
+
localStorage.setItem(AUTO_REFRESH_KEY, 'off');
|
|
265
|
+
document.getElementById('auto-refresh-toggle').textContent = '○ Auto';
|
|
266
|
+
document.getElementById('auto-refresh-toggle').style.color = '';
|
|
267
|
+
} else {
|
|
268
|
+
AUTO_REFRESH_INTERVAL = setInterval(refresh, 30000);
|
|
269
|
+
localStorage.setItem(AUTO_REFRESH_KEY, 'on');
|
|
270
|
+
document.getElementById('auto-refresh-toggle').textContent = '⬤ Auto';
|
|
271
|
+
document.getElementById('auto-refresh-toggle').style.color = '#22c55e';
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function restoreAutoRefresh() {
|
|
276
|
+
if (localStorage.getItem(AUTO_REFRESH_KEY) === 'on') {
|
|
277
|
+
AUTO_REFRESH_INTERVAL = setInterval(refresh, 30000);
|
|
278
|
+
document.getElementById('auto-refresh-toggle').textContent = '⬤ Auto';
|
|
279
|
+
document.getElementById('auto-refresh-toggle').style.color = '#22c55e';
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ─── Keyboard Shortcuts ───────────────────────────────
|
|
284
|
+
let shortcutsTimeout = null;
|
|
285
|
+
|
|
286
|
+
function showShortcuts() {
|
|
287
|
+
const overlay = document.getElementById('shortcuts-overlay');
|
|
288
|
+
overlay.classList.add('visible');
|
|
289
|
+
clearTimeout(shortcutsTimeout);
|
|
290
|
+
shortcutsTimeout = setTimeout(() => overlay.classList.remove('visible'), 3000);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
document.addEventListener('keydown', (e) => {
|
|
294
|
+
const tag = document.activeElement.tagName.toLowerCase();
|
|
295
|
+
if (tag === 'input' || tag === 'select' || tag === 'textarea') return;
|
|
296
|
+
|
|
297
|
+
const key = e.key;
|
|
298
|
+
|
|
299
|
+
if (key >= '1' && key <= '6') {
|
|
300
|
+
e.preventDefault();
|
|
301
|
+
switchTab(TAB_NAMES[parseInt(key) - 1]);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (key === 'r' || key === 'R') { e.preventDefault(); refresh(); return; }
|
|
305
|
+
if (key === '/') {
|
|
306
|
+
e.preventDefault();
|
|
307
|
+
switchTab('sessions');
|
|
308
|
+
setTimeout(() => document.getElementById('session-search').focus(), 50);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
if (key === 'Escape') {
|
|
312
|
+
closeSessionModal();
|
|
313
|
+
document.getElementById('shortcuts-overlay').classList.remove('visible');
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
if (key === '?') { e.preventDefault(); showShortcuts(); return; }
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// ─── Deep Link Hash Navigation ────────────────────────
|
|
320
|
+
function checkDeepLink(data) {
|
|
321
|
+
const hash = window.location.hash;
|
|
322
|
+
if (!hash.startsWith('#sessions')) return;
|
|
323
|
+
const params = new URLSearchParams(hash.slice('#sessions'.length + 1));
|
|
324
|
+
const sessionId = params.get('id');
|
|
325
|
+
if (!sessionId) return;
|
|
326
|
+
switchTab('sessions');
|
|
327
|
+
const session = data.sessionDetails.find(s => s.sessionId === sessionId);
|
|
328
|
+
if (session) openSessionModal(session);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ─── Render All ───────────────────────────────────────
|
|
332
|
+
function renderAll(data) {
|
|
333
|
+
renderOverview(data);
|
|
334
|
+
renderProjects(data);
|
|
335
|
+
renderToolsAndSkills(data);
|
|
336
|
+
renderSessions(data);
|
|
337
|
+
renderActivity(data);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ─── Data Loading ─────────────────────────────────────
|
|
341
|
+
async function loadData() {
|
|
342
|
+
const res = await fetch('/api/data');
|
|
343
|
+
DATA = await res.json();
|
|
344
|
+
return DATA;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function refresh() {
|
|
348
|
+
document.getElementById('loading').classList.remove('hidden');
|
|
349
|
+
document.getElementById('app').classList.add('hidden');
|
|
350
|
+
await fetch('/api/refresh', { method: 'POST' });
|
|
351
|
+
await init();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function init() {
|
|
355
|
+
try {
|
|
356
|
+
// Restore theme first (before anything renders)
|
|
357
|
+
restoreTheme();
|
|
358
|
+
|
|
359
|
+
// Load config for username initial
|
|
360
|
+
try {
|
|
361
|
+
const cfgRes = await fetch('/api/config');
|
|
362
|
+
if (cfgRes.ok) {
|
|
363
|
+
CONFIG = await cfgRes.json();
|
|
364
|
+
USERNAME_INITIAL = ((CONFIG.username || 'U')[0] || 'U').toUpperCase();
|
|
365
|
+
}
|
|
366
|
+
} catch {
|
|
367
|
+
// Non-critical
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Load pinned sessions
|
|
371
|
+
await loadPinnedSessions();
|
|
372
|
+
|
|
373
|
+
const data = await loadData();
|
|
374
|
+
|
|
375
|
+
// Apply current time range selection
|
|
376
|
+
const range = document.getElementById('time-range').value;
|
|
377
|
+
const cutoff = getCutoffDate(range);
|
|
378
|
+
FILTERED_DATA = filterAndAggregate(data, cutoff);
|
|
379
|
+
|
|
380
|
+
document.getElementById('loading').classList.add('hidden');
|
|
381
|
+
document.getElementById('app').classList.remove('hidden');
|
|
382
|
+
document.getElementById('lastUpdated').textContent = 'Updated: ' + new Date().toLocaleTimeString();
|
|
383
|
+
|
|
384
|
+
renderAll(FILTERED_DATA);
|
|
385
|
+
renderInsights();
|
|
386
|
+
checkBudgetAlert(data);
|
|
387
|
+
checkDeepLink(FILTERED_DATA);
|
|
388
|
+
} catch (err) {
|
|
389
|
+
document.getElementById('loading').innerHTML =
|
|
390
|
+
`<div class="text-center"><p class="text-red-400 text-sm">Failed to load data</p><p class="text-claude-muted text-xs mt-2">${err.message}</p></div>`;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
restoreAutoRefresh();
|
|
395
|
+
init();
|