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..dbd41e38f9 100644 --- a/.github/workflows/microk8s-ci.yaml +++ b/.github/workflows/microk8s-ci.yaml @@ -60,7 +60,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/main.py b/backend/btrixcloud/main.py index 21a7f95bd7..0f32934dac 100644 --- a/backend/btrixcloud/main.py +++ b/backend/btrixcloud/main.py @@ -172,9 +172,7 @@ 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, @@ -183,9 +181,10 @@ def main() -> None: crawl_manager, invites, current_active_user, + shared_secret_or_superuser, ) - 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 e0347c6f29..b301be4818 100644 --- a/backend/btrixcloud/models.py +++ b/backend/btrixcloud/models.py @@ -1857,6 +1857,8 @@ class OrgQuotasIn(BaseModel): extraExecMinutes: Optional[int] = None giftedExecMinutes: Optional[int] = None + context: str | None = None + # ============================================================================ class Plan(BaseModel): @@ -1980,6 +1982,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""" @@ -2058,6 +2084,7 @@ class OrgQuotaUpdate(BaseModel): modified: datetime update: OrgQuotas + context: str | None = None # ============================================================================ diff --git a/backend/btrixcloud/orgs.py b/backend/btrixcloud/orgs.py index 41dab0cec1..11f3b3f906 100644 --- a/backend/btrixcloud/orgs.py +++ b/backend/btrixcloud/orgs.py @@ -12,8 +12,19 @@ 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 AsyncIOMotorDatabase from pydantic import ValidationError from pymongo import ReturnDocument from pymongo.errors import AutoReconnect, DuplicateKeyError @@ -546,9 +557,15 @@ async def update_subscription_data( org = Organization.from_dict(org_data) if update.quotas: - # don't change gifted minutes here + # don't change gifted or extra minutes here update.quotas.giftedExecMinutes = None - await self.update_quotas(org, update.quotas) + update.quotas.extraExecMinutes = None + await self.update_quotas( + org, + update.quotas, + mode="set", + context=f"subscription_change:{update.planId}", + ) return org @@ -599,9 +616,17 @@ 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"], + context: str | None = None, + ) -> None: """update organization quotas""" + quotas.context = None + previous_extra_mins = ( org.quotas.extraExecMinutes if (org.quotas and org.quotas.extraExecMinutes) @@ -613,51 +638,65 @@ async def update_quotas(self, org: Organization, quotas: OrgQuotasIn) -> None: else 0 ) - update = quotas.dict( - exclude_unset=True, exclude_defaults=True, exclude_none=True - ) + if mode == "add": + increment_update: dict[str, Any] = { + "$inc": {}, + } - 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()) + for field, value in quotas.model_dump( + exclude_unset=True, exclude_defaults=True, exclude_none=True + ).items(): + if field == "context" or value is None: + continue + inc = max(value, -org.quotas.model_dump().get(field, 0)) + increment_update["$inc"][f"quotas.{field}"] = inc - await self.orgs.find_one_and_update( - {"_id": org.id}, - { - "$set": { - "quotas": update, - "quotaUpdates": quota_updates, - } + updated_org = await self.orgs.find_one_and_update( + {"_id": org.id}, + increment_update, + projection={"quotas": True}, + return_document=ReturnDocument.AFTER, + ) + 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 + ) + ), + context=context, + ).model_dump() }, - ) + "$inc": {}, + "$set": {}, + } + + 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: - await self.orgs.find_one_and_update( - {"_id": org.id}, - {"$set": {"extraExecSecondsAvailable": 0}}, - ) + update["$set"]["extraExecSecondsAvailable"] = 0 else: - await self.orgs.find_one_and_update( - {"_id": org.id}, - {"$inc": {"extraExecSecondsAvailable": extra_secs_diff}}, - ) + 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: - await self.orgs.find_one_and_update( - {"_id": org.id}, - {"$set": {"giftedExecSecondsAvailable": 0}}, - ) + update["$set"]["giftedExecSecondsAvailable"] = 0 else: - await self.orgs.find_one_and_update( - {"_id": org.id}, - {"$inc": {"giftedExecSecondsAvailable": gifted_secs_diff}}, - ) + update["$inc"]["giftedExecSecondsAvailable"] = gifted_secs_diff + + await self.orgs.find_one_and_update({"_id": org.id}, update) async def update_event_webhook_urls( self, org: Organization, urls: OrgWebhookUrls @@ -1127,7 +1166,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""" @@ -1439,10 +1478,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 @@ -1499,12 +1540,13 @@ 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, + mdb: AsyncIOMotorDatabase[Any], user_manager: UserManager, crawl_manager: CrawlManager, invites: InviteOps, - user_dep: Callable, + user_dep: Callable[[str], Awaitable[User]], + superuser_or_shared_secret_dep: Callable[[str], Awaitable[User]], ): """Init organizations api router for /orgs""" # pylint: disable=too-many-locals,invalid-name @@ -1659,7 +1701,20 @@ 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", context=quotas.context) + + return {"updated": True} + + @app.post( + "/orgs/{oid}/quotas/add", tags=["organizations"], response_model=UpdatedResponse + ) + async def update_quotas_add( + oid: UUID, + quotas: OrgQuotasIn, + _user: User = Depends(superuser_or_shared_secret_dep), + ): + org = await ops.get_org_by_id(oid) + await ops.update_quotas(org, quotas, mode="add", context=quotas.context) return {"updated": True} diff --git a/backend/btrixcloud/subs.py b/backend/btrixcloud/subs.py index 9180ae3878..a4e9c22157 100644 --- a/backend/btrixcloud/subs.py +++ b/backend/btrixcloud/subs.py @@ -2,19 +2,23 @@ Subscription API handling """ -from typing import Callable, Union, Any, Optional, Tuple, List +from typing import Awaitable, Callable, Union, Any, Optional, Tuple, List 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 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, @@ -363,11 +367,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 +382,77 @@ 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" -# pylint: disable=invalid-name,too-many-arguments + 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) + + +# 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 +469,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 +485,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 +496,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 +507,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( @@ -453,7 +520,7 @@ async def send_trial_end_reminder( @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 +532,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( @@ -501,4 +568,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, + ) + 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..e17c2c35ef 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( @@ -498,6 +508,7 @@ def test_subscription_events_log(admin_auth_headers, non_default_org_id): "planId": "basic2", "futureCancelDate": None, "quotas": { + "context": None, "maxPagesPerCrawl": 50, "storageQuota": 500000, "extraExecMinutes": None, @@ -566,6 +577,7 @@ def test_subscription_events_log_filter_sub_id(admin_auth_headers): "planId": "basic2", "futureCancelDate": None, "quotas": { + "context": None, "maxPagesPerCrawl": 50, "storageQuota": 500000, "extraExecMinutes": None, @@ -627,6 +639,7 @@ def test_subscription_events_log_filter_oid(admin_auth_headers): "planId": "basic2", "futureCancelDate": None, "quotas": { + "context": None, "maxPagesPerCrawl": 50, "storageQuota": 500000, "extraExecMinutes": None, @@ -662,6 +675,7 @@ def test_subscription_events_log_filter_plan_id(admin_auth_headers): "planId": "basic2", "futureCancelDate": None, "quotas": { + "context": None, "maxPagesPerCrawl": 50, "storageQuota": 500000, "extraExecMinutes": None, @@ -713,6 +727,7 @@ def test_subscription_events_log_filter_status(admin_auth_headers): "planId": "basic2", "futureCancelDate": None, "quotas": { + "context": None, "maxPagesPerCrawl": 50, "storageQuota": 500000, "extraExecMinutes": None, diff --git a/backend/test_nightly/conftest.py b/backend/test_nightly/conftest.py index d984d4642c..b369774de1 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 cd81d86330..992d732bae 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 config_id = None @@ -195,3 +196,36 @@ def test_unset_execution_mins_quota(org_with_quotas, admin_auth_headers): ) data = r.json() assert data.get("updated") == True + + +def test_add_execution_mins_extra_quotas( + org_with_quotas, admin_auth_headers, preshared_secret_auth_headers +): + r = requests.post( + f"{API_PREFIX}/orgs/{org_with_quotas}/quotas/add", + headers=preshared_secret_auth_headers, + json={ + "extraExecMinutes": EXTRA_MINS_ADDED_QUOTA, + "context": "test context 123", + }, + ) + data = r.json() + assert data.get("updated") == True + + # Ensure org data looks as we expect + r = requests.get( + f"{API_PREFIX}/orgs/{org_with_quotas}", + headers=admin_auth_headers, + ) + data = r.json() + assert ( + data["extraExecSecondsAvailable"] == EXTRA_SECS_QUOTA + EXTRA_SECS_ADDED_QUOTA + ) + assert data["giftedExecSecondsAvailable"] == GIFTED_SECS_QUOTA + assert data["extraExecSeconds"] == {} + assert data["giftedExecSeconds"] == {} + assert len(data["quotaUpdates"]) + for update in data["quotaUpdates"]: + assert update["modified"] + assert update["update"] + assert data["quotaUpdates"][-1]["context"] == "test context 123" 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..0733d55bfe --- /dev/null +++ b/frontend/src/features/meters/execution-minutes/execution-minute-meter.ts @@ -0,0 +1,253 @@ +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", + })} + ${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", + })} + + + ${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..f8dd1dfb09 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,18 @@ export class OrgStatusBanner extends BtrixElement { ), }), }, + { + test: () => !readOnly && !!execMinutesQuotaReached && !!subscription, + content: () => ({ + title: msg( + str`Your org has reached its monthly execution minutes limit`, + ), + detail: msg( + html`Purchase additional monthly execution minutes or upgrade your + 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`