|
10 | 10 | from pathlib import Path |
11 | 11 | from datetime import datetime |
12 | 12 |
|
| 13 | +from scanner import VERSION |
| 14 | + |
13 | 15 | DB_PATH = Path.home() / ".claude" / "usage.db" |
14 | 16 |
|
| 17 | +# Which surface is rendering the dashboard: "web" (standalone `cli.py dashboard`) |
| 18 | +# or "vscode" (embedded in the extension's sidebar webview). serve() sets this |
| 19 | +# from the --surface flag the extension passes. The footer reads it to decide |
| 20 | +# what to show — the web build promotes the VS Code extension and offers a |
| 21 | +# "check GitHub for a newer release" update link; the embedded build shows just |
| 22 | +# the version (VS Code updates the extension itself, and a GitHub-release check |
| 23 | +# would misfire there because the Marketplace publish lags the GitHub release). |
| 24 | +SURFACE = "web" |
| 25 | + |
15 | 26 |
|
16 | 27 | def get_dashboard_data(db_path=DB_PATH): |
17 | 28 | if not db_path.exists(): |
@@ -135,6 +146,7 @@ def get_dashboard_data(db_path=DB_PATH): |
135 | 146 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
136 | 147 | <title>Claude Code Usage Dashboard</title> |
137 | 148 | <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script> |
| 149 | +<script>window.APP_CONFIG = __APP_CONFIG_JSON__;</script> |
138 | 150 | <style> |
139 | 151 | :root { |
140 | 152 | --bg: #161617; /* page base */ |
@@ -258,6 +270,7 @@ def get_dashboard_data(db_path=DB_PATH): |
258 | 270 | .footer-content p:last-child { margin-bottom: 0; } |
259 | 271 | .footer-content a { color: var(--blue); text-decoration: none; } |
260 | 272 | .footer-content a:hover { text-decoration: underline; } |
| 273 | + .footer-content a.update-link { color: var(--accent); font-weight: 600; } |
261 | 274 |
|
262 | 275 | @media (max-width: 768px) { .charts-grid { grid-template-columns: 1fr; } .chart-card.wide { grid-column: 1; } } |
263 | 276 | </style> |
@@ -398,6 +411,7 @@ def get_dashboard_data(db_path=DB_PATH): |
398 | 411 | · |
399 | 412 | License: MIT |
400 | 413 | </p> |
| 414 | + <p id="footer-meta"></p> |
401 | 415 | </div> |
402 | 416 | </footer> |
403 | 417 |
|
@@ -1471,6 +1485,82 @@ def get_dashboard_data(db_path=DB_PATH): |
1471 | 1485 | } |
1472 | 1486 | } |
1473 | 1487 |
|
| 1488 | +// ── Footer meta: version, extension promo, update check ────────────────────── |
| 1489 | +// APP_CONFIG is injected server-side (see do_GET). { version, surface }. |
| 1490 | +const APP_CONFIG = window.APP_CONFIG || { version: '', surface: 'web' }; |
| 1491 | +const REPO_URL = 'https://github.com/phuryn/claude-usage'; |
| 1492 | +const MARKETPLACE_URL = 'https://marketplace.visualstudio.com/items?itemName=PawelHuryn.claude-usage-phuryn'; |
| 1493 | +const UPDATE_CACHE_KEY = 'cu_update_check'; |
| 1494 | +const UPDATE_CACHE_TTL = 24 * 60 * 60 * 1000; // re-check GitHub at most once a day |
| 1495 | +
|
| 1496 | +// Compare dotted numeric versions ("1.3.0"); leading "v" tolerated. Returns |
| 1497 | +// true only when `latest` is strictly ahead of `current`. |
| 1498 | +function isNewer(latest, current) { |
| 1499 | + const a = String(latest).replace(/^v/, '').split('.').map(n => parseInt(n, 10) || 0); |
| 1500 | + const b = String(current).replace(/^v/, '').split('.').map(n => parseInt(n, 10) || 0); |
| 1501 | + for (let i = 0; i < Math.max(a.length, b.length); i++) { |
| 1502 | + const x = a[i] || 0, y = b[i] || 0; |
| 1503 | + if (x > y) return true; |
| 1504 | + if (x < y) return false; |
| 1505 | + } |
| 1506 | + return false; |
| 1507 | +} |
| 1508 | +
|
| 1509 | +function appendUpdateLink(latest) { |
| 1510 | + const el = document.getElementById('footer-meta'); |
| 1511 | + if (!el || !el.innerHTML) return; |
| 1512 | + const a = document.createElement('a'); |
| 1513 | + a.className = 'update-link'; |
| 1514 | + a.href = REPO_URL + '/releases/latest'; |
| 1515 | + a.target = '_blank'; |
| 1516 | + a.rel = 'noopener'; |
| 1517 | + a.textContent = 'Update to v' + latest; |
| 1518 | + el.insertAdjacentHTML('beforeend', ' · '); |
| 1519 | + el.appendChild(a); |
| 1520 | +} |
| 1521 | +
|
| 1522 | +// Web only. Asks GitHub's public releases API whether a newer release exists and, |
| 1523 | +// if so, appends an "Update to vX.Y.Z" link. Cached in localStorage for 24h and |
| 1524 | +// fully fail-silent (offline / rate-limited / blocked -> no link, no error). No |
| 1525 | +// usage data is sent; this is a plain unauthenticated GET of release metadata. |
| 1526 | +function checkForUpdate(current) { |
| 1527 | + let cached = null; |
| 1528 | + try { cached = JSON.parse(localStorage.getItem(UPDATE_CACHE_KEY) || 'null'); } catch (e) {} |
| 1529 | + if (cached && cached.latest && cached.ts && (Date.now() - cached.ts) < UPDATE_CACHE_TTL) { |
| 1530 | + if (isNewer(cached.latest, current)) appendUpdateLink(cached.latest); |
| 1531 | + return; |
| 1532 | + } |
| 1533 | + fetch('https://api.github.com/repos/phuryn/claude-usage/releases/latest', { |
| 1534 | + headers: { 'Accept': 'application/vnd.github+json' } |
| 1535 | + }) |
| 1536 | + .then(r => r.ok ? r.json() : null) |
| 1537 | + .then(data => { |
| 1538 | + if (!data || !data.tag_name) return; |
| 1539 | + const latest = String(data.tag_name).replace(/^v/, ''); |
| 1540 | + try { localStorage.setItem(UPDATE_CACHE_KEY, JSON.stringify({ ts: Date.now(), latest: latest })); } catch (e) {} |
| 1541 | + if (isNewer(latest, current)) appendUpdateLink(latest); |
| 1542 | + }) |
| 1543 | + .catch(() => {}); // fail-silent: never let a version check disrupt the dashboard |
| 1544 | +} |
| 1545 | +
|
| 1546 | +function initFooterMeta() { |
| 1547 | + const el = document.getElementById('footer-meta'); |
| 1548 | + if (!el) return; |
| 1549 | + const v = APP_CONFIG.version || ''; |
| 1550 | + const parts = []; |
| 1551 | + if (v) { |
| 1552 | + parts.push('Version <a href="' + REPO_URL + '/releases/tag/v' + esc(v) + '" target="_blank" rel="noopener">v' + esc(v) + '</a>'); |
| 1553 | + } |
| 1554 | + // The web build promotes the extension; the embedded build is already in it. |
| 1555 | + if (APP_CONFIG.surface !== 'vscode') { |
| 1556 | + parts.push('<a href="' + MARKETPLACE_URL + '" target="_blank" rel="noopener">Get the VS Code extension</a>'); |
| 1557 | + } |
| 1558 | + el.innerHTML = parts.join(' · '); |
| 1559 | + // VS Code auto-updates the extension, so only the web build checks for updates. |
| 1560 | + if (v && APP_CONFIG.surface !== 'vscode') checkForUpdate(v); |
| 1561 | +} |
| 1562 | +
|
| 1563 | +initFooterMeta(); |
1474 | 1564 | loadData(); |
1475 | 1565 | scheduleAutoRefresh(); |
1476 | 1566 | </script> |
@@ -1510,10 +1600,17 @@ def do_GET(self): |
1510 | 1600 | # URLs don't fall through to 404. |
1511 | 1601 | path = urlparse(self.path).path |
1512 | 1602 | if path in ("/", "/index.html"): |
| 1603 | + # Inject runtime config (version + surface) the page can't know at |
| 1604 | + # author time. json.dumps produces a valid JS object literal for the |
| 1605 | + # `window.APP_CONFIG = __APP_CONFIG_JSON__;` placeholder in the head. |
| 1606 | + config = json.dumps({"version": VERSION, "surface": SURFACE}) |
| 1607 | + html = HTML_TEMPLATE.replace("__APP_CONFIG_JSON__", config) |
| 1608 | + body = html.encode("utf-8") |
1513 | 1609 | self.send_response(200) |
1514 | 1610 | self.send_header("Content-Type", "text/html; charset=utf-8") |
| 1611 | + self.send_header("Content-Length", str(len(body))) |
1515 | 1612 | self.end_headers() |
1516 | | - self.wfile.write(HTML_TEMPLATE.encode("utf-8")) |
| 1613 | + self.wfile.write(body) |
1517 | 1614 |
|
1518 | 1615 | elif path == "/api/data": |
1519 | 1616 | data = get_dashboard_data() |
@@ -1570,7 +1667,10 @@ def do_POST(self): |
1570 | 1667 | self.end_headers() |
1571 | 1668 |
|
1572 | 1669 |
|
1573 | | -def serve(host=None, port=None): |
| 1670 | +def serve(host=None, port=None, surface=None): |
| 1671 | + global SURFACE |
| 1672 | + if surface: |
| 1673 | + SURFACE = surface |
1574 | 1674 | host = host or os.environ.get("HOST", "localhost") |
1575 | 1675 | port = port or int(os.environ.get("PORT", "8080")) |
1576 | 1676 | server = ThreadingHTTPServer((host, port), DashboardHandler) |
|
0 commit comments