Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions dash/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'
import { useQuery, useQueryClient } from '@tanstack/react-query'

import {
approvePairing,
Expand Down Expand Up @@ -354,7 +354,6 @@ export function App() {
const { data, isError, error, refetch } = useQuery({
queryKey: ['devices', period, provider],
queryFn: () => fetchDevices(period, provider),
placeholderData: keepPreviousData,
// When devices are paired, re-pull periodically so a device that briefly
// dropped (asleep/network blip) reappears on its own instead of staying
// gone until you switch tabs.
Expand Down
49 changes: 38 additions & 11 deletions src/cli-date.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,20 +31,35 @@ export const PERIOD_LABELS: Record<Period, string> = {

const VALID_PERIODS: ReadonlyArray<Period> = ['today', 'week', '30days', 'month', 'all']

export function toPeriod(s: string): Period {
export class UsageQueryError extends Error {
constructor(message: string) {
super(message)
this.name = 'UsageQueryError'
}
}

export function parsePeriodOrThrow(s: string): Period {
if ((VALID_PERIODS as readonly string[]).includes(s)) return s as Period
// Fail loudly instead of silently coercing to 'week'. Previously a typo
// like `-p mounth` produced a quiet 7-day report and the user thought
// they were viewing the month.
process.stderr.write(
`codeburn: unknown period "${s}". Valid values: ${VALID_PERIODS.join(', ')}.\n`
)
process.exit(1)
throw new UsageQueryError(`Unknown period "${s}". Valid values: ${VALID_PERIODS.join(', ')}.`)
}

export function toPeriod(s: string): Period {
try {
return parsePeriodOrThrow(s)
} catch {
// Fail loudly instead of silently coercing to 'week'. Previously a typo
// like `-p mounth` produced a quiet 7-day report and the user thought
// they were viewing the month.
process.stderr.write(
`codeburn: unknown period "${s}". Valid values: ${VALID_PERIODS.join(', ')}.\n`
)
process.exit(1)
}
}

function parseLocalDate(s: string): Date {
if (!ISO_DATE_RE.test(s)) {
throw new Error(`Invalid date format "${s}": expected YYYY-MM-DD`)
throw new UsageQueryError(`Invalid date format "${s}": expected YYYY-MM-DD`)
}
const [y, m, d] = s.split('-').map(Number) as [number, number, number]
const date = new Date(y, m - 1, d)
Expand All @@ -53,7 +68,7 @@ function parseLocalDate(s: string): Date {
// dated Feb 28 - Mar 2. Reject overflow so the user gets a loud error
// instead of an off-by-N-days date range.
if (date.getFullYear() !== y || date.getMonth() !== m - 1 || date.getDate() !== d) {
throw new Error(`Invalid date "${s}": ${m}/${d}/${y} is not a real calendar date`)
throw new UsageQueryError(`Invalid date "${s}": ${m}/${d}/${y} is not a real calendar date`)
}
return date
}
Expand Down Expand Up @@ -118,7 +133,7 @@ export function parseDateRangeFlags(from: string | undefined, to: string | undef
const end = endOfLocalDay(endDate)

if (start > end) {
throw new Error(`--from must not be after --to (got ${from} > ${to})`)
throw new UsageQueryError(`--from must not be after --to (got ${from} > ${to})`)
}
return { start, end }
}
Expand Down Expand Up @@ -198,3 +213,15 @@ export function parseDaysFlag(days: string | undefined): { days: Set<string>; ra
export function formatDateRangeLabel(from: string | undefined, to: string | undefined): string {
return `${from ?? 'all'} to ${to ?? 'today'}`
}

/** Resolve a usage query period for HTTP handlers without calling process.exit. */
export function periodInfoFromQuery(
q: { period?: string; from?: string; to?: string },
defaultPeriod: string,
): { range: DateRange; label: string } {
const customRange = parseDateRangeFlags(q.from, q.to)
if (customRange) {
return { range: customRange, label: formatDateRangeLabel(q.from, q.to) }
}
return getDateRange(parsePeriodOrThrow(q.period ?? defaultPeriod))
}
7 changes: 2 additions & 5 deletions src/sharing/share-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { sanitizeForSharing } from './sanitize.js'
import { getSharingDir, loadPeers, savePeers } from './store.js'
import { loadPricing } from '../models.js'
import { buildMenubarPayloadForRange } from '../usage-aggregator.js'
import { getDateRange, parseDateRangeFlags, formatDateRangeLabel, toPeriod } from '../cli-date.js'
import { periodInfoFromQuery } from '../cli-date.js'

function lanAddress(): string | null {
for (const list of Object.values(networkInterfaces())) {
Expand All @@ -32,10 +32,7 @@ export async function runShareServer(opts: { port: number; pair: boolean; always
const peers = new PeerStore(await loadPeers(dir))

const getUsage = async (q: UsageQuery): Promise<unknown> => {
const customRange = parseDateRangeFlags(q.from, q.to)
const periodInfo = customRange
? { range: customRange, label: formatDateRangeLabel(q.from, q.to) }
: getDateRange(toPeriod(q.period ?? 'month'))
const periodInfo = periodInfoFromQuery(q, 'month')
return sanitizeForSharing(await buildMenubarPayloadForRange(periodInfo, { provider: 'all', optimize: false }))
}

Expand Down
6 changes: 5 additions & 1 deletion src/sharing/share-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { IncomingMessage, ServerResponse } from 'http'
import type { TLSSocket } from 'tls'
import type { AddressInfo } from 'net'

import { UsageQueryError } from '../cli-date.js'
import { certFingerprint, pairingCode, PeerStore, PairingWindow } from './pairing.js'
import type { Identity } from './identity.js'

Expand Down Expand Up @@ -82,7 +83,10 @@ export class ShareServer {
} catch (err) {
// Never leave a request hanging (a hung peer makes the caller time out
// and drop this device); always answer, even on an internal error.
if (!res.headersSent) json(500, { error: err instanceof Error ? err.message : String(err) })
if (!res.headersSent) {
const message = err instanceof Error ? err.message : String(err)
json(err instanceof UsageQueryError ? 400 : 500, { error: message })
}
}
}

Expand Down
36 changes: 23 additions & 13 deletions src/web-dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { hostname } from 'os'

import { loadPricing } from './models.js'
import { buildMenubarPayloadForRange } from './usage-aggregator.js'
import { getDateRange, parseDateRangeFlags, formatDateRangeLabel, toPeriod } from './cli-date.js'
import { periodInfoFromQuery, UsageQueryError } from './cli-date.js'
import { pullDevices, linkRemote } from './sharing/host.js'
import { browse } from './sharing/discovery.js'
import { loadOrCreateIdentity } from './sharing/identity.js'
Expand All @@ -31,6 +31,11 @@ function readBody(req: import('http').IncomingMessage): Promise<string> {
})
}

function writeJsonError(res: import('http').ServerResponse, status: number, error: string): void {
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
res.end(JSON.stringify({ error }))
}

const HERE = dirname(fileURLToPath(import.meta.url))

// Locate the built React dashboard (dist/dash). Works both when running from a
Expand Down Expand Up @@ -94,10 +99,7 @@ export async function runWebDashboard(opts: {
// Sharing this device serves the SANITIZED aggregate (no project names/paths
// or per-session detail), unlike the local /api/usage which shows everything.
const shareGetUsage = async (q: { period?: string; from?: string; to?: string }) => {
const customRange = parseDateRangeFlags(q.from, q.to)
const periodInfo = customRange
? { range: customRange, label: formatDateRangeLabel(q.from, q.to) }
: getDateRange(toPeriod(q.period ?? opts.period))
const periodInfo = periodInfoFromQuery(q, opts.period)
return sanitizeForSharing(await buildMenubarPayloadForRange(periodInfo, { provider: 'all', optimize: false }))
}
const share = new ShareController(shareGetUsage)
Expand Down Expand Up @@ -126,10 +128,14 @@ export async function runWebDashboard(opts: {
const provider = url.searchParams.get('provider') ?? opts.provider
const from = url.searchParams.get('from') ?? opts.from
const to = url.searchParams.get('to') ?? opts.to
const customRange = parseDateRangeFlags(from, to)
const periodInfo = customRange
? { range: customRange, label: formatDateRangeLabel(from, to) }
: getDateRange(toPeriod(period))
let periodInfo
try {
periodInfo = periodInfoFromQuery({ period, from, to }, opts.period)
} catch (err) {
if (!(err instanceof UsageQueryError)) throw err
writeJsonError(res, 400, err instanceof Error ? err.message : String(err))
return
}
const payload = await buildMenubarPayloadForRange(periodInfo, {
provider,
project: opts.project,
Expand All @@ -148,11 +154,15 @@ export async function runWebDashboard(opts: {
const provider = url.searchParams.get('provider') ?? opts.provider
const from = url.searchParams.get('from') ?? opts.from
const to = url.searchParams.get('to') ?? opts.to
try {
periodInfoFromQuery({ period, from, to }, opts.period)
} catch (err) {
if (!(err instanceof UsageQueryError)) throw err
writeJsonError(res, 400, err instanceof Error ? err.message : String(err))
return
}
const localGetUsage = async (q: { period?: string; from?: string; to?: string }) => {
const customRange = parseDateRangeFlags(q.from, q.to)
const periodInfo = customRange
? { range: customRange, label: formatDateRangeLabel(q.from, q.to) }
: getDateRange(toPeriod(q.period ?? period))
const periodInfo = periodInfoFromQuery(q, period)
return buildMenubarPayloadForRange(periodInfo, { provider, project: opts.project, exclude: opts.exclude, optimize: false })
}
const results = await pullDevices(localGetUsage, { period, from, to }, hostname(), {})
Expand Down
38 changes: 38 additions & 0 deletions tests/cli-date.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {
getDateRange,
PERIODS,
PERIOD_LABELS,
parsePeriodOrThrow,
periodInfoFromQuery,
toPeriod,
type Period,
} from '../src/cli-date.js'
Expand Down Expand Up @@ -104,6 +106,42 @@ describe('PERIODS / PERIOD_LABELS', () => {
})
})

describe('parsePeriodOrThrow', () => {
it('round-trips known periods', () => {
const known: Period[] = ['today', 'week', '30days', 'month', 'all']
for (const p of known) {
expect(parsePeriodOrThrow(p)).toBe(p)
}
})

it('throws on unknown input without calling process.exit', () => {
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit') })
try {
expect(() => parsePeriodOrThrow('garbage')).toThrow(/Unknown period "garbage"/)
expect(exitSpy).not.toHaveBeenCalled()
} finally {
exitSpy.mockRestore()
}
})
})

describe('periodInfoFromQuery', () => {
it('resolves a named period', () => {
const info = periodInfoFromQuery({ period: 'week' }, 'month')
expect(info.label).toBe('Last 7 Days')
})

it('throws for an invalid period without calling process.exit', () => {
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit') })
try {
expect(() => periodInfoFromQuery({ period: 'garbage' }, 'month')).toThrow(/Unknown period "garbage"/)
expect(exitSpy).not.toHaveBeenCalled()
} finally {
exitSpy.mockRestore()
}
})
})

describe('toPeriod', () => {
it('round-trips known periods', () => {
const known: Period[] = ['today', 'week', '30days', 'month', 'all']
Expand Down
75 changes: 75 additions & 0 deletions tests/sharing/transport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { generateIdentity, type Identity } from '../../src/sharing/identity.js'
import { PeerStore } from '../../src/sharing/pairing.js'
import { ShareServer } from '../../src/sharing/share-server.js'
import { getDateRange, parsePeriodOrThrow } from '../../src/cli-date.js'
import { hello, pair, fetchUsage } from '../../src/sharing/client.js'

describe('device sharing transport (loopback mutual TLS)', () => {
Expand Down Expand Up @@ -52,6 +53,80 @@ describe('device sharing transport (loopback mutual TLS)', () => {
expect((ur.json as { current: { cost: number } }).current.cost).toBe(42)
})

it('returns bad request when getUsage rejects an invalid period', async () => {
const badServer = new ShareServer({
identity: serverId,
peers,
getUsage: async (q) => {
getDateRange(parsePeriodOrThrow(q.period ?? 'month'))
return { current: { cost: 0 } }
},
})
const badPort = await badServer.listen(0, '127.0.0.1')
try {
const pin = badServer.openPairing()
const pr = await pair({ ...ep(), port: badPort }, pin, 'Mac Studio')
const token = (pr.json as { token: string }).token
const ur = await fetchUsage(
{ ...ep(), port: badPort, expectedFingerprint: serverId.fingerprint },
token,
{ period: 'garbage' },
)
expect(ur.status).toBe(400)
expect((ur.json as { error: string }).error).toMatch(/Unknown period "garbage"/)
} finally {
await badServer.close()
}
})

it('keeps unexpected getUsage failures as internal errors', async () => {
const badServer = new ShareServer({
identity: serverId,
peers,
getUsage: async () => {
throw new Error('database temporarily unavailable')
},
})
const badPort = await badServer.listen(0, '127.0.0.1')
try {
const pin = badServer.openPairing()
const pr = await pair({ ...ep(), port: badPort }, pin, 'Mac Studio')
const token = (pr.json as { token: string }).token
const ur = await fetchUsage(
{ ...ep(), port: badPort, expectedFingerprint: serverId.fingerprint },
token,
)
expect(ur.status).toBe(500)
expect((ur.json as { error: string }).error).toMatch(/database temporarily unavailable/)
} finally {
await badServer.close()
}
})

it('does not classify plain string-matched errors as usage validation errors', async () => {
const badServer = new ShareServer({
identity: serverId,
peers,
getUsage: async () => {
throw new Error('Unknown period "garbage". Valid values: today, week, 30days, month, all.')
},
})
const badPort = await badServer.listen(0, '127.0.0.1')
try {
const pin = badServer.openPairing()
const pr = await pair({ ...ep(), port: badPort }, pin, 'Mac Studio')
const token = (pr.json as { token: string }).token
const ur = await fetchUsage(
{ ...ep(), port: badPort, expectedFingerprint: serverId.fingerprint },
token,
)
expect(ur.status).toBe(500)
expect((ur.json as { error: string }).error).toMatch(/Unknown period "garbage"/)
} finally {
await badServer.close()
}
})

it('rejects a wrong PIN', async () => {
server.openPairing()
const pr = await pair(ep(), '000000', 'x')
Expand Down
Loading