From 54c8315ca427ff058587473b493f836c93768cb9 Mon Sep 17 00:00:00 2001 From: Ben Fornefeld Date: Fri, 10 Jan 2025 10:16:36 +0100 Subject: [PATCH 01/12] add: skeletons + usage limits components --- apps/web/src/components/Dashboard/Usage.tsx | 272 ++++++++++++-------- apps/web/src/components/ui/skeleton.tsx | 15 ++ 2 files changed, 184 insertions(+), 103 deletions(-) create mode 100644 apps/web/src/components/ui/skeleton.tsx diff --git a/apps/web/src/components/Dashboard/Usage.tsx b/apps/web/src/components/Dashboard/Usage.tsx index 23d151c2e..fe6b2b149 100644 --- a/apps/web/src/components/Dashboard/Usage.tsx +++ b/apps/web/src/components/Dashboard/Usage.tsx @@ -1,10 +1,11 @@ import { useEffect, useState } from 'react' -import { Card, CardContent } from '@/components/ui/card' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import LineChart from '@/components/Dashboard/Chart' -import Spinner from '@/components/Spinner' import { toast } from '@/components/ui/use-toast' import { Team } from '@/utils/useUser' import { getBillingUrl } from '@/app/(dashboard)/dashboard/utils' +import { cn } from '@/lib/utils' +import { Skeleton } from '../ui/skeleton' type Usage = { month: number @@ -22,6 +23,25 @@ type Series = { data: PlotData[] } +const ChartSkeleton = ({ className }: { className?: string }) => { + const numBars = Math.floor(Math.random() * 6) + 5 // Random number between 5-10 + const bars = Array.from({ length: numBars }, () => Math.random() * 80 + 20) // Random heights between 20-100 + + return ( +
+
+ {bars.map((height, i) => ( + + ))} +
+
+ ) +} + export const UsageContent = ({ team, domain, @@ -39,133 +59,179 @@ export const UsageContent = ({ >() const [costUsage, setCostUsage] = useState([]) const [costThisMonth, setCostMonth] = useState() + const [isLoading, setIsLoading] = useState(true) useEffect(() => { const getUsage = async () => { + setIsLoading(true) setVcpuData([]) setRamData([]) setCostUsage([]) - const response = await fetch( - getBillingUrl(domain, `/teams/${team.id}/usage`), - { - headers: { - 'X-Team-API-Key': team.apiKeys[0], - }, + try { + const response = await fetch( + getBillingUrl(domain, `/teams/${team.id}/usage`), + { + headers: { + 'X-Team-API-Key': team.apiKeys[0], + }, + } + ) + if (!response.ok) { + throw new Error('Failed to fetch usage data') } - ) - if (!response.ok) { - // TODO: Add sentry event here + + const data = await response.json() + const { vcpuSeries, ramSeries } = transformData(data.usages) + const costData = transformCostData(data.usages) + + const latestCost = costData[0].data[costData[0].data.length - 1] + setCostUsage(costData) + setCostMonth(latestCost.y) + + setVcpuData(vcpuSeries) + setVcpuHoursThisMonth( + vcpuSeries[0].data[vcpuSeries[0].data.length - 1].y + ) + + setRamData(ramSeries) + setRamHoursThisMonth(ramSeries[0].data[ramSeries[0].data.length - 1].y) + } catch (error) { toast({ title: 'An error occurred', description: 'We were unable to fetch the usage data', }) - console.log(response) - return + console.error(error) + } finally { + setIsLoading(false) } - - const data = await response.json() - const { vcpuSeries, ramSeries } = transformData(data.usages) - - const costData = transformCostData(data.usages) - const latestCost = costData[0].data[costData[0].data.length - 1] - setCostUsage(costData) - setCostMonth(latestCost.y) - - setVcpuData(vcpuSeries) - setVcpuHoursThisMonth(vcpuSeries[0].data[vcpuSeries[0].data.length - 1].y) - - setRamData(ramSeries) - setRamHoursThisMonth(ramSeries[0].data[ramSeries[0].data.length - 1].y) } getUsage() }, [team, domain]) return ( -
-
-

Usage history

-

- The graphs show the total monthly vCPU-hours and RAM-hours used by the - team.
-

-
- -
-
-

Costs in USD

- {costUsage && costUsage.length > 0 ? ( -
- +
+ {/* First Row - Costs and Budget Controls */} +
+ {/* Cost Card - Full width mobile, half tablet, third desktop */} + + + Costs in USD + {!isLoading && costThisMonth && ( +

Total costs this month: ${costThisMonth?.toFixed(2)} - - - - - - +

+ )} +
+ + {isLoading ? ( + + ) : ( + + )} + +
+ + {/* Budget Controls */} + + + Budget Alerts + + +
+

Set a Budget Alert

+

+ If your organization exceeds this threshold in a given calendar + month (UTC), an alert notification will be sent to this email. +

+
- ) : ( -
- + + + + + + Budget Limits + + +
+

Enable Budget Limit

+

+ If your organization exceeds this budget in a given calendar + month (UTC), subsequent API requests will be rejected. +

+

+ Caution: Enabling a Budget Limit could result in interruptions + to your service. +

+
- )} -
- -
-

vCPU hours

- {vcpuData && vcpuData.length > 0 ? ( -
- + + +
+ + {/* Second Row - Resource Usage */} +
+ {/* vCPU Card - Always half width except mobile */} + + + vCPU hours + {!isLoading && vcpuHoursThisMonth && ( +

Total vCPU hours this month:{' '} {vcpuHoursThisMonth?.toFixed(2)} - - - - - - -

- ) : ( -
- -
- )} -
- -
-

RAM hours

- {ramData && ramData.length > 0 ? ( -
- +

+ )} + + + {isLoading ? ( + + ) : ( + + )} + + + + {/* RAM Card - Always half width except mobile */} + + + RAM hours + {!isLoading && ramHoursThisMonth && ( +

Total RAM hours this month:{' '} {ramHoursThisMonth?.toFixed(2)} - - - - - - -

- ) : ( -
- -
- )} -
+

+ )} + + + {isLoading ? ( + + ) : ( + + )} + +
) diff --git a/apps/web/src/components/ui/skeleton.tsx b/apps/web/src/components/ui/skeleton.tsx new file mode 100644 index 000000000..dfb99c9aa --- /dev/null +++ b/apps/web/src/components/ui/skeleton.tsx @@ -0,0 +1,15 @@ +import { cn } from '@/lib/utils' + +function Skeleton({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ) +} + +export { Skeleton } From 6d9a27800ffe24e55b5d24abbbf1d1dcfd4a07f0 Mon Sep 17 00:00:00 2001 From: Ben Fornefeld Date: Fri, 10 Jan 2025 10:41:58 +0100 Subject: [PATCH 02/12] improve: billing limit components --- apps/web/src/components/Dashboard/Usage.tsx | 70 ++++++++++----------- 1 file changed, 33 insertions(+), 37 deletions(-) diff --git a/apps/web/src/components/Dashboard/Usage.tsx b/apps/web/src/components/Dashboard/Usage.tsx index fe6b2b149..97e86c70a 100644 --- a/apps/web/src/components/Dashboard/Usage.tsx +++ b/apps/web/src/components/Dashboard/Usage.tsx @@ -138,46 +138,42 @@ export const UsageContent = ({ {/* Budget Controls */} - + - Budget Alerts + Budget Controls - -
-

Set a Budget Alert

-

- If your organization exceeds this threshold in a given calendar - month (UTC), an alert notification will be sent to this email. -

- -
-
-
+ +
+
+

Set a Budget Alert

+

+ If your organization exceeds this threshold in a given + calendar month (UTC), an alert notification will be sent to{' '} + {team.email} +

+ +
- - - Budget Limits - - -
-

Enable Budget Limit

-

- If your organization exceeds this budget in a given calendar - month (UTC), subsequent API requests will be rejected. -

-

- Caution: Enabling a Budget Limit could result in interruptions - to your service. -

- +
+

Enable Budget Limit

+

+ If your organization exceeds this budget in a given calendar + month (UTC), subsequent API requests will be rejected. +

+

+ Caution: Enabling a Budget Limit could result in interruptions + to your service. +

+ +
From 93a6b9b868085d7f387c7d8f2ded6b00660026c1 Mon Sep 17 00:00:00 2001 From: Ben Fornefeld Date: Fri, 10 Jan 2025 23:03:35 +0100 Subject: [PATCH 03/12] add: billing limits states + api layer --- .../components/Dashboard/BillingAlerts.tsx | 171 ++++++++++++++++++ apps/web/src/components/Dashboard/Usage.tsx | 38 +--- 2 files changed, 175 insertions(+), 34 deletions(-) create mode 100644 apps/web/src/components/Dashboard/BillingAlerts.tsx diff --git a/apps/web/src/components/Dashboard/BillingAlerts.tsx b/apps/web/src/components/Dashboard/BillingAlerts.tsx new file mode 100644 index 000000000..6f69061a5 --- /dev/null +++ b/apps/web/src/components/Dashboard/BillingAlerts.tsx @@ -0,0 +1,171 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' +import { useToast } from '../ui/use-toast' +import { Button } from '../Button' +import { getBillingUrl } from '@/app/(dashboard)/dashboard/utils' +import { Team, useUser } from '@/utils/useUser' + +interface BillingThreshold { + amount_gte: number | null + alert_amount_percentage: number | null +} + +export const BillingAlerts = ({ + team, + domain, + email, +}: { + team: Team + domain: string + email: string +}) => { + const { toast } = useToast() + const [threshold, setThreshold] = useState({ + amount_gte: null, + alert_amount_percentage: null, + }) + const { user } = useUser() + + const fetchBillingThreshold = useCallback(async () => { + if (!user) return + + try { + const res = await fetch( + getBillingUrl(domain, `/teams/${team.id}/billing-thresholds`), + { + headers: { + 'X-Team-API-Key': team.apiKeys[0], + }, + } + ) + + if (!res.ok) { + toast({ + title: 'Failed to fetch billing alerts', + description: 'Unable to load your billing alert settings', + }) + return + } + + const data = await res.json() + setThreshold(data) + } catch (error) { + console.error('Error fetching billing threshold:', error) + toast({ + title: 'Error', + description: 'Failed to load billing alert settings', + }) + } + }, [user, domain, team, toast]) + + const updateBillingThreshold = useCallback(async () => { + if (!user) return + + try { + const res = await fetch( + getBillingUrl(domain, `/teams/${team.id}/billing-thresholds`), + { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'X-Team-API-Key': team.apiKeys[0], + }, + body: JSON.stringify(threshold), + } + ) + + if (!res.ok) { + toast({ + title: 'Failed to update billing alerts', + description: 'Unable to save your billing alert settings', + }) + return + } + + toast({ + title: 'Billing alerts updated', + description: 'Your billing alert settings have been saved', + }) + } catch (error) { + console.error('Error updating billing threshold:', error) + toast({ + title: 'Error', + description: 'Failed to save billing alert settings', + }) + } + }, [user, domain, team, threshold, toast]) + + useEffect(() => { + fetchBillingThreshold() + }, [fetchBillingThreshold]) + + return ( + <> +
+
+

Set a Budget Alert

+

+ When your usage reaches this percentage of your budget, you'll + receive an early warning notification to {email}. +

+ +
+ + setThreshold({ + ...threshold, + alert_amount_percentage: Number(e.target.value) || null, + }) + } + placeholder="Enter percentage" + /> +
+ % +
+
+
+ +
+

Enable Budget Limit

+

+ If your organization exceeds this threshold in a given billing + period, +

+

+ Caution: This helps you monitor spending before reaching your budget + limit. +

+
+ + setThreshold({ + ...threshold, + amount_gte: Number(e.target.value) || null, + }) + } + placeholder="Enter amount" + /> +
+ USD +
+
+
+
+ + + ) +} diff --git a/apps/web/src/components/Dashboard/Usage.tsx b/apps/web/src/components/Dashboard/Usage.tsx index 97e86c70a..c29d0cc7e 100644 --- a/apps/web/src/components/Dashboard/Usage.tsx +++ b/apps/web/src/components/Dashboard/Usage.tsx @@ -6,6 +6,7 @@ import { Team } from '@/utils/useUser' import { getBillingUrl } from '@/app/(dashboard)/dashboard/utils' import { cn } from '@/lib/utils' import { Skeleton } from '../ui/skeleton' +import { BillingAlerts } from './BillingAlerts' type Usage = { month: number @@ -115,7 +116,7 @@ export const UsageContent = ({ {/* First Row - Costs and Budget Controls */}
{/* Cost Card - Full width mobile, half tablet, third desktop */} - + Costs in USD {!isLoading && costThisMonth && ( @@ -138,43 +139,12 @@ export const UsageContent = ({ {/* Budget Controls */} - + Budget Controls -
-
-

Set a Budget Alert

-

- If your organization exceeds this threshold in a given - calendar month (UTC), an alert notification will be sent to{' '} - {team.email} -

- -
- -
-

Enable Budget Limit

-

- If your organization exceeds this budget in a given calendar - month (UTC), subsequent API requests will be rejected. -

-

- Caution: Enabling a Budget Limit could result in interruptions - to your service. -

- -
-
+
From 673272efcf6278eea4a18ac1a1695c0d67496452 Mon Sep 17 00:00:00 2001 From: Ben Fornefeld Date: Fri, 17 Jan 2025 12:59:48 -0800 Subject: [PATCH 04/12] fix account selector billing url retrieval --- apps/web/src/app/(dashboard)/dashboard/page.tsx | 3 +++ apps/web/src/components/Dashboard/AccountSelector.tsx | 8 +++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/web/src/app/(dashboard)/dashboard/page.tsx b/apps/web/src/app/(dashboard)/dashboard/page.tsx index 4224bf658..9ef38e833 100644 --- a/apps/web/src/app/(dashboard)/dashboard/page.tsx +++ b/apps/web/src/app/(dashboard)/dashboard/page.tsx @@ -149,6 +149,7 @@ const Dashboard = ({ user }) => { currentTeam={currentTeam} setCurrentTeam={setCurrentTeam} setTeams={setTeams} + domainState={domainState} />

@@ -178,6 +179,7 @@ const Sidebar = ({ currentTeam, setCurrentTeam, setTeams, + domainState, }) => (
diff --git a/apps/web/src/components/Dashboard/AccountSelector.tsx b/apps/web/src/components/Dashboard/AccountSelector.tsx index 8d085a4b7..0ed941a4c 100644 --- a/apps/web/src/components/Dashboard/AccountSelector.tsx +++ b/apps/web/src/components/Dashboard/AccountSelector.tsx @@ -19,8 +19,7 @@ import { } from '../ui/alert-dialog' import { useState } from 'react' import { Button } from '../Button' - -const createTeamUrl = `${process.env.NEXT_PUBLIC_BILLING_API_URL}/teams` +import { getBillingUrl } from '@/app/(dashboard)/dashboard/utils' export const AccountSelector = ({ teams, @@ -28,13 +27,16 @@ export const AccountSelector = ({ currentTeam, setCurrentTeam, setTeams, + domainState, }) => { + const [domain] = domainState + const [isDialogOpen, setIsDialogOpen] = useState(false) const [teamName, setTeamName] = useState('') const closeDialog = () => setIsDialogOpen(false) const createNewTeam = async () => { - const res = await fetch(createTeamUrl, { + const res = await fetch(getBillingUrl(domain, '/teams'), { method: 'POST', headers: { 'Content-Type': 'application/json', From e644a98a14472796df0c59401dbbea011e4021ae Mon Sep 17 00:00:00 2001 From: Ben Fornefeld Date: Fri, 17 Jan 2025 14:44:30 -0800 Subject: [PATCH 05/12] add billing api logic + improve billing alerts components --- .../components/Dashboard/BillingAlerts.tsx | 313 ++++++++++++++---- apps/web/src/components/Dashboard/Usage.tsx | 71 ++-- apps/web/src/components/ui/alert-dialog.tsx | 2 +- package.json | 1 + 4 files changed, 274 insertions(+), 113 deletions(-) diff --git a/apps/web/src/components/Dashboard/BillingAlerts.tsx b/apps/web/src/components/Dashboard/BillingAlerts.tsx index 6f69061a5..6ce66c08a 100644 --- a/apps/web/src/components/Dashboard/BillingAlerts.tsx +++ b/apps/web/src/components/Dashboard/BillingAlerts.tsx @@ -5,10 +5,11 @@ import { useToast } from '../ui/use-toast' import { Button } from '../Button' import { getBillingUrl } from '@/app/(dashboard)/dashboard/utils' import { Team, useUser } from '@/utils/useUser' +import { Loader2 } from 'lucide-react' -interface BillingThreshold { - amount_gte: number | null - alert_amount_percentage: number | null +interface BillingLimit { + limit_amount_gte: number | null + alert_amount_gte: number | null } export const BillingAlerts = ({ @@ -21,18 +22,36 @@ export const BillingAlerts = ({ email: string }) => { const { toast } = useToast() - const [threshold, setThreshold] = useState({ - amount_gte: null, - alert_amount_percentage: null, + const [originalLimits, setOriginalLimits] = useState({ + limit_amount_gte: null, + alert_amount_gte: null, + }) + const [limits, setLimits] = useState({ + limit_amount_gte: null, + alert_amount_gte: null, + }) + const [editMode, setEditMode] = useState({ + limit: false, + alert: false, }) const { user } = useUser() + const [isLoading, setIsLoading] = useState({ + limit: { + save: false, + clear: false, + }, + alert: { + save: false, + clear: false, + }, + }) - const fetchBillingThreshold = useCallback(async () => { + const fetchBillingLimits = useCallback(async () => { if (!user) return try { const res = await fetch( - getBillingUrl(domain, `/teams/${team.id}/billing-thresholds`), + getBillingUrl(domain, `/teams/${team.id}/billing-limits`), { headers: { 'X-Team-API-Key': team.apiKeys[0], @@ -49,7 +68,8 @@ export const BillingAlerts = ({ } const data = await res.json() - setThreshold(data) + setOriginalLimits(data) + setLimits(data) } catch (error) { console.error('Error fetching billing threshold:', error) toast({ @@ -59,33 +79,48 @@ export const BillingAlerts = ({ } }, [user, domain, team, toast]) - const updateBillingThreshold = useCallback(async () => { + const updateBillingLimit = async (type: 'limit' | 'alert') => { if (!user) return + setIsLoading((prev) => ({ + ...prev, + [type]: { ...prev[type], save: true }, + })) + + const value = + type === 'limit' ? limits.limit_amount_gte : limits.alert_amount_gte + try { const res = await fetch( - getBillingUrl(domain, `/teams/${team.id}/billing-thresholds`), + getBillingUrl(domain, `/teams/${team.id}/billing-limits`), { method: 'PATCH', headers: { 'Content-Type': 'application/json', 'X-Team-API-Key': team.apiKeys[0], }, - body: JSON.stringify(threshold), + body: JSON.stringify({ + [type === 'limit' ? 'limit_amount_gte' : 'alert_amount_gte']: value, + }), } ) if (!res.ok) { toast({ - title: 'Failed to update billing alerts', - description: 'Unable to save your billing alert settings', + title: 'Failed to update billing alert', + description: 'Unable to save your billing alert setting', }) return } + setOriginalLimits((prev) => ({ + ...prev, + [type === 'limit' ? 'limit_amount_gte' : 'alert_amount_gte']: value, + })) + toast({ - title: 'Billing alerts updated', - description: 'Your billing alert settings have been saved', + title: 'Billing alert updated', + description: 'Your billing alert setting has been saved', }) } catch (error) { console.error('Error updating billing threshold:', error) @@ -93,79 +128,213 @@ export const BillingAlerts = ({ title: 'Error', description: 'Failed to save billing alert settings', }) + } finally { + setIsLoading((prev) => ({ + ...prev, + [type]: { ...prev[type], save: false }, + })) + } + } + + const deleteBillingLimit = async (type: 'limit' | 'alert') => { + if (!user) return + + setIsLoading((prev) => ({ + ...prev, + [type]: { ...prev[type], clear: true }, + })) + + try { + const res = await fetch( + getBillingUrl( + domain, + `/teams/${team.id}/billing-limits/${ + type === 'limit' ? 'limit_amount_gte' : 'alert_amount_gte' + }` + ), + { + method: 'DELETE', + headers: { + 'X-Team-API-Key': team.apiKeys[0], + }, + } + ) + + if (!res.ok) { + toast({ + title: 'Failed to clear billing alert', + description: 'Unable to clear your billing alert setting', + }) + return + } + + setOriginalLimits((prev) => ({ + ...prev, + [type === 'limit' ? 'limit_amount_gte' : 'alert_amount_gte']: null, + })) + setLimits((prev) => ({ + ...prev, + [type === 'limit' ? 'limit_amount_gte' : 'alert_amount_gte']: null, + })) + + toast({ + title: 'Billing alert cleared', + description: 'Your billing alert setting has been cleared', + }) + } catch (error) { + console.error('Error clearing billing threshold:', error) + toast({ + title: 'Error', + description: 'Failed to clear billing alert settings', + }) + } finally { + setIsLoading((prev) => ({ + ...prev, + [type]: { ...prev[type], clear: false }, + })) } - }, [user, domain, team, threshold, toast]) + } useEffect(() => { - fetchBillingThreshold() - }, [fetchBillingThreshold]) + fetchBillingLimits() + }, [fetchBillingLimits]) - return ( - <> -
-
-

Set a Budget Alert

-

- When your usage reaches this percentage of your budget, you'll - receive an early warning notification to {email}. -

- -
+ const handleSubmit = async ( + e: React.FormEvent, + type: 'limit' | 'alert' + ) => { + e.preventDefault() + + await updateBillingLimit(type) + setEditMode((prev) => ({ ...prev, [type]: false })) + } + + const renderAmountInput = (type: 'limit' | 'alert') => { + const value = + type === 'limit' ? limits.limit_amount_gte : limits.alert_amount_gte + const originalValue = + type === 'limit' + ? originalLimits.limit_amount_gte + : originalLimits.alert_amount_gte + const isEditing = type === 'limit' ? editMode.limit : editMode.alert + + const buttonClasses = 'h-9 items-center' + + if (originalValue === null || isEditing) { + return ( +
+
- setThreshold({ - ...threshold, - alert_amount_percentage: Number(e.target.value) || null, + setLimits({ + ...limits, + [type === 'limit' ? 'limit_amount_gte' : 'alert_amount_gte']: + Number(e.target.value) || null, }) } - placeholder="Enter percentage" + placeholder={`${type === 'limit' ? 'Limit' : 'Alert'} Amount`} />
- % + $
+ + {originalValue !== null && ( + + )}
+ ) + } -
-

Enable Budget Limit

-

- If your organization exceeds this threshold in a given billing - period, -

-

- Caution: This helps you monitor spending before reaching your budget - limit. -

-
- - setThreshold({ - ...threshold, - amount_gte: Number(e.target.value) || null, - }) - } - placeholder="Enter amount" - /> -
- USD -
-
+ return ( +
+
+ $ + + {originalValue} +
+ +
- + ) + } + + return ( + <> +
handleSubmit(e, 'limit')} className="space-y-2"> +

Enable Budget Limit

+

+ If your organization exceeds this threshold in a given billing period, +

+

+ You will automatically receive email notifications when your usage + reaches 50% and 80% of this limit. +

+

+ Caution: This helps you monitor spending before reaching your budget + limit. +

+
{renderAmountInput('limit')}
+
+ +
handleSubmit(e, 'alert')} className="space-y-2"> +

Set an Early Warning Alert

+

+ When your usage reaches this amount, you'll receive an early + warning notification to {email}. +

+
{renderAmountInput('alert')}
+
) } diff --git a/apps/web/src/components/Dashboard/Usage.tsx b/apps/web/src/components/Dashboard/Usage.tsx index c29d0cc7e..4205269cc 100644 --- a/apps/web/src/components/Dashboard/Usage.tsx +++ b/apps/web/src/components/Dashboard/Usage.tsx @@ -1,5 +1,4 @@ import { useEffect, useState } from 'react' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import LineChart from '@/components/Dashboard/Chart' import { toast } from '@/components/ui/use-toast' import { Team } from '@/utils/useUser' @@ -113,19 +112,24 @@ export const UsageContent = ({ return (
- {/* First Row - Costs and Budget Controls */} -
- {/* Cost Card - Full width mobile, half tablet, third desktop */} - - - Costs in USD + {/* Charts Row */} +
+ {/* Budget Controls */} + + +
+ + {/* Cost Section */} +
+
+

Costs in USD

{!isLoading && costThisMonth && (

Total costs this month: ${costThisMonth?.toFixed(2)}

)} - - +
+
{isLoading ? ( ) : ( @@ -135,34 +139,21 @@ export const UsageContent = ({ className="aspect-[21/9] xs:aspect-[2/1] lg:aspect-[16/9]" /> )} - - - - {/* Budget Controls */} - - - Budget Controls - - - - - -
+
+
- {/* Second Row - Resource Usage */} -
- {/* vCPU Card - Always half width except mobile */} - - - vCPU hours + {/* vCPU Section */} +
+
+

vCPU hours

{!isLoading && vcpuHoursThisMonth && (

Total vCPU hours this month:{' '} {vcpuHoursThisMonth?.toFixed(2)}

)} - - +
+
{isLoading ? ( ) : ( @@ -172,21 +163,21 @@ export const UsageContent = ({ className="aspect-[21/9] xs:aspect-[2/1] lg:aspect-[16/9]" /> )} - - +
+
- {/* RAM Card - Always half width except mobile */} - - - RAM hours + {/* RAM Section */} +
+
+

RAM hours

{!isLoading && ramHoursThisMonth && (

Total RAM hours this month:{' '} {ramHoursThisMonth?.toFixed(2)}

)} - - +
+
{isLoading ? ( ) : ( @@ -196,8 +187,8 @@ export const UsageContent = ({ className="aspect-[21/9] xs:aspect-[2/1] lg:aspect-[16/9]" /> )} - - +
+
) diff --git a/apps/web/src/components/ui/alert-dialog.tsx b/apps/web/src/components/ui/alert-dialog.tsx index c5b9ebc1c..ea4cb7a9f 100644 --- a/apps/web/src/components/ui/alert-dialog.tsx +++ b/apps/web/src/components/ui/alert-dialog.tsx @@ -18,7 +18,7 @@ const AlertDialogOverlay = React.forwardRef< >(({ className, ...props }, ref) => ( Date: Fri, 17 Jan 2025 16:15:26 -0800 Subject: [PATCH 06/12] Update text for usage limits --- .../src/components/Dashboard/BillingAlerts.tsx | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/Dashboard/BillingAlerts.tsx b/apps/web/src/components/Dashboard/BillingAlerts.tsx index 6ce66c08a..3a53c3c9d 100644 --- a/apps/web/src/components/Dashboard/BillingAlerts.tsx +++ b/apps/web/src/components/Dashboard/BillingAlerts.tsx @@ -148,8 +148,7 @@ export const BillingAlerts = ({ const res = await fetch( getBillingUrl( domain, - `/teams/${team.id}/billing-limits/${ - type === 'limit' ? 'limit_amount_gte' : 'alert_amount_gte' + `/teams/${team.id}/billing-limits/${type === 'limit' ? 'limit_amount_gte' : 'alert_amount_gte' }` ), { @@ -314,24 +313,23 @@ export const BillingAlerts = ({
handleSubmit(e, 'limit')} className="space-y-2">

Enable Budget Limit

- If your organization exceeds this threshold in a given billing period, + If your team exceeds this threshold in a given billing period, subsequent API requests will be blocked.

You will automatically receive email notifications when your usage - reaches 50% and 80% of this limit. + reaches 50% and 80% of this limit.

- Caution: This helps you monitor spending before reaching your budget - limit. + Caution: Enabling a Budget Limit may cause interruptions to your service. + Once your Budget Limit is reached, your team will not be able to create new sandboxes in the given billing period unless the limit is increased.

{renderAmountInput('limit')}
handleSubmit(e, 'alert')} className="space-y-2"> -

Set an Early Warning Alert

+

Set a Budget Alert

- When your usage reaches this amount, you'll receive an early - warning notification to {email}. + If your team exceeds this threshold in a given billing period, you'll receive an alert notification to your email.

{renderAmountInput('alert')}
From eada5d44795fe39cce642a4acba677d0d1c5143d Mon Sep 17 00:00:00 2001 From: Vasek Mlejnsky Date: Sun, 19 Jan 2025 15:18:33 -0800 Subject: [PATCH 07/12] Fix build error --- apps/web/src/components/Dashboard/BillingAlerts.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/Dashboard/BillingAlerts.tsx b/apps/web/src/components/Dashboard/BillingAlerts.tsx index 3a53c3c9d..8757b146c 100644 --- a/apps/web/src/components/Dashboard/BillingAlerts.tsx +++ b/apps/web/src/components/Dashboard/BillingAlerts.tsx @@ -329,7 +329,7 @@ export const BillingAlerts = ({
handleSubmit(e, 'alert')} className="space-y-2">

Set a Budget Alert

- If your team exceeds this threshold in a given billing period, you'll receive an alert notification to your email. + If your team exceeds this threshold in a given billing period, you'll receive an alert notification to {email}.

{renderAmountInput('alert')}
From 1e454554b92b494a9e3579c1c0a74efbbe47d1a0 Mon Sep 17 00:00:00 2001 From: Ben Fornefeld Date: Mon, 20 Jan 2025 08:37:51 -0800 Subject: [PATCH 08/12] prevent form submission with empty values --- .../components/Dashboard/BillingAlerts.tsx | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/Dashboard/BillingAlerts.tsx b/apps/web/src/components/Dashboard/BillingAlerts.tsx index 8757b146c..921770ac1 100644 --- a/apps/web/src/components/Dashboard/BillingAlerts.tsx +++ b/apps/web/src/components/Dashboard/BillingAlerts.tsx @@ -148,7 +148,8 @@ export const BillingAlerts = ({ const res = await fetch( getBillingUrl( domain, - `/teams/${team.id}/billing-limits/${type === 'limit' ? 'limit_amount_gte' : 'alert_amount_gte' + `/teams/${team.id}/billing-limits/${ + type === 'limit' ? 'limit_amount_gte' : 'alert_amount_gte' }` ), { @@ -204,6 +205,12 @@ export const BillingAlerts = ({ ) => { e.preventDefault() + const value = + type === 'limit' ? limits.limit_amount_gte : limits.alert_amount_gte + if (value === null) { + return + } + await updateBillingLimit(type) setEditMode((prev) => ({ ...prev, [type]: false })) } @@ -313,15 +320,18 @@ export const BillingAlerts = ({
handleSubmit(e, 'limit')} className="space-y-2">

Enable Budget Limit

- If your team exceeds this threshold in a given billing period, subsequent API requests will be blocked. + If your team exceeds this threshold in a given billing period, + subsequent API requests will be blocked.

You will automatically receive email notifications when your usage reaches 50% and 80% of this limit.

- Caution: Enabling a Budget Limit may cause interruptions to your service. - Once your Budget Limit is reached, your team will not be able to create new sandboxes in the given billing period unless the limit is increased. + Caution: Enabling a Budget Limit may cause interruptions to your + service. Once your Budget Limit is reached, your team will not be able + to create new sandboxes in the given billing period unless the limit + is increased.

{renderAmountInput('limit')}
@@ -329,7 +339,8 @@ export const BillingAlerts = ({
handleSubmit(e, 'alert')} className="space-y-2">

Set a Budget Alert

- If your team exceeds this threshold in a given billing period, you'll receive an alert notification to {email}. + If your team exceeds this threshold in a given billing period, + you'll receive an alert notification to {email}.

{renderAmountInput('alert')}
From 379cc26d5bbbb4d9e1d9da9f0aaba125893ba811 Mon Sep 17 00:00:00 2001 From: Ben Fornefeld Date: Fri, 24 Jan 2025 11:20:30 -0800 Subject: [PATCH 09/12] update fetch headers for billing alerts --- apps/web/src/components/Dashboard/BillingAlerts.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/Dashboard/BillingAlerts.tsx b/apps/web/src/components/Dashboard/BillingAlerts.tsx index 921770ac1..8a5801233 100644 --- a/apps/web/src/components/Dashboard/BillingAlerts.tsx +++ b/apps/web/src/components/Dashboard/BillingAlerts.tsx @@ -54,7 +54,7 @@ export const BillingAlerts = ({ getBillingUrl(domain, `/teams/${team.id}/billing-limits`), { headers: { - 'X-Team-API-Key': team.apiKeys[0], + 'X-User-Access-Token': user.accessToken, }, } ) @@ -97,7 +97,7 @@ export const BillingAlerts = ({ method: 'PATCH', headers: { 'Content-Type': 'application/json', - 'X-Team-API-Key': team.apiKeys[0], + 'X-User-Access-Token': user.accessToken, }, body: JSON.stringify({ [type === 'limit' ? 'limit_amount_gte' : 'alert_amount_gte']: value, @@ -155,7 +155,7 @@ export const BillingAlerts = ({ { method: 'DELETE', headers: { - 'X-Team-API-Key': team.apiKeys[0], + 'X-User-Access-Token': user.accessToken, }, } ) From 13375738b988678235250991113d90ec924772a2 Mon Sep 17 00:00:00 2001 From: Ben Fornefeld Date: Fri, 24 Jan 2025 13:48:28 -0800 Subject: [PATCH 10/12] update teams retrieval for is_blocked & blocked_reason --- apps/web/src/utils/useUser.tsx | 63 +++++++++++++++++++++------------- 1 file changed, 40 insertions(+), 23 deletions(-) diff --git a/apps/web/src/utils/useUser.tsx b/apps/web/src/utils/useUser.tsx index 0883b4c6b..8bf5a1ba1 100644 --- a/apps/web/src/utils/useUser.tsx +++ b/apps/web/src/utils/useUser.tsx @@ -1,6 +1,13 @@ 'use client' -import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react' +import { + createContext, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react' import { User } from '@supabase/supabase-auth-helpers/react' import { createPagesBrowserClient } from '@supabase/auth-helpers-nextjs' import { type Session } from '@supabase/supabase-js' @@ -13,34 +20,38 @@ export type Team = { is_default: boolean email: string apiKeys: string[] + is_blocked: boolean + blocked_reason: string } -interface APIKey { api_key: string; } +interface APIKey { + api_key: string +} interface UserTeam { - is_default: boolean; + is_default: boolean teams: { - tier: string; - email: string; - team_api_keys: { api_key: string; }[]; - id: string; - name: string; + tier: string + email: string + team_api_keys: { api_key: string }[] + id: string + name: string + is_blocked: boolean + blocked_reason: string } } -export type E2BUser = (User & { - teams: Team[]; - accessToken: string; - defaultTeamId: string; -}) +export type E2BUser = User & { + teams: Team[] + accessToken: string + defaultTeamId: string +} type UserContextType = { - isLoading: boolean; - session: Session | null; - user: - | E2BUser - | null; - error: Error | null; - wasUpdated: boolean | null; + isLoading: boolean + session: Session | null + user: E2BUser | null + error: Error | null + wasUpdated: boolean | null } export const UserContext = createContext(undefined) @@ -115,7 +126,9 @@ export const CustomUserContextProvider = (props) => { const { data: userTeams, error: teamsError } = await supabase .from('users_teams') - .select('is_default, teams (id, name, tier, email, team_api_keys (api_key))') + .select( + 'is_default, teams (id, name, tier, is_blocked, blocked_reason, email, team_api_keys (api_key))' + ) .eq('user_id', session?.user.id) // Due to RLS, we could also safely just fetch all, but let's be explicit for sure if (teamsError) Sentry.captureException(teamsError) @@ -134,10 +147,14 @@ export const CustomUserContextProvider = (props) => { tier: userTeam.teams.tier, is_default: userTeam.is_default, email: userTeam.teams.email, - apiKeys: userTeam.teams.team_api_keys.map((apiKey: APIKey) => apiKey.api_key), + apiKeys: userTeam.teams.team_api_keys.map( + (apiKey: APIKey) => apiKey.api_key + ), + is_blocked: userTeam.teams.is_blocked, + blocked_reason: userTeam.teams.blocked_reason, } }) - const defaultTeam = teams?.find(team => team.is_default) + const defaultTeam = teams?.find((team) => team.is_default) if (!defaultTeam) { console.error('No default team found for user', session?.user.id) From 830af8584ed54613def71a842318676372538659 Mon Sep 17 00:00:00 2001 From: Ben Fornefeld Date: Fri, 24 Jan 2025 14:10:02 -0800 Subject: [PATCH 11/12] add team blocked banner on page layout --- apps/web/src/app/(dashboard)/dashboard/page.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/web/src/app/(dashboard)/dashboard/page.tsx b/apps/web/src/app/(dashboard)/dashboard/page.tsx index 9ef38e833..052fb42df 100644 --- a/apps/web/src/app/(dashboard)/dashboard/page.tsx +++ b/apps/web/src/app/(dashboard)/dashboard/page.tsx @@ -152,9 +152,18 @@ const Dashboard = ({ user }) => { domainState={domainState} />
-

- {selectedItem[0].toUpperCase() + selectedItem.slice(1)} -

+
+

+ {selectedItem[0].toUpperCase() + selectedItem.slice(1)} +

+ {currentTeam.is_blocked && ( +

+ Your team is blocked.
+ {currentTeam.blocked_reason}. +

+ )} +
+
Date: Fri, 24 Jan 2025 15:07:19 -0800 Subject: [PATCH 12/12] improve team blocked banner --- apps/web/src/app/(dashboard)/dashboard/page.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/apps/web/src/app/(dashboard)/dashboard/page.tsx b/apps/web/src/app/(dashboard)/dashboard/page.tsx index 052fb42df..7b0f01f50 100644 --- a/apps/web/src/app/(dashboard)/dashboard/page.tsx +++ b/apps/web/src/app/(dashboard)/dashboard/page.tsx @@ -4,6 +4,7 @@ import { Suspense, useEffect, useState } from 'react' import { useLocalStorage } from 'usehooks-ts' import { + ArrowUpRight, BarChart, CreditCard, FileText, @@ -27,6 +28,7 @@ import { PersonalContent } from '@/components/Dashboard/Personal' import { TemplatesContent } from '@/components/Dashboard/Templates' import { SandboxesContent } from '@/components/Dashboard/Sandboxes' import { DeveloperContent } from '@/components/Dashboard/Developer' +import { Button } from '@/components/Button' function redirectToCurrentURL() { const url = typeof window !== 'undefined' ? window.location.href : undefined @@ -152,15 +154,19 @@ const Dashboard = ({ user }) => { domainState={domainState} />
-
+

{selectedItem[0].toUpperCase() + selectedItem.slice(1)}

{currentTeam.is_blocked && ( -

- Your team is blocked.
- {currentTeam.blocked_reason}. -

+ )}