claude-code-watch 0.2.0 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-watch",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Web-based real-time monitor for Claude Code.",
5
5
  "main": "./src/server/server.js",
6
6
  "bin": {
@@ -203,9 +203,8 @@ body {
203
203
  #tokens-page { flex: 1; display: flex; flex-direction: column; overflow-y: auto; padding: 20px 24px; gap: 16px; background: var(--bg); }
204
204
  .tp-refresh-bar { display: flex; align-items: center; justify-content: flex-end; gap: 12px; padding-bottom: 4px; }
205
205
  .tp-refresh-info { font-size: 11px; color: var(--dim); }
206
- .tp-top { display: flex; gap: 16px; }
207
- .tp-left { width: 260px; flex-shrink: 0; display: flex; flex-direction: column; gap: 12px; }
208
- .tp-right { flex: 1; display: flex; flex-direction: column; gap: 12px; }
206
+ .tp-top { display: grid; grid-template-columns: 1fr 1.5fr 1fr; gap: 16px; }
207
+ .tp-row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 16px; }
209
208
  .tp-box { background: var(--bg2); border: 1px solid var(--border); border-radius: 8px; padding: 14px 16px; }
210
209
  .tp-total-label { font-size: 11px; color: var(--dim); text-transform: uppercase; margin-bottom: 2px; }
211
210
  .tp-total-value { font-size: 22px; font-weight: 700; color: var(--white); font-family: monospace; }
@@ -267,32 +266,34 @@ body {
267
266
  .tp-chart-title { font-size: 12px; color: var(--dim); font-weight: 600; text-transform: uppercase; margin-bottom: 10px; }
268
267
 
269
268
  /* ── Stacked bar chart (weekly / monthly) ── */
270
- .tp-stack-bars { display: flex; align-items: flex-end; gap: 4px; height: 160px; position: relative; padding-bottom: 22px; }
271
- .tp-stack-wrap { flex: 1; display: flex; flex-direction: column; justify-content: flex-end; position: relative; min-width: 0; height: 100%; }
272
- .tp-stack-seg { transition: all 0.15s; cursor: pointer; min-height: 1px; }
269
+ .tp-stack-bars { display: flex; align-items: stretch; gap: 4px; height: 240px; position: relative; }
270
+ .tp-stack-wrap { flex: 1; display: flex; flex-direction: column; min-width: 0; }
271
+ .tp-stack-bar-area { flex: 1; display: flex; align-items: stretch; }
272
+ .tp-stack-seg { transition: all 0.15s; cursor: pointer; min-height: 1px; width: 100%; }
273
273
  .tp-stack-seg:hover { filter: brightness(1.3); }
274
274
  .tp-stack-bar-group:hover::after { content: attr(data-tip); position: absolute; bottom: calc(100% + 4px); left: 50%; transform: translateX(-50%); background: var(--bg2); color: var(--white); padding: 3px 10px; border-radius: 4px; font-size: 11px; white-space: nowrap; z-index: 100; border: 1px solid var(--border); pointer-events: none; font-family: monospace; }
275
- .tp-stack-bar-group { display: flex; flex-direction: column; justify-content: flex-end; position: relative; height: 100%; cursor: pointer; }
276
- .tp-stack-label { font-size: 9px; color: var(--dim); text-align: center; position: absolute; bottom: 0; width: 100%; white-space: nowrap; overflow: hidden; }
275
+ .tp-stack-bar-group { display: flex; flex-direction: column; justify-content: flex-end; position: relative; width: 100%; cursor: pointer; }
276
+ .tp-stack-label { font-size: 11px; color: var(--text); text-align: center; padding-top: 6px; white-space: nowrap; }
277
277
  .tp-stack-grid { position: absolute; inset: 0 0 22px 0; display: flex; flex-direction: column; justify-content: space-between; pointer-events: none; }
278
278
  .tp-stack-grid-line { width: 100%; border-top: 1px dashed var(--border); opacity: 0.3; }
279
279
 
280
280
  /* ── Hourly distribution chart ── */
281
- .tp-hourly-bars { display: flex; align-items: flex-end; gap: 2px; height: 140px; position: relative; padding-bottom: 22px; }
282
- .tp-hourly-bar-wrap { flex: 1; display: flex; flex-direction: column; justify-content: flex-end; position: relative; min-width: 0; height: 100%; }
283
- .tp-hourly-bar { border-radius: 3px 3px 0 0; transition: all 0.15s; cursor: pointer; min-height: 2px; }
281
+ .tp-hourly-bars { display: flex; align-items: stretch; gap: 2px; height: 200px; position: relative; }
282
+ .tp-hourly-bar-wrap { flex: 1; display: flex; flex-direction: column; min-width: 0; }
283
+ .tp-hourly-bar-area { flex: 1; display: flex; flex-direction: column; align-items: stretch; justify-content: flex-end; }
284
+ .tp-hourly-bar { border-radius: 3px 3px 0 0; transition: all 0.15s; cursor: pointer; min-height: 2px; width: 100%; }
284
285
  .tp-hourly-bar:hover { filter: brightness(1.3); }
285
286
  .tp-hourly-bar:hover::after { content: attr(data-tip); position: absolute; bottom: calc(100% + 4px); left: 50%; transform: translateX(-50%); background: var(--bg2); color: var(--white); padding: 2px 8px; border-radius: 4px; font-size: 10px; white-space: nowrap; z-index: 100; border: 1px solid var(--border); pointer-events: none; font-family: monospace; }
286
- .tp-hourly-label { font-size: 8px; color: var(--dim); text-align: center; position: absolute; bottom: 0; width: 100%; }
287
+ .tp-hourly-label { font-size: 11px; color: var(--text); text-align: center; padding-top: 6px; }
287
288
  .tp-hourly-grid { position: absolute; inset: 0 0 22px 0; display: flex; flex-direction: column; justify-content: space-between; pointer-events: none; }
288
289
 
289
290
  /* ── Model proportion doughnut ── */
290
291
  .tp-pie-wrap { display: flex; align-items: center; gap: 16px; }
291
- .tp-pie-ring { position: relative; width: 180px; height: 180px; border-radius: 50%; flex-shrink: 0; }
292
- .tp-pie-hole { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 90px; height: 90px; border-radius: 50%; background: var(--bg2); display: flex; align-items: center; justify-content: center; flex-direction: column; }
292
+ .tp-pie-ring { position: relative; width: 220px; height: 220px; border-radius: 50%; flex-shrink: 0; }
293
+ .tp-pie-hole { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 110px; height: 110px; border-radius: 50%; background: var(--bg2); display: flex; align-items: center; justify-content: center; flex-direction: column; }
293
294
  .tp-pie-hole-v { font-size: 14px; font-weight: 700; color: var(--white); font-family: monospace; }
294
295
  .tp-pie-hole-l { font-size: 9px; color: var(--dim); }
295
- .tp-pie-legend { display: flex; flex-direction: column; gap: 4px; overflow-y: auto; max-height: 180px; }
296
+ .tp-pie-legend { display: flex; flex-direction: column; gap: 4px; overflow-y: auto; max-height: 220px; }
296
297
  .tp-pie-legend-item { display: flex; align-items: center; gap: 6px; font-size: 11px; }
297
298
  .tp-pie-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
298
299
  .tp-pie-name { color: var(--text); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -348,6 +349,31 @@ body {
348
349
  font-size: 11px; flex-shrink: 0; flex-wrap: wrap;
349
350
  }
350
351
  #footer .sep { color: var(--dim); margin: 0 2px; }
352
+ .version-update-badge {
353
+ display: inline-flex; align-items: center; gap: 4px;
354
+ color: var(--green, #3fb950); font-weight: 500;
355
+ text-decoration: none; padding: 1px 6px;
356
+ border-radius: 8px; border: 1px solid var(--green, #3fb950);
357
+ transition: background 0.2s, color 0.2s;
358
+ animation: version-badge-pulse 2s ease-in-out infinite;
359
+ }
360
+ .version-update-badge:hover {
361
+ background: var(--green, #3fb950); color: var(--bg, #0d1117);
362
+ }
363
+ .version-update-dot {
364
+ width: 6px; height: 6px; border-radius: 50%;
365
+ background: var(--green, #3fb950);
366
+ display: inline-block;
367
+ animation: version-dot-blink 1.5s ease-in-out infinite;
368
+ }
369
+ @keyframes version-badge-pulse {
370
+ 0%, 100% { opacity: 1; }
371
+ 50% { opacity: 0.7; }
372
+ }
373
+ @keyframes version-dot-blink {
374
+ 0%, 100% { opacity: 1; }
375
+ 50% { opacity: 0.3; }
376
+ }
351
377
 
352
378
  /* ── Scrollbar ── */
353
379
  ::-webkit-scrollbar { width: 6px; height: 6px; }
@@ -467,8 +493,8 @@ body {
467
493
  }
468
494
 
469
495
  @media (max-width: 768px) {
470
- .tp-top { flex-direction: column; }
471
- .tp-left { width: 100%; }
496
+ .tp-top { grid-template-columns: 1fr; }
497
+ .tp-row2 { grid-template-columns: 1fr; }
472
498
  .tp-charts-row { grid-template-columns: 1fr; }
473
499
  .tp-pie-wrap { flex-direction: column; align-items: center; }
474
500
  }
package/public/index.html CHANGED
@@ -13,24 +13,24 @@
13
13
  <div id="header">
14
14
  <button class="btn on" id="tab-stream" onclick="switchTab('stream')">📡 Stream</button>
15
15
  <button class="btn" id="tab-tokens" onclick="switchTab('tokens')">📊 Tokens</button>
16
- <span class="sep">│</span>
17
- <button class="btn on" id="btn-thinking" onclick="toggleThinking()" data-tooltip="Toggle thinking">🧠 Thinking</button>
18
- <button class="btn on" id="btn-tool-input" onclick="toggleToolInput()" data-tooltip="Toggle tool input">🔧 Tools</button>
19
- <button class="btn on" id="btn-tool-output" onclick="toggleToolOutput()" data-tooltip="Toggle tool output">📤 Output</button>
20
- <button class="btn on" id="btn-text" onclick="toggleText()" data-tooltip="Toggle text responses">💬 Text</button>
21
- <button class="btn on" id="btn-hook" onclick="toggleHook()" data-tooltip="Toggle hook output">🪝 Hook</button>
22
- <button class="btn on" id="btn-user-prompt" onclick="toggleUserPrompt()" data-tooltip="Toggle user prompt">👤 Prompt</button>
23
- <span class="sep">│</span>
24
- <button class="btn on" id="btn-autoscroll" onclick="toggleAutoScroll()" data-tooltip="Auto-scroll">⇣ Auto</button>
25
- <button class="btn" id="btn-tree-toggle" onclick="toggleTree()" data-tooltip="Toggle tree panel">📂 Tree</button>
26
- <span class="sep">│</span>
27
- <span id="session-info">Connecting...</span>
16
+ <span class="stream-only sep">│</span>
17
+ <button class="btn on stream-only" id="btn-thinking" onclick="toggleThinking()" data-tooltip="Toggle thinking">🧠 Thinking</button>
18
+ <button class="btn on stream-only" id="btn-tool-input" onclick="toggleToolInput()" data-tooltip="Toggle tool input">🔧 Tools</button>
19
+ <button class="btn on stream-only" id="btn-tool-output" onclick="toggleToolOutput()" data-tooltip="Toggle tool output">📤 Output</button>
20
+ <button class="btn on stream-only" id="btn-text" onclick="toggleText()" data-tooltip="Toggle text responses">💬 Text</button>
21
+ <button class="btn on stream-only" id="btn-hook" onclick="toggleHook()" data-tooltip="Toggle hook output">🪝 Hook</button>
22
+ <button class="btn on stream-only" id="btn-user-prompt" onclick="toggleUserPrompt()" data-tooltip="Toggle user prompt">👤 Prompt</button>
23
+ <span class="stream-only sep">│</span>
24
+ <button class="btn on stream-only" id="btn-autoscroll" onclick="toggleAutoScroll()" data-tooltip="Auto-scroll">⇣ Auto</button>
25
+ <button class="btn stream-only" id="btn-tree-toggle" onclick="toggleTree()" data-tooltip="Toggle tree panel">📂 Tree</button>
26
+ <span class="stream-only sep">│</span>
27
+ <span class="stream-only" id="session-info">Connecting...</span>
28
28
  <div class="auto">
29
- <button class="btn btn-icon" id="btn-export" onclick="openExportModal()" data-tooltip="导出 HTML">💾</button>
29
+ <button class="btn btn-icon stream-only" id="btn-export" onclick="openExportModal()" data-tooltip="导出 HTML">💾</button>
30
30
  <button class="btn btn-icon" id="btn-theme" onclick="toggleTheme()" data-tooltip="Toggle theme">🌙</button>
31
- <button class="btn on" id="btn-autodisco" onclick="toggleAutoDiscovery()" data-tooltip="Auto-discover">🔍 Auto</button>
32
- <span class="sep">│</span>
33
- <span id="token-info"></span>
31
+ <button class="btn on stream-only" id="btn-autodisco" onclick="toggleAutoDiscovery()" data-tooltip="Auto-discover">🔍 Auto</button>
32
+ <span class="stream-only sep">│</span>
33
+ <span class="stream-only" id="token-info"></span>
34
34
  </div>
35
35
  </div>
36
36
 
@@ -64,15 +64,9 @@
64
64
  <button class="btn" id="btn-refresh-tokens" onclick="refreshTokenStats()">🔄 刷新数据</button>
65
65
  </div>
66
66
  <div class="tp-top">
67
- <div class="tp-left">
68
- <div class="tp-box" id="tp-total-card"></div>
69
- <div class="tp-box" id="tp-stats-grid"></div>
70
- <div class="tp-box" id="tp-model-rank"></div>
71
- </div>
72
- <div class="tp-right">
73
- <div class="tp-box" id="tp-trend-card"></div>
74
- <div class="tp-box" id="tp-heatmap-card"></div>
75
- </div>
67
+ <div class="tp-box" id="tp-total-card"></div>
68
+ <div class="tp-box" id="tp-stats-grid"></div>
69
+ <div class="tp-box" id="tp-model-rank"></div>
76
70
  </div>
77
71
  <div class="tp-charts-row" id="tp-charts-row">
78
72
  <div class="tp-chart-box" id="tp-weekly-chart" role="img" aria-label="Weekly token consumption chart"></div>
@@ -80,6 +74,10 @@
80
74
  <div class="tp-chart-box" id="tp-model-pie" role="img" aria-label="Model token proportion chart"></div>
81
75
  <div class="tp-chart-box" id="tp-hourly-chart" role="img" aria-label="Active time distribution chart"></div>
82
76
  </div>
77
+ <div class="tp-row2">
78
+ <div class="tp-box" id="tp-trend-card"></div>
79
+ <div class="tp-box" id="tp-heatmap-card"></div>
80
+ </div>
83
81
  <div class="tp-box">
84
82
  <div class="tp-tabs" id="tp-detail-tabs"></div>
85
83
  <div id="tp-tc-daily" class="tp-tc"><div class="tp-st" id="tp-daily-table"></div></div>
package/public/js/app.js CHANGED
@@ -17,6 +17,7 @@ let lastMsgTime = 0;
17
17
  let staleCheckTimer = null;
18
18
  let currentTab = 'stream';
19
19
  let appVersion = '';
20
+ let latestVersion = '';
20
21
 
21
22
  // Cache highlight.js CSS for HTML export
22
23
  let hljsDarkCSS = '', hljsLightCSS = '';
@@ -93,6 +94,7 @@ function handleMessage(msg) {
93
94
  case 'tokenStats': handleTokenStats(msg.payload); break;
94
95
  case 'config':
95
96
  if (msg.payload.version) appVersion = msg.payload.version;
97
+ if (msg.payload.latestVersion) { latestVersion = msg.payload.latestVersion; renderFooterVersion(); }
96
98
  if (msg.payload.collapseAfter > 0) {
97
99
  applyCollapsePolicy(msg.payload.collapseAfter);
98
100
  }
@@ -143,7 +145,11 @@ function renderFooterVersion() {
143
145
  const vEl = document.getElementById('footer-version');
144
146
  if (vEl) {
145
147
  const v = appVersion ? `v${appVersion}` : '';
146
- vEl.innerHTML = `${v ? v + ' · ' : ''}<a href="https://github.com/shuxuecode/claude-watch" target="_blank" rel="noopener" style="color:var(--dim);display:inline-flex;align-items:center;gap:3px"><svg viewBox="0 0 16 16" width="12" height="12" fill="currentColor" style="vertical-align:middle"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>claude-watch</a>`;
148
+ const hasUpdate = latestVersion && appVersion && latestVersion !== appVersion;
149
+ const updateBadge = hasUpdate
150
+ ? `<a href="https://www.npmjs.com/package/claude-code-watch" target="_blank" rel="noopener" class="version-update-badge" data-tooltip="New version available! Click to view on npm"><span class="version-update-dot"></span>v${latestVersion} ↑</a>`
151
+ : '';
152
+ vEl.innerHTML = `${v ? v + ' ' : ''}${updateBadge}${updateBadge ? ' · ' : ''}<a href="https://github.com/shuxuecode/claude-watch" target="_blank" rel="noopener" style="color:var(--dim);display:inline-flex;align-items:center;gap:3px"><svg viewBox="0 0 16 16" width="12" height="12" fill="currentColor" style="vertical-align:middle"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>claude-watch</a>`;
147
153
  }
148
154
  }
149
155
 
@@ -162,6 +168,10 @@ function switchTab(tab) {
162
168
  document.getElementById('tab-stream').classList.toggle('on', tab === 'stream');
163
169
  document.getElementById('tab-tokens').classList.toggle('on', tab === 'tokens');
164
170
  document.getElementById('footer').style.display = tab === 'stream' ? 'flex' : 'none';
171
+ // Toggle stream-only header controls
172
+ document.querySelectorAll('.stream-only').forEach(el => {
173
+ el.style.display = tab === 'stream' ? '' : 'none';
174
+ });
165
175
  if (tab === 'tokens' && !tokenStatsRendered && tokenStatsData.totals.messages > 0) {
166
176
  tokenStatsRendered = true;
167
177
  renderTokenPage();
@@ -206,9 +206,7 @@ function buildModelRank(mt, totalAll) {
206
206
  return html;
207
207
  }
208
208
 
209
- // ── Stacked bar chart for weekly/monthly token consumption ──
210
- const STACK_COLORS = { input: '#58a6ff', output: '#f0883e', cacheRead: '#3fb950', cacheCreation: '#a371f7' };
211
- const STACK_LABELS = { input: '输入 Input', output: '输出 Output', cacheRead: '缓存读取 Cache Read', cacheCreation: '缓存创建 Cache Create' };
209
+ // ── Bar chart for weekly/monthly token consumption ──
212
210
 
213
211
  function buildStackedChart(dailyKeys, daily, type) {
214
212
  const title = type === 'weekly' ? '📊 周 Token 消耗 Weekly Token Consumption' : '📊 月 Token 消耗 Monthly Token Consumption';
@@ -231,25 +229,11 @@ function buildStackedChart(dailyKeys, daily, type) {
231
229
  let barsHTML = '';
232
230
  for (let i = 0; i < periodKeys.length; i++) {
233
231
  const k = periodKeys[i];
234
- const p = periods[k];
235
232
  const total = totals[i];
236
-
237
- const segs = [
238
- { key: 'input', val: p.input },
239
- { key: 'output', val: p.output },
240
- { key: 'cacheRead', val: p.cacheRead },
241
- { key: 'cacheCreation', val: p.cacheCreation },
242
- ];
243
- let segsHTML = '';
244
- for (const s of segs) {
245
- const segPct = maxTotal > 0 ? (s.val / maxTotal * 100) : 0;
246
- const segH = maxTotal > 0 ? Math.max(segPct, 0.5) : 0;
247
- segsHTML += `<div class="tp-stack-seg" style="height:${segH}%;background:${STACK_COLORS[s.key]}"></div>`;
248
- }
249
-
233
+ const pct = maxTotal > 0 ? (total / maxTotal * 100) : 0;
250
234
  const label = type === 'weekly' ? k.slice(5) : k.slice(2);
251
- const tip = `${k}: 总计 ${fmtTS(total)} | 输入 ${fmtTS(p.input)} 输出 ${fmtTS(p.output)} 缓存读 ${fmtTS(p.cacheRead)} 缓存写 ${fmtTS(p.cacheCreation)}`;
252
- barsHTML += `<div class="tp-stack-wrap"><div class="tp-stack-bar-group" data-tip="${esc(tip)}">${segsHTML}</div><span class="tp-stack-label">${esc(label)}</span></div>`;
235
+ const tip = `${k}: ${fmtTS(total)} tokens`;
236
+ barsHTML += `<div class="tp-stack-wrap"><div class="tp-stack-bar-area"><div class="tp-stack-bar-group" data-tip="${esc(tip)}"><div class="tp-stack-seg" style="height:${Math.max(pct, 1)}%;background:#58a6ff"></div></div></div><span class="tp-stack-label">${esc(label)}</span></div>`;
253
237
  }
254
238
 
255
239
  let gridHTML = '<div class="tp-stack-grid">';
@@ -260,13 +244,7 @@ function buildStackedChart(dailyKeys, daily, type) {
260
244
  gridHTML += `<span style="font-size:9px;color:var(--dim);align-self:flex-end">0</span>`;
261
245
  gridHTML += '</div>';
262
246
 
263
- let legendHTML = '<div style="display:flex;gap:12px;margin-bottom:6px;font-size:10px;color:var(--dim)">';
264
- for (const [key, color] of Object.entries(STACK_COLORS)) {
265
- legendHTML += `<span style="display:inline-flex;align-items:center;gap:4px"><span style="width:8px;height:8px;border-radius:2px;background:${color};display:inline-block"></span>${STACK_LABELS[key]}</span>`;
266
- }
267
- legendHTML += '</div>';
268
-
269
- return `<div class="tp-chart-title">${title}</div>${legendHTML}<div class="tp-stack-bars">${gridHTML}${barsHTML}</div>`;
247
+ return `<div class="tp-chart-title">${title}</div><div class="tp-stack-bars">${gridHTML}${barsHTML}</div>`;
270
248
  }
271
249
 
272
250
  // ── Model proportion doughnut chart ──
@@ -332,10 +310,10 @@ function buildHourlyChart(hourly) {
332
310
  for (let h = 0; h < 24; h++) {
333
311
  const calls = hourly[h] || 0;
334
312
  const pct = maxCalls > 0 ? (calls / maxCalls * 100) : 0;
335
- const color = pct < 30 ? 'rgba(247,120,186,0.25)' : pct < 60 ? 'rgba(247,120,186,0.5)' : pct < 80 ? 'rgba(247,120,186,0.75)' : 'rgba(247,120,186,1)';
336
- const borderColor = '#f778ba';
313
+ const color = pct < 30 ? 'rgba(16,185,129,0.25)' : pct < 60 ? 'rgba(16,185,129,0.5)' : pct < 80 ? 'rgba(16,185,129,0.75)' : 'rgb(16,185,129)';
314
+ const borderColor = 'rgb(16,185,129)';
337
315
  const tip = `${h}:00 · ${calls.toLocaleString()} 次调用 calls`;
338
- barsHTML += `<div class="tp-hourly-bar-wrap"><div class="tp-hourly-bar" style="height:${Math.max(pct, 2)}%;background:${color};border:1px solid ${borderColor}" data-tip="${esc(tip)}"></div><span class="tp-hourly-label">${h}</span></div>`;
316
+ barsHTML += `<div class="tp-hourly-bar-wrap"><div class="tp-hourly-bar-area"><div class="tp-hourly-bar" style="height:${Math.max(pct, 2)}%;background:${color};border:1px solid ${borderColor}" data-tip="${esc(tip)}"></div></div><span class="tp-hourly-label">${h}</span></div>`;
339
317
  }
340
318
 
341
319
  let gridHTML = '<div class="tp-hourly-grid">';
@@ -7,12 +7,35 @@ var os = require('os');
7
7
  var cp = require('child_process');
8
8
  var readline = require('readline');
9
9
  var { WebSocketServer } = require('ws');
10
+ var { compareVersions } = require('../cli-helpers');
10
11
  var { Watcher, listSessions, listActiveSessions } = require('../watcher/watcher');
11
12
  var { setDebugAll, contextWindowFor } = require('../parser/parser');
12
13
  var { fullScanTokenUsage } = require('../scanner/scanner');
13
14
 
14
15
  var PACKAGE_VERSION = require('../../package.json').version;
15
16
 
17
+ function fetchLatestVersion() {
18
+ return new Promise(function(resolve, reject) {
19
+ var opts = {
20
+ hostname: 'registry.npmjs.org',
21
+ path: '/claude-code-watch/latest',
22
+ timeout: 5000,
23
+ };
24
+ var req = require('https').get(opts, function(res) {
25
+ if (res.statusCode !== 200) { reject(new Error('HTTP ' + res.statusCode)); return; }
26
+ var data = '';
27
+ res.on('data', function(chunk) { data += chunk; });
28
+ res.on('end', function() {
29
+ try { resolve(JSON.parse(data).version); }
30
+ catch (err) { reject(err); }
31
+ });
32
+ });
33
+ req.on('error', reject);
34
+ req.on('timeout', function() { req.destroy(); reject(new Error('timeout')); });
35
+ req.end();
36
+ });
37
+ }
38
+
16
39
  var MIME = {
17
40
  '.html': 'text/html; charset=utf-8',
18
41
  '.css': 'text/css; charset=utf-8',
@@ -54,6 +77,8 @@ class DashboardServer {
54
77
  this.wss = null;
55
78
  this._heartbeatTimer = null;
56
79
  this._allowedPrefix = null;
80
+ this.latestVersion = null;
81
+ this._versionCheckTimer = null;
57
82
 
58
83
  setDebugAll(options.debugAll || false);
59
84
  this.debugAll = options.debugAll || false;
@@ -446,7 +471,17 @@ class DashboardServer {
446
471
  }
447
472
 
448
473
  sendConfig(ws) {
449
- this.send(ws, 'config', { collapseAfter: this.collapseAfterMs, version: PACKAGE_VERSION });
474
+ this.send(ws, 'config', { collapseAfter: this.collapseAfterMs, version: PACKAGE_VERSION, latestVersion: this.latestVersion });
475
+ }
476
+
477
+ _checkLatestVersion() {
478
+ fetchLatestVersion().then((latest) => {
479
+ if (compareVersions(latest, PACKAGE_VERSION) > 0) {
480
+ this.latestVersion = latest;
481
+ // Notify all connected clients
482
+ this.broadcast('config', { collapseAfter: this.collapseAfterMs, version: PACKAGE_VERSION, latestVersion: latest });
483
+ }
484
+ }).catch(() => { /* network unavailable, skip */ });
450
485
  }
451
486
 
452
487
  setupWatcher(watcherOpts) {
@@ -670,6 +705,10 @@ class DashboardServer {
670
705
  this._contextCleanupTimer = setInterval(() => this.cleanupContextMap(), CONTEXT_STALE_MS);
671
706
  this._heartbeatTimer = setInterval(() => this.broadcast('heartbeat', null), 30000);
672
707
 
708
+ // Check for latest version on startup and periodically (every hour)
709
+ this._checkLatestVersion();
710
+ this._versionCheckTimer = setInterval(() => this._checkLatestVersion(), 60 * 60 * 1000);
711
+
673
712
  // Start listening and wait for server to be ready before opening browser
674
713
  await new Promise((resolve) => {
675
714
  this.server.listen(this.port, this.host, () => {
@@ -702,6 +741,7 @@ class DashboardServer {
702
741
  stop() {
703
742
  if (this._contextCleanupTimer) clearInterval(this._contextCleanupTimer);
704
743
  if (this._heartbeatTimer) clearInterval(this._heartbeatTimer);
744
+ if (this._versionCheckTimer) clearInterval(this._versionCheckTimer);
705
745
  if (this._flushTimer) {
706
746
  clearTimeout(this._flushTimer);
707
747
  this._flushTimer = null;