feat: add ccusage web command with interactive dashboard#920
feat: add ccusage web command with interactive dashboard#920BuluBulugege wants to merge 3 commits intoryoppippi:mainfrom
Conversation
Add Hit Rate as a first-class metric alongside Input/Output/Cache columns in daily, monthly, weekly, and session reports. The hit rate is calculated as CacheRead / (Input + CacheCreate + CacheRead), color-coded green (>=70%), yellow (>=40%), or red (<40%). Also included in compact mode and JSON output. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When terminal width is insufficient, ResponsiveTable normally scales column widths proportionally and wraps text within cells. The --full flag disables this behavior: columns use their full content width, compact mode is overridden, and word wrapping is disabled. The table may overflow horizontally but all data is displayed without truncation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add `ccusage web` command that starts a local HTTP server serving an interactive dashboard with charts and session drill-down. Uses Hono for API routes backed by existing data loaders, and a self-contained HTML page with Chart.js for visualization. Features: - Overview cards (cost, tokens, cache hit rate, sessions) - Daily token usage stacked bar chart - Cost trend line chart with cumulative view - Model distribution doughnut chart - Session table with clickable drill-down - Date range picker with quick presets (7D/30D/Month/All) - Dark theme matching terminal aesthetics Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
📝 WalkthroughWalkthroughThis PR introduces a web dashboard feature for the ccusage app with a new Changes
Sequence DiagramsequenceDiagram
participant User as Browser/User
participant Dashboard as Web Dashboard
participant Server as Hono Server
participant Loader as Data Loader
User->>Dashboard: Select time range (7D/30D/Month/Custom/All)
Dashboard->>Dashboard: Call load()
par Concurrent API Calls
Dashboard->>Server: GET /api/daily?since=X&until=Y
Dashboard->>Server: GET /api/session?since=X&until=Y
Dashboard->>Server: GET /api/monthly?since=X&until=Y
end
par Load & Process Data
Server->>Loader: Load daily usage data
Server->>Loader: Load session data
Server->>Loader: Load monthly data
end
par Compute Metrics
Loader-->>Server: Calculate cache hit rates
Loader-->>Server: Compute token/cost totals
end
Server-->>Dashboard: Return daily, session, monthly JSON
Dashboard->>Dashboard: Render summary cards
Dashboard->>Dashboard: Create/update charts (tokens, cost, models)
Dashboard->>Dashboard: Populate sessions table (limit 50)
Dashboard-->>User: Display dashboard
User->>Dashboard: Click session row
Dashboard->>Server: GET /api/session/{id}
Server-->>Dashboard: Return session detail with entry breakdown
Dashboard->>Dashboard: Open modal with details
Dashboard-->>User: Display session info
Estimated Code Review Effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly Related PRs
Suggested Reviewers
Poem
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 8
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
apps/ccusage/src/commands/daily.ts (1)
143-149:⚠️ Potential issue | 🟡 MinorUse the merged
fulloption here.
ctx.values.fullbypassesmergeConfigWithArgs(), so a config-filefull: truenever reaches the table.noTruncateshould read frommergedOptions.fullto keep CLI args and config behavior consistent.♻️ Suggested fix
- noTruncate: ctx.values.full, + noTruncate: mergedOptions.full,🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/ccusage/src/commands/daily.ts` around lines 143 - 149, The tableConfig construction uses ctx.values.full (which bypasses mergeConfigWithArgs()) for noTruncate; change noTruncate to read from mergedOptions.full so the effective option respects config-file and CLI merging. Locate the UsageReportConfig creation in function that builds tableConfig and replace the noTruncate reference to ctx.values.full with mergedOptions.full, ensuring other fields (firstColumnName, dateFormatter, forceCompact) remain unchanged.
🧹 Nitpick comments (1)
apps/ccusage/src/commands/_web_dashboard.ts (1)
158-161: Drop the monthly request until the UI actually uses it.
load()waits for/api/monthly, butrender()ignores the response. That adds backend work and visible latency on every filter change for no user-facing benefit.Also applies to: 170-176
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/ccusage/src/commands/_web_dashboard.ts` around lines 158 - 161, The load() implementation currently awaits api('monthly', p) even though render() ignores the monthly result; remove the unnecessary monthly API call by changing the Promise.all to only call api('daily', p) and api('session', p), update the destructuring to const [daily, sessions] and call render(daily, sessions) instead of render(daily, sessions, monthly), and make the same change for the other occurrence noted (lines ~170-176); ensure references to api('monthly', p) and the unused monthly variable are removed so the UI no longer waits on the unused monthly request.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/ccusage/src/commands/_web_dashboard.ts`:
- Around line 279-287: The code is building an HTML row by concatenating
untrusted values (x.sessionId, sid, models, timestamps, etc.) into a string and
inserting it as innerHTML/inline JS; replace this by creating elements with
document.createElement, setting text nodes via textContent for cells (use
fmt/fmtPct/fmtCost/hitRateColor for formatted text but assign results to
textContent), and attach the row click behavior with
row.addEventListener('click', () => openSession(x.sessionId)) instead of an
onclick string; apply the same change to the similar code block around lines
298-312 so no user-controlled data is injected into markup or inline event
handlers.
- Around line 153-168: The load function can render stale results because
parallel API calls aren't tied to the latest request; add a request-sequencing
guard (e.g., a module-level counter like currentLoadSeq) and on each load()
increment it and capture the current seq into a local loadSeq variable; after
the Promise.all([...]) (and in the catch) check that loadSeq === currentLoadSeq
before calling render(...) or updating `#loading/`#content so only the most recent
load updates the UI; alternatively, if api(...) supports AbortController, create
a new AbortController for each load(), pass its signal to api, and abort the
previous controller before starting a new load to cancel earlier requests.
- Around line 274-288: The session rows generated in body.innerHTML are only
mouse-clickable; make them keyboard-accessible by adding a tabindex="0" and
role="button" to the generated <tr> with class "clickable", and attach a keydown
handler that calls openSession(x.sessionId) when Enter or Space is pressed (in
the same string/template where onclick="openSession(...)" is set). Update the
mapping in the body.innerHTML code block (the function that builds each row
using x.sessionId, sid, models, fmt, hitRateColor, fmtPct, fmtCost) so keyboard
users can focus the row and open the session via Enter/Space.
- Line 11: Replace the runtime CDN script tag that loads Chart.js with a
bundled/pinned local asset: remove the <script
src="https://cdn.jsdelivr.net/npm/chart.js@4"></script> usage and instead serve
or inline a vendored Chart.js build (pinning to a specific version) from the CLI
distribution or embed it into the dashboard HTML template (the HTML string
produced in apps/ccusage/src/commands/_web_dashboard.ts). Ensure the dashboard
references the local file or inline script and that package.json/build pipeline
pins the Chart.js version so the asset is bundled with the release.
In `@apps/ccusage/src/commands/web.ts`:
- Around line 218-226: The current code uses await import('nano-spawn') and
spawns the Windows "start" command without a shell, causing it to fail silently;
replace the dynamic import with a static/top-level import of nano-spawn (the
spawn symbol) and change the Windows spawn logic so that either you call cmd.exe
with args ['/c','start', url] or call 'start' but pass { shell: true } to spawn;
also ensure the catch block does not silently swallow errors—log or rethrow the
caught error so failures are visible.
- Around line 209-213: The dashboard server is currently bound to all
interfaces; update the serve invocation used to start the server (the call to
serve with { fetch: app.fetch, port }) to include hostname: '127.0.0.1' so it
only listens on loopback. Locate the dynamic import and call to serve (the
serve({ fetch: app.fetch, port }) call) and add the hostname property, then keep
the existing url/logging (logger.info) behavior unchanged.
- Around line 210-226: The dynamic imports for '@hono/node-server' (used to call
serve({ fetch: app.fetch, port })) and for 'nano-spawn' (used to get spawn and
call await spawn(cmd, [url])) must be replaced with static imports at the top of
the module; remove the await import(...) expressions and instead import { serve
} from '@hono/node-server' and the default export from 'nano-spawn' via standard
ES imports, then update the code to call serve(...) and spawn(...) directly
(keep existing variable names and logic for port, url, cmd and ctx.values.open).
---
Outside diff comments:
In `@apps/ccusage/src/commands/daily.ts`:
- Around line 143-149: The tableConfig construction uses ctx.values.full (which
bypasses mergeConfigWithArgs()) for noTruncate; change noTruncate to read from
mergedOptions.full so the effective option respects config-file and CLI merging.
Locate the UsageReportConfig creation in function that builds tableConfig and
replace the noTruncate reference to ctx.values.full with mergedOptions.full,
ensuring other fields (firstColumnName, dateFormatter, forceCompact) remain
unchanged.
---
Nitpick comments:
In `@apps/ccusage/src/commands/_web_dashboard.ts`:
- Around line 158-161: The load() implementation currently awaits api('monthly',
p) even though render() ignores the monthly result; remove the unnecessary
monthly API call by changing the Promise.all to only call api('daily', p) and
api('session', p), update the destructuring to const [daily, sessions] and call
render(daily, sessions) instead of render(daily, sessions, monthly), and make
the same change for the other occurrence noted (lines ~170-176); ensure
references to api('monthly', p) and the unused monthly variable are removed so
the UI no longer waits on the unused monthly request.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 1b418e5e-12b4-4a8b-bb18-5a82b093947a
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (11)
apps/ccusage/config-schema.jsonapps/ccusage/package.jsonapps/ccusage/src/_shared-args.tsapps/ccusage/src/commands/_web_dashboard.tsapps/ccusage/src/commands/daily.tsapps/ccusage/src/commands/index.tsapps/ccusage/src/commands/monthly.tsapps/ccusage/src/commands/session.tsapps/ccusage/src/commands/web.tsapps/ccusage/src/commands/weekly.tspackages/terminal/src/table.ts
| <meta charset="UTF-8"> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||
| <title>ccusage Dashboard</title> | ||
| <script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script> |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, check the dashboard file to confirm CDN usage
head -20 apps/ccusage/src/commands/_web_dashboard.ts | cat -nRepository: ryoppippi/ccusage
Length of output: 1244
🏁 Script executed:
# Check if chart.js is in project dependencies
find . -name "package.json" -type f | head -5Repository: ryoppippi/ccusage
Length of output: 191
🏁 Script executed:
# Search for chart.js in package files
rg "chart\.js" --type jsonRepository: ryoppippi/ccusage
Length of output: 43
🏁 Script executed:
# Check for any bundled chart.js or build artifacts
fd -e js -e mjs -path "*/chart*" --type f | head -20Repository: ryoppippi/ccusage
Length of output: 229
🏁 Script executed:
# Look at the full _web_dashboard.ts to understand context
wc -l apps/ccusage/src/commands/_web_dashboard.tsRepository: ryoppippi/ccusage
Length of output: 108
🏁 Script executed:
# Check ccusage package.json for chart.js dependency
cat apps/ccusage/package.jsonRepository: ryoppippi/ccusage
Length of output: 3763
🏁 Script executed:
# Also check the root package.json to see if chart.js is a workspace dependency
cat package.json | head -50Repository: ryoppippi/ccusage
Length of output: 1529
🏁 Script executed:
# Check how the dashboard is served (web.ts file)
cat apps/ccusage/src/commands/web.ts | head -100Repository: ryoppippi/ccusage
Length of output: 3107
🏁 Script executed:
# Look for any build configuration or bundling setup
find . -name "*.config.*" -type f | grep -E "(vite|webpack|esbuild|tsdown)" | head -10Repository: ryoppippi/ccusage
Length of output: 362
🏁 Script executed:
# Check if there's any existing static file serving or asset handling
rg "static|asset|public" apps/ccusage/src/commands --type tsRepository: ryoppippi/ccusage
Length of output: 168
🏁 Script executed:
# Let's check the tsdown.config to understand the build/bundling approach
cat apps/ccusage/tsdown.config.tsRepository: ryoppippi/ccusage
Length of output: 790
🏁 Script executed:
# Check if chart.js library could reasonably be embedded or bundled
# Look at the template size and approach
wc -c apps/ccusage/src/commands/_web_dashboard.tsRepository: ryoppippi/ccusage
Length of output: 110
🏁 Script executed:
# Verify whether Chart.js is available in npm and could be added as a dependency
web_search
npm chart.js package latest version sizeRepository: ryoppippi/ccusage
Length of output: 212
🌐 Web query:
chart.js npm package latest version minified size
💡 Result:
The latest version of the chart.js npm package is 4.5.1, released on October 13, 2025. The minified bundle size (min+gzip) is approximately 65 KB for the full package, as reported by bundle size analysis tools. Note that Chart.js is tree-shakeable, so actual sizes can be smaller depending on usage. The primary minified UMD build is dist/chart.umd.min.js, available via CDN like jsDelivr.
Citations:
- 1: https://www.npmjs.com/package/chart.js?activeTab=readme
- 2: https://www.npmjs.com/package/chart.js
- 3: http://frontendtools.tech/blog/reduce-javascript-bundle-size-2025
🏁 Script executed:
# Check if the dashboard truly needs to be self-contained (understand the deployment model)
grep -r "dashboardHtml" apps/ccusage/src --include="*.ts" -B 2 -A 2Repository: ryoppippi/ccusage
Length of output: 973
🏁 Script executed:
# Let's see what the comment says about why it's embedded
head -10 apps/ccusage/src/commands/_web_dashboard.tsRepository: ryoppippi/ccusage
Length of output: 391
Load Chart.js from a local or bundled source to avoid runtime CDN dependency.
The dashboard currently depends on jsDelivr to load Chart.js at runtime, which causes failures on offline or restricted networks and allows the asset to drift silently (using @4 rather than a pinned version). While the dashboard is intentionally self-contained to avoid serving static files, this external dependency undermines that goal. Consider bundling Chart.js into the dashboard (either inline or as part of the CLI distribution) rather than hotlinking it.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/ccusage/src/commands/_web_dashboard.ts` at line 11, Replace the runtime
CDN script tag that loads Chart.js with a bundled/pinned local asset: remove the
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script> usage and
instead serve or inline a vendored Chart.js build (pinning to a specific
version) from the CLI distribution or embed it into the dashboard HTML template
(the HTML string produced in apps/ccusage/src/commands/_web_dashboard.ts).
Ensure the dashboard references the local file or inline script and that
package.json/build pipeline pins the Chart.js version so the asset is bundled
with the release.
| } else if (v === 'month') { | ||
| const now = new Date(); | ||
| const start = new Date(now.getFullYear(), now.getMonth(), 1); | ||
| state.since = dateToYMD(start.toISOString().slice(0,10)); | ||
| state.until = null; | ||
| $('#btnMonth').classList.add('active'); | ||
| } else { | ||
| const now = new Date(); | ||
| const start = new Date(now); | ||
| start.setDate(now.getDate() - v); | ||
| state.since = dateToYMD(start.toISOString().slice(0,10)); |
There was a problem hiding this comment.
Build preset dates from local calendar values, not toISOString().
toISOString() converts the boundary to UTC before slicing, so Month, 7D, and 30D can slip by a day for non-UTC users or around local midnight. Format the local year/month/day parts directly before sending since.
| async function load() { | ||
| $('#loading').classList.remove('hide'); | ||
| $('#content').style.display = 'none'; | ||
| const p = { since: state.since, until: state.until }; | ||
| try { | ||
| const [daily, sessions, monthly] = await Promise.all([ | ||
| api('daily', p), api('session', p), api('monthly', p) | ||
| ]); | ||
| render(daily, sessions, monthly); | ||
| } catch(e) { | ||
| $('#loading').textContent = 'Error loading data: ' + e.message; | ||
| return; | ||
| } | ||
| $('#loading').classList.add('hide'); | ||
| $('#content').style.display = ''; | ||
| } |
There was a problem hiding this comment.
Prevent stale responses from overwriting a newer filter selection.
Every range change starts a new load(), but nothing cancels or supersedes the previous request. If an older Promise.all(...) resolves last, the dashboard re-renders with stale data for the wrong date range.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/ccusage/src/commands/_web_dashboard.ts` around lines 153 - 168, The load
function can render stale results because parallel API calls aren't tied to the
latest request; add a request-sequencing guard (e.g., a module-level counter
like currentLoadSeq) and on each load() increment it and capture the current seq
into a local loadSeq variable; after the Promise.all([...]) (and in the catch)
check that loadSeq === currentLoadSeq before calling render(...) or updating
`#loading/`#content so only the most recent load updates the UI; alternatively, if
api(...) supports AbortController, create a new AbortController for each load(),
pass its signal to api, and abort the previous controller before starting a new
load to cancel earlier requests.
| body.innerHTML = s.map(x => { | ||
| const sid = x.sessionId.split('-').slice(-2).join('-'); | ||
| const totalIn = (x.inputTokens||0) + (x.cacheCreationTokens||0) + (x.cacheReadTokens||0); | ||
| const hr = totalIn > 0 ? (x.cacheReadTokens||0) / totalIn : 0; | ||
| const models = (x.modelsUsed||[]).map(m => m.replace(/^claude-/, '').replace(/-2025\\d+$/, '')).join(', '); | ||
| return '<tr class="clickable" onclick="openSession(\\'' + x.sessionId + '\\')">' + | ||
| '<td>' + sid + '</td>' + | ||
| '<td>' + (x.lastActivity||'') + '</td>' + | ||
| '<td><span class="tag">' + models + '</span></td>' + | ||
| '<td class="r">' + fmt(x.inputTokens) + '</td>' + | ||
| '<td class="r">' + fmt(x.outputTokens) + '</td>' + | ||
| '<td class="r">' + fmt(x.cacheReadTokens) + '</td>' + | ||
| '<td class="r" style="color:var(--' + hitRateColor(hr) + ')">' + fmtPct(hr) + '</td>' + | ||
| '<td class="r">' + fmtCost(x.totalCost) + '</td></tr>'; | ||
| }).join(''); |
There was a problem hiding this comment.
Make the session drill-down keyboard accessible.
These rows are mouse-only right now. The <tr> is not focusable and has no Enter/Space handling, so keyboard users cannot open session details.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/ccusage/src/commands/_web_dashboard.ts` around lines 274 - 288, The
session rows generated in body.innerHTML are only mouse-clickable; make them
keyboard-accessible by adding a tabindex="0" and role="button" to the generated
<tr> with class "clickable", and attach a keydown handler that calls
openSession(x.sessionId) when Enter or Space is pressed (in the same
string/template where onclick="openSession(...)" is set). Update the mapping in
the body.innerHTML code block (the function that builds each row using
x.sessionId, sid, models, fmt, hitRateColor, fmtPct, fmtCost) so keyboard users
can focus the row and open the session via Enter/Space.
| return '<tr class="clickable" onclick="openSession(\\'' + x.sessionId + '\\')">' + | ||
| '<td>' + sid + '</td>' + | ||
| '<td>' + (x.lastActivity||'') + '</td>' + | ||
| '<td><span class="tag">' + models + '</span></td>' + | ||
| '<td class="r">' + fmt(x.inputTokens) + '</td>' + | ||
| '<td class="r">' + fmt(x.outputTokens) + '</td>' + | ||
| '<td class="r">' + fmt(x.cacheReadTokens) + '</td>' + | ||
| '<td class="r" style="color:var(--' + hitRateColor(hr) + ')">' + fmtPct(hr) + '</td>' + | ||
| '<td class="r">' + fmtCost(x.totalCost) + '</td></tr>'; |
There was a problem hiding this comment.
Stop injecting raw session data into innerHTML and inline JS.
sessionId, model names, timestamps, and error text are concatenated straight into markup/onclick strings here. A malformed or poisoned value can break the DOM or execute script in the dashboard; build the rows with createElement()/textContent and attach listeners programmatically.
Also applies to: 298-312
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/ccusage/src/commands/_web_dashboard.ts` around lines 279 - 287, The code
is building an HTML row by concatenating untrusted values (x.sessionId, sid,
models, timestamps, etc.) into a string and inserting it as innerHTML/inline JS;
replace this by creating elements with document.createElement, setting text
nodes via textContent for cells (use fmt/fmtPct/fmtCost/hitRateColor for
formatted text but assign results to textContent), and attach the row click
behavior with row.addEventListener('click', () => openSession(x.sessionId))
instead of an onclick string; apply the same change to the similar code block
around lines 298-312 so no user-controlled data is injected into markup or
inline event handlers.
| // Start server | ||
| const { serve } = await import('@hono/node-server'); | ||
| const server = serve({ fetch: app.fetch, port }); | ||
| const url = `http://localhost:${port}`; | ||
| logger.info(`Dashboard running at ${url}`); |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
Does @hono/node-server's serve({ fetch, port }) bind only to localhost by default, or all network interfaces? What option should be passed to force loopback-only binding?
💡 Result:
By default, @hono/node-server's serve({ fetch, port }) binds to all network interfaces (equivalent to '0.0.0.0' or undefined hostname in Node.js http.Server.listen). All official documentation examples use http://localhost:${port} for display purposes, but this does not indicate loopback-only binding. Node.js http.createServer.listen(port) without hostname binds to all interfaces. To force loopback-only binding (127.0.0.1), pass the hostname: '127.0.0.1' or hostname: 'localhost' option: ts serve({ fetch: app.fetch, port: 3000, hostname: '127.0.0.1' }) This is confirmed by the serve options table listing hostname: string | undefined (default undefined, meaning all interfaces), and source references in DeepWiki documentation.
Citations:
- 1: https://deepwiki.com/honojs/node-server/2.1-server-creation-and-configuration
- 2: https://github.com/honojs/node-server
- 3: https://hono.dev/docs/getting-started/nodejs
- 4: https://www.npmjs.com/package/@hono/node-server
Bind the dashboard server to loopback explicitly.
Starting the server without specifying a hostname makes it accessible on all network interfaces, turning this dashboard into a LAN-visible endpoint exposing usage and project metadata. Pass hostname: '127.0.0.1' to the serve() call to restrict it to loopback only:
serve({ fetch: app.fetch, port, hostname: '127.0.0.1' })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/ccusage/src/commands/web.ts` around lines 209 - 213, The dashboard
server is currently bound to all interfaces; update the serve invocation used to
start the server (the call to serve with { fetch: app.fetch, port }) to include
hostname: '127.0.0.1' so it only listens on loopback. Locate the dynamic import
and call to serve (the serve({ fetch: app.fetch, port }) call) and add the
hostname property, then keep the existing url/logging (logger.info) behavior
unchanged.
| const { serve } = await import('@hono/node-server'); | ||
| const server = serve({ fetch: app.fetch, port }); | ||
| const url = `http://localhost:${port}`; | ||
| logger.info(`Dashboard running at ${url}`); | ||
| logger.info('Press Ctrl+C to stop'); | ||
|
|
||
| // Auto-open browser | ||
| if (ctx.values.open) { | ||
| const cmd = | ||
| process.platform === 'darwin' | ||
| ? 'open' | ||
| : process.platform === 'win32' | ||
| ? 'start' | ||
| : 'xdg-open'; | ||
| try { | ||
| const spawn = await import('nano-spawn').then((m) => m.default); | ||
| await spawn(cmd, [url]); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# Find and read the web.ts file to confirm the dynamic imports
fd -t f "web.ts" apps/ccusage/src/commands/Repository: ryoppippi/ccusage
Length of output: 93
🏁 Script executed:
# Read the file around lines 210-226 to verify the exact code
head -250 apps/ccusage/src/commands/web.ts | tail -50Repository: ryoppippi/ccusage
Length of output: 1371
🏁 Script executed:
# Check the full context of the file - imports at top and structure
head -50 apps/ccusage/src/commands/web.tsRepository: ryoppippi/ccusage
Length of output: 1497
🏁 Script executed:
# Check package.json to verify `@hono/node-server` and nano-spawn are listed as dependencies
cat apps/ccusage/package.json | grep -A 200 '"dependencies"' | head -50Repository: ryoppippi/ccusage
Length of output: 43
🏁 Script executed:
# Also check in the root package.json or monorepo structure
cat package.json | grep -E '"@hono/node-server"|"nano-spawn"' || echo "Not found in root"Repository: ryoppippi/ccusage
Length of output: 78
🏁 Script executed:
# Check if there's a pnpm-lock.yaml or similar to verify these are real dependencies
fd -t f "package.json" apps/ccusage/ | head -5Repository: ryoppippi/ccusage
Length of output: 86
🏁 Script executed:
# Read the apps/ccusage/package.json directly
cat apps/ccusage/package.jsonRepository: ryoppippi/ccusage
Length of output: 3763
Replace both await import() calls with static imports.
This code violates the explicit guideline: "NEVER use await import() dynamic imports anywhere in the codebase - this causes tree-shaking issues." Both @hono/node-server and nano-spawn are standard dependencies and must be imported statically at the top of the file.
♻️ Suggested fix
+import { serve } from "@hono/node-server";
+import spawn from "nano-spawn";
import type { CostMode } from '../_types.ts';
import process from 'node:process';
import { calculateCacheHitRate } from '@ccusage/terminal/table';
import { define } from 'gunshi';
import { Hono } from 'hono/tiny';
@@
- const { serve } = await import('@hono/node-server');
const server = serve({ fetch: app.fetch, port });
@@
- const spawn = await import('nano-spawn').then((m) => m.default);
await spawn(cmd, [url]);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/ccusage/src/commands/web.ts` around lines 210 - 226, The dynamic imports
for '@hono/node-server' (used to call serve({ fetch: app.fetch, port })) and for
'nano-spawn' (used to get spawn and call await spawn(cmd, [url])) must be
replaced with static imports at the top of the module; remove the await
import(...) expressions and instead import { serve } from '@hono/node-server'
and the default export from 'nano-spawn' via standard ES imports, then update
the code to call serve(...) and spawn(...) directly (keep existing variable
names and logic for port, url, cmd and ctx.values.open).
| const cmd = | ||
| process.platform === 'darwin' | ||
| ? 'open' | ||
| : process.platform === 'win32' | ||
| ? 'start' | ||
| : 'xdg-open'; | ||
| try { | ||
| const spawn = await import('nano-spawn').then((m) => m.default); | ||
| await spawn(cmd, [url]); |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
On Windows, is startavailable as a standalone executable or only as acmd.exebuilt-in? Doesnano-spawn execute commands through a shell by default?
💡 Result:
On Windows, start is only available as a cmd.exe built-in command, not as a standalone executable. There is no start.exe file. nano-spawn does not execute commands through a shell by default. It uses the same API as Node.js child_process.spawn, which spawns the process directly without a shell unless the shell option is explicitly set to true.
Citations:
- 1: https://docs.microsoft.com/en-us/windows-server/administration/windows-commands/start
- 2: https://ss64.com/nt/start.html
- 3: https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/start
- 4: https://en.wikipedia.org/wiki/Start_(command)
- 5: https://github.com/sindresorhus/nano-spawn
- 6: https://www.npmjs.com/package/nano-spawn
Fix Windows URL opening: start requires shell invocation.
start is a cmd.exe built-in command, not a standalone executable. nano-spawn spawns processes directly without invoking a shell by default, so this will fail silently on Windows—the catch block swallows the error without any notification to the user. Pass { shell: true } as an option when spawning on Windows, or use cmd.exe /c start as the command.
Additionally, the dynamic import await import('nano-spawn') violates the coding guideline against await import() usage in this codebase.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/ccusage/src/commands/web.ts` around lines 218 - 226, The current code
uses await import('nano-spawn') and spawns the Windows "start" command without a
shell, causing it to fail silently; replace the dynamic import with a
static/top-level import of nano-spawn (the spawn symbol) and change the Windows
spawn logic so that either you call cmd.exe with args ['/c','start', url] or
call 'start' but pass { shell: true } to spawn; also ensure the catch block does
not silently swallow errors—log or rethrow the caught error so failures are
visible.
Summary
ccusage webcommand that starts a local HTTP server with an interactive web dashboardDashboard Features
Usage
Depends on #919
Test plan
ccusage web --no-open— server starts, accessible at localhostccusage web --port 8080— custom port works🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
--fulloption to view complete tables without column truncation