diff --git a/.github/workflows/k3d-ci.yaml b/.github/workflows/k3d-ci.yaml index 6e5ac7185e..8db45c8a33 100644 --- a/.github/workflows/k3d-ci.yaml +++ b/.github/workflows/k3d-ci.yaml @@ -119,7 +119,7 @@ jobs: version: 3.10.2 - name: Create secret - run: kubectl create secret generic btrix-subs-app-secret --from-literal=BTRIX_SUBS_APP_URL=${{ env.ECHO_SERVER_HOST_URL }}/portalUrl + run: kubectl create secret generic btrix-subs-app-secret --from-literal=BTRIX_SUBS_APP_URL=${{ env.ECHO_SERVER_HOST_URL }} - name: Start Cluster with Helm run: | diff --git a/.github/workflows/k3d-nightly-ci.yaml b/.github/workflows/k3d-nightly-ci.yaml index b1bb957971..a31bf8d90c 100644 --- a/.github/workflows/k3d-nightly-ci.yaml +++ b/.github/workflows/k3d-nightly-ci.yaml @@ -99,6 +99,12 @@ jobs: with: version: 3.10.2 + - name: Create Secret + run: > + kubectl create secret generic btrix-subs-app-secret + --from-literal=BTRIX_SUBS_APP_URL=${{ env.ECHO_SERVER_HOST_URL }} + --from-literal=BTRIX_SUBS_APP_API_KEY=TEST_PRESHARED_SECRET_PASSWORD + - name: Start Cluster with Helm run: | helm upgrade --install -f ./chart/values.yaml -f ./chart/test/test.yaml -f ./chart/test/test-nightly-addons.yaml btrix ./chart/ diff --git a/.github/workflows/microk8s-ci.yaml b/.github/workflows/microk8s-ci.yaml index 33810511a8..a6801fb7dc 100644 --- a/.github/workflows/microk8s-ci.yaml +++ b/.github/workflows/microk8s-ci.yaml @@ -17,6 +17,12 @@ jobs: btrix-microk8s-test: runs-on: ubuntu-latest steps: + - name: Initial Disk Cleanup + uses: mathio/gha-cleanup@v1 + with: + remove-browsers: true + verbose: true + - uses: balchua/microk8s-actions@v0.3.1 with: channel: "1.25/stable" @@ -60,7 +66,7 @@ jobs: cache-to: type=gha,scope=frontend,mode=max - name: Create Secret - run: sudo microk8s kubectl create secret generic btrix-subs-app-secret --from-literal=BTRIX_SUBS_APP_URL=${{ env.ECHO_SERVER_HOST_URL }}/portalUrl + run: sudo microk8s kubectl create secret generic btrix-subs-app-secret --from-literal=BTRIX_SUBS_APP_URL=${{ env.ECHO_SERVER_HOST_URL }} - name: Start Cluster with Helm run: | diff --git a/backend/btrixcloud/auth.py b/backend/btrixcloud/auth.py index 60e8acd591..2ef167d6ae 100644 --- a/backend/btrixcloud/auth.py +++ b/backend/btrixcloud/auth.py @@ -66,7 +66,9 @@ class OA2BearerOrQuery(OAuth2PasswordBearer): """Override bearer check to also test query""" async def __call__( - self, request: Request = None, websocket: WebSocket = None # type: ignore + self, + request: Request = None, # type: ignore + websocket: WebSocket = None, # type: ignore ) -> str: param = None exc = None @@ -163,7 +165,7 @@ async def get_current_user( headers={"WWW-Authenticate": "Bearer"}, ) - async def shared_secret_or_active_user( + async def shared_secret_or_superuser( token: str = Depends(oauth2_scheme), ) -> User: # allow superadmin access if token matches the known shared secret @@ -257,4 +259,4 @@ async def refresh_jwt(user=Depends(current_active_user)): user_info = await user_manager.get_user_info_with_orgs(user) return get_bearer_response(user, user_info) - return auth_jwt_router, current_active_user, shared_secret_or_active_user + return auth_jwt_router, current_active_user, shared_secret_or_superuser diff --git a/backend/btrixcloud/db.py b/backend/btrixcloud/db.py index 7f0bfbd9b2..cb9637c254 100644 --- a/backend/btrixcloud/db.py +++ b/backend/btrixcloud/db.py @@ -4,11 +4,17 @@ import importlib.util import os -import urllib +import urllib.parse import asyncio from uuid import UUID, uuid4 -from typing import Optional, Union, TypeVar, Type, TYPE_CHECKING +from typing import ( + Optional, + Type, + TypeVar, + Union, + TYPE_CHECKING, +) from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase from pydantic import BaseModel diff --git a/backend/btrixcloud/main.py b/backend/btrixcloud/main.py index 21a7f95bd7..78bd76be6b 100644 --- a/backend/btrixcloud/main.py +++ b/backend/btrixcloud/main.py @@ -172,12 +172,11 @@ def main() -> None: user_manager = init_user_manager(mdb, email, invites) - current_active_user, shared_secret_or_active_user = init_users_api( - app, user_manager - ) + current_active_user, shared_secret_or_superuser = init_users_api(app, user_manager) org_ops = init_orgs_api( app, + dbclient, mdb, user_manager, crawl_manager, @@ -185,7 +184,7 @@ def main() -> None: current_active_user, ) - init_subs_api(app, mdb, org_ops, user_manager, shared_secret_or_active_user) + init_subs_api(app, mdb, org_ops, user_manager, shared_secret_or_superuser) event_webhook_ops = init_event_webhooks_api(mdb, org_ops, app_root) diff --git a/backend/btrixcloud/models.py b/backend/btrixcloud/models.py index 4225783e17..e3b5e9264d 100644 --- a/backend/btrixcloud/models.py +++ b/backend/btrixcloud/models.py @@ -1854,6 +1854,8 @@ class S3Storage(BaseModel): REASON_PAUSED = "subscriptionPaused" REASON_CANCELED = "subscriptionCanceled" +SubscriptionEventType = Literal["create", "import", "update", "cancel", "add-minutes"] + # ============================================================================ class OrgQuotas(BaseModel): @@ -1906,6 +1908,7 @@ class SubscriptionEventOut(BaseModel): oid: UUID timestamp: datetime + type: SubscriptionEventType # ============================================================================ @@ -1971,18 +1974,54 @@ class SubscriptionCancel(BaseModel): # ============================================================================ -class SubscriptionTrialEndReminder(BaseModel): - """Email reminder that subscription will end soon""" +class SubscriptionCancelOut(SubscriptionCancel, SubscriptionEventOut): + """Output model for subscription cancellation event""" - subId: str - behavior_on_trial_end: Literal["cancel", "continue", "read-only"] + type: Literal["cancel"] = "cancel" # ============================================================================ -class SubscriptionCancelOut(SubscriptionCancel, SubscriptionEventOut): - """Output model for subscription cancellation event""" +class SubscriptionAddMinutes(BaseModel): + """Represents a purchase of additional minutes""" + + oid: UUID + minutes: int + totalPrice: float + currency: str + paymentId: str - type: Literal["cancel"] = "cancel" + +# ============================================================================ +class SubscriptionAddMinutesOut(SubscriptionAddMinutes, SubscriptionEventOut): + """SubscriptionAddMinutes output model""" + + type: Literal["add-minutes"] = "add-minutes" + + +# ============================================================================ +SubscriptionEventAny = Union[ + SubscriptionCreate, + SubscriptionUpdate, + SubscriptionCancel, + SubscriptionImport, + SubscriptionAddMinutes, +] + +SubscriptionEventAnyOut = Union[ + SubscriptionCreateOut, + SubscriptionUpdateOut, + SubscriptionCancelOut, + SubscriptionImportOut, + SubscriptionAddMinutesOut, +] + + +# ============================================================================ +class SubscriptionTrialEndReminder(BaseModel): + """Email reminder that subscription will end soon""" + + subId: str + behavior_on_trial_end: Literal["cancel", "continue", "read-only"] # ============================================================================ @@ -2005,6 +2044,30 @@ class SubscriptionPortalUrlResponse(BaseModel): portalUrl: str = "" +# ============================================================================ +class AddonMinutesPricing(BaseModel): + """Addon minutes pricing""" + + value: float + currency: str + + +# ============================================================================ +class CheckoutAddonMinutesRequest(BaseModel): + """Request for additional minutes checkout session""" + + orgId: str + subId: str + minutes: int | None = None + return_url: str + + +class CheckoutAddonMinutesResponse(BaseModel): + """Response for additional minutes checkout session""" + + checkoutUrl: str + + # ============================================================================ class Subscription(BaseModel): """subscription data""" @@ -2083,6 +2146,15 @@ class OrgQuotaUpdate(BaseModel): modified: datetime update: OrgQuotas + subEventId: str | None = None + + +# ============================================================================ +class OrgQuotaUpdateOut(BaseModel): + """Organization quota update output for admins""" + + modified: datetime + update: OrgQuotas # ============================================================================ @@ -2159,7 +2231,7 @@ class OrgOut(BaseMongoModel): giftedExecSecondsAvailable: int = 0 quotas: OrgQuotas = OrgQuotas() - quotaUpdates: Optional[List[OrgQuotaUpdate]] = [] + quotaUpdates: Optional[List[OrgQuotaUpdateOut]] = [] webhookUrls: Optional[OrgWebhookUrls] = OrgWebhookUrls() @@ -3124,14 +3196,7 @@ class PaginatedProfileResponse(PaginatedResponse): class PaginatedSubscriptionEventResponse(PaginatedResponse): """Response model for paginated subscription events""" - items: List[ - Union[ - SubscriptionCreateOut, - SubscriptionUpdateOut, - SubscriptionCancelOut, - SubscriptionImportOut, - ] - ] + items: List[SubscriptionEventAnyOut] # ============================================================================ diff --git a/backend/btrixcloud/ops.py b/backend/btrixcloud/ops.py index 869c13c755..148299e3a5 100644 --- a/backend/btrixcloud/ops.py +++ b/backend/btrixcloud/ops.py @@ -56,7 +56,7 @@ def init_ops() -> Tuple[ user_manager = UserManager(mdb, email, invite_ops) - org_ops = OrgOps(mdb, invite_ops, user_manager, crawl_manager) + org_ops = OrgOps(dbclient, mdb, invite_ops, user_manager, crawl_manager) event_webhook_ops = EventWebhookOps(mdb, org_ops) diff --git a/backend/btrixcloud/orgs.py b/backend/btrixcloud/orgs.py index c950696c6d..8323a6266d 100644 --- a/backend/btrixcloud/orgs.py +++ b/backend/btrixcloud/orgs.py @@ -12,8 +12,23 @@ from uuid import UUID, uuid4 from tempfile import NamedTemporaryFile -from typing import Optional, TYPE_CHECKING, Dict, Callable, List, AsyncGenerator, Any +from typing import ( + Awaitable, + Optional, + TYPE_CHECKING, + Dict, + Callable, + List, + Literal, + AsyncGenerator, + Any, +) +from motor.motor_asyncio import ( + AsyncIOMotorClient, + AsyncIOMotorClientSession, + AsyncIOMotorDatabase, +) from pydantic import ValidationError from pymongo import ReturnDocument from pymongo.errors import AutoReconnect, DuplicateKeyError @@ -191,11 +206,14 @@ class OrgOps(BaseOrgs): def __init__( self, - mdb, + dbclient: AsyncIOMotorClient, + mdb: AsyncIOMotorDatabase, invites: InviteOps, user_manager: UserManager, crawl_manager: CrawlManager, ): + self.dbclient = dbclient + self.orgs = mdb["organizations"] self.crawls_db = mdb["crawls"] self.crawl_configs_db = mdb["crawl_configs"] @@ -373,9 +391,11 @@ async def get_users_for_org( users.append(User(**user_dict)) return users - async def get_org_by_id(self, oid: UUID) -> Organization: + async def get_org_by_id( + self, oid: UUID, session: AsyncIOMotorClientSession | None = None + ) -> Organization: """Get an org by id""" - res = await self.orgs.find_one({"_id": oid}) + res = await self.orgs.find_one({"_id": oid}, session=session) if not res: raise HTTPException(status_code=400, detail="invalid_org_id") @@ -599,13 +619,7 @@ async def update_subscription_data( if not org_data: return None - org = Organization.from_dict(org_data) - if update.quotas: - # don't change gifted minutes here - update.quotas.giftedExecMinutes = None - await self.update_quotas(org, update.quotas) - - return org + return Organization.from_dict(org_data) async def cancel_subscription_data( self, cancel: SubscriptionCancel @@ -654,65 +668,106 @@ async def update_proxies(self, org: Organization, proxies: OrgProxies) -> None: }, ) - async def update_quotas(self, org: Organization, quotas: OrgQuotasIn) -> None: + async def update_quotas( + self, + org: Organization, + quotas: OrgQuotasIn, + mode: Literal["set", "add"], + sub_event_id: str | None = None, + session: AsyncIOMotorClientSession | None = None, + ) -> None: """update organization quotas""" - previous_extra_mins = ( - org.quotas.extraExecMinutes - if (org.quotas and org.quotas.extraExecMinutes) - else 0 - ) - previous_gifted_mins = ( - org.quotas.giftedExecMinutes - if (org.quotas and org.quotas.giftedExecMinutes) - else 0 - ) - - update = quotas.dict( - exclude_unset=True, exclude_defaults=True, exclude_none=True - ) + async with await self.dbclient.start_session( + causal_consistency=True + ) as session: + try: + # Re-fetch the organization within the session + # so that the operation as a whole is atomic. + org = await self.get_org_by_id(org.id, session=session) + + previous_extra_mins = ( + org.quotas.extraExecMinutes + if (org.quotas and org.quotas.extraExecMinutes) + else 0 + ) + previous_gifted_mins = ( + org.quotas.giftedExecMinutes + if (org.quotas and org.quotas.giftedExecMinutes) + else 0 + ) - quota_updates = [] - for prev_update in org.quotaUpdates or []: - quota_updates.append(prev_update.dict()) - quota_updates.append(OrgQuotaUpdate(update=update, modified=dt_now()).dict()) + if mode == "add": + increment_update: dict[str, Any] = { + "$inc": {}, + } - await self.orgs.find_one_and_update( - {"_id": org.id}, - { - "$set": { - "quotas": update, - "quotaUpdates": quota_updates, + for field, value in quotas.model_dump( + exclude_unset=True, exclude_defaults=True, exclude_none=True + ).items(): + if value is None: + continue + inc = max(value, -org.quotas.model_dump().get(field, 0)) + increment_update["$inc"][f"quotas.{field}"] = inc + + updated_org = await self.orgs.find_one_and_update( + {"_id": org.id}, + increment_update, + projection={"quotas": True}, + return_document=ReturnDocument.AFTER, + session=session, + ) + quotas = OrgQuotasIn(**updated_org["quotas"]) + + update: dict[str, dict[str, dict[str, Any] | int]] = { + "$push": { + "quotaUpdates": OrgQuotaUpdate( + modified=dt_now(), + update=OrgQuotas( + **quotas.model_dump( + exclude_unset=True, + exclude_defaults=True, + exclude_none=True, + ) + ), + subEventId=sub_event_id, + ).model_dump() + }, + "$inc": {}, + "$set": {}, } - }, - ) - # Inc org available fields for extra/gifted execution time as needed - if quotas.extraExecMinutes is not None: - extra_secs_diff = (quotas.extraExecMinutes - previous_extra_mins) * 60 - if org.extraExecSecondsAvailable + extra_secs_diff <= 0: - await self.orgs.find_one_and_update( - {"_id": org.id}, - {"$set": {"extraExecSecondsAvailable": 0}}, - ) - else: - await self.orgs.find_one_and_update( - {"_id": org.id}, - {"$inc": {"extraExecSecondsAvailable": extra_secs_diff}}, - ) + if mode == "set": + increment_update = quotas.model_dump( + exclude_unset=True, exclude_defaults=True, exclude_none=True + ) + update["$set"]["quotas"] = increment_update + + # Inc org available fields for extra/gifted execution time as needed + if quotas.extraExecMinutes is not None: + extra_secs_diff = ( + quotas.extraExecMinutes - previous_extra_mins + ) * 60 + if org.extraExecSecondsAvailable + extra_secs_diff <= 0: + update["$set"]["extraExecSecondsAvailable"] = 0 + else: + update["$inc"]["extraExecSecondsAvailable"] = extra_secs_diff + + if quotas.giftedExecMinutes is not None: + gifted_secs_diff = ( + quotas.giftedExecMinutes - previous_gifted_mins + ) * 60 + if org.giftedExecSecondsAvailable + gifted_secs_diff <= 0: + update["$set"]["giftedExecSecondsAvailable"] = 0 + else: + update["$inc"]["giftedExecSecondsAvailable"] = gifted_secs_diff - if quotas.giftedExecMinutes is not None: - gifted_secs_diff = (quotas.giftedExecMinutes - previous_gifted_mins) * 60 - if org.giftedExecSecondsAvailable + gifted_secs_diff <= 0: await self.orgs.find_one_and_update( - {"_id": org.id}, - {"$set": {"giftedExecSecondsAvailable": 0}}, - ) - else: - await self.orgs.find_one_and_update( - {"_id": org.id}, - {"$inc": {"giftedExecSecondsAvailable": gifted_secs_diff}}, + {"_id": org.id}, update, session=session ) + except Exception as e: + print(f"Error updating organization quotas: {e}") + raise HTTPException(status_code=500, detail=str(e)) from e async def update_event_webhook_urls( self, org: Organization, urls: OrgWebhookUrls @@ -1143,7 +1198,7 @@ async def json_items_gen( yield b"\n" doc_index += 1 - yield f']{"" if skip_closing_comma else ","}\n'.encode("utf-8") + yield f"]{'' if skip_closing_comma else ','}\n".encode("utf-8") async def json_closing_gen() -> AsyncGenerator: """Async generator to close JSON document""" @@ -1455,10 +1510,12 @@ async def delete_org_and_data( async def recalculate_storage(self, org: Organization) -> dict[str, bool]: """Recalculate org storage use""" try: - total_crawl_size, crawl_size, upload_size = ( - await self.base_crawl_ops.calculate_org_crawl_file_storage( - org.id, - ) + ( + total_crawl_size, + crawl_size, + upload_size, + ) = await self.base_crawl_ops.calculate_org_crawl_file_storage( + org.id, ) profile_size = await self.profile_ops.calculate_org_profile_file_storage( org.id @@ -1515,17 +1572,18 @@ async def inc_org_bytes_stored_field(self, oid: UUID, field: str, size: int): # ============================================================================ # pylint: disable=too-many-statements, too-many-arguments def init_orgs_api( - app, - mdb, + app: APIRouter, + dbclient: AsyncIOMotorClient, + mdb: AsyncIOMotorDatabase[Any], user_manager: UserManager, crawl_manager: CrawlManager, invites: InviteOps, - user_dep: Callable, + user_dep: Callable[[str], Awaitable[User]], ): """Init organizations api router for /orgs""" # pylint: disable=too-many-locals,invalid-name - ops = OrgOps(mdb, invites, user_manager, crawl_manager) + ops = OrgOps(dbclient, mdb, invites, user_manager, crawl_manager) async def org_dep(oid: UUID, user: User = Depends(user_dep)): org = await ops.get_org_for_user_by_id(oid, user) @@ -1675,7 +1733,7 @@ async def update_quotas( if not user.is_superuser: raise HTTPException(status_code=403, detail="Not Allowed") - await ops.update_quotas(org, quotas) + await ops.update_quotas(org, quotas, mode="set") return {"updated": True} diff --git a/backend/btrixcloud/subs.py b/backend/btrixcloud/subs.py index 9180ae3878..970ea83bef 100644 --- a/backend/btrixcloud/subs.py +++ b/backend/btrixcloud/subs.py @@ -2,33 +2,43 @@ Subscription API handling """ -from typing import Callable, Union, Any, Optional, Tuple, List +from typing import Awaitable, Callable, Any, Optional, Tuple, List, Annotated import os import asyncio from uuid import UUID from datetime import datetime -from fastapi import Depends, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException, Request, Query import aiohttp +from motor.motor_asyncio import AsyncIOMotorDatabase from .orgs import OrgOps from .users import UserManager from .utils import is_bool, get_origin from .models import ( + AddonMinutesPricing, + CheckoutAddonMinutesRequest, + CheckoutAddonMinutesResponse, SubscriptionCreate, SubscriptionImport, SubscriptionUpdate, SubscriptionCancel, + SubscriptionAddMinutes, + SubscriptionEventAny, SubscriptionCreateOut, SubscriptionImportOut, SubscriptionUpdateOut, SubscriptionCancelOut, + SubscriptionAddMinutesOut, + SubscriptionEventAnyOut, + SubscriptionEventType, Subscription, SubscriptionPortalUrlRequest, SubscriptionPortalUrlResponse, SubscriptionCanceledResponse, SubscriptionTrialEndReminder, Organization, + OrgQuotasIn, InviteToOrgRequest, InviteAddedResponse, User, @@ -117,7 +127,18 @@ async def update_subscription(self, update: SubscriptionUpdate) -> dict[str, boo status_code=404, detail="org_for_subscription_not_found" ) - await self.add_sub_event("update", update, org.id) + sub_event_id = await self.add_sub_event("update", update, org.id) + + if update.quotas: + # don't change gifted or extra minutes here + update.quotas.giftedExecMinutes = None + update.quotas.extraExecMinutes = None + await self.org_ops.update_quotas( + org, + update.quotas, + mode="set", + sub_event_id=sub_event_id, + ) if update.futureCancelDate and self.should_send_cancel_email(org, update): asyncio.create_task(self.send_cancel_emails(update.futureCancelDate, org)) @@ -224,30 +245,31 @@ async def send_trial_end_reminder( return SuccessResponse(success=True) + async def add_sub_minutes(self, add_min: SubscriptionAddMinutes): + """add extra minutes for subscription""" + org = await self.org_ops.get_org_by_id(add_min.oid) + quotas = OrgQuotasIn(extraExecMinutes=add_min.minutes) + event_id = await self.add_sub_event("add-minutes", add_min, add_min.oid) + await self.org_ops.update_quotas(org, quotas, mode="add", sub_event_id=event_id) + return {"updated": True} + async def add_sub_event( self, - type_: str, - event: Union[ - SubscriptionCreate, - SubscriptionImport, - SubscriptionUpdate, - SubscriptionCancel, - ], + type_: SubscriptionEventType, + event: SubscriptionEventAny, oid: UUID, - ) -> None: + ) -> str: """add a subscription event to the db""" data = event.dict(exclude_unset=True) data["type"] = type_ data["timestamp"] = dt_now() data["oid"] = oid - await self.subs.insert_one(data) + res = await self.subs.insert_one(data) + return str(res.inserted_id) - def _get_sub_by_type_from_data(self, data: dict[str, object]) -> Union[ - SubscriptionCreateOut, - SubscriptionImportOut, - SubscriptionUpdateOut, - SubscriptionCancelOut, - ]: + def _get_sub_by_type_from_data( + self, data: dict[str, object] + ) -> SubscriptionEventAnyOut: """convert dict to propert background job type""" if data["type"] == "create": return SubscriptionCreateOut(**data) @@ -255,7 +277,12 @@ def _get_sub_by_type_from_data(self, data: dict[str, object]) -> Union[ return SubscriptionImportOut(**data) if data["type"] == "update": return SubscriptionUpdateOut(**data) - return SubscriptionCancelOut(**data) + if data["type"] == "cancel": + return SubscriptionCancelOut(**data) + if data["type"] == "add-minutes": + return SubscriptionAddMinutesOut(**data) + + raise HTTPException(status_code=500, detail="unknown sub event") # pylint: disable=too-many-arguments async def list_sub_events( @@ -264,19 +291,13 @@ async def list_sub_events( sub_id: Optional[str] = None, oid: Optional[UUID] = None, plan_id: Optional[str] = None, + type_: Optional[SubscriptionEventType] = None, page_size: int = DEFAULT_PAGE_SIZE, page: int = 1, sort_by: Optional[str] = None, sort_direction: Optional[int] = -1, ) -> Tuple[ - List[ - Union[ - SubscriptionCreateOut, - SubscriptionImportOut, - SubscriptionUpdateOut, - SubscriptionCancelOut, - ] - ], + List[SubscriptionEventAnyOut], int, ]: """list subscription events""" @@ -294,6 +315,8 @@ async def list_sub_events( query["planId"] = plan_id if oid: query["oid"] = oid + if type_: + query["type"] = type_ aggregate = [{"$match": query}] @@ -363,11 +386,11 @@ async def get_billing_portal_url( async with aiohttp.ClientSession() as session: async with session.request( "POST", - external_subs_app_api_url, + f"{external_subs_app_api_url}/portalUrl", headers={ "Authorization": "bearer " + external_subs_app_api_key }, - json=req.dict(), + json=req.model_dump(), raise_for_status=True, ) as resp: json = await resp.json() @@ -378,14 +401,80 @@ async def get_billing_portal_url( return SubscriptionPortalUrlResponse() + async def get_execution_minutes_price(self, org: Organization): + """Fetch price for addon execution minutes from external subscription app""" + if not org.subscription: + raise HTTPException( + status_code=404, detail="Organization has no subscription" + ) + if external_subs_app_api_url: + try: + async with aiohttp.ClientSession() as session: + async with session.request( + "GET", + f"{external_subs_app_api_url}/prices/additionalMinutes", + headers={ + "Authorization": "bearer " + external_subs_app_api_key, + }, + raise_for_status=True, + ) as resp: + json = await resp.json() + return AddonMinutesPricing(**json) + # pylint: disable=broad-exception-caught + except Exception as exc: + print("Error fetching execution minutes price", exc) + + async def get_checkout_url( + self, + org: Organization, + headers: dict[str, str], + minutes: int | None, + ): + """Create checkout url for additional minutes""" + if not org.subscription: + raise HTTPException( + status_code=404, detail="Organization has no subscription" + ) + subscription_id = org.subscription.subId + return_url = f"{get_origin(headers)}/orgs/{org.slug}/settings/billing" + + if external_subs_app_api_url: + try: + req = CheckoutAddonMinutesRequest( + orgId=str(org.id), + subId=subscription_id, + minutes=minutes, + return_url=return_url, + ) + async with aiohttp.ClientSession() as session: + async with session.request( + "POST", + f"{external_subs_app_api_url}/checkout/additionalMinutes", + headers={ + "Authorization": "bearer " + external_subs_app_api_key, + "Content-Type": "application/json", + }, + json=req.model_dump(), + raise_for_status=True, + ) as resp: + json = await resp.json() + print(f"get_checkout_url got response: {json}") + return CheckoutAddonMinutesResponse(**json) + # pylint: disable=broad-exception-caught + except Exception as exc: + print("Error fetching checkout url", exc) + raise HTTPException( + status_code=500, detail="Error fetching checkout url" + ) from exc + -# pylint: disable=invalid-name,too-many-arguments +# pylint: disable=invalid-name,too-many-arguments,too-many-locals def init_subs_api( - app, - mdb, + app: APIRouter, + mdb: AsyncIOMotorDatabase[Any], org_ops: OrgOps, user_manager: UserManager, - user_or_shared_secret_dep: Callable, + superuser_or_shared_secret_dep: Callable[[str], Awaitable[User]], ) -> Optional[SubOps]: """init subs API""" @@ -402,14 +491,14 @@ def init_subs_api( async def new_sub( create: SubscriptionCreate, request: Request, - user: User = Depends(user_or_shared_secret_dep), + user: User = Depends(superuser_or_shared_secret_dep), ): return await ops.create_new_subscription(create, user, request) @app.post( "/subscriptions/import", tags=["subscriptions"], - dependencies=[Depends(user_or_shared_secret_dep)], + dependencies=[Depends(superuser_or_shared_secret_dep)], response_model=AddedResponseId, ) async def import_sub(sub_import: SubscriptionImport): @@ -418,7 +507,7 @@ async def import_sub(sub_import: SubscriptionImport): @app.post( "/subscriptions/update", tags=["subscriptions"], - dependencies=[Depends(user_or_shared_secret_dep)], + dependencies=[Depends(superuser_or_shared_secret_dep)], response_model=UpdatedResponse, ) async def update_subscription( @@ -429,7 +518,7 @@ async def update_subscription( @app.post( "/subscriptions/cancel", tags=["subscriptions"], - dependencies=[Depends(user_or_shared_secret_dep)], + dependencies=[Depends(superuser_or_shared_secret_dep)], response_model=SubscriptionCanceledResponse, ) async def cancel_subscription( @@ -440,7 +529,7 @@ async def cancel_subscription( @app.post( "/subscriptions/send-trial-end-reminder", tags=["subscriptions"], - dependencies=[Depends(user_or_shared_secret_dep)], + dependencies=[Depends(superuser_or_shared_secret_dep)], response_model=SuccessResponse, ) async def send_trial_end_reminder( @@ -448,12 +537,21 @@ async def send_trial_end_reminder( ): return await ops.send_trial_end_reminder(reminder) + @app.post( + "/subscriptions/add-minutes", + tags=["subscriptions"], + dependencies=[Depends(superuser_or_shared_secret_dep)], + response_model=UpdatedResponse, + ) + async def add_sub_minutes(add_min: SubscriptionAddMinutes): + return await ops.add_sub_minutes(add_min) + assert org_ops.router @app.get( "/subscriptions/is-activated/{sub_id}", tags=["subscriptions"], - dependencies=[Depends(user_or_shared_secret_dep)], + dependencies=[Depends(superuser_or_shared_secret_dep)], response_model=SuccessResponse, ) async def is_subscription_activated( @@ -465,7 +563,7 @@ async def is_subscription_activated( @app.get( "/subscriptions/events", tags=["subscriptions"], - dependencies=[Depends(user_or_shared_secret_dep)], + dependencies=[Depends(superuser_or_shared_secret_dep)], response_model=PaginatedSubscriptionEventResponse, ) async def get_sub_events( @@ -473,6 +571,7 @@ async def get_sub_events( subId: Optional[str] = None, oid: Optional[UUID] = None, planId: Optional[str] = None, + type_: Annotated[Optional[SubscriptionEventType], Query(alias="type")] = None, pageSize: int = DEFAULT_PAGE_SIZE, page: int = 1, sortBy: Optional[str] = "timestamp", @@ -484,6 +583,7 @@ async def get_sub_events( oid=oid, plan_id=planId, page_size=pageSize, + type_=type_, page=page, sort_by=sortBy, sort_direction=sortDirection, @@ -501,4 +601,26 @@ async def get_billing_portal_url( ): return await ops.get_billing_portal_url(org, dict(request.headers)) + @org_ops.router.get( + "/price/execution-minutes", + tags=["organizations"], + response_model=AddonMinutesPricing | None, + ) + async def get_execution_minutes_price( + org: Organization = Depends(org_ops.org_owner_dep), + ): + return await ops.get_execution_minutes_price(org) + + @org_ops.router.get( + "/checkout/execution-minutes", + tags=["organizations"], + response_model=CheckoutAddonMinutesResponse, + ) + async def get_execution_minutes_checkout_url( + request: Request, + minutes: int | None = None, + org: Organization = Depends(org_ops.org_owner_dep), + ): + return await ops.get_checkout_url(org, dict(request.headers), minutes) + return ops diff --git a/backend/btrixcloud/users.py b/backend/btrixcloud/users.py index f458238570..8ba9f07e02 100644 --- a/backend/btrixcloud/users.py +++ b/backend/btrixcloud/users.py @@ -596,7 +596,7 @@ def init_user_manager(mdb, emailsender, invites): def init_users_api(app, user_manager: UserManager): """init fastapi_users""" - auth_jwt_router, current_active_user, shared_secret_or_active_user = init_jwt_auth( + auth_jwt_router, current_active_user, shared_secret_or_superuser = init_jwt_auth( user_manager ) @@ -618,7 +618,7 @@ def init_users_api(app, user_manager: UserManager): tags=["users"], ) - return current_active_user, shared_secret_or_active_user + return current_active_user, shared_secret_or_superuser # ============================================================================ diff --git a/backend/test/echo_server.py b/backend/test/echo_server.py index 0da8715a4e..097bb2d301 100644 --- a/backend/test/echo_server.py +++ b/backend/test/echo_server.py @@ -2,6 +2,7 @@ """ A web server to record POST requests and return them on a GET request """ + from http.server import HTTPServer, BaseHTTPRequestHandler import json @@ -29,6 +30,14 @@ def do_POST(self): "utf-8" ) ) + elif self.path.endswith("/checkout/additionalMinutes"): + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write( + json.dumps( + {"checkoutUrl": "https://checkout.example.com/path/"} + ).encode("utf-8") + ) else: self.end_headers() diff --git a/backend/test/test_org_subs.py b/backend/test/test_org_subs.py index 429a32bde9..916d4c6adf 100644 --- a/backend/test/test_org_subs.py +++ b/backend/test/test_org_subs.py @@ -334,6 +334,16 @@ def test_get_billing_portal_url(admin_auth_headers, echo_server): assert r.json() == {"portalUrl": "https://portal.example.com/path/"} +def test_get_addon_minutes_checkout_url(admin_auth_headers, echo_server): + r = requests.get( + f"{API_PREFIX}/orgs/{new_subs_oid}/checkout/execution-minutes", + headers=admin_auth_headers, + ) + assert r.status_code == 200 + + assert r.json() == {"checkoutUrl": "https://checkout.example.com/path/"} + + def test_cancel_sub_and_delete_org(admin_auth_headers): # cancel, resulting in org deletion r = requests.post( @@ -752,7 +762,7 @@ def test_subscription_events_log_filter_sort(admin_auth_headers): last_id = None for event in events: - sub_id = event["subId"] + sub_id = event.get("subId") if last_id: assert last_id <= sub_id last_id = sub_id @@ -768,7 +778,7 @@ def test_subscription_events_log_filter_sort(admin_auth_headers): last_id = None for event in events: - sub_id = event["subId"] + sub_id = event.get("subId") if last_id: assert last_id >= sub_id last_id = sub_id @@ -905,3 +915,57 @@ def test_subscription_events_log_filter_sort(admin_auth_headers): assert last_id >= cancel_date if cancel_date: last_date = cancel_date + + +def test_subscription_add_minutes(admin_auth_headers): + r = requests.post( + f"{API_PREFIX}/subscriptions/add-minutes", + headers=admin_auth_headers, + json={ + "oid": str(new_subs_oid_2), + "minutes": 75, + "totalPrice": 350, + "currency": "usd", + "paymentId": "789", + }, + ) + + assert r.status_code == 200 + assert r.json() == {"updated": True} + + # get event from log + r = requests.get( + f"{API_PREFIX}/subscriptions/events?oid={new_subs_oid_2}&type=add-minutes", + headers=admin_auth_headers, + ) + assert r.status_code == 200 + data = r.json() + assert len(data["items"]) == 1 + event = data["items"][0] + + assert event["type"] == "add-minutes" + assert event["oid"] == new_subs_oid_2 + assert event["minutes"] == 75 + assert event["totalPrice"] == 350 + assert event["currency"] == "usd" + assert event["paymentId"] == "789" + + # check org quota updates for corresponding entry + r = requests.get( + f"{API_PREFIX}/orgs/{new_subs_oid_2}", + headers=admin_auth_headers, + ) + + assert r.status_code == 200 + quota_updates = r.json()["quotaUpdates"] + assert len(quota_updates) + last_update = quota_updates[-1] + assert "subEventId" not in last_update + assert last_update["update"] == { + "maxPagesPerCrawl": 100, + "storageQuota": 1000000, + "extraExecMinutes": 75, # only this value updated from previous + "giftedExecMinutes": 0, + "maxConcurrentCrawls": 1, + "maxExecMinutesPerMonth": 1000, + } diff --git a/backend/test_nightly/conftest.py b/backend/test_nightly/conftest.py index d27f73cc86..0fc5887e03 100644 --- a/backend/test_nightly/conftest.py +++ b/backend/test_nightly/conftest.py @@ -13,6 +13,8 @@ CRAWLER_USERNAME = "crawlernightly@example.com" CRAWLER_PW = "crawlerPASSWORD!" +PRESHARED_SECRET_PW = "TEST_PRESHARED_SECRET_PASSWORD" + @pytest.fixture(scope="session") def admin_auth_headers(): @@ -33,6 +35,11 @@ def admin_auth_headers(): time.sleep(5) +@pytest.fixture(scope="session") +def preshared_secret_auth_headers(): + return {"Authorization": f"Bearer {PRESHARED_SECRET_PW}"} + + @pytest.fixture(scope="session") def default_org_id(admin_auth_headers): while True: diff --git a/backend/test_nightly/test_execution_minutes_quota.py b/backend/test_nightly/test_execution_minutes_quota.py index d84ca09d04..20f34457e5 100644 --- a/backend/test_nightly/test_execution_minutes_quota.py +++ b/backend/test_nightly/test_execution_minutes_quota.py @@ -1,20 +1,21 @@ import math -import requests import time -import pytest - from typing import Dict +import pytest +import requests + from .conftest import API_PREFIX from .utils import get_crawl_status - EXEC_MINS_QUOTA = 1 EXEC_SECS_QUOTA = EXEC_MINS_QUOTA * 60 GIFTED_MINS_QUOTA = 3 GIFTED_SECS_QUOTA = GIFTED_MINS_QUOTA * 60 EXTRA_MINS_QUOTA = 5 EXTRA_SECS_QUOTA = EXTRA_MINS_QUOTA * 60 +EXTRA_MINS_ADDED_QUOTA = 7 +EXTRA_SECS_ADDED_QUOTA = EXTRA_MINS_ADDED_QUOTA * 60 def test_set_execution_mins_quota(org_with_quotas, admin_auth_headers): diff --git a/chart/test/test.yaml b/chart/test/test.yaml index 1add7c53f8..292b1cf521 100644 --- a/chart/test/test.yaml +++ b/chart/test/test.yaml @@ -16,6 +16,9 @@ operator_resync_seconds: 3 qa_scale: 2 +backend_memory: "500Mi" + + # lower storage sizes redis_storage: "100Mi" profile_browser_workdir_size: "100Mi" diff --git a/frontend/src/components/ui/floating-popover.ts b/frontend/src/components/ui/floating-popover.ts new file mode 100644 index 0000000000..318f303c59 --- /dev/null +++ b/frontend/src/components/ui/floating-popover.ts @@ -0,0 +1,244 @@ +import { type VirtualElement } from "@shoelace-style/shoelace/dist/components/popup/popup.component.js"; +import SlTooltip from "@shoelace-style/shoelace/dist/components/tooltip/tooltip.component.js"; +import slTooltipStyles from "@shoelace-style/shoelace/dist/components/tooltip/tooltip.styles.js"; +import { css, html, type PropertyValues } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { classMap } from "lit/directives/class-map.js"; + +/** Re-implemented from Shoelace, since it's not exported */ +function parseDuration(delay: number | string) { + delay = delay.toString().toLowerCase(); + + if (delay.indexOf("ms") > -1) { + return parseFloat(delay); + } + + if (delay.indexOf("s") > -1) { + return parseFloat(delay) * 1000; + } + + return parseFloat(delay); +} + +/** + * Floating popovers are used to show labels and additional details in data visualizations. + * They're hidden until hover, and follow the cursor within the anchor element. + * + * Importantly, they are not interactive and do not respond to user input via keyboard. + * Their content will not be accessible to screen readers or other assistive technologies. + * + * @attr {String} content + * @attr {String} placement + * @attr {String} distance + * @attr {String} trigger + * @attr {Boolean} open + * @attr {Boolean} disabled + */ +@customElement("btrix-floating-popover") +export class FloatingPopover extends SlTooltip { + @property({ type: Boolean, reflect: true }) + hoist = true; + + @property({ type: String, reflect: true }) + placement: SlTooltip["placement"] = "bottom"; + + @property({ type: String, reflect: true }) + lock: "x" | "y" | "x y" | "" = "y"; + + clientX: number | null = 0; + clientY: number | null = 0; + + isHovered = false; + + private get slottedChildren() { + const slot = this.shadowRoot!.querySelector("slot"); + return slot?.assignedElements({ flatten: true }); + } + + get anchor(): VirtualElement { + let originalRect: DOMRect | undefined; + if (this.lock !== "") { + originalRect = this.slottedChildren?.[0].getBoundingClientRect(); + } + return { + getBoundingClientRect: () => { + return new DOMRect( + (this.hasLock("x") ? originalRect?.x : this.clientX) ?? 0, + (this.hasLock("y") ? originalRect?.y : this.clientY) ?? 0, + this.hasLock("x") ? originalRect?.width : 0, + this.hasLock("y") ? originalRect?.height : 0, + ); + }, + }; + } + + static styles = [ + slTooltipStyles, + css` + :host { + --btrix-border: 1px solid var(--sl-color-neutral-300); + --sl-tooltip-border-radius: var(--sl-border-radius-large); + --sl-tooltip-background-color: var(--sl-color-neutral-50); + --sl-tooltip-color: var(--sl-color-neutral-700); + --sl-tooltip-font-size: var(--sl-font-size-x-small); + --sl-tooltip-padding: var(--sl-spacing-small); + --sl-tooltip-line-height: var(--sl-line-height-dense); + } + + .tooltip__body { + border: var(--btrix-border); + box-shadow: var(--sl-shadow-small), var(--sl-shadow-large); + } + + ::part(popup) { + pointer-events: none; + } + + ::part(arrow) { + z-index: 1; + } + + [data-current-placement^="bottom"]::part(arrow), + [data-current-placement^="left"]::part(arrow) { + border-top: var(--btrix-border); + } + + [data-current-placement^="bottom"]::part(arrow), + [data-current-placement^="right"]::part(arrow) { + border-left: var(--btrix-border); + } + + [data-current-placement^="top"]::part(arrow), + [data-current-placement^="right"]::part(arrow) { + border-bottom: var(--btrix-border); + } + + [data-current-placement^="top"]::part(arrow), + [data-current-placement^="left"]::part(arrow) { + border-right: var(--btrix-border); + } + `, + ]; + + constructor() { + super(); + this.addEventListener("mouseover", this.overrideHandleMouseOver); + this.addEventListener("mouseout", this.overrideHandleMouseOut); + } + + override render() { + return html` + + + + + + `; + } + + connectedCallback(): void { + super.connectedCallback(); + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + document.body.removeEventListener("mousemove", this.handleMouseMove); + } + + async handleOptionsChange() { + if (this.hasUpdated) { + await this.updateComplete; + this.popup.reposition(); + } + } + + hasChanged(changedProps: PropertyValues) { + if ( + ( + [ + "content", + "distance", + "hoist", + "placement", + "skidding", + ] as (keyof FloatingPopover)[] + ).some(changedProps.has) + ) { + void this.handleOptionsChange(); + } + } + + handleMouseMove = (event: MouseEvent) => { + if (this.isHovered) { + this.clientX = event.clientX; + this.clientY = event.clientY; + this.popup.reposition(); + } + }; + + private readonly overrideHandleMouseOver = (event: MouseEvent) => { + if (this.overrideHasTrigger("hover")) { + this.isHovered = true; + this.clientX = event.clientX; + this.clientY = event.clientY; + document.body.addEventListener("mousemove", this.handleMouseMove); + const delay = parseDuration( + getComputedStyle(this).getPropertyValue("--show-delay"), + ); + // @ts-expect-error need to access SlTooltip's hoverTimeout + clearTimeout(this.hoverTimeout as number | undefined); + // @ts-expect-error need to access SlTooltip's hoverTimeout + this.hoverTimeout = window.setTimeout(async () => this.show(), delay); + } + }; + + private readonly overrideHandleMouseOut = () => { + if (this.overrideHasTrigger("hover")) { + this.isHovered = false; + document.body.removeEventListener("mousemove", this.handleMouseMove); + const delay = parseDuration( + getComputedStyle(this).getPropertyValue("--hide-delay"), + ); + // @ts-expect-error need to access SlTooltip's hoverTimeout + clearTimeout(this.hoverTimeout as number | undefined); + // @ts-expect-error need to access SlTooltip's hoverTimeout + this.hoverTimeout = window.setTimeout(async () => this.hide(), delay); + } + }; + + private readonly overrideHasTrigger = (triggerType: string) => { + const triggers = this.trigger.split(" "); + return triggers.includes(triggerType); + }; + + private readonly hasLock = (lockType: "x" | "y") => { + const locks = this.lock.split(" "); + return locks.includes(lockType); + }; +} diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts index 632598bc7c..10fda37edd 100644 --- a/frontend/src/components/ui/index.ts +++ b/frontend/src/components/ui/index.ts @@ -22,6 +22,7 @@ import("./details"); import("./file-input"); import("./file-list"); import("./filter-chip"); +import("./floating-popover"); import("./format-date"); import("./inline-input"); import("./language-select"); diff --git a/frontend/src/components/ui/meter.ts b/frontend/src/components/ui/meter.ts index b9667755e6..d616ef4f85 100644 --- a/frontend/src/components/ui/meter.ts +++ b/frontend/src/components/ui/meter.ts @@ -5,9 +5,7 @@ import { query, queryAssignedElements, } from "lit/decorators.js"; -import { classMap } from "lit/directives/class-map.js"; import { ifDefined } from "lit/directives/if-defined.js"; -import { when } from "lit/directives/when.js"; import debounce from "lodash/fp/debounce"; import { TailwindElement } from "@/classes/TailwindElement"; @@ -20,78 +18,45 @@ export class MeterBar extends TailwindElement { @property({ type: Number }) value = 0; - // postcss-lit-disable-next-line - static styles = css` - :host { - display: contents; - } - - .bar { - height: 1rem; - background-color: var(--background-color, var(--sl-color-blue-500)); - min-width: 4px; - transition: 400ms width; - } - `; + @property({ type: String }) + placement: "top" | "bottom" = "top"; - render() { - if (this.value <= 0) { - return; + updated(changedProperties: PropertyValues) { + if (changedProperties.has("value")) { + this.style.width = `${this.value}%`; + if (this.value <= 0) { + this.style.display = "none"; + } else { + this.style.display = ""; + } } - return html` -
-
-
`; } -} - -@customElement("btrix-divided-meter-bar") -export class DividedMeterBar extends TailwindElement { - /* Percentage of value / max */ - @property({ type: Number }) - value = 0; - - @property({ type: Number }) - quota = 0; + // postcss-lit-disable-next-line static styles = css` :host { - display: contents; - } - - .bar { - height: 1rem; - background-color: var(--background-color, var(--sl-color-blue-400)); + display: block; + --background-color: var(--background-color, var(--sl-color-blue-500)); + overflow: hidden; + transition: box-shadow 150ms cubic-bezier(0.4, 0, 0.2, 1); min-width: 4px; + transition: 400ms width; } - .rightBorderRadius { - border-radius: 0 var(--sl-border-radius-medium) - var(--sl-border-radius-medium) 0; - } - - .quotaBar { + .bar { height: 1rem; - background-color: var(--quota-background-color, var(--sl-color-blue-100)); - min-width: 4px; - box-shadow: inset 0px 1px 1px 0px rgba(0, 0, 0, 0.25); + background-color: var(--background-color); } `; render() { - return html` + if (this.value <= 0) { + return; + } + return html`
-
- ${when(this.value, () => { - return html`
`; - })} -
-
`; +
+ `; } } @@ -117,6 +82,9 @@ export class Meter extends TailwindElement { @property({ type: String }) valueText?: string; + @property({ type: Boolean }) + hasBackground = false; + @query(".labels") private readonly labels?: HTMLElement; @@ -145,14 +113,56 @@ export class Meter extends TailwindElement { height: 1rem; border-radius: var(--sl-border-radius-medium); background-color: var(--sl-color-neutral-100); - box-shadow: inset 0px 1px 1px 0px rgba(0, 0, 0, 0.25); + box-shadow: inset 0 0 0 1px var(--sl-color-neutral-300); + position: relative; } .valueBar { + box-shadow: var(--sl-shadow-medium); + } + + .valueBar:after, + .track:after { + content: ""; + position: absolute; + top: 100%; + right: 0; + width: 1px; + height: 6px; + background-color: var(--sl-color-neutral-400); + pointer-events: none; + z-index: -1; + } + + .valueBar[data-empty]::after { + right: unset; + left: 0; + } + + .valueBar, + .background { display: flex; border-radius: var(--sl-border-radius-medium); - overflow: hidden; transition: 400ms width; + position: relative; + } + + .valueBar::before, + .background::before { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border-radius: var(--sl-border-radius-medium); + content: ""; + box-shadow: inset 0 0 0 1px var(--sl-color-neutral-500); + mix-blend-mode: color-burn; + pointer-events: none; + } + + .valueBar::before { + z-index: 1; } .labels { @@ -161,8 +171,6 @@ export class Meter extends TailwindElement { white-space: nowrap; color: var(--sl-color-neutral-500); font-size: var(--sl-font-size-x-small); - font-family: var(--font-monostyle-family); - font-variation-settings: var(--font-monostyle-variation); line-height: 1; margin-top: var(--sl-spacing-x-small); } @@ -183,6 +191,70 @@ export class Meter extends TailwindElement { .maxText { display: inline-flex; } + + .valueBar ::slotted(btrix-meter-bar) { + position: relative; + transition-property: box-shadow, opacity; + transition-duration: 150ms; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + border-radius: 1px; + --darkened-background-color-1: oklch( + from var(--background-color) calc(l - 0.2) c h + ); + --darkened-background-color-2: oklch( + from var(--background-color) calc(l - 0.1) calc(c + 0.1) h / 0.5 + ); + } + + .valueBar ::slotted(btrix-meter-bar):after { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: var(--sl-color-neutral-100); + opacity: 0; + transition-property: opacity; + transition-duration: 150ms; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + pointer-events: none; + } + + .valueBar:hover ::slotted(btrix-meter-bar:not(:hover)):after { + opacity: 0.5; + } + + .valueBar:hover ::slotted(btrix-meter-bar:hover) { + box-shadow: + 0 0 0 1px var(--darkened-background-color-1), + 0 1px 3px 0 var(--darkened-background-color-2), + 0 1px 2px -1px var(--darkened-background-color-2); + z-index: 1; + } + + .background { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 1rem; + border-radius: var(--sl-border-radius-medium); + overflow: hidden; + } + + .valueBar ::slotted(btrix-meter-bar:first-of-type), + .valueBar ::slotted(btrix-meter-bar:first-of-type):after, + .valueBar:hover ::slotted(btrix-meter-bar:first-of-type) { + border-top-left-radius: var(--sl-border-radius-medium); + border-bottom-left-radius: var(--sl-border-radius-medium); + } + .valueBar ::slotted(btrix-meter-bar:last-of-type), + .valueBar ::slotted(btrix-meter-bar:last-of-type):after, + .valueBar:hover ::slotted(btrix-meter-bar:last-of-type) { + border-top-right-radius: var(--sl-border-radius-medium); + border-bottom-right-radius: var(--sl-border-radius-medium); + } `; @queryAssignedElements({ selector: "btrix-meter-bar" }) @@ -224,7 +296,16 @@ export class Meter extends TailwindElement { >} >
-
+ ${this.hasBackground + ? html`
+ +
` + : null} +
${this.value < max ? html`` : ""} diff --git a/frontend/src/features/archived-items/archived-item-state-filter.ts b/frontend/src/features/archived-items/archived-item-state-filter.ts index 541db1e4b7..ce0642c4cd 100644 --- a/frontend/src/features/archived-items/archived-item-state-filter.ts +++ b/frontend/src/features/archived-items/archived-item-state-filter.ts @@ -107,7 +107,7 @@ export class ArchivedItemStateFilter extends BtrixElement {
; diff --git a/frontend/src/features/meters/execution-minutes/execution-minute-meter.ts b/frontend/src/features/meters/execution-minutes/execution-minute-meter.ts new file mode 100644 index 0000000000..e55bf9c4ba --- /dev/null +++ b/frontend/src/features/meters/execution-minutes/execution-minute-meter.ts @@ -0,0 +1,255 @@ +import { localized, msg } from "@lit/localize"; +import { html } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { when } from "lit/directives/when.js"; + +import { executionMinuteColors } from "./colors"; +import { renderBar, type RenderBarProps } from "./render-bar"; +import { tooltipRow } from "./tooltip"; + +import { BtrixElement } from "@/classes/BtrixElement"; +import { renderLegendColor } from "@/features/meters/utils/legend"; +import { type Metrics } from "@/types/org"; +import { humanizeExecutionSeconds } from "@/utils/executionTimeFormatter"; + +export type Bucket = "monthly" | "gifted" | "extra"; + +const EXEC_MINUTE_ORDER = [ + "monthly", + "gifted", + "extra", +] as const satisfies Bucket[]; + +@customElement("btrix-execution-minute-meter") +@localized() +export class ExecutionMinuteMeter extends BtrixElement { + @property({ type: Object }) + metrics?: Metrics; + + render() { + if (!this.metrics) return; + return this.renderExecutionMinuteMeter2(); + } + + private readonly renderExecutionMinuteMeter2 = () => { + if (!this.org) return; + + const now = new Date(); + const currentYear = now.getFullYear(); + const currentMonth = String(now.getUTCMonth() + 1).padStart(2, "0"); + const currentPeriod = `${currentYear}-${currentMonth}`; + + /** Usages in seconds */ + const usage = { + monthly: this.org.monthlyExecSeconds?.[currentPeriod] ?? 0, + extra: this.org.extraExecSeconds?.[currentPeriod] ?? 0, + gifted: this.org.giftedExecSeconds?.[currentPeriod] ?? 0, + total: + (this.org.monthlyExecSeconds?.[currentPeriod] ?? 0) + + (this.org.extraExecSeconds?.[currentPeriod] ?? 0) + + (this.org.giftedExecSeconds?.[currentPeriod] ?? 0), + }; + + /** Quotas in seconds */ + const quotas = { + monthly: this.org.quotas.maxExecMinutesPerMonth * 60, + extra: this.org.extraExecSecondsAvailable + usage.extra, + gifted: this.org.giftedExecSecondsAvailable + usage.gifted, + total: + this.org.quotas.maxExecMinutesPerMonth * 60 + + this.org.extraExecSecondsAvailable + + usage.extra + + this.org.giftedExecSecondsAvailable + + usage.gifted, + }; + + if (Math.abs(quotas.extra - this.org.quotas.extraExecMinutes * 60) > 0) { + console.debug("WARN extra minutes doesn't match quotas", { + quota: quotas.extra, + usage: usage.extra, + available: this.org.extraExecSecondsAvailable, + expected: this.org.quotas.extraExecMinutes * 60, + }); + } + + if (Math.abs(quotas.gifted - this.org.quotas.giftedExecMinutes * 60) > 0) { + console.debug("WARN gifted minutes doesn't match quotas", { + quota: quotas.gifted, + usage: usage.gifted, + available: this.org.giftedExecSecondsAvailable, + expected: this.org.quotas.giftedExecMinutes * 60, + }); + } + + /** Width values in reference to the total width of the value bar (usage.total) */ + const usedValues = { + monthly: usage.total === 0 ? 0 : usage.monthly / usage.total, + extra: usage.total === 0 ? 0 : usage.extra / usage.total, + gifted: usage.total === 0 ? 0 : usage.gifted / usage.total, + }; + + /** Width values in reference to the total width of the meter (quotas.total) */ + const backgroundValues = { + monthly: (quotas.monthly - usage.monthly) / quotas.total, + extra: (quotas.extra - usage.extra) / quotas.total, + gifted: (quotas.gifted - usage.gifted) / quotas.total, + total: usage.total / quotas.total, + }; + + const hasQuota = + this.org.quotas.maxExecMinutesPerMonth > 0 || + this.org.quotas.extraExecMinutes > 0 || + this.org.quotas.giftedExecMinutes > 0; + const isReached = hasQuota && usage.total >= quotas.total; + + const foregroundTooltipContent = (currentBucket: Bucket) => { + const rows = EXEC_MINUTE_ORDER.filter((bucket) => usedValues[bucket] > 0); + if (rows.length < 2) return; + return html`
+ ${rows.map((bucket) => + tooltipRow( + { + monthly: msg("Monthly"), + extra: msg("Extra"), + gifted: msg("Gifted"), + }[bucket], + usage[bucket], + bucket === currentBucket, + executionMinuteColors[bucket].foreground, + ), + )} +
+ ${tooltipRow(msg("All used execution time"), usage.total)}`; + }; + + const backgroundTooltipContent = (currentBucket: Bucket) => { + const rows = EXEC_MINUTE_ORDER.filter( + (bucket) => backgroundValues[bucket] > 0, + ); + if (rows.length < 2) return; + return html`
+ ${rows.map((bucket) => + tooltipRow( + { + monthly: msg("Monthly Remaining"), + extra: msg("Extra Remaining"), + gifted: msg("Gifted Remaining"), + }[bucket], + quotas[bucket] - usage[bucket], + bucket === currentBucket, + executionMinuteColors[bucket].background, + ), + )} +
+ ${tooltipRow( + msg("All remaining execution time"), + quotas.total - usage.total, + )}`; + }; + + const foregroundBarConfig = (bucket: Bucket): RenderBarProps => ({ + value: usedValues[bucket], + usedSeconds: Math.min(usage[bucket], quotas[bucket]), + quotaSeconds: quotas[bucket], + totalQuotaSeconds: quotas.total, + title: html`${renderLegendColor( + executionMinuteColors[bucket].foreground, + )}${{ + monthly: msg("Used Monthly Execution Time"), + extra: msg("Used Extra Execution Time"), + gifted: msg("Used Gifted Execution Time"), + }[bucket]}`, + color: executionMinuteColors[bucket].foreground.primary, + highlight: "used", + content: foregroundTooltipContent(bucket), + }); + + const firstBackgroundBar = + EXEC_MINUTE_ORDER.find((group) => backgroundValues[group] !== 0) ?? + "monthly"; + + const backgroundBarConfig = (bucket: Bucket): RenderBarProps => ({ + value: + backgroundValues[bucket] + + // If the bucket is the first background bar, extend it to the width of the value bar + // plus its own value, so that it extends under the value bar's rounded corners + (bucket === firstBackgroundBar ? backgroundValues.total : 0), + title: html`${renderLegendColor( + executionMinuteColors[bucket].background, + )}${{ + monthly: msg("Remaining Monthly Execution Time"), + extra: msg("Remaining Extra Execution Time"), + gifted: msg("Remaining Gifted Execution Time"), + }[bucket]}`, + highlight: "available", + content: backgroundTooltipContent(bucket), + usedSeconds: Math.max(usage[bucket], quotas[bucket]), + quotaSeconds: quotas[bucket], + availableSeconds: Math.max(0, quotas[bucket] - usage[bucket]), + totalQuotaSeconds: Math.max(0, quotas.total - usage.total), + color: executionMinuteColors[bucket].background.primary, + }); + + return html` +
+ ${when( + isReached, + () => html` +
+ + ${msg("Execution Minutes Quota Reached")} +
+ `, + () => + hasQuota && this.org + ? html` + + ${humanizeExecutionSeconds(quotas.total - usage.total, { + style: "short", + round: "down", + displaySeconds: true, + })} + ${msg("remaining")} + + ` + : "", + )} +
+ ${when( + hasQuota && this.org, + () => html` +
+ + ${EXEC_MINUTE_ORDER.map((bucket) => + renderBar(foregroundBarConfig(bucket)), + )} + +
+ ${EXEC_MINUTE_ORDER.map((bucket) => + renderBar(backgroundBarConfig(bucket)), + )} +
+ + + ${humanizeExecutionSeconds(usage.total, { + style: "short", + round: "down", + })} + + + ${humanizeExecutionSeconds(quotas.total, { + style: "short", + })} + +
+
+ `, + )} + `; + }; +} diff --git a/frontend/src/features/meters/execution-minutes/render-bar.ts b/frontend/src/features/meters/execution-minutes/render-bar.ts new file mode 100644 index 0000000000..76f6caa2d3 --- /dev/null +++ b/frontend/src/features/meters/execution-minutes/render-bar.ts @@ -0,0 +1,52 @@ +import { html, type TemplateResult } from "lit"; + +import { tooltipContent } from "@/features/meters/utils/tooltip"; +import { humanizeExecutionSeconds } from "@/utils/executionTimeFormatter"; + +export type RenderBarProps = { + value: number; + usedSeconds: number; + quotaSeconds: number; + totalQuotaSeconds?: number; + title: string | TemplateResult; + content?: string | TemplateResult; + color: string; + highlight?: "used" | "available" | "totalAvailable"; + availableSeconds?: number; +}; + +export const renderBar = ({ + value, + usedSeconds, + quotaSeconds, + availableSeconds, + totalQuotaSeconds = quotaSeconds, + title, + content, + color, + highlight = "used", +}: RenderBarProps) => { + if (value === 0) return; + availableSeconds ??= quotaSeconds; + return html` + ${tooltipContent({ + title, + value: humanizeExecutionSeconds( + { + used: usedSeconds, + available: availableSeconds, + totalAvailable: totalQuotaSeconds, + }[highlight], + { + displaySeconds: true, + round: highlight === "used" ? "up" : "down", + }, + ), + content, + })} + `; +}; diff --git a/frontend/src/features/meters/execution-minutes/tooltip.ts b/frontend/src/features/meters/execution-minutes/tooltip.ts new file mode 100644 index 0000000000..20ad2a1e9e --- /dev/null +++ b/frontend/src/features/meters/execution-minutes/tooltip.ts @@ -0,0 +1,32 @@ +import clsx from "clsx"; +import { html } from "lit"; +import { ifDefined } from "lit/directives/if-defined.js"; + +import { renderLegendColor } from "@/features/meters/utils/legend"; +import { humanizeExecutionSeconds } from "@/utils/executionTimeFormatter"; +import { tw } from "@/utils/tailwind"; + +export const tooltipRow = ( + title: string, + value: number, + highlight = false, + color?: { primary: string; border: string }, +) => html` +

+ ${color ? renderLegendColor(color) : null}${title} + ${humanizeExecutionSeconds(value, { + round: "down", + displaySeconds: true, + })} +

+`; diff --git a/frontend/src/features/meters/has-quotas.ts b/frontend/src/features/meters/has-quotas.ts new file mode 100644 index 0000000000..360fad883b --- /dev/null +++ b/frontend/src/features/meters/has-quotas.ts @@ -0,0 +1,14 @@ +import { type OrgData } from "@/types/org"; + +export function hasExecutionMinuteQuota(org: OrgData | null | undefined) { + if (!org) return; + + return ( + org.quotas.maxExecMinutesPerMonth > 0 || + org.quotas.extraExecMinutes > 0 || + org.quotas.giftedExecMinutes > 0 + ); +} +export function hasStorageQuota(org: OrgData | null | undefined) { + return !!org?.quotas.storageQuota; +} diff --git a/frontend/src/features/meters/index.ts b/frontend/src/features/meters/index.ts new file mode 100644 index 0000000000..104150f862 --- /dev/null +++ b/frontend/src/features/meters/index.ts @@ -0,0 +1,2 @@ +import "./execution-minutes/execution-minute-meter"; +import "./storage/storage-meter"; diff --git a/frontend/src/features/meters/storage/colors.ts b/frontend/src/features/meters/storage/colors.ts new file mode 100644 index 0000000000..0862fda7a2 --- /dev/null +++ b/frontend/src/features/meters/storage/colors.ts @@ -0,0 +1,32 @@ +import { type Color } from "../utils/colors"; + +import { tw } from "@/utils/tailwind"; + +export type StorageType = + | "default" + | "crawls" + | "uploads" + | "archivedItems" + | "browserProfiles" + | "runningTime" + | "misc"; + +export const storageColorClasses = { + default: tw`text-neutral-600`, + crawls: tw`text-lime-500`, + uploads: tw`text-sky-500`, + archivedItems: tw`text-primary-500`, + browserProfiles: tw`text-orange-500`, + runningTime: tw`text-blue-600`, + misc: tw`text-gray-400`, +}; + +export const storageColors = { + default: { primary: "neutral-600", border: "neutral-700" }, + crawls: { primary: "lime-500", border: "lime-700" }, + uploads: { primary: "sky-500", border: "sky-700" }, + archivedItems: { primary: "primary-500", border: "primary-700" }, + browserProfiles: { primary: "orange-500", border: "orange-700" }, + runningTime: { primary: "blue-600", border: "blue-700" }, + misc: { primary: "gray-400", border: "gray-600" }, +} as const satisfies Record; diff --git a/frontend/src/features/meters/storage/storage-meter.ts b/frontend/src/features/meters/storage/storage-meter.ts new file mode 100644 index 0000000000..73047ff131 --- /dev/null +++ b/frontend/src/features/meters/storage/storage-meter.ts @@ -0,0 +1,162 @@ +import { localized, msg } from "@lit/localize"; +import { html } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; +import { when } from "lit/directives/when.js"; + +import { storageColors } from "./colors"; +import { tooltipRow } from "./tooltip"; + +import { BtrixElement } from "@/classes/BtrixElement"; +import { type Color } from "@/features/meters/utils/colors"; +import { renderLegendColor } from "@/features/meters/utils/legend"; +import { tooltipContent } from "@/features/meters/utils/tooltip"; +import { type Metrics } from "@/types/org"; + +const STORAGE_TYPES = ["crawls", "uploads", "browserProfiles", "misc"] as const; +type StorageType = (typeof STORAGE_TYPES)[number]; + +@customElement("btrix-storage-meter") +@localized() +export class StorageMeter extends BtrixElement { + @property({ type: Object }) + metrics?: Metrics; + + render() { + if (!this.metrics) return; + return this.renderStorageMeter(this.metrics); + } + + private readonly renderStorageMeter = (metrics: Metrics) => { + const hasQuota = Boolean(metrics.storageQuotaBytes); + const isStorageFull = + hasQuota && metrics.storageUsedBytes >= metrics.storageQuotaBytes; + const misc = metrics.storageUsedSeedFiles + metrics.storageUsedThumbnails; + + const values = { + crawls: metrics.storageUsedCrawls, + uploads: metrics.storageUsedUploads, + browserProfiles: metrics.storageUsedProfiles, + misc: misc, + } satisfies Record; + + const titles = { + crawls: msg("Crawls"), + uploads: msg("Uploads"), + browserProfiles: msg("Profiles"), + misc: msg("Miscellaneous"), + } satisfies Record; + + const nonZeroValues = STORAGE_TYPES.filter((type) => values[type] > 0); + + const renderBar = ( + values: Record, + titles: Record, + colors: Record, + key: StorageType, + ) => { + return html` + + ${tooltipContent({ + title: html`${renderLegendColor(colors[key])}${titles[key]}`, + value: this.localize.bytes(values[key], { + unitDisplay: "narrow", + }), + content: + nonZeroValues.length > 1 + ? html`
+ ${nonZeroValues.map((type) => + tooltipRow( + titles[type], + values[type], + type === key, + colors[type], + ), + )} +
+ ${tooltipRow( + msg("All used storage"), + metrics.storageUsedBytes, + )}` + : undefined, + })} +
+ `; + }; + + return html` +
+ ${when( + isStorageFull, + () => html` +
+ + ${msg("Storage is Full")} +
+ `, + () => + hasQuota + ? html` + ${this.localize.bytes( + metrics.storageQuotaBytes - metrics.storageUsedBytes, + )} + ${msg("available")} + ` + : "", + )} +
+ ${when( + hasQuota, + () => html` +
+ + ${nonZeroValues.map((type) => + when(values[type], () => + renderBar(values, titles, storageColors, type), + ), + )} + +
+ +
+
+ ${msg("Available Storage")} + ${this.localize.bytes( + metrics.storageQuotaBytes - metrics.storageUsedBytes, + { + unitDisplay: "narrow", + }, + )} +
+
+
+
+
+ ${this.localize.bytes(metrics.storageUsedBytes, { + unitDisplay: "narrow", + })} + ${this.localize.bytes(metrics.storageQuotaBytes, { + unitDisplay: "narrow", + })} +
+
+ `, + )} + `; + }; +} diff --git a/frontend/src/features/meters/storage/tooltip.ts b/frontend/src/features/meters/storage/tooltip.ts new file mode 100644 index 0000000000..098323612e --- /dev/null +++ b/frontend/src/features/meters/storage/tooltip.ts @@ -0,0 +1,26 @@ +import clsx from "clsx"; +import { html } from "lit"; + +import { renderLegendColor } from "@/features/meters/utils/legend"; +import localize from "@/utils/localize"; +import { tw } from "@/utils/tailwind"; + +export const tooltipRow = ( + title: string, + value: number, + highlight = false, + color?: { primary: string; border: string }, +) => html` +

+ ${color ? renderLegendColor(color) : null}${title} + ${localize.bytes(value)} +

+`; diff --git a/frontend/src/features/meters/utils/colors.ts b/frontend/src/features/meters/utils/colors.ts new file mode 100644 index 0000000000..f663285254 --- /dev/null +++ b/frontend/src/features/meters/utils/colors.ts @@ -0,0 +1,36 @@ +type ShoelaceColor = + | "neutral" + | "gray" + | "primary" + | "red" + | "orange" + | "amber" + | "yellow" + | "lime" + | "green" + | "emerald" + | "teal" + | "cyan" + | "sky" + | "blue" + | "indigo" + | "violet" + | "purple" + | "fuchsia" + | "pink" + | "rose"; + +type ShoelaceValue = + | "50" + | "100" + | "200" + | "300" + | "400" + | "500" + | "600" + | "700" + | "800" + | "900" + | "950"; + +export type Color = `${ShoelaceColor}-${ShoelaceValue}`; diff --git a/frontend/src/features/meters/utils/legend.ts b/frontend/src/features/meters/utils/legend.ts new file mode 100644 index 0000000000..81159b5cdd --- /dev/null +++ b/frontend/src/features/meters/utils/legend.ts @@ -0,0 +1,11 @@ +import { html } from "lit"; +import { styleMap } from "lit/directives/style-map.js"; + +export const renderLegendColor = (color: { primary: string; border: string }) => + html``; diff --git a/frontend/src/features/meters/utils/tooltip.ts b/frontend/src/features/meters/utils/tooltip.ts new file mode 100644 index 0000000000..a0edee2418 --- /dev/null +++ b/frontend/src/features/meters/utils/tooltip.ts @@ -0,0 +1,16 @@ +import { html, type TemplateResult } from "lit"; + +export const tooltipContent = ({ + title, + value, + content, +}: { + title: string | TemplateResult; + value: string | TemplateResult; + content: string | TemplateResult | undefined; +}) => + html`
+ ${title} + ${value} +
+ ${content}`; diff --git a/frontend/src/features/org/org-status-banner.ts b/frontend/src/features/org/org-status-banner.ts index 151f74d9fc..9db2c1e1b6 100644 --- a/frontend/src/features/org/org-status-banner.ts +++ b/frontend/src/features/org/org-status-banner.ts @@ -261,7 +261,7 @@ export class OrgStatusBanner extends BtrixElement { }), }, { - test: () => !readOnly && !!execMinutesQuotaReached, + test: () => !readOnly && !!execMinutesQuotaReached && !subscription, content: () => ({ title: msg( str`Your org has reached its monthly execution minutes limit`, @@ -271,6 +271,17 @@ export class OrgStatusBanner extends BtrixElement { ), }), }, + { + test: () => !readOnly && !!execMinutesQuotaReached && !!subscription, + content: () => ({ + title: msg(`Your org is out of execution minutes`), + detail: msg( + html`Any running crawls have been paused. To resume crawling, you + can purchase additional minutes or upgrade your monthly plan from + ${billingTabLink}.`, + ), + }), + }, ]; } } diff --git a/frontend/src/features/org/usage-history-table.ts b/frontend/src/features/org/usage-history-table.ts index 9aef8702fc..87404424f8 100644 --- a/frontend/src/features/org/usage-history-table.ts +++ b/frontend/src/features/org/usage-history-table.ts @@ -5,7 +5,10 @@ import { customElement } from "lit/decorators.js"; import { BtrixElement } from "@/classes/BtrixElement"; import type { GridColumn, GridItem } from "@/components/ui/data-grid/types"; import { noData } from "@/strings/ui"; -import { humanizeExecutionSeconds } from "@/utils/executionTimeFormatter"; +import { + humanizeExecutionSeconds, + humanizeSeconds, +} from "@/utils/executionTimeFormatter"; enum Field { Month = "month", @@ -116,7 +119,7 @@ export class UsageHistoryTable extends BtrixElement { if (org.quotas.maxExecMinutesPerMonth) { maxMonthlySeconds = org.quotas.maxExecMinutesPerMonth * 60; } - if (monthlySecondsUsed > maxMonthlySeconds) { + if (maxMonthlySeconds !== 0 && monthlySecondsUsed > maxMonthlySeconds) { monthlySecondsUsed = maxMonthlySeconds; } @@ -125,7 +128,7 @@ export class UsageHistoryTable extends BtrixElement { if (org.quotas.extraExecMinutes) { maxExtraSeconds = org.quotas.extraExecMinutes * 60; } - if (extraSecondsUsed > maxExtraSeconds) { + if (maxExtraSeconds !== 0 && extraSecondsUsed > maxExtraSeconds) { extraSecondsUsed = maxExtraSeconds; } @@ -134,14 +137,16 @@ export class UsageHistoryTable extends BtrixElement { if (org.quotas.giftedExecMinutes) { maxGiftedSeconds = org.quotas.giftedExecMinutes * 60; } - if (giftedSecondsUsed > maxGiftedSeconds) { + if (maxGiftedSeconds !== 0 && giftedSecondsUsed > maxGiftedSeconds) { giftedSecondsUsed = maxGiftedSeconds; } let totalSecondsUsed = org.crawlExecSeconds?.[mY] || 0; const totalMaxQuota = - maxMonthlySeconds + maxExtraSeconds + maxGiftedSeconds; - if (totalSecondsUsed > totalMaxQuota) { + maxMonthlySeconds !== 0 + ? maxMonthlySeconds + maxExtraSeconds + maxGiftedSeconds + : 0; + if (totalMaxQuota !== 0 && totalSecondsUsed > totalMaxQuota) { totalSecondsUsed = totalMaxQuota; } @@ -168,7 +173,17 @@ export class UsageHistoryTable extends BtrixElement { private readonly renderSecondsForField = (field: `${Field}`) => - ({ item }: { item: GridItem }) => html` - ${item[field] ? humanizeExecutionSeconds(+item[field]) : noData} - `; + ({ item }: { item: GridItem }) => { + if (!item[field]) return html`${noData}`; + + if (field === Field.ElapsedTime) + return html`${humanizeSeconds(+item[field], { displaySeconds: true })}`; + + if (field === Field.BillableExecutionTime) + return html`${humanizeExecutionSeconds(+item[field])}`; + + return html`${humanizeExecutionSeconds(+item[field], { + displaySeconds: true, + })}`; + }; } diff --git a/frontend/src/pages/org/dashboard.ts b/frontend/src/pages/org/dashboard.ts index 1780b0541c..b6b88a4df3 100644 --- a/frontend/src/pages/org/dashboard.ts +++ b/frontend/src/pages/org/dashboard.ts @@ -17,41 +17,20 @@ import type { SelectNewDialogEvent } from "."; import { BtrixElement } from "@/classes/BtrixElement"; import { parsePage, type PageChangeEvent } from "@/components/ui/pagination"; import { type CollectionSavedEvent } from "@/features/collections/collection-edit-dialog"; +import { storageColorClasses } from "@/features/meters/storage/colors"; import { pageHeading } from "@/layouts/page"; import { pageHeader } from "@/layouts/pageHeader"; import { RouteNamespace } from "@/routes"; import type { APIPaginatedList, APISortQuery } from "@/types/api"; import { CollectionAccess, type Collection } from "@/types/collection"; +import { type Metrics } from "@/types/org"; import { SortDirection } from "@/types/utils"; -import { humanizeExecutionSeconds } from "@/utils/executionTimeFormatter"; import { richText } from "@/utils/rich-text"; import { tw } from "@/utils/tailwind"; import { timeoutCache } from "@/utils/timeoutCache"; import { toShortUrl } from "@/utils/url-helpers"; import { cached } from "@/utils/weakCache"; -type Metrics = { - storageUsedBytes: number; - storageUsedCrawls: number; - storageUsedUploads: number; - storageUsedProfiles: number; - storageUsedSeedFiles: number; - storageUsedThumbnails: number; - storageQuotaBytes: number; - archivedItemCount: number; - crawlCount: number; - uploadCount: number; - pageCount: number; - crawlPageCount: number; - uploadPageCount: number; - profileCount: number; - workflowsRunningCount: number; - maxConcurrentCrawls: number; - workflowsQueuedCount: number; - collectionsCount: number; - publicCollectionsCount: number; -}; - enum CollectionGridView { All = "all", Public = "public", @@ -80,16 +59,6 @@ export class Dashboard extends BtrixElement { // Used for busting cache when updating visible collection cacheBust = 0; - private readonly colors = { - default: tw`text-neutral-600`, - crawls: tw`text-lime-500`, - uploads: tw`text-sky-500`, - archivedItems: tw`text-primary-500`, - browserProfiles: tw`text-orange-500`, - runningTime: tw`text-blue-600`, - misc: tw`text-gray-400`, - }; - private readonly collections = new Task(this, { task: cached( async ([orgId, collectionsView, collectionPage]) => { @@ -262,7 +231,7 @@ export class Dashboard extends BtrixElement { iconProps: { name: "gear-wide-connected", - class: this.colors.crawls, + class: storageColorClasses.crawls, }, button: { url: "/items/crawl", @@ -276,7 +245,10 @@ export class Dashboard extends BtrixElement { singleLabel: msg("Upload"), pluralLabel: msg("Uploads"), - iconProps: { name: "upload", class: this.colors.uploads }, + iconProps: { + name: "upload", + class: storageColorClasses.uploads, + }, button: { url: "/items/upload", }, @@ -290,7 +262,7 @@ export class Dashboard extends BtrixElement { pluralLabel: msg("Browser Profiles"), iconProps: { name: "window-fullscreen", - class: this.colors.browserProfiles, + class: storageColorClasses.browserProfiles, }, button: { url: "/browser-profiles", @@ -307,7 +279,7 @@ export class Dashboard extends BtrixElement { pluralLabel: msg("Archived Items"), iconProps: { name: "file-zip-fill", - class: this.colors.archivedItems, + class: storageColorClasses.archivedItems, }, button: { url: "/items", @@ -369,7 +341,7 @@ export class Dashboard extends BtrixElement { pluralLabel: msg("Pages Crawled"), iconProps: { name: "file-richtext-fill", - class: this.colors.crawls, + class: storageColorClasses.crawls, }, })} ${this.renderStat({ @@ -378,7 +350,7 @@ export class Dashboard extends BtrixElement { pluralLabel: msg("Pages Uploaded"), iconProps: { name: "file-richtext-fill", - class: this.colors.uploads, + class: storageColorClasses.uploads, }, })} ${this.renderStat({ @@ -560,7 +532,7 @@ export class Dashboard extends BtrixElement {
${msg("Miscellaneous")} @@ -621,360 +593,15 @@ export class Dashboard extends BtrixElement { } private renderStorageMeter(metrics: Metrics) { - const hasQuota = Boolean(metrics.storageQuotaBytes); - const isStorageFull = - hasQuota && metrics.storageUsedBytes >= metrics.storageQuotaBytes; - const misc = metrics.storageUsedSeedFiles + metrics.storageUsedThumbnails; - - const renderBar = ( - value: number, - label: string, - colorClassname: string, - ) => html` - -
${label}
-
-

- ${this.localize.bytes(value, { - unitDisplay: "narrow", - })} -
- ${this.renderPercentage(value / metrics.storageUsedBytes)} -

-
- `; - return html` -
- ${when( - isStorageFull, - () => html` -
- - ${msg("Storage is Full")} -
- `, - () => - hasQuota - ? html` - ${this.localize.bytes( - metrics.storageQuotaBytes - metrics.storageUsedBytes, - )} - ${msg("available")} - ` - : "", - )} -
- ${when( - hasQuota, - () => html` -
- - ${when(metrics.storageUsedCrawls, () => - renderBar( - metrics.storageUsedCrawls, - msg("Crawls"), - this.colors.crawls, - ), - )} - ${when(metrics.storageUsedUploads, () => - renderBar( - metrics.storageUsedUploads, - msg("Uploads"), - this.colors.uploads, - ), - )} - ${when(metrics.storageUsedProfiles, () => - renderBar( - metrics.storageUsedProfiles, - msg("Profiles"), - this.colors.browserProfiles, - ), - )} - ${when(misc, () => - renderBar(misc, msg("Miscellaneous"), this.colors.misc), - )} -
- -
-
- ${msg("Available")} -
-
-

- ${this.renderPercentage( - (metrics.storageQuotaBytes - metrics.storageUsedBytes) / - metrics.storageQuotaBytes, - )} -

-
-
-
-
- ${this.localize.bytes(metrics.storageUsedBytes, { - unitDisplay: "narrow", - })} - ${this.localize.bytes(metrics.storageQuotaBytes, { - unitDisplay: "narrow", - })} -
-
- `, - )} - `; + return html``; } - private renderCrawlingMeter(_metrics: Metrics) { - if (!this.org) return; - - let quotaSeconds = 0; - - if (this.org.quotas.maxExecMinutesPerMonth) { - quotaSeconds = this.org.quotas.maxExecMinutesPerMonth * 60; - } - - let quotaSecondsAllTypes = quotaSeconds; - - let quotaSecondsExtra = 0; - if (this.org.extraExecSecondsAvailable) { - quotaSecondsExtra = this.org.extraExecSecondsAvailable; - quotaSecondsAllTypes += this.org.extraExecSecondsAvailable; - } - - let quotaSecondsGifted = 0; - if (this.org.giftedExecSecondsAvailable) { - quotaSecondsGifted = this.org.giftedExecSecondsAvailable; - quotaSecondsAllTypes += this.org.giftedExecSecondsAvailable; - } - - const now = new Date(); - const currentYear = now.getFullYear(); - const currentMonth = String(now.getUTCMonth() + 1).padStart(2, "0"); - const currentPeriod = `${currentYear}-${currentMonth}`; - - let usageSeconds = 0; - if (this.org.monthlyExecSeconds) { - const actualUsage = this.org.monthlyExecSeconds[currentPeriod]; - if (actualUsage) { - usageSeconds = actualUsage; - } - } - - if (usageSeconds > quotaSeconds) { - usageSeconds = quotaSeconds; - } - - let usageSecondsAllTypes = 0; - if (this.org.monthlyExecSeconds) { - const actualUsage = this.org.monthlyExecSeconds[currentPeriod]; - if (actualUsage) { - usageSecondsAllTypes = actualUsage; - } - } - - let usageSecondsExtra = 0; - if (this.org.extraExecSeconds) { - const actualUsageExtra = this.org.extraExecSeconds[currentPeriod]; - if (actualUsageExtra) { - usageSecondsExtra = actualUsageExtra; - } - } - const maxExecSecsExtra = this.org.quotas.extraExecMinutes * 60; - // Cap usage at quota for display purposes - if (usageSecondsExtra > maxExecSecsExtra) { - usageSecondsExtra = maxExecSecsExtra; - } - if (usageSecondsExtra) { - // Quota for extra = this month's usage + remaining available - quotaSecondsAllTypes += usageSecondsExtra; - quotaSecondsExtra += usageSecondsExtra; - } - - let usageSecondsGifted = 0; - if (this.org.giftedExecSeconds) { - const actualUsageGifted = this.org.giftedExecSeconds[currentPeriod]; - if (actualUsageGifted) { - usageSecondsGifted = actualUsageGifted; - } - } - const maxExecSecsGifted = this.org.quotas.giftedExecMinutes * 60; - // Cap usage at quota for display purposes - if (usageSecondsGifted > maxExecSecsGifted) { - usageSecondsGifted = maxExecSecsGifted; - } - if (usageSecondsGifted) { - // Quota for gifted = this month's usage + remaining available - quotaSecondsAllTypes += usageSecondsGifted; - quotaSecondsGifted += usageSecondsGifted; - } - - const hasQuota = Boolean(quotaSecondsAllTypes); - const isReached = hasQuota && usageSecondsAllTypes >= quotaSecondsAllTypes; - - const maxTotalTime = quotaSeconds + quotaSecondsExtra + quotaSecondsGifted; - if (isReached) { - usageSecondsAllTypes = maxTotalTime; - quotaSecondsAllTypes = maxTotalTime; - } - - const hasExtra = - usageSecondsExtra || - this.org.extraExecSecondsAvailable || - usageSecondsGifted || - this.org.giftedExecSecondsAvailable; - - const renderBar = ( - /** Time in Seconds */ - used: number, - quota: number, - label: string, - color: string, - divided = true, - ) => { - if (divided) { - return html` -
${label}
-
-

- ${humanizeExecutionSeconds(used, { displaySeconds: true })} - ${msg("of")} -
- ${humanizeExecutionSeconds(quota, { displaySeconds: true })} -

-
`; - } else { - return html` -
${label}
-
-

- ${humanizeExecutionSeconds(used, { displaySeconds: true })} -
- ${this.renderPercentage(used / quota)} -

-
`; - } - }; - return html` -
- ${when( - isReached, - () => html` -
- - ${msg("Execution Minutes Quota Reached")} -
- `, - () => - hasQuota && this.org - ? html` - - ${humanizeExecutionSeconds( - quotaSeconds - - usageSeconds + - this.org.extraExecSecondsAvailable + - this.org.giftedExecSecondsAvailable, - { style: "short", round: "down" }, - )} - ${msg("remaining")} - - ` - : "", - )} -
- ${when( - hasQuota && this.org, - (org) => html` -
- - ${when(usageSeconds || quotaSeconds, () => - renderBar( - usageSeconds > quotaSeconds ? quotaSeconds : usageSeconds, - hasExtra ? quotaSeconds : quotaSecondsAllTypes, - msg("Monthly Execution Time Used"), - "lime", - hasExtra ? true : false, - ), - )} - ${when(usageSecondsGifted || org.giftedExecSecondsAvailable, () => - renderBar( - usageSecondsGifted > quotaSecondsGifted - ? quotaSecondsGifted - : usageSecondsGifted, - quotaSecondsGifted, - msg("Gifted Execution Time Used"), - "blue", - ), - )} - ${when(usageSecondsExtra || org.extraExecSecondsAvailable, () => - renderBar( - usageSecondsExtra > quotaSecondsExtra - ? quotaSecondsExtra - : usageSecondsExtra, - quotaSecondsExtra, - msg("Extra Execution Time Used"), - "violet", - ), - )} -
- -
-
${msg("Monthly Execution Time Remaining")}
-
- ${humanizeExecutionSeconds(quotaSeconds - usageSeconds, { - displaySeconds: true, - })} - | - ${this.renderPercentage( - (quotaSeconds - usageSeconds) / quotaSeconds, - )} -
-
-
-
-
- - ${humanizeExecutionSeconds(usageSecondsAllTypes, { - style: "short", - })} - - - ${humanizeExecutionSeconds(quotaSecondsAllTypes, { - style: "short", - })} - -
-
- `, - )} - `; + private renderCrawlingMeter(metrics: Metrics) { + return html``; } private renderCard( @@ -1059,12 +686,6 @@ export class Dashboard extends BtrixElement { `; - private renderPercentage(ratio: number) { - const percent = ratio * 100; - if (percent < 1) return `<1%`; - return `${percent.toFixed(2)}%`; - } - private async fetchMetrics() { try { const data = await this.api.fetch( diff --git a/frontend/src/pages/org/settings/components/billing-addon-link.ts b/frontend/src/pages/org/settings/components/billing-addon-link.ts new file mode 100644 index 0000000000..4964766baa --- /dev/null +++ b/frontend/src/pages/org/settings/components/billing-addon-link.ts @@ -0,0 +1,172 @@ +import { localized, msg } from "@lit/localize"; +import { Task, TaskStatus } from "@lit/task"; +import { type SlSelectEvent } from "@shoelace-style/shoelace"; +import { html } from "lit"; +import { customElement, state } from "lit/decorators.js"; + +import { BtrixElement } from "@/classes/BtrixElement"; +import { type BillingAddonCheckout } from "@/types/billing"; +import appState from "@/utils/state"; + +const PRESET_MINUTES = [100, 600, 1500, 3000]; + +type Price = { + value: number; + currency: string; +}; + +@customElement("btrix-org-settings-billing-addon-link") +@localized() +export class OrgSettingsBillingAddonLink extends BtrixElement { + static _price: Price | undefined; + + @state() + private lastClickedMinutesPreset: number | undefined = undefined; + + private readonly price = new Task(this, { + task: async () => { + if (OrgSettingsBillingAddonLink._price) + return OrgSettingsBillingAddonLink._price; + try { + const price = await this.api.fetch( + `/orgs/${this.orgId}/price/execution-minutes`, + ); + OrgSettingsBillingAddonLink._price = price; + return price; + } catch (error) { + console.log("Failed to fetch price", error); + return; + } + }, + args: () => [] as const, + }); + + private readonly checkoutUrl = new Task(this, { + task: async ([minutes]) => { + if (!appState.settings?.billingEnabled || !appState.org?.subscription) + return; + + try { + const { checkoutUrl } = await this.getCheckoutUrl(minutes); + + if (checkoutUrl) { + return checkoutUrl; + } else { + throw new Error("Missing checkoutUrl"); + } + } catch (e) { + console.debug(e); + + throw new Error( + msg("Sorry, couldn't retrieve current plan at this time."), + ); + } + }, + args: () => [undefined] as readonly [number | undefined], + autoRun: false, + }); + private async getCheckoutUrl(minutes?: number | undefined) { + const params = new URLSearchParams(); + if (minutes) params.append("minutes", minutes.toString()); + return this.api.fetch( + `/orgs/${this.orgId}/checkout/execution-minutes?${params.toString()}`, + ); + } + + private readonly localizeMinutes = (minutes: number) => { + return this.localize.number(minutes, { + style: "unit", + unit: "minute", + unitDisplay: "long", + }); + }; + + private async checkout(minutes?: number | undefined) { + await this.checkoutUrl.run([minutes]); + if (this.checkoutUrl.value) { + window.location.href = this.checkoutUrl.value; + } else { + this.notify.toast({ + message: msg("Sorry, checkout isn’t available at this time."), + id: "checkout-unavailable", + variant: "warning", + }); + } + } + + render() { + const priceForMinutes = (minutes: number) => { + if (!this.price.value) return; + return this.localize.number(minutes * this.price.value.value, { + style: "currency", + currency: this.price.value.currency, + }); + }; + const price = priceForMinutes(1); + return html` + { + this.lastClickedMinutesPreset = undefined; + await this.checkout(); + }} + size="small" + variant="text" + ?loading=${this.checkoutUrl.status === TaskStatus.PENDING && + this.lastClickedMinutesPreset === undefined} + ?disabled=${this.checkoutUrl.status === TaskStatus.PENDING && + this.lastClickedMinutesPreset !== undefined} + class="-ml-3" + > + ${msg("Add More Execution Minutes")} + +
+ { + this.lastClickedMinutesPreset = parseInt(e.detail.item.value); + await this.checkout(this.lastClickedMinutesPreset); + void e.detail.item.closest("sl-dropdown")!.hide(); + }} + > + + + ${msg("Preset minute amounts")} + + + + ${msg("Preset minute amounts")} +
+ ${msg("Amounts are adjustable during checkout.")} +
+
+ ${PRESET_MINUTES.map((m) => { + const minutes = this.localizeMinutes(m); + return html` + + ${minutes} + ${this.price.value && + html` + ${priceForMinutes(m)} + `} + + `; + })} +
+
+ ${this.price.value && + html`
+ ${msg(html`${price} per minute`)} +
`} + `; + } +} diff --git a/frontend/src/pages/org/settings/components/billing.ts b/frontend/src/pages/org/settings/components/billing.ts index 5c8035f73c..8be01d4c67 100644 --- a/frontend/src/pages/org/settings/components/billing.ts +++ b/frontend/src/pages/org/settings/components/billing.ts @@ -9,14 +9,18 @@ import { when } from "lit/directives/when.js"; import capitalize from "lodash/fp/capitalize"; import { BtrixElement } from "@/classes/BtrixElement"; +import { + hasExecutionMinuteQuota, + hasStorageQuota, +} from "@/features/meters/has-quotas"; import { columns } from "@/layouts/columns"; import { SubscriptionStatus, type BillingPortal } from "@/types/billing"; -import type { OrgData, OrgQuotas } from "@/types/org"; -import { humanizeSeconds } from "@/utils/executionTimeFormatter"; +import type { Metrics, OrgData, OrgQuotas } from "@/types/org"; +import { humanizeExecutionSeconds } from "@/utils/executionTimeFormatter"; import { pluralOf } from "@/utils/pluralize"; import { tw } from "@/utils/tailwind"; -const linkClassList = tw`transition-color text-primary hover:text-primary-500`; +const linkClassList = tw`text-primary transition-colors hover:text-primary-600`; const manageLinkClasslist = clsx( linkClassList, tw`flex cursor-pointer items-center gap-2 p-2 text-sm font-semibold leading-none`, @@ -78,24 +82,53 @@ export class OrgSettingsBilling extends BtrixElement { console.debug(e); throw new Error( - msg("Sorry, couldn't retrieve current plan at this time."), + msg("Sorry, couldn’t retrieve current plan at this time."), ); } }, args: () => [this.appState] as const, }); + private readonly metrics = new Task(this, { + task: async ([orgId]) => { + const metrics = await this.api.fetch( + `/orgs/${orgId}/metrics`, + ); + if (!metrics) { + throw new Error("Missing metrics"); + } + + return metrics; + }, + args: () => [this.org?.id] as const, + }); + render() { const manageSubscriptionMessage = msg( str`Click “${this.portalUrlLabel}” to view plan details, payment methods, and billing information.`, ); + const meterPendingExecutionTime = html` + + + `; + + const meterPendingStorage = html` + + + `; + return html`
${columns([ [ html` -
+
@@ -187,13 +220,31 @@ export class OrgSettingsBilling extends BtrixElement { ${when( this.org, - (org) => this.renderQuotas(org.quotas), + (org) => this.renderMonthlyQuotas(org.quotas), () => html` `, )} + ${when( + this.org?.quotas.extraExecMinutes || + this.org?.quotas.giftedExecMinutes, + () => + html`
+ ${msg("Add-ons")} +
+ ${this.renderExtraQuotas(this.org!.quotas)}`, + )} + ${when( + this.org?.subscription, + () => + html``, + )}
`, html` @@ -257,6 +308,71 @@ export class OrgSettingsBilling extends BtrixElement { ], ])}
+
+
+

${msg("Usage")}

+
+ +

+ ${msg("Execution time")} +

+ ${when( + hasExecutionMinuteQuota(this.org), + () => + this.metrics.render({ + initial: () => meterPendingExecutionTime, + complete: (metrics) => + html` `, + pending: () => meterPendingExecutionTime, + }), + () => { + if (!this.org?.crawlExecSeconds) + return html``; + + const now = new Date(); + const currentYear = now.getFullYear(); + const currentMonth = String(now.getUTCMonth() + 1).padStart(2, "0"); + const currentPeriod = `${currentYear}-${currentMonth}`; + + const minutesUsed = html`${humanizeExecutionSeconds( + this.org.crawlExecSeconds[currentPeriod] || 0, + )}`; + return html`${msg(html`${minutesUsed} this month`)}`; + }, + )} +

+ ${msg("Storage")} +

+ ${when( + hasStorageQuota(this.org), + () => + this.metrics.render({ + initial: () => meterPendingStorage, + complete: (metrics) => + when( + metrics.storageQuotaBytes, + () => + html` `, + ), + pending: () => meterPendingStorage, + }), + () => { + if (!this.org?.bytesStored) + return html``; + + const bytesUsed = this.localize.bytes(this.org.bytesStored); + return html`
${bytesUsed}
`; + }, + )} +

${msg("Usage History")}

@@ -343,15 +459,15 @@ export class OrgSettingsBilling extends BtrixElement { : nothing}`; }; - private readonly renderQuotas = (quotas: OrgQuotas) => { - const maxExecMinutesPerMonth = - quotas.maxExecMinutesPerMonth && - humanizeSeconds( - quotas.maxExecMinutesPerMonth * 60, - this.localize.lang(), - undefined, - "long", - ); + private readonly renderMonthlyQuotas = (quotas: OrgQuotas) => { + const maxExecMinutesPerMonth = this.localize.number( + quotas.maxExecMinutesPerMonth, + { + style: "unit", + unit: "minute", + unitDisplay: "long", + }, + ); const maxPagesPerCrawl = quotas.maxPagesPerCrawl && `${this.localize.number(quotas.maxPagesPerCrawl)} ${pluralOf("pages", quotas.maxPagesPerCrawl)}`; @@ -360,18 +476,20 @@ export class OrgSettingsBilling extends BtrixElement { msg( str`${this.localize.number(quotas.maxConcurrentCrawls)} concurrent ${pluralOf("crawls", quotas.maxConcurrentCrawls)}`, ); - const storageBytesText = quotas.storageQuota - ? this.localize.bytes(quotas.storageQuota) - : msg("Unlimited"); + const storageBytesText = this.localize.bytes(quotas.storageQuota); return html`