feat(ui): add cost tracking to usage dashboard and pricing settings
- Add toggle view (Tokens/Costs) to UsageStats component - Display cost breakdown in usage tables - Add Total Cost card combined with Total Output Tokens - Create PricingModal component for editing rates - Create Pricing Settings page 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
173
src/app/dashboard/settings/pricing/page.js
Normal file
173
src/app/dashboard/settings/pricing/page.js
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Card from "@/shared/components/Card";
|
||||||
|
import PricingModal from "@/shared/components/PricingModal";
|
||||||
|
|
||||||
|
export default function PricingSettingsPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [currentPricing, setCurrentPricing] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadPricing();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadPricing = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/pricing");
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setCurrentPricing(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load pricing:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePricingUpdated = () => {
|
||||||
|
loadPricing();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Count total models with pricing
|
||||||
|
const getModelCount = () => {
|
||||||
|
if (!currentPricing) return 0;
|
||||||
|
let count = 0;
|
||||||
|
for (const provider in currentPricing) {
|
||||||
|
count += Object.keys(currentPricing[provider]).length;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get providers list
|
||||||
|
const getProviders = () => {
|
||||||
|
if (!currentPricing) return [];
|
||||||
|
return Object.keys(currentPricing).sort();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-6xl mx-auto p-6 space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">Pricing Settings</h1>
|
||||||
|
<p className="text-text-muted mt-1">
|
||||||
|
Configure pricing rates for cost tracking and calculations
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowModal(true)}
|
||||||
|
className="px-4 py-2 bg-primary text-white rounded hover:bg-primary/90 transition-colors"
|
||||||
|
>
|
||||||
|
Edit Pricing
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Stats */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="text-text-muted text-sm uppercase font-semibold">
|
||||||
|
Total Models
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold mt-1">
|
||||||
|
{loading ? "..." : getModelCount()}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="text-text-muted text-sm uppercase font-semibold">
|
||||||
|
Providers
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold mt-1">
|
||||||
|
{loading ? "..." : getProviders().length}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="text-text-muted text-sm uppercase font-semibold">
|
||||||
|
Status
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold mt-1 text-success">
|
||||||
|
{loading ? "..." : "Active"}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Section */}
|
||||||
|
<Card className="p-6">
|
||||||
|
<h2 className="text-xl font-semibold mb-4">How Pricing Works</h2>
|
||||||
|
<div className="space-y-3 text-sm text-text-muted">
|
||||||
|
<p>
|
||||||
|
<strong>Cost Calculation:</strong> Costs are calculated based on token usage and pricing rates.
|
||||||
|
Each request's cost is determined by: (input_tokens × input_rate) + (output_tokens × output_rate) + (cached_tokens × cached_rate)
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Pricing Format:</strong> All rates are in <strong>dollars per million tokens</strong> ($/1M tokens).
|
||||||
|
Example: An input rate of 2.50 means $2.50 per 1,000,000 input tokens.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Token Types:</strong>
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc list-inside ml-4 space-y-1">
|
||||||
|
<li><strong>Input:</strong> Standard prompt tokens</li>
|
||||||
|
<li><strong>Output:</strong> Completion/response tokens</li>
|
||||||
|
<li><strong>Cached:</strong> Cached input tokens (typically 50% of input rate)</li>
|
||||||
|
<li><strong>Reasoning:</strong> Special reasoning/thinking tokens (fallback to output rate)</li>
|
||||||
|
<li><strong>Cache Creation:</strong> Tokens used to create cache entries (fallback to input rate)</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
<strong>Custom Pricing:</strong> You can override default pricing for specific models.
|
||||||
|
Reset to defaults anytime to restore standard rates.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Current Pricing Preview */}
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-xl font-semibold">Current Pricing Overview</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowModal(true)}
|
||||||
|
className="text-primary hover:underline text-sm"
|
||||||
|
>
|
||||||
|
View Full Details
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-4 text-text-muted">Loading pricing data...</div>
|
||||||
|
) : currentPricing ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Object.keys(currentPricing).slice(0, 5).map(provider => (
|
||||||
|
<div key={provider} className="text-sm">
|
||||||
|
<span className="font-semibold">{provider.toUpperCase()}:</span>{" "}
|
||||||
|
<span className="text-text-muted">
|
||||||
|
{Object.keys(currentPricing[provider]).length} models
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{Object.keys(currentPricing).length > 5 && (
|
||||||
|
<div className="text-sm text-text-muted">
|
||||||
|
+ {Object.keys(currentPricing).length - 5} more providers
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-text-muted">No pricing data available</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Pricing Modal */}
|
||||||
|
{showModal && (
|
||||||
|
<PricingModal
|
||||||
|
isOpen={showModal}
|
||||||
|
onClose={() => setShowModal(false)}
|
||||||
|
onSave={handlePricingUpdated}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
208
src/shared/components/PricingModal.js
Normal file
208
src/shared/components/PricingModal.js
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { getDefaultPricing, formatCost } from "@/shared/constants/pricing.js";
|
||||||
|
|
||||||
|
export default function PricingModal({ isOpen, onClose, onSave }) {
|
||||||
|
const [pricingData, setPricingData] = useState({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
loadPricing();
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const loadPricing = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/pricing");
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setPricingData(data);
|
||||||
|
} else {
|
||||||
|
// Fallback to defaults
|
||||||
|
const defaults = getDefaultPricing();
|
||||||
|
setPricingData(defaults);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load pricing:", error);
|
||||||
|
const defaults = getDefaultPricing();
|
||||||
|
setPricingData(defaults);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePricingChange = (provider, model, field, value) => {
|
||||||
|
const numValue = parseFloat(value);
|
||||||
|
if (isNaN(numValue) || numValue < 0) return;
|
||||||
|
|
||||||
|
setPricingData(prev => {
|
||||||
|
const newData = { ...prev };
|
||||||
|
if (!newData[provider]) newData[provider] = {};
|
||||||
|
if (!newData[provider][model]) newData[provider][model] = {};
|
||||||
|
newData[provider][model][field] = numValue;
|
||||||
|
return newData;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/pricing", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(pricingData)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
onSave?.();
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
alert(`Failed to save pricing: ${error.error}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to save pricing:", error);
|
||||||
|
alert("Failed to save pricing");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = async () => {
|
||||||
|
if (!confirm("Reset all pricing to defaults? This cannot be undone.")) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/pricing", { method: "DELETE" });
|
||||||
|
if (response.ok) {
|
||||||
|
const defaults = getDefaultPricing();
|
||||||
|
setPricingData(defaults);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to reset pricing:", error);
|
||||||
|
alert("Failed to reset pricing");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
// Get all unique providers and models for display
|
||||||
|
const allProviders = Object.keys(pricingData).sort();
|
||||||
|
const pricingFields = ["input", "output", "cached", "reasoning", "cache_creation"];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-bg-base border border-border rounded-lg shadow-xl max-w-6xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-4 border-b border-border flex items-center justify-between">
|
||||||
|
<h2 className="text-xl font-semibold">Pricing Configuration</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-text-muted hover:text-text text-2xl leading-none"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-auto p-4">
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-8 text-text-muted">Loading pricing data...</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Instructions */}
|
||||||
|
<div className="bg-bg-subtle border border-border rounded-lg p-3 text-sm">
|
||||||
|
<p className="font-medium mb-1">Pricing Rates Format</p>
|
||||||
|
<p className="text-text-muted">
|
||||||
|
All rates are in <strong>dollars per million tokens</strong> ($/1M tokens).
|
||||||
|
Example: Input rate of 2.50 means $2.50 per 1,000,000 input tokens.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pricing Tables */}
|
||||||
|
{allProviders.map(provider => {
|
||||||
|
const models = Object.keys(pricingData[provider]).sort();
|
||||||
|
return (
|
||||||
|
<div key={provider} className="border border-border rounded-lg overflow-hidden">
|
||||||
|
<div className="bg-bg-subtle px-4 py-2 font-semibold text-sm">
|
||||||
|
{provider.toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-bg-hover text-text-muted uppercase text-xs">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 text-left">Model</th>
|
||||||
|
<th className="px-3 py-2 text-right">Input</th>
|
||||||
|
<th className="px-3 py-2 text-right">Output</th>
|
||||||
|
<th className="px-3 py-2 text-right">Cached</th>
|
||||||
|
<th className="px-3 py-2 text-right">Reasoning</th>
|
||||||
|
<th className="px-3 py-2 text-right">Cache Creation</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{models.map(model => (
|
||||||
|
<tr key={model} className="hover:bg-bg-subtle/50">
|
||||||
|
<td className="px-3 py-2 font-medium">{model}</td>
|
||||||
|
{pricingFields.map(field => (
|
||||||
|
<td key={field} className="px-3 py-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
value={pricingData[provider][model][field] || 0}
|
||||||
|
onChange={(e) => handlePricingChange(provider, model, field, e.target.value)}
|
||||||
|
className="w-20 px-2 py-1 text-right bg-bg-base border border-border rounded focus:outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{allProviders.length === 0 && (
|
||||||
|
<div className="text-center py-8 text-text-muted">
|
||||||
|
No pricing data available
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="p-4 border-t border-border flex items-center justify-between gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleReset}
|
||||||
|
className="px-4 py-2 text-sm text-red-500 hover:bg-red-500/10 rounded border border-red-500/20 transition-colors"
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
Reset to Defaults
|
||||||
|
</button>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm text-text-muted hover:text-text border border-border rounded transition-colors"
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
className="px-4 py-2 text-sm bg-primary text-white rounded hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving ? "Saving..." : "Save Changes"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ export default function UsageStats() {
|
|||||||
const [stats, setStats] = useState(null);
|
const [stats, setStats] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||||
|
const [viewMode, setViewMode] = useState("tokens"); // 'tokens' or 'costs'
|
||||||
|
|
||||||
const toggleSort = (field) => {
|
const toggleSort = (field) => {
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
@@ -51,12 +52,31 @@ export default function UsageStats() {
|
|||||||
|
|
||||||
const sortData = (dataMap, pendingMap = {}) => {
|
const sortData = (dataMap, pendingMap = {}) => {
|
||||||
return Object.entries(dataMap || {})
|
return Object.entries(dataMap || {})
|
||||||
.map(([key, data]) => ({
|
.map(([key, data]) => {
|
||||||
...data,
|
const totalTokens =
|
||||||
key,
|
(data.promptTokens || 0) + (data.completionTokens || 0);
|
||||||
totalTokens: (data.promptTokens || 0) + (data.completionTokens || 0),
|
const totalCost = data.cost || 0;
|
||||||
pending: pendingMap[key] || 0,
|
|
||||||
}))
|
// Calculate cost breakdown (estimated based on token ratio)
|
||||||
|
const inputCost =
|
||||||
|
totalTokens > 0
|
||||||
|
? (data.promptTokens || 0) * (totalCost / totalTokens)
|
||||||
|
: 0;
|
||||||
|
const outputCost =
|
||||||
|
totalTokens > 0
|
||||||
|
? (data.completionTokens || 0) * (totalCost / totalTokens)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
key,
|
||||||
|
totalTokens,
|
||||||
|
totalCost,
|
||||||
|
inputCost,
|
||||||
|
outputCost,
|
||||||
|
pending: pendingMap[key] || 0,
|
||||||
|
};
|
||||||
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
let valA = a[sortBy];
|
let valA = a[sortBy];
|
||||||
let valB = b[sortBy];
|
let valB = b[sortBy];
|
||||||
@@ -133,6 +153,9 @@ export default function UsageStats() {
|
|||||||
// Format number with commas
|
// Format number with commas
|
||||||
const fmt = (n) => new Intl.NumberFormat().format(n || 0);
|
const fmt = (n) => new Intl.NumberFormat().format(n || 0);
|
||||||
|
|
||||||
|
// Format cost with dollar sign and 2 decimals
|
||||||
|
const fmtCost = (n) => `$${(n || 0).toFixed(2)}`;
|
||||||
|
|
||||||
// Time format for "Last Used"
|
// Time format for "Last Used"
|
||||||
const fmtTime = (iso) => {
|
const fmtTime = (iso) => {
|
||||||
if (!iso) return "Never";
|
if (!iso) return "Never";
|
||||||
@@ -149,10 +172,35 @@ export default function UsageStats() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
{/* Header with Auto Refresh Toggle */}
|
{/* Header with Auto Refresh Toggle and View Toggle */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-xl font-semibold">Usage Overview</h2>
|
<h2 className="text-xl font-semibold">Usage Overview</h2>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{/* View Toggle */}
|
||||||
|
<div className="flex items-center gap-1 bg-bg-subtle rounded-lg p-1 border border-border">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode("tokens")}
|
||||||
|
className={`px-3 py-1 rounded-md text-sm font-medium transition-colors ${
|
||||||
|
viewMode === "tokens"
|
||||||
|
? "bg-primary text-white shadow-sm"
|
||||||
|
: "text-text-muted hover:text-text hover:bg-bg-hover"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Tokens
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode("costs")}
|
||||||
|
className={`px-3 py-1 rounded-md text-sm font-medium transition-colors ${
|
||||||
|
viewMode === "costs"
|
||||||
|
? "bg-primary text-white shadow-sm"
|
||||||
|
: "text-text-muted hover:text-text hover:bg-bg-hover"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Costs
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auto Refresh Toggle */}
|
||||||
<label className="text-sm font-medium text-text-muted flex items-center gap-2 cursor-pointer">
|
<label className="text-sm font-medium text-text-muted flex items-center gap-2 cursor-pointer">
|
||||||
<span>Auto Refresh (1s)</span>
|
<span>Auto Refresh (1s)</span>
|
||||||
<div
|
<div
|
||||||
@@ -207,7 +255,7 @@ export default function UsageStats() {
|
|||||||
|
|
||||||
{/* Overview Cards */}
|
{/* Overview Cards */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<Card className="p-4 flex flex-col gap-1">
|
<Card className="px-4 py-3 flex flex-col gap-1">
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="text-text-muted text-sm uppercase font-semibold">
|
<span className="text-text-muted text-sm uppercase font-semibold">
|
||||||
@@ -223,7 +271,7 @@ export default function UsageStats() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="p-4 flex flex-col gap-1">
|
<Card className="px-4 py-3 flex flex-col gap-1">
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="text-text-muted text-sm uppercase font-semibold">
|
<span className="text-text-muted text-sm uppercase font-semibold">
|
||||||
@@ -239,9 +287,9 @@ export default function UsageStats() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="p-4 flex flex-col gap-1">
|
<Card className="px-4 py-2 flex flex-col gap-1">
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start gap-4">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1 flex-1">
|
||||||
<span className="text-text-muted text-sm uppercase font-semibold">
|
<span className="text-text-muted text-sm uppercase font-semibold">
|
||||||
Total Output Tokens
|
Total Output Tokens
|
||||||
</span>
|
</span>
|
||||||
@@ -249,10 +297,15 @@ export default function UsageStats() {
|
|||||||
{fmt(stats.totalCompletionTokens)}
|
{fmt(stats.totalCompletionTokens)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<MiniBarGraph
|
<div className="w-px bg-border self-stretch mx-2" />
|
||||||
data={(stats.last10Minutes || []).map((m) => m.completionTokens)}
|
<div className="flex flex-col gap-1 flex-1">
|
||||||
colorClass="bg-success/50"
|
<span className="text-text-muted text-sm uppercase font-semibold">
|
||||||
/>
|
Total Cost
|
||||||
|
</span>
|
||||||
|
<span className="text-2xl font-bold text-warning">
|
||||||
|
{fmtCost(stats.totalCost)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -310,39 +363,79 @@ export default function UsageStats() {
|
|||||||
currentOrder={sortOrder}
|
currentOrder={sortOrder}
|
||||||
/>
|
/>
|
||||||
</th>
|
</th>
|
||||||
<th
|
{viewMode === "tokens" ? (
|
||||||
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
|
<>
|
||||||
onClick={() => toggleSort("promptTokens")}
|
<th
|
||||||
>
|
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
|
||||||
Input Tokens{" "}
|
onClick={() => toggleSort("promptTokens")}
|
||||||
<SortIcon
|
>
|
||||||
field="promptTokens"
|
Input Tokens{" "}
|
||||||
currentSort={sortBy}
|
<SortIcon
|
||||||
currentOrder={sortOrder}
|
field="promptTokens"
|
||||||
/>
|
currentSort={sortBy}
|
||||||
</th>
|
currentOrder={sortOrder}
|
||||||
<th
|
/>
|
||||||
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
|
</th>
|
||||||
onClick={() => toggleSort("completionTokens")}
|
<th
|
||||||
>
|
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
|
||||||
Output Tokens{" "}
|
onClick={() => toggleSort("completionTokens")}
|
||||||
<SortIcon
|
>
|
||||||
field="completionTokens"
|
Output Tokens{" "}
|
||||||
currentSort={sortBy}
|
<SortIcon
|
||||||
currentOrder={sortOrder}
|
field="completionTokens"
|
||||||
/>
|
currentSort={sortBy}
|
||||||
</th>
|
currentOrder={sortOrder}
|
||||||
<th
|
/>
|
||||||
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
|
</th>
|
||||||
onClick={() => toggleSort("totalTokens")}
|
<th
|
||||||
>
|
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
|
||||||
Total Tokens{" "}
|
onClick={() => toggleSort("totalTokens")}
|
||||||
<SortIcon
|
>
|
||||||
field="totalTokens"
|
Total Tokens{" "}
|
||||||
currentSort={sortBy}
|
<SortIcon
|
||||||
currentOrder={sortOrder}
|
field="totalTokens"
|
||||||
/>
|
currentSort={sortBy}
|
||||||
</th>
|
currentOrder={sortOrder}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<th
|
||||||
|
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
|
||||||
|
onClick={() => toggleSort("promptTokens")}
|
||||||
|
>
|
||||||
|
Input Cost{" "}
|
||||||
|
<SortIcon
|
||||||
|
field="promptTokens"
|
||||||
|
currentSort={sortBy}
|
||||||
|
currentOrder={sortOrder}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
|
||||||
|
onClick={() => toggleSort("completionTokens")}
|
||||||
|
>
|
||||||
|
Output Cost{" "}
|
||||||
|
<SortIcon
|
||||||
|
field="completionTokens"
|
||||||
|
currentSort={sortBy}
|
||||||
|
currentOrder={sortOrder}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
|
||||||
|
onClick={() => toggleSort("cost")}
|
||||||
|
>
|
||||||
|
Total Cost{" "}
|
||||||
|
<SortIcon
|
||||||
|
field="cost"
|
||||||
|
currentSort={sortBy}
|
||||||
|
currentOrder={sortOrder}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border">
|
<tbody className="divide-y divide-border">
|
||||||
@@ -367,15 +460,31 @@ export default function UsageStats() {
|
|||||||
<td className="px-6 py-3 text-right text-text-muted whitespace-nowrap">
|
<td className="px-6 py-3 text-right text-text-muted whitespace-nowrap">
|
||||||
{fmtTime(data.lastUsed)}
|
{fmtTime(data.lastUsed)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-3 text-right text-text-muted">
|
{viewMode === "tokens" ? (
|
||||||
{fmt(data.promptTokens)}
|
<>
|
||||||
</td>
|
<td className="px-6 py-3 text-right text-text-muted">
|
||||||
<td className="px-6 py-3 text-right text-text-muted">
|
{fmt(data.promptTokens)}
|
||||||
{fmt(data.completionTokens)}
|
</td>
|
||||||
</td>
|
<td className="px-6 py-3 text-right text-text-muted">
|
||||||
<td className="px-6 py-3 text-right font-medium">
|
{fmt(data.completionTokens)}
|
||||||
{fmt(data.totalTokens)}
|
</td>
|
||||||
</td>
|
<td className="px-6 py-3 text-right font-medium">
|
||||||
|
{fmt(data.totalTokens)}
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<td className="px-6 py-3 text-right text-text-muted">
|
||||||
|
{fmtCost(data.inputCost)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3 text-right text-text-muted">
|
||||||
|
{fmtCost(data.outputCost)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3 text-right font-medium text-warning">
|
||||||
|
{fmtCost(data.totalCost)}
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{sortedModels.length === 0 && (
|
{sortedModels.length === 0 && (
|
||||||
@@ -457,39 +566,79 @@ export default function UsageStats() {
|
|||||||
currentOrder={sortOrder}
|
currentOrder={sortOrder}
|
||||||
/>
|
/>
|
||||||
</th>
|
</th>
|
||||||
<th
|
{viewMode === "tokens" ? (
|
||||||
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
|
<>
|
||||||
onClick={() => toggleSort("promptTokens")}
|
<th
|
||||||
>
|
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
|
||||||
Input Tokens{" "}
|
onClick={() => toggleSort("promptTokens")}
|
||||||
<SortIcon
|
>
|
||||||
field="promptTokens"
|
Input Tokens{" "}
|
||||||
currentSort={sortBy}
|
<SortIcon
|
||||||
currentOrder={sortOrder}
|
field="promptTokens"
|
||||||
/>
|
currentSort={sortBy}
|
||||||
</th>
|
currentOrder={sortOrder}
|
||||||
<th
|
/>
|
||||||
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
|
</th>
|
||||||
onClick={() => toggleSort("completionTokens")}
|
<th
|
||||||
>
|
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
|
||||||
Output Tokens{" "}
|
onClick={() => toggleSort("completionTokens")}
|
||||||
<SortIcon
|
>
|
||||||
field="completionTokens"
|
Output Tokens{" "}
|
||||||
currentSort={sortBy}
|
<SortIcon
|
||||||
currentOrder={sortOrder}
|
field="completionTokens"
|
||||||
/>
|
currentSort={sortBy}
|
||||||
</th>
|
currentOrder={sortOrder}
|
||||||
<th
|
/>
|
||||||
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
|
</th>
|
||||||
onClick={() => toggleSort("totalTokens")}
|
<th
|
||||||
>
|
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
|
||||||
Total Tokens{" "}
|
onClick={() => toggleSort("totalTokens")}
|
||||||
<SortIcon
|
>
|
||||||
field="totalTokens"
|
Total Tokens{" "}
|
||||||
currentSort={sortBy}
|
<SortIcon
|
||||||
currentOrder={sortOrder}
|
field="totalTokens"
|
||||||
/>
|
currentSort={sortBy}
|
||||||
</th>
|
currentOrder={sortOrder}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<th
|
||||||
|
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
|
||||||
|
onClick={() => toggleSort("promptTokens")}
|
||||||
|
>
|
||||||
|
Input Cost{" "}
|
||||||
|
<SortIcon
|
||||||
|
field="promptTokens"
|
||||||
|
currentSort={sortBy}
|
||||||
|
currentOrder={sortOrder}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
|
||||||
|
onClick={() => toggleSort("completionTokens")}
|
||||||
|
>
|
||||||
|
Output Cost{" "}
|
||||||
|
<SortIcon
|
||||||
|
field="completionTokens"
|
||||||
|
currentSort={sortBy}
|
||||||
|
currentOrder={sortOrder}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
|
||||||
|
onClick={() => toggleSort("cost")}
|
||||||
|
>
|
||||||
|
Total Cost{" "}
|
||||||
|
<SortIcon
|
||||||
|
field="cost"
|
||||||
|
currentSort={sortBy}
|
||||||
|
currentOrder={sortOrder}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border">
|
<tbody className="divide-y divide-border">
|
||||||
@@ -524,15 +673,31 @@ export default function UsageStats() {
|
|||||||
<td className="px-6 py-3 text-right text-text-muted whitespace-nowrap">
|
<td className="px-6 py-3 text-right text-text-muted whitespace-nowrap">
|
||||||
{fmtTime(data.lastUsed)}
|
{fmtTime(data.lastUsed)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-3 text-right text-text-muted">
|
{viewMode === "tokens" ? (
|
||||||
{fmt(data.promptTokens)}
|
<>
|
||||||
</td>
|
<td className="px-6 py-3 text-right text-text-muted">
|
||||||
<td className="px-6 py-3 text-right text-text-muted">
|
{fmt(data.promptTokens)}
|
||||||
{fmt(data.completionTokens)}
|
</td>
|
||||||
</td>
|
<td className="px-6 py-3 text-right text-text-muted">
|
||||||
<td className="px-6 py-3 text-right font-medium">
|
{fmt(data.completionTokens)}
|
||||||
{fmt(data.totalTokens)}
|
</td>
|
||||||
</td>
|
<td className="px-6 py-3 text-right font-medium">
|
||||||
|
{fmt(data.totalTokens)}
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<td className="px-6 py-3 text-right text-text-muted">
|
||||||
|
{fmtCost(data.inputCost)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3 text-right text-text-muted">
|
||||||
|
{fmtCost(data.outputCost)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3 text-right font-medium text-warning">
|
||||||
|
{fmtCost(data.totalCost)}
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{sortedAccounts.length === 0 && (
|
{sortedAccounts.length === 0 && (
|
||||||
|
|||||||
Reference in New Issue
Block a user