mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-10 01:29:41 +00:00
feat(devices): joined Totals by machine report
Rework codeburn devices into a spacious joined report matching the screenshot: Host / Cost / Total tokens / Input / Output / Cache create / Cache read, with full comma-grouped numbers and a bold Combined row. Add a reusable box-drawing table renderer (text-table.ts).
This commit is contained in:
parent
1dba4e0aa4
commit
4a86b90795
2 changed files with 93 additions and 22 deletions
|
|
@ -7,11 +7,14 @@ import type { UsageQuery } from './share-server.js'
|
|||
import { getSharingDir, loadRemotes, saveRemotes, type RemoteDevice } from './store.js'
|
||||
import type { MenubarPayload } from '../menubar-json.js'
|
||||
import { formatCost } from '../currency.js'
|
||||
import { formatTokens } from '../format.js'
|
||||
import { renderTable } from '../text-table.js'
|
||||
import { Chalk } from 'chalk'
|
||||
|
||||
// Minimal shape we read from a device's usage payload (the menubar payload).
|
||||
// Cache create/read are only in the daily history, so we sum those.
|
||||
type DevicePayload = {
|
||||
current?: { cost?: number; calls?: number; sessions?: number; inputTokens?: number; outputTokens?: number }
|
||||
history?: { daily?: Array<{ cacheReadTokens?: number; cacheWriteTokens?: number }> }
|
||||
}
|
||||
|
||||
export type DeviceUsage = {
|
||||
|
|
@ -112,36 +115,63 @@ export async function pullDevices(
|
|||
return [local, ...remoteResults]
|
||||
}
|
||||
|
||||
// Joined "Totals by machine" report: one row per device plus a bold Combined
|
||||
// row. Tokens are shown as full, comma-grouped numbers.
|
||||
export function renderDevices(results: DeviceUsage[]): string {
|
||||
const num = (n: number | undefined): number => n ?? 0
|
||||
const n = (x: number): string => Math.round(x).toLocaleString()
|
||||
const money = (x: number): string => formatCost(x).replace(/(\d)(?=(\d{3})+(\.|$))/g, '$1,')
|
||||
const rows = results.map((d) => {
|
||||
const c = d.payload?.current
|
||||
const cur = d.payload?.current
|
||||
const daily = d.payload?.history?.daily ?? []
|
||||
const input = num(cur?.inputTokens)
|
||||
const output = num(cur?.outputTokens)
|
||||
const cacheCreate = daily.reduce((s, e) => s + num(e.cacheWriteTokens), 0)
|
||||
const cacheRead = daily.reduce((s, e) => s + num(e.cacheReadTokens), 0)
|
||||
return {
|
||||
name: d.name + (d.local ? ' (this Mac)' : ''),
|
||||
cost: num(c?.cost),
|
||||
tokens: num(c?.inputTokens) + num(c?.outputTokens),
|
||||
calls: num(c?.calls),
|
||||
sessions: num(c?.sessions),
|
||||
error: d.error,
|
||||
cost: num(cur?.cost),
|
||||
input,
|
||||
output,
|
||||
cacheCreate,
|
||||
cacheRead,
|
||||
total: input + output + cacheCreate + cacheRead,
|
||||
}
|
||||
})
|
||||
const combined = rows.reduce(
|
||||
(a, r) => ({ cost: a.cost + r.cost, tokens: a.tokens + r.tokens, calls: a.calls + r.calls, sessions: a.sessions + r.sessions }),
|
||||
{ cost: 0, tokens: 0, calls: 0, sessions: 0 },
|
||||
(a, r) => ({
|
||||
cost: a.cost + r.cost,
|
||||
input: a.input + r.input,
|
||||
output: a.output + r.output,
|
||||
cacheCreate: a.cacheCreate + r.cacheCreate,
|
||||
cacheRead: a.cacheRead + r.cacheRead,
|
||||
total: a.total + r.total,
|
||||
}),
|
||||
{ cost: 0, input: 0, output: 0, cacheCreate: 0, cacheRead: 0, total: 0 },
|
||||
)
|
||||
|
||||
const nameW = Math.max(8, ...rows.map((r) => r.name.length), 'Combined'.length)
|
||||
const line = (name: string, cost: string, tokens: string, calls: string): string =>
|
||||
` ${name.padEnd(nameW)} ${cost.padStart(11)} ${tokens.padStart(9)} ${calls.padStart(8)}`
|
||||
|
||||
const out: string[] = []
|
||||
out.push(line('Device', 'Cost', 'Tokens', 'Calls'))
|
||||
out.push(' ' + '-'.repeat(nameW + 11 + 9 + 8 + 6))
|
||||
for (const r of rows) {
|
||||
if (r.error) out.push(line(r.name, '-', '-', r.error))
|
||||
else out.push(line(r.name, formatCost(r.cost), formatTokens(r.tokens), r.calls.toLocaleString()))
|
||||
}
|
||||
out.push(' ' + '-'.repeat(nameW + 11 + 9 + 8 + 6))
|
||||
out.push(line('Combined', formatCost(combined.cost), formatTokens(combined.tokens), combined.calls.toLocaleString()))
|
||||
return out.join('\n') + '\n'
|
||||
const tableRows = [
|
||||
...rows.map((r) =>
|
||||
r.error
|
||||
? [r.name, r.error, '-', '-', '-', '-', '-']
|
||||
: [r.name, money(r.cost), n(r.total), n(r.input), n(r.output), n(r.cacheCreate), n(r.cacheRead)],
|
||||
),
|
||||
['Combined', money(combined.cost), n(combined.total), n(combined.input), n(combined.output), n(combined.cacheCreate), n(combined.cacheRead)],
|
||||
]
|
||||
const table = renderTable(
|
||||
[
|
||||
{ header: 'Host' },
|
||||
{ header: 'Cost', right: true },
|
||||
{ header: 'Total tokens', right: true },
|
||||
{ header: 'Input', right: true },
|
||||
{ header: 'Output', right: true },
|
||||
{ header: 'Cache create', right: true },
|
||||
{ header: 'Cache read', right: true },
|
||||
],
|
||||
tableRows,
|
||||
{ boldRows: new Set([tableRows.length - 1]) },
|
||||
)
|
||||
const heading = new Chalk({}).cyan('Totals by machine')
|
||||
return heading + '\n' + table + '\n'
|
||||
}
|
||||
|
|
|
|||
41
src/text-table.ts
Normal file
41
src/text-table.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { Chalk } from 'chalk'
|
||||
|
||||
export type TableColumn = { header: string; right?: boolean }
|
||||
|
||||
// Visible width, ignoring ANSI color codes, so padding stays aligned.
|
||||
function vlen(s: string): number {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return s.replace(/\[[0-9;]*m/g, '').length
|
||||
}
|
||||
|
||||
// Box-drawing table with roomy 2-space cell padding, right-aligned numeric
|
||||
// columns, and optional bold rows (e.g. a Combined/total row). Color is
|
||||
// auto-detected; pass color:false to force plain text.
|
||||
export function renderTable(
|
||||
columns: TableColumn[],
|
||||
rows: string[][],
|
||||
opts: { color?: boolean; boldRows?: ReadonlySet<number> } = {},
|
||||
): string {
|
||||
const c = new Chalk(opts.color === false ? { level: 0 } : {})
|
||||
const bold = opts.boldRows ?? new Set<number>()
|
||||
const widths = columns.map((col, i) => Math.max(vlen(col.header), ...rows.map((r) => vlen(r[i] ?? ''))))
|
||||
const pad = (s: string, w: number, right?: boolean): string => {
|
||||
const fill = ' '.repeat(Math.max(0, w - vlen(s)))
|
||||
return right ? fill + s : s + fill
|
||||
}
|
||||
const gap = ' '
|
||||
const sep = gap + '│' + gap
|
||||
const bar = (l: string, mid: string, r: string): string => l + widths.map((w) => '─'.repeat(w + 4)).join(mid) + r
|
||||
const line = (cells: string[], makeBold: boolean): string =>
|
||||
'│' + gap + columns.map((col, i) => {
|
||||
const padded = pad(cells[i] ?? '', widths[i]!, col.right)
|
||||
return makeBold ? c.bold(padded) : padded
|
||||
}).join(sep) + gap + '│'
|
||||
return [
|
||||
bar('┌', '┬', '┐'),
|
||||
line(columns.map((col) => col.header), false),
|
||||
bar('├', '┼', '┤'),
|
||||
...rows.map((r, i) => line(r, bold.has(i))),
|
||||
bar('└', '┴', '┘'),
|
||||
].join('\n')
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue