Compare commits
1 Commits
main
...
feat/usage
| Author | SHA1 | Date | |
|---|---|---|---|
| 79ceddd85c |
@@ -5,6 +5,7 @@ import Link from "next/link";
|
|||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useAuth } from "@/lib/auth/auth-context";
|
import { useAuth } from "@/lib/auth/auth-context";
|
||||||
import { ThemeToggle } from "./ThemeToggle";
|
import { ThemeToggle } from "./ThemeToggle";
|
||||||
|
import { UsageWidget } from "@/components/ui/UsageWidget";
|
||||||
import { useSidebar } from "./SidebarContext";
|
import { useSidebar } from "./SidebarContext";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -350,6 +351,9 @@ export function AppHeader(): React.JSX.Element {
|
|||||||
{/* Theme Toggle */}
|
{/* Theme Toggle */}
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
|
|
||||||
|
{/* Usage Widget */}
|
||||||
|
<UsageWidget />
|
||||||
|
|
||||||
{/* User Avatar + Dropdown */}
|
{/* User Avatar + Dropdown */}
|
||||||
<div ref={dropdownRef} style={{ position: "relative", flexShrink: 0 }}>
|
<div ref={dropdownRef} style={{ position: "relative", flexShrink: 0 }}>
|
||||||
<button
|
<button
|
||||||
|
|||||||
337
apps/web/src/components/ui/UsageWidget.tsx
Normal file
337
apps/web/src/components/ui/UsageWidget.tsx
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
import { fetchUsageSummary, type UsageSummary } from "@/lib/api/telemetry";
|
||||||
|
|
||||||
|
// ─── Types ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface UsageTier {
|
||||||
|
name: string;
|
||||||
|
tokens: number;
|
||||||
|
limit: number;
|
||||||
|
percentage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getUsageColor(percentage: number): string {
|
||||||
|
if (percentage < 60) return "var(--success)";
|
||||||
|
if (percentage < 80) return "var(--warn)";
|
||||||
|
return "var(--danger)";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTokens(value: number): string {
|
||||||
|
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
|
||||||
|
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`;
|
||||||
|
return value.toFixed(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Component ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function UsageWidget(): React.JSX.Element {
|
||||||
|
const [summary, setSummary] = useState<UsageSummary | null>(null);
|
||||||
|
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const tiers: UsageTier[] = summary
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
name: "Session",
|
||||||
|
tokens: summary.totalTokens,
|
||||||
|
limit: 100_000,
|
||||||
|
percentage: (summary.totalTokens / 100_000) * 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Daily",
|
||||||
|
tokens: summary.totalTokens,
|
||||||
|
limit: 500_000,
|
||||||
|
percentage: (summary.totalTokens / 500_000) * 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Monthly",
|
||||||
|
tokens: summary.totalTokens,
|
||||||
|
limit: 2_000_000,
|
||||||
|
percentage: (summary.totalTokens / 2_000_000) * 100,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const currentTier = tiers[0];
|
||||||
|
const usageColor = currentTier ? getUsageColor(currentTier.percentage) : "var(--muted)";
|
||||||
|
|
||||||
|
const loadSummary = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await fetchUsageSummary("30d");
|
||||||
|
setSummary(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load usage summary:", err);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadSummary();
|
||||||
|
}, [loadSummary]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(event: MouseEvent): void {
|
||||||
|
if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
|
||||||
|
setPopoverOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!popoverOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return (): void => {
|
||||||
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [popoverOpen]);
|
||||||
|
|
||||||
|
const pct = currentTier ? Math.min(currentTier.percentage, 100) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={popoverRef} style={{ position: "relative" }}>
|
||||||
|
<button
|
||||||
|
onClick={(): void => {
|
||||||
|
setPopoverOpen((prev) => !prev);
|
||||||
|
}}
|
||||||
|
aria-label="Usage widget"
|
||||||
|
aria-expanded={popoverOpen}
|
||||||
|
aria-haspopup="true"
|
||||||
|
className="hidden lg:flex items-center"
|
||||||
|
style={{
|
||||||
|
gap: 6,
|
||||||
|
padding: "5px 10px",
|
||||||
|
borderRadius: 6,
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: `1px solid ${popoverOpen ? usageColor : "var(--border)"}`,
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
color: "var(--text-2)",
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "border-color 0.15s, color 0.15s",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e): void => {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.borderColor = usageColor;
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.color = "var(--text)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e): void => {
|
||||||
|
if (!popoverOpen) {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.borderColor = "var(--border)";
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.color = "var(--text-2)";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
style={{ color: usageColor, flexShrink: 0 }}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M9 1L3 9h5l-1 6 6-8H8l1-6z" />
|
||||||
|
</svg>
|
||||||
|
<span style={{ fontWeight: 500, color: "var(--text-2)" }}>
|
||||||
|
{isLoading ? "..." : summary ? formatTokens(summary.totalTokens) : "0"}
|
||||||
|
</span>
|
||||||
|
{!isLoading && currentTier && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 24,
|
||||||
|
height: 4,
|
||||||
|
borderRadius: 2,
|
||||||
|
background: "var(--bg-mid)",
|
||||||
|
overflow: "hidden",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${String(pct)}%`,
|
||||||
|
height: "100%",
|
||||||
|
background: usageColor,
|
||||||
|
borderRadius: 2,
|
||||||
|
transition: "width 0.3s ease-out",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isLoading && currentTier && (
|
||||||
|
<span style={{ fontWeight: 600, color: usageColor, minWidth: 32, textAlign: "right" }}>
|
||||||
|
{Math.round(currentTier.percentage)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{popoverOpen && (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Usage details"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "calc(100% + 8px)",
|
||||||
|
right: 0,
|
||||||
|
width: 280,
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
boxShadow: "0 8px 32px rgba(0,0,0,0.3)",
|
||||||
|
zIndex: 200,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.83rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--text)",
|
||||||
|
marginBottom: 12,
|
||||||
|
paddingBottom: 8,
|
||||||
|
borderBottom: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Token Usage
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
textAlign: "center",
|
||||||
|
padding: "20px 0",
|
||||||
|
color: "var(--muted)",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Loading usage data…
|
||||||
|
</div>
|
||||||
|
) : summary ? (
|
||||||
|
<>
|
||||||
|
<div style={{ marginBottom: 12, display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
|
<div
|
||||||
|
style={{ display: "flex", justifyContent: "space-between", fontSize: "0.75rem" }}
|
||||||
|
>
|
||||||
|
<span style={{ color: "var(--muted)" }}>Total Tokens</span>
|
||||||
|
<span style={{ color: "var(--text)", fontFamily: "var(--mono)" }}>
|
||||||
|
{formatTokens(summary.totalTokens)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{ display: "flex", justifyContent: "space-between", fontSize: "0.75rem" }}
|
||||||
|
>
|
||||||
|
<span style={{ color: "var(--muted)" }}>Estimated Cost</span>
|
||||||
|
<span style={{ color: "var(--text)", fontFamily: "var(--mono)" }}>
|
||||||
|
${summary.totalCost.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{ display: "flex", justifyContent: "space-between", fontSize: "0.75rem" }}
|
||||||
|
>
|
||||||
|
<span style={{ color: "var(--muted)" }}>Tasks</span>
|
||||||
|
<span style={{ color: "var(--text)", fontFamily: "var(--mono)" }}>
|
||||||
|
{summary.taskCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||||
|
{tiers.map((tier) => {
|
||||||
|
const tierPct = Math.min(tier.percentage, 100);
|
||||||
|
return (
|
||||||
|
<div key={tier.name}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: "var(--text-2)" }}>{tier.name}</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: getUsageColor(tier.percentage),
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatTokens(tier.tokens)} / {formatTokens(tier.limit)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: 6,
|
||||||
|
borderRadius: 3,
|
||||||
|
background: "var(--bg-mid)",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${String(tierPct)}%`,
|
||||||
|
height: "100%",
|
||||||
|
background: getUsageColor(tier.percentage),
|
||||||
|
borderRadius: 3,
|
||||||
|
transition: "width 0.3s ease-out",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/usage"
|
||||||
|
onClick={(): void => {
|
||||||
|
setPopoverOpen(false);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
marginTop: 12,
|
||||||
|
paddingTop: 8,
|
||||||
|
borderTop: "1px solid var(--border)",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
color: "var(--primary)",
|
||||||
|
textDecoration: "none",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e): void => {
|
||||||
|
(e.currentTarget as HTMLAnchorElement).style.textDecoration = "underline";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e): void => {
|
||||||
|
(e.currentTarget as HTMLAnchorElement).style.textDecoration = "none";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
View detailed usage →
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
textAlign: "center",
|
||||||
|
padding: "20px 0",
|
||||||
|
color: "var(--muted)",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No usage data available
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user