From 322c2b30ee5e756c2815de35bd8c89bb4e30a4eb Mon Sep 17 00:00:00 2001 From: Jiun Bae Date: Wed, 15 Jul 2026 19:53:18 +0900 Subject: [PATCH] feat: add usage distribution graphs --- README.md | 14 +++++++++ ccusage-fleet.schema.json | 2 ++ package-lock.json | 4 +-- package.json | 2 +- src/args.js | 5 +++ src/cli.js | 7 ++++- src/config.js | 12 +++++++- src/graph.js | 65 +++++++++++++++++++++++++++++++++++++++ test/args-config.test.js | 13 ++++++++ test/graph.test.js | 35 +++++++++++++++++++++ 10 files changed, 154 insertions(+), 5 deletions(-) create mode 100644 src/graph.js create mode 100644 test/graph.test.js diff --git a/README.md b/README.md index 8310248..6c89d92 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,12 @@ npx ccusage-fleet daily --hosts localhost,rtzr --group-by device # Date totals only npx ccusage-fleet daily --hosts localhost,rtzr --group-by none +# Append a report-bucket token distribution graph +npx ccusage-fleet daily --hosts localhost,rtzr --graph + +# Scale the graph by estimated cost +npx ccusage-fleet daily --hosts localhost,rtzr --graph --graph-metric cost + # One consistent date range and timezone on every device npx ccusage-fleet daily \ --hosts localhost,rtzr \ @@ -66,6 +72,8 @@ Create `ccusage-fleet.config.json`, `.ccusage-fleet.json`, or `~/.config/ccusage ], "timezone": "Asia/Seoul", "groupBy": "agent", + "graph": false, + "graphMetric": "tokens", "concurrency": 4, "timeoutMs": 120000 } @@ -91,6 +99,12 @@ The default table follows ccusage's report shape: one `All` row per date, follow The former `--by-host` and `--no-by-host` options remain as aliases for `--group-by device` and `--group-by none`. Set `CCUSAGE_FLEET_GROUP_BY` to choose a default without a config file. +## Graph + +Add `--graph` to append a proportional distribution chart after the table. Bars use total tokens by default; choose `--graph-metric cost` to scale them by estimated cost. Buckets follow the report command: days for `daily`, weeks for `weekly`, and months for `monthly`. + +Hour buckets will require a future normalized event export from ccusage; ccusage-fleet does not copy raw transcripts to approximate them. + ## Failure handling By default, reachable hosts are reported even when another host fails. Add `--strict` to return a non-zero exit status if any host fails. `--timeout` is applied per host. diff --git a/ccusage-fleet.schema.json b/ccusage-fleet.schema.json index b78f9db..b0daef8 100644 --- a/ccusage-fleet.schema.json +++ b/ccusage-fleet.schema.json @@ -26,6 +26,8 @@ }, "timezone": { "type": "string" }, "groupBy": { "enum": ["agent", "device", "none"] }, + "graph": { "type": "boolean" }, + "graphMetric": { "enum": ["tokens", "cost"] }, "byHost": { "type": "boolean" }, "offline": { "type": "boolean" }, "noCost": { "type": "boolean" }, diff --git a/package-lock.json b/package-lock.json index 37b81b4..7e036ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ccusage-fleet", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ccusage-fleet", - "version": "0.2.0", + "version": "0.3.0", "license": "MIT", "dependencies": { "ccusage": "20.0.17" diff --git a/package.json b/package.json index 06d9011..9af9097 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ccusage-fleet", - "version": "0.2.0", + "version": "0.3.0", "description": "Aggregate ccusage reports across local and SSH-connected machines", "type": "module", "bin": { diff --git a/src/args.js b/src/args.js index 6d84079..8ead174 100644 --- a/src/args.js +++ b/src/args.js @@ -10,6 +10,7 @@ const VALUE_OPTIONS = new Map([ ['--ssh-connect-timeout', 'sshConnectTimeoutSeconds'], ['--ccusage-version', 'ccusageVersion'], ['--group-by', 'groupBy'], + ['--graph-metric', 'graphMetric'], ]); const BOOLEAN_OPTIONS = new Map([ @@ -22,6 +23,8 @@ const BOOLEAN_OPTIONS = new Map([ ['--no-cost', ['noCost', true]], ['--debug', ['debug', true]], ['--dry-run', ['dryRun', true]], + ['--graph', ['graph', true]], + ['--no-graph', ['graph', false]], ['--help', ['help', true]], ['-h', ['help', true]], ['--version', ['version', true]], @@ -103,6 +106,8 @@ REPORT --json Emit machine-readable fleet JSON --group-by Group detail rows by agent, device, or none (default: agent) --by-host / --no-by-host Compatibility aliases for --group-by device/none + --graph / --no-graph Show a report-bucket distribution graph + --graph-metric Scale graph bars by tokens or cost (default: tokens) --no-cost Hide costs --offline / --online Use bundled or online ccusage pricing (default: offline) diff --git a/src/cli.js b/src/cli.js index a38ea66..6d7eea8 100644 --- a/src/cli.js +++ b/src/cli.js @@ -7,6 +7,7 @@ import { helpText, parseArgs } from './args.js'; import { discoverConfigPath, loadConfig, resolveSettings } from './config.js'; import { mapConcurrent, runHost } from './runner.js'; import { renderFleetTable } from './table.js'; +import { renderFleetGraph } from './graph.js'; const here = dirname(fileURLToPath(import.meta.url)); const packageJson = JSON.parse(readFileSync(resolve(here, '..', 'package.json'), 'utf8')); @@ -62,7 +63,11 @@ export async function runCli(argv, dependencies = {}) { if (settings.json) { process.stdout.write(`${JSON.stringify(fleet, null, 2)}\n`); } else { - process.stdout.write(`${renderFleetTable(fleet, settings)}\n`); + const sections = [renderFleetTable(fleet, settings)]; + if (settings.graph) { + sections.push(renderFleetGraph(fleet, settings)); + } + process.stdout.write(`${sections.join('\n\n')}\n`); } return settings.strict && successful.length !== results.length ? 1 : 0; } diff --git a/src/config.js b/src/config.js index 7874522..488665b 100644 --- a/src/config.js +++ b/src/config.js @@ -143,6 +143,14 @@ export function resolveSettings(parsedOptions, config, defaults) { if (!['agent', 'device', 'none'].includes(groupBy)) { throw new Error(`group-by must be agent, device, or none (received '${groupBy}')`); } + const graphMetric = parsedOptions.graphMetric ?? config.graphMetric ?? 'tokens'; + if (!['tokens', 'cost'].includes(graphMetric)) { + throw new Error(`graph-metric must be tokens or cost (received '${graphMetric}')`); + } + const noCost = parsedOptions.noCost ?? config.noCost ?? false; + if (graphMetric === 'cost' && noCost) { + throw new Error('graph-metric cost cannot be combined with --no-cost'); + } return { ccusageVersion: parsedOptions.ccusageVersion ?? config.ccusageVersion ?? defaults.ccusageVersion, @@ -150,9 +158,11 @@ export function resolveSettings(parsedOptions, config, defaults) { debug: parsedOptions.debug ?? false, dryRun: parsedOptions.dryRun ?? false, groupBy, + graph: parsedOptions.graph ?? config.graph ?? false, + graphMetric, hosts, json: parsedOptions.json ?? false, - noCost: parsedOptions.noCost ?? config.noCost ?? false, + noCost, offline: parsedOptions.offline ?? config.offline ?? true, since: parsedOptions.since, sshConnectTimeoutSeconds: integer( diff --git a/src/graph.js b/src/graph.js new file mode 100644 index 0000000..adc9ce9 --- /dev/null +++ b/src/graph.js @@ -0,0 +1,65 @@ +function number(value) { + return typeof value === 'number' && Number.isFinite(value) ? value : 0; +} + +function compact(value, prefix = '') { + const numeric = number(value); + const absolute = Math.abs(numeric); + if (absolute >= 1_000_000_000) return `${prefix}${(numeric / 1_000_000_000).toFixed(1)}B`; + if (absolute >= 1_000_000) return `${prefix}${(numeric / 1_000_000).toFixed(1)}M`; + if (absolute >= 1_000) return `${prefix}${(numeric / 1_000).toFixed(1)}K`; + return `${prefix}${Math.round(numeric)}`; +} + +function bar(value, peak, width) { + if (value <= 0 || peak <= 0) return ' '.repeat(width); + const scaled = Math.min(width, (value / peak) * width); + let full = Math.floor(scaled); + const fraction = scaled - full; + const partials = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']; + const partialIndex = Math.round(fraction * 8); + if (partialIndex === 8) { + full += 1; + } + const partial = full < width ? partials[partialIndex] ?? '' : ''; + const used = Math.min(width, full + (partial ? 1 : 0)); + return `${'█'.repeat(Math.min(full, width))}${partial}${'░'.repeat(width - used)}`; +} + +function pad(value, width, right = false) { + const text = String(value); + return right ? text.padStart(width) : text.padEnd(width); +} + +function border(left, middle, right, widths, fill = '─') { + return `${left}${widths.map((width) => fill.repeat(width + 2)).join(middle)}${right}`; +} + +export function renderFleetGraph(fleet, settings, terminalWidth = process.stdout.columns ?? 120) { + const rows = fleet[fleet.command]; + const bucketName = fleet.command === 'weekly' ? 'week' : fleet.command === 'monthly' ? 'month' : 'day'; + const metricKey = settings.graphMetric === 'cost' ? 'totalCost' : 'totalTokens'; + const values = rows.map((row) => number(row[metricKey])); + const total = values.reduce((sum, value) => sum + value, 0); + const peak = Math.max(0, ...values); + const metricTitle = settings.graphMetric === 'cost' ? 'Cost' : 'Total tokens'; + const metricValue = (value) => settings.graphMetric === 'cost' ? compact(value, '$') : compact(value); + const title = `${metricTitle} over time · ${bucketName} buckets · total ${metricValue(total)} · peak ${metricValue(peak)}`; + + const widths = [10, 10, 10, Math.max(16, Math.min(60, terminalWidth - 46))]; + const output = [title, border('╭', '┬', '╮', widths)]; + const headers = ['BUCKET', 'TOKENS', 'COST', 'DISTRIBUTION']; + output.push(`│ ${headers.map((header, index) => pad(header, widths[index])).join(' ┆ ')} │`); + output.push(border('╞', '╪', '╡', widths, '═')); + for (const row of rows) { + const cells = [ + pad(row.period, widths[0]), + pad(compact(row.totalTokens), widths[1], true), + pad(settings.noCost ? '—' : compact(row.totalCost, '$'), widths[2], true), + bar(number(row[metricKey]), peak, widths[3]), + ]; + output.push(`│ ${cells.join(' ┆ ')} │`); + } + output.push(border('╰', '┴', '╯', widths)); + return output.join('\n'); +} diff --git a/test/args-config.test.js b/test/args-config.test.js index 682fd4e..79ad223 100644 --- a/test/args-config.test.js +++ b/test/args-config.test.js @@ -49,6 +49,19 @@ test('rejects an unknown grouping', () => { ); }); +test('configures token and cost graphs', () => { + const tokens = resolveSettings(parseArgs(['--graph']).options, {}, defaults); + assert.equal(tokens.graph, true); + assert.equal(tokens.graphMetric, 'tokens'); + + const cost = resolveSettings(parseArgs(['--graph', '--graph-metric', 'cost']).options, {}, defaults); + assert.equal(cost.graphMetric, 'cost'); + assert.throws( + () => resolveSettings(parseArgs(['--graph-metric', 'cost', '--no-cost']).options, {}, defaults), + /cannot be combined with --no-cost/, + ); +}); + test('rejects SSH option and shell injection', () => { assert.throws(() => validateSshTarget('-oProxyCommand=bad'), /Invalid SSH target/); assert.throws(() => validateSshTarget('host;touch /tmp/pwned'), /Invalid SSH target/); diff --git a/test/graph.test.js b/test/graph.test.js new file mode 100644 index 0000000..ce0f2ef --- /dev/null +++ b/test/graph.test.js @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { renderFleetGraph } from '../src/graph.js'; + +const fleet = { + command: 'daily', + daily: [ + { period: '2026-07-14', totalCost: 25, totalTokens: 500 }, + { period: '2026-07-15', totalCost: 50, totalTokens: 1000 }, + ], +}; + +test('renders a proportional day-bucket token graph', () => { + const output = renderFleetGraph(fleet, { graphMetric: 'tokens', noCost: false }, 100); + assert.match(output, /Total tokens over time · day buckets · total 1\.5K · peak 1\.0K/); + assert.match(output, /2026-07-14/); + assert.match(output, /████/); + assert.match(output, /░/); + assert.match(output, /╭─/); +}); + +test('supports cost-scaled graphs', () => { + const output = renderFleetGraph(fleet, { graphMetric: 'cost', noCost: false }, 100); + assert.match(output, /Cost over time/); + assert.match(output, /total \$75/); +}); + +test('uses the active report bucket label', () => { + for (const [command, bucket] of [['weekly', 'week'], ['monthly', 'month']]) { + const report = { command, [command]: fleet.daily }; + const output = renderFleetGraph(report, { graphMetric: 'tokens', noCost: false }, 100); + assert.match(output, new RegExp(`${bucket} buckets`)); + } +});