Skip to content

Commit 8ee6fc6

Browse files
GenerQAQclaude
andauthored
feat(dashboard): add global usage indicator in top nav (#448)
Add a hover-triggered usage indicator icon in the top nav that shows per-org usage (tasks & storage) with progress bars and warning dots for orgs approaching quota limits. Clicking an org navigates to its billing page. - Add getAllOrganizationsUsage server action with shared fetchUsageAndLimits core - Add UsageIndicator component using shadcn HoverCard (responsive: hover on desktop, tap on mobile) - Consolidate duplicate formatBytes into shared lib/utils import - Add hover-card shadcn component Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 024a830 commit 8ee6fc6

6 files changed

Lines changed: 356 additions & 58 deletions

File tree

dashboard/app/org/[id]/usage/usage-page-client.tsx

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { useTopNavStore } from "@/stores/top-nav";
66
import { usePlanStore } from "@/stores/plan";
77
import { Organization } from "@/types";
88
import { OrganizationUsageData } from "@/lib/supabase/operations/organizations";
9+
import { formatBytes } from "@/lib/utils";
910
import {
1011
Card,
1112
CardContent,
@@ -22,14 +23,6 @@ interface UsagePageClientProps {
2223
usageData: OrganizationUsageData;
2324
}
2425

25-
function formatStorage(bytes: number): string {
26-
if (bytes === 0) return "0 B";
27-
const units = ["B", "KB", "MB", "GB", "TB"];
28-
const i = Math.floor(Math.log(bytes) / Math.log(1024));
29-
const value = bytes / Math.pow(1024, i);
30-
return `${value.toFixed(value < 10 && i > 0 ? 1 : 0)} ${units[i]}`;
31-
}
32-
3326
function getProgressColor(percentage: number): string {
3427
if (percentage >= 90) return "bg-red-500";
3528
if (percentage >= 70) return "bg-amber-500";
@@ -131,7 +124,7 @@ export function UsagePageClient({
131124
description: "Total storage used across all projects",
132125
current: usage.current_storage,
133126
max: limits.max_storage,
134-
formatValue: formatStorage,
127+
formatValue: formatBytes,
135128
},
136129
];
137130

dashboard/app/project/[id]/disk/disk-page-client.tsx

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ import {
7474
DropdownMenuSeparator,
7575
DropdownMenuTrigger,
7676
} from "@/components/ui/dropdown-menu";
77-
import { cn } from "@/lib/utils";
77+
import { cn, formatBytes } from "@/lib/utils";
7878
import { useTopNavStore } from "@/stores/top-nav";
7979
import {
8080
Organization,
@@ -102,13 +102,6 @@ interface DiskTreeNode extends TreeNode {
102102
fileInfo?: Artifact;
103103
}
104104

105-
function formatBytes(bytes: number): string {
106-
if (bytes === 0) return "0 B";
107-
const k = 1024;
108-
const sizes = ["B", "KB", "MB", "GB"];
109-
const i = Math.floor(Math.log(bytes) / Math.log(k));
110-
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
111-
}
112105

113106
interface DiskPageClientProps {
114107
project: Project;

dashboard/components/top-nav.tsx

Lines changed: 173 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import {
1212
Plus,
1313
BookOpen,
1414
Github,
15+
Receipt,
16+
ExternalLink,
1517
} from "lucide-react";
1618
import { Button } from "@/components/ui/button";
1719
import { Badge } from "@/components/ui/badge";
@@ -49,10 +51,19 @@ import { useUserStore } from "@/stores/user";
4951
import { useTopNavStore } from "@/stores/top-nav";
5052
import { usePlanStore, Price, Product, getPlanTypeDisplayName, isPaidPlan } from "@/stores/plan";
5153
import { User } from "@supabase/supabase-js";
52-
import { cn } from "@/lib/utils";
54+
import { cn, formatBytes } from "@/lib/utils";
5355
import { encodeId } from "@/lib/id-codec";
56+
import {
57+
HoverCard,
58+
HoverCardContent,
59+
HoverCardTrigger,
60+
} from "@/components/ui/hover-card";
5461
import { SidebarTriggerWrapper } from "@/components/sidebar-trigger-wrapper";
5562
import { AlertBanner } from "@/components/alert-banner";
63+
import {
64+
getAllOrganizationsUsage,
65+
type OrganizationUsageSummary,
66+
} from "@/lib/supabase/operations/organizations";
5667

5768
const EXTERNAL_LINKS = [
5869
{
@@ -282,6 +293,162 @@ function ProjectSelector({
282293
);
283294
}
284295

296+
// Usage Indicator Component
297+
function UsageIndicator({ className }: { className?: string }) {
298+
const [usageData, setUsageData] = React.useState<OrganizationUsageSummary[]>(
299+
[]
300+
);
301+
const [loading, setLoading] = React.useState(false);
302+
const [fetched, setFetched] = React.useState(false);
303+
const router = useRouter();
304+
305+
const fetchUsage = React.useCallback(async () => {
306+
if (fetched) return;
307+
setLoading(true);
308+
try {
309+
const data = await getAllOrganizationsUsage();
310+
setUsageData(data);
311+
setFetched(true);
312+
} catch {
313+
// silently fail
314+
} finally {
315+
setLoading(false);
316+
}
317+
}, [fetched]);
318+
319+
// Auto-fetch on mount to show warning dot
320+
React.useEffect(() => {
321+
fetchUsage();
322+
}, [fetchUsage]);
323+
324+
// Check if any org has critical usage (>=90%)
325+
const hasWarning = React.useMemo(() => {
326+
return usageData.some((org) => {
327+
const metrics = [
328+
{ current: org.usage.current_task, max: org.limits.max_task },
329+
{ current: org.usage.current_storage, max: org.limits.max_storage },
330+
];
331+
return metrics.some(
332+
(m) => m.max > 0 && (m.current / m.max) * 100 >= 90
333+
);
334+
});
335+
}, [usageData]);
336+
337+
const getBarColor = (percentage: number) => {
338+
if (percentage >= 90) return "bg-red-500";
339+
if (percentage >= 70) return "bg-amber-500";
340+
return "bg-primary";
341+
};
342+
343+
return (
344+
<HoverCard openDelay={200} closeDelay={150}>
345+
<HoverCardTrigger asChild>
346+
<Button
347+
variant="ghost"
348+
size="icon"
349+
className={cn("rounded-full h-8 w-8 relative border border-border", className)}
350+
>
351+
<Receipt className="h-4 w-4" />
352+
{hasWarning && (
353+
<span className="absolute -top-0.5 -right-0.5 h-2.5 w-2.5 rounded-full bg-red-500 border-2 border-background" />
354+
)}
355+
</Button>
356+
</HoverCardTrigger>
357+
<HoverCardContent className="w-[320px] p-0" align="end">
358+
<div className="max-h-[320px] overflow-y-auto">
359+
{loading && !fetched ? (
360+
<div className="px-3 py-4 text-center text-sm text-muted-foreground">
361+
Loading...
362+
</div>
363+
) : usageData.length === 0 ? (
364+
<div className="px-3 py-4 text-center text-sm text-muted-foreground">
365+
No organizations
366+
</div>
367+
) : (
368+
usageData.map((org) => {
369+
const taskPct =
370+
org.limits.max_task > 0
371+
? Math.min(
372+
(org.usage.current_task / org.limits.max_task) * 100,
373+
100
374+
)
375+
: 0;
376+
const storagePct =
377+
org.limits.max_storage > 0
378+
? Math.min(
379+
(org.usage.current_storage / org.limits.max_storage) *
380+
100,
381+
100
382+
)
383+
: 0;
384+
const maxPct = Math.max(taskPct, storagePct);
385+
const encodedId = encodeId(org.orgId);
386+
387+
return (
388+
<button
389+
key={org.orgId}
390+
className="w-full px-3 py-2.5 text-left hover:bg-muted/50 transition-colors border-b last:border-b-0 cursor-pointer"
391+
onClick={() => {
392+
router.push(`/org/${encodedId}/billing`);
393+
}}
394+
>
395+
<div className="flex items-center justify-between mb-1.5">
396+
<div className="flex items-center gap-1.5 min-w-0">
397+
<span className="text-sm font-medium truncate">
398+
{org.orgName}
399+
</span>
400+
{maxPct >= 90 && (
401+
<span className="h-1.5 w-1.5 rounded-full bg-red-500 shrink-0" />
402+
)}
403+
</div>
404+
<ExternalLink className="h-3 w-3 text-muted-foreground shrink-0" />
405+
</div>
406+
{/* Agent Tasks */}
407+
<div className="space-y-1">
408+
<div className="flex items-center justify-between text-xs text-muted-foreground">
409+
<span>Tasks</span>
410+
<span className="tabular-nums">
411+
{org.usage.current_task.toLocaleString()} /{" "}
412+
{org.limits.max_task > 0
413+
? org.limits.max_task.toLocaleString()
414+
: "∞"}
415+
</span>
416+
</div>
417+
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
418+
<div
419+
className={`h-full rounded-full transition-all ${getBarColor(taskPct)}`}
420+
style={{ width: `${taskPct}%` }}
421+
/>
422+
</div>
423+
</div>
424+
{/* Storage */}
425+
<div className="space-y-1 mt-1.5">
426+
<div className="flex items-center justify-between text-xs text-muted-foreground">
427+
<span>Storage</span>
428+
<span className="tabular-nums">
429+
{formatBytes(org.usage.current_storage)} /{" "}
430+
{org.limits.max_storage > 0
431+
? formatBytes(org.limits.max_storage)
432+
: "∞"}
433+
</span>
434+
</div>
435+
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
436+
<div
437+
className={`h-full rounded-full transition-all ${getBarColor(storagePct)}`}
438+
style={{ width: `${storagePct}%` }}
439+
/>
440+
</div>
441+
</div>
442+
</button>
443+
);
444+
})
445+
)}
446+
</div>
447+
</HoverCardContent>
448+
</HoverCard>
449+
);
450+
}
451+
285452
export function TopNav({ user, prices = [], products = [] }: TopNavProps) {
286453
// Get data from stores
287454
const { setUser } = useUserStore();
@@ -366,6 +533,8 @@ export function TopNav({ user, prices = [], products = [] }: TopNavProps) {
366533
</Link>
367534
<div className="flex gap-2 min-w-0 ml-3">
368535
<AlertBanner variant="mobile" />
536+
{/* Usage Indicator - shown on mobile */}
537+
<UsageIndicator className="md:hidden" />
369538
{/* External links - shown on mobile */}
370539
{EXTERNAL_LINKS.map((link) => {
371540
const Icon = link.icon;
@@ -504,6 +673,9 @@ export function TopNav({ user, prices = [], products = [] }: TopNavProps) {
504673
</a>
505674
</Button>
506675

676+
{/* Usage Indicator - desktop only */}
677+
<UsageIndicator className="hidden md:inline-flex" />
678+
507679
{/* External links - shown on medium screens and above (hidden on small screens where they appear in Mobile Top Layer) */}
508680
{EXTERNAL_LINKS.map((link) => {
509681
const Icon = link.icon;
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"use client"
2+
3+
import * as React from "react"
4+
import { HoverCard as HoverCardPrimitive } from "radix-ui"
5+
6+
import { cn } from "@/lib/utils"
7+
8+
function HoverCard({
9+
...props
10+
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
11+
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
12+
}
13+
14+
function HoverCardTrigger({
15+
...props
16+
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
17+
return (
18+
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
19+
)
20+
}
21+
22+
function HoverCardContent({
23+
className,
24+
align = "center",
25+
sideOffset = 4,
26+
...props
27+
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
28+
return (
29+
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
30+
<HoverCardPrimitive.Content
31+
data-slot="hover-card-content"
32+
align={align}
33+
sideOffset={sideOffset}
34+
className={cn(
35+
"z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
36+
className
37+
)}
38+
{...props}
39+
/>
40+
</HoverCardPrimitive.Portal>
41+
)
42+
}
43+
44+
export { HoverCard, HoverCardTrigger, HoverCardContent }

dashboard/lib/supabase/operations/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,10 @@ export {
4343
removeOrganizationMember,
4444
getOrganizationDataWithPlan,
4545
getOrganizationUsage,
46+
getAllOrganizationsUsage,
4647
type OrganizationMember,
4748
type OrganizationUsageData,
49+
type OrganizationUsageSummary,
4850
} from "./organizations";
4951

5052
// Project operations (Server Actions)

0 commit comments

Comments
 (0)