From af340ef5a479c21debb281b8004b815c986700d0 Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Thu, 14 May 2026 12:52:15 +0200 Subject: [PATCH 01/49] orgs --- datalayer_core/cli/commands/authn.py | 4 +- datalayer_core/cli/commands/subscription.py | 20 +- datalayer_core/mixins/usage.py | 6 +- src/components/checkout/StripeCheckout.tsx | 798 ++++++++++++-------- src/hooks/useCache.ts | 14 +- src/models/Profile.ts | 2 +- src/models/User.ts | 2 +- 7 files changed, 499 insertions(+), 347 deletions(-) diff --git a/datalayer_core/cli/commands/authn.py b/datalayer_core/cli/commands/authn.py index ccbf25d0..2ba70461 100644 --- a/datalayer_core/cli/commands/authn.py +++ b/datalayer_core/cli/commands/authn.py @@ -429,9 +429,9 @@ def whoami( console.print(f" šŸ”— {provider_name.capitalize()}") # Customer UID - if user.get("credits_customer_uid"): + if user.get("stripe_customer_id_s"): console.print( - f"\nšŸ’³ Credits Customer: {user.get('credits_customer_uid')}" + f"\nšŸ’³ Credits Customer: {user.get('stripe_customer_id_s')}" ) else: console.print("[yellow]Not authenticated[/yellow]") diff --git a/datalayer_core/cli/commands/subscription.py b/datalayer_core/cli/commands/subscription.py index c4d85ce7..be73efe9 100644 --- a/datalayer_core/cli/commands/subscription.py +++ b/datalayer_core/cli/commands/subscription.py @@ -21,7 +21,7 @@ def _extract_subscription(payload: dict[str, Any]) -> dict[str, Any]: - return payload.get("subscription") or {} + return payload.get("plan") or {} def _normalize_value(value: Any, fallback: str = "Not available") -> str: @@ -71,12 +71,8 @@ def _as_plan_list(value: Any) -> list[dict[str, Any]]: def _extract_available_plans(payload: dict[str, Any]) -> list[dict[str, Any]]: subscription = _extract_subscription(payload) candidates = [ - payload.get("available_subscriptions"), payload.get("available_plans"), payload.get("plans"), - subscription.get("available_subscriptions") - if isinstance(subscription, dict) - else None, subscription.get("available_plans") if isinstance(subscription, dict) else None, subscription.get("plans") if isinstance(subscription, dict) else None, ] @@ -572,8 +568,8 @@ def subscription_stats( paid_count = 0 for user in users: - status = str(user.get("subscription_status_s") or "none").lower() - plan = str(user.get("subscription_plan_s") or "none") + status = str(user.get("plan_status_s") or "none").lower() + plan = str(user.get("plan_name_s") or "none") status_counter[status] += 1 plan_counter[plan] += 1 @@ -663,9 +659,9 @@ def subscription_admin_users( for user in users: table.add_row( _normalize_value(user.get("handle_s")), - _normalize_value(user.get("subscription_plan_s"), fallback="none"), - _normalize_value(user.get("subscription_status_s"), fallback="none"), - _normalize_value(user.get("credits_customer_uid"), fallback="none"), + _normalize_value(user.get("plan_name_s"), fallback="none"), + _normalize_value(user.get("plan_status_s"), fallback="none"), + _normalize_value(user.get("stripe_customer_id_s"), fallback="none"), ) console.print(table) @@ -740,13 +736,13 @@ def subscription_dry_run( if sub_resp.get("success", True): sub = _extract_subscription(sub_resp) console.print( - "[green]OK[/green] /api/iam/v1/subscription " + "[green]OK[/green] /api/iam/v1/plans " f"plan={_normalize_value(sub.get('plan_name'), 'unknown')} " f"status={_normalize_value(sub.get('status'), 'unknown')}" ) else: console.print( - "[red]FAILED[/red] /api/iam/v1/subscription " + "[red]FAILED[/red] /api/iam/v1/plans " f"{sub_resp.get('message', 'Unknown error')}" ) diff --git a/datalayer_core/mixins/usage.py b/datalayer_core/mixins/usage.py index 80bc8f43..ae5856f3 100644 --- a/datalayer_core/mixins/usage.py +++ b/datalayer_core/mixins/usage.py @@ -37,7 +37,7 @@ def _get_subscription(self) -> dict[str, Any]: """ try: response = self._fetch( # type: ignore - "{}/api/iam/v1/subscription".format(self.urls.iam_url), # type: ignore + "{}/api/iam/v1/plans".format(self.urls.iam_url), # type: ignore ) return response.json() except RuntimeError as e: @@ -54,7 +54,7 @@ def _cancel_subscription(self) -> dict[str, Any]: """ try: response = self._fetch( # type: ignore - "{}/api/iam/v1/subscription/cancel".format(self.urls.iam_url), # type: ignore + "{}/api/iam/v1/plans/cancel".format(self.urls.iam_url), # type: ignore method="POST", ) return response.json() @@ -72,7 +72,7 @@ def _get_subscription_plans(self) -> dict[str, Any]: """ try: response = self._fetch( # type: ignore - "{}/api/iam/v1/subscription/plans".format(self.urls.iam_url), # type: ignore + "{}/api/iam/v1/plans/catalog".format(self.urls.iam_url), # type: ignore ) return response.json() except RuntimeError as e: diff --git a/src/components/checkout/StripeCheckout.tsx b/src/components/checkout/StripeCheckout.tsx index 7006f81b..2afa540b 100644 --- a/src/components/checkout/StripeCheckout.tsx +++ b/src/components/checkout/StripeCheckout.tsx @@ -311,12 +311,11 @@ export function StripeCheckout({ checkoutPortal, appearance, accountUid, - showStatusUsageSummary = true, + showStatusUsageSummary = false, }: StripeCheckoutProps) { const { useCreateTopUpPaymentIntent, useCreateSubscriptionPaymentIntent, - useCreateResumeSetupIntent, useSubscriptionPlans, useTopUpPrices, useSubscriptionStatus, @@ -368,7 +367,6 @@ export function StripeCheckout({ const subscriptionPaymentIntentMutation = useCreateSubscriptionPaymentIntent({ accountUid, }); - const resumeSetupIntentMutation = useCreateResumeSetupIntent({ accountUid }); // Load stripe API useEffect(() => { @@ -445,7 +443,7 @@ export function StripeCheckout({ } }, [checkoutType, refetchSubscriptionStatus, resumeSubscriptionMutation]); - const subscription = subscriptionResp?.subscription || null; + const subscription = subscriptionResp?.plan || null; const availablePlans = useMemo(() => { const byId = new Map(); const add = (plan: any) => { @@ -466,9 +464,9 @@ export function StripeCheckout({ }); }; plans.forEach(add); - (subscriptionResp?.available_subscriptions || []).forEach(add); + (subscriptionResp?.available_plans || []).forEach(add); return Array.from(byId.values()); - }, [plans, subscriptionResp?.available_subscriptions]); + }, [plans, subscriptionResp?.available_plans]); const subscriptionStatus = subscription?.status || 'unknown'; const normalizedSubscriptionStatus = String(subscriptionStatus).toLowerCase(); @@ -894,27 +892,35 @@ export function StripeCheckout({ const onResumeSubscription = useCallback(async () => { setPaymentMessage(null); try { - const clientSecret = await resumeSetupIntentMutation.mutateAsync(); - if (!clientSecret) { - setCheckout(false); - setPaymentClientSecret(null); - setPaymentMessage( - 'Unable to initialize Stripe checkout. Please try again.', + const resp = await resumeSubscriptionMutation.mutateAsync(); + if (resp?.success === false) { + throw new Error( + resp?.message || 'Unable to resume your plan right now.', ); - return; } - setCheckoutType('resume'); - setPaymentClientSecret(clientSecret); - setCheckout(true); - setPaymentMessage(null); + + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + await refetchSubscriptionStatus(); + } catch { + // Ignore transient refetch errors and keep trying. + } + if (attempt < 4) { + await new Promise(resolve => setTimeout(resolve, 800)); + } + } + + setCheckout(false); + setPaymentClientSecret(null); + setPaymentMessage(resp?.message || 'Plan resumed successfully.'); } catch (error) { setPaymentMessage( error instanceof Error ? error.message - : 'Unable to initialize resume checkout right now.', + : 'Unable to resume your plan right now.', ); } - }, [resumeSetupIntentMutation]); + }, [refetchSubscriptionStatus, resumeSubscriptionMutation]); const onRefreshSubscriptionStatus = useCallback(async () => { setPaymentMessage(null); @@ -947,10 +953,6 @@ export function StripeCheckout({ return `${product.name} (${amount}, ${product.credits} credits)`; } - if (checkoutType === 'resume') { - return 'Plan resume (card update required)'; - } - return null; }, [checkoutType, product, subscriptionPlan]); @@ -960,7 +962,10 @@ export function StripeCheckout({ marginBottom: 'var(--stack-gap-normal)', } as const; - const monthlySubscriptionSection = ( + const shouldShowMonthlySubscriptionSection = + !isPaidSubscription || isIncompleteSubscription; + + const monthlySubscriptionSection = shouldShowMonthlySubscriptionSection ? ( - ) : ( - - {isCancellationScheduled - ? `Your monthly plan will cancel on ${subscriptionPeriodEndLabel}.` - : 'Your monthly plan is active. You can manage plan details from plan controls.'} - - )} + ) : null} - ); + ) : null; const topUpSection = ( @@ -1133,341 +1132,494 @@ export function StripeCheckout({ ); - const topCards = showStatusUsageSummary ? ( - + const topCards = + showStatusUsageSummary && !isPaidSubscription ? ( - - - Plan status - - Plan: {String(currentSubscriptionPlan)} - {isPendingSubscriptionCheckout && ( - + + - Upgrade pending payment. Your Team plan is not active until card - payment succeeds. - - )} - {currentPlanPriceLabel !== 'N/A' && ( - Price: {currentPlanPriceLabel} - )} - {displaySubscriptionStatus && ( - - Status: {displaySubscriptionStatus} + Plan status - )} - + Plan: {String(currentSubscriptionPlan)} + {isPendingSubscriptionCheckout && ( + + Upgrade pending payment. Your Team plan is not active until card + payment succeeds. + + )} + {currentPlanPriceLabel !== 'N/A' && ( + Price: {currentPlanPriceLabel} + )} + {displaySubscriptionStatus && ( + + Status: {displaySubscriptionStatus} + + )} - - Current usage - - - - - - - Runs: {usedRuns.toLocaleString()} / {runsTotal.toLocaleString()} - - - - - - - - - Used in quota - - - - Remaining - - - - Over quota - + + Current usage + + - - {periodProgress ? ( - Usage period days: {periodProgress.elapsedDays} /{' '} - {periodProgress.totalDays} + Runs: {usedRuns.toLocaleString()} /{' '} + {runsTotal.toLocaleString()} + - - {periodProgress.remainingDays} day(s) remaining in current - period - + + + Used in quota + + + + Remaining + + + + Over quota + + - ) : null} - - - Wallet balance: {walletBalance.toLocaleString()} - - - Spent credits in current period:{' '} - {usedCredits.toLocaleString(undefined, { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })} - - - Wallet credits are additive on renewal and top-ups. - + {periodProgress ? ( + + + Usage period days: {periodProgress.elapsedDays} /{' '} + {periodProgress.totalDays} + + + + + + + {periodProgress.remainingDays} day(s) remaining in current + period + + + ) : null} + + + + Wallet balance: {walletBalance.toLocaleString()} + + + Spent credits in current period:{' '} + {usedCredits.toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} + + + Wallet credits are additive on renewal and top-ups. + + - - {isCancellationScheduled && ( - + Plan will switch to Free at the end of the current period on{' '} + {subscriptionPeriodEndLabel}. + + )} + - Plan will switch to Free at the end of the current period on{' '} - {subscriptionPeriodEndLabel}. - - )} - - {subscriptionPortalUrl && ( + {subscriptionPortalUrl && ( + + )} - )} - - {canCancelSubscription && !cancelViewOpen && ( - - )} - {isIncompleteSubscription && !cancelViewOpen && ( - <> + {canCancelSubscription && !cancelViewOpen && ( + + )} + {isIncompleteSubscription && !cancelViewOpen && ( + <> + + + + )} + {isCancellationScheduled && ( - - - )} - {isCancellationScheduled && ( - + + {isIncompleteSubscription + ? 'Cancel pending plan change' + : 'Downgrade to Free Plan'} + + + {isIncompleteSubscription + ? 'This pending plan change will be canceled immediately.' + : 'Your plan will switch at the end of the current usage period.'} + + + + + + )} - - Next step:{' '} - {isCancellationScheduled - ? 'Your plan is already scheduled to switch at period end. You can keep using it until then.' - : isIncompleteSubscription - ? 'Your payment is pending. Open the in-app cancel view below to cancel this plan change or continue with payment.' - : isPaidSubscription - ? 'Keep your plan active. You can top-up credits any time.' - : 'Top-up credits are available on Free and Team plans.'} + + + ) : null; + + const currentPlanSection = isPaidSubscription ? ( + + + + Current plan + + + {String(currentSubscriptionPlan)} + + + You are currently on {String(currentSubscriptionPlan)}. + + {currentPlanPriceLabel !== 'N/A' && ( + + {currentPlanPriceLabel} - {cancelViewOpen && ( + )} + {displaySubscriptionStatus && ( + + + + )} + + {isCancellationScheduled ? ( + + Your downgrade to Free Plan is scheduled at period end on{' '} + {subscriptionPeriodEndLabel}. + + ) : null} + + + {isCancellationScheduled + ? 'Possible action: Resume Team Plan.' + : 'Possible action: Downgrade to Free Plan.'} + + + + {canCancelSubscription && !cancelViewOpen && ( + + )} + {isCancellationScheduled && ( + + )} + + + {cancelViewOpen && ( + + + Downgrade to Free Plan + + + Your plan will switch at the end of the current usage period. + - - {isIncompleteSubscription - ? 'Cancel pending plan change' - : 'Downgrade to Free Plan'} - - - {isIncompleteSubscription - ? 'This pending plan change will be canceled immediately.' - : 'Your plan will switch at the end of the current usage period.'} - - void onConfirmCancelSubscription()} + disabled={cancelSubscriptionMutation.isPending} > - - - + {cancelSubscriptionMutation.isPending + ? 'Downgrading...' + : 'Confirm downgrade'} + + - )} - + + )} ) : null; @@ -1540,13 +1692,7 @@ export function StripeCheckout({ 'Cancel', ), ), - checkoutType === 'resume' - ? createElement( - Flash, - { variant: 'warning' }, - 'Enter a new payment card to resume your plan.', - ) - : null, + null, createElement( Elements, { @@ -1623,22 +1769,32 @@ export function StripeCheckout({ padding: 'var(--stack-padding-normal)', display: 'grid', gap: 'var(--stack-gap-normal)', - gridTemplateColumns: ['1fr', 'minmax(0, 1fr) minmax(0, 1fr)'], - alignItems: 'start', + gridTemplateColumns: + shouldShowMonthlySubscriptionSection || currentPlanSection + ? ['1fr', 'minmax(0, 1fr) minmax(0, 1fr)'] + : ['1fr'], + alignItems: 'stretch', }} > + {shouldShowMonthlySubscriptionSection ? ( + + {monthlySubscriptionSection} + + ) : null} + {currentPlanSection} - {monthlySubscriptionSection} - - {topUpSection} diff --git a/src/hooks/useCache.ts b/src/hooks/useCache.ts index 4573bed0..0e966bf3 100644 --- a/src/hooks/useCache.ts +++ b/src/hooks/useCache.ts @@ -5496,7 +5496,7 @@ export const useCache = ({ loginRoute = '/login' }: CacheProps = {}) => { queryFn: async () => { const resp = await requestDatalayer({ url: withAccountUidQuery( - `${configuration.iamRunUrl}/api/iam/v1/subscription/plans`, + `${configuration.iamRunUrl}/api/iam/v1/plans/catalog`, scope?.accountUid, ), method: 'GET', @@ -5561,7 +5561,7 @@ export const useCache = ({ loginRoute = '/login' }: CacheProps = {}) => { queryFn: async () => { return requestDatalayer({ url: withAccountUidQuery( - `${configuration.iamRunUrl}/api/iam/v1/subscription`, + `${configuration.iamRunUrl}/api/iam/v1/plans`, scope?.accountUid, ), method: 'GET', @@ -5581,7 +5581,7 @@ export const useCache = ({ loginRoute = '/login' }: CacheProps = {}) => { queryKey: ['subscription', 'eligible-accounts'], queryFn: async () => { const resp = await requestDatalayer({ - url: `${configuration.iamRunUrl}/api/iam/v1/subscription/eligible-accounts`, + url: `${configuration.iamRunUrl}/api/iam/v1/plans/eligible-accounts`, method: 'GET', }); return resp.accounts || []; @@ -5600,7 +5600,7 @@ export const useCache = ({ loginRoute = '/login' }: CacheProps = {}) => { mutationFn: async () => { return requestDatalayer({ url: withAccountUidQuery( - `${configuration.iamRunUrl}/api/iam/v1/subscription/cancel`, + `${configuration.iamRunUrl}/api/iam/v1/plans/cancel`, scope?.accountUid, ), method: 'POST', @@ -5624,7 +5624,7 @@ export const useCache = ({ loginRoute = '/login' }: CacheProps = {}) => { mutationFn: async () => { return requestDatalayer({ url: withAccountUidQuery( - `${configuration.iamRunUrl}/api/iam/v1/subscription/resume`, + `${configuration.iamRunUrl}/api/iam/v1/plans/resume`, scope?.accountUid, ), method: 'POST', @@ -5649,7 +5649,7 @@ export const useCache = ({ loginRoute = '/login' }: CacheProps = {}) => { queryKey: ['subscription', 'admin', userId], queryFn: async () => { return requestDatalayer({ - url: `${configuration.iamRunUrl}/api/iam/v1/subscription/admin/${userId}`, + url: `${configuration.iamRunUrl}/api/iam/v1/plans/admin/${userId}`, method: 'GET', }); }, @@ -5667,7 +5667,7 @@ export const useCache = ({ loginRoute = '/login' }: CacheProps = {}) => { return useMutation({ mutationFn: async (userId: string) => { return requestDatalayer({ - url: `${configuration.iamRunUrl}/api/iam/v1/subscription/admin/${userId}/reset`, + url: `${configuration.iamRunUrl}/api/iam/v1/plans/admin/${userId}/reset`, method: 'POST', }); }, diff --git a/src/models/Profile.ts b/src/models/Profile.ts index fbe41d5f..1353a898 100644 --- a/src/models/Profile.ts +++ b/src/models/Profile.ts @@ -48,7 +48,7 @@ export interface Profile { /** Customer UID */ customer_uid?: string | null; /** Credits customer UID for billing */ - credits_customer_uid?: string | null; + stripe_customer_id_s?: string | null; /** Email unsubscription status */ unsubscribed_from_outbounds_b?: boolean; /** Linked contact UID */ diff --git a/src/models/User.ts b/src/models/User.ts index 56ee8b9f..1ca8a3fc 100644 --- a/src/models/User.ts +++ b/src/models/User.ts @@ -77,7 +77,7 @@ export class User implements IUser { this.origin = u.origin_s; this.joinDate = u.join_ts_dt ? new Date(u.join_ts_dt) : undefined; this.credits = u.credits_i ? Number(u.credits_i) : 0; - this.creditsCustomerId = u.credits_customer_uid; + this.creditsCustomerId = u.stripe_customer_id_s; this.roles = u.roles_ss ?? []; let iamProviders = []; try { From 8f90afa33356452347e00613075303e6d624eb31 Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Thu, 14 May 2026 18:44:13 +0200 Subject: [PATCH 02/49] feat: stripe --- src/components/checkout/StripeCheckout.tsx | 391 +++++++++++++++++---- src/hooks/useCache.ts | 94 ++++- 2 files changed, 415 insertions(+), 70 deletions(-) diff --git a/src/components/checkout/StripeCheckout.tsx b/src/components/checkout/StripeCheckout.tsx index 2afa540b..e647dc07 100644 --- a/src/components/checkout/StripeCheckout.tsx +++ b/src/components/checkout/StripeCheckout.tsx @@ -58,6 +58,10 @@ export interface IPrice { * Computational credits to receive */ credits: number; + /** + * Whether this price is the server-selected default option + */ + default?: boolean; } export interface ISubscriptionPlan { @@ -69,11 +73,23 @@ export interface ISubscriptionPlan { included_runs?: number; } +type TopUpConfirmation = { + purchasedCredits: number; + oldWalletBalance: number; + newWalletBalance: number; + oldAvailableCredits: number; + newAvailableCredits: number; +}; + export type StripeCheckoutProps = { checkoutPortal: ICheckoutPortal | null; appearance?: StripeElementsOptions['appearance']; accountUid?: string; showStatusUsageSummary?: boolean; + onCheckoutSuccess?: (event: { + checkoutType: 'topup' | 'subscription' | 'resume'; + purchasedCredits?: number; + }) => void; }; const PLAN_INCLUDED_RUNS_DEFAULTS: Record = { @@ -312,6 +328,7 @@ export function StripeCheckout({ appearance, accountUid, showStatusUsageSummary = false, + onCheckoutSuccess, }: StripeCheckoutProps) { const { useCreateTopUpPaymentIntent, @@ -334,11 +351,37 @@ export function StripeCheckout({ 'topup' | 'subscription' | 'resume' >('topup'); const [cancelViewOpen, setCancelViewOpen] = useState(false); + const [isConfirmingCancel, setIsConfirmingCancel] = useState(false); + const [isResumingTransition, setIsResumingTransition] = useState(false); const [paymentMessage, setPaymentMessage] = useState(null); + const [resumeConfirmationMessage, setResumeConfirmationMessage] = useState< + string | null + >(null); + const [isReturningFromCheckout, setIsReturningFromCheckout] = useState(false); + const [topUpConfirmation, setTopUpConfirmation] = + useState(null); + const [pendingTopUpTarget, setPendingTopUpTarget] = useState<{ + targetWalletBalance: number; + } | null>(null); + const topUpPurchaseRef = useRef<{ + purchasedCredits: number; + oldWalletBalance: number; + oldAvailableCredits: number; + } | null>(null); // Get Stripe prices using TanStack Query hook - const { data: pricesData } = useTopUpPrices(); - const items = (pricesData as IPrice[] | undefined) ?? null; + const { + data: pricesData, + isPending: isTopUpPricesPending, + isError: isTopUpPricesError, + error: topUpPricesError, + } = useTopUpPrices(); + const items = useMemo(() => { + if (Array.isArray(pricesData)) { + return pricesData as IPrice[]; + } + return []; + }, [pricesData]); const sortedTopUpItems = useMemo( () => [...(items ?? [])].sort( @@ -405,12 +448,14 @@ export function StripeCheckout({ setProduct(null); setSubscriptionPlan(null); setPaymentMessage(null); + setIsReturningFromCheckout(true); if (checkoutType === 'resume') { try { const resp = await resumeSubscriptionMutation.mutateAsync(); setPaymentMessage( resp?.message || 'Payment confirmed and plan resumed successfully.', ); + onCheckoutSuccess?.({ checkoutType: 'resume' }); } catch (error) { setPaymentMessage( error instanceof Error @@ -418,6 +463,7 @@ export function StripeCheckout({ : 'Payment confirmed, but unable to resume your plan right now.', ); } + setIsReturningFromCheckout(false); return; } if (checkoutType === 'subscription') { @@ -436,12 +482,53 @@ export function StripeCheckout({ setPaymentMessage( 'Plan payment confirmed. Your plan status may take a few seconds to refresh.', ); + onCheckoutSuccess?.({ checkoutType: 'subscription' }); } else { + const topUpPurchase = topUpPurchaseRef.current; + const purchasedCredits = topUpPurchase?.purchasedCredits || 0; + if (topUpPurchase && topUpPurchase.purchasedCredits > 0) { + const targetWalletBalance = + topUpPurchase.oldWalletBalance + topUpPurchase.purchasedCredits; + setTopUpConfirmation({ + purchasedCredits: topUpPurchase.purchasedCredits, + oldWalletBalance: topUpPurchase.oldWalletBalance, + newWalletBalance: targetWalletBalance, + oldAvailableCredits: topUpPurchase.oldAvailableCredits, + newAvailableCredits: + topUpPurchase.oldAvailableCredits + topUpPurchase.purchasedCredits, + }); + setPendingTopUpTarget({ + targetWalletBalance, + }); + } + + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + await refetchSubscriptionStatus(); + } catch { + // Keep confirmation visible even if refresh fails transiently. + } + if (attempt < 4) { + await new Promise(resolve => setTimeout(resolve, 800)); + } + } + setPaymentMessage( 'Payment confirmed. Credits update may take a few seconds.', ); + onCheckoutSuccess?.({ + checkoutType: 'topup', + purchasedCredits, + }); + topUpPurchaseRef.current = null; } - }, [checkoutType, refetchSubscriptionStatus, resumeSubscriptionMutation]); + setIsReturningFromCheckout(false); + }, [ + checkoutType, + onCheckoutSuccess, + refetchSubscriptionStatus, + resumeSubscriptionMutation, + ]); const subscription = subscriptionResp?.plan || null; const availablePlans = useMemo(() => { @@ -636,6 +723,12 @@ export function StripeCheckout({ const walletBalance = walletIsQuota ? Math.max(0, remainingCredits) : Math.max(0, walletBalanceRaw); + const displayedWalletBalance = pendingTopUpTarget + ? Math.max(walletBalance, pendingTopUpTarget.targetWalletBalance) + : walletBalance; + const displayedAvailableCredits = pendingTopUpTarget + ? Math.max(remainingCredits, pendingTopUpTarget.targetWalletBalance) + : remainingCredits; const isRunsOverQuota = runsTotal > 0 && usedRuns > runsTotal; const hasBillablePlan = useMemo(() => { @@ -684,6 +777,18 @@ export function StripeCheckout({ return !nonCancelable; }, [hasBillablePlan, subscriptionStatus, isCancellationScheduled]); + const isCancelActionPending = + cancelSubscriptionMutation.isPending || isConfirmingCancel; + const isResumeActionPending = + resumeSubscriptionMutation.isPending || isResumingTransition; + const showResumeAction = isCancellationScheduled && !isCancelActionPending; + + useEffect(() => { + if (isResumingTransition && !isCancellationScheduled) { + setIsResumingTransition(false); + } + }, [isCancellationScheduled, isResumingTransition]); + useEffect(() => { if (isPaidSubscription && paymentMessage) { setPaymentMessage(null); @@ -698,10 +803,21 @@ export function StripeCheckout({ useEffect(() => { if (!product && sortedTopUpItems.length > 0) { - setProduct(sortedTopUpItems[sortedTopUpItems.length - 1]); + const secondCard = + sortedTopUpItems.length > 1 ? sortedTopUpItems[1] : sortedTopUpItems[0]; + setProduct(secondCard); } }, [product, sortedTopUpItems]); + useEffect(() => { + if (!pendingTopUpTarget) { + return; + } + if (walletBalance >= pendingTopUpTarget.targetWalletBalance) { + setPendingTopUpTarget(null); + } + }, [pendingTopUpTarget, walletBalance]); + // Auto-open the in-app cancel/downgrade view when the page is opened with // `?action=downgrade` (e.g. from the Plan Overview "Downgrade" CTA). // When opened with `?action=resume`, immediately trigger the resume flow. @@ -732,6 +848,12 @@ export function StripeCheckout({ if (!product) { return; } + topUpPurchaseRef.current = { + purchasedCredits: Math.max(0, Number(product.credits || 0)), + oldWalletBalance: displayedWalletBalance, + oldAvailableCredits: displayedAvailableCredits, + }; + setTopUpConfirmation(null); setPaymentMessage(null); setCheckoutType('topup'); setCheckout(true); @@ -753,11 +875,17 @@ export function StripeCheckout({ error instanceof Error ? error.message : 'Unable to initialize Stripe checkout. Please try again.'; + topUpPurchaseRef.current = null; setPaymentClientSecret(null); setCheckout(false); setPaymentMessage(detail); } - }, [topUpPaymentIntentMutation, product]); + }, [ + displayedAvailableCredits, + displayedWalletBalance, + topUpPaymentIntentMutation, + product, + ]); const startSubscriptionCheckout = useCallback( async (planOverride?: ISubscriptionPlan | null) => { @@ -829,6 +957,7 @@ export function StripeCheckout({ const onCancelSubscription = useCallback(() => { setPaymentMessage(null); + setResumeConfirmationMessage(null); setCancelViewOpen(true); }, []); @@ -838,6 +967,7 @@ export function StripeCheckout({ const onConfirmCancelSubscription = useCallback(async () => { setPaymentMessage(null); + setIsConfirmingCancel(true); try { const resp = await cancelSubscriptionMutation.mutateAsync(); if (resp?.success === false) { @@ -846,19 +976,6 @@ export function StripeCheckout({ ); } - // Refresh plan status so stale "incomplete" snapshots disappear - // as soon as cancellation is applied upstream. - for (let attempt = 0; attempt < 5; attempt += 1) { - try { - await refetchSubscriptionStatus(); - } catch { - // Ignore transient refetch errors and keep trying. - } - if (attempt < 4) { - await new Promise(resolve => setTimeout(resolve, 800)); - } - } - const responseStatus = String(resp?.status || '').toLowerCase(); const responseCancelAtPeriodEnd = Boolean(resp?.cancel_at_period_end); const isNowCanceled = @@ -876,7 +993,23 @@ export function StripeCheckout({ 'Plan change requested successfully.', ); setCancelViewOpen(false); + setIsConfirmingCancel(false); + + // Refresh plan status in the background so UI feedback is immediate. + void (async () => { + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + await refetchSubscriptionStatus(); + } catch { + // Ignore transient refetch errors and keep trying. + } + if (attempt < 4) { + await new Promise(resolve => setTimeout(resolve, 800)); + } + } + })(); } catch (error) { + setIsConfirmingCancel(false); setPaymentMessage( error instanceof Error ? error.message @@ -891,6 +1024,8 @@ export function StripeCheckout({ const onResumeSubscription = useCallback(async () => { setPaymentMessage(null); + setResumeConfirmationMessage(null); + setIsResumingTransition(true); try { const resp = await resumeSubscriptionMutation.mutateAsync(); if (resp?.success === false) { @@ -899,28 +1034,44 @@ export function StripeCheckout({ ); } - for (let attempt = 0; attempt < 5; attempt += 1) { - try { - await refetchSubscriptionStatus(); - } catch { - // Ignore transient refetch errors and keep trying. - } - if (attempt < 4) { - await new Promise(resolve => setTimeout(resolve, 800)); - } - } - setCheckout(false); setPaymentClientSecret(null); - setPaymentMessage(resp?.message || 'Plan resumed successfully.'); + setPaymentMessage(null); + const periodEndText = + subscriptionPeriodEndLabel && subscriptionPeriodEndLabel !== 'N/A' + ? ` through ${subscriptionPeriodEndLabel}` + : ''; + setResumeConfirmationMessage( + `Resume complete. Your plan remains active${periodEndText} and will renew automatically after that date.`, + ); + setIsResumingTransition(false); + + // Refresh plan status in the background so success feedback appears fast. + void (async () => { + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + await refetchSubscriptionStatus(); + } catch { + // Ignore transient refetch errors and keep trying. + } + if (attempt < 4) { + await new Promise(resolve => setTimeout(resolve, 800)); + } + } + })(); } catch (error) { + setIsResumingTransition(false); setPaymentMessage( error instanceof Error ? error.message : 'Unable to resume your plan right now.', ); } - }, [refetchSubscriptionStatus, resumeSubscriptionMutation]); + }, [ + refetchSubscriptionStatus, + resumeSubscriptionMutation, + subscriptionPeriodEndLabel, + ]); const onRefreshSubscriptionStatus = useCallback(async () => { setPaymentMessage(null); @@ -976,10 +1127,37 @@ export function StripeCheckout({ Choose a monthly plan {isIncompleteSubscription ? ( - - A pending plan change already exists. Complete payment or cancel it - from the billing portal before creating a new one. - + <> + + A pending plan change already exists. Complete payment or cancel it + from the billing portal before creating a new one. + + + + + + ) : !isPaidSubscription ? ( <> + {topUpConfirmation ? ( + + + Top-up confirmed: + + {topUpConfirmation.purchasedCredits.toLocaleString()} credits + + + {`Wallet balance: ${topUpConfirmation.oldWalletBalance.toLocaleString()} to ${topUpConfirmation.newWalletBalance.toLocaleString()}`} + + + {`Available credits: ${topUpConfirmation.oldAvailableCredits.toLocaleString()} to ${topUpConfirmation.newAvailableCredits.toLocaleString()}`} + + + ) : null} ); @@ -1337,7 +1529,7 @@ export function StripeCheckout({ as="p" sx={{ marginBottom: 'var(--stack-gap-condensed)' }} > - Wallet balance: {walletBalance.toLocaleString()} + Wallet balance: {displayedWalletBalance.toLocaleString()} Spent credits in current period:{' '} @@ -1409,15 +1601,16 @@ export function StripeCheckout({ )} - {isCancellationScheduled && ( + {showResumeAction && ( )} @@ -1467,12 +1660,17 @@ export function StripeCheckout({ )} @@ -1604,16 +1805,19 @@ export function StripeCheckout({ @@ -1749,7 +1953,47 @@ export function StripeCheckout({ ); } - } else if (items) { + } else if (isReturningFromCheckout) { + view = ( + + + + Refreshing plan status… + + {disabledTopCards} + + ); + } else if (isTopUpPricesPending) { + view = ( + + + + ); + } else if (isTopUpPricesError) { + view = ( + + {topCards} + + {topUpPricesError instanceof Error + ? topUpPricesError.message + : 'Unable to fetch the available products. Please try again later.'} + + + ); + } else { view = items.length ? ( ) : null} - {paymentMessage && ( + {resumeConfirmationMessage && ( + + {resumeConfirmationMessage} + + )} + {paymentMessage && !resumeConfirmationMessage && ( {paymentMessage} @@ -1821,10 +2070,18 @@ export function StripeCheckout({ ) : ( + {resumeConfirmationMessage && ( + + {resumeConfirmationMessage} + + )} + {paymentMessage && !resumeConfirmationMessage && ( + + {paymentMessage} + + )} {topCards} - - Unable to fetch the available products. Please try again later. - + No products are available yet. ); } diff --git a/src/hooks/useCache.ts b/src/hooks/useCache.ts index 0e966bf3..f384f6e2 100644 --- a/src/hooks/useCache.ts +++ b/src/hooks/useCache.ts @@ -5448,13 +5448,68 @@ export const useCache = ({ loginRoute = '/login' }: CacheProps = {}) => { options?: Omit, 'queryKey' | 'queryFn'>, ) => { return useQuery({ - queryKey: ['stripe', 'topup', 'prices'], + queryKey: ['stripe', 'plans', 'prices'], queryFn: async () => { + const normalizeTopUpPrices = (raw: unknown[]): unknown[] => { + const prices = raw.map((item: any) => ({ + ...item, + default: item?.default === true, + })); + + const explicitDefaultIndex = prices.findIndex( + item => item.default === true, + ); + if (explicitDefaultIndex >= 0) { + return prices.map((item, index) => ({ + ...item, + default: index === explicitDefaultIndex, + })); + } + + if (prices.length === 0) { + return prices; + } + + let fallbackDefaultIndex = 0; + let fallbackDefaultAmount = Number(prices[0]?.amount || 0); + for (let index = 1; index < prices.length; index += 1) { + const amount = Number(prices[index]?.amount || 0); + if (amount > fallbackDefaultAmount) { + fallbackDefaultAmount = amount; + fallbackDefaultIndex = index; + } + } + + return prices.map((item, index) => ({ + ...item, + default: index === fallbackDefaultIndex, + })); + }; + const resp = await requestDatalayer({ - url: `${configuration.iamRunUrl}/api/iam/stripe/v1/topup/prices`, + url: `${configuration.iamRunUrl}/api/iam/stripe/v1/plans/prices`, method: 'GET', }); - return resp.prices || []; + + if (resp?.success === false) { + throw new Error( + resp?.message || 'Unable to fetch available top-up products.', + ); + } + + if (Array.isArray(resp?.prices)) { + return normalizeTopUpPrices(resp.prices); + } + + if (Array.isArray(resp)) { + return normalizeTopUpPrices(resp); + } + + if (resp && Object.keys(resp).length === 0) { + return []; + } + + throw new Error('Unable to fetch available top-up products.'); }, ...options, }); @@ -5590,6 +5645,38 @@ export const useCache = ({ loginRoute = '/login' }: CacheProps = {}) => { }); }; + /** + * Get subscription details + eligibility for a batch of account UIDs. + */ + const useSubscriptionAccountsDetails = ( + accountUids: string[], + options?: Omit, 'queryKey' | 'queryFn' | 'enabled'>, + ) => { + const normalizedAccountUids = Array.from( + new Set( + (accountUids || []) + .map(uid => String(uid || '').trim()) + .filter(Boolean), + ), + ); + + return useQuery({ + queryKey: ['subscription', 'accounts-details', normalizedAccountUids], + queryFn: async () => { + const resp = await requestDatalayer({ + url: `${configuration.iamRunUrl}/api/iam/v1/plans/accounts/details`, + method: 'POST', + body: { + account_uids: normalizedAccountUids, + }, + }); + return resp.accounts || []; + }, + enabled: normalizedAccountUids.length > 0, + ...options, + }); + }; + /** * Request cancellation portal for the current subscription. */ @@ -8572,6 +8659,7 @@ export const useCache = ({ loginRoute = '/login' }: CacheProps = {}) => { useSubscriptionStatus, useSubscriptionPlans, useEligibleSubscriptionAccounts, + useSubscriptionAccountsDetails, useCancelSubscription, useResumeSubscription, useUserSubscription, From 90e2d537617a87695f87353838d3ba0e54ecd71c Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Fri, 15 May 2026 18:35:33 +0200 Subject: [PATCH 03/49] growth kpi --- src/hooks/useCache.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/hooks/useCache.ts b/src/hooks/useCache.ts index f384f6e2..eed6393b 100644 --- a/src/hooks/useCache.ts +++ b/src/hooks/useCache.ts @@ -7223,6 +7223,25 @@ export const useCache = ({ loginRoute = '/login' }: CacheProps = {}) => { }); }; + /** + * Get growth contacts KPIs. + */ + const useGrowthContactsKPI = ( + options?: Omit, 'queryKey' | 'queryFn'>, + ) => { + return useQuery({ + queryKey: ['growth', 'contacts-kpi'], + queryFn: async () => { + const resp = await requestDatalayer({ + url: `${configuration.growthRunUrl}/api/growth/v1/kpis/contacts`, + method: 'GET', + }); + return resp; + }, + ...options, + }); + }; + // ============================================================================ // Refresh Operations & Additional Methods // ============================================================================ @@ -8672,6 +8691,7 @@ export const useCache = ({ loginRoute = '/login' }: CacheProps = {}) => { useRequestPlatformSupport2, useUserSurveys, useGrowthKPI, + useGrowthContactsKPI, // Query keys for manual operations queryKeys, From bf11e70180eb89b0ec1b2d86f82eb61a5f5bfddf Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Sat, 16 May 2026 10:54:13 +0200 Subject: [PATCH 04/49] sandbox --- datalayer_core/cli/__main__.py | 4 ++-- .../{runtime_snapshots.py => sandbox_snapshots.py} | 4 ++-- datalayer_core/client/client.py | 6 +++--- .../{runtime_snapshots.py => sandbox_snapshots.py} | 0 datalayer_core/mixins/__init__.py | 2 +- .../{runtime_snapshots.py => sandbox_snapshots.py} | 6 +++--- datalayer_core/models/__init__.py | 2 +- .../{runtime_snapshot.py => sandbox_snapshot.py} | 0 datalayer_core/runtimes/runtime.py | 4 ++-- .../{runtime_snapshot.py => sandbox_snapshot.py} | 2 +- datalayer_core/tests/test_client.py | 2 +- pyproject.toml | 2 +- src/api/runtimes/snapshots.ts | 8 ++++---- src/components/snapshots/RuntimeSnapshotMenu.tsx | 4 ++-- src/stateful/runtimes/actions.ts | 14 +++++++------- 15 files changed, 30 insertions(+), 30 deletions(-) rename datalayer_core/cli/commands/{runtime_snapshots.py => sandbox_snapshots.py} (98%) rename datalayer_core/displays/{runtime_snapshots.py => sandbox_snapshots.py} (100%) rename datalayer_core/mixins/{runtime_snapshots.py => sandbox_snapshots.py} (94%) rename datalayer_core/models/{runtime_snapshot.py => sandbox_snapshot.py} (100%) rename datalayer_core/runtimes/{runtime_snapshot.py => sandbox_snapshot.py} (96%) diff --git a/datalayer_core/cli/__main__.py b/datalayer_core/cli/__main__.py index a14b4807..4ae319f0 100644 --- a/datalayer_core/cli/__main__.py +++ b/datalayer_core/cli/__main__.py @@ -27,8 +27,8 @@ checkpoints_list, checkpoints_ls, ) -from datalayer_core.cli.commands.runtime_snapshots import app as snapshots_app -from datalayer_core.cli.commands.runtime_snapshots import snapshots_list, snapshots_ls +from datalayer_core.cli.commands.sandbox_snapshots import app as snapshots_app +from datalayer_core.cli.commands.sandbox_snapshots import snapshots_list, snapshots_ls from datalayer_core.cli.commands.runtimes import app as runtimes_app from datalayer_core.cli.commands.runtimes import runtimes_list, runtimes_ls from datalayer_core.cli.commands.secrets import app as secrets_app diff --git a/datalayer_core/cli/commands/runtime_snapshots.py b/datalayer_core/cli/commands/sandbox_snapshots.py similarity index 98% rename from datalayer_core/cli/commands/runtime_snapshots.py rename to datalayer_core/cli/commands/sandbox_snapshots.py index 0b63bbf1..4ab7f6cf 100644 --- a/datalayer_core/cli/commands/runtime_snapshots.py +++ b/datalayer_core/cli/commands/sandbox_snapshots.py @@ -9,11 +9,11 @@ from rich.console import Console from datalayer_core.client.client import DatalayerClient -from datalayer_core.displays.runtime_snapshots import display_runtime_snapshots +from datalayer_core.displays.sandbox_snapshots import display_runtime_snapshots # Create a Typer app for snapshot commands app = typer.Typer( - name="runtime-snapshots", + name="sandbox-snapshots", help="Runtime snapshots management commands", invoke_without_command=True, ) diff --git a/datalayer_core/client/client.py b/datalayer_core/client/client.py index a1f59033..c8277c07 100644 --- a/datalayer_core/client/client.py +++ b/datalayer_core/client/client.py @@ -17,7 +17,7 @@ from datalayer_core.mixins.authn import AuthnMixin from datalayer_core.mixins.environments import EnvironmentsMixin from datalayer_core.mixins.events import EventsMixin -from datalayer_core.mixins.runtime_snapshots import RuntimeSnapshotsMixin +from datalayer_core.mixins.sandbox_snapshots import RuntimeSnapshotsMixin from datalayer_core.mixins.runtimes import RuntimesMixin from datalayer_core.mixins.secrets import SecretsMixin from datalayer_core.mixins.tokens import TokensMixin @@ -25,11 +25,11 @@ from datalayer_core.mixins.whoami import WhoamiAppMixin from datalayer_core.models import UserModel from datalayer_core.models.environment import EnvironmentModel -from datalayer_core.models.runtime_snapshot import RuntimeSnapshotModel +from datalayer_core.models.sandbox_snapshot import RuntimeSnapshotModel from datalayer_core.models.secret import SecretModel, SecretVariant from datalayer_core.models.token import TokenModel, TokenType from datalayer_core.runtimes.runtime import RuntimeService -from datalayer_core.runtimes.runtime_snapshot import ( +from datalayer_core.runtimes.sandbox_snapshot import ( as_runtime_snapshots, create_snapshot, ) diff --git a/datalayer_core/displays/runtime_snapshots.py b/datalayer_core/displays/sandbox_snapshots.py similarity index 100% rename from datalayer_core/displays/runtime_snapshots.py rename to datalayer_core/displays/sandbox_snapshots.py diff --git a/datalayer_core/mixins/__init__.py b/datalayer_core/mixins/__init__.py index cdd27246..17d81e01 100644 --- a/datalayer_core/mixins/__init__.py +++ b/datalayer_core/mixins/__init__.py @@ -2,7 +2,7 @@ # Distributed under the terms of the Modified BSD License. from .authn import AuthnMixin from .environments import EnvironmentsMixin -from .runtime_snapshots import RuntimeSnapshotsMixin +from .sandbox_snapshots import RuntimeSnapshotsMixin from .runtimes import RuntimesMixin from .secrets import SecretsMixin from .tokens import TokensMixin diff --git a/datalayer_core/mixins/runtime_snapshots.py b/datalayer_core/mixins/sandbox_snapshots.py similarity index 94% rename from datalayer_core/mixins/runtime_snapshots.py rename to datalayer_core/mixins/sandbox_snapshots.py index 20881caf..a84b829b 100644 --- a/datalayer_core/mixins/runtime_snapshots.py +++ b/datalayer_core/mixins/sandbox_snapshots.py @@ -37,7 +37,7 @@ def _create_snapshot( } try: response = self._fetch( # type: ignore - "{}/api/runtimes/v1/runtime-snapshots".format(self.urls.runtimes_url), # type: ignore + "{}/api/runtimes/v1/sandbox-snapshots".format(self.urls.runtimes_url), # type: ignore method="POST", json=body, ) @@ -67,7 +67,7 @@ def _delete_snapshot(self, snapshot_uid: str) -> dict[str, Any]: """ try: response = self._fetch( # type: ignore - "{}/api/runtimes/v1/runtime-snapshots/{}".format( + "{}/api/runtimes/v1/sandbox-snapshots/{}".format( self.urls.runtimes_url, # type: ignore snapshot_uid, ), @@ -97,7 +97,7 @@ def _list_snapshots(self) -> dict[str, Any]: """ try: response = self._fetch( # type: ignore - "{}/api/runtimes/v1/runtime-snapshots".format(self.urls.runtimes_url), # type: ignore + "{}/api/runtimes/v1/sandbox-snapshots".format(self.urls.runtimes_url), # type: ignore ) return response.json() except RuntimeError as e: diff --git a/datalayer_core/models/__init__.py b/datalayer_core/models/__init__.py index 74e3d0cc..697895c0 100644 --- a/datalayer_core/models/__init__.py +++ b/datalayer_core/models/__init__.py @@ -81,7 +81,7 @@ UserSettingsModel, ) from .runtime import RuntimeModel -from .runtime_snapshot import RuntimeSnapshotModel +from .sandbox_snapshot import RuntimeSnapshotModel from .secret import SecretModel, SecretVariant from .token import TokenModel, TokenType diff --git a/datalayer_core/models/runtime_snapshot.py b/datalayer_core/models/sandbox_snapshot.py similarity index 100% rename from datalayer_core/models/runtime_snapshot.py rename to datalayer_core/models/sandbox_snapshot.py diff --git a/datalayer_core/runtimes/runtime.py b/datalayer_core/runtimes/runtime.py index e17fcab6..334565e1 100644 --- a/datalayer_core/runtimes/runtime.py +++ b/datalayer_core/runtimes/runtime.py @@ -16,11 +16,11 @@ from jupyter_kernel_client import KernelClient from datalayer_core.mixins.authn import AuthnMixin -from datalayer_core.mixins.runtime_snapshots import RuntimeSnapshotsMixin +from datalayer_core.mixins.sandbox_snapshots import RuntimeSnapshotsMixin from datalayer_core.mixins.runtimes import RuntimesMixin from datalayer_core.models import ExecutionResponse from datalayer_core.models.runtime import RuntimeModel -from datalayer_core.runtimes.runtime_snapshot import ( +from datalayer_core.runtimes.sandbox_snapshot import ( RuntimeSnapshotModel, as_runtime_snapshots, create_snapshot, diff --git a/datalayer_core/runtimes/runtime_snapshot.py b/datalayer_core/runtimes/sandbox_snapshot.py similarity index 96% rename from datalayer_core/runtimes/runtime_snapshot.py rename to datalayer_core/runtimes/sandbox_snapshot.py index d2ea786c..cabd45c4 100644 --- a/datalayer_core/runtimes/runtime_snapshot.py +++ b/datalayer_core/runtimes/sandbox_snapshot.py @@ -10,7 +10,7 @@ import uuid from typing import Any, List, Optional, Tuple -from datalayer_core.models.runtime_snapshot import RuntimeSnapshotModel +from datalayer_core.models.sandbox_snapshot import RuntimeSnapshotModel def create_snapshot(name: Optional[str], description: Optional[str]) -> Tuple[str, str]: diff --git a/datalayer_core/tests/test_client.py b/datalayer_core/tests/test_client.py index 342e3ad6..4fbbaa86 100644 --- a/datalayer_core/tests/test_client.py +++ b/datalayer_core/tests/test_client.py @@ -11,7 +11,7 @@ from dotenv import load_dotenv from datalayer_core import DatalayerClient -from datalayer_core.models.runtime_snapshot import RuntimeSnapshotModel +from datalayer_core.models.sandbox_snapshot import RuntimeSnapshotModel load_dotenv() diff --git a/pyproject.toml b/pyproject.toml index 5434358a..b375249c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ dependencies = [ "jupyter-kernel-client", "jupyter-nbmodel-client", "jupyter-server>=2.10,<3", - "keyring==23.0.1", + "keyring", "mcp", "pydantic-settings", "pydantic[email]", diff --git a/src/api/runtimes/snapshots.ts b/src/api/runtimes/snapshots.ts index de62661b..f6f01a5f 100644 --- a/src/api/runtimes/snapshots.ts +++ b/src/api/runtimes/snapshots.ts @@ -37,7 +37,7 @@ export const createSnapshot = async ( validateToken(token); return requestDatalayerAPI({ - url: `${baseUrl}${API_BASE_PATHS.RUNTIMES}/runtime-snapshots`, + url: `${baseUrl}${API_BASE_PATHS.RUNTIMES}/sandbox-snapshots`, method: 'POST', token, body: data, @@ -58,7 +58,7 @@ export const listSnapshots = async ( validateToken(token); return requestDatalayerAPI({ - url: `${baseUrl}${API_BASE_PATHS.RUNTIMES}/runtime-snapshots`, + url: `${baseUrl}${API_BASE_PATHS.RUNTIMES}/sandbox-snapshots`, method: 'GET', token, }); @@ -82,7 +82,7 @@ export const getSnapshot = async ( validateRequiredString(snapshotId, 'Snapshot ID'); return requestDatalayerAPI({ - url: `${baseUrl}${API_BASE_PATHS.RUNTIMES}/runtime-snapshots/${snapshotId}`, + url: `${baseUrl}${API_BASE_PATHS.RUNTIMES}/sandbox-snapshots/${snapshotId}`, method: 'GET', token, }); @@ -106,7 +106,7 @@ export const deleteSnapshot = async ( validateRequiredString(snapshotId, 'Snapshot ID'); return requestDatalayerAPI({ - url: `${baseUrl}${API_BASE_PATHS.RUNTIMES}/runtime-snapshots/${snapshotId}`, + url: `${baseUrl}${API_BASE_PATHS.RUNTIMES}/sandbox-snapshots/${snapshotId}`, method: 'DELETE', token, }); diff --git a/src/components/snapshots/RuntimeSnapshotMenu.tsx b/src/components/snapshots/RuntimeSnapshotMenu.tsx index 088e2988..18fe82ed 100644 --- a/src/components/snapshots/RuntimeSnapshotMenu.tsx +++ b/src/components/snapshots/RuntimeSnapshotMenu.tsx @@ -9,7 +9,7 @@ import { useState, type PropsWithChildren, } from 'react'; -import { CameraIcon } from '@datalayer/icons-react'; +import { DeviceCameraIcon } from '@primer/octicons-react'; import { Kernel } from '@jupyterlab/services'; import { ActionList, @@ -194,7 +194,7 @@ export function RuntimeSnapshotMenu({ <> ({ url: URLExt.join( runtimesStore.getState().runtimesRunUrl, - 'api/runtimes/v1/runtime-snapshots', + 'api/runtimes/v1/sandbox-snapshots', ), method: 'POST', body: { @@ -208,7 +208,7 @@ export async function getRuntimeSnapshots(): Promise { }>({ url: URLExt.join( runtimesStore.getState().runtimesRunUrl, - 'api/runtimes/v1/runtime-snapshots', + 'api/runtimes/v1/sandbox-snapshots', ), token: iamStore.getState().token, }); @@ -263,7 +263,7 @@ export function createRuntimeSnapshotDownloadURL(id: string): string { return ( URLExt.join( runtimesStore.getState().runtimesRunUrl, - `api/runtimes/v1/runtime-snapshots/${id}`, + `api/runtimes/v1/sandbox-snapshots/${id}`, ) + URLExt.objectToQueryString({ download: '1', @@ -298,7 +298,7 @@ export async function deleteRuntimeSnapshot(id: string): Promise { }>({ url: URLExt.join( runtimesStore.getState().runtimesRunUrl, - `api/runtimes/v1/runtime-snapshots/${id}`, + `api/runtimes/v1/sandbox-snapshots/${id}`, ), method: 'DELETE', token: iamStore.getState().token, @@ -317,7 +317,7 @@ export async function deleteRuntimeSnapshot(id: string): Promise { }>({ url: URLExt.join( runtimesStore.getState().runtimesRunUrl, - `api/runtimes/v1/runtime-snapshots/${id}`, + `api/runtimes/v1/sandbox-snapshots/${id}`, ), token: iamStore.getState().token, }); @@ -352,7 +352,7 @@ export async function updateRuntimeSnapshot( }>({ url: URLExt.join( runtimesStore.getState().runtimesRunUrl, - `api/runtimes/v1/runtime-snapshots/${id}`, + `api/runtimes/v1/sandbox-snapshots/${id}`, ), method: 'PATCH', body: { ...metadata }, @@ -378,7 +378,7 @@ export async function uploadRuntimeSnapshot(options: { // Create a new tus upload. const upload = new Upload(options.file, { // Endpoint is the upload creation URL from your tus server. - endpoint: `${runtimesStore.getState().runtimesRunUrl}/api/runtimes/v1/runtime-snapshots/upload`, + endpoint: `${runtimesStore.getState().runtimesRunUrl}/api/runtimes/v1/sandbox-snapshots/upload`, headers: { Authorization: `Bearer ${iamStore.getState().token}` }, // Retry delays will enable tus-js-client to automatically retry on errors. // retryDelays: [0, 3000, 5000, 10000, 20000], From 299d2422a7c2035690ba57627152fcbf139e80ba Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Sat, 16 May 2026 12:56:43 +0200 Subject: [PATCH 05/49] snapshot --- .../cli/commands/sandbox_snapshots.py | 6 +- datalayer_core/client/client.py | 28 +++---- datalayer_core/displays/sandbox_snapshots.py | 10 +-- datalayer_core/mixins/__init__.py | 4 +- datalayer_core/mixins/sandbox_snapshots.py | 10 +-- datalayer_core/models/__init__.py | 4 +- datalayer_core/models/sandbox_snapshot.py | 4 +- datalayer_core/runtimes/runtime.py | 20 ++--- datalayer_core/runtimes/sandbox_snapshot.py | 12 +-- datalayer_core/tests/test_client.py | 6 +- src/api/runtimes/snapshots.ts | 24 +++--- .../client.models.integration.test.ts | 6 +- .../client.runtimes.integration.test.ts | 12 +-- src/client/index.ts | 26 +++--- src/client/mixins/RuntimesMixin.ts | 20 ++--- .../runtimes/RuntimeLauncherDialog.tsx | 4 +- ...apshotMenu.tsx => SandboxSnapshotMenu.tsx} | 80 +++++++++---------- src/components/snapshots/index.ts | 2 +- src/index.ts | 18 ++--- src/models/Page.ts | 6 +- src/models/RuntimeDTO.ts | 4 +- ...{RuntimeSnapshot.ts => SandboxSnapshot.ts} | 6 +- ...meSnapshotDTO.ts => SandboxSnapshotDTO.ts} | 42 +++++----- src/models/__tests__/RuntimeSnapshot.test.ts | 16 ++-- src/models/__tests__/Snapshot.test.ts | 16 ++-- src/models/index.ts | 4 +- src/state/substates/RuntimesState.ts | 16 ++-- src/stateful/runtimes/actions.ts | 38 ++++----- src/stateful/runtimes/apis.ts | 6 +- src/stateful/runtimes/snapshots.ts | 14 ++-- src/utils/Snapshot.ts | 2 +- 31 files changed, 233 insertions(+), 233 deletions(-) rename src/components/snapshots/{RuntimeSnapshotMenu.tsx => SandboxSnapshotMenu.tsx} (79%) rename src/models/{RuntimeSnapshot.ts => SandboxSnapshot.ts} (90%) rename src/models/{RuntimeSnapshotDTO.ts => SandboxSnapshotDTO.ts} (88%) diff --git a/datalayer_core/cli/commands/sandbox_snapshots.py b/datalayer_core/cli/commands/sandbox_snapshots.py index 4ab7f6cf..64c07318 100644 --- a/datalayer_core/cli/commands/sandbox_snapshots.py +++ b/datalayer_core/cli/commands/sandbox_snapshots.py @@ -9,7 +9,7 @@ from rich.console import Console from datalayer_core.client.client import DatalayerClient -from datalayer_core.displays.sandbox_snapshots import display_runtime_snapshots +from datalayer_core.displays.sandbox_snapshots import display_sandbox_snapshots # Create a Typer app for snapshot commands app = typer.Typer( @@ -54,7 +54,7 @@ def list_snapshots( } ) - display_runtime_snapshots(snapshot_dicts) + display_sandbox_snapshots(snapshot_dicts) except Exception as e: console.print(f"[red]Error listing snapshots: {e}[/red]") @@ -121,7 +121,7 @@ def create_snapshot( "metadata": snapshot.metadata, } - display_runtime_snapshots([snapshot_dict]) + display_sandbox_snapshots([snapshot_dict]) console.print( f"[green]Snapshot '{snapshot.name}' created successfully![/green]" ) diff --git a/datalayer_core/client/client.py b/datalayer_core/client/client.py index c8277c07..f5da684d 100644 --- a/datalayer_core/client/client.py +++ b/datalayer_core/client/client.py @@ -17,7 +17,7 @@ from datalayer_core.mixins.authn import AuthnMixin from datalayer_core.mixins.environments import EnvironmentsMixin from datalayer_core.mixins.events import EventsMixin -from datalayer_core.mixins.sandbox_snapshots import RuntimeSnapshotsMixin +from datalayer_core.mixins.sandbox_snapshots import SandboxSnapshotsMixin from datalayer_core.mixins.runtimes import RuntimesMixin from datalayer_core.mixins.secrets import SecretsMixin from datalayer_core.mixins.tokens import TokensMixin @@ -25,12 +25,12 @@ from datalayer_core.mixins.whoami import WhoamiAppMixin from datalayer_core.models import UserModel from datalayer_core.models.environment import EnvironmentModel -from datalayer_core.models.sandbox_snapshot import RuntimeSnapshotModel +from datalayer_core.models.sandbox_snapshot import SandboxSnapshotModel from datalayer_core.models.secret import SecretModel, SecretVariant from datalayer_core.models.token import TokenModel, TokenType from datalayer_core.runtimes.runtime import RuntimeService from datalayer_core.runtimes.sandbox_snapshot import ( - as_runtime_snapshots, + as_sandbox_snapshots, create_snapshot, ) from datalayer_core.utils.defaults import ( @@ -49,7 +49,7 @@ class DatalayerClient( EnvironmentsMixin, EventsMixin, SecretsMixin, - RuntimeSnapshotsMixin, + SandboxSnapshotsMixin, TokensMixin, UsageMixin, WhoamiAppMixin, @@ -511,7 +511,7 @@ def create_snapshot( name: Optional[str] = None, description: Optional[str] = None, stop: bool = True, - ) -> "RuntimeSnapshotModel": + ) -> "SandboxSnapshotModel": """ Create a snapshot of the current runtime state. @@ -530,7 +530,7 @@ def create_snapshot( Returns ------- - RuntimeSnapshotModel + SandboxSnapshotModel The created snapshot object. """ if pod_name is None and runtime is None: @@ -556,7 +556,7 @@ def create_snapshot( raise RuntimeError( f"Failed to create snapshot '{name}': {response.get('message', 'unknown error')}" ) - snapshot: Optional[RuntimeSnapshotModel] = None + snapshot: Optional[SandboxSnapshotModel] = None max_poll_attempts = max( 1, int(os.getenv("DATALAYER_SNAPSHOT_POLL_ATTEMPTS", "30")), @@ -577,7 +577,7 @@ def create_snapshot( f"Snapshot '{name}' was created but not found in snapshot listing" ) - return RuntimeSnapshotModel( + return SandboxSnapshotModel( uid=snapshot.uid, name=name, description=description, @@ -585,28 +585,28 @@ def create_snapshot( metadata=response, ) - def list_snapshots(self) -> list[RuntimeSnapshotModel]: + def list_snapshots(self) -> list[SandboxSnapshotModel]: """ List all snapshots. Returns ------- - list[RuntimeSnapshotModel] + list[SandboxSnapshotModel] A list of snapshots associated with the user. """ response = self._list_snapshots() - snapshot_objects = as_runtime_snapshots(response) + snapshot_objects = as_sandbox_snapshots(response) return snapshot_objects def delete_snapshot( - self, snapshot: Union[str, RuntimeSnapshotModel] + self, snapshot: Union[str, SandboxSnapshotModel] ) -> dict[str, str]: """ Delete a specific snapshot. Parameters ---------- - snapshot : Union[str, RuntimeSnapshotModel] + snapshot : Union[str, SandboxSnapshotModel] Snapshot object or UID string to delete. Returns @@ -615,7 +615,7 @@ def delete_snapshot( The result of the deletion operation. """ snapshot_uid = ( - snapshot.uid if isinstance(snapshot, RuntimeSnapshotModel) else snapshot + snapshot.uid if isinstance(snapshot, SandboxSnapshotModel) else snapshot ) return self._delete_snapshot(snapshot_uid) diff --git a/datalayer_core/displays/sandbox_snapshots.py b/datalayer_core/displays/sandbox_snapshots.py index 5fd6c692..c8f3d6f6 100644 --- a/datalayer_core/displays/sandbox_snapshots.py +++ b/datalayer_core/displays/sandbox_snapshots.py @@ -11,7 +11,7 @@ from rich.table import Table -def _new_runtime_snapshots_table(title: str = "Snapshots") -> Table: +def _new_sandbox_snapshots_table(title: str = "Snapshots") -> Table: """ Create a new runtime snapshots table. @@ -33,7 +33,7 @@ def _new_runtime_snapshots_table(title: str = "Snapshots") -> Table: return table -def _add_runtime_snapshot_to_table(table: Table, snapshot: dict[str, Any]) -> None: +def _add_sandbox_snapshot_to_table(table: Table, snapshot: dict[str, Any]) -> None: """ Add a runtime snapshot row to the table. @@ -52,7 +52,7 @@ def _add_runtime_snapshot_to_table(table: Table, snapshot: dict[str, Any]) -> No ) -def display_runtime_snapshots(snapshots: list[dict[str, Any]]) -> None: +def display_sandbox_snapshots(snapshots: list[dict[str, Any]]) -> None: """ Display a list of runtime snapshots in the console. @@ -61,8 +61,8 @@ def display_runtime_snapshots(snapshots: list[dict[str, Any]]) -> None: snapshots : list[dict[str, Any]] List of snapshot dictionaries to display. """ - table = _new_runtime_snapshots_table(title="Runtime Snapshots") + table = _new_sandbox_snapshots_table(title="Runtime Snapshots") for snapshot in snapshots: - _add_runtime_snapshot_to_table(table, snapshot) + _add_sandbox_snapshot_to_table(table, snapshot) console = Console() console.print(table) diff --git a/datalayer_core/mixins/__init__.py b/datalayer_core/mixins/__init__.py index 17d81e01..8370f351 100644 --- a/datalayer_core/mixins/__init__.py +++ b/datalayer_core/mixins/__init__.py @@ -2,7 +2,7 @@ # Distributed under the terms of the Modified BSD License. from .authn import AuthnMixin from .environments import EnvironmentsMixin -from .sandbox_snapshots import RuntimeSnapshotsMixin +from .sandbox_snapshots import SandboxSnapshotsMixin from .runtimes import RuntimesMixin from .secrets import SecretsMixin from .tokens import TokensMixin @@ -12,7 +12,7 @@ __all__ = [ "AuthnMixin", "EnvironmentsMixin", - "RuntimeSnapshotsMixin", + "SandboxSnapshotsMixin", "RuntimesMixin", "SecretsMixin", "TokensMixin", diff --git a/datalayer_core/mixins/sandbox_snapshots.py b/datalayer_core/mixins/sandbox_snapshots.py index a84b829b..0c082d6c 100644 --- a/datalayer_core/mixins/sandbox_snapshots.py +++ b/datalayer_core/mixins/sandbox_snapshots.py @@ -4,7 +4,7 @@ from typing import Any -class RuntimeSnapshotsCreateMixin: +class SandboxSnapshotsCreateMixin: """Mixin class for creating snapshots.""" def _create_snapshot( @@ -46,7 +46,7 @@ def _create_snapshot( return {"success": False, "message": str(e)} -class RuntimeSnapshotsDeleteMixin: +class SandboxSnapshotsDeleteMixin: """ Mixin class that provides snapshot deletion functionality. """ @@ -81,7 +81,7 @@ def _delete_snapshot(self, snapshot_uid: str) -> dict[str, Any]: return {"success": False, "message": str(e)} -class RuntimeSnapshotsListMixin: +class SandboxSnapshotsListMixin: """ Mixin class to provide functionality for listing snapshots. """ @@ -104,8 +104,8 @@ def _list_snapshots(self) -> dict[str, Any]: return {"success": False, "message": str(e)} -class RuntimeSnapshotsMixin( - RuntimeSnapshotsCreateMixin, RuntimeSnapshotsDeleteMixin, RuntimeSnapshotsListMixin +class SandboxSnapshotsMixin( + SandboxSnapshotsCreateMixin, SandboxSnapshotsDeleteMixin, SandboxSnapshotsListMixin ): """ Mixin class that provides snapshot management functionality. diff --git a/datalayer_core/models/__init__.py b/datalayer_core/models/__init__.py index 697895c0..c128d2c2 100644 --- a/datalayer_core/models/__init__.py +++ b/datalayer_core/models/__init__.py @@ -81,7 +81,7 @@ UserSettingsModel, ) from .runtime import RuntimeModel -from .sandbox_snapshot import RuntimeSnapshotModel +from .sandbox_snapshot import SandboxSnapshotModel from .secret import SecretModel, SecretVariant from .token import TokenModel, TokenType @@ -137,7 +137,7 @@ "ResourceRequirements", "Response", "RuntimeModel", - "RuntimeSnapshotModel", + "SandboxSnapshotModel", "SecretModel", "SecretModel", "SecretVariant", diff --git a/datalayer_core/models/sandbox_snapshot.py b/datalayer_core/models/sandbox_snapshot.py index 775eb4ff..8cbba9d5 100644 --- a/datalayer_core/models/sandbox_snapshot.py +++ b/datalayer_core/models/sandbox_snapshot.py @@ -12,7 +12,7 @@ from pydantic import BaseModel, Field -class RuntimeSnapshotModel(BaseModel): +class SandboxSnapshotModel(BaseModel): """ Pydantic model representing a snapshot of a Datalayer runtime state. @@ -32,6 +32,6 @@ class RuntimeSnapshotModel(BaseModel): def __repr__(self) -> str: return ( - f"RuntimeSnapshotModel(uid='{self.uid}', name='{self.name}', " + f"SandboxSnapshotModel(uid='{self.uid}', name='{self.name}', " f"description='{self.description}', environment='{self.environment}')" ) diff --git a/datalayer_core/runtimes/runtime.py b/datalayer_core/runtimes/runtime.py index 334565e1..5f8ac15e 100644 --- a/datalayer_core/runtimes/runtime.py +++ b/datalayer_core/runtimes/runtime.py @@ -16,13 +16,13 @@ from jupyter_kernel_client import KernelClient from datalayer_core.mixins.authn import AuthnMixin -from datalayer_core.mixins.sandbox_snapshots import RuntimeSnapshotsMixin +from datalayer_core.mixins.sandbox_snapshots import SandboxSnapshotsMixin from datalayer_core.mixins.runtimes import RuntimesMixin from datalayer_core.models import ExecutionResponse from datalayer_core.models.runtime import RuntimeModel from datalayer_core.runtimes.sandbox_snapshot import ( - RuntimeSnapshotModel, - as_runtime_snapshots, + SandboxSnapshotModel, + as_sandbox_snapshots, create_snapshot, ) from datalayer_core.utils.defaults import ( @@ -38,7 +38,7 @@ from datalayer_core.utils.urls import DEFAULT_DATALAYER_RUN_URL, DatalayerURLs -class RuntimeService(AuthnMixin, RuntimesMixin, RuntimeSnapshotsMixin): +class RuntimeService(AuthnMixin, RuntimesMixin, SandboxSnapshotsMixin): """ Service for managing Datalayer runtime operations. @@ -678,7 +678,7 @@ def create_snapshot( name: Optional[str] = None, description: Optional[str] = None, stop: bool = True, - ) -> "RuntimeSnapshotModel": + ) -> "SandboxSnapshotModel": """ Create a new snapshot from the current state. @@ -693,7 +693,7 @@ def create_snapshot( Returns ------- - RuntimeSnapshot + SandboxSnapshot A new snapshot object. """ if self.model.pod_name is None: @@ -720,8 +720,8 @@ def create_snapshot( pass response = self._list_snapshots() - snapshot_objects = as_runtime_snapshots(response) - snapshot: Optional[RuntimeSnapshotModel] = None + snapshot_objects = as_sandbox_snapshots(response) + snapshot: Optional[SandboxSnapshotModel] = None max_poll_attempts = max( 1, int(os.getenv("DATALAYER_SNAPSHOT_POLL_ATTEMPTS", "30")), @@ -736,14 +736,14 @@ def create_snapshot( break time.sleep(poll_interval_seconds) response = self._list_snapshots() - snapshot_objects = as_runtime_snapshots(response) + snapshot_objects = as_sandbox_snapshots(response) if snapshot is None: raise RuntimeError( f"Snapshot '{name}' was created but not found in snapshot listing" ) - return RuntimeSnapshotModel( + return SandboxSnapshotModel( uid=snapshot.uid, name=name, description=description, diff --git a/datalayer_core/runtimes/sandbox_snapshot.py b/datalayer_core/runtimes/sandbox_snapshot.py index cabd45c4..6d29fadf 100644 --- a/datalayer_core/runtimes/sandbox_snapshot.py +++ b/datalayer_core/runtimes/sandbox_snapshot.py @@ -10,7 +10,7 @@ import uuid from typing import Any, List, Optional, Tuple -from datalayer_core.models.sandbox_snapshot import RuntimeSnapshotModel +from datalayer_core.models.sandbox_snapshot import SandboxSnapshotModel def create_snapshot(name: Optional[str], description: Optional[str]) -> Tuple[str, str]: @@ -39,9 +39,9 @@ def create_snapshot(name: Optional[str], description: Optional[str]) -> Tuple[st return name, description -def as_runtime_snapshots(response: dict[str, Any]) -> List["RuntimeSnapshotModel"]: +def as_sandbox_snapshots(response: dict[str, Any]) -> List["SandboxSnapshotModel"]: """ - Parse API response and create RuntimeSnapshot objects. + Parse API response and create SandboxSnapshot objects. Parameters ---------- @@ -50,15 +50,15 @@ def as_runtime_snapshots(response: dict[str, Any]) -> List["RuntimeSnapshotModel Returns ------- - List[RuntimeSnapshot] - List of RuntimeSnapshot objects parsed from the response. + List[SandboxSnapshot] + List of SandboxSnapshot objects parsed from the response. """ snapshot_objects = [] if response["success"]: snapshots = response["snapshots"] for snapshot in snapshots: snapshot_objects.append( - RuntimeSnapshotModel( + SandboxSnapshotModel( uid=snapshot["uid"], name=snapshot["name"], description=snapshot["description"], diff --git a/datalayer_core/tests/test_client.py b/datalayer_core/tests/test_client.py index 4fbbaa86..a97d167f 100644 --- a/datalayer_core/tests/test_client.py +++ b/datalayer_core/tests/test_client.py @@ -11,7 +11,7 @@ from dotenv import load_dotenv from datalayer_core import DatalayerClient -from datalayer_core.models.sandbox_snapshot import RuntimeSnapshotModel +from datalayer_core.models.sandbox_snapshot import SandboxSnapshotModel load_dotenv() @@ -101,7 +101,7 @@ def test_runtime_create_execute_and_list() -> None: not bool(TEST_DATALAYER_API_KEY), reason="TEST_DATALAYER_API_KEY is not set, skipping secret tests.", ) -def test_runtime_snapshot_create_and_delete() -> None: +def test_sandbox_snapshot_create_and_delete() -> None: """ Test the creation and deletion of runtime. """ @@ -114,7 +114,7 @@ def test_runtime_snapshot_create_and_delete() -> None: def _delete_with_retry( client: DatalayerClient, - snap: RuntimeSnapshotModel, + snap: SandboxSnapshotModel, retries: int = 10, delay: float = 5.0, ) -> None: diff --git a/src/api/runtimes/snapshots.ts b/src/api/runtimes/snapshots.ts index f6f01a5f..2e230569 100644 --- a/src/api/runtimes/snapshots.ts +++ b/src/api/runtimes/snapshots.ts @@ -14,11 +14,11 @@ import { requestDatalayerAPI } from '../DatalayerApi'; import { API_BASE_PATHS, DEFAULT_SERVICE_URLS } from '../constants'; import { - CreateRuntimeSnapshotRequest, - ListRuntimeSnapshotsResponse, - GetRuntimeSnapshotResponse, - CreateRuntimeSnapshotResponse, -} from '../../models/RuntimeSnapshotDTO'; + CreateSandboxSnapshotRequest, + ListSandboxSnapshotsResponse, + GetSandboxSnapshotResponse, + CreateSandboxSnapshotResponse, +} from '../../models/SandboxSnapshotDTO'; import { validateToken, validateRequiredString } from '../utils/validation'; /** @@ -31,12 +31,12 @@ import { validateToken, validateRequiredString } from '../utils/validation'; */ export const createSnapshot = async ( token: string, - data: CreateRuntimeSnapshotRequest, + data: CreateSandboxSnapshotRequest, baseUrl: string = DEFAULT_SERVICE_URLS.RUNTIMES, -): Promise => { +): Promise => { validateToken(token); - return requestDatalayerAPI({ + return requestDatalayerAPI({ url: `${baseUrl}${API_BASE_PATHS.RUNTIMES}/sandbox-snapshots`, method: 'POST', token, @@ -54,10 +54,10 @@ export const createSnapshot = async ( export const listSnapshots = async ( token: string, baseUrl: string = DEFAULT_SERVICE_URLS.RUNTIMES, -): Promise => { +): Promise => { validateToken(token); - return requestDatalayerAPI({ + return requestDatalayerAPI({ url: `${baseUrl}${API_BASE_PATHS.RUNTIMES}/sandbox-snapshots`, method: 'GET', token, @@ -77,11 +77,11 @@ export const getSnapshot = async ( token: string, snapshotId: string, baseUrl: string = DEFAULT_SERVICE_URLS.RUNTIMES, -): Promise => { +): Promise => { validateToken(token); validateRequiredString(snapshotId, 'Snapshot ID'); - return requestDatalayerAPI({ + return requestDatalayerAPI({ url: `${baseUrl}${API_BASE_PATHS.RUNTIMES}/sandbox-snapshots/${snapshotId}`, method: 'GET', token, diff --git a/src/client/__tests__/client.models.integration.test.ts b/src/client/__tests__/client.models.integration.test.ts index c35341f3..68b74381 100644 --- a/src/client/__tests__/client.models.integration.test.ts +++ b/src/client/__tests__/client.models.integration.test.ts @@ -9,7 +9,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { DatalayerClient } from '..'; import { RuntimeDTO } from '../../models/RuntimeDTO'; import { DEFAULT_SERVICE_URLS } from '../../api/constants'; -import { RuntimeSnapshotDTO } from '../../models/RuntimeSnapshotDTO'; +import { SandboxSnapshotDTO } from '../../models/SandboxSnapshotDTO'; import { SpaceDTO } from '../../models/SpaceDTO'; import { NotebookDTO } from '../../models/NotebookDTO'; import { LexicalDTO } from '../../models/LexicalDTO'; @@ -45,7 +45,7 @@ describe.skipIf(skipInCi)('Client Models Integration Tests', () => { let testNotebook: NotebookDTO | null = null; let testLexical: LexicalDTO | null = null; let testRuntime: RuntimeDTO | null = null; - let testSnapshot: RuntimeSnapshotDTO | null = null; + let testSnapshot: SandboxSnapshotDTO | null = null; beforeAll(async () => { if (!testConfig.hasToken()) { @@ -288,7 +288,7 @@ describe.skipIf(skipInCi)('Client Models Integration Tests', () => { 'Test snapshot from model test', ); - expect(testSnapshot).toBeInstanceOf(RuntimeSnapshotDTO); + expect(testSnapshot).toBeInstanceOf(SandboxSnapshotDTO); // Snapshots don't have a podName property // Instead, check that the snapshot was created successfully expect(testSnapshot.uid).toBeDefined(); diff --git a/src/client/__tests__/client.runtimes.integration.test.ts b/src/client/__tests__/client.runtimes.integration.test.ts index d7be3dee..89194b57 100644 --- a/src/client/__tests__/client.runtimes.integration.test.ts +++ b/src/client/__tests__/client.runtimes.integration.test.ts @@ -8,7 +8,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { DatalayerClient } from '..'; import { RuntimeDTO } from '../../models/RuntimeDTO'; -import { RuntimeSnapshotDTO } from '../../models/RuntimeSnapshotDTO'; +import { SandboxSnapshotDTO } from '../../models/SandboxSnapshotDTO'; import { testConfig } from '../../__tests__/shared/test-config'; import { DEFAULT_SERVICE_URLS } from '../../api/constants'; import { performCleanup } from '../../__tests__/shared/cleanup-shared'; @@ -39,7 +39,7 @@ const resolveEnvironmentName = async ( describe.skipIf(skipInCi)('Client Runtimes Integration Tests', () => { let client: DatalayerClient; let createdRuntime: RuntimeDTO | null = null; - let createdSnapshot: RuntimeSnapshotDTO | null = null; + let createdSnapshot: SandboxSnapshotDTO | null = null; const ensureRuntime = async (): Promise => { if (createdRuntime) { @@ -56,7 +56,7 @@ describe.skipIf(skipInCi)('Client Runtimes Integration Tests', () => { return createdRuntime; }; - const ensureSnapshot = async (): Promise => { + const ensureSnapshot = async (): Promise => { if (createdSnapshot) { return createdSnapshot; } @@ -219,7 +219,7 @@ describe.skipIf(skipInCi)('Client Runtimes Integration Tests', () => { 'Test snapshot from Client', ); - expect(snapshot).toBeInstanceOf(RuntimeSnapshotDTO); + expect(snapshot).toBeInstanceOf(SandboxSnapshotDTO); expect(snapshot.uid).toBeDefined(); expect(snapshot.name).toContain('client-test-snapshot'); @@ -239,7 +239,7 @@ describe.skipIf(skipInCi)('Client Runtimes Integration Tests', () => { const found = snapshots.find(s => s.uid === snapshotRef.uid); expect(found).toBeDefined(); - expect(found).toBeInstanceOf(RuntimeSnapshotDTO); + expect(found).toBeInstanceOf(SandboxSnapshotDTO); console.log(`Found ${snapshots.length} snapshot(s)`); console.log(`Created snapshot found in list: ${found!.uid}`); @@ -251,7 +251,7 @@ describe.skipIf(skipInCi)('Client Runtimes Integration Tests', () => { console.log('Getting snapshot details...'); const snapshot = await client.getSnapshot(snapshotRef.uid); - expect(snapshot).toBeInstanceOf(RuntimeSnapshotDTO); + expect(snapshot).toBeInstanceOf(SandboxSnapshotDTO); expect(snapshot.uid).toBe(snapshotRef.uid); expect(snapshot.environment).toBe(snapshotRef.environment); diff --git a/src/client/index.ts b/src/client/index.ts index 5d4e4205..2d93725c 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -34,7 +34,7 @@ import type { UserDTO } from './../models/UserDTO'; import type { CreditsDTO } from '../models/CreditsDTO'; import type { EnvironmentDTO } from '../models/EnvironmentDTO'; import type { RuntimeDTO } from '../models/RuntimeDTO'; -import type { RuntimeSnapshotDTO } from '../models/RuntimeSnapshotDTO'; +import type { SandboxSnapshotDTO } from '../models/SandboxSnapshotDTO'; import type { SpaceDTO } from '../models/SpaceDTO'; import type { NotebookDTO } from '../models/NotebookDTO'; import type { LexicalDTO } from '../models/LexicalDTO'; @@ -124,15 +124,15 @@ export type { EnvironmentData, ListEnvironmentsResponse, } from '../models/EnvironmentDTO'; -export { RuntimeSnapshotDTO as Snapshot } from '../models/RuntimeSnapshotDTO'; +export { SandboxSnapshotDTO as Snapshot } from '../models/SandboxSnapshotDTO'; export type { - RuntimeSnapshotJSON, - RuntimeSnapshotData, - CreateRuntimeSnapshotRequest, - CreateRuntimeSnapshotResponse, - GetRuntimeSnapshotResponse, - ListRuntimeSnapshotsResponse, -} from '../models/RuntimeSnapshotDTO'; + SandboxSnapshotJSON, + SandboxSnapshotData, + CreateSandboxSnapshotRequest, + CreateSandboxSnapshotResponse, + GetSandboxSnapshotResponse, + ListSandboxSnapshotsResponse, +} from '../models/SandboxSnapshotDTO'; export { SpaceDTO as Space } from '../models/SpaceDTO'; export type { SpaceJSON, @@ -246,7 +246,7 @@ export type { IRuntimeLocation, IRuntimeCapabilities, } from '../models/Runtime'; -export type { IRuntimeSnapshot } from '../models/RuntimeSnapshot'; +export type { ISandboxSnapshot } from '../models/SandboxSnapshot'; export type { IDatalayerEnvironment, IResources, @@ -394,9 +394,9 @@ export interface DatalayerClient { name: string, description: string, stop?: boolean, - ): Promise; - listSnapshots(): Promise; - getSnapshot(id: string): Promise; + ): Promise; + listSnapshots(): Promise; + getSnapshot(id: string): Promise; deleteSnapshot(id: string): Promise; checkRuntimesHealth(): Promise; diff --git a/src/client/mixins/RuntimesMixin.ts b/src/client/mixins/RuntimesMixin.ts index dc9ba30c..6ce7290c 100644 --- a/src/client/mixins/RuntimesMixin.ts +++ b/src/client/mixins/RuntimesMixin.ts @@ -12,11 +12,11 @@ import * as environments from '../../api/runtimes/environments'; import * as runtimes from '../../api/runtimes/runtimes'; import * as snapshots from '../../api/runtimes/snapshots'; import type { CreateRuntimeRequest } from '../../models/RuntimeDTO'; -import type { CreateRuntimeSnapshotRequest } from '../../models/RuntimeSnapshotDTO'; +import type { CreateSandboxSnapshotRequest } from '../../models/SandboxSnapshotDTO'; import type { Constructor } from '../utils/mixins'; import { EnvironmentDTO } from '../../models/EnvironmentDTO'; import { RuntimeDTO } from '../../models/RuntimeDTO'; -import { RuntimeSnapshotDTO } from '../../models/RuntimeSnapshotDTO'; +import { SandboxSnapshotDTO } from '../../models/SandboxSnapshotDTO'; import { HealthCheck } from '../../models/HealthCheck'; /** Options for ensuring a runtime is available. */ @@ -51,7 +51,7 @@ export function RuntimesMixin(Base: TBase) { } _extractSnapshotId( - snapshotIdOrInstance: string | RuntimeSnapshotDTO, + snapshotIdOrInstance: string | SandboxSnapshotDTO, ): string { return typeof snapshotIdOrInstance === 'string' ? snapshotIdOrInstance @@ -212,11 +212,11 @@ export function RuntimesMixin(Base: TBase) { name: string, description: string, stop: boolean = false, - ): Promise { + ): Promise { const token = (this as any).getToken(); const runtimesRunUrl = (this as any).getRuntimesRunUrl(); - const data: CreateRuntimeSnapshotRequest = { + const data: CreateSandboxSnapshotRequest = { pod_name: podName, name, description, @@ -228,19 +228,19 @@ export function RuntimesMixin(Base: TBase) { data, runtimesRunUrl, ); - return new RuntimeSnapshotDTO(response.snapshot, this as any); + return new SandboxSnapshotDTO(response.snapshot, this as any); } /** * List all runtime snapshots. * @returns Array of snapshots */ - async listSnapshots(): Promise { + async listSnapshots(): Promise { const token = (this as any).getToken(); const runtimesRunUrl = (this as any).getRuntimesRunUrl(); const response = await snapshots.listSnapshots(token, runtimesRunUrl); return response.snapshots.map( - s => new RuntimeSnapshotDTO(s, this as any), + s => new SandboxSnapshotDTO(s, this as any), ); } @@ -249,11 +249,11 @@ export function RuntimesMixin(Base: TBase) { * @param id - Snapshot ID * @returns Snapshot details */ - async getSnapshot(id: string): Promise { + async getSnapshot(id: string): Promise { const token = (this as any).getToken(); const runtimesRunUrl = (this as any).getRuntimesRunUrl(); const response = await snapshots.getSnapshot(token, id, runtimesRunUrl); - return new RuntimeSnapshotDTO(response.snapshot, this as any); + return new SandboxSnapshotDTO(response.snapshot, this as any); } /** diff --git a/src/components/runtimes/RuntimeLauncherDialog.tsx b/src/components/runtimes/RuntimeLauncherDialog.tsx index 64c12869..9ac34fb1 100644 --- a/src/components/runtimes/RuntimeLauncherDialog.tsx +++ b/src/components/runtimes/RuntimeLauncherDialog.tsx @@ -26,7 +26,7 @@ import { useNavigate } from '../../hooks'; import { NO_RUNTIME_AVAILABLE_LABEL } from '../../i18n'; import type { IRemoteServicesManager } from '../../stateful/runtimes'; import type { RunResponseError } from '../../api/DatalayerApi'; -import type { IRuntimeSnapshot, IRuntimeDesc } from '../../models'; +import type { ISandboxSnapshot, IRuntimeDesc } from '../../models'; import { iamStore, useCoreStore, useIAMStore } from '../../state'; import { createNotebook, sleep } from '../../utils'; import { Markdown } from '../display'; @@ -88,7 +88,7 @@ export interface IRuntimeLauncherDialogProps { * If provided the kernel will be started and will * restore the provided snapshot in the kernel. */ - kernelSnapshot?: IRuntimeSnapshot; + kernelSnapshot?: ISandboxSnapshot; /** * HTML sanitizer diff --git a/src/components/snapshots/RuntimeSnapshotMenu.tsx b/src/components/snapshots/SandboxSnapshotMenu.tsx similarity index 79% rename from src/components/snapshots/RuntimeSnapshotMenu.tsx rename to src/components/snapshots/SandboxSnapshotMenu.tsx index 18fe82ed..52c7966e 100644 --- a/src/components/snapshots/RuntimeSnapshotMenu.tsx +++ b/src/components/snapshots/SandboxSnapshotMenu.tsx @@ -22,21 +22,21 @@ import { import { Dialog } from '@primer/react/experimental'; import { Box } from '@datalayer/primer-addons'; import { useToast } from '../../hooks'; -import { type IRuntimeSnapshot } from '../../models'; +import { type ISandboxSnapshot } from '../../models'; import { - createRuntimeSnapshot, - getRuntimeSnapshots, - loadBrowserRuntimeSnapshot, - loadRuntimeSnapshot, + createSandboxSnapshot, + getSandboxSnapshots, + loadBrowserSandboxSnapshot, + loadSandboxSnapshot, IMultiServiceManager, } from '../../stateful/runtimes'; import { useRuntimesStore } from '../../state'; -import { createRuntimeSnapshotName } from '../../utils'; +import { createSandboxSnapshotName } from '../../utils'; /** * Runtime snapshot menu component properties */ -type IRuntimeSnapshotMenu = { +type ISandboxSnapshotMenu = { /** * Application multi service manager. */ @@ -62,29 +62,29 @@ type IRuntimeSnapshotMenu = { /** * Runtime Snapshot menu component. */ -export function RuntimeSnapshotMenu({ +export function SandboxSnapshotMenu({ children, connection, podName, multiServiceManager, disabled = false, -}: PropsWithChildren): JSX.Element { +}: PropsWithChildren): JSX.Element { const { - addRuntimeSnapshot, + addSandboxSnapshot, runtimesRunUrl, runtimeSnapshots, - setRuntimeSnapshots, + setSandboxSnapshots, } = useRuntimesStore(); const { enqueueToast, trackAsyncTask } = useToast(); const [openLoadDialog, setOpenLoadDialog] = useState(false); - const [loadingRuntimeSnapshot, setLoadingRuntimeSnapshot] = useState(false); - const [takingRuntimeSnapshot, setTakingRuntimeSnapshot] = useState(false); + const [loadingSandboxSnapshot, setLoadingSandboxSnapshot] = useState(false); + const [takingSandboxSnapshot, setTakingSandboxSnapshot] = useState(false); const [selection, setSelection] = useState(runtimeSnapshots[0]?.id ?? ''); const [error, setError] = useState(); useEffect(() => { - getRuntimeSnapshots() + getSandboxSnapshots() .then(snapshots => { - setRuntimeSnapshots(snapshots); + setSandboxSnapshots(snapshots); if (!selection && snapshots.length > 0) { setSelection(snapshots[0].id); } @@ -93,14 +93,14 @@ export function RuntimeSnapshotMenu({ console.error(`Failed to fetch remote kernel snapshots; ${reason}`); }); }, [runtimesRunUrl]); - const onLoadRuntimeSnapshot = useCallback(() => { + const onLoadSandboxSnapshot = useCallback(() => { setError(undefined); setOpenLoadDialog(true); }, []); - const onRuntimeSnapshotChanged = useCallback(event => { + const onSandboxSnapshotChanged = useCallback(event => { setSelection(event.target.value); }, []); - const onLoadRuntimeSnapshotSubmit = useCallback( + const onLoadSandboxSnapshotSubmit = useCallback( async ({ id, connection, @@ -111,12 +111,12 @@ export function RuntimeSnapshotMenu({ podName?: string; }) => { if (podName) { - await loadRuntimeSnapshot({ id: podName, from: id }); + await loadSandboxSnapshot({ id: podName, from: id }); enqueueToast(`Runtime snapshot ${podName} is loaded.`, { variant: 'success', }); } else if (connection) { - await loadBrowserRuntimeSnapshot({ connection, id }); + await loadBrowserSandboxSnapshot({ connection, id }); enqueueToast(`Runtime snapshot ${id} is loaded.`, { variant: 'success', }); @@ -124,15 +124,15 @@ export function RuntimeSnapshotMenu({ }, [], ); - const onTakeRuntimeSnapshot = useCallback(async () => { + const onTakeSandboxSnapshot = useCallback(async () => { try { - setTakingRuntimeSnapshot(true); - let snapshot: IRuntimeSnapshot | undefined; + setTakingSandboxSnapshot(true); + let snapshot: ISandboxSnapshot | undefined; let task: Promise | undefined; let ref = ''; let snapshotName = ''; if (podName && multiServiceManager?.remote) { - snapshotName = createRuntimeSnapshotName('cloud'); + snapshotName = createSandboxSnapshotName('cloud'); task = multiServiceManager.remote.runtimesManager.snapshotRuntime({ podName, name: snapshotName, @@ -146,9 +146,9 @@ export function RuntimeSnapshotMenu({ } else if (connection && multiServiceManager?.browser) { const model = connection.model; ref = model.id; - snapshotName = createRuntimeSnapshotName('browser'); + snapshotName = createSandboxSnapshotName('browser'); let isPending = true; - task = createRuntimeSnapshot({ + task = createSandboxSnapshot({ connection: multiServiceManager.browser.kernels.connectTo({ model, }), @@ -157,7 +157,7 @@ export function RuntimeSnapshotMenu({ if (isPending) { isPending = false; // Get the kernel snapshot uid. - getRuntimeSnapshots().then(snapshots => { + getSandboxSnapshots().then(snapshots => { snapshot = snapshots.find(s => s.name === snapshotName); }); } @@ -183,11 +183,11 @@ export function RuntimeSnapshotMenu({ }); await task; if (snapshot) { - addRuntimeSnapshot(snapshot); + addSandboxSnapshot(snapshot); } } } finally { - setTakingRuntimeSnapshot(false); + setTakingSandboxSnapshot(false); } }, [connection, podName, multiServiceManager]); return ( @@ -197,21 +197,21 @@ export function RuntimeSnapshotMenu({ leadingVisual={DeviceCameraIcon} variant="invisible" size="small" - disabled={loadingRuntimeSnapshot || takingRuntimeSnapshot || disabled} + disabled={loadingSandboxSnapshot || takingSandboxSnapshot || disabled} > {children} Load a runtime snapshot… Take a runtime snapshot @@ -241,23 +241,23 @@ export function RuntimeSnapshotMenu({ }, { buttonType: 'primary', - content: loadingRuntimeSnapshot ? ( + content: loadingSandboxSnapshot ? ( ) : ( 'Load' ), - disabled: loadingRuntimeSnapshot, + disabled: loadingSandboxSnapshot, onClick: async event => { if (!event.defaultPrevented) { event.preventDefault(); - setLoadingRuntimeSnapshot(true); + setLoadingSandboxSnapshot(true); try { setError(undefined); const snapshot = runtimeSnapshots.find( s => s.id === selection, ); if (snapshot && (connection || podName)) { - await onLoadRuntimeSnapshotSubmit({ + await onLoadSandboxSnapshotSubmit({ connection, id: snapshot.id, podName, @@ -266,7 +266,7 @@ export function RuntimeSnapshotMenu({ setError('No runtime snapshot found.'); } } finally { - setLoadingRuntimeSnapshot(false); + setLoadingSandboxSnapshot(false); setOpenLoadDialog(false); } } @@ -281,7 +281,7 @@ export function RuntimeSnapshotMenu({