@viren/claude-code-dashboard 0.0.2 → 0.0.4
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 +1 -1
- package/generate-dashboard.mjs +282 -35
- package/package.json +1 -1
- package/src/anonymize.mjs +6 -2
- package/src/cli.mjs +8 -4
- package/src/constants.mjs +4 -1
- package/src/demo.mjs +76 -2
- package/src/html-template.mjs +565 -363
- package/src/mcp.mjs +105 -14
- package/src/render.mjs +9 -0
- package/src/watch.mjs +2 -2
package/src/html-template.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { esc, formatTokens } from "./helpers.mjs";
|
|
2
|
-
import { QUICK_REFERENCE } from "./constants.mjs";
|
|
2
|
+
import { QUICK_REFERENCE, VERSION, REPO_URL } from "./constants.mjs";
|
|
3
3
|
import {
|
|
4
4
|
renderCmd,
|
|
5
5
|
renderRule,
|
|
@@ -33,7 +33,346 @@ export function generateDashboardHtml({
|
|
|
33
33
|
driftCount,
|
|
34
34
|
mcpCount,
|
|
35
35
|
scanScope,
|
|
36
|
+
insights,
|
|
37
|
+
insightsReport,
|
|
36
38
|
}) {
|
|
39
|
+
// ── Build tab content sections ──────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
// Skills card
|
|
42
|
+
const skillsHtml = globalSkills.length
|
|
43
|
+
? (() => {
|
|
44
|
+
const groups = groupSkillsByCategory(globalSkills);
|
|
45
|
+
const categoryHtml = Object.entries(groups)
|
|
46
|
+
.map(
|
|
47
|
+
([cat, skills], idx) =>
|
|
48
|
+
`<details class="skill-category"${idx === 0 ? " open" : ""}>` +
|
|
49
|
+
`<summary class="skill-category-label">${esc(cat)} <span class="cat-n">${skills.length}</span></summary>` +
|
|
50
|
+
skills.map((s) => renderSkill(s)).join("\n ") +
|
|
51
|
+
`</details>`,
|
|
52
|
+
)
|
|
53
|
+
.join("\n ");
|
|
54
|
+
return `<div class="card" id="section-skills">
|
|
55
|
+
<h2>Skills <span class="n">${globalSkills.length}</span></h2>
|
|
56
|
+
${categoryHtml}
|
|
57
|
+
</div>`;
|
|
58
|
+
})()
|
|
59
|
+
: "";
|
|
60
|
+
|
|
61
|
+
// MCP card
|
|
62
|
+
const mcpHtml = mcpSummary.length
|
|
63
|
+
? (() => {
|
|
64
|
+
const rows = mcpSummary
|
|
65
|
+
.map((s) => {
|
|
66
|
+
const disabledClass = s.disabledIn > 0 ? " mcp-disabled" : "";
|
|
67
|
+
const disabledHint =
|
|
68
|
+
s.disabledIn > 0
|
|
69
|
+
? `<span class="mcp-disabled-hint">disabled in ${s.disabledIn} project${s.disabledIn > 1 ? "s" : ""}</span>`
|
|
70
|
+
: "";
|
|
71
|
+
const scopeBadge = s.userLevel
|
|
72
|
+
? `<span class="badge mcp-global">global</span>`
|
|
73
|
+
: s.recentlyActive
|
|
74
|
+
? `<span class="badge mcp-recent">recent</span>`
|
|
75
|
+
: `<span class="badge mcp-project">project</span>`;
|
|
76
|
+
const typeBadge = `<span class="badge mcp-type">${esc(s.type)}</span>`;
|
|
77
|
+
const projects =
|
|
78
|
+
!s.userLevel && s.projects.length
|
|
79
|
+
? `<span class="mcp-projects">${s.projects.map((p) => esc(p)).join(", ")}</span>`
|
|
80
|
+
: "";
|
|
81
|
+
return `<div class="mcp-row${disabledClass}"><span class="mcp-name">${esc(s.name)}</span>${scopeBadge}${typeBadge}${disabledHint}${projects}</div>`;
|
|
82
|
+
})
|
|
83
|
+
.join("\n ");
|
|
84
|
+
const promoteHtml = mcpPromotions.length
|
|
85
|
+
? mcpPromotions
|
|
86
|
+
.map(
|
|
87
|
+
(p) =>
|
|
88
|
+
`<div class="mcp-promote"><span class="mcp-name">${esc(p.name)}</span> installed in ${p.projects.length} projects → add to <code>~/.claude/mcp_config.json</code></div>`,
|
|
89
|
+
)
|
|
90
|
+
.join("\n ")
|
|
91
|
+
: "";
|
|
92
|
+
const formerHtml = formerMcpServers.length
|
|
93
|
+
? `<div class="label" style="margin-top:.75rem">Formerly Installed</div>
|
|
94
|
+
${formerMcpServers
|
|
95
|
+
.map((s) => {
|
|
96
|
+
const hint = s.projects.length
|
|
97
|
+
? `<span class="mcp-projects">${s.projects.map((p) => esc(p)).join(", ")}</span>`
|
|
98
|
+
: "";
|
|
99
|
+
return `<div class="mcp-row mcp-former"><span class="mcp-name">${esc(s.name)}</span><span class="badge mcp-former-badge">removed</span>${hint}</div>`;
|
|
100
|
+
})
|
|
101
|
+
.join("\n ")}`
|
|
102
|
+
: "";
|
|
103
|
+
return `<div class="card" id="section-mcp">
|
|
104
|
+
<h2>MCP Servers <span class="n">${mcpSummary.length}</span></h2>
|
|
105
|
+
${rows}
|
|
106
|
+
${promoteHtml}
|
|
107
|
+
${formerHtml}
|
|
108
|
+
</div>`;
|
|
109
|
+
})()
|
|
110
|
+
: "";
|
|
111
|
+
|
|
112
|
+
// Usage bar cards (tools, languages, errors)
|
|
113
|
+
const toolsHtml = usageAnalytics.topTools.length
|
|
114
|
+
? (() => {
|
|
115
|
+
const maxCount = usageAnalytics.topTools[0].count;
|
|
116
|
+
const rows = usageAnalytics.topTools
|
|
117
|
+
.map((t) => {
|
|
118
|
+
const pct = maxCount > 0 ? Math.round((t.count / maxCount) * 100) : 0;
|
|
119
|
+
return `<div class="usage-bar-row"><span class="usage-bar-label">${esc(t.name)}</span><div class="usage-bar-track"><div class="usage-bar-fill usage-bar-tool" style="width:${pct}%"></div></div><span class="usage-bar-count">${t.count.toLocaleString()}</span></div>`;
|
|
120
|
+
})
|
|
121
|
+
.join("\n ");
|
|
122
|
+
return `<div class="card">
|
|
123
|
+
<h2>Top Tools Used <span class="n">${usageAnalytics.topTools.length}</span></h2>
|
|
124
|
+
${rows}
|
|
125
|
+
</div>`;
|
|
126
|
+
})()
|
|
127
|
+
: "";
|
|
128
|
+
|
|
129
|
+
const langsHtml = usageAnalytics.topLanguages.length
|
|
130
|
+
? (() => {
|
|
131
|
+
const maxCount = usageAnalytics.topLanguages[0].count;
|
|
132
|
+
const rows = usageAnalytics.topLanguages
|
|
133
|
+
.map((l) => {
|
|
134
|
+
const pct = maxCount > 0 ? Math.round((l.count / maxCount) * 100) : 0;
|
|
135
|
+
return `<div class="usage-bar-row"><span class="usage-bar-label">${esc(l.name)}</span><div class="usage-bar-track"><div class="usage-bar-fill usage-bar-lang" style="width:${pct}%"></div></div><span class="usage-bar-count">${l.count.toLocaleString()}</span></div>`;
|
|
136
|
+
})
|
|
137
|
+
.join("\n ");
|
|
138
|
+
return `<div class="card">
|
|
139
|
+
<h2>Languages <span class="n">${usageAnalytics.topLanguages.length}</span></h2>
|
|
140
|
+
${rows}
|
|
141
|
+
</div>`;
|
|
142
|
+
})()
|
|
143
|
+
: "";
|
|
144
|
+
|
|
145
|
+
const errorsHtml = usageAnalytics.errorCategories.length
|
|
146
|
+
? (() => {
|
|
147
|
+
const maxCount = usageAnalytics.errorCategories[0].count;
|
|
148
|
+
const rows = usageAnalytics.errorCategories
|
|
149
|
+
.map((e) => {
|
|
150
|
+
const pct = maxCount > 0 ? Math.round((e.count / maxCount) * 100) : 0;
|
|
151
|
+
return `<div class="usage-bar-row"><span class="usage-bar-label">${esc(e.name)}</span><div class="usage-bar-track"><div class="usage-bar-fill usage-bar-error" style="width:${pct}%"></div></div><span class="usage-bar-count">${e.count.toLocaleString()}</span></div>`;
|
|
152
|
+
})
|
|
153
|
+
.join("\n ");
|
|
154
|
+
return `<div class="card">
|
|
155
|
+
<h2>Top Errors <span class="n">${usageAnalytics.errorCategories.length}</span></h2>
|
|
156
|
+
${rows}
|
|
157
|
+
</div>`;
|
|
158
|
+
})()
|
|
159
|
+
: "";
|
|
160
|
+
|
|
161
|
+
// Activity/heatmap/peak hours/model usage card
|
|
162
|
+
const activityHtml = (() => {
|
|
163
|
+
const dailyActivity = statsCache.dailyActivity || [];
|
|
164
|
+
const hourCounts = statsCache.hourCounts || {};
|
|
165
|
+
const modelUsage = statsCache.modelUsage || {};
|
|
166
|
+
const hasActivity = dailyActivity.length > 0;
|
|
167
|
+
const hasHours = Object.keys(hourCounts).length > 0;
|
|
168
|
+
const hasModels = Object.keys(modelUsage).length > 0;
|
|
169
|
+
|
|
170
|
+
if (!hasActivity && !hasHours && !hasModels && !ccusageData) return "";
|
|
171
|
+
|
|
172
|
+
let content = "";
|
|
173
|
+
|
|
174
|
+
if (hasActivity) {
|
|
175
|
+
const dateMap = new Map(dailyActivity.map((d) => [d.date, d.messageCount || 0]));
|
|
176
|
+
const dates = dailyActivity.map((d) => d.date).sort();
|
|
177
|
+
const lastDate = new Date(dates[dates.length - 1]);
|
|
178
|
+
const firstDate = new Date(lastDate);
|
|
179
|
+
firstDate.setDate(firstDate.getDate() - 364);
|
|
180
|
+
|
|
181
|
+
const nonZero = dailyActivity
|
|
182
|
+
.map((d) => d.messageCount || 0)
|
|
183
|
+
.filter((n) => n > 0)
|
|
184
|
+
.sort((a, b) => a - b);
|
|
185
|
+
const q1 = nonZero[Math.floor(nonZero.length * 0.25)] || 1;
|
|
186
|
+
const q2 = nonZero[Math.floor(nonZero.length * 0.5)] || 2;
|
|
187
|
+
const q3 = nonZero[Math.floor(nonZero.length * 0.75)] || 3;
|
|
188
|
+
|
|
189
|
+
function level(count) {
|
|
190
|
+
if (count === 0) return "";
|
|
191
|
+
if (count <= q1) return " l1";
|
|
192
|
+
if (count <= q2) return " l2";
|
|
193
|
+
if (count <= q3) return " l3";
|
|
194
|
+
return " l4";
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const start = new Date(firstDate);
|
|
198
|
+
start.setUTCDate(start.getUTCDate() - start.getUTCDay());
|
|
199
|
+
|
|
200
|
+
const months = [];
|
|
201
|
+
let lastMonth = -1;
|
|
202
|
+
const cursor1 = new Date(start);
|
|
203
|
+
let weekIdx = 0;
|
|
204
|
+
while (cursor1 <= lastDate) {
|
|
205
|
+
if (cursor1.getUTCDay() === 0) {
|
|
206
|
+
const m = cursor1.getUTCMonth();
|
|
207
|
+
if (m !== lastMonth) {
|
|
208
|
+
months.push({
|
|
209
|
+
name: cursor1.toLocaleString("en", { month: "short", timeZone: "UTC" }),
|
|
210
|
+
week: weekIdx,
|
|
211
|
+
});
|
|
212
|
+
lastMonth = m;
|
|
213
|
+
}
|
|
214
|
+
weekIdx++;
|
|
215
|
+
}
|
|
216
|
+
cursor1.setUTCDate(cursor1.getUTCDate() + 1);
|
|
217
|
+
}
|
|
218
|
+
const totalWeeks = weekIdx;
|
|
219
|
+
const monthLabels = months
|
|
220
|
+
.map((m) => {
|
|
221
|
+
const left = totalWeeks > 0 ? Math.round((m.week / totalWeeks) * 100) : 0;
|
|
222
|
+
return `<span class="heatmap-month" style="position:absolute;left:${left}%">${m.name}</span>`;
|
|
223
|
+
})
|
|
224
|
+
.join("");
|
|
225
|
+
|
|
226
|
+
let cells = "";
|
|
227
|
+
const cursor2 = new Date(start);
|
|
228
|
+
while (cursor2 <= lastDate) {
|
|
229
|
+
const key = cursor2.toISOString().slice(0, 10);
|
|
230
|
+
const count = dateMap.get(key) || 0;
|
|
231
|
+
const fmtDate = cursor2.toLocaleDateString("en-US", {
|
|
232
|
+
month: "short",
|
|
233
|
+
day: "numeric",
|
|
234
|
+
year: "numeric",
|
|
235
|
+
});
|
|
236
|
+
cells += `<div class="heatmap-cell${level(count)}" title="${esc(fmtDate)}: ${count} messages"></div>`;
|
|
237
|
+
cursor2.setUTCDate(cursor2.getUTCDate() + 1);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
content += `<div class="label">Activity</div>
|
|
241
|
+
<div style="overflow-x:auto;margin-bottom:.5rem">
|
|
242
|
+
<div style="width:fit-content;position:relative">
|
|
243
|
+
<div class="heatmap-months" style="position:relative;height:.8rem">${monthLabels}</div>
|
|
244
|
+
<div class="heatmap">${cells}</div>
|
|
245
|
+
</div>
|
|
246
|
+
</div>`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (hasHours) {
|
|
250
|
+
const maxHour = Math.max(...Object.values(hourCounts), 1);
|
|
251
|
+
let bars = "";
|
|
252
|
+
let labels = "";
|
|
253
|
+
for (let h = 0; h < 24; h++) {
|
|
254
|
+
const count = hourCounts[String(h)] || 0;
|
|
255
|
+
const pct = Math.round((count / maxHour) * 100);
|
|
256
|
+
bars += `<div class="peak-bar" style="height:${Math.max(pct, 2)}%" title="${esc(String(h))}:00 — ${count} messages"></div>`;
|
|
257
|
+
labels += `<div class="peak-label">${h % 6 === 0 ? h : ""}</div>`;
|
|
258
|
+
}
|
|
259
|
+
content += `<div class="label" style="margin-top:.75rem">Peak Hours</div>
|
|
260
|
+
<div class="peak-hours">${bars}</div>
|
|
261
|
+
<div class="peak-labels">${labels}</div>`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (ccusageData) {
|
|
265
|
+
const modelCosts = {};
|
|
266
|
+
for (const day of ccusageData.daily) {
|
|
267
|
+
for (const mb of day.modelBreakdowns || []) {
|
|
268
|
+
if (!modelCosts[mb.modelName]) modelCosts[mb.modelName] = { cost: 0, tokens: 0 };
|
|
269
|
+
modelCosts[mb.modelName].cost += mb.cost || 0;
|
|
270
|
+
modelCosts[mb.modelName].tokens +=
|
|
271
|
+
(mb.inputTokens || 0) +
|
|
272
|
+
(mb.outputTokens || 0) +
|
|
273
|
+
(mb.cacheCreationTokens || 0) +
|
|
274
|
+
(mb.cacheReadTokens || 0);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
const modelRows = Object.entries(modelCosts)
|
|
278
|
+
.sort((a, b) => b[1].cost - a[1].cost)
|
|
279
|
+
.map(
|
|
280
|
+
([name, data]) =>
|
|
281
|
+
`<div class="model-row"><span class="model-name">${esc(name)}</span><span class="model-tokens">$${Math.round(data.cost).toLocaleString()} · ${formatTokens(data.tokens)}</span></div>`,
|
|
282
|
+
)
|
|
283
|
+
.join("\n ");
|
|
284
|
+
|
|
285
|
+
const t = ccusageData.totals;
|
|
286
|
+
const breakdownHtml = `<div class="token-breakdown">
|
|
287
|
+
<div class="tb-row"><span class="tb-label">Cache Read</span><span class="tb-val">${formatTokens(t.cacheReadTokens)}</span></div>
|
|
288
|
+
<div class="tb-row"><span class="tb-label">Cache Creation</span><span class="tb-val">${formatTokens(t.cacheCreationTokens)}</span></div>
|
|
289
|
+
<div class="tb-row"><span class="tb-label">Output</span><span class="tb-val">${formatTokens(t.outputTokens)}</span></div>
|
|
290
|
+
<div class="tb-row"><span class="tb-label">Input</span><span class="tb-val">${formatTokens(t.inputTokens)}</span></div>
|
|
291
|
+
</div>`;
|
|
292
|
+
|
|
293
|
+
content += `<div class="label" style="margin-top:.75rem">Model Usage (via ccusage)</div>
|
|
294
|
+
${modelRows}
|
|
295
|
+
<div class="label" style="margin-top:.75rem">Token Breakdown</div>
|
|
296
|
+
${breakdownHtml}`;
|
|
297
|
+
} else if (hasModels) {
|
|
298
|
+
const modelRows = Object.entries(modelUsage)
|
|
299
|
+
.map(([name, usage]) => {
|
|
300
|
+
const total = (usage.inputTokens || 0) + (usage.outputTokens || 0);
|
|
301
|
+
return { name, total };
|
|
302
|
+
})
|
|
303
|
+
.sort((a, b) => b.total - a.total)
|
|
304
|
+
.map(
|
|
305
|
+
(m) =>
|
|
306
|
+
`<div class="model-row"><span class="model-name">${esc(m.name)}</span><span class="model-tokens">${formatTokens(m.total)}</span></div>`,
|
|
307
|
+
)
|
|
308
|
+
.join("\n ");
|
|
309
|
+
content += `<div class="label" style="margin-top:.75rem">Model Usage (partial — install ccusage for full data)</div>
|
|
310
|
+
${modelRows}`;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return `<div class="card" id="section-activity">
|
|
314
|
+
<h2>Activity</h2>
|
|
315
|
+
${content}
|
|
316
|
+
</div>`;
|
|
317
|
+
})();
|
|
318
|
+
|
|
319
|
+
// Chains
|
|
320
|
+
const chainsHtml = chains.length
|
|
321
|
+
? `<div class="card">
|
|
322
|
+
<h2>Dependency Chains</h2>
|
|
323
|
+
${chains.map((c) => `<div class="chain">${c.nodes.map((n, i) => `<span class="chain-node">${esc(n.trim())}</span>${i < c.nodes.length - 1 ? `<span class="chain-arrow">${c.arrow}</span>` : ""}`).join("")}</div>`).join("\n ")}
|
|
324
|
+
</div>`
|
|
325
|
+
: "";
|
|
326
|
+
|
|
327
|
+
// Consolidation
|
|
328
|
+
const consolidationHtml = consolidationGroups.length
|
|
329
|
+
? `<div class="card">
|
|
330
|
+
<h2>Consolidation Opportunities <span class="n">${consolidationGroups.length}</span></h2>
|
|
331
|
+
${consolidationGroups.map((g) => `<div class="consolidation-hint"><span class="consolidation-stack">${esc(g.stack)}</span> <span class="consolidation-text">${esc(g.suggestion)}</span></div>`).join("\n ")}
|
|
332
|
+
</div>`
|
|
333
|
+
: "";
|
|
334
|
+
|
|
335
|
+
// Unconfigured repos
|
|
336
|
+
const unconfiguredHtml = unconfigured.length
|
|
337
|
+
? `<details class="card">
|
|
338
|
+
<summary style="cursor:pointer;list-style:none"><h2 style="margin:0">Unconfigured Repos <span class="n">${unconfiguredCount}</span></h2></summary>
|
|
339
|
+
<div style="margin-top:.75rem">
|
|
340
|
+
<div class="unconfigured-grid">
|
|
341
|
+
${unconfigured
|
|
342
|
+
.map((r) => {
|
|
343
|
+
const stackTag =
|
|
344
|
+
r.techStack && r.techStack.length
|
|
345
|
+
? `<span class="stack-tag">${esc(r.techStack.join(", "))}</span>`
|
|
346
|
+
: "";
|
|
347
|
+
const suggestionsHtml =
|
|
348
|
+
r.suggestions && r.suggestions.length
|
|
349
|
+
? `<div class="suggestion-hints">${r.suggestions.map((s) => `<span class="suggestion-hint">${esc(s)}</span>`).join("")}</div>`
|
|
350
|
+
: "";
|
|
351
|
+
return `<div class="unconfigured-item">${esc(r.name)}${stackTag}<span class="upath">${esc(r.shortPath)}</span>${suggestionsHtml}</div>`;
|
|
352
|
+
})
|
|
353
|
+
.join("\n ")}
|
|
354
|
+
</div>
|
|
355
|
+
</div>
|
|
356
|
+
</details>`
|
|
357
|
+
: "";
|
|
358
|
+
|
|
359
|
+
// Quick reference
|
|
360
|
+
const referenceHtml = `<div class="card">
|
|
361
|
+
<h2>Quick Reference</h2>
|
|
362
|
+
<div class="ref-grid">
|
|
363
|
+
<div class="ref-col">
|
|
364
|
+
<div class="label">Essential Commands</div>
|
|
365
|
+
${QUICK_REFERENCE.essentialCommands.map((c) => `<div class="ref-row"><code class="ref-cmd">${esc(c.cmd)}</code><span class="ref-desc">${esc(c.desc)}</span></div>`).join("\n ")}
|
|
366
|
+
</div>
|
|
367
|
+
<div class="ref-col">
|
|
368
|
+
<div class="label">Built-in Tools</div>
|
|
369
|
+
${QUICK_REFERENCE.tools.map((t) => `<div class="ref-row"><code class="ref-cmd">${esc(t.name)}</code><span class="ref-desc">${esc(t.desc)}</span></div>`).join("\n ")}
|
|
370
|
+
<div class="label" style="margin-top:.75rem">Keyboard Shortcuts</div>
|
|
371
|
+
${QUICK_REFERENCE.shortcuts.map((s) => `<div class="ref-row"><kbd class="ref-key">${esc(s.keys)}</kbd><span class="ref-desc">${esc(s.desc)}</span></div>`).join("\n ")}
|
|
372
|
+
</div>
|
|
373
|
+
</div>
|
|
374
|
+
</div>`;
|
|
375
|
+
|
|
37
376
|
return `<!DOCTYPE html>
|
|
38
377
|
<html lang="en">
|
|
39
378
|
<head>
|
|
@@ -61,14 +400,28 @@ export function generateDashboardHtml({
|
|
|
61
400
|
}
|
|
62
401
|
code, .cmd-name { font-family: 'SF Mono', 'Fira Code', 'JetBrains Mono', monospace; }
|
|
63
402
|
h1 { font-size: 1.4rem; font-weight: 600; color: var(--accent); margin-bottom: .2rem; }
|
|
64
|
-
.sub { color: var(--text-dim); font-size: .78rem; margin-bottom:
|
|
403
|
+
.sub { color: var(--text-dim); font-size: .78rem; margin-bottom: 1rem; }
|
|
65
404
|
kbd { background: var(--surface2); border: 1px solid var(--border); border-radius: 3px; padding: .05rem .3rem; font-size: .7rem; font-family: inherit; }
|
|
66
405
|
|
|
406
|
+
/* ── Tabs ─────────────────────────────────────────────────── */
|
|
407
|
+
.tab-nav { display: flex; gap: 0; border-bottom: 1px solid var(--border); margin-bottom: 1.5rem; overflow-x: auto; }
|
|
408
|
+
.tab-btn {
|
|
409
|
+
padding: .6rem 1.2rem; font-size: .78rem; font-weight: 500; color: var(--text-dim);
|
|
410
|
+
background: none; border: none; border-bottom: 2px solid transparent;
|
|
411
|
+
cursor: pointer; white-space: nowrap; font-family: inherit; transition: color .15s, border-color .15s;
|
|
412
|
+
}
|
|
413
|
+
.tab-btn:hover { color: var(--text); }
|
|
414
|
+
.tab-btn.active { color: var(--accent); border-bottom-color: var(--accent); }
|
|
415
|
+
.tab-content { display: none; }
|
|
416
|
+
.tab-content.active { display: block; }
|
|
417
|
+
|
|
418
|
+
/* ── Cards ────────────────────────────────────────────────── */
|
|
67
419
|
.top-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1.25rem; margin-bottom: 1.25rem; }
|
|
420
|
+
.top-grid > .card { margin-bottom: 0; }
|
|
68
421
|
@media (max-width: 900px) { .top-grid { grid-template-columns: 1fr; } }
|
|
69
422
|
|
|
70
|
-
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 1.25rem; overflow: hidden; }
|
|
71
|
-
.card
|
|
423
|
+
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 1.25rem; overflow: hidden; margin-bottom: 1.25rem; }
|
|
424
|
+
.card:last-child { margin-bottom: 0; }
|
|
72
425
|
.card h2 { font-size: .7rem; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; color: var(--text-dim); margin-bottom: .75rem; display: flex; align-items: center; gap: .5rem; }
|
|
73
426
|
.card h2 .n { background: var(--surface2); border: 1px solid var(--border); border-radius: 4px; padding: .05rem .35rem; font-size: .65rem; color: var(--accent); }
|
|
74
427
|
|
|
@@ -109,7 +462,8 @@ export function generateDashboardHtml({
|
|
|
109
462
|
.chain-arrow { color: var(--text-dim); font-size: .85rem; }
|
|
110
463
|
|
|
111
464
|
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: .65rem; margin-bottom: 1.5rem; }
|
|
112
|
-
.stat { text-align: center; padding: .65rem .5rem; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; }
|
|
465
|
+
.stat { text-align: center; padding: .65rem .5rem; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; cursor: pointer; transition: border-color .15s, transform .1s; }
|
|
466
|
+
.stat:hover { border-color: var(--accent-dim); transform: translateY(-1px); }
|
|
113
467
|
.stat b { display: block; font-size: 1.4rem; color: var(--accent); }
|
|
114
468
|
.stat span { font-size: .6rem; color: var(--text-dim); text-transform: uppercase; letter-spacing: .06em; }
|
|
115
469
|
.stat.coverage b { color: ${coveragePct >= 70 ? "var(--green)" : coveragePct >= 40 ? "var(--yellow)" : "var(--red)"}; }
|
|
@@ -136,6 +490,7 @@ export function generateDashboardHtml({
|
|
|
136
490
|
.repo-card > summary {
|
|
137
491
|
cursor: pointer; list-style: none; padding: .85rem 1rem;
|
|
138
492
|
display: flex; flex-direction: column; gap: .3rem;
|
|
493
|
+
min-height: 7.5rem; justify-content: center;
|
|
139
494
|
}
|
|
140
495
|
.repo-card > summary::-webkit-details-marker { display: none; }
|
|
141
496
|
.repo-card > summary:hover { background: var(--surface2); }
|
|
@@ -175,10 +530,36 @@ export function generateDashboardHtml({
|
|
|
175
530
|
.mcp-projects { font-size: .65rem; color: var(--text-dim); margin-left: auto; }
|
|
176
531
|
.badge.mcp-global { color: var(--green); border-color: #4ade8033; background: #4ade8010; }
|
|
177
532
|
.badge.mcp-project { color: var(--blue); border-color: #60a5fa33; background: #60a5fa10; }
|
|
533
|
+
.badge.mcp-recent { color: var(--yellow); border-color: #fbbf2433; background: #fbbf2410; }
|
|
178
534
|
.badge.mcp-type { color: var(--text-dim); border-color: var(--border); background: var(--surface2); text-transform: none; font-size: .5rem; }
|
|
179
535
|
.mcp-promote { font-size: .72rem; color: var(--text-dim); padding: .4rem .5rem; background: rgba(251,191,36,.05); border: 1px solid rgba(251,191,36,.15); border-radius: 6px; margin-top: .3rem; }
|
|
180
536
|
.mcp-promote .mcp-name { color: var(--yellow); }
|
|
181
537
|
.mcp-promote code { font-size: .65rem; color: var(--accent); }
|
|
538
|
+
|
|
539
|
+
.insight-card { margin-bottom: 1.25rem; }
|
|
540
|
+
.insight-row { display: flex; align-items: flex-start; gap: .6rem; padding: .5rem .6rem; border-radius: 6px; margin-bottom: .35rem; font-size: .78rem; line-height: 1.4; }
|
|
541
|
+
.insight-row:last-child { margin-bottom: 0; }
|
|
542
|
+
.insight-icon { flex-shrink: 0; font-size: .85rem; line-height: 1; margin-top: .1rem; }
|
|
543
|
+
.insight-body { flex: 1; min-width: 0; }
|
|
544
|
+
.insight-title { font-weight: 600; color: var(--text); }
|
|
545
|
+
.insight-detail { color: var(--text-dim); font-size: .72rem; margin-top: .15rem; }
|
|
546
|
+
.insight-action { color: var(--accent-dim); font-size: .68rem; font-style: italic; margin-top: .15rem; }
|
|
547
|
+
.insight-row.warning { background: rgba(251,191,36,.06); border: 1px solid rgba(251,191,36,.15); }
|
|
548
|
+
.insight-row.info { background: rgba(96,165,250,.06); border: 1px solid rgba(96,165,250,.15); }
|
|
549
|
+
.insight-row.tip { background: rgba(74,222,128,.06); border: 1px solid rgba(74,222,128,.15); }
|
|
550
|
+
.insight-row.promote { background: rgba(192,132,252,.06); border: 1px solid rgba(192,132,252,.15); }
|
|
551
|
+
|
|
552
|
+
.report-card { margin-bottom: 1.25rem; }
|
|
553
|
+
.report-subtitle { font-size: .72rem; color: var(--text-dim); margin-bottom: .75rem; }
|
|
554
|
+
.report-stats { display: flex; flex-wrap: wrap; gap: .5rem; margin-bottom: .75rem; }
|
|
555
|
+
.report-stat { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: .4rem .6rem; text-align: center; min-width: 70px; }
|
|
556
|
+
.report-stat b { display: block; font-size: 1rem; color: var(--accent); }
|
|
557
|
+
.report-stat span { font-size: .55rem; color: var(--text-dim); text-transform: uppercase; letter-spacing: .04em; }
|
|
558
|
+
.report-glance { display: flex; flex-direction: column; gap: .5rem; margin-bottom: .75rem; }
|
|
559
|
+
.report-glance-item { font-size: .75rem; line-height: 1.5; color: var(--text-dim); padding: .5rem .6rem; background: var(--bg); border-radius: 6px; border: 1px solid var(--border); }
|
|
560
|
+
.report-glance-item strong { color: var(--text); font-weight: 600; }
|
|
561
|
+
.report-link { display: inline-block; margin-top: .5rem; font-size: .72rem; color: var(--accent); text-decoration: none; }
|
|
562
|
+
.report-link:hover { text-decoration: underline; }
|
|
182
563
|
.mcp-former { opacity: .4; }
|
|
183
564
|
.badge.mcp-former-badge { color: var(--text-dim); border-color: var(--border); background: var(--surface2); font-style: italic; }
|
|
184
565
|
|
|
@@ -191,8 +572,8 @@ export function generateDashboardHtml({
|
|
|
191
572
|
.usage-bar-error { background: linear-gradient(90deg, var(--red), var(--yellow)); }
|
|
192
573
|
.usage-bar-count { font-size: .65rem; color: var(--text-dim); min-width: 40px; text-align: right; font-variant-numeric: tabular-nums; }
|
|
193
574
|
|
|
194
|
-
.heatmap { display: grid; grid-template-rows: repeat(7,
|
|
195
|
-
.heatmap-cell {
|
|
575
|
+
.heatmap { display: grid; grid-template-rows: repeat(7, 10px); grid-auto-flow: column; grid-auto-columns: 10px; gap: 3px; overflow-x: auto; width: fit-content; max-width: 100%; }
|
|
576
|
+
.heatmap-cell { border-radius: 2px; background: var(--surface2); width: 10px; height: 10px; }
|
|
196
577
|
.heatmap-cell.l1 { background: #0e4429; }
|
|
197
578
|
.heatmap-cell.l2 { background: #006d32; }
|
|
198
579
|
.heatmap-cell.l3 { background: #26a641; }
|
|
@@ -205,6 +586,9 @@ export function generateDashboardHtml({
|
|
|
205
586
|
.heatmap-months { display: flex; font-size: .5rem; color: var(--text-dim); margin-bottom: .2rem; }
|
|
206
587
|
.heatmap-month { flex: 1; }
|
|
207
588
|
|
|
589
|
+
.chart-tooltip { position: fixed; pointer-events: none; background: var(--surface); color: var(--text); border: 1px solid var(--border); border-radius: 6px; padding: .3rem .5rem; font-size: .7rem; white-space: nowrap; z-index: 999; box-shadow: 0 2px 8px rgba(0,0,0,.25); opacity: 0; transition: opacity .1s; }
|
|
590
|
+
.chart-tooltip.visible { opacity: 1; }
|
|
591
|
+
|
|
208
592
|
.peak-hours { display: flex; align-items: flex-end; gap: 2px; height: 40px; }
|
|
209
593
|
.peak-bar { flex: 1; background: var(--purple); border-radius: 2px 2px 0 0; min-width: 4px; opacity: .7; }
|
|
210
594
|
.peak-labels { display: flex; gap: 2px; font-size: .45rem; color: var(--text-dim); }
|
|
@@ -231,6 +615,9 @@ export function generateDashboardHtml({
|
|
|
231
615
|
.quick-win { font-size: .6rem; padding: .15rem .4rem; border-radius: 3px; background: rgba(251,191,36,.08); border: 1px solid rgba(251,191,36,.2); color: var(--yellow); }
|
|
232
616
|
.matched-skills { display: flex; flex-wrap: wrap; gap: .3rem; margin-bottom: .5rem; }
|
|
233
617
|
.matched-skill { font-size: .6rem; padding: .12rem .4rem; border-radius: 3px; background: rgba(251,191,36,.08); border: 1px solid rgba(251,191,36,.2); color: var(--yellow); font-family: 'SF Mono', monospace; }
|
|
618
|
+
.similar-repos { display: flex; flex-wrap: wrap; gap: .3rem; margin-bottom: .5rem; }
|
|
619
|
+
.similar-repo { font-size: .6rem; padding: .12rem .4rem; border-radius: 3px; background: rgba(99,179,237,.08); border: 1px solid rgba(99,179,237,.2); color: var(--blue); font-family: 'SF Mono', monospace; }
|
|
620
|
+
.similar-repo small { opacity: .6; }
|
|
234
621
|
.consolidation-hint { padding: .45rem .6rem; background: var(--surface2); border-radius: 6px; margin-top: .4rem; display: flex; align-items: baseline; gap: .5rem; }
|
|
235
622
|
.consolidation-hint:first-child { margin-top: 0; }
|
|
236
623
|
.consolidation-stack { font-size: .7rem; font-weight: 600; color: var(--accent); white-space: nowrap; }
|
|
@@ -296,386 +683,168 @@ export function generateDashboardHtml({
|
|
|
296
683
|
<body>
|
|
297
684
|
<h1>claude code dashboard</h1>
|
|
298
685
|
<button id="theme-toggle" class="theme-toggle" title="Toggle light/dark mode" aria-label="Toggle theme"><span class="theme-icon"></span></button>
|
|
299
|
-
<p class="sub">generated ${timestamp} · run <code>claude-code-dashboard</code> to refresh ·
|
|
686
|
+
<p class="sub">generated ${timestamp} · run <code>claude-code-dashboard</code> to refresh · <a href="${esc(REPO_URL)}" target="_blank" rel="noopener" style="color:var(--accent);text-decoration:none">v${esc(VERSION)}</a></p>
|
|
300
687
|
|
|
301
688
|
<div class="stats">
|
|
302
|
-
<div class="stat coverage"><b>${coveragePct}%</b><span>Coverage (${configuredCount}/${totalRepos})</span></div>
|
|
303
|
-
<div class="stat" style="${avgHealth >= 70 ? "border-color:#4ade8033" : avgHealth >= 40 ? "border-color:#fbbf2433" : "border-color:#f8717133"}"><b style="color:${healthScoreColor(avgHealth)}">${avgHealth}</b><span>Avg Health</span></div>
|
|
304
|
-
<div class="stat"><b>${globalCmds.length}</b><span>Global Commands</span></div>
|
|
305
|
-
<div class="stat"><b>${globalSkills.length}</b><span>Skills</span></div>
|
|
306
|
-
<div class="stat"><b>${totalRepoCmds}</b><span>Repo Commands</span></div>
|
|
307
|
-
${mcpCount > 0 ? `<div class="stat"><b>${mcpCount}</b><span>MCP Servers</span></div>` : ""}
|
|
308
|
-
${driftCount > 0 ? `<div class="stat" style="border-color:#f8717133"><b style="color:var(--red)">${driftCount}</b><span>Drifting Repos</span></div>` : ""}
|
|
309
|
-
${ccusageData ? `<div class="stat" style="border-color:#4ade8033"><b style="color:var(--green)">$${Math.round(Number(ccusageData.totals.totalCost) || 0).toLocaleString()}</b><span>Total Spent</span></div>` : ""}
|
|
310
|
-
${ccusageData ? `<div class="stat"><b>${formatTokens(ccusageData.totals.totalTokens).replace(" tokens", "")}</b><span>Total Tokens</span></div>` : ""}
|
|
311
|
-
${usageAnalytics.heavySessions > 0 ? `<div class="stat"><b>${usageAnalytics.heavySessions}</b><span>Heavy Sessions</span></div>` : ""}
|
|
689
|
+
<div class="stat coverage" data-nav="repos" data-section="repo-grid" title="View repos"><b>${coveragePct}%</b><span>Coverage (${configuredCount}/${totalRepos})</span></div>
|
|
690
|
+
<div class="stat" data-nav="repos" data-section="repo-grid" title="View repos" style="${avgHealth >= 70 ? "border-color:#4ade8033" : avgHealth >= 40 ? "border-color:#fbbf2433" : "border-color:#f8717133"}"><b style="color:${healthScoreColor(avgHealth)}">${avgHealth}</b><span>Avg Health</span></div>
|
|
691
|
+
<div class="stat" data-nav="overview" data-section="section-commands" title="View commands"><b>${globalCmds.length}</b><span>Global Commands</span></div>
|
|
692
|
+
<div class="stat" data-nav="skills-mcp" data-section="section-skills" title="View skills"><b>${globalSkills.length}</b><span>Skills</span></div>
|
|
693
|
+
<div class="stat" data-nav="repos" data-section="repo-grid" title="View repos"><b>${totalRepoCmds}</b><span>Repo Commands</span></div>
|
|
694
|
+
${mcpCount > 0 ? `<div class="stat" data-nav="skills-mcp" data-section="section-mcp" title="View MCP servers"><b>${mcpCount}</b><span>MCP Servers</span></div>` : ""}
|
|
695
|
+
${driftCount > 0 ? `<div class="stat" data-nav="repos" data-section="repo-grid" title="View drifting repos" style="border-color:#f8717133"><b style="color:var(--red)">${driftCount}</b><span>Drifting Repos</span></div>` : ""}
|
|
696
|
+
${ccusageData ? `<div class="stat" data-nav="analytics" data-section="section-activity" title="View analytics" style="border-color:#4ade8033"><b style="color:var(--green)">$${Math.round(Number(ccusageData.totals.totalCost) || 0).toLocaleString()}</b><span>Total Spent</span></div>` : ""}
|
|
697
|
+
${ccusageData ? `<div class="stat" data-nav="analytics" data-section="section-activity" title="View analytics"><b>${formatTokens(ccusageData.totals.totalTokens).replace(" tokens", "")}</b><span>Total Tokens</span></div>` : ""}
|
|
698
|
+
${usageAnalytics.heavySessions > 0 ? `<div class="stat" data-nav="analytics" data-section="section-activity" title="View analytics"><b>${usageAnalytics.heavySessions}</b><span>Heavy Sessions</span></div>` : ""}
|
|
312
699
|
</div>
|
|
313
700
|
|
|
314
|
-
<
|
|
315
|
-
<
|
|
316
|
-
<
|
|
317
|
-
|
|
318
|
-
</
|
|
319
|
-
<
|
|
320
|
-
|
|
321
|
-
|
|
701
|
+
<nav class="tab-nav">
|
|
702
|
+
<button class="tab-btn active" data-tab="overview">Overview</button>
|
|
703
|
+
<button class="tab-btn" data-tab="skills-mcp">Skills & MCP</button>
|
|
704
|
+
<button class="tab-btn" data-tab="analytics">Analytics</button>
|
|
705
|
+
<button class="tab-btn" data-tab="repos">Repos</button>
|
|
706
|
+
<button class="tab-btn" data-tab="reference">Reference</button>
|
|
707
|
+
</nav>
|
|
708
|
+
|
|
709
|
+
<div class="tab-content active" id="tab-overview">
|
|
710
|
+
<div class="top-grid">
|
|
711
|
+
<div class="card" id="section-commands" style="margin-bottom:0">
|
|
712
|
+
<h2>Global Commands <span class="n">${globalCmds.length}</span></h2>
|
|
713
|
+
${globalCmds.map((c) => renderCmd(c)).join("\n ")}
|
|
714
|
+
</div>
|
|
715
|
+
<div class="card" style="margin-bottom:0">
|
|
716
|
+
<h2>Global Rules <span class="n">${globalRules.length}</span></h2>
|
|
717
|
+
${globalRules.map((r) => renderRule(r)).join("\n ")}
|
|
718
|
+
</div>
|
|
719
|
+
</div>
|
|
720
|
+
${
|
|
721
|
+
insights && insights.length > 0
|
|
722
|
+
? `<div class="card insight-card">
|
|
723
|
+
<h2>Insights <span class="n">${insights.length}</span></h2>
|
|
724
|
+
${insights
|
|
725
|
+
.map(
|
|
726
|
+
(i) =>
|
|
727
|
+
`<div class="insight-row ${esc(i.type)}">
|
|
728
|
+
<span class="insight-icon">${i.type === "warning" ? "⚠" : i.type === "tip" ? "✨" : i.type === "promote" ? "↑" : "ⓘ"}</span>
|
|
729
|
+
<div class="insight-body">
|
|
730
|
+
<div class="insight-title">${esc(i.title)}</div>
|
|
731
|
+
${i.detail ? `<div class="insight-detail">${esc(i.detail)}</div>` : ""}
|
|
732
|
+
${i.action ? `<div class="insight-action">${esc(i.action)}</div>` : ""}
|
|
733
|
+
</div>
|
|
734
|
+
</div>`,
|
|
735
|
+
)
|
|
736
|
+
.join("\n ")}
|
|
737
|
+
</div>`
|
|
738
|
+
: ""
|
|
739
|
+
}
|
|
740
|
+
${chainsHtml}
|
|
741
|
+
${consolidationHtml}
|
|
322
742
|
</div>
|
|
323
|
-
${
|
|
324
|
-
globalSkills.length
|
|
325
|
-
? (() => {
|
|
326
|
-
const groups = groupSkillsByCategory(globalSkills);
|
|
327
|
-
const categoryHtml = Object.entries(groups)
|
|
328
|
-
.map(
|
|
329
|
-
([cat, skills], idx) =>
|
|
330
|
-
`<details class="skill-category"${idx === 0 ? " open" : ""}>` +
|
|
331
|
-
`<summary class="skill-category-label">${esc(cat)} <span class="cat-n">${skills.length}</span></summary>` +
|
|
332
|
-
skills.map((s) => renderSkill(s)).join("\n ") +
|
|
333
|
-
`</details>`,
|
|
334
|
-
)
|
|
335
|
-
.join("\n ");
|
|
336
|
-
return `<div class="card full">
|
|
337
|
-
<h2>Skills <span class="n">${globalSkills.length}</span></h2>
|
|
338
|
-
${categoryHtml}
|
|
339
|
-
</div>`;
|
|
340
|
-
})()
|
|
341
|
-
: ""
|
|
342
|
-
}
|
|
343
|
-
${
|
|
344
|
-
mcpSummary.length
|
|
345
|
-
? (() => {
|
|
346
|
-
const rows = mcpSummary
|
|
347
|
-
.map((s) => {
|
|
348
|
-
const disabledClass = s.disabledIn > 0 ? " mcp-disabled" : "";
|
|
349
|
-
const disabledHint =
|
|
350
|
-
s.disabledIn > 0
|
|
351
|
-
? `<span class="mcp-disabled-hint">disabled in ${s.disabledIn} project${s.disabledIn > 1 ? "s" : ""}</span>`
|
|
352
|
-
: "";
|
|
353
|
-
const scopeBadge = s.userLevel
|
|
354
|
-
? `<span class="badge mcp-global">global</span>`
|
|
355
|
-
: `<span class="badge mcp-project">project</span>`;
|
|
356
|
-
const typeBadge = `<span class="badge mcp-type">${esc(s.type)}</span>`;
|
|
357
|
-
const projects = s.projects.length
|
|
358
|
-
? `<span class="mcp-projects">${s.projects.map((p) => esc(p)).join(", ")}</span>`
|
|
359
|
-
: "";
|
|
360
|
-
return `<div class="mcp-row${disabledClass}"><span class="mcp-name">${esc(s.name)}</span>${scopeBadge}${typeBadge}${disabledHint}${projects}</div>`;
|
|
361
|
-
})
|
|
362
|
-
.join("\n ");
|
|
363
|
-
const promoteHtml = mcpPromotions.length
|
|
364
|
-
? mcpPromotions
|
|
365
|
-
.map(
|
|
366
|
-
(p) =>
|
|
367
|
-
`<div class="mcp-promote"><span class="mcp-name">${esc(p.name)}</span> installed in ${p.projects.length} projects → add to <code>~/.claude/mcp_config.json</code></div>`,
|
|
368
|
-
)
|
|
369
|
-
.join("\n ")
|
|
370
|
-
: "";
|
|
371
|
-
const formerHtml = formerMcpServers.length
|
|
372
|
-
? `<div class="label" style="margin-top:.75rem">Formerly Installed</div>
|
|
373
|
-
${formerMcpServers.map((name) => `<div class="mcp-row mcp-former"><span class="mcp-name">${esc(name)}</span><span class="badge mcp-former-badge">removed</span></div>`).join("\n ")}`
|
|
374
|
-
: "";
|
|
375
|
-
return `<div class="card full">
|
|
376
|
-
<h2>MCP Servers <span class="n">${mcpSummary.length}</span></h2>
|
|
377
|
-
${rows}
|
|
378
|
-
${promoteHtml}
|
|
379
|
-
${formerHtml}
|
|
380
|
-
</div>`;
|
|
381
|
-
})()
|
|
382
|
-
: ""
|
|
383
|
-
}
|
|
384
|
-
${
|
|
385
|
-
usageAnalytics.topTools.length
|
|
386
|
-
? (() => {
|
|
387
|
-
const maxCount = usageAnalytics.topTools[0].count;
|
|
388
|
-
const rows = usageAnalytics.topTools
|
|
389
|
-
.map((t) => {
|
|
390
|
-
const pct = maxCount > 0 ? Math.round((t.count / maxCount) * 100) : 0;
|
|
391
|
-
return `<div class="usage-bar-row"><span class="usage-bar-label">${esc(t.name)}</span><div class="usage-bar-track"><div class="usage-bar-fill usage-bar-tool" style="width:${pct}%"></div></div><span class="usage-bar-count">${t.count.toLocaleString()}</span></div>`;
|
|
392
|
-
})
|
|
393
|
-
.join("\n ");
|
|
394
|
-
return `<div class="card">
|
|
395
|
-
<h2>Top Tools Used <span class="n">${usageAnalytics.topTools.length}</span></h2>
|
|
396
|
-
${rows}
|
|
397
|
-
</div>`;
|
|
398
|
-
})()
|
|
399
|
-
: ""
|
|
400
|
-
}
|
|
401
|
-
${
|
|
402
|
-
usageAnalytics.topLanguages.length
|
|
403
|
-
? (() => {
|
|
404
|
-
const maxCount = usageAnalytics.topLanguages[0].count;
|
|
405
|
-
const rows = usageAnalytics.topLanguages
|
|
406
|
-
.map((l) => {
|
|
407
|
-
const pct = maxCount > 0 ? Math.round((l.count / maxCount) * 100) : 0;
|
|
408
|
-
return `<div class="usage-bar-row"><span class="usage-bar-label">${esc(l.name)}</span><div class="usage-bar-track"><div class="usage-bar-fill usage-bar-lang" style="width:${pct}%"></div></div><span class="usage-bar-count">${l.count.toLocaleString()}</span></div>`;
|
|
409
|
-
})
|
|
410
|
-
.join("\n ");
|
|
411
|
-
return `<div class="card">
|
|
412
|
-
<h2>Languages <span class="n">${usageAnalytics.topLanguages.length}</span></h2>
|
|
413
|
-
${rows}
|
|
414
|
-
</div>`;
|
|
415
|
-
})()
|
|
416
|
-
: ""
|
|
417
|
-
}
|
|
418
|
-
${
|
|
419
|
-
usageAnalytics.errorCategories.length
|
|
420
|
-
? (() => {
|
|
421
|
-
const maxCount = usageAnalytics.errorCategories[0].count;
|
|
422
|
-
const rows = usageAnalytics.errorCategories
|
|
423
|
-
.map((e) => {
|
|
424
|
-
const pct = maxCount > 0 ? Math.round((e.count / maxCount) * 100) : 0;
|
|
425
|
-
return `<div class="usage-bar-row"><span class="usage-bar-label">${esc(e.name)}</span><div class="usage-bar-track"><div class="usage-bar-fill usage-bar-error" style="width:${pct}%"></div></div><span class="usage-bar-count">${e.count.toLocaleString()}</span></div>`;
|
|
426
|
-
})
|
|
427
|
-
.join("\n ");
|
|
428
|
-
return `<div class="card">
|
|
429
|
-
<h2>Top Errors <span class="n">${usageAnalytics.errorCategories.length}</span></h2>
|
|
430
|
-
${rows}
|
|
431
|
-
</div>`;
|
|
432
|
-
})()
|
|
433
|
-
: ""
|
|
434
|
-
}
|
|
435
|
-
${(() => {
|
|
436
|
-
const dailyActivity = statsCache.dailyActivity || [];
|
|
437
|
-
const hourCounts = statsCache.hourCounts || {};
|
|
438
|
-
const modelUsage = statsCache.modelUsage || {};
|
|
439
|
-
const hasActivity = dailyActivity.length > 0;
|
|
440
|
-
const hasHours = Object.keys(hourCounts).length > 0;
|
|
441
|
-
const hasModels = Object.keys(modelUsage).length > 0;
|
|
442
|
-
|
|
443
|
-
if (!hasActivity && !hasHours && !hasModels && !ccusageData) return "";
|
|
444
|
-
|
|
445
|
-
let content = "";
|
|
446
|
-
|
|
447
|
-
// Activity heatmap
|
|
448
|
-
if (hasActivity) {
|
|
449
|
-
const dateMap = new Map(dailyActivity.map((d) => [d.date, d.messageCount || 0]));
|
|
450
|
-
const dates = dailyActivity.map((d) => d.date).sort();
|
|
451
|
-
const lastDate = new Date(dates[dates.length - 1]);
|
|
452
|
-
const earliest = new Date(lastDate);
|
|
453
|
-
earliest.setDate(earliest.getDate() - 364);
|
|
454
|
-
const firstDate = new Date(dates[0]) < earliest ? earliest : new Date(dates[0]);
|
|
455
|
-
|
|
456
|
-
const nonZero = dailyActivity
|
|
457
|
-
.map((d) => d.messageCount || 0)
|
|
458
|
-
.filter((n) => n > 0)
|
|
459
|
-
.sort((a, b) => a - b);
|
|
460
|
-
const q1 = nonZero[Math.floor(nonZero.length * 0.25)] || 1;
|
|
461
|
-
const q2 = nonZero[Math.floor(nonZero.length * 0.5)] || 2;
|
|
462
|
-
const q3 = nonZero[Math.floor(nonZero.length * 0.75)] || 3;
|
|
463
|
-
|
|
464
|
-
function level(count) {
|
|
465
|
-
if (count === 0) return "";
|
|
466
|
-
if (count <= q1) return " l1";
|
|
467
|
-
if (count <= q2) return " l2";
|
|
468
|
-
if (count <= q3) return " l3";
|
|
469
|
-
return " l4";
|
|
470
|
-
}
|
|
471
743
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
let lastMonth = -1;
|
|
477
|
-
const cursor1 = new Date(start);
|
|
478
|
-
let weekIdx = 0;
|
|
479
|
-
while (cursor1 <= lastDate) {
|
|
480
|
-
if (cursor1.getUTCDay() === 0) {
|
|
481
|
-
const m = cursor1.getUTCMonth();
|
|
482
|
-
if (m !== lastMonth) {
|
|
483
|
-
months.push({
|
|
484
|
-
name: cursor1.toLocaleString("en", { month: "short", timeZone: "UTC" }),
|
|
485
|
-
week: weekIdx,
|
|
486
|
-
});
|
|
487
|
-
lastMonth = m;
|
|
488
|
-
}
|
|
489
|
-
weekIdx++;
|
|
490
|
-
}
|
|
491
|
-
cursor1.setUTCDate(cursor1.getUTCDate() + 1);
|
|
492
|
-
}
|
|
493
|
-
const totalWeeks = weekIdx;
|
|
494
|
-
const monthLabels = months
|
|
495
|
-
.map((m) => {
|
|
496
|
-
const left = totalWeeks > 0 ? Math.round((m.week / totalWeeks) * 100) : 0;
|
|
497
|
-
return `<span class="heatmap-month" style="position:absolute;left:${left}%">${m.name}</span>`;
|
|
498
|
-
})
|
|
499
|
-
.join("");
|
|
500
|
-
|
|
501
|
-
let cells = "";
|
|
502
|
-
const cursor2 = new Date(start);
|
|
503
|
-
while (cursor2 <= lastDate) {
|
|
504
|
-
const key = cursor2.toISOString().slice(0, 10);
|
|
505
|
-
const count = dateMap.get(key) || 0;
|
|
506
|
-
cells += `<div class="heatmap-cell${level(count)}" title="${esc(key)}: ${count} messages"></div>`;
|
|
507
|
-
cursor2.setUTCDate(cursor2.getUTCDate() + 1);
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
content += `<div class="label">Activity</div>
|
|
511
|
-
<div style="position:relative;margin-bottom:.5rem">
|
|
512
|
-
<div class="heatmap-months" style="position:relative;height:.8rem">${monthLabels}</div>
|
|
513
|
-
<div style="overflow-x:auto"><div class="heatmap">${cells}</div></div>
|
|
514
|
-
</div>`;
|
|
515
|
-
}
|
|
744
|
+
<div class="tab-content" id="tab-skills-mcp">
|
|
745
|
+
${skillsHtml}
|
|
746
|
+
${mcpHtml}
|
|
747
|
+
</div>
|
|
516
748
|
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
749
|
+
<div class="tab-content" id="tab-analytics">
|
|
750
|
+
${
|
|
751
|
+
insightsReport
|
|
752
|
+
? `<div class="card report-card" id="section-insights-report">
|
|
753
|
+
<h2>Claude Code Insights</h2>
|
|
754
|
+
${insightsReport.subtitle ? `<div class="report-subtitle">${esc(insightsReport.subtitle)}</div>` : ""}
|
|
755
|
+
${
|
|
756
|
+
insightsReport.stats.length > 0
|
|
757
|
+
? `<div class="report-stats">${insightsReport.stats
|
|
758
|
+
.map((s) => {
|
|
759
|
+
if (s.isDiff) {
|
|
760
|
+
const parts = s.value.match(/^([+-][^/]+)\/([-+].+)$/);
|
|
761
|
+
if (parts) {
|
|
762
|
+
return `<div class="report-stat"><b><span style="color:var(--green)">${esc(parts[1])}</span><span style="color:var(--text-dim)">/</span><span style="color:var(--red)">${esc(parts[2])}</span></b><span>${esc(s.label)}</span></div>`;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
return `<div class="report-stat"><b>${esc(s.value)}</b><span>${esc(s.label)}</span></div>`;
|
|
766
|
+
})
|
|
767
|
+
.join("")}</div>`
|
|
768
|
+
: ""
|
|
527
769
|
}
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
// Model usage
|
|
534
|
-
if (ccusageData) {
|
|
535
|
-
const modelCosts = {};
|
|
536
|
-
for (const day of ccusageData.daily) {
|
|
537
|
-
for (const mb of day.modelBreakdowns || []) {
|
|
538
|
-
if (!modelCosts[mb.modelName]) modelCosts[mb.modelName] = { cost: 0, tokens: 0 };
|
|
539
|
-
modelCosts[mb.modelName].cost += mb.cost || 0;
|
|
540
|
-
modelCosts[mb.modelName].tokens +=
|
|
541
|
-
(mb.inputTokens || 0) +
|
|
542
|
-
(mb.outputTokens || 0) +
|
|
543
|
-
(mb.cacheCreationTokens || 0) +
|
|
544
|
-
(mb.cacheReadTokens || 0);
|
|
545
|
-
}
|
|
770
|
+
${
|
|
771
|
+
insightsReport.glance.length > 0
|
|
772
|
+
? `<div class="report-glance">${insightsReport.glance.map((g) => `<div class="report-glance-item"><strong>${esc(g.label)}:</strong> ${esc(g.text)}</div>`).join("")}</div>`
|
|
773
|
+
: ""
|
|
546
774
|
}
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
.join("\n ");
|
|
554
|
-
|
|
555
|
-
const t = ccusageData.totals;
|
|
556
|
-
const breakdownHtml = `<div class="token-breakdown">
|
|
557
|
-
<div class="tb-row"><span class="tb-label">Cache Read</span><span class="tb-val">${formatTokens(t.cacheReadTokens)}</span></div>
|
|
558
|
-
<div class="tb-row"><span class="tb-label">Cache Creation</span><span class="tb-val">${formatTokens(t.cacheCreationTokens)}</span></div>
|
|
559
|
-
<div class="tb-row"><span class="tb-label">Output</span><span class="tb-val">${formatTokens(t.outputTokens)}</span></div>
|
|
560
|
-
<div class="tb-row"><span class="tb-label">Input</span><span class="tb-val">${formatTokens(t.inputTokens)}</span></div>
|
|
561
|
-
</div>`;
|
|
562
|
-
|
|
563
|
-
content += `<div class="label" style="margin-top:.75rem">Model Usage (via ccusage)</div>
|
|
564
|
-
${modelRows}
|
|
565
|
-
<div class="label" style="margin-top:.75rem">Token Breakdown</div>
|
|
566
|
-
${breakdownHtml}`;
|
|
567
|
-
} else if (hasModels) {
|
|
568
|
-
const modelRows = Object.entries(modelUsage)
|
|
569
|
-
.map(([name, usage]) => {
|
|
570
|
-
const total = (usage.inputTokens || 0) + (usage.outputTokens || 0);
|
|
571
|
-
return { name, total };
|
|
572
|
-
})
|
|
573
|
-
.sort((a, b) => b.total - a.total)
|
|
574
|
-
.map(
|
|
575
|
-
(m) =>
|
|
576
|
-
`<div class="model-row"><span class="model-name">${esc(m.name)}</span><span class="model-tokens">${formatTokens(m.total)}</span></div>`,
|
|
577
|
-
)
|
|
578
|
-
.join("\n ");
|
|
579
|
-
content += `<div class="label" style="margin-top:.75rem">Model Usage (partial — install ccusage for full data)</div>
|
|
580
|
-
${modelRows}`;
|
|
775
|
+
<a class="report-link" href="file://${encodeURI(insightsReport.filePath)}" target="_blank">View full insights report →</a>
|
|
776
|
+
</div>`
|
|
777
|
+
: `<div class="card report-card">
|
|
778
|
+
<h2>Claude Code Insights</h2>
|
|
779
|
+
<div class="report-glance"><div class="report-glance-item">No insights report found. Run <code>/insights</code> in Claude Code to generate a personalized report with usage patterns, friction points, and feature suggestions.</div></div>
|
|
780
|
+
</div>`
|
|
581
781
|
}
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
${content}
|
|
586
|
-
</div>`;
|
|
587
|
-
})()}
|
|
588
|
-
<details class="card full">
|
|
589
|
-
<summary style="cursor:pointer;list-style:none"><h2 style="margin:0">Quick Reference</h2></summary>
|
|
590
|
-
<div style="margin-top:.75rem">
|
|
591
|
-
<div class="ref-grid">
|
|
592
|
-
<div class="ref-col">
|
|
593
|
-
<div class="label">Essential Commands</div>
|
|
594
|
-
${QUICK_REFERENCE.essentialCommands.map((c) => `<div class="ref-row"><code class="ref-cmd">${esc(c.cmd)}</code><span class="ref-desc">${esc(c.desc)}</span></div>`).join("\n ")}
|
|
595
|
-
</div>
|
|
596
|
-
<div class="ref-col">
|
|
597
|
-
<div class="label">Built-in Tools</div>
|
|
598
|
-
${QUICK_REFERENCE.tools.map((t) => `<div class="ref-row"><code class="ref-cmd">${esc(t.name)}</code><span class="ref-desc">${esc(t.desc)}</span></div>`).join("\n ")}
|
|
599
|
-
<div class="label" style="margin-top:.75rem">Keyboard Shortcuts</div>
|
|
600
|
-
${QUICK_REFERENCE.shortcuts.map((s) => `<div class="ref-row"><kbd class="ref-key">${esc(s.keys)}</kbd><span class="ref-desc">${esc(s.desc)}</span></div>`).join("\n ")}
|
|
601
|
-
</div>
|
|
602
|
-
</div>
|
|
782
|
+
<div class="top-grid">
|
|
783
|
+
${toolsHtml || ""}
|
|
784
|
+
${langsHtml || ""}
|
|
603
785
|
</div>
|
|
604
|
-
|
|
605
|
-
${
|
|
606
|
-
chains.length
|
|
607
|
-
? `<div class="card full">
|
|
608
|
-
<h2>Dependency Chains</h2>
|
|
609
|
-
${chains.map((c) => `<div class="chain">${c.nodes.map((n, i) => `<span class="chain-node">${esc(n.trim())}</span>${i < c.nodes.length - 1 ? `<span class="chain-arrow">${c.arrow}</span>` : ""}`).join("")}</div>`).join("\n ")}
|
|
610
|
-
</div>`
|
|
611
|
-
: ""
|
|
612
|
-
}
|
|
786
|
+
${errorsHtml}
|
|
787
|
+
${activityHtml}
|
|
613
788
|
</div>
|
|
614
789
|
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
<
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
</div>
|
|
628
|
-
<div class="
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
<option value="stack">Tech Stack</option>
|
|
633
|
-
<option value="parent">Parent Directory</option>
|
|
634
|
-
</select>
|
|
790
|
+
<div class="tab-content" id="tab-repos">
|
|
791
|
+
<div class="search-bar">
|
|
792
|
+
<input type="text" id="search" placeholder="search repos..." autocomplete="off">
|
|
793
|
+
<span class="search-hint"><kbd>/</kbd></span>
|
|
794
|
+
</div>
|
|
795
|
+
<div class="group-controls">
|
|
796
|
+
<label class="group-label">Group by:</label>
|
|
797
|
+
<select id="group-by" class="group-select">
|
|
798
|
+
<option value="none">None</option>
|
|
799
|
+
<option value="stack">Tech Stack</option>
|
|
800
|
+
<option value="parent">Parent Directory</option>
|
|
801
|
+
</select>
|
|
802
|
+
</div>
|
|
803
|
+
<div class="repo-grid" id="repo-grid">
|
|
804
|
+
${configured.map((r) => renderRepoCard(r)).join("\n")}
|
|
805
|
+
</div>
|
|
806
|
+
${unconfiguredHtml}
|
|
635
807
|
</div>
|
|
636
808
|
|
|
637
|
-
<div class="
|
|
638
|
-
${
|
|
809
|
+
<div class="tab-content" id="tab-reference">
|
|
810
|
+
${referenceHtml}
|
|
639
811
|
</div>
|
|
640
812
|
|
|
641
|
-
${
|
|
642
|
-
unconfigured.length
|
|
643
|
-
? `<details class="card" style="margin-bottom:1.25rem">
|
|
644
|
-
<summary style="cursor:pointer;list-style:none"><h2 style="margin:0">Unconfigured Repos <span class="n">${unconfiguredCount}</span></h2></summary>
|
|
645
|
-
<div style="margin-top:.75rem">
|
|
646
|
-
<div class="unconfigured-grid">
|
|
647
|
-
${unconfigured
|
|
648
|
-
.map((r) => {
|
|
649
|
-
const stackTag =
|
|
650
|
-
r.techStack && r.techStack.length
|
|
651
|
-
? `<span class="stack-tag">${esc(r.techStack.join(", "))}</span>`
|
|
652
|
-
: "";
|
|
653
|
-
const suggestionsHtml =
|
|
654
|
-
r.suggestions && r.suggestions.length
|
|
655
|
-
? `<div class="suggestion-hints">${r.suggestions.map((s) => `<span class="suggestion-hint">${esc(s)}</span>`).join("")}</div>`
|
|
656
|
-
: "";
|
|
657
|
-
return `<div class="unconfigured-item">${esc(r.name)}${stackTag}<span class="upath">${esc(r.shortPath)}</span>${suggestionsHtml}</div>`;
|
|
658
|
-
})
|
|
659
|
-
.join("\n ")}
|
|
660
|
-
</div>
|
|
661
|
-
</div>
|
|
662
|
-
</details>`
|
|
663
|
-
: ""
|
|
664
|
-
}
|
|
665
|
-
|
|
666
813
|
<div class="ts">found ${totalRepos} repos · ${configuredCount} configured · ${unconfiguredCount} unconfigured · scanned ${scanScope} · ${timestamp}</div>
|
|
667
814
|
|
|
815
|
+
<div class="chart-tooltip" id="chart-tooltip"></div>
|
|
668
816
|
<script>
|
|
669
|
-
|
|
670
|
-
|
|
817
|
+
function switchTab(tabName) {
|
|
818
|
+
document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('active'); });
|
|
819
|
+
document.querySelectorAll('.tab-content').forEach(function(c) { c.classList.remove('active'); });
|
|
820
|
+
var btn = document.querySelector('.tab-btn[data-tab="' + tabName + '"]');
|
|
821
|
+
if (btn) btn.classList.add('active');
|
|
822
|
+
var content = document.getElementById('tab-' + tabName);
|
|
823
|
+
if (content) content.classList.add('active');
|
|
824
|
+
}
|
|
825
|
+
document.querySelectorAll('.tab-btn').forEach(function(btn) {
|
|
826
|
+
btn.addEventListener('click', function() { switchTab(btn.dataset.tab); });
|
|
827
|
+
});
|
|
828
|
+
document.querySelectorAll('.stat[data-nav]').forEach(function(stat) {
|
|
829
|
+
stat.addEventListener('click', function() {
|
|
830
|
+
switchTab(stat.dataset.nav);
|
|
831
|
+
if (stat.dataset.section) {
|
|
832
|
+
var el = document.getElementById(stat.dataset.section);
|
|
833
|
+
if (el) setTimeout(function() { el.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, 50);
|
|
834
|
+
}
|
|
835
|
+
});
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
var input = document.getElementById('search');
|
|
839
|
+
var hint = document.querySelector('.search-hint');
|
|
671
840
|
|
|
672
841
|
input.addEventListener('input', function(e) {
|
|
673
|
-
|
|
842
|
+
var q = e.target.value.toLowerCase();
|
|
674
843
|
hint.style.display = q ? 'none' : '';
|
|
675
844
|
document.querySelectorAll('.repo-card').forEach(function(card) {
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
845
|
+
var name = card.dataset.name.toLowerCase();
|
|
846
|
+
var path = (card.dataset.path || '').toLowerCase();
|
|
847
|
+
var text = card.textContent.toLowerCase();
|
|
679
848
|
card.style.display = (q === '' || name.includes(q) || path.includes(q) || text.includes(q)) ? '' : 'none';
|
|
680
849
|
});
|
|
681
850
|
});
|
|
@@ -683,6 +852,11 @@ input.addEventListener('input', function(e) {
|
|
|
683
852
|
document.addEventListener('keydown', function(e) {
|
|
684
853
|
if (e.key === '/' && document.activeElement !== input) {
|
|
685
854
|
e.preventDefault();
|
|
855
|
+
// Switch to repos tab first
|
|
856
|
+
document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('active'); });
|
|
857
|
+
document.querySelectorAll('.tab-content').forEach(function(c) { c.classList.remove('active'); });
|
|
858
|
+
document.querySelector('[data-tab="repos"]').classList.add('active');
|
|
859
|
+
document.getElementById('tab-repos').classList.add('active');
|
|
686
860
|
input.focus();
|
|
687
861
|
}
|
|
688
862
|
if (e.key === 'Escape' && document.activeElement === input) {
|
|
@@ -738,6 +912,34 @@ groupSelect.addEventListener('change', function() {
|
|
|
738
912
|
groups[key].forEach(function(card) { grid.appendChild(card); });
|
|
739
913
|
});
|
|
740
914
|
});
|
|
915
|
+
|
|
916
|
+
// Custom tooltip for heatmap cells and peak bars
|
|
917
|
+
var tip = document.getElementById('chart-tooltip');
|
|
918
|
+
document.addEventListener('mouseover', function(e) {
|
|
919
|
+
var t = e.target.closest('.heatmap-cell, .peak-bar');
|
|
920
|
+
if (t && t.title) {
|
|
921
|
+
tip.textContent = t.title;
|
|
922
|
+
tip.classList.add('visible');
|
|
923
|
+
t.dataset.tip = t.title;
|
|
924
|
+
t.removeAttribute('title');
|
|
925
|
+
}
|
|
926
|
+
});
|
|
927
|
+
document.addEventListener('mousemove', function(e) {
|
|
928
|
+
if (tip.classList.contains('visible')) {
|
|
929
|
+
tip.style.left = (e.clientX + 12) + 'px';
|
|
930
|
+
tip.style.top = (e.clientY - 28) + 'px';
|
|
931
|
+
}
|
|
932
|
+
});
|
|
933
|
+
document.addEventListener('mouseout', function(e) {
|
|
934
|
+
var t = e.target.closest('.heatmap-cell, .peak-bar');
|
|
935
|
+
if (t && t.dataset.tip) {
|
|
936
|
+
t.title = t.dataset.tip;
|
|
937
|
+
delete t.dataset.tip;
|
|
938
|
+
}
|
|
939
|
+
if (!e.relatedTarget || !e.relatedTarget.closest || !e.relatedTarget.closest('.heatmap-cell, .peak-bar')) {
|
|
940
|
+
tip.classList.remove('visible');
|
|
941
|
+
}
|
|
942
|
+
});
|
|
741
943
|
</script>
|
|
742
944
|
</body>
|
|
743
945
|
</html>`;
|