Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions main_service/frontend/.frontend_last_built_at.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
1772827541
190 changes: 190 additions & 0 deletions main_service/frontend/src/components/BillingPortalSettings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { AlertTriangle, CheckCircle2, CreditCard, ExternalLink, Loader2 } from "lucide-react";

type ViewState = "idle" | "processing" | "error" | "returned";
const PORTAL_REDIRECT_PATH = "/billing/portal-session/redirect";

const BillingPortalSettings = () => {
const [viewState, setViewState] = useState<ViewState>("idle");
const [error, setError] = useState<string | null>(null);

const setupState = useMemo(() => {
const params = new URLSearchParams(window.location.search);
return {
billingSetup: params.get("billing_setup"),
checkoutSessionId: params.get("checkout_session_id"),
didReturnFromPortal: params.get("billing_portal_return") === "1",
};
}, []);

const clearQueryParam = useCallback((name: string) => {
const url = new URL(window.location.href);
if (!url.searchParams.has(name)) return;
url.searchParams.delete(name);
window.history.replaceState({}, "", url.toString());
}, []);

const clearSetupQueryParams = useCallback(() => {
const url = new URL(window.location.href);
let changed = false;
for (const key of ["billing_setup", "checkout_session_id"]) {
if (!url.searchParams.has(key)) continue;
url.searchParams.delete(key);
changed = true;
}
if (changed) {
window.history.replaceState({}, "", url.toString());
}
}, []);

const openPortalInNewTab = useCallback(() => {
setError(null);
const popup = window.open(PORTAL_REDIRECT_PATH, "_blank");
if (popup) return;
setError("Popup blocked by browser. Please allow popups for this site and try again.");
setViewState("error");
}, []);

const confirmSetupSession = useCallback(async (checkoutSessionId: string) => {
const res = await fetch("/billing/confirm-setup-session", {
method: "POST",
credentials: "include",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({ session_id: checkoutSessionId }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const payload = await res.json().catch(() => ({}));
if (payload?.has_payment_method === true) {
window.dispatchEvent(new Event("burla:payment-method-updated"));
}
}, []);

useEffect(() => {
const run = async () => {
if (setupState.billingSetup === "success" && setupState.checkoutSessionId) {
setError(null);
setViewState("processing");
try {
await confirmSetupSession(setupState.checkoutSessionId);
setViewState("returned");
} catch (err: any) {
setError(err?.message || "Could not confirm Stripe setup session.");
setViewState("error");
} finally {
clearSetupQueryParams();
}
return;
}

if (setupState.billingSetup === "cancel") {
clearSetupQueryParams();
setViewState("idle");
return;
}

if (setupState.didReturnFromPortal) {
clearQueryParam("billing_portal_return");
setViewState("returned");
return;
}

setViewState("idle");
};

run();
}, [
clearQueryParam,
clearSetupQueryParams,
confirmSetupSession,
setupState.billingSetup,
setupState.checkoutSessionId,
setupState.didReturnFromPortal,
]);

const processingView = (
<div className="min-h-[420px] flex items-center justify-center">
<div className="w-full max-w-md rounded-xl border border-border bg-card p-7 shadow-sm text-center">
<div className="mx-auto w-14 h-14 rounded-full bg-primary/10 flex items-center justify-center">
<Loader2 className="h-7 w-7 animate-spin text-primary" />
</div>
<h3 className="mt-4 text-xl font-semibold text-foreground">Finalizing payment setup</h3>
<p className="mt-2 text-sm text-muted-foreground">
Confirming your saved card details from Stripe.
</p>
</div>
</div>
);

const idleView = (
<div className="min-h-[420px] flex items-center justify-center">
<div className="w-full max-w-md rounded-xl border border-border bg-card p-7 shadow-sm text-center">
<div className="mx-auto w-14 h-14 rounded-full bg-primary/10 flex items-center justify-center">
<CreditCard className="h-7 w-7 text-primary" />
</div>
<h3 className="mt-4 text-xl font-semibold text-foreground">Manage payment method</h3>
<p className="mt-2 text-sm text-muted-foreground">
Open Stripe in a new tab to securely update your card details.
</p>
<div className="mt-6 flex justify-center">
<Button onClick={openPortalInNewTab}>
<ExternalLink className="mr-2 h-4 w-4" />
Open payment update
</Button>
</div>
</div>
</div>
);

const returnedView = (
<div className="min-h-[420px] flex items-center justify-center">
<div className="w-full max-w-md rounded-xl border border-border bg-card p-7 shadow-sm text-center">
<div className="mx-auto w-14 h-14 rounded-full bg-emerald-100 flex items-center justify-center">
<CheckCircle2 className="h-7 w-7 text-emerald-700" />
</div>
<h3 className="mt-4 text-xl font-semibold text-foreground">Payment method updated</h3>
<p className="mt-2 text-sm text-muted-foreground">
If you need to make another change, reopen Stripe below.
</p>
<div className="mt-6 flex justify-center">
<Button onClick={openPortalInNewTab}>
<ExternalLink className="mr-2 h-4 w-4" />
Open payment update
</Button>
</div>
</div>
</div>
);

const errorView = (
<div className="space-y-4">
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Could not open Stripe payment update</AlertTitle>
<AlertDescription>{error || "Please try again."}</AlertDescription>
</Alert>
<Button onClick={openPortalInNewTab}>Try again</Button>
</div>
);

return (
<Card className="w-full">
<CardHeader>
<CardTitle className="text-xl font-semibold text-primary">Billing</CardTitle>
</CardHeader>
<CardContent>
{viewState === "error" ? errorView : null}
{viewState === "returned" ? returnedView : null}
{viewState === "idle" ? idleView : null}
{viewState === "processing" ? processingView : null}
</CardContent>
</Card>
);
};

export default BillingPortalSettings;
64 changes: 32 additions & 32 deletions main_service/frontend/src/components/SettingsForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,39 +17,39 @@ import { toast } from "@/components/ui/use-toast";
// Add prop type for SettingsForm
interface SettingsFormProps {
isEditing: boolean;
onChange?: () => void;
}
onChange?: () => void;
}

export const SettingsForm = forwardRef<{ isRegionValid: () => boolean }, SettingsFormProps>(
({ isEditing, onChange }, ref) => {
const { settings, setSettings } = useSettings();
const users = settings.users ?? [];
const [newUser, setNewUser] = useState("");
const [newUser, setNewUser] = useState("");

const cpuOptions = [
{ label: "2vCPU / 8G RAM", value: "n4-standard-2" },
{ label: "4vCPU / 16G RAM", value: "n4-standard-4" },
{ label: "8vCPU / 32G RAM", value: "n4-standard-8" },
{ label: "16vCPU / 64G RAM", value: "n4-standard-16" },
{ label: "32vCPU / 128G RAM", value: "n4-standard-32" },
{ label: "64vCPU / 256G RAM", value: "n4-standard-64" },
{ label: "80vCPU / 320G RAM", value: "n4-standard-80" },
{ label: "2 vCPUs / 8 GB RAM", value: "n4-standard-2" },
{ label: "4 vCPUs / 16 GB RAM", value: "n4-standard-4" },
{ label: "8 vCPUs / 32 GB RAM", value: "n4-standard-8" },
{ label: "16 vCPUs / 64 GB RAM", value: "n4-standard-16" },
{ label: "32 vCPUs / 128 GB RAM", value: "n4-standard-32" },
{ label: "64 vCPUs / 256 GB RAM", value: "n4-standard-64" },
{ label: "80 vCPUs / 320 GB RAM", value: "n4-standard-80" },
];

const gpuCpuMap = {
"1x A100 40G": { label: "12vCPU / 85G RAM", value: "a2-highgpu-1g" },
"2x A100 40G": { label: "24vCPU / 170G RAM", value: "a2-highgpu-2g" },
"4x A100 40G": { label: "48vCPU / 340G RAM", value: "a2-highgpu-4g" },
"8x A100 40G": { label: "96vCPU / 680G RAM", value: "a2-highgpu-8g" },
"1x A100 80G": { label: "12vCPU / 170G RAM", value: "a2-ultragpu-1g" },
"2x A100 80G": { label: "24vCPU / 340G RAM", value: "a2-ultragpu-2g" },
"4x A100 80G": { label: "48vCPU / 680G RAM", value: "a2-ultragpu-4g" },
"8x A100 80G": { label: "96vCPU / 1360G RAM", value: "a2-ultragpu-8g" },
"1x H100 80G": { label: "26vCPU / 234G RAM", value: "a3-highgpu-1g" },
"2x H100 80G": { label: "52vCPU / 468G RAM", value: "a3-highgpu-2g" },
"4x H100 80G": { label: "104vCPU / 936G RAM", value: "a3-highgpu-4g" },
"8x H100 80G": { label: "208vCPU / 1872G RAM", value: "a3-highgpu-8g" },
"8x H200 141G": { label: "224vCPU / 2952G RAM", value: "a3-ultragpu-8g" },
"1x A100 40G": { label: "12 vCPUs / 85 GB RAM", value: "a2-highgpu-1g" },
"2x A100 40G": { label: "24 vCPUs / 170 GB RAM", value: "a2-highgpu-2g" },
"4x A100 40G": { label: "48 vCPUs / 340 GB RAM", value: "a2-highgpu-4g" },
"8x A100 40G": { label: "96 vCPUs / 680 GB RAM", value: "a2-highgpu-8g" },
"1x A100 80G": { label: "12 vCPUs / 170 GB RAM", value: "a2-ultragpu-1g" },
"2x A100 80G": { label: "24 vCPUs / 340 GB RAM", value: "a2-ultragpu-2g" },
"4x A100 80G": { label: "48 vCPUs / 680 GB RAM", value: "a2-ultragpu-4g" },
"8x A100 80G": { label: "96 vCPUs / 1360 GB RAM", value: "a2-ultragpu-8g" },
"1x H100 80G": { label: "26 vCPUs / 234 GB RAM", value: "a3-highgpu-1g" },
"2x H100 80G": { label: "52 vCPUs / 468 GB RAM", value: "a3-highgpu-2g" },
"4x H100 80G": { label: "104 vCPUs / 936 GB RAM", value: "a3-highgpu-4g" },
"8x H100 80G": { label: "208 vCPUs / 1872 GB RAM", value: "a3-highgpu-8g" },
"8x H200 141G": { label: "224 vCPUs / 2952 GB RAM", value: "a3-ultragpu-8g" },
};

// Build variant -> supported counts (e.g., "A100 40G" -> [1,2,4,8,16])
Expand Down Expand Up @@ -213,7 +213,7 @@ export const SettingsForm = forwardRef<{ isRegionValid: () => boolean }, Setting
{ value: "asia-southeast1", label: "asia‑southeast1" },
{ value: "australia-southeast1", label: "australia‑southeast1" },
],
};
};

// Helper to determine which region list to use
function getRegionOptionsForGpu(gpuVariant) {
Expand All @@ -224,7 +224,7 @@ export const SettingsForm = forwardRef<{ isRegionValid: () => boolean }, Setting
if (gpuVariant.includes("H200 141G")) return REGION_OPTIONS["H200 141G"];
// fallback to None if unknown
return REGION_OPTIONS["None"];
}
}

