Skip to content

feat: add ccusage web command with interactive dashboard#920

Open
BuluBulugege wants to merge 3 commits intoryoppippi:mainfrom
BuluBulugege:feat/web-dashboard
Open

feat: add ccusage web command with interactive dashboard#920
BuluBulugege wants to merge 3 commits intoryoppippi:mainfrom
BuluBulugege:feat/web-dashboard

Conversation

@BuluBulugege
Copy link
Copy Markdown

@BuluBulugege BuluBulugege commented Mar 31, 2026

Summary

  • Add ccusage web command that starts a local HTTP server with an interactive web dashboard
  • Hono-based API routes backed by existing data loaders (daily, monthly, weekly, session, blocks)
  • Self-contained single-page HTML dashboard with Chart.js visualization
  • Dark theme, date range filtering, session drill-down

Dashboard Features

  • Overview cards: total cost, total tokens, cache hit rate, session count
  • Daily token usage: stacked bar chart (input/output/cache create/cache read)
  • Cost trend: line chart with daily + cumulative cost
  • Model distribution: doughnut chart by cost
  • Session table: clickable rows with per-message detail modal
  • Date range picker: 7D / 30D / Month / All presets + custom date inputs

Usage

ccusage web                  # start on port 10002, auto-open browser
ccusage web --port 8080      # custom port
ccusage web --no-open        # don't auto-open browser
ccusage web --offline        # use cached pricing

Depends on #919

Test plan

  • ccusage web --no-open — server starts, accessible at localhost
  • Dashboard loads with charts and data
  • Date range filtering works (7D, 30D, custom)
  • Session rows clickable with detail modal
  • ccusage web --port 8080 — custom port works

🤖 Generated with Claude Code

Summary by CodeRabbit

New Features

  • Added --full option to view complete tables without column truncation
  • Cache hit rate metrics now calculated and included in all usage reports (daily, monthly, weekly, sessions)
  • New interactive web dashboard with real-time charts, cost trend visualization, and session details explorer

BuluBulugege and others added 3 commits March 31, 2026 21:26
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>
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 31, 2026

📝 Walkthrough

Walkthrough

This PR introduces a web dashboard feature for the ccusage app with a new --full flag to disable column truncation. A new web subcommand starts a Hono-based server serving an interactive dashboard and JSON API endpoints for usage data. Cache hit rate calculations are added across all usage commands, and the terminal table utility is enhanced with truncation control and cache hit rate formatting functions.

Changes

Cohort / File(s) Summary
Configuration & Dependencies
apps/ccusage/config-schema.json, apps/ccusage/package.json
Added full boolean property to schema defaults and command sections; added @hono/node-server and hono runtime dev dependencies.
CLI Arguments
apps/ccusage/src/_shared-args.ts
Added full boolean CLI argument with default false to control column truncation behavior.
Web Dashboard Command
apps/ccusage/src/commands/web.ts, apps/ccusage/src/commands/_web_dashboard.ts, apps/ccusage/src/commands/index.ts
Introduced new web subcommand with Hono server serving dashboard at / and JSON APIs (/api/daily, /api/monthly, /api/weekly, /api/session, /api/blocks); registered in command exports; includes self-contained HTML/JS/CSS dashboard template with Chart.js integration, time-range selection, and session details modal.
Usage Commands with Cache Hit Rate
apps/ccusage/src/commands/daily.ts, apps/ccusage/src/commands/monthly.ts, apps/ccusage/src/commands/weekly.ts, apps/ccusage/src/commands/session.ts
Added cacheHitRate calculation and JSON output for each command; wired noTruncate: ctx.values.full to table configuration; adjusted table separator row counts to accommodate new column layout.
Terminal Table Utilities
packages/terminal/src/table.ts
Added noTruncate option to TableOptions and UsageReportConfig to gate responsive scaling behavior; added formatCacheHitRate() and calculateCacheHitRate() utility functions; inserted new "Hit Rate" column into usage report headers and row formatting with color-coded percentage display.

Sequence Diagram

sequenceDiagram
    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
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly Related PRs

Suggested Reviewers

  • ryoppippi

Poem

🐰 A dashboard blooms with charts so bright,
Cache hit rates glowing in the night,
The --full flag sets columns free,
No truncation, wide as can be!
With Hono's strength, data takes flight,
Our reports now feel just right! ✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: add ccusage web command with interactive dashboard' directly and concisely captures the main change—adding a new web command with a dashboard.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 | 🟡 Minor

Use the merged full option here.

ctx.values.full bypasses mergeConfigWithArgs(), so a config-file full: true never reaches the table. noTruncate should read from mergedOptions.full to 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, but render() 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

📥 Commits

Reviewing files that changed from the base of the PR and between 61ee04d and 8c3af9d.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (11)
  • apps/ccusage/config-schema.json
  • apps/ccusage/package.json
  • apps/ccusage/src/_shared-args.ts
  • apps/ccusage/src/commands/_web_dashboard.ts
  • apps/ccusage/src/commands/daily.ts
  • apps/ccusage/src/commands/index.ts
  • apps/ccusage/src/commands/monthly.ts
  • apps/ccusage/src/commands/session.ts
  • apps/ccusage/src/commands/web.ts
  • apps/ccusage/src/commands/weekly.ts
  • packages/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>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, check the dashboard file to confirm CDN usage
head -20 apps/ccusage/src/commands/_web_dashboard.ts | cat -n

Repository: ryoppippi/ccusage

Length of output: 1244


🏁 Script executed:

# Check if chart.js is in project dependencies
find . -name "package.json" -type f | head -5

Repository: ryoppippi/ccusage

Length of output: 191


🏁 Script executed:

# Search for chart.js in package files
rg "chart\.js" --type json

Repository: 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 -20

Repository: 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.ts

Repository: ryoppippi/ccusage

Length of output: 108


🏁 Script executed:

# Check ccusage package.json for chart.js dependency
cat apps/ccusage/package.json

Repository: 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 -50

Repository: 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 -100

Repository: 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 -10

Repository: 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 ts

Repository: 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.ts

Repository: 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.ts

Repository: 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 size

Repository: 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:


🏁 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 2

Repository: 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.ts

Repository: 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.

Comment on lines +127 to +137
} 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));
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +153 to +168
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 = '';
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +274 to +288
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('');
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +279 to +287
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>';
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +209 to +213
// 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}`);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 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:


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.

Comment on lines +210 to +226
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]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 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 -50

Repository: 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.ts

Repository: 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 -50

Repository: 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 -5

Repository: ryoppippi/ccusage

Length of output: 86


🏁 Script executed:

# Read the apps/ccusage/package.json directly
cat apps/ccusage/package.json

Repository: 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).

Comment on lines +218 to +226
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]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 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:


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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant