diff --git a/README.md b/README.md index 103825e0..1e924e78 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,33 @@ datalayer runtime exec my-script.py --runtime datalayer snapshots create my-snapshot 'AI work!' False ``` +### 4. Subscription and Credits CLI + +Use these commands to inspect billing and manage credits distribution. + +```bash +# End-user billing view +datalayer subscriptions show +datalayer subscriptions available +datalayer subscriptions move +datalayer subscriptions topups +datalayer subscriptions dry-run + +# Organization and team credits visibility +datalayer usage org-overview --organization-uid +datalayer usage team-overview --team-uid + +# Monitoring-driven credit management +datalayer usage org-monitor --organization-uid --window-hours 24 +datalayer usage team-monitor --team-uid --window-hours 24 + +# Credits transfer operations (owners/admins) +datalayer usage org-allocate-team --organization-uid --team-uid --amount 50 +datalayer usage org-revoke-team --organization-uid --team-uid --amount 20 +datalayer usage team-allocate-member --team-uid --member-uid --amount 15 +datalayer usage team-revoke-member --team-uid --member-uid --amount 5 +``` + ## Examples ### Python Examples diff --git a/datalayer_core/cli/__main__.py b/datalayer_core/cli/__main__.py index 21b1eeea..0cfdafbe 100644 --- a/datalayer_core/cli/__main__.py +++ b/datalayer_core/cli/__main__.py @@ -35,6 +35,8 @@ from datalayer_core.cli.commands.secrets import secrets_list, secrets_ls from datalayer_core.cli.commands.tokens import app as tokens_app from datalayer_core.cli.commands.tokens import tokens_list, tokens_ls +from datalayer_core.cli.commands.subscription import app as subscription_app +from datalayer_core.cli.commands.subscription import subscription_root from datalayer_core.cli.commands.usage import app as usage_app from datalayer_core.cli.commands.usage import usage_root from datalayer_core.cli.commands.users import app as users_app @@ -83,6 +85,7 @@ def main_callback( app.add_typer(runtimes_app) app.add_typer(secrets_app) app.add_typer(snapshots_app) +app.add_typer(subscription_app) app.add_typer(tokens_app) app.add_typer(users_app) app.add_typer(usage_app) @@ -96,6 +99,7 @@ def main_callback( app.command(name="logout")(logout_root) app.command(name="whoami")(whoami_root) app.command(name="usage")(usage_root) +app.command(name="subscription")(subscription_root) # Add convenient aliases at root level app.command(name="envs-list")(envs_list) diff --git a/datalayer_core/cli/commands/subscription.py b/datalayer_core/cli/commands/subscription.py new file mode 100644 index 00000000..ffd25ebe --- /dev/null +++ b/datalayer_core/cli/commands/subscription.py @@ -0,0 +1,812 @@ +# Copyright (c) 2023-2025 Datalayer, Inc. +# Distributed under the terms of the Modified BSD License. + +"""Subscription and billing commands for Datalayer CLI.""" + +from collections import Counter +from typing import Any, Optional + +import typer +from rich.console import Console +from rich.table import Table + +from datalayer_core.client.client import DatalayerClient + +app = typer.Typer( + name='subscriptions', + help='Subscription and billing commands', + invoke_without_command=True, +) +console = Console() + + +def _extract_subscription(payload: dict[str, Any]) -> dict[str, Any]: + return payload.get('subscription') or {} + + +def _normalize_value(value: Any, fallback: str = 'Not available') -> str: + if value is None: + return fallback + text = str(value).strip() + if not text: + return fallback + return text + + +def _format_plan_price(plan: dict[str, Any]) -> str: + amount = ( + plan.get('amount') + or plan.get('unit_amount') + or plan.get('unit_amount_decimal') + or plan.get('price') + ) + currency = str(plan.get('currency') or '').strip().upper() + + try: + if amount is None: + return 'Not available' + amount_minor = float(amount) + amount_major = amount_minor / 100.0 + if currency == 'USD': + return f'${amount_major:,.2f} USD' + if currency: + return f'{amount_major:,.2f} {currency}' + return f'{amount_major:,.2f}' + except (TypeError, ValueError): + return _normalize_value(amount) + + +def _as_plan_list(value: Any) -> list[dict[str, Any]]: + if not isinstance(value, list): + return [] + plans: list[dict[str, Any]] = [] + for item in value: + if isinstance(item, dict): + plans.append(item) + elif item is not None: + plans.append({'name': str(item)}) + return plans + + +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, + ] + for candidate in candidates: + plans = _as_plan_list(candidate) + if plans: + return plans + return [] + + +def _render_available_plans(plans: list[dict[str, Any]]) -> None: + if not plans: + console.print( + '[yellow]No available subscription list provided by IAM response.[/yellow]' + ) + return + + plans_table = Table(title='Available Subscriptions') + plans_table.add_column('Plan', style='cyan') + plans_table.add_column('Price', style='white') + plans_table.add_column('Interval', style='white') + plans_table.add_column('Included runs', style='white') + + for plan in plans: + recurring = plan.get('recurring') or {} + interval = ( + plan.get('interval') or recurring.get('interval') or plan.get('billing_period') + ) + plans_table.add_row( + _normalize_value( + plan.get('name') + or plan.get('plan_name') + or plan.get('nickname') + or plan.get('id') + ), + _format_plan_price(plan), + _normalize_value(interval), + _normalize_value( + plan.get('included_runs') or (plan.get('metadata') or {}).get('included_runs') + ), + ) + + console.print(plans_table) + + +def _is_platform_admin(client: DatalayerClient) -> bool: + profile = client.get_profile() + roles = set(profile.roles or []) + return 'platform_admin' in roles + + +@app.callback() +def subscription_callback(ctx: typer.Context) -> None: + """Subscription and billing commands.""" + if ctx.invoked_subcommand is None: + typer.echo(ctx.get_help()) + + +@app.command(name='show') +def subscription_show( + token: Optional[str] = typer.Option( + None, + '--token', + help='Authentication token (Bearer token for API requests).', + ), + raw: bool = typer.Option( + False, + '--raw', + help='Print raw JSON payload from IAM.', + ), +) -> None: + """Show current subscription status and billing details.""" + try: + client = DatalayerClient(token=token) + response = client.get_subscription() + + if not response.get('success', True): + console.print( + f"[red]Error: {response.get('message', 'Unknown error')}[/red]" + ) + raise typer.Exit(1) + + if raw: + console.print(response) + return + + subscription = _extract_subscription(response) + portal = response.get('portal') or {} + + plan_name = _normalize_value( + subscription.get('plan_name') + or (subscription.get('plan') or {}).get('name') + or subscription.get('plan') + or subscription.get('tier'), + fallback='Free', + ) + status = _normalize_value( + subscription.get('status') + or subscription.get('subscription_status') + or subscription.get('state'), + fallback='', + ) + if status.lower() == 'unknown': + status = '' + period_start = _normalize_value( + subscription.get('current_period_start') + or subscription.get('period_start') + or subscription.get('start_date') + ) + period_end = _normalize_value( + subscription.get('current_period_end') + or subscription.get('period_end') + or subscription.get('next_renewal_at') + or subscription.get('renewal_date') + ) + + included_runs = subscription.get('included_runs') + used_runs = subscription.get('used_runs') + remaining_runs = None + try: + if included_runs is not None and used_runs is not None: + remaining_runs = max(0, int(included_runs) - int(used_runs)) + except (ValueError, TypeError): + remaining_runs = None + + table = Table(title='Subscription') + table.add_column('Field', style='cyan', no_wrap=True) + table.add_column('Value', style='white') + + table.add_row('Plan', plan_name) + table.add_row('Status', status.replace('_', ' ')) + table.add_row('Period start', period_start) + table.add_row('Period end', period_end) + table.add_row( + 'Included runs', + _normalize_value(included_runs), + ) + table.add_row( + 'Used runs', + _normalize_value(used_runs), + ) + table.add_row( + 'Remaining runs', + _normalize_value(remaining_runs), + ) + table.add_row('Billing portal', _normalize_value(portal.get('url'))) + + console.print(table) + + plans_response = client.get_subscription_plans() + if plans_response.get('success', True): + _render_available_plans(_extract_available_plans(plans_response)) + else: + _render_available_plans(_extract_available_plans(response)) + + console.print( + '[green]To upgrade or downgrade, run:[/green] datalayer subscriptions move' + ) + except Exception as e: + console.print(f'[red]Error fetching subscription: {e}[/red]') + raise typer.Exit(1) + + +@app.command(name='available') +def subscription_available( + token: Optional[str] = typer.Option( + None, + '--token', + help='Authentication token (Bearer token for API requests).', + ), + raw: bool = typer.Option( + False, + '--raw', + help='Print raw JSON payload from IAM.', + ), +) -> None: + """Show available subscription plans for the current user.""" + try: + client = DatalayerClient(token=token) + response = client.get_subscription_plans() + + if not response.get('success', True): + fallback = client.get_subscription() + if not fallback.get('success', True): + console.print( + f"[red]Error: {response.get('message', 'Unknown error')}[/red]" + ) + raise typer.Exit(1) + response = fallback + + if raw: + console.print(response) + return + + _render_available_plans(_extract_available_plans(response)) + console.print( + '[green]To upgrade or downgrade, run:[/green] datalayer subscriptions move' + ) + except Exception as e: + console.print(f'[red]Error fetching available subscriptions: {e}[/red]') + raise typer.Exit(1) + + +@app.command(name='move') +def subscription_move( + token: Optional[str] = typer.Option( + None, + '--token', + help='Authentication token (Bearer token for API requests).', + ), + return_url: Optional[str] = typer.Option( + None, + '--return-url', + help='Optional return URL after plan change. Defaults to IAM base URL.', + ), + open_browser: bool = typer.Option( + True, + '--open/--no-open', + help='Open checkout portal in your browser when URL is available.', + ), +) -> None: + """Start upgrade/downgrade flow via checkout portal.""" + try: + client = DatalayerClient(token=token) + resolved_return_url = return_url or client.urls.iam_url + response = client.create_checkout_portal(resolved_return_url) + + if not response.get('success', True): + console.print( + f"[red]Error: {response.get('message', 'Unknown error')}[/red]" + ) + raise typer.Exit(1) + + portal = response.get('portal') or {} + portal_url = portal.get('url') + portal_route = portal.get('route') + + if portal_url: + console.print( + '[green]Use this checkout portal to upgrade/downgrade:[/green] ' + f'{portal_url}' + ) + if open_browser: + import webbrowser + + webbrowser.open(portal_url) + return + + if portal_route: + console.print( + '[yellow]Portal URL is not exposed, but a checkout route is available:[/yellow] ' + f'{portal_route}' + ) + console.print( + '[green]Open your Datalayer UI and navigate to that route to change plans.[/green]' + ) + return + + console.print( + '[yellow]No checkout portal available for this account. Contact support or platform admin.[/yellow]' + ) + raise typer.Exit(1) + except Exception as e: + console.print(f'[red]Error starting subscription move flow: {e}[/red]') + raise typer.Exit(1) + + +@app.command(name='portal') +def subscription_portal( + token: Optional[str] = typer.Option( + None, + '--token', + help='Authentication token (Bearer token for API requests).', + ), + open_browser: bool = typer.Option( + True, + '--open/--no-open', + help='Open the billing portal in your browser.', + ), +) -> None: + """Print billing portal URL and optionally open it in a browser.""" + try: + client = DatalayerClient(token=token) + response = client.get_subscription() + if not response.get('success', True): + console.print( + f"[red]Error: {response.get('message', 'Unknown error')}[/red]" + ) + raise typer.Exit(1) + + portal_url = (response.get('portal') or {}).get('url') + if not portal_url: + console.print( + '[yellow]No billing portal URL available for this account.[/yellow]' + ) + raise typer.Exit(1) + + console.print(f'[green]Billing portal:[/green] {portal_url}') + if open_browser: + import webbrowser + + webbrowser.open(portal_url) + except Exception as e: + console.print(f'[red]Error opening billing portal: {e}[/red]') + raise typer.Exit(1) + + +@app.command(name='cancel') +def subscription_cancel( + token: Optional[str] = typer.Option( + None, + '--token', + help='Authentication token (Bearer token for API requests).', + ), + open_browser: bool = typer.Option( + True, + '--open/--no-open', + help='Open the cancellation portal in your browser when available.', + ), +) -> None: + """Start subscription cancellation flow.""" + try: + client = DatalayerClient(token=token) + response = client.cancel_subscription() + if not response.get('success', True): + console.print( + f"[red]Error: {response.get('message', 'Unknown error')}[/red]" + ) + raise typer.Exit(1) + + console.print('[green]Cancellation request submitted.[/green]') + portal_url = (response.get('portal') or {}).get('url') + if portal_url: + console.print(f'[green]Cancellation portal:[/green] {portal_url}') + if open_browser: + import webbrowser + + webbrowser.open(portal_url) + except Exception as e: + console.print(f'[red]Error cancelling subscription: {e}[/red]') + raise typer.Exit(1) + + +@app.command(name='topups') +def subscription_topups( + token: Optional[str] = typer.Option( + None, + '--token', + help='Authentication token (Bearer token for API requests).', + ), + raw: bool = typer.Option( + False, + '--raw', + help='Print raw JSON payload from IAM.', + ), +) -> None: + """Show top-up eligibility and available top-up prices.""" + try: + client = DatalayerClient(token=token) + subscription_resp = client.get_subscription() + usage_resp = client.get_usage_credits() + prices_resp = client._fetch( + f'{client.urls.iam_url}/api/iam/stripe/v1/topup/prices', + method='GET', + ).json() + + if raw: + console.print( + { + 'subscription': subscription_resp, + 'usage': usage_resp, + 'prices': prices_resp, + } + ) + return + + subscription = _extract_subscription(subscription_resp) + plan_name = _normalize_value( + subscription.get('plan_name') or subscription.get('plan') or 'Free', + fallback='Free', + ) + status = _normalize_value( + subscription.get('status') + or subscription.get('subscription_status') + or 'unknown', + fallback='unknown', + ).lower() + has_topup_access = status in {'active', 'trialing'} and str(plan_name).lower() not in { + 'free', + 'none', + } + + credits = (usage_resp.get('credits') or {}).get('credits') + quota = (usage_resp.get('credits') or {}).get('quota') + prices = prices_resp.get('prices') or [] + + summary = Table(title='Top-up Status') + summary.add_column('Field', style='cyan', no_wrap=True) + summary.add_column('Value', style='white') + summary.add_row('Plan', str(plan_name)) + summary.add_row('Subscription status', str(status).replace('_', ' ')) + summary.add_row('Top-up eligible', 'yes' if has_topup_access else 'no') + summary.add_row('Current credits', _normalize_value(credits, fallback='0')) + summary.add_row('Quota', _normalize_value(quota, fallback='none')) + summary.add_row('Top-up prices', str(len(prices))) + console.print(summary) + + prices_table = Table(title='Top-up Prices') + prices_table.add_column('Price ID', style='cyan') + prices_table.add_column('Currency', style='white') + prices_table.add_column('Unit Amount', style='white') + prices_table.add_column('Credits per Unit', style='white') + + for price in prices: + # Stripe price payloads can vary by endpoint version. + unit_amount = ( + price.get('unit_amount') + or price.get('amount') + or price.get('unit_amount_decimal') + ) + transform_quantity = price.get('transform_quantity') or {} + credits_per_unit = ( + transform_quantity.get('divide_by') + if isinstance(transform_quantity, dict) + else None + ) + if credits_per_unit is None: + credits_per_unit = price.get('credits') or price.get('credits_delta') + + prices_table.add_row( + _normalize_value(price.get('id')), + _normalize_value(price.get('currency'), fallback='n/a'), + _normalize_value(unit_amount, fallback='n/a'), + _normalize_value(credits_per_unit, fallback='n/a'), + ) + + if prices: + console.print(prices_table) + else: + console.print('[yellow]No top-up prices configured.[/yellow]') + + if not has_topup_access: + console.print( + '[yellow]Top-up purchase is currently blocked. Activate a monthly subscription first.[/yellow]' + ) + except Exception as e: + console.print(f'[red]Error fetching top-up information: {e}[/red]') + raise typer.Exit(1) + + +@app.command(name='stats') +def subscription_stats( + token: Optional[str] = typer.Option( + None, + '--token', + help='Authentication token (Bearer token for API requests).', + ), + query: str = typer.Option( + '', + '--query', + help='Optional user search query for scoped analytics.', + ), +) -> None: + """Show subscription aggregates (platform_admin only).""" + try: + client = DatalayerClient(token=token) + if not _is_platform_admin(client): + console.print('[red]Access denied: platform_admin role required.[/red]') + raise typer.Exit(1) + + response = client._fetch( + f'{client.urls.iam_url}/api/iam/v1/users/search', + method='POST', + json={'query': query}, + ).json() + + if not response.get('success', True): + console.print( + f"[red]Error: {response.get('message', 'Unknown error')}[/red]" + ) + raise typer.Exit(1) + + data = response.get('data') or {} + users = data.get('users') or response.get('users') or [] + + status_counter = Counter() + plan_counter = Counter() + 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_counter[status] += 1 + plan_counter[plan] += 1 + + if status in {'active', 'trialing', 'past_due', 'unpaid'}: + paid_count += 1 + + total_users = len(users) + + headline = Table(title='Subscription Aggregates') + headline.add_column('Metric', style='cyan', no_wrap=True) + headline.add_column('Value', style='white') + headline.add_row('Total users scanned', str(total_users)) + headline.add_row('Users with paid subscriptions', str(paid_count)) + headline.add_row( + 'Paid subscription ratio', + f'{(paid_count / total_users * 100):.1f}%' if total_users else '0.0%', + ) + console.print(headline) + + status_table = Table(title='Subscription Status Counts') + status_table.add_column('Status', style='cyan', no_wrap=True) + status_table.add_column('Count', style='white') + for status, count in sorted(status_counter.items(), key=lambda x: (-x[1], x[0])): + status_table.add_row(status, str(count)) + console.print(status_table) + + plan_table = Table(title='Subscription Plan Counts') + plan_table.add_column('Plan', style='cyan', no_wrap=True) + plan_table.add_column('Count', style='white') + for plan, count in sorted(plan_counter.items(), key=lambda x: (-x[1], x[0])): + plan_table.add_row(plan, str(count)) + console.print(plan_table) + + except Exception as e: + console.print(f'[red]Error computing subscription stats: {e}[/red]') + raise typer.Exit(1) + + +@app.command(name='admin-users') +def subscription_admin_users( + token: Optional[str] = typer.Option( + None, + '--token', + help='Authentication token (Bearer token for API requests).', + ), + query: str = typer.Option( + '', + '--query', + help='Optional user search query.', + ), + limit: int = typer.Option( + 25, + '--limit', + help='Maximum users to print.', + ), +) -> None: + """List users with subscription fields (platform_admin only).""" + try: + client = DatalayerClient(token=token) + if not _is_platform_admin(client): + console.print('[red]Access denied: platform_admin role required.[/red]') + raise typer.Exit(1) + + response = client._fetch( + f'{client.urls.iam_url}/api/iam/v1/users/search', + method='POST', + json={'query': query}, + ).json() + + if not response.get('success', True): + console.print( + f"[red]Error: {response.get('message', 'Unknown error')}[/red]" + ) + raise typer.Exit(1) + + data = response.get('data') or {} + users = (data.get('users') or response.get('users') or [])[:limit] + + table = Table(title='Subscription Users') + table.add_column('Handle', style='cyan') + table.add_column('Plan', style='white') + table.add_column('Status', style='white') + table.add_column('Customer', style='white') + + 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'), + ) + + console.print(table) + console.print( + f"[green]Shown {len(users)} user(s). Use --query to narrow results.[/green]" + ) + except Exception as e: + console.print(f'[red]Error listing subscription users: {e}[/red]') + raise typer.Exit(1) + + +@app.command(name='dry-run') +def subscription_dry_run( + token: Optional[str] = typer.Option( + None, + '--token', + help='Authentication token (Bearer token for API requests).', + ), + check_api: bool = typer.Option( + True, + '--check-api/--no-check-api', + help='Verify IAM Stripe API endpoints during the walkthrough.', + ), + price_id: Optional[str] = typer.Option( + None, + '--price-id', + help='Optional Stripe top-up price id used for payment-intent creation check.', + ), + create_intent: bool = typer.Option( + False, + '--create-intent/--no-create-intent', + help='Create a real test-mode payment intent to validate end-to-end wiring.', + ), +) -> None: + """Didactic dry-run for subscriptions and Stripe configuration.""" + try: + client = DatalayerClient(token=token) + + console.rule('[bold]Datalayer Subscriptions Dry-Run[/bold]') + console.print('This walkthrough validates Stripe test configuration end-to-end.') + + checklist = Table(title='Step-by-step Checklist') + checklist.add_column('Step', style='cyan', no_wrap=True) + checklist.add_column('What to verify', style='white') + checklist.add_row('1', 'Stripe Dashboard in Test Mode (pk_test/sk_test keys).') + checklist.add_row('2', 'Top-up one-time product and prices configured.') + checklist.add_row( + '3', + 'Top-up prices have transform_quantity.divide_by mapped to credits.', + ) + checklist.add_row('4', 'Monthly subscription product exists and is active.') + checklist.add_row( + '5', + 'Customer portal enabled for payment method and cancellation flows.', + ) + checklist.add_row( + '6', + 'Webhook endpoint set to /api/iam/stripe/v1/webhook with subscription + invoice + payment_intent events.', + ) + checklist.add_row( + '7', + 'IAM env vars are set: DATALAYER_STRIPE_API_KEY, DATALAYER_STRIPE_JS_API_KEY, DATALAYER_STRIPE_PRODUCT_ID, DATALAYER_STRIPE_WEBHOOK_SECRET.', + ) + console.print(checklist) + + if check_api: + console.rule('[bold]API Checks[/bold]') + + sub_resp = client.get_subscription() + if sub_resp.get('success', True): + sub = _extract_subscription(sub_resp) + console.print( + '[green]OK[/green] /api/iam/v1/subscription ' + 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 ' + f"{sub_resp.get('message', 'Unknown error')}" + ) + + prices_resp = client._fetch( + f'{client.urls.iam_url}/api/iam/stripe/v1/topup/prices', + method='GET', + ).json() + prices = prices_resp.get('prices') or [] + if prices_resp.get('success', True): + console.print( + f"[green]OK[/green] /api/iam/stripe/v1/topup/prices returned {len(prices)} price(s)." + ) + else: + console.print( + '[red]FAILED[/red] /api/iam/stripe/v1/topup/prices ' + f"{prices_resp.get('message', 'Unknown error')}" + ) + + if create_intent: + if not price_id: + if prices: + price_id = prices[0].get('id') + else: + console.print( + '[yellow]SKIP[/yellow] No top-up price available for intent creation check.' + ) + + if price_id: + intent_resp = client._fetch( + f'{client.urls.iam_url}/api/iam/stripe/v1/topup/payment-intent', + method='POST', + json={'price_id': price_id}, + ).json() + if intent_resp.get('success', True) and intent_resp.get('client_secret'): + console.print( + '[green]OK[/green] /api/iam/stripe/v1/topup/payment-intent returned client_secret.' + ) + else: + console.print( + '[red]FAILED[/red] /api/iam/stripe/v1/topup/payment-intent ' + f"{intent_resp.get('message', 'Unknown error')}" + ) + + console.rule('[bold]Stripe Test Card Suggestions[/bold]') + cards = Table() + cards.add_column('Scenario', style='cyan') + cards.add_column('Card Number', style='white') + cards.add_row('Success', '4242 4242 4242 4242') + cards.add_row('3DS Challenge', '4000 0025 0000 3155') + cards.add_row('Declined', '4000 0000 0000 0002') + cards.add_row('Insufficient Funds', '4000 0000 0000 9995') + console.print(cards) + console.print('Use any future expiry date, any CVC, and any postal code in test mode.') + + console.rule('[bold]Expected Result[/bold]') + console.print( + 'Top-up must be blocked without monthly subscription, and allowed with active monthly subscription.' + ) + except Exception as e: + console.print(f'[red]Error running subscriptions dry-run: {e}[/red]') + raise typer.Exit(1) + + +def subscription_root( + token: Optional[str] = typer.Option( + None, + '--token', + help='Authentication token (Bearer token for API requests).', + ), +) -> None: + """Show subscription status (root command).""" + subscription_show(token=token) diff --git a/datalayer_core/cli/commands/usage.py b/datalayer_core/cli/commands/usage.py index 9b879e2b..9c70ff1a 100644 --- a/datalayer_core/cli/commands/usage.py +++ b/datalayer_core/cli/commands/usage.py @@ -3,7 +3,7 @@ """Usage/credits commands for Datalayer CLI.""" -from typing import Optional +from typing import Any, Optional import typer from rich.console import Console @@ -17,6 +17,25 @@ console = Console() +def _normalize_value(value: Any, fallback: str = "n/a") -> str: + if value is None: + return fallback + text = str(value).strip() + return text if text else fallback + + +def _iam_get(client: DatalayerClient, path: str) -> dict[str, Any]: + return client._fetch(f"{client.urls.iam_url}{path}", method="GET").json() + + +def _iam_post(client: DatalayerClient, path: str, body: dict[str, Any]) -> dict[str, Any]: + return client._fetch( + f"{client.urls.iam_url}{path}", + method="POST", + json=body, + ).json() + + @app.callback() def usage_callback(ctx: typer.Context) -> None: """Usage and credits commands.""" @@ -55,6 +74,445 @@ def usage_show( raise typer.Exit(1) +@app.command(name="org-overview") +def usage_org_overview( + organization_uid: str = typer.Option( + ..., + "--organization-uid", + help="Organization UID.", + ), + token: Optional[str] = typer.Option( + None, + "--token", + help="Authentication token (Bearer token for API requests).", + ), + raw: bool = typer.Option(False, "--raw", help="Print raw JSON payload."), +) -> None: + """Show organization/team credits allocation overview.""" + try: + client = DatalayerClient(token=token) + response = _iam_get( + client, + f"/api/iam/v1/usage/credits/allocations/organizations/{organization_uid}/overview", + ) + if not response.get("success", True): + console.print(f"[red]Error: {response.get('message', 'Unknown error')}[/red]") + raise typer.Exit(1) + + if raw: + console.print(response) + return + + overview = response.get("overview") or {} + organization = overview.get("organization") or {} + teams = overview.get("teams") or [] + + summary = Table(title="Organization Credits Overview") + summary.add_column("Field", style="cyan", no_wrap=True) + summary.add_column("Value", style="white") + summary.add_row("Organization UID", _normalize_value(organization.get("uid"))) + summary.add_row("Credits", _normalize_value(organization.get("credits"), fallback="0")) + summary.add_row("Quota", _normalize_value(organization.get("quota"), fallback="none")) + summary.add_row("Teams", str(len(teams))) + console.print(summary) + + team_table = Table(title="Teams") + team_table.add_column("UID", style="cyan") + team_table.add_column("Handle", style="white") + team_table.add_column("Name", style="white") + team_table.add_column("Credits", style="white") + for team in teams: + team_table.add_row( + _normalize_value(team.get("uid")), + _normalize_value(team.get("handle")), + _normalize_value(team.get("name")), + _normalize_value(team.get("credits"), fallback="0"), + ) + console.print(team_table) + except Exception as e: + console.print(f"[red]Error fetching organization overview: {e}[/red]") + raise typer.Exit(1) + + +@app.command(name="team-overview") +def usage_team_overview( + team_uid: str = typer.Option( + ..., + "--team-uid", + help="Team UID.", + ), + token: Optional[str] = typer.Option( + None, + "--token", + help="Authentication token (Bearer token for API requests).", + ), + raw: bool = typer.Option(False, "--raw", help="Print raw JSON payload."), +) -> None: + """Show team/member credits allocation overview.""" + try: + client = DatalayerClient(token=token) + response = _iam_get( + client, + f"/api/iam/v1/usage/credits/allocations/teams/{team_uid}/overview", + ) + if not response.get("success", True): + console.print(f"[red]Error: {response.get('message', 'Unknown error')}[/red]") + raise typer.Exit(1) + + if raw: + console.print(response) + return + + overview = response.get("overview") or {} + team = overview.get("team") or {} + members = overview.get("members") or [] + + summary = Table(title="Team Credits Overview") + summary.add_column("Field", style="cyan", no_wrap=True) + summary.add_column("Value", style="white") + summary.add_row("Team UID", _normalize_value(team.get("uid"))) + summary.add_row("Team Handle", _normalize_value(team.get("handle"))) + summary.add_row("Credits", _normalize_value(team.get("credits"), fallback="0")) + summary.add_row("Members", str(len(members))) + console.print(summary) + + members_table = Table(title="Members") + members_table.add_column("UID", style="cyan") + members_table.add_column("Handle", style="white") + members_table.add_column("Display Name", style="white") + members_table.add_column("Credits", style="white") + for member in members: + members_table.add_row( + _normalize_value(member.get("uid")), + _normalize_value(member.get("handle")), + _normalize_value(member.get("display_name")), + _normalize_value(member.get("credits"), fallback="0"), + ) + console.print(members_table) + except Exception as e: + console.print(f"[red]Error fetching team overview: {e}[/red]") + raise typer.Exit(1) + + +@app.command(name="org-history") +def usage_org_history( + organization_uid: str = typer.Option(..., "--organization-uid", help="Organization UID."), + token: Optional[str] = typer.Option(None, "--token", help="Authentication token."), + limit: int = typer.Option(20, "--limit", help="Max events to print."), +) -> None: + """Show organization/team credits transfer history.""" + try: + client = DatalayerClient(token=token) + response = _iam_get( + client, + f"/api/iam/v1/usage/credits/allocations/organizations/{organization_uid}/history", + ) + if not response.get("success", True): + console.print(f"[red]Error: {response.get('message', 'Unknown error')}[/red]") + raise typer.Exit(1) + + events = ((response.get("history") or {}).get("events") or [])[: max(1, limit)] + table = Table(title="Organization Allocation History") + table.add_column("When", style="cyan") + table.add_column("Event", style="white") + table.add_column("Credits", style="white") + table.add_column("Account", style="white") + for event in events: + table.add_row( + _normalize_value(event.get("created_at")), + _normalize_value(event.get("event")), + _normalize_value(event.get("credits"), fallback="0"), + _normalize_value(event.get("account_uid")), + ) + console.print(table) + except Exception as e: + console.print(f"[red]Error fetching organization history: {e}[/red]") + raise typer.Exit(1) + + +@app.command(name="team-history") +def usage_team_history( + team_uid: str = typer.Option(..., "--team-uid", help="Team UID."), + token: Optional[str] = typer.Option(None, "--token", help="Authentication token."), + limit: int = typer.Option(20, "--limit", help="Max events to print."), +) -> None: + """Show team/member credits transfer history.""" + try: + client = DatalayerClient(token=token) + response = _iam_get( + client, + f"/api/iam/v1/usage/credits/allocations/teams/{team_uid}/history", + ) + if not response.get("success", True): + console.print(f"[red]Error: {response.get('message', 'Unknown error')}[/red]") + raise typer.Exit(1) + + events = ((response.get("history") or {}).get("events") or [])[: max(1, limit)] + table = Table(title="Team Allocation History") + table.add_column("When", style="cyan") + table.add_column("Event", style="white") + table.add_column("Credits", style="white") + table.add_column("Account", style="white") + for event in events: + table.add_row( + _normalize_value(event.get("created_at")), + _normalize_value(event.get("event")), + _normalize_value(event.get("credits"), fallback="0"), + _normalize_value(event.get("account_uid")), + ) + console.print(table) + except Exception as e: + console.print(f"[red]Error fetching team history: {e}[/red]") + raise typer.Exit(1) + + +@app.command(name="org-monitor") +def usage_org_monitor( + organization_uid: str = typer.Option(..., "--organization-uid", help="Organization UID."), + token: Optional[str] = typer.Option(None, "--token", help="Authentication token."), + window_hours: int = typer.Option(24, "--window-hours", help="Monitoring window in hours."), +) -> None: + """Show organization/team credits monitoring metrics and recommendations.""" + try: + client = DatalayerClient(token=token) + response = _iam_get( + client, + f"/api/iam/v1/usage/credits/allocations/organizations/{organization_uid}/monitoring?window_hours={max(1, window_hours)}", + ) + if not response.get("success", True): + console.print(f"[red]Error: {response.get('message', 'Unknown error')}[/red]") + raise typer.Exit(1) + + monitoring = response.get("monitoring") or {} + organization = monitoring.get("organization") or {} + teams = monitoring.get("teams") or [] + recommendations = monitoring.get("recommendations") or [] + + summary = Table(title="Organization Monitoring") + summary.add_column("Field", style="cyan") + summary.add_column("Value", style="white") + summary.add_row("Credits", _normalize_value(organization.get("credits"), fallback="0")) + summary.add_row( + "Active reservations", + _normalize_value(organization.get("active_reservations"), fallback="0"), + ) + summary.add_row( + "Burning rate / hour", + _normalize_value(organization.get("burning_rate_per_hour"), fallback="0"), + ) + summary.add_row( + "ETA (hours)", + _normalize_value(organization.get("estimated_hours_to_depletion"), fallback="n/a"), + ) + console.print(summary) + + teams_table = Table(title="Team Monitoring") + teams_table.add_column("Team", style="cyan") + teams_table.add_column("Credits", style="white") + teams_table.add_column("Reservations", style="white") + teams_table.add_column("Burn/hr", style="white") + teams_table.add_column("ETA(h)", style="white") + for team in teams: + teams_table.add_row( + _normalize_value(team.get("handle") or team.get("uid")), + _normalize_value(team.get("credits"), fallback="0"), + _normalize_value(team.get("active_reservations"), fallback="0"), + _normalize_value(team.get("burning_rate_per_hour"), fallback="0"), + _normalize_value(team.get("estimated_hours_to_depletion"), fallback="n/a"), + ) + console.print(teams_table) + + if recommendations: + rec_table = Table(title="Recommendations") + rec_table.add_column("Severity", style="cyan") + rec_table.add_column("Account", style="white") + rec_table.add_column("Message", style="white") + for rec in recommendations: + rec_table.add_row( + _normalize_value(rec.get("severity")), + _normalize_value(rec.get("account_uid")), + _normalize_value(rec.get("message")), + ) + console.print(rec_table) + except Exception as e: + console.print(f"[red]Error fetching organization monitoring: {e}[/red]") + raise typer.Exit(1) + + +@app.command(name="team-monitor") +def usage_team_monitor( + team_uid: str = typer.Option(..., "--team-uid", help="Team UID."), + token: Optional[str] = typer.Option(None, "--token", help="Authentication token."), + window_hours: int = typer.Option(24, "--window-hours", help="Monitoring window in hours."), +) -> None: + """Show team/member credits monitoring metrics and recommendations.""" + try: + client = DatalayerClient(token=token) + response = _iam_get( + client, + f"/api/iam/v1/usage/credits/allocations/teams/{team_uid}/monitoring?window_hours={max(1, window_hours)}", + ) + if not response.get("success", True): + console.print(f"[red]Error: {response.get('message', 'Unknown error')}[/red]") + raise typer.Exit(1) + + monitoring = response.get("monitoring") or {} + team = monitoring.get("team") or {} + members = monitoring.get("members") or [] + recommendations = monitoring.get("recommendations") or [] + + summary = Table(title="Team Monitoring") + summary.add_column("Field", style="cyan") + summary.add_column("Value", style="white") + summary.add_row("Team", _normalize_value(team.get("handle") or team.get("uid"))) + summary.add_row("Credits", _normalize_value(team.get("credits"), fallback="0")) + summary.add_row( + "Active reservations", + _normalize_value(team.get("active_reservations"), fallback="0"), + ) + summary.add_row( + "Burning rate / hour", + _normalize_value(team.get("burning_rate_per_hour"), fallback="0"), + ) + summary.add_row( + "ETA (hours)", + _normalize_value(team.get("estimated_hours_to_depletion"), fallback="n/a"), + ) + console.print(summary) + + members_table = Table(title="Member Monitoring") + members_table.add_column("Member", style="cyan") + members_table.add_column("Credits", style="white") + members_table.add_column("Reservations", style="white") + members_table.add_column("Burn/hr", style="white") + members_table.add_column("ETA(h)", style="white") + for member in members: + members_table.add_row( + _normalize_value(member.get("handle") or member.get("uid")), + _normalize_value(member.get("credits"), fallback="0"), + _normalize_value(member.get("active_reservations"), fallback="0"), + _normalize_value(member.get("burning_rate_per_hour"), fallback="0"), + _normalize_value(member.get("estimated_hours_to_depletion"), fallback="n/a"), + ) + console.print(members_table) + + if recommendations: + rec_table = Table(title="Recommendations") + rec_table.add_column("Severity", style="cyan") + rec_table.add_column("Account", style="white") + rec_table.add_column("Message", style="white") + for rec in recommendations: + rec_table.add_row( + _normalize_value(rec.get("severity")), + _normalize_value(rec.get("account_uid")), + _normalize_value(rec.get("message")), + ) + console.print(rec_table) + except Exception as e: + console.print(f"[red]Error fetching team monitoring: {e}[/red]") + raise typer.Exit(1) + + +@app.command(name="org-allocate-team") +def usage_org_allocate_team( + organization_uid: str = typer.Option(..., "--organization-uid", help="Organization UID."), + team_uid: str = typer.Option(..., "--team-uid", help="Team UID."), + amount: float = typer.Option(..., "--amount", help="Amount of credits to allocate."), + token: Optional[str] = typer.Option(None, "--token", help="Authentication token."), +) -> None: + """Allocate credits from organization to team.""" + try: + client = DatalayerClient(token=token) + response = _iam_post( + client, + f"/api/iam/v1/usage/credits/allocations/organizations/{organization_uid}/teams/{team_uid}", + {"amount": amount}, + ) + if not response.get("success", True): + console.print(f"[red]Error: {response.get('message', 'Unknown error')}[/red]") + raise typer.Exit(1) + console.print("[green]Credits allocated from organization to team.[/green]") + console.print(response.get("transfer") or response) + except Exception as e: + console.print(f"[red]Error allocating credits: {e}[/red]") + raise typer.Exit(1) + + +@app.command(name="org-revoke-team") +def usage_org_revoke_team( + organization_uid: str = typer.Option(..., "--organization-uid", help="Organization UID."), + team_uid: str = typer.Option(..., "--team-uid", help="Team UID."), + amount: float = typer.Option(..., "--amount", help="Amount of credits to revoke."), + token: Optional[str] = typer.Option(None, "--token", help="Authentication token."), +) -> None: + """Revoke credits from team back to organization.""" + try: + client = DatalayerClient(token=token) + response = _iam_post( + client, + f"/api/iam/v1/usage/credits/allocations/organizations/{organization_uid}/teams/{team_uid}/revoke", + {"amount": amount}, + ) + if not response.get("success", True): + console.print(f"[red]Error: {response.get('message', 'Unknown error')}[/red]") + raise typer.Exit(1) + console.print("[green]Credits revoked from team to organization.[/green]") + console.print(response.get("transfer") or response) + except Exception as e: + console.print(f"[red]Error revoking credits: {e}[/red]") + raise typer.Exit(1) + + +@app.command(name="team-allocate-member") +def usage_team_allocate_member( + team_uid: str = typer.Option(..., "--team-uid", help="Team UID."), + member_uid: str = typer.Option(..., "--member-uid", help="Member UID."), + amount: float = typer.Option(..., "--amount", help="Amount of credits to allocate."), + token: Optional[str] = typer.Option(None, "--token", help="Authentication token."), +) -> None: + """Allocate credits from team to member.""" + try: + client = DatalayerClient(token=token) + response = _iam_post( + client, + f"/api/iam/v1/usage/credits/allocations/teams/{team_uid}/members/{member_uid}", + {"amount": amount}, + ) + if not response.get("success", True): + console.print(f"[red]Error: {response.get('message', 'Unknown error')}[/red]") + raise typer.Exit(1) + console.print("[green]Credits allocated from team to member.[/green]") + console.print(response.get("transfer") or response) + except Exception as e: + console.print(f"[red]Error allocating credits: {e}[/red]") + raise typer.Exit(1) + + +@app.command(name="team-revoke-member") +def usage_team_revoke_member( + team_uid: str = typer.Option(..., "--team-uid", help="Team UID."), + member_uid: str = typer.Option(..., "--member-uid", help="Member UID."), + amount: float = typer.Option(..., "--amount", help="Amount of credits to revoke."), + token: Optional[str] = typer.Option(None, "--token", help="Authentication token."), +) -> None: + """Revoke credits from member back to team.""" + try: + client = DatalayerClient(token=token) + response = _iam_post( + client, + f"/api/iam/v1/usage/credits/allocations/teams/{team_uid}/members/{member_uid}/revoke", + {"amount": amount}, + ) + if not response.get("success", True): + console.print(f"[red]Error: {response.get('message', 'Unknown error')}[/red]") + raise typer.Exit(1) + console.print("[green]Credits revoked from member to team.[/green]") + console.print(response.get("transfer") or response) + except Exception as e: + console.print(f"[red]Error revoking credits: {e}[/red]") + raise typer.Exit(1) + + # Root-level command for convenience diff --git a/datalayer_core/client/client.py b/datalayer_core/client/client.py index e4f0ad6a..a1f59033 100644 --- a/datalayer_core/client/client.py +++ b/datalayer_core/client/client.py @@ -154,6 +154,55 @@ def get_usage_credits(self) -> dict[str, Any]: """ return self._get_usage_credits() + def get_subscription(self) -> dict[str, Any]: + """ + Get current subscription information. + + Returns + ------- + dict[str, Any] + Subscription response payload. + """ + return self._get_subscription() + + def cancel_subscription(self) -> dict[str, Any]: + """ + Start cancellation flow for current subscription. + + Returns + ------- + dict[str, Any] + Cancellation response payload. + """ + return self._cancel_subscription() + + def get_subscription_plans(self) -> dict[str, Any]: + """ + Get available monthly subscription plans. + + Returns + ------- + dict[str, Any] + Subscription plans response payload. + """ + return self._get_subscription_plans() + + def create_checkout_portal(self, return_url: str) -> dict[str, Any]: + """ + Create a checkout portal session. + + Parameters + ---------- + return_url : str + URL to return to after checkout operations. + + Returns + ------- + dict[str, Any] + Checkout portal response payload. + """ + return self._create_checkout_portal(return_url) + @lru_cache def list_environments(self) -> list[EnvironmentModel]: """ diff --git a/datalayer_core/mixins/usage.py b/datalayer_core/mixins/usage.py index be08a125..80bc8f43 100644 --- a/datalayer_core/mixins/usage.py +++ b/datalayer_core/mixins/usage.py @@ -25,3 +25,79 @@ def _get_usage_credits(self) -> dict[str, Any]: return response.json() except RuntimeError as e: return {"success": False, "message": str(e)} + + def _get_subscription(self) -> dict[str, Any]: + """ + Fetch subscription status and portal details. + + Returns + ------- + dict[str, Any] + Dictionary containing subscription data. + """ + try: + response = self._fetch( # type: ignore + "{}/api/iam/v1/subscription".format(self.urls.iam_url), # type: ignore + ) + return response.json() + except RuntimeError as e: + return {"success": False, "message": str(e)} + + def _cancel_subscription(self) -> dict[str, Any]: + """ + Start cancellation flow for current subscription. + + Returns + ------- + dict[str, Any] + Dictionary containing cancellation response and optional portal. + """ + try: + response = self._fetch( # type: ignore + "{}/api/iam/v1/subscription/cancel".format(self.urls.iam_url), # type: ignore + method="POST", + ) + return response.json() + except RuntimeError as e: + return {"success": False, "message": str(e)} + + def _get_subscription_plans(self) -> dict[str, Any]: + """ + Fetch available monthly subscription plans. + + Returns + ------- + dict[str, Any] + Dictionary containing plans data. + """ + try: + response = self._fetch( # type: ignore + "{}/api/iam/v1/subscription/plans".format(self.urls.iam_url), # type: ignore + ) + return response.json() + except RuntimeError as e: + return {"success": False, "message": str(e)} + + def _create_checkout_portal(self, return_url: str) -> dict[str, Any]: + """ + Create a checkout portal session. + + Parameters + ---------- + return_url : str + URL to return to after checkout operations. + + Returns + ------- + dict[str, Any] + Dictionary containing checkout portal response. + """ + try: + response = self._fetch( # type: ignore + "{}/api/iam/v1/checkout/portal".format(self.urls.iam_url), # type: ignore + method="POST", + json={"return_url": return_url}, + ) + return response.json() + except RuntimeError as e: + return {"success": False, "message": str(e)} diff --git a/datalayer_core/models/iam.py b/datalayer_core/models/iam.py index 51c8d0eb..ec09cbb5 100644 --- a/datalayer_core/models/iam.py +++ b/datalayer_core/models/iam.py @@ -395,6 +395,10 @@ class MembershipModel(BaseModel): members: Optional[List[Dict[str, Any]]] = Field( None, description="Members (if included)" ) + roles_ss: List[str] = Field( + default_factory=list, + description="Membership roles (organization/team)", + ) @classmethod def from_solr(cls, solr_doc: Dict[str, Any]) -> "MembershipModel": @@ -419,6 +423,7 @@ def from_solr(cls, solr_doc: Dict[str, Any]) -> "MembershipModel": organization_uid=solr_doc.get("organization_uid"), public=solr_doc.get("public_b"), members=members, + roles_ss=solr_doc.get("roles_ss", []), ) @@ -463,6 +468,10 @@ class ReservationData(BaseModel): id: str = Field(..., description="Reservation ID") account_uid: str = Field(..., description="Account UID") + billing_account_uid: Optional[str] = Field( + None, + description="Account UID charged for the reservation cost", + ) credits: float = Field(..., description="Reserved credits") resource: str = Field(..., description="Resource identifier") resource_type: str = Field(..., description="Resource type") @@ -475,6 +484,10 @@ class UsageData(BaseModel): """Usage data model.""" account_uid: str = Field(..., description="Account UID") + billing_account_uid: Optional[str] = Field( + None, + description="Account UID charged for the usage cost", + ) resource_uid: str = Field(..., description="Resource UID") resource_type: str = Field(..., description="Resource type") resource_given_name: str = Field(..., description="Resource given name") @@ -523,6 +536,10 @@ class CheckoutPortalRequest(BaseModel): """Checkout portal request model.""" return_url: str = Field(..., description="Return URL after checkout") + account_uid: Optional[str] = Field( + None, + description="Optional target account UID for account-scoped checkout portal", + ) class CheckoutPortalModel(BaseModel): diff --git a/examples/otel/app/main.py b/examples/otel/app/main.py index d4058a3f..23ead008 100644 --- a/examples/otel/app/main.py +++ b/examples/otel/app/main.py @@ -68,14 +68,14 @@ def _extract_token(request: Request) -> Optional[str]: def home() -> dict: """Welcome page.""" return { - "message": "Datalayer OTEL Example – POST /api/generate/{traces,logs,metrics} to create signals", + "message": "Datalayer OTEL Example – POST /api/otel/v1/generate/{traces,logs,metrics} to create signals", } # ── Signal generators ─────────────────────────────────────────────── -@app.post("/api/generate/traces") +@app.post("/api/otel/v1/generate/traces") def gen_traces(request: Request, count: int = Query(3, ge=1, le=50)) -> dict: """Generate *count* sample traces with nested spans and send them via OTLP.""" generate_sample_traces(count, token=_extract_token(request)) @@ -83,7 +83,7 @@ def gen_traces(request: Request, count: int = Query(3, ge=1, le=50)) -> dict: return {"status": "ok", "generated_traces": count} -@app.post("/api/generate/ai-traces") +@app.post("/api/otel/v1/generate/ai-traces") def gen_ai_traces(request: Request, count: int = Query(3, ge=1, le=50)) -> dict: """Generate *count* pydantic-ai / logfire-style nested agent traces.""" generate_pydantic_ai_traces(count, token=_extract_token(request)) @@ -91,7 +91,7 @@ def gen_ai_traces(request: Request, count: int = Query(3, ge=1, le=50)) -> dict: return {"status": "ok", "generated_ai_traces": count} -@app.post("/api/generate/logs") +@app.post("/api/otel/v1/generate/logs") def gen_logs(request: Request, count: int = Query(10, ge=1, le=200)) -> dict: """Generate *count* sample log records and send them via OTLP.""" generate_sample_logs(count, token=_extract_token(request)) @@ -99,7 +99,7 @@ def gen_logs(request: Request, count: int = Query(10, ge=1, le=200)) -> dict: return {"status": "ok", "generated_logs": count} -@app.post("/api/generate/metrics") +@app.post("/api/otel/v1/generate/metrics") def gen_metrics(request: Request, count: int = Query(5, ge=1, le=100)) -> dict: """Generate *count* sample metric data-points and send them via OTLP.""" generate_sample_metrics(count, token=_extract_token(request)) diff --git a/examples/otel/ui/vite.config.ts b/examples/otel/ui/vite.config.ts index 88ae1237..ef77a3a2 100644 --- a/examples/otel/ui/vite.config.ts +++ b/examples/otel/ui/vite.config.ts @@ -97,7 +97,7 @@ export default defineConfig(({ command }: ConfigEnv) => { port: 5173, proxy: { // Signal generators → local FastAPI backend (port 8600). - '/api/generate': { + '/api/otel/v1/generate': { target: 'http://localhost:8600', changeOrigin: true, }, diff --git a/package.json b/package.json index 4f1bb3d7..8f72a22e 100644 --- a/package.json +++ b/package.json @@ -133,8 +133,8 @@ "@primer/primitives": "^10.5.0", "@primer/react": "^37.19.0", "@primer/react-brand": "^0.58.0", - "@stripe/react-stripe-js": "^2.7.1", - "@stripe/stripe-js": "^4.0.0", + "@stripe/react-stripe-js": "^6.3.0", + "@stripe/stripe-js": "^9.4.0", "@styled-system/css": "^5.1.5", "@tailwindcss/vite": "^4.1.13", "@tanstack/react-query": "^5.90.6", diff --git a/src/api/DatalayerApi.ts b/src/api/DatalayerApi.ts index 978ac161..65420a6f 100644 --- a/src/api/DatalayerApi.ts +++ b/src/api/DatalayerApi.ts @@ -14,6 +14,35 @@ import { URLExt } from '@jupyterlab/coreutils'; import axios, { AxiosRequestConfig } from 'axios'; import { sleep } from '../utils/Sleep'; +function isFormDataBody(body: unknown): body is FormData { + if (!body || typeof body !== 'object') { + return false; + } + + // `instanceof FormData` is not reliable across realms (e.g. jsdom/undici). + if (typeof FormData !== 'undefined' && body instanceof FormData) { + return true; + } + + const formDataTag = Object.prototype.toString.call(body); + if (formDataTag === '[object FormData]') { + return true; + } + + const candidate = body as { + append?: unknown; + get?: unknown; + has?: unknown; + entries?: unknown; + }; + return ( + typeof candidate.append === 'function' && + typeof candidate.get === 'function' && + typeof candidate.has === 'function' && + typeof candidate.entries === 'function' + ); +} + /** * Error wrapper for failed HTTP responses. * Includes response details, warnings, errors, and tracebacks. @@ -160,7 +189,7 @@ export async function requestDatalayerAPI({ headers = {}, }: IRequestDatalayerAPIOptions): Promise { // Handle FormData differently from JSON - const isFormData = body instanceof FormData; + const isFormData = isFormDataBody(body); // Prepare axios config const axiosConfig: AxiosRequestConfig = { diff --git a/src/client/mixins/SpacerMixin.ts b/src/client/mixins/SpacerMixin.ts index 170feeb6..ae20b521 100644 --- a/src/client/mixins/SpacerMixin.ts +++ b/src/client/mixins/SpacerMixin.ts @@ -621,8 +621,37 @@ export function SpacerMixin(Base: TBase) { // Use getMySpaces and filter by variant, because the // /spaces/types/project endpoint returns empty results. const allSpaces = await this.getMySpaces(); + + const isProjectSpace = (space: SpaceDTO): boolean => { + const raw = space.rawData() as any; + const variant = raw?.variant_s ?? space.variant; + if (variant !== 'project') { + return false; + } + + const items = Array.isArray(raw?.items) + ? raw.items + : raw?.items && typeof raw.items === 'object' + ? [raw.items] + : []; + + if (items.length === 0) { + return true; + } + + const hasNotebook = items.some( + (item: any) => (item?.type_s ?? item?.type) === 'notebook', + ); + const hasDocument = items.some((item: any) => { + const type = item?.type_s ?? item?.type; + return type === 'document' || type === 'lexical'; + }); + + return hasNotebook && hasDocument; + }; + return allSpaces - .filter(s => s.variant === 'project') + .filter(s => isProjectSpace(s)) .map(s => new ProjectDTO(s.rawData())); } diff --git a/src/collaboration/DatalayerCollaborationProvider.ts b/src/collaboration/DatalayerCollaborationProvider.ts index 3bd71ad7..5cbd2676 100644 --- a/src/collaboration/DatalayerCollaborationProvider.ts +++ b/src/collaboration/DatalayerCollaborationProvider.ts @@ -3,7 +3,6 @@ * Distributed under the terms of the Modified BSD License. */ -import { YNotebook } from '@jupyter/ydoc'; import { WebsocketProvider } from 'y-websocket'; import { URLExt } from '@jupyterlab/coreutils'; import { Signal } from '@lumino/signaling'; @@ -21,6 +20,9 @@ export enum CollaborationStatus { } import { requestDatalayerCollaborationSessionId } from './DatalayerCollaboration'; +type SharedModel = Parameters[0]; +type ConnectOptions = Parameters[2]; + /** * Configuration for Datalayer collaboration provider */ @@ -50,10 +52,13 @@ export class DatalayerCollaborationProvider implements ICollaborationProvider { private _status: CollaborationStatus = CollaborationStatus.Disconnected; private _provider: WebsocketProvider | null = null; - private _sharedModel: YNotebook | null = null; - private _statusChanged = new Signal(this); - private _errorOccurred = new Signal(this); - private _syncStateChanged = new Signal(this); + private _sharedModel: SharedModel | null = null; + private _statusChanged = new Signal< + ICollaborationProvider, + CollaborationStatus + >(this); + private _errorOccurred = new Signal(this); + private _syncStateChanged = new Signal(this); private _isDisposed = false; private _config: IDatalayerCollaborationConfig; @@ -93,9 +98,9 @@ export class DatalayerCollaborationProvider implements ICollaborationProvider { } async connect( - sharedModel: YNotebook, + sharedModel: SharedModel, documentId: string, - options?: Record, + options?: ConnectOptions, ): Promise { if (this.isConnected) { console.warn('Already connected to Datalayer collaboration service'); @@ -191,7 +196,7 @@ export class DatalayerCollaborationProvider implements ICollaborationProvider { return this._provider; } - getSharedModel(): YNotebook | null { + getSharedModel(): SharedModel | null { return this._sharedModel; } diff --git a/src/components/checkout/StripeCheckout.tsx b/src/components/checkout/StripeCheckout.tsx index 56aa321a..5c10829b 100644 --- a/src/components/checkout/StripeCheckout.tsx +++ b/src/components/checkout/StripeCheckout.tsx @@ -3,10 +3,31 @@ * Distributed under the terms of the Modified BSD License. */ -import { createElement, useCallback, useEffect, useState } from 'react'; -import { Button, Flash, FormControl, Spinner, Text } from '@primer/react'; +import { + createElement, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type FormEvent, +} from 'react'; +import { + CardElement, + Elements, + useElements, + useStripe, +} from '@stripe/react-stripe-js'; +import { + Button, + Flash, + FormControl, + Spinner, + Text, + useTheme, +} from '@primer/react'; import { Box } from '@datalayer/primer-addons'; -import type { Stripe } from '@stripe/stripe-js'; +import type { Stripe, StripeElementsOptions } from '@stripe/stripe-js'; import { useCache } from '../../hooks'; import type { ICheckoutPortal } from '../../models'; @@ -36,40 +57,291 @@ export interface IPrice { credits: number; } +export interface ISubscriptionPlan { + id: string; + name: string; + amount: number; + currency: string; + interval?: string; + included_runs?: number; +} + +export type StripeCheckoutProps = { + checkoutPortal: ICheckoutPortal | null; + appearance?: StripeElementsOptions['appearance']; + accountUid?: string; +}; + +const PLAN_INCLUDED_RUNS_DEFAULTS: Record = { + starter: 1000, + free: 1000, + team: 5000, + pro: 5000, + enterprise: 50000, +}; + +const asNumber = (value: unknown): number | null => { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +}; + +const asPositiveNumber = (value: unknown): number | null => { + const parsed = asNumber(value); + return parsed !== null && parsed > 0 ? parsed : null; +}; + +const asNonNegativeNumber = (value: unknown): number | null => { + const parsed = asNumber(value); + return parsed !== null && parsed >= 0 ? parsed : null; +}; + +type StripePaymentFormProps = { + clientSecret: string; + intentType?: 'payment' | 'setup'; + onPaymentSucceeded: () => void; +}; + +function StripePaymentForm({ + clientSecret, + intentType = 'payment', + onPaymentSucceeded, +}: StripePaymentFormProps) { + const stripe = useStripe(); + const elements = useElements(); + const { colorScheme, resolvedColorScheme } = useTheme(); + const [isSubmitting, setIsSubmitting] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + + const themeIsDark = useMemo(() => { + const scheme = (resolvedColorScheme || colorScheme || '').toLowerCase(); + if (scheme.includes('dark')) { + return true; + } + if (scheme.includes('light')) { + return false; + } + return false; + }, [colorScheme, resolvedColorScheme]); + + const cardOptions = useMemo(() => { + const rootStyles = + typeof window !== 'undefined' + ? getComputedStyle(document.documentElement) + : null; + const detectDarkMode = () => { + if (themeIsDark) { + return true; + } + if (typeof window === 'undefined' || typeof document === 'undefined') { + return false; + } + const candidates: (Element | null)[] = [ + document.documentElement, + document.body, + ]; + for (const el of candidates) { + const mode = el?.getAttribute('data-color-mode'); + if (mode === 'dark') { + return true; + } + if (mode === 'light') { + return false; + } + } + for (const el of candidates) { + const darkTheme = el?.getAttribute('data-dark-theme'); + if ( + darkTheme && + darkTheme !== 'light' && + (el?.getAttribute('data-color-mode') || 'auto') === 'auto' && + window.matchMedia('(prefers-color-scheme: dark)').matches + ) { + return true; + } + } + return window.matchMedia('(prefers-color-scheme: dark)').matches; + }; + const isDarkMode = detectDarkMode(); + const fontFamily = + rootStyles?.getPropertyValue('--base-text-font-family')?.trim() || + 'system-ui, -apple-system, Segoe UI, sans-serif'; + // Stripe's CardElement renders in an iframe that does not inherit our CSS + // variables, so reading them from documentElement does not always reflect + // the active theme. Use explicit dark/light values so typed characters are + // always readable. + const baseColor = isDarkMode ? '#f0f6fc' : '#1f2328'; + const mutedColor = isDarkMode ? '#8b949e' : '#59636e'; + return { + style: { + base: { + color: baseColor, + iconColor: mutedColor, + fontFamily, + fontSize: '16px', + '::placeholder': { + color: mutedColor, + }, + ':-webkit-autofill': { + color: baseColor, + }, + }, + invalid: { + color: '#d1242f', + iconColor: '#d1242f', + }, + }, + }; + }, [themeIsDark]); + + const handleSubmit = useCallback( + async (event: FormEvent) => { + event.preventDefault(); + + if (!stripe || !elements) { + return; + } + + const card = elements.getElement(CardElement); + if (!card) { + setErrorMessage('Payment form is not ready yet. Please try again.'); + return; + } + + setIsSubmitting(true); + setErrorMessage(null); + + if (intentType === 'setup') { + const result = await stripe.confirmCardSetup(clientSecret, { + payment_method: { + card, + }, + }); + + if (result.error) { + setErrorMessage( + result.error.message || 'Payment failed. Please try again.', + ); + setIsSubmitting(false); + return; + } + + if (result.setupIntent?.status === 'succeeded') { + onPaymentSucceeded(); + } + } else { + const result = await stripe.confirmCardPayment(clientSecret, { + payment_method: { + card, + }, + }); + + if (result.error) { + setErrorMessage( + result.error.message || 'Payment failed. Please try again.', + ); + setIsSubmitting(false); + return; + } + + if (result.paymentIntent?.status === 'succeeded') { + onPaymentSucceeded(); + } + } + + setIsSubmitting(false); + }, + [clientSecret, elements, onPaymentSucceeded, stripe], + ); + + return ( + + { + try { + element.focus(); + } catch { + // no-op: focus may fail if the element is unmounted. + } + }} + /> + {errorMessage && {errorMessage}} + + + ); +} + /** * Stripe checkout. */ export function StripeCheckout({ checkoutPortal, -}: { - checkoutPortal: ICheckoutPortal | null; -}) { - const { useCreateCheckoutSession, useStripePrices } = useCache(); + appearance, + accountUid, +}: StripeCheckoutProps) { + const { + useCreateTopUpPaymentIntent, + useCreateSubscriptionPaymentIntent, + useCreateResumeSetupIntent, + useSubscriptionPlans, + useTopUpPrices, + useSubscriptionStatus, + useCancelSubscription, + useResumeSubscription, + } = useCache(); const [stripe, setStripe] = useState | null>(null); - const [components, setComponents] = useState(null); - const [items, setItems] = useState(null); + const [paymentClientSecret, setPaymentClientSecret] = useState( + null, + ); const [product, setProduct] = useState(null); + const [subscriptionPlan, setSubscriptionPlan] = + useState(null); const [checkout, setCheckout] = useState(false); + const [checkoutType, setCheckoutType] = useState< + 'topup' | 'subscription' | 'resume' + >('topup'); + const [cancelViewOpen, setCancelViewOpen] = useState(false); + const [paymentMessage, setPaymentMessage] = useState(null); // Get Stripe prices using TanStack Query hook - const { data: pricesData } = useStripePrices(); + const { data: pricesData } = useTopUpPrices(); + const items = (pricesData as IPrice[] | undefined) ?? null; + const sortedTopUpItems = useMemo( + () => + [...(items ?? [])].sort( + (left, right) => Number(left.amount || 0) - Number(right.amount || 0), + ), + [items], + ); + const { data: plansData } = useSubscriptionPlans({ accountUid }); + const plans = useMemo( + () => (plansData as ISubscriptionPlan[] | undefined) ?? [], + [plansData], + ); - // Update items when prices data changes - useEffect(() => { - if (pricesData) { - setItems(pricesData as IPrice[]); - } - }, [pricesData]); + const { + data: subscriptionResp, + refetch: refetchSubscriptionStatus, + isFetching: isSubscriptionStatusRefreshing, + } = useSubscriptionStatus({ accountUid }); + const cancelSubscriptionMutation = useCancelSubscription({ accountUid }); + const resumeSubscriptionMutation = useResumeSubscription({ accountUid }); // Get checkout session mutation - const checkoutSessionMutation = useCreateCheckoutSession(); + const topUpPaymentIntentMutation = useCreateTopUpPaymentIntent({ + accountUid, + }); + const subscriptionPaymentIntentMutation = useCreateSubscriptionPaymentIntent({ + accountUid, + }); + const resumeSetupIntentMutation = useCreateResumeSetupIntent({ accountUid }); - // Load stripe components. - useEffect(() => { - import('@stripe/react-stripe-js').then(module => { - setComponents(module); - }); - }, []); // Load stripe API useEffect(() => { if (checkoutPortal?.metadata?.stripe_key) { @@ -79,32 +351,995 @@ export function StripeCheckout({ } }, [checkoutPortal?.metadata?.stripe_key]); - const fetchClientSecret = useCallback(async () => { - const location = document.location; - // Create a Checkout Session using TanStack Query mutation - const result = await checkoutSessionMutation.mutateAsync({ - product, - location, - }); - return result; - }, [checkoutSessionMutation, product]); - const options = { fetchClientSecret }; + const paymentOptions = useMemo(() => { + if (!paymentClientSecret) { + return null; + } + + return { + clientSecret: paymentClientSecret, + ...(appearance ? { appearance } : {}), + }; + }, [appearance, paymentClientSecret]); + + const elementsAppearanceKey = useMemo(() => { + if (!appearance) { + return 'default'; + } + try { + return JSON.stringify(appearance); + } catch { + return String(appearance?.theme ?? 'default'); + } + }, [appearance]); + + const onPaymentSucceeded = useCallback(async () => { + setCheckout(false); + setPaymentClientSecret(null); + setProduct(null); + setSubscriptionPlan(null); + setPaymentMessage(null); + if (checkoutType === 'resume') { + try { + const resp = await resumeSubscriptionMutation.mutateAsync(); + setPaymentMessage( + resp?.message || + 'Payment confirmed and subscription resumed successfully.', + ); + } catch (error) { + setPaymentMessage( + error instanceof Error + ? error.message + : 'Payment confirmed, but unable to resume subscription right now.', + ); + } + return; + } + if (checkoutType === 'subscription') { + // Poll subscription status briefly so the UI flips to paid state as soon + // as Stripe + backend snapshot are ready. + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + await refetchSubscriptionStatus(); + } catch { + // Keep the success message even if refresh fails transiently. + } + if (attempt < 4) { + await new Promise(resolve => setTimeout(resolve, 1200)); + } + } + setPaymentMessage( + 'Subscription payment confirmed. Your plan status may take a few seconds to refresh.', + ); + } else { + setPaymentMessage( + 'Payment confirmed. Credits update may take a few seconds.', + ); + } + }, [checkoutType, refetchSubscriptionStatus, resumeSubscriptionMutation]); + + const subscription = subscriptionResp?.subscription || null; + const availablePlans = useMemo(() => { + const byId = new Map(); + const add = (plan: any) => { + const id = plan?.id; + if (!id || typeof id !== 'string') { + return; + } + byId.set(id, { + id, + name: String(plan?.name || id), + amount: Number(plan?.amount || 0), + currency: String(plan?.currency || 'usd'), + interval: plan?.interval, + included_runs: + typeof plan?.included_runs === 'number' + ? plan.included_runs + : undefined, + }); + }; + plans.forEach(add); + (subscriptionResp?.available_subscriptions || []).forEach(add); + return Array.from(byId.values()); + }, [plans, subscriptionResp?.available_subscriptions]); + + const subscriptionStatus = + subscription?.status || subscription?.subscription_status || 'unknown'; + const normalizedSubscriptionStatus = String(subscriptionStatus).toLowerCase(); + const isPendingSubscriptionCheckout = + normalizedSubscriptionStatus === 'incomplete'; + const rawCurrentSubscriptionPlan = isPendingSubscriptionCheckout + ? 'Free' + : subscription?.plan_name || + subscription?.plan?.name || + subscription?.plan || + 'Free'; + const currentSubscriptionPlan = useMemo(() => { + const raw = String(rawCurrentSubscriptionPlan || 'Free'); + const byId = availablePlans.find(plan => plan.id === raw); + const resolved = byId?.name || raw; + // Always present plan names with a trailing " Plan" for consistency with + // other surfaces (e.g. /settings/subscription/overview). + return /\bplan$/i.test(resolved) ? resolved : `${resolved} Plan`; + }, [availablePlans, rawCurrentSubscriptionPlan]); + const currentPlanDetails = useMemo(() => { + const byPlanId = availablePlans.find( + plan => plan.id && plan.id === subscription?.plan_id, + ); + if (byPlanId) { + return byPlanId; + } + + const normalizedCurrentPlanName = String( + currentSubscriptionPlan, + ).toLowerCase(); + return ( + availablePlans.find( + plan => + plan.name && + normalizedCurrentPlanName.includes(plan.name.toLowerCase()), + ) || null + ); + }, [availablePlans, currentSubscriptionPlan, subscription?.plan_id]); + const currentPlanPriceLabel = useMemo(() => { + if (!currentPlanDetails) { + return 'N/A'; + } + const amount = Number(currentPlanDetails.amount || 0); + const currency = currentPlanDetails.currency || 'usd'; + const formatted = new Intl.NumberFormat(undefined, { + style: 'currency', + currency, + }).format(amount / 100); + return `${formatted}${currentPlanDetails.interval ? ` / ${currentPlanDetails.interval}` : ''}`; + }, [currentPlanDetails]); + const subscriptionPeriodStartLabel = useMemo(() => { + const rawStart = + subscription?.current_period_start || + subscription?.subscription_period_start_ts_dt; + if (!rawStart) { + return 'N/A'; + } + const parsed = new Date(rawStart); + if (Number.isNaN(parsed.getTime())) { + return String(rawStart); + } + return new Intl.DateTimeFormat(undefined, { + year: 'numeric', + month: 'short', + day: '2-digit', + }).format(parsed); + }, [ + subscription?.current_period_start, + subscription?.subscription_period_start_ts_dt, + ]); + const subscriptionPeriodEndLabel = useMemo(() => { + const rawEnd = + subscription?.current_period_end || + subscription?.subscription_period_end_ts_dt; + if (!rawEnd) { + return 'N/A'; + } + const parsed = new Date(rawEnd); + if (Number.isNaN(parsed.getTime())) { + return String(rawEnd); + } + return new Intl.DateTimeFormat(undefined, { + year: 'numeric', + month: 'short', + day: '2-digit', + }).format(parsed); + }, [ + subscription?.current_period_end, + subscription?.subscription_period_end_ts_dt, + ]); + const subscriptionPortalUrl = + subscriptionResp?.portal?.url || checkoutPortal?.url; + const isCancellationScheduled = + Boolean(subscription?.cancel_at_period_end) || + Boolean(subscription?.subscription_cancel_at_period_end_b); + const isIncompleteSubscription = + normalizedSubscriptionStatus === 'incomplete'; + const displaySubscriptionStatus = isCancellationScheduled + ? 'cancelled' + : normalizedSubscriptionStatus && normalizedSubscriptionStatus !== 'unknown' + ? String(subscriptionStatus).replaceAll('_', ' ') + : null; + const normalizedPlanName = String(currentSubscriptionPlan).toLowerCase(); + const planIncludedRuns = asPositiveNumber( + plans.find( + plan => plan.name && normalizedPlanName.includes(plan.name.toLowerCase()), + )?.included_runs, + ); + const defaultIncludedRuns = + planIncludedRuns || + Object.entries(PLAN_INCLUDED_RUNS_DEFAULTS).find(([planKey]) => + normalizedPlanName.includes(planKey), + )?.[1] || + null; + const resolvedIncludedRuns = + asPositiveNumber(subscription?.included_runs) || + asPositiveNumber(subscription?.plan?.included_runs) || + asPositiveNumber(subscription?.metadata?.included_runs) || + defaultIncludedRuns; + const usedRuns = + asNonNegativeNumber(subscription?.used_runs) || + asNonNegativeNumber(subscription?.usage?.used_runs) || + asNonNegativeNumber(subscription?.metadata?.used_runs) || + 0; + const remainingRuns = + resolvedIncludedRuns === null + ? null + : Math.max(0, (resolvedIncludedRuns ?? 0) - usedRuns); + + const hasBillablePlan = useMemo(() => { + const normalizedPlan = String(currentSubscriptionPlan).toLowerCase(); + const freeLike = + normalizedPlan.includes('free') || + normalizedPlan.includes('trial') || + normalizedPlan === 'unknown' || + normalizedPlan === 'none'; + return !freeLike; + }, [currentSubscriptionPlan]); + + const isPaidSubscription = useMemo(() => { + const normalizedStatus = String(subscriptionStatus).toLowerCase(); + if (!hasBillablePlan) { + return false; + } + if ( + normalizedStatus.includes('incomplete') || + normalizedStatus.includes('canceled') || + normalizedStatus.includes('cancelled') || + normalizedStatus.includes('free') || + normalizedStatus === 'unknown' + ) { + return false; + } + return ['active', 'trialing', 'past_due', 'paid'].includes( + normalizedStatus, + ); + }, [hasBillablePlan, subscriptionStatus]); + + const canCancelSubscription = useMemo(() => { + if (!hasBillablePlan) { + return false; + } + + const status = String(subscriptionStatus).toLowerCase(); + const nonCancelable = + isCancellationScheduled || + status.includes('incomplete_expired') || + status.includes('canceled') || + status.includes('cancelled') || + status.includes('free') || + status === 'unknown'; + + return !nonCancelable; + }, [hasBillablePlan, subscriptionStatus, isCancellationScheduled]); + + const hasTopUpAccess = useMemo(() => { + const normalizedPlan = String(currentSubscriptionPlan).toLowerCase(); + const normalizedStatus = String(subscriptionStatus).toLowerCase(); + const freeLike = + normalizedPlan.includes('free') || + normalizedPlan.includes('trial') || + normalizedStatus.includes('free') || + normalizedStatus.includes('canceled') || + normalizedStatus.includes('cancelled'); + if (freeLike) { + return false; + } + return ['active', 'trialing', 'paid', 'past_due'].includes( + normalizedStatus, + ); + }, [currentSubscriptionPlan, subscriptionStatus]); + + // Temporary business override: allow top-up even when monthly subscription + // is not active, while still encouraging an upgrade path. + const isTemporaryFreeTopUpAllowed = true; + const canBuyTopUp = + hasTopUpAccess || (!isPaidSubscription && isTemporaryFreeTopUpAllowed); + + useEffect(() => { + if (isPaidSubscription && paymentMessage) { + setPaymentMessage(null); + } + }, [isPaidSubscription, paymentMessage]); + + useEffect(() => { + if (!isPaidSubscription && !subscriptionPlan && plans.length > 0) { + setSubscriptionPlan(plans[0]); + } + }, [isPaidSubscription, plans, subscriptionPlan]); + + // Auto-open the in-app cancel/downgrade view when the page is opened with + // `?action=downgrade` (e.g. from the Subscription Overview "Downgrade" CTA). + // When opened with `?action=resume`, immediately trigger the resume flow. + const autoActionTriggeredRef = useRef(false); + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + if (autoActionTriggeredRef.current) { + return; + } + try { + const params = new URLSearchParams(window.location.search); + const action = params.get('action'); + if (action === 'downgrade' && isPaidSubscription) { + autoActionTriggeredRef.current = true; + setCancelViewOpen(true); + } else if (action === 'resume' && isCancellationScheduled) { + autoActionTriggeredRef.current = true; + void onResumeSubscription(); + } + } catch (_error) { + // Ignore malformed URLs. + } + }, [isPaidSubscription, isCancellationScheduled]); + + const startCheckout = useCallback(async () => { + if (!product || !canBuyTopUp) { + if (!canBuyTopUp) { + setPaymentMessage( + 'Monthly subscription required before buying top-up credits.', + ); + } + return; + } + setPaymentMessage(null); + setCheckoutType('topup'); + setCheckout(true); + try { + const clientSecret = await topUpPaymentIntentMutation.mutateAsync({ + product, + }); + if (!clientSecret) { + setPaymentClientSecret(null); + setCheckout(false); + setPaymentMessage( + 'Unable to initialize Stripe checkout. Please try again.', + ); + return; + } + setPaymentClientSecret(clientSecret); + } catch (error) { + const detail = + error instanceof Error + ? error.message + : 'Unable to initialize Stripe checkout. Please try again.'; + setPaymentClientSecret(null); + setCheckout(false); + setPaymentMessage(detail); + } + }, [topUpPaymentIntentMutation, canBuyTopUp, product]); + + const startSubscriptionCheckout = useCallback( + async (planOverride?: ISubscriptionPlan | null) => { + const selectedPlan = planOverride ?? subscriptionPlan; + if (!selectedPlan) { + setPaymentMessage('Select a monthly subscription plan first.'); + return; + } + setPaymentMessage(null); + try { + const clientSecret = + await subscriptionPaymentIntentMutation.mutateAsync({ + plan: selectedPlan, + }); + if (!clientSecret) { + setPaymentClientSecret(null); + setCheckout(false); + setPaymentMessage( + 'Unable to initialize Stripe checkout. Please try again.', + ); + return; + } + setSubscriptionPlan(selectedPlan); + setPaymentClientSecret(clientSecret); + setCheckoutType('subscription'); + setCheckout(true); + } catch (error) { + const detail = + error instanceof Error + ? error.message + : 'Unable to initialize Stripe checkout. Please try again.'; + setPaymentClientSecret(null); + setCheckout(false); + setPaymentMessage(detail); + } + }, + [subscriptionPaymentIntentMutation, subscriptionPlan], + ); + + const pendingSubscriptionPlan = useMemo(() => { + const teamLikePlan = plans.find(plan => + String(plan?.name || '') + .toLowerCase() + .includes('team'), + ); + return teamLikePlan || plans[0] || null; + }, [plans]); + + const startPendingSubscriptionCheckout = useCallback(() => { + if (!pendingSubscriptionPlan) { + setPaymentMessage('Select a monthly subscription plan first.'); + return; + } + void startSubscriptionCheckout(pendingSubscriptionPlan); + }, [pendingSubscriptionPlan, startSubscriptionCheckout]); + + const openPortal = useCallback((url?: string) => { + if (!url) { + return; + } + window.open(url, '_blank', 'noreferrer'); + }, []); + + const cancelStripeCheckout = useCallback(() => { + setCheckout(false); + setPaymentClientSecret(null); + setPaymentMessage(null); + }, []); + + const onCancelSubscription = useCallback(() => { + setPaymentMessage(null); + setCancelViewOpen(true); + }, []); + + const onAbortCancelView = useCallback(() => { + setCancelViewOpen(false); + }, []); + + const onConfirmCancelSubscription = useCallback(async () => { + setPaymentMessage(null); + try { + const resp = await cancelSubscriptionMutation.mutateAsync(); + if (resp?.success === false) { + throw new Error( + resp?.message || 'Unable to cancel subscription right now.', + ); + } + + // Refresh subscription 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 = + responseStatus.includes('canceled') || + responseStatus.includes('cancelled'); + + const datedMessage = + responseCancelAtPeriodEnd && subscriptionPeriodEndLabel + ? `Subscription will cancel at the end of the current period on ${subscriptionPeriodEndLabel}.` + : null; + + setPaymentMessage( + (isNowCanceled ? 'Pending subscription canceled.' : datedMessage) || + resp?.message || + 'Subscription cancellation requested successfully.', + ); + setCancelViewOpen(false); + } catch (error) { + setPaymentMessage( + error instanceof Error + ? error.message + : 'Unable to cancel subscription right now.', + ); + } + }, [ + cancelSubscriptionMutation, + refetchSubscriptionStatus, + subscriptionPeriodEndLabel, + ]); + + 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.', + ); + return; + } + setCheckoutType('resume'); + setPaymentClientSecret(clientSecret); + setCheckout(true); + setPaymentMessage(null); + } catch (error) { + setPaymentMessage( + error instanceof Error + ? error.message + : 'Unable to initialize resume checkout right now.', + ); + } + }, [resumeSetupIntentMutation]); + + const onRefreshSubscriptionStatus = useCallback(async () => { + setPaymentMessage(null); + try { + await refetchSubscriptionStatus(); + setPaymentMessage('Subscription status refreshed.'); + } catch (error) { + setPaymentMessage( + error instanceof Error + ? error.message + : 'Unable to refresh subscription status right now.', + ); + } + }, [refetchSubscriptionStatus]); + + const selectedCheckoutLabel = useMemo(() => { + if (checkoutType === 'subscription' && subscriptionPlan) { + const amount = new Intl.NumberFormat(undefined, { + style: 'currency', + currency: subscriptionPlan.currency, + }).format((subscriptionPlan.amount || 0) / 100); + return `${subscriptionPlan.name} (${amount}${subscriptionPlan.interval ? ` / ${subscriptionPlan.interval}` : ''})`; + } + + if (checkoutType === 'topup' && product) { + const amount = new Intl.NumberFormat(undefined, { + style: 'currency', + currency: product.currency, + }).format((product.amount || 0) / 100); + return `${product.name} (${amount}, ${product.credits} credits)`; + } + + if (checkoutType === 'resume') { + return 'Subscription resume (card update required)'; + } + + return null; + }, [checkoutType, product, subscriptionPlan]); + + const monthlySubscriptionSection = ( + + + Choose a monthly subscription + + {isIncompleteSubscription ? ( + + A pending subscription already exists. Complete payment or cancel it + from the billing portal before creating a new one. + + ) : !isPaidSubscription ? ( + <> + + {plans.map(plan => ( + setSubscriptionPlan(plan)} + sx={{ + borderStyle: 'solid', + borderRadius: 'var(--borderRadius-medium)', + borderWidth: 'var(--borderWidth-thick)', + borderColor: + subscriptionPlan?.id === plan.id + ? 'var(--borderColor-accent-emphasis)' + : 'var(--borderColor-default)', + padding: 'var(--stack-padding-condensed)', + cursor: 'pointer', + }} + > + + + {plan.name} + + + {new Intl.NumberFormat(undefined, { + style: 'currency', + currency: plan.currency, + }).format((plan.amount || 0) / 100)} + {plan.interval ? ` / ${plan.interval}` : ''} + + {typeof plan.included_runs === 'number' && ( + {plan.included_runs} included runs + )} + + + ))} + {plans.length === 0 && ( + + No monthly subscription plans available right now. + + )} + + + + ) : ( + + {isCancellationScheduled + ? `Your monthly subscription will cancel on ${subscriptionPeriodEndLabel}.` + : 'Your monthly subscription is active. You can manage plan details from subscription controls.'} + + )} + + ); + + const topCards = ( + + + + + Subscription 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} + + )} + {subscriptionPeriodStartLabel !== 'N/A' && ( + Period start: {subscriptionPeriodStartLabel} + )} + {subscriptionPeriodEndLabel !== 'N/A' && ( + + Period end: {subscriptionPeriodEndLabel} + + )} + {isCancellationScheduled && ( + + Subscription will cancel at the end of the current period on{' '} + {subscriptionPeriodEndLabel}. + + )} + {remainingRuns !== null && ( + + {`Included runs remaining this period: ${remainingRuns.toLocaleString()}`} + + )} + + {subscriptionPortalUrl && ( + + )} + + {canCancelSubscription && !cancelViewOpen && ( + + )} + {isIncompleteSubscription && !cancelViewOpen && ( + <> + + + + )} + {isCancellationScheduled && ( + + )} + + + Next step:{' '} + {isCancellationScheduled + ? 'Your subscription is already scheduled for cancellation 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 subscription or continue with payment.' + : isPaidSubscription + ? 'Keep your subscription active. Top-up credits are available for active monthly subscribers.' + : 'Choose a monthly subscription, then buy top-up credits as needed.'} + + {cancelViewOpen && ( + + + {isIncompleteSubscription + ? 'Cancel pending subscription' + : 'Downgrade to Free Plan'} + + + {isIncompleteSubscription + ? 'This pending subscription will be canceled immediately.' + : 'Your subscription will be canceled at the end of the current billing period.'} + + + + + + + )} + + + {monthlySubscriptionSection} + + + + ); + let view = ( ); + // While the Stripe payment form is shown, disable interaction with the + // status / plan picker cards behind it so the only available action is the + // "Cancel" button next to the form. + const disabledTopCards = ( + + {topCards} + + ); if (checkout) { - if (stripe && components) { + if (stripe && paymentOptions && paymentClientSecret) { view = createElement( Box, - { id: 'checkout', sx: { flex: '1 1 auto' } }, + { id: 'checkout', sx: { flex: '1 1 auto', display: 'grid', gap: 3 } }, + disabledTopCards, + createElement( + Box, + { + sx: { + border: '1px solid', + borderColor: 'border.default', + borderRadius: 'var(--borderRadius-medium)', + backgroundColor: 'canvas.default', + padding: 'var(--stack-padding-normal)', + display: 'flex', + gap: 'var(--stack-gap-normal)', + alignItems: 'center', + justifyContent: 'space-between', + flexWrap: 'wrap', + }, + }, + createElement( + Text, + { as: 'p' }, + selectedCheckoutLabel + ? `Selected for checkout: ${selectedCheckoutLabel}` + : 'Selected for checkout', + ), + createElement( + Button, + { variant: 'default', onClick: cancelStripeCheckout }, + 'Cancel', + ), + ), + checkoutType === 'resume' + ? createElement( + Flash, + { variant: 'warning' }, + 'Enter a new payment card to resume your subscription.', + ) + : null, createElement( - components.EmbeddedCheckoutProvider, - { stripe, options }, - createElement(components.EmbeddedCheckout), + Elements, + { + key: `${checkoutType}-${paymentClientSecret ?? 'none'}-${elementsAppearanceKey}`, + stripe, + options: paymentOptions, + }, + createElement( + Box, + { + sx: { + border: '1px solid', + borderColor: 'border.default', + borderRadius: 'var(--borderRadius-medium)', + backgroundColor: 'canvas.default', + padding: 'var(--stack-padding-normal)', + width: '100%', + maxWidth: '720px', + margin: '0 auto', + }, + }, + createElement(StripePaymentForm, { + clientSecret: paymentClientSecret!, + intentType: checkoutType === 'resume' ? 'setup' : 'payment', + onPaymentSucceeded, + }), + ), ), ); + } else { + view = ( + + {disabledTopCards} + + Preparing Stripe checkout… + + + + ); } } else if (items) { view = items.length ? ( @@ -112,27 +1347,61 @@ export function StripeCheckout({ sx={{ flex: '1 1 auto' }} onKeyDown={event => { if (product && event.key === 'Enter') { - setCheckout(true); + void startCheckout(); } }} > - Choose a credits package + {topCards} + {paymentMessage && ( + + {paymentMessage} + + )} + {!canBuyTopUp && ( + + Monthly subscription required. Activate a monthly plan first, then + top-up credits will be available. + + )} + + Topup with credits package + + {!hasTopUpAccess && canBuyTopUp && ( + + Monthly subscription is normally required. Temporary allowance is + enabled: you can buy top-up credits now. Update to Team Plan for + included monthly runs. + + )} - {items.map(item => ( + {sortedTopUpItems.map(item => ( { - setProduct(item); + if (canBuyTopUp) { + setProduct(item); + } }} sx={{ borderStyle: 'solid', @@ -143,11 +1412,11 @@ export function StripeCheckout({ ? 'var(--borderColor-accent-emphasis)' : 'var(--borderColor-default)', padding: 'var(--stack-padding-condensed)', - cursor: 'pointer', + cursor: canBuyTopUp ? 'pointer' : 'not-allowed', + opacity: canBuyTopUp ? 1 : 0.6, }} > { - setCheckout(true); + void startCheckout(); }} - disabled={product === null} + disabled={ + product === null || + !canBuyTopUp || + topUpPaymentIntentMutation.isPending || + checkout + } sx={{ float: 'right' }} > - Checkout + {topUpPaymentIntentMutation.isPending + ? 'Preparing top-up checkout...' + : 'Checkout'} ) : ( + {topCards} Unable to fetch the available products. Please try again later. diff --git a/src/components/checkout/ThemedStripeCheckout.tsx b/src/components/checkout/ThemedStripeCheckout.tsx new file mode 100644 index 00000000..e29deb34 --- /dev/null +++ b/src/components/checkout/ThemedStripeCheckout.tsx @@ -0,0 +1,305 @@ +/* + * Copyright (c) 2023-2025 Datalayer, Inc. + * Distributed under the terms of the Modified BSD License. + */ + +import { useEffect, useMemo, useState } from 'react'; +import { useTheme } from '@primer/react'; +import type { StripeElementsOptions } from '@stripe/stripe-js'; +import type { ICheckoutPortal } from '../../models'; +import { StripeCheckout } from './StripeCheckout'; + +const readColorModeFromDom = (): 'light' | 'dark' => { + if (typeof window === 'undefined') { + return 'light'; + } + + const rootMode = document.documentElement + .getAttribute('data-color-mode') + ?.toLowerCase(); + if (rootMode === 'dark' || rootMode === 'light') { + return rootMode; + } + + if (rootMode === 'auto') { + const darkTheme = document.documentElement + .getAttribute('data-dark-theme') + ?.toLowerCase(); + if (darkTheme && darkTheme !== 'light') { + return 'dark'; + } + } + + const bodyMode = document.body + ?.getAttribute('data-color-mode') + ?.toLowerCase(); + if (bodyMode === 'dark' || bodyMode === 'light') { + return bodyMode; + } + + if (bodyMode === 'auto') { + const darkTheme = document.body + ?.getAttribute('data-dark-theme') + ?.toLowerCase(); + if (darkTheme && darkTheme !== 'light') { + return 'dark'; + } + } + + const cssMode = readCssVar('--base-color-mode', '').toLowerCase(); + if (cssMode === 'dark' || cssMode === 'light') { + return cssMode; + } + + return window.matchMedia?.('(prefers-color-scheme: dark)').matches + ? 'dark' + : 'light'; +}; + +const readCssVar = (name: string, fallback: string) => { + if (typeof window === 'undefined') { + return fallback; + } + const value = getComputedStyle(document.documentElement) + .getPropertyValue(name) + .trim(); + return value || fallback; +}; + +const readThemeColor = (names: string[], fallback: string) => { + for (const name of names) { + const value = readCssVar(name, '').trim(); + if (value) { + return value; + } + } + return fallback; +}; + +const themeColorFromPrimer = ( + theme: unknown, + path: string[], +): string | null => { + let cursor: unknown = theme; + for (const key of path) { + if (cursor && typeof cursor === 'object' && key in (cursor as object)) { + cursor = (cursor as Record)[key]; + } else { + return null; + } + } + return typeof cursor === 'string' && cursor.trim() ? cursor.trim() : null; +}; + +const resolveAppearance = ( + mode: 'light' | 'dark', + primerTheme?: unknown, +): StripeElementsOptions['appearance'] => { + const isDark = mode === 'dark'; + + const pick = ( + primerPath: string[], + cssNames: string[], + fallback: string, + ): string => { + const fromPrimer = primerTheme + ? themeColorFromPrimer(primerTheme, primerPath) + : null; + if (fromPrimer) { + return fromPrimer; + } + return readThemeColor(cssNames, fallback); + }; + + const colorPrimary = pick( + ['colors', 'accent', 'fg'], + ['--fgColor-accent', '--color-accent-fg', '--color-accent-emphasis'], + isDark ? '#58a6ff' : '#0969da', + ); + const colorText = pick( + ['colors', 'fg', 'default'], + ['--fgColor-default', '--color-fg-default'], + isDark ? '#e6edf3' : '#1f2328', + ); + const colorTextSecondary = pick( + ['colors', 'fg', 'muted'], + ['--fgColor-muted', '--color-fg-muted'], + isDark ? '#8b949e' : '#59636e', + ); + const colorBackground = pick( + ['colors', 'canvas', 'default'], + ['--bgColor-default', '--color-canvas-default'], + isDark ? '#0d1117' : '#ffffff', + ); + const colorSurface = pick( + ['colors', 'canvas', 'subtle'], + ['--bgColor-muted', '--color-canvas-subtle'], + isDark ? '#161b22' : '#f6f8fa', + ); + const colorBorder = pick( + ['colors', 'border', 'default'], + ['--borderColor-default', '--color-border-default'], + isDark ? '#30363d' : '#d0d7de', + ); + const colorDanger = pick( + ['colors', 'danger', 'fg'], + ['--fgColor-danger', '--color-danger-fg'], + isDark ? '#ff7b72' : '#d1242f', + ); + const colorSuccess = pick( + ['colors', 'success', 'fg'], + ['--fgColor-success', '--color-success-fg'], + isDark ? '#3fb950' : '#1a7f37', + ); + + return { + theme: isDark ? 'night' : 'stripe', + variables: { + colorPrimary, + colorText, + colorTextSecondary, + colorBackground, + colorDanger, + colorSuccess, + colorIcon: colorTextSecondary, + colorIconHover: colorText, + colorPrimaryText: colorBackground, + colorTextPlaceholder: colorTextSecondary, + borderRadius: '8px', + spacingUnit: '4px', + fontFamily: readThemeColor( + ['--base-text-font-family', '--fontStack-sansSerif'], + 'system-ui, -apple-system, Segoe UI, sans-serif', + ), + }, + rules: { + '.Input': { + backgroundColor: colorBackground, + border: `1px solid ${colorBorder}`, + boxShadow: 'none', + }, + '.Input:focus': { + border: `1px solid ${colorPrimary}`, + boxShadow: `0 0 0 1px ${colorPrimary}`, + }, + '.Label': { + color: colorText, + }, + '.Tab': { + backgroundColor: colorSurface, + color: colorTextSecondary, + }, + '.Tab:hover': { + color: colorText, + }, + '.Tab--selected': { + backgroundColor: colorPrimary, + color: colorBackground, + }, + '.Error': { + color: colorDanger, + }, + '.Text': { + color: colorText, + }, + '.Footer': { + display: 'none', + }, + '.TermsText': { + display: 'none', + }, + '.PoweredByStripe': { + display: 'none', + }, + '.p-PoweredByStripe': { + display: 'none', + }, + }, + }; +}; + +export type ThemedStripeCheckoutProps = { + checkoutPortal: ICheckoutPortal | null; + accountUid?: string; + /** + * Primer color mode. Accepts 'light' | 'dark' | 'auto' | 'day' | 'night'. + * If omitted, falls back to Primer's `useTheme()` context, then to DOM. + */ + colorMode?: 'light' | 'dark' | 'auto' | 'day' | 'night' | string; + /** + * Primer theme object (e.g. from `useTheme().theme`). Used to derive + * Stripe appearance tokens. If omitted, falls back to Primer context, then + * to CSS variables on `:root`. + */ + theme?: unknown; +}; + +export function ThemedStripeCheckout({ + checkoutPortal, + accountUid, + colorMode, + theme, +}: ThemedStripeCheckoutProps) { + // Fallback source: Primer's theme context (only if props are not provided). + const primer = useTheme() as { + colorMode?: string; + resolvedColorMode?: string; + theme?: unknown; + }; + + const effectiveTheme = theme ?? primer?.theme; + + const effectiveColorMode: 'light' | 'dark' = useMemo(() => { + const value = String( + colorMode ?? primer?.resolvedColorMode ?? primer?.colorMode ?? '', + ).toLowerCase(); + if (value === 'night' || value === 'dark') return 'dark'; + if (value === 'day' || value === 'light') return 'light'; + return readColorModeFromDom(); + }, [colorMode, primer?.resolvedColorMode, primer?.colorMode]); + + const [appearance, setAppearance] = useState< + StripeElementsOptions['appearance'] + >(() => resolveAppearance(effectiveColorMode, effectiveTheme)); + + useEffect(() => { + setAppearance(resolveAppearance(effectiveColorMode, effectiveTheme)); + }, [effectiveColorMode, effectiveTheme]); + + // Safety net: react to DOM attribute changes if no explicit prop driving updates. + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + const recompute = () => + setAppearance(resolveAppearance(effectiveColorMode, effectiveTheme)); + const observer = new MutationObserver(recompute); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: [ + 'data-color-mode', + 'data-light-theme', + 'data-dark-theme', + 'style', + 'class', + ], + }); + if (document.body) { + observer.observe(document.body, { + attributes: true, + attributeFilter: ['data-color-mode', 'style', 'class'], + }); + } + return () => observer.disconnect(); + }, [effectiveColorMode, effectiveTheme]); + + return ( + + ); +} + +export default ThemedStripeCheckout; diff --git a/src/components/checkout/index.ts b/src/components/checkout/index.ts index 036edc61..a4a30d91 100644 --- a/src/components/checkout/index.ts +++ b/src/components/checkout/index.ts @@ -4,3 +4,4 @@ */ export * from './StripeCheckout'; +export * from './ThemedStripeCheckout'; diff --git a/src/components/tokens/SpaceVariantToken.tsx b/src/components/tokens/SpaceVariantToken.tsx index 63a80e3b..5484edb9 100644 --- a/src/components/tokens/SpaceVariantToken.tsx +++ b/src/components/tokens/SpaceVariantToken.tsx @@ -8,15 +8,22 @@ import { ProjectIcon } from '@primer/octicons-react'; import { StudentIcon } from '@datalayer/icons-react'; import { ISpaceVariant } from '../../models'; -export const SpaceVariantToken = (props: { variant: ISpaceVariant }) => { - const { variant } = props; +export const SpaceVariantToken = (props: { + variant: ISpaceVariant | string; +}) => { + const variant = String(props.variant || 'default').toLowerCase(); + + if (variant.includes('project')) { + return ; + } + switch (variant) { case 'default': - return ; + return ; case 'course': return ; default: - return <>; + return ; } }; diff --git a/src/hooks/useCache.ts b/src/hooks/useCache.ts index 8efff92f..89cdc24f 100644 --- a/src/hooks/useCache.ts +++ b/src/hooks/useCache.ts @@ -417,6 +417,24 @@ export const queryKeys = { platform: () => ['usages', 'platform'] as const, }, + // Credits allocations + credits: { + organizationOverview: (organizationUid: string) => + ['credits', 'allocations', 'organization', organizationUid] as const, + organizationHistory: (organizationUid: string) => + [ + 'credits', + 'allocations', + 'organization', + organizationUid, + 'history', + ] as const, + teamOverview: (teamUid: string) => + ['credits', 'allocations', 'team', teamUid] as const, + teamHistory: (teamUid: string) => + ['credits', 'allocations', 'team', teamUid, 'history'] as const, + }, + // Prices prices: { stripe: () => ['prices', 'stripe'] as const, @@ -1593,7 +1611,9 @@ export const useCache = ({ loginRoute = '/login' }: CacheProps = {}) => { ) => { const { id, name, description, ...extraFields } = space as any; return requestDatalayer({ - url: `${configuration.spacerRunUrl}/api/spacer/v1/spaces/${id}/users/${user?.id}`, + // Use the canonical space update route. This keeps project runtime + // assignment cleanup resilient when stale runtime links must be cleared. + url: `${configuration.spacerRunUrl}/api/spacer/v1/spaces/${id}`, method: 'PUT', body: { name, @@ -5179,16 +5199,28 @@ export const useCache = ({ loginRoute = '/login' }: CacheProps = {}) => { // ============================================================================ /** - * Get Stripe pricing information + * Get Stripe top-up pricing information */ - const useStripePrices = ( + type SubscriptionScopeOptions = { + accountUid?: string; + }; + + const withAccountUidQuery = (url: string, accountUid?: string) => { + if (!accountUid) { + return url; + } + const separator = url.includes('?') ? '&' : '?'; + return `${url}${separator}account_uid=${encodeURIComponent(accountUid)}`; + }; + + const useTopUpPrices = ( options?: Omit, 'queryKey' | 'queryFn'>, ) => { return useQuery({ - queryKey: ['stripe', 'prices'], + queryKey: ['stripe', 'topup', 'prices'], queryFn: async () => { const resp = await requestDatalayer({ - url: `${configuration.iamRunUrl}/api/iam/stripe/v1/prices`, + url: `${configuration.iamRunUrl}/api/iam/stripe/v1/topup/prices`, method: 'GET', }); return resp.prices || []; @@ -5198,24 +5230,71 @@ export const useCache = ({ loginRoute = '/login' }: CacheProps = {}) => { }; /** - * Create Stripe checkout session + * Create Stripe top-up payment intent client secret */ - const useCreateCheckoutSession = () => { + const useCreateTopUpPaymentIntent = (scope?: SubscriptionScopeOptions) => { return useMutation({ mutationFn: async ({ product, - location, }: { // eslint-disable-next-line @typescript-eslint/no-explicit-any product: any; - location: Location; }) => { const resp = await requestDatalayer({ - url: `${configuration.iamRunUrl}/api/iam/stripe/v1/checkout/session`, + url: `${configuration.iamRunUrl}/api/iam/stripe/v1/topup/payment-intent`, method: 'POST', body: { price_id: product?.id, - return_url: `${location.protocol}//${location.host}${location.pathname.split('/').slice(0, -1).join('/')}`, + ...(scope?.accountUid ? { account_uid: scope.accountUid } : {}), + }, + }); + return resp.client_secret; + }, + }); + }; + + /** + * Get available monthly subscription plans. + */ + const useSubscriptionPlans = ( + scope?: SubscriptionScopeOptions, + options?: Omit, 'queryKey' | 'queryFn'>, + ) => { + return useQuery({ + queryKey: ['subscription', 'plans', scope?.accountUid ?? 'self'], + queryFn: async () => { + const resp = await requestDatalayer({ + url: withAccountUidQuery( + `${configuration.iamRunUrl}/api/iam/v1/subscription/plans`, + scope?.accountUid, + ), + method: 'GET', + }); + return resp.plans || []; + }, + ...options, + }); + }; + + /** + * Create Stripe monthly subscription payment intent client secret. + */ + const useCreateSubscriptionPaymentIntent = ( + scope?: SubscriptionScopeOptions, + ) => { + return useMutation({ + mutationFn: async ({ + plan, + }: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + plan: any; + }) => { + const resp = await requestDatalayer({ + url: `${configuration.iamRunUrl}/api/iam/stripe/v1/subscription/payment-intent`, + method: 'POST', + body: { + price_id: plan?.id, + ...(scope?.accountUid ? { account_uid: scope.accountUid } : {}), }, }); return resp.client_secret; @@ -5223,6 +5302,152 @@ export const useCache = ({ loginRoute = '/login' }: CacheProps = {}) => { }); }; + /** + * Create Stripe SetupIntent client secret to update card before resuming. + */ + const useCreateResumeSetupIntent = (scope?: SubscriptionScopeOptions) => { + return useMutation({ + mutationFn: async () => { + const resp = await requestDatalayer({ + url: `${configuration.iamRunUrl}/api/iam/stripe/v1/subscription/resume/setup-intent`, + method: 'POST', + body: scope?.accountUid ? { account_uid: scope.accountUid } : {}, + }); + return resp.client_secret; + }, + }); + }; + + /** + * Get authenticated user subscription details. + */ + const useSubscriptionStatus = ( + scope?: SubscriptionScopeOptions, + options?: Omit, 'queryKey' | 'queryFn'>, + ) => { + return useQuery({ + queryKey: ['subscription', 'status', scope?.accountUid ?? 'self'], + queryFn: async () => { + return requestDatalayer({ + url: withAccountUidQuery( + `${configuration.iamRunUrl}/api/iam/v1/subscription`, + scope?.accountUid, + ), + method: 'GET', + }); + }, + ...options, + }); + }; + + /** + * Get accounts (personal + organizations) eligible for subscription-backed workloads. + */ + const useEligibleSubscriptionAccounts = ( + options?: Omit, 'queryKey' | 'queryFn'>, + ) => { + return useQuery({ + queryKey: ['subscription', 'eligible-accounts'], + queryFn: async () => { + const resp = await requestDatalayer({ + url: `${configuration.iamRunUrl}/api/iam/v1/subscription/eligible-accounts`, + method: 'GET', + }); + return resp.accounts || []; + }, + ...options, + }); + }; + + /** + * Request cancellation portal for the current subscription. + */ + const useCancelSubscription = (scope?: SubscriptionScopeOptions) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async () => { + return requestDatalayer({ + url: withAccountUidQuery( + `${configuration.iamRunUrl}/api/iam/v1/subscription/cancel`, + scope?.accountUid, + ), + method: 'POST', + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ['subscription', 'status', scope?.accountUid ?? 'self'], + }); + }, + }); + }; + + /** + * Resume a subscription already scheduled for cancellation. + */ + const useResumeSubscription = (scope?: SubscriptionScopeOptions) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async () => { + return requestDatalayer({ + url: withAccountUidQuery( + `${configuration.iamRunUrl}/api/iam/v1/subscription/resume`, + scope?.accountUid, + ), + method: 'POST', + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ['subscription', 'status', scope?.accountUid ?? 'self'], + }); + }, + }); + }; + + /** + * Get subscription details for an arbitrary user (platform_admin only). + */ + const useUserSubscription = ( + userId: string | undefined, + options?: Omit, 'queryKey' | 'queryFn'>, + ) => { + return useQuery({ + queryKey: ['subscription', 'admin', userId], + queryFn: async () => { + return requestDatalayer({ + url: `${configuration.iamRunUrl}/api/iam/v1/subscription/admin/${userId}`, + method: 'GET', + }); + }, + enabled: Boolean(userId), + ...options, + }); + }; + + /** + * Force-reset a user's subscription (platform_admin only). + */ + const useResetUserSubscription = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (userId: string) => { + return requestDatalayer({ + url: `${configuration.iamRunUrl}/api/iam/v1/subscription/admin/${userId}/reset`, + method: 'POST', + }); + }, + onSuccess: (_data, userId) => { + queryClient.invalidateQueries({ + queryKey: ['subscription', 'admin', userId], + }); + }, + }); + }; + /** * Burn user credits (deduct from balance) */ @@ -5784,6 +6009,216 @@ export const useCache = ({ loginRoute = '/login' }: CacheProps = {}) => { // Credits Quota & Usage // ============================================================================ + /** + * Get organization/team balances for allocation workflows + */ + const useOrganizationAllocationOverview = (organizationUid: string) => { + return useQuery({ + queryKey: queryKeys.credits.organizationOverview(organizationUid), + queryFn: async () => { + const resp = await requestDatalayer({ + url: `${configuration.iamRunUrl}/api/iam/v1/usage/credits/allocations/organizations/${organizationUid}/overview`, + method: 'GET', + }); + return resp.overview ?? { organization: null, teams: [] }; + }, + ...DEFAULT_QUERY_OPTIONS, + enabled: !!organizationUid, + }); + }; + + /** + * Get team/member balances for allocation workflows + */ + const useTeamAllocationOverview = (teamUid: string) => { + return useQuery({ + queryKey: queryKeys.credits.teamOverview(teamUid), + queryFn: async () => { + const resp = await requestDatalayer({ + url: `${configuration.iamRunUrl}/api/iam/v1/usage/credits/allocations/teams/${teamUid}/overview`, + method: 'GET', + }); + return resp.overview ?? { team: null, members: [] }; + }, + ...DEFAULT_QUERY_OPTIONS, + enabled: !!teamUid, + }); + }; + + /** + * Get organization allocation history + */ + const useOrganizationAllocationHistory = (organizationUid: string) => { + return useQuery({ + queryKey: queryKeys.credits.organizationHistory(organizationUid), + queryFn: async () => { + const resp = await requestDatalayer({ + url: `${configuration.iamRunUrl}/api/iam/v1/usage/credits/allocations/organizations/${organizationUid}/history`, + method: 'GET', + }); + return ( + resp.history ?? { organization_uid: organizationUid, events: [] } + ); + }, + ...DEFAULT_QUERY_OPTIONS, + enabled: !!organizationUid, + }); + }; + + /** + * Get team allocation history + */ + const useTeamAllocationHistory = (teamUid: string) => { + return useQuery({ + queryKey: queryKeys.credits.teamHistory(teamUid), + queryFn: async () => { + const resp = await requestDatalayer({ + url: `${configuration.iamRunUrl}/api/iam/v1/usage/credits/allocations/teams/${teamUid}/history`, + method: 'GET', + }); + return resp.history ?? { team_uid: teamUid, events: [] }; + }, + ...DEFAULT_QUERY_OPTIONS, + enabled: !!teamUid, + }); + }; + + /** + * Allocate credits from organization to team + */ + const useAllocateOrganizationCreditsToTeam = () => { + return useMutation({ + mutationFn: async ({ + organizationUid, + teamUid, + amount, + }: { + organizationUid: string; + teamUid: string; + amount: number; + }) => { + return requestDatalayer({ + url: `${configuration.iamRunUrl}/api/iam/v1/usage/credits/allocations/organizations/${organizationUid}/teams/${teamUid}`, + method: 'POST', + body: { amount }, + }); + }, + onSuccess: (_, { organizationUid, teamUid }) => { + queryClient.invalidateQueries({ + queryKey: queryKeys.credits.organizationOverview(organizationUid), + }); + queryClient.invalidateQueries({ + queryKey: queryKeys.credits.organizationHistory(organizationUid), + }); + queryClient.invalidateQueries({ + queryKey: queryKeys.credits.teamOverview(teamUid), + }); + queryClient.invalidateQueries({ + queryKey: queryKeys.credits.teamHistory(teamUid), + }); + }, + }); + }; + + /** + * Allocate credits from team to member + */ + const useAllocateTeamCreditsToMember = () => { + return useMutation({ + mutationFn: async ({ + teamUid, + memberUid, + amount, + }: { + teamUid: string; + memberUid: string; + amount: number; + }) => { + return requestDatalayer({ + url: `${configuration.iamRunUrl}/api/iam/v1/usage/credits/allocations/teams/${teamUid}/members/${memberUid}`, + method: 'POST', + body: { amount }, + }); + }, + onSuccess: (_, { teamUid }) => { + queryClient.invalidateQueries({ + queryKey: queryKeys.credits.teamOverview(teamUid), + }); + queryClient.invalidateQueries({ + queryKey: queryKeys.credits.teamHistory(teamUid), + }); + }, + }); + }; + + /** + * Revoke credits from team back to organization + */ + const useRevokeOrganizationCreditsFromTeam = () => { + return useMutation({ + mutationFn: async ({ + organizationUid, + teamUid, + amount, + }: { + organizationUid: string; + teamUid: string; + amount: number; + }) => { + return requestDatalayer({ + url: `${configuration.iamRunUrl}/api/iam/v1/usage/credits/allocations/organizations/${organizationUid}/teams/${teamUid}/revoke`, + method: 'POST', + body: { amount }, + }); + }, + onSuccess: (_, { organizationUid, teamUid }) => { + queryClient.invalidateQueries({ + queryKey: queryKeys.credits.organizationOverview(organizationUid), + }); + queryClient.invalidateQueries({ + queryKey: queryKeys.credits.organizationHistory(organizationUid), + }); + queryClient.invalidateQueries({ + queryKey: queryKeys.credits.teamOverview(teamUid), + }); + queryClient.invalidateQueries({ + queryKey: queryKeys.credits.teamHistory(teamUid), + }); + }, + }); + }; + + /** + * Revoke credits from member back to team + */ + const useRevokeTeamCreditsFromMember = () => { + return useMutation({ + mutationFn: async ({ + teamUid, + memberUid, + amount, + }: { + teamUid: string; + memberUid: string; + amount: number; + }) => { + return requestDatalayer({ + url: `${configuration.iamRunUrl}/api/iam/v1/usage/credits/allocations/teams/${teamUid}/members/${memberUid}/revoke`, + method: 'POST', + body: { amount }, + }); + }, + onSuccess: (_, { teamUid }) => { + queryClient.invalidateQueries({ + queryKey: queryKeys.credits.teamOverview(teamUid), + }); + queryClient.invalidateQueries({ + queryKey: queryKeys.credits.teamHistory(teamUid), + }); + }, + }); + }; + /** * Update user credits quota */ @@ -5820,13 +6255,30 @@ export const useCache = ({ loginRoute = '/login' }: CacheProps = {}) => { * Get current user's usage data */ const useUsages = ( + scopeOrOptions?: + | SubscriptionScopeOptions + | Omit, 'queryKey' | 'queryFn'>, options?: Omit, 'queryKey' | 'queryFn'>, ) => { + const hasScope = + !!scopeOrOptions && + typeof scopeOrOptions === 'object' && + 'accountUid' in scopeOrOptions; + const scope = (hasScope ? scopeOrOptions : undefined) as + | SubscriptionScopeOptions + | undefined; + const queryOptions = (hasScope ? options : scopeOrOptions) as + | Omit, 'queryKey' | 'queryFn'> + | undefined; + return useQuery({ - queryKey: ['usage', 'me'], + queryKey: ['usage', 'me', scope?.accountUid ?? 'self'], queryFn: async () => { const resp = await requestDatalayer({ - url: `${configuration.iamRunUrl}/api/iam/v1/usage/user`, + url: withAccountUidQuery( + `${configuration.iamRunUrl}/api/iam/v1/usage/user`, + scope?.accountUid, + ), method: 'GET', }); // Transform snake_case API response to camelCase IUsage interface @@ -5847,7 +6299,7 @@ export const useCache = ({ loginRoute = '/login' }: CacheProps = {}) => { })); return usages; }, - ...options, + ...queryOptions, }); }; @@ -7501,6 +7953,14 @@ export const useCache = ({ loginRoute = '/login' }: CacheProps = {}) => { useUpdateUserCredits, useAssignRoleToUser, useUnassignRoleFromUser, + useOrganizationAllocationOverview, + useOrganizationAllocationHistory, + useTeamAllocationOverview, + useTeamAllocationHistory, + useAllocateOrganizationCreditsToTeam, + useAllocateTeamCreditsToMember, + useRevokeOrganizationCreditsFromTeam, + useRevokeTeamCreditsFromMember, useUpdateUserCreditsQuota, useUsages, useUsagesForUser, @@ -7741,9 +8201,18 @@ export const useCache = ({ loginRoute = '/login' }: CacheProps = {}) => { useValidateUserMFACode, // Checkout & Credits - useCreateCheckoutSession, + useCreateTopUpPaymentIntent, + useCreateSubscriptionPaymentIntent, + useCreateResumeSetupIntent, + useSubscriptionStatus, + useSubscriptionPlans, + useEligibleSubscriptionAccounts, + useCancelSubscription, + useResumeSubscription, + useUserSubscription, + useResetUserSubscription, useBurnCredit, - useStripePrices, + useTopUpPrices, // Support & Growth useRequestPlatformSupport, diff --git a/src/hooks/useDatalayer.ts b/src/hooks/useDatalayer.ts index db12109a..2b7b6820 100755 --- a/src/hooks/useDatalayer.ts +++ b/src/hooks/useDatalayer.ts @@ -35,6 +35,27 @@ export interface DatalayerRequest export function useDatalayer(props: IDatalayerRequestProps = {}) { const { loginRoute = '/login', notifyOnError = true } = props; const location = useLocation(); + const resolveLoginRoute = (candidateRoute: string): string => { + if (candidateRoute !== '/login') { + return candidateRoute; + } + + const pathname = location.pathname || ''; + const kernelsPrefix = '/jupyter/kernels'; + const iamPrefix = '/jupyter/iam'; + + if (pathname.includes(kernelsPrefix)) { + const [base] = pathname.split(kernelsPrefix); + return `${base}${kernelsPrefix}/login`; + } + + if (pathname.includes(iamPrefix)) { + const [base] = pathname.split(iamPrefix); + return `${base}${iamPrefix}/login`; + } + + return candidateRoute; + }; /* // TODO Fix the conditional hook call. const coreStore = useCoreStore(); @@ -67,9 +88,14 @@ export function useDatalayer(props: IDatalayerRequestProps = {}) { const responseError = error as RunResponseError; if (responseError.response.status === 401) { console.log('Datalayer sent a 401 return code.'); - if (location.pathname !== loginRoute_) { + const resolvedLoginRoute = resolveLoginRoute(loginRoute_); + const alreadyOnLoginRoute = + location.pathname === resolvedLoginRoute || + location.pathname.endsWith('/login'); + + if (!alreadyOnLoginRoute) { iamStore.logout(); - navigate(loginRoute_); + navigate(resolvedLoginRoute); } } else { if (notifyOnError_) { diff --git a/src/hooks/useIAM.ts b/src/hooks/useIAM.ts index 608c4138..297d6e34 100755 --- a/src/hooks/useIAM.ts +++ b/src/hooks/useIAM.ts @@ -6,7 +6,6 @@ import { useEffect, useState } from 'react'; import { useCache } from './useCache'; import { - coreStore, useIAMStore, useLayoutStore, useOrganizationStore, @@ -105,13 +104,6 @@ export const useIAM = ( const user = asUser(whoamiData.profile); setIAMState({ user, token }); iamStore.setLogin(user, token); - // TODO centralize user settings management. - const aiagentsRunUrl = user.settings?.aiAgentsUrl; - if (aiagentsRunUrl) { - coreStore.getState().setConfiguration({ - aiagentsRunUrl, - }); - } } } }, [token, whoamiData, iamStore]); diff --git a/src/hooks/useProjects.ts b/src/hooks/useProjects.ts index bd82c6b4..a26bae81 100644 --- a/src/hooks/useProjects.ts +++ b/src/hooks/useProjects.ts @@ -20,6 +20,38 @@ import { useCache } from './useCache'; /** The space type value used to identify project spaces in Solr */ export const PROJECT_SPACE_VARIANT = 'project'; +const normalizeProjectItems = (items: unknown): any[] => { + if (Array.isArray(items)) { + return items; + } + if (items && typeof items === 'object') { + return [items]; + } + return []; +}; + +const isProjectSpace = (space: any): boolean => { + const variant = space?.variant ?? space?.variant_s; + if (variant !== PROJECT_SPACE_VARIANT) { + return false; + } + + const items = normalizeProjectItems(space?.items); + if (items.length === 0) { + return true; + } + + const hasNotebook = items.some( + item => (item?.type_s ?? item?.type) === 'notebook', + ); + const hasDocument = items.some(item => { + const type = item?.type_s ?? item?.type; + return type === 'document' || type === 'lexical'; + }); + + return hasNotebook && hasDocument; +}; + /** * Project data type (mapped from spacer service space data). */ @@ -69,11 +101,7 @@ export function useProjects() { const projects: ProjectData[] = useMemo(() => { if (!allSpaces) return []; return allSpaces - .filter( - (space: any) => - space.variant === PROJECT_SPACE_VARIANT || - space.type_s === PROJECT_SPACE_VARIANT, - ) + .filter((space: any) => isProjectSpace(space)) .map((space: any) => ({ uid: space.uid, id: space.id ?? space.uid, diff --git a/src/mocks/components/FlashMock.tsx b/src/mocks/components/FlashMock.tsx index 70214b94..741d6600 100644 --- a/src/mocks/components/FlashMock.tsx +++ b/src/mocks/components/FlashMock.tsx @@ -4,18 +4,33 @@ */ import { AlertIcon } from '@primer/octicons-react'; -import { Flash, Link } from '@primer/react'; +import { Box, Button, Flash } from '@primer/react'; import { useNavigate } from '../../hooks'; export const FlashMock = () => { const navigate = useNavigate(); return ( - This is a mock content.{' '} - navigate('/contact', e)}> - Contact us - {' '} - if you'd like to know more about this feature. + + + + + This is placeholder content. Contact us to learn more about this + feature. + + + + ); }; diff --git a/src/models/Space.ts b/src/models/Space.ts index 4578eb0d..9b256b42 100644 --- a/src/models/Space.ts +++ b/src/models/Space.ts @@ -9,7 +9,6 @@ import { ICourse as ICourse } from './Course'; import { IOrganization } from './Organization'; import { asUser, IUser } from './User'; import { asArray } from '../utils'; -import { newUserMock } from './../mocks/models'; /** * Convert the raw space object to {@link ISpace}. @@ -18,7 +17,34 @@ import { newUserMock } from './../mocks/models'; * @returns Space */ export const asSpace = (raw_space: any): ISpace => { - const owner = newUserMock(); + const organizationHandle = + raw_space?.organization_handle_s || raw_space?.organization?.handle; + const owner = raw_space?.owner + ? asUser(raw_space.owner) + : ({ + id: raw_space?.creator_uid || raw_space?.user_uid || '', + handle: + raw_space?.owner_handle_s || + raw_space?.creator_handle_s || + raw_space?.user_handle_s || + '', + email: '', + firstName: '', + lastName: '', + initials: '', + displayName: + raw_space?.owner_handle_s || + raw_space?.creator_handle_s || + raw_space?.user_handle_s || + '', + roles: [], + iamProviders: [], + setRoles: () => {}, + unsubscribedFromOutbounds: false, + onboarding: {} as any, + events: [], + settings: {}, + } as IUser); let members = new Array(); if (raw_space.members) { members = asArray(raw_space.members).map(m => { @@ -37,9 +63,9 @@ export const asSpace = (raw_space: any): ISpace => { members, creationDate: new Date(raw_space.creation_ts_dt), owner, - organization: { - handle: raw_space.handle_s, - }, + organization: organizationHandle + ? { handle: organizationHandle } + : undefined, // Preserve raw Solr fields so consumers can access dynamic fields // (e.g. attached_agent_pod_name_s for project-agent assignment) ...raw_space, diff --git a/src/state/substates/IAMState.ts b/src/state/substates/IAMState.ts index c2849b6f..670f9c42 100644 --- a/src/state/substates/IAMState.ts +++ b/src/state/substates/IAMState.ts @@ -299,13 +299,6 @@ export const iamStore = createStore((set, get) => { storeUser(user); storeToken(token); set(() => ({ user, token })); - // TODO Centralize User Setting Management. - const aiagentsRunUrl = user.settings?.aiAgentsUrl; - if (aiagentsRunUrl) { - coreStore.getState().setConfiguration({ - aiagentsRunUrl, - }); - } } catch (error) { if ( (error as RunResponseError).name === 'RunResponseError' && diff --git a/src/views/datasources/DatasourceDetail.tsx b/src/views/datasources/DatasourceDetail.tsx index ed988785..cc24ee12 100644 --- a/src/views/datasources/DatasourceDetail.tsx +++ b/src/views/datasources/DatasourceDetail.tsx @@ -6,15 +6,16 @@ import { useState, useEffect } from 'react'; import { useParams } from 'react-router-dom'; import { - PageHeader, - Heading, + PageLayout, Text, Button, TextInput, FormControl, Textarea, Label, + Spinner, } from '@primer/react'; +import { PageHeader } from '@primer/react/experimental'; import { Box } from '@datalayer/primer-addons'; import { EyeIcon, EyeClosedIcon } from '@primer/octicons-react'; import { BoringAvatar } from '../../components/avatars'; @@ -39,23 +40,27 @@ export const DatasourceDetail = () => { const { useUpdateDatasource, useDatasource } = useCache(); const updateDatasourceMutation = useUpdateDatasource(); - const datasourceQuery = useDatasource(datasourceId!); + const datasourceQuery = useDatasource(datasourceId ?? ''); const [datasource, setDatasource] = useState(); const [formValues, setFormValues] = useState({ - name: datasource?.name!, - description: datasource?.description!, + name: '', + description: '', }); const [validationResult, setValidationResult] = useState({ name: undefined, description: undefined, }); const [passwordVisibility, setPasswordVisibility] = useState(false); + useEffect(() => { if (datasourceQuery.data) { const datasource = datasourceQuery.data as AnyDatasource; setDatasource(datasource); - setFormValues({ ...datasource }); + setFormValues({ + name: datasource.name || '', + description: datasource.description || '', + }); } }, [datasourceQuery.data]); const nameNameChange = (event: React.ChangeEvent) => { @@ -73,8 +78,8 @@ export const DatasourceDetail = () => { })); }; useEffect(() => { - setValidationResult({ - ...validationResult, + setValidationResult(prev => ({ + ...prev, name: formValues.name === undefined ? undefined @@ -87,19 +92,30 @@ export const DatasourceDetail = () => { : formValues.description.length > 2 ? true : false, - }); + })); }, [formValues]); const nameSubmit = async () => { + if (!datasource) { + return; + } runStore.layout().showBackdrop(); - datasource!.name = formValues.name; - datasource!.description = formValues.description; - updateDatasourceMutation.mutate(datasource!, { - onSuccess: (resp: any) => { - if (resp.success) { + const updatedDatasource = { + ...datasource, + name: formValues.name, + description: formValues.description, + }; + updateDatasourceMutation.mutate(updatedDatasource, { + onSuccess: (resp: unknown) => { + if ( + typeof resp === 'object' && + resp !== null && + 'success' in resp && + (resp as { success: boolean }).success + ) { enqueueToast('The datasource is successfully updated.', { variant: 'success', }); - setDatasource(datasource); + setDatasource(updatedDatasource); } }, onSettled: () => { @@ -107,116 +123,176 @@ export const DatasourceDetail = () => { }, }); }; + + if (!datasourceId) { + return <>; + } + + if (datasourceQuery.isLoading) { + return ( + + + + + + + + ); + } + + if (datasourceQuery.isError || (!datasourceQuery.isLoading && !datasource)) { + return ( + + + + + Datasource not found or failed to load. + + + + + ); + } + return ( - <> - - Datasource - - - - - - {datasource?.name} - - - + + + + + Datasource + + + + + + + + + + - - - - - Name - - {validationResult.name === false && ( - - Name must have more than 2 characters. - - )} - - - Description -