mirror of
https://github.com/JohnRiceML/clawport-ui.git
synced 2026-08-14 00:47:50 +00:00
- Break CostsPage (1500 lines) into 8 sub-components under components/costs/ - Add sidebar usage widget showing Claude Code 5-hour and weekly utilization - Fix usage API: never cache zeros, retry on 429 with backoff, hide when unavailable - Remove system theme option (dark/glass/color/light only) - Compact sidebar nav (36px items, scrollable nav area, pinned footer) - LiveStreamWidget: always visible as collapsed pill, play/pause on pill - Live logs: add search bar and level filter pills (INF/WRN/ERR/DBG) - Rename "Live Stream" to "Live Logs" throughout - Weekly cap shows reset day instead of countdown - Add PipelineDetailPanel and pipelines API route - Add cron-utils with parseCronExpression and tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
100 lines
2.9 KiB
TypeScript
100 lines
2.9 KiB
TypeScript
import { execSync } from 'child_process'
|
|
import { readFileSync, writeFileSync, mkdirSync } from 'fs'
|
|
import { join } from 'path'
|
|
import type { ClaudeCodeUsage } from '@/lib/types'
|
|
|
|
const ANTHROPIC_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
|
|
const CACHE_PATH = join(process.env.HOME ?? '/tmp', '.cache', 'clawport-ui', 'usage.json')
|
|
const MAX_RETRIES = 3
|
|
const RETRY_DELAYS = [2000, 5000, 10000] // escalating backoffs
|
|
|
|
function readCache(): ClaudeCodeUsage | null {
|
|
try {
|
|
const data: ClaudeCodeUsage = JSON.parse(readFileSync(CACHE_PATH, 'utf-8'))
|
|
if (data.fiveHour.utilization === 0 && data.sevenDay.utilization === 0) return null
|
|
return data
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function writeCache(usage: ClaudeCodeUsage): void {
|
|
if (usage.fiveHour.utilization === 0 && usage.sevenDay.utilization === 0) return
|
|
try {
|
|
mkdirSync(join(process.env.HOME ?? '/tmp', '.cache', 'clawport-ui'), { recursive: true })
|
|
writeFileSync(CACHE_PATH, JSON.stringify(usage))
|
|
} catch { /* best effort */ }
|
|
}
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise(resolve => setTimeout(resolve, ms))
|
|
}
|
|
|
|
/**
|
|
* Read the Claude Code OAuth token from the macOS Keychain.
|
|
*/
|
|
export function getKeychainToken(): string | null {
|
|
try {
|
|
const raw = execSync(
|
|
'security find-generic-password -s "Claude Code-credentials" -w',
|
|
{ encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] },
|
|
).trim()
|
|
if (!raw) return null
|
|
|
|
const parsed = JSON.parse(raw)
|
|
const token = parsed?.claudeAiOauth?.accessToken
|
|
return typeof token === 'string' && token.length > 0 ? token : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch Claude Code subscription usage (five-hour window + seven-day cap).
|
|
* Retries on 429 with backoff. Falls back to disk cache on failure.
|
|
* Returns null when no real utilization data is available.
|
|
*/
|
|
export async function fetchClaudeCodeUsage(): Promise<ClaudeCodeUsage | null> {
|
|
const token = getKeychainToken()
|
|
if (!token) return null
|
|
|
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
try {
|
|
const res = await fetch(ANTHROPIC_USAGE_URL, {
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
Accept: 'application/json',
|
|
'anthropic-beta': 'oauth-2025-04-20',
|
|
},
|
|
signal: AbortSignal.timeout(10000),
|
|
})
|
|
|
|
if (res.status === 429 && attempt < MAX_RETRIES) {
|
|
await sleep(RETRY_DELAYS[attempt])
|
|
continue
|
|
}
|
|
if (!res.ok) return readCache()
|
|
|
|
const data = await res.json()
|
|
|
|
const usage: ClaudeCodeUsage = {
|
|
fiveHour: {
|
|
utilization: data.five_hour?.utilization ?? 0,
|
|
resetsAt: data.five_hour?.resets_at ?? null,
|
|
},
|
|
sevenDay: {
|
|
utilization: data.seven_day?.utilization ?? 0,
|
|
resetsAt: data.seven_day?.resets_at ?? null,
|
|
},
|
|
}
|
|
|
|
writeCache(usage)
|
|
return usage
|
|
} catch {
|
|
return readCache()
|
|
}
|
|
}
|
|
|
|
return readCache()
|
|
}
|