diff --git a/README.md b/README.md index 020959b..8310248 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ccusage-fleet -Run [ccusage](https://github.com/ccusage/ccusage) on local and SSH-connected machines, then aggregate the usage reports into one daily view. +Run [ccusage](https://github.com/ccusage/ccusage) on local and SSH-connected machines, then aggregate daily, weekly, or monthly usage reports into one view. ```bash npx ccusage-fleet daily --hosts localhost,rtzr,jiun-mbp,jiun-mini @@ -25,6 +25,19 @@ npx ccusage-fleet daily --hosts localhost,rtzr,jiun-mbp,jiun-mini # Machine-readable output npx ccusage-fleet daily --hosts localhost,rtzr --json +# Weekly and monthly reports use the same options +npx ccusage-fleet weekly --hosts localhost,rtzr --group-by agent +npx ccusage-fleet monthly --hosts localhost,rtzr --group-by device + +# Default: date -> agent -> models +npx ccusage-fleet daily --hosts localhost,rtzr --group-by agent + +# Date -> device -> models +npx ccusage-fleet daily --hosts localhost,rtzr --group-by device + +# Date totals only +npx ccusage-fleet daily --hosts localhost,rtzr --group-by none + # One consistent date range and timezone on every device npx ccusage-fleet daily \ --hosts localhost,rtzr \ @@ -52,6 +65,7 @@ Create `ccusage-fleet.config.json`, `.ccusage-fleet.json`, or `~/.config/ccusage { "name": "jiun-mini", "type": "ssh", "target": "jiun-mini" } ], "timezone": "Asia/Seoul", + "groupBy": "agent", "concurrency": 4, "timeoutMs": 120000 } @@ -65,6 +79,18 @@ Named entries let the displayed device name differ from its SSH target: Command-line hosts override configured hosts. Repeated `--host` options are also supported. +## Grouping + +The default table follows ccusage's report shape: one `All` row per date, followed by agent rows and their models, and a final `Total` row. + +| Option | Detail rows | +| --- | --- | +| `--group-by agent` | Claude, Codex, and other detected agents aggregated across devices | +| `--group-by device` | One row per local or SSH device | +| `--group-by none` | Date totals without detail rows | + +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. + ## 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 210edf1..b78f9db 100644 --- a/ccusage-fleet.schema.json +++ b/ccusage-fleet.schema.json @@ -25,6 +25,7 @@ } }, "timezone": { "type": "string" }, + "groupBy": { "enum": ["agent", "device", "none"] }, "byHost": { "type": "boolean" }, "offline": { "type": "boolean" }, "noCost": { "type": "boolean" }, diff --git a/package-lock.json b/package-lock.json index b50a42a..37b81b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ccusage-fleet", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ccusage-fleet", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "dependencies": { "ccusage": "20.0.17" diff --git a/package.json b/package.json index a874153..06d9011 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ccusage-fleet", - "version": "0.1.0", + "version": "0.2.0", "description": "Aggregate ccusage reports across local and SSH-connected machines", "type": "module", "bin": { diff --git a/src/aggregate.js b/src/aggregate.js index d322cfb..fa5203b 100644 --- a/src/aggregate.js +++ b/src/aggregate.js @@ -80,10 +80,10 @@ export function mergeUsage(target, source, includeAgents = true) { return target; } -function aggregateRows(reports) { +function aggregateRows(reports, command) { const byPeriod = new Map(); for (const { report } of reports) { - for (const row of report.daily) { + for (const row of report[command]) { const period = String(row.period ?? row.date ?? 'unknown'); let target = byPeriod.get(period); if (target == null) { @@ -107,18 +107,19 @@ function totalsFromRows(rows) { export function aggregateFleet(results, settings) { const successful = results.filter((result) => result.status === 'ok'); - const daily = aggregateRows(successful); + const rows = aggregateRows(successful, settings.command); const byHost = successful.map((result) => ({ - daily: result.report.daily, + [settings.command]: result.report[settings.command], host: result.host.name, - totals: result.report.totals ?? totalsFromRows(result.report.daily), + totals: result.report.totals ?? totalsFromRows(result.report[settings.command]), })); return { byHost, ccusageVersion: settings.ccusageVersion, - command: 'daily', - daily, + command: settings.command, + [settings.command]: rows, generatedAt: new Date().toISOString(), + groupBy: settings.groupBy, hosts: results.map((result) => ({ durationMs: result.durationMs, error: result.error, @@ -129,6 +130,6 @@ export function aggregateFleet(results, settings) { })), schemaVersion: 1, timezone: settings.timezone, - totals: totalsFromRows(daily), + totals: totalsFromRows(rows), }; } diff --git a/src/args.js b/src/args.js index ffd5717..6d84079 100644 --- a/src/args.js +++ b/src/args.js @@ -9,6 +9,7 @@ const VALUE_OPTIONS = new Map([ ['--concurrency', 'concurrency'], ['--ssh-connect-timeout', 'sshConnectTimeoutSeconds'], ['--ccusage-version', 'ccusageVersion'], + ['--group-by', 'groupBy'], ]); const BOOLEAN_OPTIONS = new Map([ @@ -51,8 +52,8 @@ export function parseArgs(argv) { if (command != null) { throw new Error(`Unexpected argument '${argument}'`); } - if (argument !== 'daily') { - throw new Error(`Unsupported report '${argument}'. ccusage-fleet currently supports daily.`); + if (!['daily', 'weekly', 'monthly'].includes(argument)) { + throw new Error(`Unsupported report '${argument}'. Choose daily, weekly, or monthly.`); } command = argument; continue; @@ -88,7 +89,7 @@ export function helpText() { return `ccusage-fleet - aggregate ccusage across local and SSH hosts USAGE - ccusage-fleet [daily] [options] + ccusage-fleet [daily|weekly|monthly] [options] HOSTS --hosts Comma-separated hosts; local or localhost means this machine @@ -100,7 +101,8 @@ REPORT --until Include usage on or before this date --timezone Use one timezone on every host --json Emit machine-readable fleet JSON - --by-host / --no-by-host Show or hide device rows (default: show) + --group-by Group detail rows by agent, device, or none (default: agent) + --by-host / --no-by-host Compatibility aliases for --group-by device/none --no-cost Hide costs --offline / --online Use bundled or online ccusage pricing (default: offline) @@ -117,6 +119,8 @@ EXECUTION EXAMPLES npx ccusage-fleet daily --hosts localhost,rtzr,jiun-mbp,jiun-mini + npx ccusage-fleet weekly --hosts localhost,server --group-by agent + npx ccusage-fleet monthly --hosts localhost,server --group-by device CCUSAGE_FLEET_HOSTS=localhost,server npx ccusage-fleet daily --json `; } diff --git a/src/cli.js b/src/cli.js index 0f6e3fa..a38ea66 100644 --- a/src/cli.js +++ b/src/cli.js @@ -28,16 +28,12 @@ export async function runCli(argv, dependencies = {}) { process.stdout.write(`${packageJson.version}\n`); return 0; } - if (command !== 'daily') { - throw new Error(`Unsupported command '${command}'`); - } - const configPath = discoverConfigPath(options.config); const config = loadConfig(configPath); - const settings = resolveSettings(options, config, { + const settings = { ...resolveSettings(options, config, { ccusageVersion: packageJson.dependencies.ccusage, timezone: DEFAULT_TIMEZONE, - }); + }), command }; debug(settings, `timezone=${settings.timezone} hosts=${settings.hosts.map((host) => host.name).join(',')}`); if (configPath) debug(settings, `config=${configPath}`); diff --git a/src/config.js b/src/config.js index 28f9884..7874522 100644 --- a/src/config.js +++ b/src/config.js @@ -124,12 +124,32 @@ export function resolveSettings(parsedOptions, config, defaults) { throw new Error(`Invalid timezone '${timezone}'`); } + const cliCompatibilityGroup = parsedOptions.byHost === true + ? 'device' + : parsedOptions.byHost === false + ? 'none' + : undefined; + const configCompatibilityGroup = config.byHost === true + ? 'device' + : config.byHost === false + ? 'none' + : undefined; + const groupBy = parsedOptions.groupBy + ?? cliCompatibilityGroup + ?? process.env.CCUSAGE_FLEET_GROUP_BY + ?? config.groupBy + ?? configCompatibilityGroup + ?? 'agent'; + if (!['agent', 'device', 'none'].includes(groupBy)) { + throw new Error(`group-by must be agent, device, or none (received '${groupBy}')`); + } + return { - byHost: parsedOptions.byHost ?? config.byHost ?? true, ccusageVersion: parsedOptions.ccusageVersion ?? config.ccusageVersion ?? defaults.ccusageVersion, concurrency: integer(parsedOptions.concurrency ?? config.concurrency, 'concurrency', 4, 1, 32), debug: parsedOptions.debug ?? false, dryRun: parsedOptions.dryRun ?? false, + groupBy, hosts, json: parsedOptions.json ?? false, noCost: parsedOptions.noCost ?? config.noCost ?? false, diff --git a/src/runner.js b/src/runner.js index f48aebf..35d7800 100644 --- a/src/runner.js +++ b/src/runner.js @@ -9,7 +9,7 @@ export function shellQuote(value) { } export function buildCcusageArgs(settings) { - const args = ['daily', '--json', '--by-agent', '--no-color', '--timezone', settings.timezone]; + const args = [settings.command, '--json', '--by-agent', '--no-color', '--timezone', settings.timezone]; if (settings.since) { args.push('--since', settings.since); } @@ -119,7 +119,7 @@ export function spawnCapture(command, args, timeoutMs) { }); } -function parseReport(stdout) { +function parseReport(stdout, command) { const trimmed = stdout.trim(); if (!trimmed) { throw new Error('ccusage returned no JSON'); @@ -129,8 +129,8 @@ function parseReport(stdout) { const lastBrace = trimmed.lastIndexOf('}'); const json = firstBrace >= 0 && lastBrace >= firstBrace ? trimmed.slice(firstBrace, lastBrace + 1) : trimmed; const report = JSON.parse(json); - if (!Array.isArray(report.daily)) { - throw new Error('missing daily array'); + if (!Array.isArray(report[command])) { + throw new Error(`missing ${command} array`); } return report; } catch (error) { @@ -154,7 +154,7 @@ export async function runHost(host, settings, spawnFn = spawnCapture) { durationMs: result.durationMs, host, invocation, - report: parseReport(result.stdout), + report: parseReport(result.stdout, settings.command), status: 'ok', stderr: result.stderr.trim(), }; diff --git a/src/table.js b/src/table.js index 1169483..9d74afe 100644 --- a/src/table.js +++ b/src/table.js @@ -1,71 +1,183 @@ -function compactNumber(value) { - const number = Number(value) || 0; - const absolute = Math.abs(number); - if (absolute >= 1_000_000_000) return `${(number / 1_000_000_000).toFixed(1)}B`; - if (absolute >= 1_000_000) return `${(number / 1_000_000).toFixed(1)}M`; - if (absolute >= 1_000) return `${(number / 1_000).toFixed(1)}K`; - return String(Math.round(number)); +const NUMBER_FORMAT = new Intl.NumberFormat('en-US'); + +function number(value) { + return typeof value === 'number' && Number.isFinite(value) ? value : 0; } -function cost(value, noCost) { - return noCost ? '—' : `$${(Number(value) || 0).toFixed(2)}`; +function formatNumber(value) { + return NUMBER_FORMAT.format(Math.round(number(value))); } -function agents(row) { - const values = (row.agents ?? []).map((item) => item.agent).filter(Boolean); - return values.length > 0 ? values.join(',') : row.agent === 'all' ? '—' : row.agent; +function formatCost(value, noCost) { + return noCost ? '—' : `$${number(value).toFixed(2)}`; } -function pad(value, width, right = false) { - const text = String(value); - return right ? text.padStart(width) : text.padEnd(width); -} - -function renderRows(rows) { - const headers = ['Date', 'Host', 'Agents', 'Input', 'Output', 'Cache Create', 'Cache Read', 'Tokens', 'Cost']; - const widths = [10, 14, 18, 9, 9, 12, 11, 9, 10]; - const lines = []; - lines.push(headers.map((value, index) => pad(value, widths[index], index >= 3)).join(' ')); - lines.push(widths.map((width) => '─'.repeat(width)).join('──')); - for (const { host, row } of rows) { - const values = [ - row.period, - host, - agents(row), - compactNumber(row.inputTokens), - compactNumber(row.outputTokens), - compactNumber(row.cacheCreationTokens), - compactNumber(row.cacheReadTokens), - compactNumber(row.totalTokens), - row.formattedCost, - ]; - lines.push(values.map((value, index) => pad(value, widths[index], index >= 3)).join(' ')); +function titleCase(value) { + if (!value) return 'Unknown'; + return value.charAt(0).toUpperCase() + value.slice(1); +} + +function displayModelName(value) { + return String(value).replace(/^claude-/, ''); +} + +function modelLines(row) { + const models = row.modelsUsed ?? row.modelBreakdowns?.map((item) => item.modelName) ?? []; + return [...new Set(models)].sort().map((model) => `- ${displayModelName(model)}`); +} + +function usageCells(row, noCost) { + return { + cacheCreate: formatNumber(row.cacheCreationTokens), + cacheRead: formatNumber(row.cacheReadTokens), + cost: formatCost(row.totalCost, noCost), + input: formatNumber(row.inputTokens), + output: formatNumber(row.outputTokens), + total: formatNumber(row.totalTokens), + }; +} + +function detailRowsForPeriod(fleet, aggregate, groupBy) { + if (groupBy === 'agent') { + return (aggregate.agents ?? []).map((agent) => ({ + label: titleCase(agent.agent), + row: agent, + })); } - return lines.join('\n'); + if (groupBy === 'device') { + return fleet.byHost.flatMap((host) => { + const row = host[fleet.command].find((candidate) => candidate.period === aggregate.period); + return row == null ? [] : [{ label: host.host, row }]; + }); + } + return []; } -export function renderFleetTable(fleet, settings) { - const hostMap = new Map(fleet.byHost.map((entry) => [entry.host, entry.daily])); +function tableRows(fleet, settings) { const rows = []; - for (const aggregate of fleet.daily) { - rows.push({ host: 'ALL', row: { ...aggregate, formattedCost: cost(aggregate.totalCost, settings.noCost) } }); - if (settings.byHost) { - for (const host of settings.hosts) { - const hostRow = (hostMap.get(host.name) ?? []).find((row) => row.period === aggregate.period); - if (hostRow) { - rows.push({ - host: host.name, - row: { ...hostRow, formattedCost: cost(hostRow.totalCost, settings.noCost) }, - }); - } + for (const aggregate of fleet[fleet.command]) { + rows.push({ + date: aggregate.period, + group: 'All', + models: [], + ...usageCells(aggregate, settings.noCost), + }); + for (const detail of detailRowsForPeriod(fleet, aggregate, settings.groupBy)) { + rows.push({ + date: '', + group: `- ${detail.label}`, + models: modelLines(detail.row), + ...usageCells(detail.row, settings.noCost), + }); + } + } + rows.push({ + date: 'Total', + group: '', + models: [], + ...usageCells(fleet.totals, settings.noCost), + }); + return rows; +} + +function truncate(value, width) { + const text = String(value); + if (text.length <= width) return text; + if (width <= 1) return '…'; + return `${text.slice(0, width - 1)}…`; +} + +function cellLines(value) { + if (Array.isArray(value)) return value.length > 0 ? value.map(String) : ['']; + return String(value ?? '').split('\n'); +} + +function chooseWidths(columns, rows, terminalWidth) { + const overhead = columns.length * 3 + 1; + const available = Math.max(80, terminalWidth) - overhead; + const widths = columns.map((column) => { + const natural = Math.max( + column.header.length, + ...rows.flatMap((row) => cellLines(row[column.key]).map((line) => line.length)), + ); + return Math.min(column.max, Math.max(column.min, natural)); + }); + + while (widths.reduce((sum, width) => sum + width, 0) > available) { + let candidate = -1; + let slack = 0; + for (let index = 0; index < columns.length; index += 1) { + const currentSlack = widths[index] - columns[index].min; + if (currentSlack > slack) { + candidate = index; + slack = currentSlack; } } + if (candidate === -1) break; + widths[candidate] -= 1; } + return widths; +} + +function border(left, middle, right, widths) { + return `${left}${widths.map((width) => '─'.repeat(width + 2)).join(middle)}${right}`; +} + +function renderLogicalRow(row, columns, widths) { + const values = columns.map((column) => cellLines(row[column.key])); + const height = Math.max(...values.map((lines) => lines.length)); + const output = []; + for (let lineIndex = 0; lineIndex < height; lineIndex += 1) { + const cells = values.map((lines, index) => { + const value = truncate(lines[lineIndex] ?? '', widths[index]); + const padded = columns[index].align === 'right' + ? value.padStart(widths[index]) + : value.padEnd(widths[index]); + return ` ${padded} `; + }); + output.push(`│${cells.join('│')}│`); + } + return output; +} + +function renderTable(rows, groupBy, command, terminalWidth) { + const groupHeader = groupBy === 'device' ? 'Device' : groupBy === 'agent' ? 'Agent' : 'Group'; + const periodHeader = command === 'weekly' ? 'Week' : command === 'monthly' ? 'Month' : 'Date'; + const columns = [ + { key: 'date', header: periodHeader, min: 10, max: 10 }, + { key: 'group', header: groupHeader, min: 9, max: 16 }, + { key: 'models', header: 'Models', min: 14, max: 30 }, + { key: 'input', header: 'Input', min: 8, max: 18, align: 'right' }, + { key: 'output', header: 'Output', min: 8, max: 16, align: 'right' }, + { key: 'cacheCreate', header: 'Cache Create', min: 12, max: 18, align: 'right' }, + { key: 'cacheRead', header: 'Cache Read', min: 10, max: 20, align: 'right' }, + { key: 'total', header: 'Total Tokens', min: 12, max: 20, align: 'right' }, + { key: 'cost', header: 'Cost', min: 9, max: 12, align: 'right' }, + ]; + const widths = chooseWidths(columns, rows, terminalWidth); + const output = [border('┌', '┬', '┐', widths)]; + const header = Object.fromEntries(columns.map((column) => [column.key, column.header])); + output.push(...renderLogicalRow(header, columns, widths)); + output.push(border('├', '┼', '┤', widths)); + rows.forEach((row, index) => { + output.push(...renderLogicalRow(row, columns, widths)); + output.push(index === rows.length - 1 + ? border('└', '┴', '┘', widths) + : border('├', '┼', '┤', widths)); + }); + return output.join('\n'); +} +export function renderFleetTable(fleet, settings, terminalWidth = process.stdout.columns ?? 160) { const ok = fleet.hosts.filter((host) => host.status === 'ok').length; const failed = fleet.hosts.filter((host) => host.status === 'error').length; - const title = `ccusage Fleet Daily · ${ok}/${fleet.hosts.length} hosts · ${fleet.timezone}`; - const output = [title, '', rows.length > 0 ? renderRows(rows) : 'No usage data found.']; + const grouping = settings.groupBy === 'none' ? 'Totals only' : `Grouped by ${titleCase(settings.groupBy)}`; + const reportName = titleCase(fleet.command); + const title = `ccusage Fleet ${reportName} · ${grouping} · ${ok}/${fleet.hosts.length} hosts · ${fleet.timezone}`; + const output = [title, '']; + output.push(fleet[fleet.command].length > 0 + ? renderTable(tableRows(fleet, settings), settings.groupBy, fleet.command, terminalWidth) + : 'No usage data found.'); if (failed > 0) { output.push('', `${failed} host(s) failed; see stderr or use --json for details.`); } diff --git a/test/aggregate.test.js b/test/aggregate.test.js index 64bd4d4..52817a8 100644 --- a/test/aggregate.test.js +++ b/test/aggregate.test.js @@ -3,12 +3,12 @@ import test from 'node:test'; import { aggregateFleet } from '../src/aggregate.js'; -function result(host, agent, inputTokens, totalCost) { +function result(host, agent, inputTokens, totalCost, command = 'daily') { return { durationMs: 10, host: { name: host, target: host === 'localhost' ? undefined : host, type: host === 'localhost' ? 'local' : 'ssh' }, report: { - daily: [ + [command]: [ { agent: 'all', agents: [{ agent, inputTokens, outputTokens: 2, totalTokens: inputTokens + 2, totalCost }], @@ -27,7 +27,7 @@ function result(host, agent, inputTokens, totalCost) { test('aggregates daily and agent totals across hosts', () => { const fleet = aggregateFleet( [result('localhost', 'codex', 10, 1.25), result('rtzr', 'claude', 20, 2.5)], - { ccusageVersion: '20.0.17', timezone: 'Asia/Seoul' }, + { ccusageVersion: '20.0.17', command: 'daily', groupBy: 'agent', timezone: 'Asia/Seoul' }, ); assert.equal(fleet.daily.length, 1); assert.equal(fleet.daily[0].inputTokens, 30); @@ -36,3 +36,15 @@ test('aggregates daily and agent totals across hosts', () => { assert.deepEqual(fleet.daily[0].agents.map((item) => item.agent), ['claude', 'codex']); assert.equal(fleet.byHost.length, 2); }); + +test('uses the selected report key for weekly and monthly reports', () => { + for (const command of ['weekly', 'monthly']) { + const fleet = aggregateFleet( + [result('localhost', 'codex', 10, 1.25, command)], + { ccusageVersion: '20.0.17', command, groupBy: 'agent', timezone: 'UTC' }, + ); + assert.equal(fleet.command, command); + assert.equal(fleet[command].length, 1); + assert.equal(fleet.byHost[0][command].length, 1); + } +}); diff --git a/test/args-config.test.js b/test/args-config.test.js index 97629ad..682fd4e 100644 --- a/test/args-config.test.js +++ b/test/args-config.test.js @@ -24,6 +24,31 @@ test('supports named SSH config targets', () => { }); }); +test('defaults to agent grouping and supports device grouping', () => { + const defaultsToAgent = resolveSettings(parseArgs([]).options, {}, defaults); + assert.equal(defaultsToAgent.groupBy, 'agent'); + + const device = resolveSettings(parseArgs(['--group-by', 'device']).options, {}, defaults); + assert.equal(device.groupBy, 'device'); + + const compatible = resolveSettings(parseArgs(['--by-host']).options, {}, defaults); + assert.equal(compatible.groupBy, 'device'); + + const cliCompatibilityWins = resolveSettings( + parseArgs(['--by-host']).options, + { groupBy: 'agent' }, + defaults, + ); + assert.equal(cliCompatibilityWins.groupBy, 'device'); +}); + +test('rejects an unknown grouping', () => { + assert.throws( + () => resolveSettings(parseArgs(['--group-by', 'project']).options, {}, defaults), + /group-by must be agent, device, or none/, + ); +}); + 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/runner.test.js b/test/runner.test.js index eb0535e..9ed4f76 100644 --- a/test/runner.test.js +++ b/test/runner.test.js @@ -5,6 +5,7 @@ import { buildCcusageArgs, buildHostCommand, shellQuote } from '../src/runner.js const settings = { ccusageVersion: '20.0.17', + command: 'daily', noCost: false, offline: true, sshConnectTimeoutSeconds: 10, @@ -28,6 +29,11 @@ test('forwards supported ccusage flags', () => { assert.ok(args.includes('--no-cost')); }); +test('forwards weekly and monthly report commands', () => { + assert.equal(buildCcusageArgs({ ...settings, command: 'weekly' })[0], 'weekly'); + assert.equal(buildCcusageArgs({ ...settings, command: 'monthly' })[0], 'monthly'); +}); + test('quotes remote shell arguments', () => { assert.equal(shellQuote("a'b"), "'a'\\''b'"); }); diff --git a/test/table.test.js b/test/table.test.js new file mode 100644 index 0000000..eba6eec --- /dev/null +++ b/test/table.test.js @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { renderFleetTable } from '../src/table.js'; + +const agent = { + agent: 'claude', + cacheCreationTokens: 3000, + cacheReadTokens: 4000, + inputTokens: 1000, + modelsUsed: ['claude-opus-4-8'], + outputTokens: 2000, + totalCost: 12.34, + totalTokens: 10000, +}; +const daily = { + ...agent, + agent: 'all', + agents: [agent], + period: '2026-07-15', +}; +const fleet = { + byHost: [{ daily: [daily], host: 'rtzr', totals: daily }], + command: 'daily', + daily: [daily], + hosts: [{ name: 'rtzr', status: 'ok' }], + timezone: 'Asia/Seoul', + totals: daily, +}; + +test('renders ccusage-style agent table with models and totals', () => { + const output = renderFleetTable(fleet, { groupBy: 'agent', noCost: false }, 180); + assert.match(output, /Grouped by Agent/); + assert.match(output, /┌─/); + assert.match(output, /│ Date/); + assert.match(output, /- Claude/); + assert.match(output, /- opus-4-8/); + assert.match(output, /10,000/); + assert.match(output, /\$12\.34/); + assert.match(output, /Total/); +}); + +test('renders device detail rows', () => { + const output = renderFleetTable(fleet, { groupBy: 'device', noCost: true }, 180); + assert.match(output, /Grouped by Device/); + assert.match(output, /│ Device/); + assert.match(output, /- rtzr/); +}); + +test('renders totals only', () => { + const output = renderFleetTable(fleet, { groupBy: 'none', noCost: false }, 180); + assert.match(output, /Totals only/); + assert.doesNotMatch(output, /- Claude/); +}); + +test('renders weekly and monthly period headers', () => { + for (const [command, header] of [['weekly', 'Week'], ['monthly', 'Month']]) { + const report = { + ...fleet, + byHost: [{ [command]: [daily], host: 'rtzr', totals: daily }], + command, + [command]: [daily], + }; + const output = renderFleetTable(report, { groupBy: 'agent', noCost: false }, 180); + assert.match(output, new RegExp(`Fleet ${command.charAt(0).toUpperCase()}${command.slice(1)}`)); + assert.match(output, new RegExp(`│ ${header}`)); + } +});