const regionOptions = getRegionOptionsForGpu(gpuVariant);
const isRegionValid = regionOptions.some((r) => r.value === settings.gcpRegion);
Expand Down Expand Up @@ -267,10 +267,10 @@ export const SettingsForm = forwardRef<{ isRegionValid: () => boolean }, Setting
</TooltipTrigger>
<TooltipContent>
<p>
URI of the Docker image to run your code
URI of the Docker image to run your code
inside.
<br />
This can be any image, as long as it has
This can be any image, as long as it has
Python installed.
<br />
Private images are pulled using the host
Expand Down Expand Up @@ -389,8 +389,8 @@ export const SettingsForm = forwardRef<{ isRegionValid: () => boolean }, Setting
</div>

{/* GPUs per VM (hidden when None) */}
{gpuVariant !== "None" ? (
<div className="flex flex-col space-y-2">
{gpuVariant !== "None" ? (
<div className="flex flex-col space-y-2">
<label className={labelClass}>GPUs per VM</label>
<Select
disabled={!isEditing}
Expand All @@ -407,12 +407,12 @@ export const SettingsForm = forwardRef<{ isRegionValid: () => boolean }, Setting
</SelectItem>
))}
</SelectContent>
</Select>
</Select>
</div>
) : (
// placeholder to maintain grid alignment
<div className="hidden md:block" />
)}
<div className="hidden md:block" />
)}
</div>

{/* Second row: two equal columns */}
Expand Down
Loading