|
1 | 1 | import { useEffect, useState } from 'react' |
2 | | -import { DollarSign, Zap, Activity, Calculator, type LucideIcon } from 'lucide-react' |
| 2 | +import { DollarSign, Zap, Activity, Calculator, Image, type LucideIcon } from 'lucide-react' |
3 | 3 | import { api } from '../lib/api' |
4 | 4 | import { |
5 | 5 | LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, |
@@ -91,15 +91,53 @@ function SkeletonCard() { |
91 | 91 | ) |
92 | 92 | } |
93 | 93 |
|
| 94 | +interface ImageCostEntry { |
| 95 | + timestamp: string |
| 96 | + model: string |
| 97 | + provider: string |
| 98 | + mode: string |
| 99 | + output_file: string |
| 100 | + size_bytes: number |
| 101 | + elapsed_seconds: number |
| 102 | + token_usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number } |
| 103 | +} |
| 104 | + |
| 105 | +interface ImageCosts { |
| 106 | + entries: ImageCostEntry[] |
| 107 | + totals: { count: number; total_tokens: number; total_seconds: number; total_bytes: number } |
| 108 | +} |
| 109 | + |
| 110 | +function relativeTime(ts: string): string { |
| 111 | + try { |
| 112 | + const diff = Date.now() - new Date(ts).getTime() |
| 113 | + const min = Math.floor(diff / 60000) |
| 114 | + if (min < 1) return 'just now' |
| 115 | + if (min < 60) return `${min}m ago` |
| 116 | + const hr = Math.floor(min / 60) |
| 117 | + if (hr < 24) return `${hr}h ago` |
| 118 | + return `${Math.floor(hr / 24)}d ago` |
| 119 | + } catch { return ts } |
| 120 | +} |
| 121 | + |
| 122 | +function formatBytes(b: number): string { |
| 123 | + if (b < 1024) return `${b} B` |
| 124 | + if (b < 1024 * 1024) return `${(b / 1024).toFixed(0)} KB` |
| 125 | + return `${(b / (1024 * 1024)).toFixed(1)} MB` |
| 126 | +} |
| 127 | + |
94 | 128 | export default function Costs() { |
95 | 129 | const [data, setData] = useState<CostData | null>(null) |
| 130 | + const [imageCosts, setImageCosts] = useState<ImageCosts | null>(null) |
96 | 131 | const [loading, setLoading] = useState(true) |
97 | 132 |
|
98 | 133 | useEffect(() => { |
99 | | - api.get('/costs') |
100 | | - .then((raw) => setData(normalizeCostData(raw))) |
101 | | - .catch(() => setData(null)) |
102 | | - .finally(() => setLoading(false)) |
| 134 | + Promise.all([ |
| 135 | + api.get('/costs').catch(() => null), |
| 136 | + api.get('/routines/image-costs').catch(() => null), |
| 137 | + ]).then(([costRaw, imgRaw]) => { |
| 138 | + if (costRaw) setData(normalizeCostData(costRaw)) |
| 139 | + if (imgRaw) setImageCosts(imgRaw) |
| 140 | + }).finally(() => setLoading(false)) |
103 | 141 | }, []) |
104 | 142 |
|
105 | 143 | if (loading) { |
@@ -270,6 +308,60 @@ export default function Costs() { |
270 | 308 | </table> |
271 | 309 | </div> |
272 | 310 | </div> |
| 311 | + |
| 312 | + {/* Image Generation Costs */} |
| 313 | + {imageCosts && imageCosts.entries.length > 0 && ( |
| 314 | + <div className="bg-[#161b22] border border-[#21262d] rounded-2xl overflow-hidden mt-6 transition-all duration-300 hover:shadow-[0_0_32px_rgba(0,255,167,0.04)]"> |
| 315 | + <div className="p-5 border-b border-[#21262d] flex items-center justify-between"> |
| 316 | + <h2 className="text-base font-semibold text-[#e6edf3] flex items-center gap-2.5"> |
| 317 | + <div className="flex items-center justify-center w-7 h-7 rounded-lg bg-[#F472B6]/10 border border-[#F472B6]/20"> |
| 318 | + <Image size={14} className="text-[#F472B6]" /> |
| 319 | + </div> |
| 320 | + Image Generation |
| 321 | + </h2> |
| 322 | + <div className="flex items-center gap-4 text-[11px] text-[#667085]"> |
| 323 | + <span>{imageCosts.totals.count} images</span> |
| 324 | + <span>{imageCosts.totals.total_tokens.toLocaleString()} tokens</span> |
| 325 | + <span>{formatBytes(imageCosts.totals.total_bytes)}</span> |
| 326 | + <span>{imageCosts.totals.total_seconds}s total</span> |
| 327 | + </div> |
| 328 | + </div> |
| 329 | + <div className="overflow-x-auto"> |
| 330 | + <table className="w-full text-sm"> |
| 331 | + <thead> |
| 332 | + <tr className="text-[#667085] text-[11px] uppercase tracking-wider font-medium"> |
| 333 | + <th className="text-left p-4 pb-3">Model</th> |
| 334 | + <th className="text-left p-4 pb-3">Provider</th> |
| 335 | + <th className="text-left p-4 pb-3">Output</th> |
| 336 | + <th className="text-right p-4 pb-3">Tokens</th> |
| 337 | + <th className="text-right p-4 pb-3">Size</th> |
| 338 | + <th className="text-right p-4 pb-3">Time</th> |
| 339 | + <th className="text-right p-4 pb-3">When</th> |
| 340 | + </tr> |
| 341 | + </thead> |
| 342 | + <tbody> |
| 343 | + {[...imageCosts.entries].reverse().map((e, i) => ( |
| 344 | + <tr key={i} className="border-t border-[#21262d]/60 hover:bg-white/[0.02] transition-colors group"> |
| 345 | + <td className="p-4"> |
| 346 | + <code className="text-[11px] text-[#F472B6] font-mono bg-[#F472B6]/8 px-2 py-0.5 rounded border border-[#F472B6]/15"> |
| 347 | + {e.model.split('/').pop()} |
| 348 | + </code> |
| 349 | + </td> |
| 350 | + <td className="p-4 text-[#8b949e] text-[13px]">{e.provider} <span className="text-[#667085]">({e.mode})</span></td> |
| 351 | + <td className="p-4 text-[#e6edf3] text-[13px] font-medium group-hover:text-white truncate max-w-[200px]" title={e.output_file}> |
| 352 | + {e.output_file.split('/').pop()} |
| 353 | + </td> |
| 354 | + <td className="p-4 text-right text-[#8b949e] tabular-nums text-[13px]">{e.token_usage.total_tokens.toLocaleString()}</td> |
| 355 | + <td className="p-4 text-right text-[#8b949e] tabular-nums text-[13px]">{formatBytes(e.size_bytes)}</td> |
| 356 | + <td className="p-4 text-right text-[#667085] tabular-nums text-[13px]">{e.elapsed_seconds.toFixed(1)}s</td> |
| 357 | + <td className="p-4 text-right text-[#667085] text-[13px] whitespace-nowrap">{relativeTime(e.timestamp)}</td> |
| 358 | + </tr> |
| 359 | + ))} |
| 360 | + </tbody> |
| 361 | + </table> |
| 362 | + </div> |
| 363 | + </div> |
| 364 | + )} |
273 | 365 | </div> |
274 | 366 | ) |
275 | 367 | } |
0 commit comments