Merge branch 'dev' into abhi/ci-chromatic

This commit is contained in:
Abhimanyu Yadav
2025-04-30 08:50:56 +05:30
committed by GitHub
31 changed files with 1161 additions and 145 deletions

View File

@@ -88,6 +88,33 @@ class StoreValueBlock(Block):
yield "output", input_data.data or input_data.input
class PrintToConsoleBlock(Block):
class Input(BlockSchema):
text: Any = SchemaField(description="The data to print to the console.")
class Output(BlockSchema):
output: Any = SchemaField(description="The data printed to the console.")
status: str = SchemaField(description="The status of the print operation.")
def __init__(self):
super().__init__(
id="f3b1c1b2-4c4f-4f0d-8d2f-4c4f0d8d2f4c",
description="Print the given text to the console, this is used for a debugging purpose.",
categories={BlockCategory.BASIC},
input_schema=PrintToConsoleBlock.Input,
output_schema=PrintToConsoleBlock.Output,
test_input={"text": "Hello, World!"},
test_output=[
("output", "Hello, World!"),
("status", "printed"),
],
)
def run(self, input_data: Input, **kwargs) -> BlockOutput:
yield "output", input_data.text
yield "status", "printed"
class FindInDictionaryBlock(Block):
class Input(BlockSchema):
input: Any = SchemaField(description="Dictionary to lookup from")

View File

@@ -82,7 +82,15 @@ class SendWebRequestBlock(Block):
json=body if input_data.json_format else None,
data=body if not input_data.json_format else None,
)
result = response.json() if input_data.json_format else response.text
if input_data.json_format:
if response.status_code == 204 or not response.content.strip():
result = None
else:
result = response.json()
else:
result = response.text
yield "response", result
except HTTPError as e:

View File

@@ -3,6 +3,7 @@ import logging
from abc import ABC, abstractmethod
from collections import defaultdict
from datetime import datetime, timezone
from typing import cast
import stripe
from autogpt_libs.utils.cache import thread_cached
@@ -27,13 +28,16 @@ from backend.data.cost import BlockCost
from backend.data.model import (
AutoTopUpConfig,
RefundRequest,
TopUpType,
TransactionHistory,
UserTransaction,
)
from backend.data.notifications import NotificationEventDTO, RefundRequestData
from backend.data.user import get_user_by_id
from backend.data.user import get_user_by_id, get_user_email_by_id
from backend.executor.utils import UsageTransactionMetadata
from backend.notifications import NotificationManager
from backend.server.model import Pagination
from backend.server.v2.admin.model import UserHistoryResponse
from backend.util.exceptions import InsufficientBalanceError
from backend.util.retry import func_retry
from backend.util.service import get_service_client
@@ -405,6 +409,7 @@ class UserCredit(UserCreditBase):
# Avoid multiple auto top-ups within the same graph execution.
key=f"AUTO-TOP-UP-{user_id}-{metadata.graph_exec_id}",
ceiling_balance=auto_top_up.threshold,
top_up_type=TopUpType.AUTO,
)
except Exception as e:
# Failed top-up is not critical, we can move on.
@@ -414,8 +419,15 @@ class UserCredit(UserCreditBase):
return balance
async def top_up_credits(self, user_id: str, amount: int):
await self._top_up_credits(user_id, amount)
async def top_up_credits(
self,
user_id: str,
amount: int,
top_up_type: TopUpType = TopUpType.UNCATEGORIZED,
):
await self._top_up_credits(
user_id=user_id, amount=amount, top_up_type=top_up_type
)
async def onboarding_reward(self, user_id: str, credits: int, step: OnboardingStep):
key = f"REWARD-{user_id}-{step.value}"
@@ -598,7 +610,7 @@ class UserCredit(UserCreditBase):
evidence_text += (
f"- {tx.description}: Amount ${tx.amount / 100:.2f} on {tx.transaction_time.isoformat()}, "
f"resulting balance ${tx.balance / 100:.2f} {additional_comment}\n"
f"resulting balance ${tx.running_balance / 100:.2f} {additional_comment}\n"
)
evidence_text += (
"\nThis evidence demonstrates that the transaction was authorized and that the charged amount was used to render the service as agreed."
@@ -617,7 +629,24 @@ class UserCredit(UserCreditBase):
amount: int,
key: str | None = None,
ceiling_balance: int | None = None,
top_up_type: TopUpType = TopUpType.UNCATEGORIZED,
metadata: dict | None = None,
):
# init metadata, without sharing it with the world
metadata = metadata or {}
if not metadata["reason"]:
match top_up_type:
case TopUpType.MANUAL:
metadata["reason"] = {"reason": f"Top up credits for {user_id}"}
case TopUpType.AUTO:
metadata["reason"] = {
"reason": f"Auto top up credits for {user_id}"
}
case _:
metadata["reason"] = {
"reason": f"Top up reason unknown for {user_id}"
}
if amount < 0:
raise ValueError(f"Top up amount must not be negative: {amount}")
@@ -640,6 +669,7 @@ class UserCredit(UserCreditBase):
is_active=False,
transaction_key=key,
ceiling_balance=ceiling_balance,
metadata=(Json(metadata)),
)
customer_id = await get_stripe_customer_id(user_id)
@@ -823,8 +853,9 @@ class UserCredit(UserCreditBase):
take=transaction_count_limit,
)
# doesn't fill current_balance, reason, user_email, admin_email, or extra_data
grouped_transactions: dict[str, UserTransaction] = defaultdict(
lambda: UserTransaction()
lambda: UserTransaction(user_id=user_id)
)
tx_time = None
for t in transactions:
@@ -854,7 +885,7 @@ class UserCredit(UserCreditBase):
if tx_time > gt.transaction_time:
gt.transaction_time = tx_time
gt.balance = t.runningBalance or 0
gt.running_balance = t.runningBalance or 0
return TransactionHistory(
transactions=list(grouped_transactions.values()),
@@ -904,6 +935,7 @@ class BetaUserCredit(UserCredit):
amount=max(self.num_user_credits_refill - balance, 0),
transaction_type=CreditTransactionType.GRANT,
transaction_key=f"MONTHLY-CREDIT-TOP-UP-{cur_time}",
metadata=Json({"reason": "Monthly credit refill"}),
)
return balance
except UniqueViolationError:
@@ -991,3 +1023,81 @@ async def get_auto_top_up(user_id: str) -> AutoTopUpConfig:
return AutoTopUpConfig(threshold=0, amount=0)
return AutoTopUpConfig.model_validate(user.topUpConfig)
async def admin_get_user_history(
page: int = 1,
page_size: int = 20,
search: str | None = None,
transaction_filter: CreditTransactionType | None = None,
) -> UserHistoryResponse:
if page < 1 or page_size < 1:
raise ValueError("Invalid pagination input")
where_clause: CreditTransactionWhereInput = {}
if transaction_filter:
where_clause["type"] = transaction_filter
if search:
where_clause["OR"] = [
{"userId": {"contains": search, "mode": "insensitive"}},
{"User": {"is": {"email": {"contains": search, "mode": "insensitive"}}}},
{"User": {"is": {"name": {"contains": search, "mode": "insensitive"}}}},
]
transactions = await CreditTransaction.prisma().find_many(
where=where_clause,
skip=(page - 1) * page_size,
take=page_size,
include={"User": True},
order={"createdAt": "desc"},
)
total = await CreditTransaction.prisma().count(where=where_clause)
total_pages = (total + page_size - 1) // page_size
history = []
for tx in transactions:
admin_id = ""
admin_email = ""
reason = ""
metadata: dict = cast(dict, tx.metadata) or {}
if metadata:
admin_id = metadata.get("admin_id")
admin_email = (
(await get_user_email_by_id(admin_id) or f"Unknown Admin: {admin_id}")
if admin_id
else ""
)
reason = metadata.get("reason", "No reason provided")
balance, last_update = await get_user_credit_model()._get_credits(tx.userId)
history.append(
UserTransaction(
transaction_key=tx.transactionKey,
transaction_time=tx.createdAt,
transaction_type=tx.type,
amount=tx.amount,
current_balance=balance,
running_balance=tx.runningBalance or 0,
user_id=tx.userId,
user_email=(
tx.User.email
if tx.User
else (await get_user_by_id(tx.userId)).email
),
reason=reason,
admin_email=admin_email,
extra_data=str(metadata),
)
)
return UserHistoryResponse(
history=history,
pagination=Pagination(
total_items=total,
total_pages=total_pages,
current_page=page,
page_size=page_size,
),
)

View File

@@ -492,21 +492,12 @@ async def upsert_execution_output(
async def update_graph_execution_start_time(
graph_exec_id: str,
) -> GraphExecution | None:
count = await AgentGraphExecution.prisma().update_many(
where={
"id": graph_exec_id,
"executionStatus": ExecutionStatus.QUEUED,
},
res = await AgentGraphExecution.prisma().update(
where={"id": graph_exec_id},
data={
"executionStatus": ExecutionStatus.RUNNING,
"startedAt": datetime.now(tz=timezone.utc),
},
)
if count == 0:
return None
res = await AgentGraphExecution.prisma().find_unique(
where={"id": graph_exec_id},
include=GRAPH_EXECUTION_INCLUDE,
)
return GraphExecution.from_db(res) if res else None

View File

@@ -736,6 +736,58 @@ async def get_graph(
return GraphModel.from_db(graph, for_export)
async def get_graph_as_admin(
graph_id: str,
version: int | None = None,
user_id: str | None = None,
for_export: bool = False,
) -> GraphModel | None:
"""
Intentionally parallels the get_graph but should only be used for admin tasks, because can return any graph that's been submitted
Retrieves a graph from the DB.
Defaults to the version with `is_active` if `version` is not passed.
Returns `None` if the record is not found.
"""
logger.warning(f"Getting {graph_id=} {version=} as ADMIN {user_id=} {for_export=}")
where_clause: AgentGraphWhereInput = {
"id": graph_id,
}
if version is not None:
where_clause["version"] = version
graph = await AgentGraph.prisma().find_first(
where=where_clause,
include=AGENT_GRAPH_INCLUDE,
order={"version": "desc"},
)
# For access, the graph must be owned by the user or listed in the store
if graph is None or (
graph.userId != user_id
and not (
await StoreListingVersion.prisma().find_first(
where={
"agentGraphId": graph_id,
"agentGraphVersion": version or graph.version,
}
)
)
):
return None
if for_export:
sub_graphs = await get_sub_graphs(graph)
return GraphModel.from_db(
graph=graph,
sub_graphs=sub_graphs,
for_export=for_export,
)
return GraphModel.from_db(graph, for_export)
async def get_sub_graphs(graph: AgentGraph) -> list[AgentGraph]:
"""
Iteratively fetches all sub-graphs of a given graph, and flattens them into a list.

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import base64
import enum
import logging
from collections import defaultdict
from datetime import datetime, timezone
@@ -449,6 +450,12 @@ class ContributorDetails(BaseModel):
name: str = Field(title="Name", description="The name of the contributor.")
class TopUpType(enum.Enum):
AUTO = "AUTO"
MANUAL = "MANUAL"
UNCATEGORIZED = "UNCATEGORIZED"
class AutoTopUpConfig(BaseModel):
amount: int
"""Amount of credits to top up."""
@@ -461,12 +468,18 @@ class UserTransaction(BaseModel):
transaction_time: datetime = datetime.min.replace(tzinfo=timezone.utc)
transaction_type: CreditTransactionType = CreditTransactionType.USAGE
amount: int = 0
balance: int = 0
running_balance: int = 0
current_balance: int = 0
description: str | None = None
usage_graph_id: str | None = None
usage_execution_id: str | None = None
usage_node_count: int = 0
usage_start_time: datetime = datetime.max.replace(tzinfo=timezone.utc)
user_id: str
user_email: str | None = None
reason: str | None = None
admin_email: str | None = None
extra_data: str | None = None
class TransactionHistory(BaseModel):

View File

@@ -5,7 +5,6 @@ import os
import signal
import sys
import threading
import time
from concurrent.futures import Future, ProcessPoolExecutor
from contextlib import contextmanager
from multiprocessing.pool import AsyncResult, Pool
@@ -23,13 +22,15 @@ from backend.data.notifications import (
NotificationEventDTO,
NotificationType,
)
from backend.data.rabbitmq import SyncRabbitMQ
from backend.executor.utils import create_execution_queue_config
from backend.util.exceptions import InsufficientBalanceError
if TYPE_CHECKING:
from backend.executor import DatabaseManager
from backend.notifications.notifications import NotificationManager
from autogpt_libs.utils.cache import clear_thread_cache, thread_cached
from autogpt_libs.utils.cache import thread_cached
from prometheus_client import Gauge, start_http_server
from backend.blocks.agent import AgentExecutorBlock
@@ -446,36 +447,28 @@ class Executor:
# Set up shutdown handlers
cls.shutdown_lock = threading.Lock()
atexit.register(cls.on_node_executor_stop) # handle regular shutdown
signal.signal( # handle termination
signal.SIGTERM, lambda _, __: cls.on_node_executor_sigterm()
)
atexit.register(cls.on_node_executor_stop)
signal.signal(signal.SIGTERM, lambda _, __: cls.on_node_executor_sigterm())
signal.signal(signal.SIGINT, lambda _, __: cls.on_node_executor_sigterm())
@classmethod
def on_node_executor_stop(cls):
def on_node_executor_stop(cls, log=logger.info):
if not cls.shutdown_lock.acquire(blocking=False):
return # already shutting down
logger.info(f"[on_node_executor_stop {cls.pid}] ⏳ Releasing locks...")
log(f"[on_node_executor_stop {cls.pid}] ⏳ Releasing locks...")
cls.creds_manager.release_all_locks()
logger.info(f"[on_node_executor_stop {cls.pid}] ⏳ Disconnecting Redis...")
log(f"[on_node_executor_stop {cls.pid}] ⏳ Disconnecting Redis...")
redis.disconnect()
logger.info(f"[on_node_executor_stop {cls.pid}] ⏳ Disconnecting DB manager...")
log(f"[on_node_executor_stop {cls.pid}] ⏳ Disconnecting DB manager...")
close_service_client(cls.db_client)
logger.info(f"[on_node_executor_stop {cls.pid}] ✅ Finished cleanup")
log(f"[on_node_executor_stop {cls.pid}] ✅ Finished NodeExec cleanup")
sys.exit(0)
@classmethod
def on_node_executor_sigterm(cls):
llprint(f"[on_node_executor_sigterm {cls.pid}] ⚠️ SIGTERM received")
if not cls.shutdown_lock.acquire(blocking=False):
return # already shutting down
llprint(f"[on_node_executor_stop {cls.pid}] ⏳ Releasing locks...")
cls.creds_manager.release_all_locks()
llprint(f"[on_node_executor_stop {cls.pid}] ⏳ Disconnecting Redis...")
redis.disconnect()
llprint(f"[on_node_executor_stop {cls.pid}] ✅ Finished cleanup")
sys.exit(0)
llprint(f"[on_node_executor_sigterm {cls.pid}] ⚠️ NodeExec SIGTERM received")
cls.on_node_executor_stop(log=llprint)
@classmethod
@error_logged
@@ -551,21 +544,7 @@ class Executor:
cls.pid = os.getpid()
cls.notification_service = get_notification_service()
cls._init_node_executor_pool()
logger.info(
f"Graph executor {cls.pid} started with {cls.pool_size} node workers"
)
# Set up shutdown handler
atexit.register(cls.on_graph_executor_stop)
@classmethod
def on_graph_executor_stop(cls):
prefix = f"[on_graph_executor_stop {cls.pid}]"
logger.info(f"{prefix} ⏳ Terminating node executor pool...")
cls.executor.terminate()
logger.info(f"{prefix} ⏳ Disconnecting DB manager...")
close_service_client(cls.db_client)
logger.info(f"{prefix} ✅ Finished cleanup")
logger.info(f"GraphExec {cls.pid} started with {cls.pool_size} node workers")
@classmethod
def _init_node_executor_pool(cls):
@@ -592,7 +571,7 @@ class Executor:
)
if exec_meta is None:
logger.warning(
f"Skipped graph execution {graph_exec.graph_exec_id}, the graph execution is not found or not currently in the QUEUED state."
f"Skipped graph execution {graph_exec.graph_exec_id}, the graph execution is not found."
)
return
@@ -638,6 +617,7 @@ class Executor:
block_id=node_exec.block_id,
block=block.name,
input=matching_filter,
reason=f"Ran block {node_exec.block_id} {block.name}",
),
)
execution_stats.cost += cost
@@ -654,6 +634,7 @@ class Executor:
"execution_count": execution_count,
"charge": "Execution Cost",
},
reason=f"Execution Cost for ex_id:{node_exec.graph_exec_id} g_id:{node_exec.graph_id}",
),
)
execution_stats.cost += cost
@@ -909,13 +890,14 @@ class ExecutionManager(AppProcess):
self.pool_size = settings.config.num_graph_workers
self.running = True
self.active_graph_runs: dict[str, tuple[Future, threading.Event]] = {}
atexit.register(self._on_cleanup)
signal.signal(signal.SIGTERM, lambda sig, frame: self._on_sigterm())
signal.signal(signal.SIGINT, lambda sig, frame: self._on_sigterm())
def run(self):
pool_size_gauge.set(self.pool_size)
active_runs_gauge.set(0)
utilization_gauge.set(0)
retry_count_max = settings.config.execution_manager_loop_max_retry
retry_count = 0
self.metrics_server = threading.Thread(
target=start_http_server,
@@ -924,27 +906,7 @@ class ExecutionManager(AppProcess):
)
self.metrics_server.start()
logger.info(f"[{self.service_name}] Starting execution manager...")
for retry_count in range(retry_count_max):
try:
self._run()
except Exception as e:
if not self.running:
break
logger.exception(
f"[{self.service_name}] Error in execution manager: {e}"
)
if retry_count >= retry_count_max:
logger.error(
f"[{self.service_name}] Max retries reached ({retry_count_max}), exiting..."
)
break
else:
logger.info(
f"[{self.service_name}] Retrying execution loop in {retry_count} seconds..."
)
time.sleep(retry_count)
self._run()
def _run(self):
logger.info(f"[{self.service_name}] ⏳ Spawn max-{self.pool_size} workers...")
@@ -956,23 +918,33 @@ class ExecutionManager(AppProcess):
logger.info(f"[{self.service_name}] ⏳ Connecting to Redis...")
redis.connect()
# Consume Cancel & Run execution requests.
clear_thread_cache(get_execution_queue)
channel = get_execution_queue().get_channel()
channel.basic_qos(prefetch_count=self.pool_size)
channel.basic_consume(
queue=GRAPH_EXECUTION_CANCEL_QUEUE_NAME,
on_message_callback=self._handle_cancel_message,
auto_ack=True,
)
channel.basic_consume(
cancel_client = SyncRabbitMQ(create_execution_queue_config())
cancel_client.connect()
cancel_channel = cancel_client.get_channel()
logger.info(f"[{self.service_name}] ⏳ Starting cancel message consumer...")
threading.Thread(
target=lambda: (
cancel_channel.basic_consume(
queue=GRAPH_EXECUTION_CANCEL_QUEUE_NAME,
on_message_callback=self._handle_cancel_message,
auto_ack=True,
),
cancel_channel.start_consuming(),
),
daemon=True,
).start()
run_client = SyncRabbitMQ(create_execution_queue_config())
run_client.connect()
run_channel = run_client.get_channel()
run_channel.basic_qos(prefetch_count=self.pool_size)
run_channel.basic_consume(
queue=GRAPH_EXECUTION_QUEUE_NAME,
on_message_callback=self._handle_run_message,
auto_ack=False,
)
logger.info(f"[{self.service_name}] Ready to consume messages...")
channel.start_consuming()
logger.info(f"[{self.service_name}] ⏳ Starting to consume run messages...")
run_channel.start_consuming()
def _handle_cancel_message(
self,
@@ -1069,19 +1041,29 @@ class ExecutionManager(AppProcess):
def cleanup(self):
super().cleanup()
self._on_cleanup()
logger.info(f"[{self.service_name}] ⏳ Shutting down service loop...")
def _on_sigterm(self):
llprint(f"[{self.service_name}] ⚠️ GraphExec SIGTERM received")
self._on_cleanup(log=llprint)
def _on_cleanup(self, log=logger.info):
prefix = f"[{self.service_name}][on_graph_executor_stop {os.getpid()}]"
log(f"{prefix} ⏳ Shutting down service loop...")
self.running = False
logger.info(f"[{self.service_name}] ⏳ Shutting down RabbitMQ channel...")
log(f"{prefix} ⏳ Shutting down RabbitMQ channel...")
get_execution_queue().get_channel().stop_consuming()
logger.info(f"[{self.service_name}] ⏳ Shutting down graph executor pool...")
self.executor.shutdown(cancel_futures=True)
if hasattr(self, "executor"):
log(f"{prefix} ⏳ Shutting down GraphExec pool...")
self.executor.shutdown(cancel_futures=True, wait=True)
logger.info(f"[{self.service_name}] ⏳ Disconnecting Redis...")
log(f"{prefix} ⏳ Disconnecting Redis...")
redis.disconnect()
log(f"{prefix} ✅ Finished GraphExec cleanup")
@property
def db_client(self) -> "DatabaseManager":
return get_db_client()
@@ -1124,5 +1106,4 @@ def llprint(message: str):
Low-level print/log helper function for use in signal handlers.
Regular log/print statements are not allowed in signal handlers.
"""
if logger.getEffectiveLevel() == logging.DEBUG:
os.write(sys.stdout.fileno(), (message + "\n").encode())
os.write(sys.stdout.fileno(), (message + "\n").encode())

View File

@@ -99,6 +99,7 @@ class UsageTransactionMetadata(BaseModel):
block_id: str | None = None
block: str | None = None
input: BlockInput | None = None
reason: str | None = None
def execution_usage_cost(execution_count: int) -> tuple[int, int]:

View File

@@ -19,6 +19,7 @@ import backend.data.graph
import backend.data.user
import backend.server.routers.postmark.postmark
import backend.server.routers.v1
import backend.server.v2.admin.credit_admin_routes
import backend.server.v2.admin.store_admin_routes
import backend.server.v2.library.db
import backend.server.v2.library.model
@@ -107,6 +108,11 @@ app.include_router(
tags=["v2", "admin"],
prefix="/api/store",
)
app.include_router(
backend.server.v2.admin.credit_admin_routes.router,
tags=["v2", "admin"],
prefix="/api/credits",
)
app.include_router(
backend.server.v2.library.routes.router, tags=["v2"], prefix="/api/library"
)

View File

@@ -0,0 +1,77 @@
import logging
import typing
from autogpt_libs.auth import requires_admin_user
from autogpt_libs.auth.depends import get_user_id
from fastapi import APIRouter, Body, Depends
from prisma import Json
from prisma.enums import CreditTransactionType
from backend.data.credit import admin_get_user_history, get_user_credit_model
from backend.server.v2.admin.model import AddUserCreditsResponse, UserHistoryResponse
logger = logging.getLogger(__name__)
_user_credit_model = get_user_credit_model()
router = APIRouter(
prefix="/admin",
tags=["credits", "admin"],
dependencies=[Depends(requires_admin_user)],
)
@router.post("/add_credits", response_model=AddUserCreditsResponse)
async def add_user_credits(
user_id: typing.Annotated[str, Body()],
amount: typing.Annotated[int, Body()],
comments: typing.Annotated[str, Body()],
admin_user: typing.Annotated[
str,
Depends(get_user_id),
],
):
""" """
logger.info(f"Admin user {admin_user} is adding {amount} credits to user {user_id}")
new_balance, transaction_key = await _user_credit_model._add_transaction(
user_id,
amount,
transaction_type=CreditTransactionType.GRANT,
metadata=Json({"admin_id": admin_user, "reason": comments}),
)
return {
"new_balance": new_balance,
"transaction_key": transaction_key,
}
@router.get(
"/users_history",
response_model=UserHistoryResponse,
)
async def admin_get_all_user_history(
admin_user: typing.Annotated[
str,
Depends(get_user_id),
],
search: typing.Optional[str] = None,
page: int = 1,
page_size: int = 20,
transaction_filter: typing.Optional[CreditTransactionType] = None,
):
""" """
logger.info(f"Admin user {admin_user} is getting grant history")
try:
resp = await admin_get_user_history(
page=page,
page_size=page_size,
search=search,
transaction_filter=transaction_filter,
)
logger.info(f"Admin user {admin_user} got {len(resp.history)} grant history")
return resp
except Exception as e:
logger.exception(f"Error getting grant history: {e}")
raise e

View File

@@ -0,0 +1,16 @@
from pydantic import BaseModel
from backend.data.model import UserTransaction
from backend.server.model import Pagination
class UserHistoryResponse(BaseModel):
"""Response model for listings with version history"""
history: list[UserTransaction]
pagination: Pagination
class AddUserCreditsResponse(BaseModel):
new_balance: int
transaction_key: str

View File

@@ -1,4 +1,5 @@
import logging
import tempfile
import typing
import autogpt_libs.auth.depends
@@ -9,6 +10,7 @@ import prisma.enums
import backend.server.v2.store.db
import backend.server.v2.store.exceptions
import backend.server.v2.store.model
import backend.util.json
logger = logging.getLogger(__name__)
@@ -98,3 +100,47 @@ async def review_submission(
status_code=500,
content={"detail": "An error occurred while reviewing the submission"},
)
@router.get(
"/submissions/download/{store_listing_version_id}",
tags=["store", "admin"],
dependencies=[fastapi.Depends(autogpt_libs.auth.depends.requires_admin_user)],
)
async def admin_download_agent_file(
user: typing.Annotated[
autogpt_libs.auth.models.User,
fastapi.Depends(autogpt_libs.auth.depends.requires_admin_user),
],
store_listing_version_id: str = fastapi.Path(
..., description="The ID of the agent to download"
),
) -> fastapi.responses.FileResponse:
"""
Download the agent file by streaming its content.
Args:
store_listing_version_id (str): The ID of the agent to download
Returns:
StreamingResponse: A streaming response containing the agent's graph data.
Raises:
HTTPException: If the agent is not found or an unexpected error occurs.
"""
graph_data = await backend.server.v2.store.db.get_agent(
user_id=user.user_id,
store_listing_version_id=store_listing_version_id,
)
file_name = f"agent_{graph_data.id}_v{graph_data.version or 'latest'}.json"
# Sending graph as a stream (similar to marketplace v1)
with tempfile.NamedTemporaryFile(
mode="w", suffix=".json", delete=False
) as tmp_file:
tmp_file.write(backend.util.json.dumps(graph_data))
tmp_file.flush()
return fastapi.responses.FileResponse(
tmp_file.name, filename=file_name, media_type="application/json"
)

View File

@@ -793,6 +793,7 @@ async def create_store_version(
changes_summary=changes_summary,
version=next_version,
)
except prisma.errors.PrismaError as e:
raise backend.server.v2.store.exceptions.DatabaseError(
"Failed to create new store version"
@@ -1361,3 +1362,31 @@ async def get_admin_listings_with_versions(
page_size=page_size,
),
)
async def get_agent_as_admin(
user_id: str | None,
store_listing_version_id: str,
) -> GraphModel:
"""Get agent using the version ID and store listing version ID."""
store_listing_version = (
await prisma.models.StoreListingVersion.prisma().find_unique(
where={"id": store_listing_version_id}
)
)
if not store_listing_version:
raise ValueError(f"Store listing version {store_listing_version_id} not found")
graph = await backend.data.graph.get_graph_as_admin(
user_id=user_id,
graph_id=store_listing_version.agentGraphId,
version=store_listing_version.agentGraphVersion,
for_export=True,
)
if not graph:
raise ValueError(
f"Agent {store_listing_version.agentGraphId} v{store_listing_version.agentGraphVersion} not found"
)
return graph

View File

@@ -4,20 +4,7 @@ from typing import List
import prisma.enums
import pydantic
class Pagination(pydantic.BaseModel):
total_items: int = pydantic.Field(
description="Total number of items.", examples=[42]
)
total_pages: int = pydantic.Field(
description="Total number of pages.", examples=[97]
)
current_page: int = pydantic.Field(
description="Current_page page number.", examples=[1]
)
page_size: int = pydantic.Field(
description="Number of items per page.", examples=[25]
)
from backend.server.model import Pagination
class MyAgent(pydantic.BaseModel):

View File

@@ -137,10 +137,6 @@ class Config(UpdateTrackingModel["Config"], BaseSettings):
default=8002,
description="The port for execution manager daemon to run on",
)
execution_manager_loop_max_retry: int = Field(
default=5,
description="The maximum number of retries for the execution manager loop",
)
execution_scheduler_port: int = Field(
default=8003,

View File

@@ -46,6 +46,7 @@ async def spend_credits(entry: NodeExecutionEntry) -> int:
block_id=entry.block_id,
block=entry.block_id,
input=matching_filter,
reason=f"Ran block {entry.block_id} {block.name}",
),
)

View File

@@ -56,3 +56,9 @@ export async function getAdminListingsWithVersions(
const response = await api.getAdminListingsWithVersions(data);
return response;
}
export async function downloadAsAdmin(storeListingVersion: string) {
const api = new BackendApi();
const file = await api.downloadStoreAgentAdmin(storeListingVersion);
return file;
}

View File

@@ -63,7 +63,7 @@ export default async function Page({
<AgentInfo
name={agent.agent_name}
creator={agent.creator}
shortDescription={agent.description}
shortDescription={agent.sub_heading}
longDescription={agent.description}
rating={agent.rating}
runs={agent.runs}

View File

@@ -298,7 +298,9 @@ export default function CreditsPage() {
>
<b>{formatCredits(transaction.amount)}</b>
</TableCell>
<TableCell>{formatCredits(transaction.balance)}</TableCell>
<TableCell>
{formatCredits(transaction.running_balance)}
</TableCell>
</TableRow>
))}
</TableBody>

View File

@@ -0,0 +1,45 @@
"use server";
import { revalidatePath } from "next/cache";
import BackendApi from "@/lib/autogpt-server-api";
import {
UsersBalanceHistoryResponse,
CreditTransactionType,
} from "@/lib/autogpt-server-api/types";
export async function addDollars(formData: FormData) {
const data = {
user_id: formData.get("id") as string,
amount: parseInt(formData.get("amount") as string),
comments: formData.get("comments") as string,
};
const api = new BackendApi();
const resp = await api.addUserCredits(
data.user_id,
data.amount,
data.comments,
);
console.log(resp);
revalidatePath("/admin/spending");
}
export async function getUsersTransactionHistory(
page: number = 1,
pageSize: number = 20,
search?: string,
transactionType?: CreditTransactionType,
): Promise<UsersBalanceHistoryResponse> {
const data: Record<string, any> = {
page,
page_size: pageSize,
};
if (search) {
data.search = search;
}
if (transactionType) {
data.transaction_filter = transactionType;
}
const api = new BackendApi();
const history = await api.getUsersHistory(data);
return history;
}

View File

@@ -0,0 +1,58 @@
import { AdminUserGrantHistory } from "@/components/admin/spending/admin-grant-history-data-table";
import type { CreditTransactionType } from "@/lib/autogpt-server-api";
import { withRoleAccess } from "@/lib/withRoleAccess";
import { Suspense } from "react";
function SpendingDashboard({
searchParams,
}: {
searchParams: {
page?: string;
status?: string;
search?: string;
};
}) {
const page = searchParams.page ? Number.parseInt(searchParams.page) : 1;
const search = searchParams.search;
const status = searchParams.status as CreditTransactionType | undefined;
return (
<div className="mx-auto p-6">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">User Spending</h1>
<p className="text-gray-500">Manage user spending balances</p>
</div>
</div>
<Suspense
fallback={
<div className="py-10 text-center">Loading submissions...</div>
}
>
<AdminUserGrantHistory
initialPage={page}
initialStatus={status}
initialSearch={search}
/>
</Suspense>
</div>
</div>
);
}
export default async function SpendingDashboardPage({
searchParams,
}: {
searchParams: {
page?: string;
status?: string;
search?: string;
};
}) {
"use server";
const withAdminAccess = await withRoleAccess(["admin"]);
const ProtectedSpendingDashboard = await withAdminAccess(SpendingDashboard);
return <ProtectedSpendingDashboard searchParams={searchParams} />;
}

View File

@@ -0,0 +1,58 @@
"use client";
import { downloadAsAdmin } from "@/app/(platform)/admin/marketplace/actions";
import { Button } from "@/components/ui/button";
import { ExternalLink } from "lucide-react";
import { useState } from "react";
export function DownloadAgentAdminButton({
storeListingVersionId,
}: {
storeListingVersionId: string;
}) {
const [isLoading, setIsLoading] = useState(false);
const handleDownload = async () => {
try {
setIsLoading(true);
// Call the server action to get the data
const fileData = await downloadAsAdmin(storeListingVersionId);
// Client-side download logic
const jsonData = JSON.stringify(fileData, null, 2);
const blob = new Blob([jsonData], { type: "application/json" });
// Create a temporary URL for the Blob
const url = window.URL.createObjectURL(blob);
// Create a temporary anchor element
const a = document.createElement("a");
a.href = url;
a.download = `agent_${storeListingVersionId}.json`;
// Append the anchor to the body, click it, and remove it
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// Revoke the temporary URL
window.URL.revokeObjectURL(url);
} catch (error) {
console.error("Download failed:", error);
} finally {
setIsLoading(false);
}
};
return (
<Button
size="sm"
variant="outline"
onClick={handleDownload}
disabled={isLoading}
>
<ExternalLink className="mr-2 h-4 w-4" />
{isLoading ? "Downloading..." : "Download"}
</Button>
);
}

View File

@@ -19,6 +19,8 @@ import {
SubmissionStatus,
} from "@/lib/autogpt-server-api/types";
import { ApproveRejectButtons } from "./approve-reject-buttons";
import { downloadAsAdmin } from "@/app/(platform)/admin/marketplace/actions";
import { DownloadAgentAdminButton } from "./download-agent-button";
// Moved the getStatusBadge function into the client component
const getStatusBadge = (status: SubmissionStatus) => {
@@ -77,10 +79,11 @@ export function ExpandableRow({
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button size="sm" variant="outline">
<ExternalLink className="mr-2 h-4 w-4" />
Builder
</Button>
{latestVersion?.store_listing_version_id && (
<DownloadAgentAdminButton
storeListingVersionId={latestVersion.store_listing_version_id}
/>
)}
{latestVersion?.status === SubmissionStatus.PENDING && (
<ApproveRejectButtons version={latestVersion} />
@@ -180,17 +183,13 @@ export function ExpandableRow({
{/* <TableCell>{version.categories.join(", ")}</TableCell> */}
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="outline"
onClick={() =>
(window.location.href = `/admin/agents/${version.store_listing_version_id}`)
}
>
<ExternalLink className="mr-2 h-4 w-4" />
Builder
</Button>
{version.store_listing_version_id && (
<DownloadAgentAdminButton
storeListingVersionId={
version.store_listing_version_id
}
/>
)}
{version.status === SubmissionStatus.PENDING && (
<ApproveRejectButtons version={version} />
)}

View File

@@ -0,0 +1,139 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
import { useRouter } from "next/navigation";
import { addDollars } from "@/app/admin/spending/actions";
import useCredits from "@/hooks/useCredits";
export function AdminAddMoneyButton({
userId,
userEmail,
currentBalance,
defaultAmount,
defaultComments,
}: {
userId: string;
userEmail: string;
currentBalance: number;
defaultAmount?: number;
defaultComments?: string;
}) {
const router = useRouter();
const [isAddMoneyDialogOpen, setIsAddMoneyDialogOpen] = useState(false);
const [dollarAmount, setDollarAmount] = useState(
defaultAmount ? Math.abs(defaultAmount / 100).toFixed(2) : "1.00",
);
const { formatCredits } = useCredits();
const handleApproveSubmit = async (formData: FormData) => {
setIsAddMoneyDialogOpen(false);
try {
await addDollars(formData);
router.refresh(); // Refresh the current route
} catch (error) {
console.error("Error adding dollars:", error);
}
};
return (
<>
<Button
size="sm"
variant="default"
onClick={(e) => {
e.stopPropagation();
setIsAddMoneyDialogOpen(true);
}}
>
Add Dollars
</Button>
{/* Add $$$ Dialog */}
<Dialog
open={isAddMoneyDialogOpen}
onOpenChange={setIsAddMoneyDialogOpen}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Dollars</DialogTitle>
<DialogDescription className="pt-2">
<div className="mb-2">
<span className="font-medium">User:</span> {userEmail}
</div>
<div>
<span className="font-medium">Current balance:</span> $
{(currentBalance / 100).toFixed(2)}
</div>
</DialogDescription>
</DialogHeader>
<form action={handleApproveSubmit}>
<input type="hidden" name="id" value={userId} />
<input
type="hidden"
name="amount"
value={formatCredits(Number(dollarAmount))}
/>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="dollarAmount">Amount (in dollars)</Label>
<div className="flex">
<div className="flex items-center justify-center rounded-l-md border border-r-0 bg-gray-50 px-3 text-gray-500">
$
</div>
<Input
id="dollarAmount"
type="number"
step="0.01"
min="0"
className="rounded-l-none"
value={dollarAmount}
onChange={(e) => setDollarAmount(e.target.value)}
placeholder="0.00"
/>
</div>
</div>
</div>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="comments">Comments (Optional)</Label>
<Textarea
id="comments"
name="comments"
placeholder="Why are you adding dollars?"
defaultValue={defaultComments || "We love you!"}
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setIsAddMoneyDialogOpen(false)}
>
Cancel
</Button>
<Button type="submit">Add Dollars</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,183 @@
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { PaginationControls } from "../../ui/pagination-controls";
import { SearchAndFilterAdminSpending } from "./search-filter-form";
import { getUsersTransactionHistory } from "@/app/admin/spending/actions";
import { AdminAddMoneyButton } from "./add-money-button";
import { CreditTransactionType } from "@/lib/autogpt-server-api";
export async function AdminUserGrantHistory({
initialPage = 1,
initialStatus,
initialSearch,
}: {
initialPage?: number;
initialStatus?: CreditTransactionType;
initialSearch?: string;
}) {
// Server-side data fetching
const { history, pagination } = await getUsersTransactionHistory(
initialPage,
15,
initialSearch,
initialStatus,
);
// Helper function to format the amount with color based on transaction type
const formatAmount = (amount: number, type: CreditTransactionType) => {
const isPositive = type === CreditTransactionType.GRANT;
const isNeutral = type === CreditTransactionType.TOP_UP;
const color = isPositive
? "text-green-600"
: isNeutral
? "text-blue-600"
: "text-red-600";
return <span className={color}>${Math.abs(amount / 100)}</span>;
};
// Helper function to format the transaction type with color
const formatType = (type: CreditTransactionType) => {
const isGrant = type === CreditTransactionType.GRANT;
const isPurchased = type === CreditTransactionType.TOP_UP;
const isSpent = type === CreditTransactionType.USAGE;
let displayText = type;
let bgColor = "";
if (isGrant) {
bgColor = "bg-green-100 text-green-800";
} else if (isPurchased) {
bgColor = "bg-blue-100 text-blue-800";
} else if (isSpent) {
bgColor = "bg-red-100 text-red-800";
}
return (
<span className={`rounded-full px-2 py-1 text-xs font-medium ${bgColor}`}>
{displayText.valueOf()}
</span>
);
};
// Helper function to format the date
const formatDate = (date: Date) => {
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "numeric",
hour12: true,
}).format(new Date(date));
};
return (
<div className="space-y-4">
<SearchAndFilterAdminSpending
initialStatus={initialStatus}
initialSearch={initialSearch}
/>
<div className="rounded-md border bg-white">
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="font-medium">User</TableHead>
<TableHead className="font-medium">Type</TableHead>
<TableHead className="font-medium">Date</TableHead>
<TableHead className="font-medium">Reason</TableHead>
<TableHead className="font-medium">Admin</TableHead>
<TableHead className="font-medium">Starting Balance</TableHead>
<TableHead className="font-medium">Amount</TableHead>
<TableHead className="font-medium">Ending Balance</TableHead>
{/* <TableHead className="font-medium">Current Balance</TableHead> */}
<TableHead className="text-right font-medium">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{history.length === 0 ? (
<TableRow>
<TableCell
colSpan={8}
className="py-10 text-center text-gray-500"
>
No transactions found
</TableCell>
</TableRow>
) : (
history.map((transaction) => (
<TableRow
key={transaction.user_id}
className="hover:bg-gray-50"
>
<TableCell className="font-medium">
{transaction.user_email}
</TableCell>
<TableCell>
{formatType(transaction.transaction_type)}
</TableCell>
<TableCell className="text-gray-600">
{formatDate(transaction.transaction_time)}
</TableCell>
<TableCell>{transaction.reason}</TableCell>
<TableCell className="text-gray-600">
{transaction.admin_email}
</TableCell>
<TableCell className="font-medium text-green-600">
${(transaction.running_balance + -transaction.amount) / 100}
</TableCell>
<TableCell>
{formatAmount(
transaction.amount,
transaction.transaction_type,
)}
</TableCell>
<TableCell className="font-medium text-green-600">
${transaction.running_balance / 100}
</TableCell>
{/* <TableCell className="font-medium text-green-600">
${transaction.current_balance / 100}
</TableCell> */}
<TableCell className="text-right">
<AdminAddMoneyButton
userId={transaction.user_id}
userEmail={
transaction.user_email ?? "User Email wasn't attached"
}
currentBalance={transaction.current_balance}
defaultAmount={
transaction.transaction_type ===
CreditTransactionType.USAGE
? -transaction.amount
: undefined
}
defaultComments={
transaction.transaction_type ===
CreditTransactionType.USAGE
? "Refund for usage"
: undefined
}
/>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<PaginationControls
currentPage={initialPage}
totalPages={pagination.total_pages}
/>
</div>
);
}

View File

@@ -0,0 +1,105 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Search } from "lucide-react";
import { CreditTransactionType } from "@/lib/autogpt-server-api";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export function SearchAndFilterAdminSpending({
initialStatus,
initialSearch,
}: {
initialStatus?: CreditTransactionType;
initialSearch?: string;
}) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
// Initialize state from URL parameters
const [searchQuery, setSearchQuery] = useState(initialSearch || "");
const [selectedStatus, setSelectedStatus] = useState<string>(
searchParams.get("status") || "ALL",
);
// Update local state when URL parameters change
useEffect(() => {
const status = searchParams.get("status");
setSelectedStatus(status || "ALL");
setSearchQuery(searchParams.get("search") || "");
}, [searchParams]);
const handleSearch = () => {
const params = new URLSearchParams(searchParams.toString());
if (searchQuery) {
params.set("search", searchQuery);
} else {
params.delete("search");
}
if (selectedStatus !== "ALL") {
params.set("status", selectedStatus);
} else {
params.delete("status");
}
params.set("page", "1"); // Reset to first page on new search
router.push(`${pathname}?${params.toString()}`);
};
return (
<div className="flex items-center justify-between">
<div className="flex w-full items-center gap-2">
<Input
placeholder="Search users by Name or Email..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
/>
<Button variant="outline" onClick={handleSearch}>
<Search className="h-4 w-4" />
</Button>
</div>
<Select
value={selectedStatus}
onValueChange={(value) => {
setSelectedStatus(value);
const params = new URLSearchParams(searchParams.toString());
if (value === "ALL") {
params.delete("status");
} else {
params.set("status", value);
}
params.set("page", "1");
router.push(`${pathname}?${params.toString()}`);
}}
>
<SelectTrigger className="w-1/4">
<SelectValue placeholder="Select Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">All</SelectItem>
<SelectItem value={CreditTransactionType.TOP_UP}>Top Up</SelectItem>
<SelectItem value={CreditTransactionType.USAGE}>Usage</SelectItem>
<SelectItem value={CreditTransactionType.REFUND}>Refund</SelectItem>
<SelectItem value={CreditTransactionType.GRANT}>Grant</SelectItem>
<SelectItem value={CreditTransactionType.CARD_CHECK}>
Card Check
</SelectItem>
</SelectContent>
</Select>
</div>
);
}

View File

@@ -429,9 +429,9 @@ export const NodeGenericInputField: FC<{
// If you want to build an object of booleans from `selection`
// (like your old code), do it here. Otherwise adapt to your actual UI.
// Example:
const allKeys = schema.properties
? Object.keys(schema.properties)
: [];
const subSchema =
schema.properties || (schema as any).anyOf[0].properties;
const allKeys = subSchema ? Object.keys(subSchema) : [];
handleInputChange(
key,
Object.fromEntries(
@@ -1025,7 +1025,12 @@ const NodeMultiSelectInput: FC<{
displayName,
handleInputChange,
}) => {
const options = Object.keys(schema.properties);
const optionSchema =
schema.properties ||
((schema as any).anyOf?.length > 0
? (schema as any).anyOf[0].properties
: {});
const options = Object.keys(optionSchema);
return (
<div className={cn("flex flex-col", className)}>
@@ -1044,7 +1049,7 @@ const NodeMultiSelectInput: FC<{
<MultiSelectorContent className="nowheel">
<MultiSelectorList>
{options
.map((key) => ({ ...schema.properties[key], key }))
.map((key) => ({ ...optionSchema[key], key }))
.map(({ key, title, description }) => (
<MultiSelectorItem key={key} value={key} title={description}>
{title ?? key}

View File

@@ -6,9 +6,11 @@ import { Button } from "@/components/ui/button";
export function PaginationControls({
currentPage,
totalPages,
pathParam = "page",
}: {
currentPage: number;
totalPages: number;
pathParam?: string;
}) {
const router = useRouter();
const pathname = usePathname();
@@ -16,7 +18,7 @@ export function PaginationControls({
const createPageUrl = (page: number) => {
const params = new URLSearchParams(searchParams);
params.set("page", page.toString());
params.set(pathParam, page.toString());
return `${pathname}?${params.toString()}`;
};

View File

@@ -53,6 +53,8 @@ import {
UserOnboarding,
ReviewSubmissionRequest,
SubmissionStatus,
AddUserCreditsResponse,
UsersBalanceHistoryResponse,
CredentialsMetaInput,
} from "./types";
import { createBrowserClient } from "@supabase/ssr";
@@ -539,9 +541,9 @@ export default class BackendAPI {
return this._get(url);
}
////////////////////////////////////////
////////////// Admin API ///////////////
////////////////////////////////////////
/////////////////////////////////////////
/////////// Admin API ///////////////////
/////////////////////////////////////////
getAdminListingsWithVersions(params?: {
status?: SubmissionStatus;
@@ -563,6 +565,32 @@ export default class BackendAPI {
);
}
addUserCredits(
user_id: string,
amount: number,
comments: string,
): Promise<AddUserCreditsResponse> {
return this._request("POST", "/credits/admin/add_credits", {
user_id,
amount,
comments,
});
}
getUsersHistory(params?: {
search?: string;
page?: number;
page_size?: number;
}): Promise<UsersBalanceHistoryResponse> {
return this._get("/credits/admin/users_history", params);
}
downloadStoreAgentAdmin(storeListingVersionId: string): Promise<BlobPart> {
const url = `/store/admin/submissions/download/${storeListingVersionId}`;
return this._get(url);
}
////////////////////////////////////////
//////////// V2 LIBRARY API ////////////
////////////////////////////////////////

View File

@@ -776,14 +776,20 @@ export interface CreateAPIKeyResponse {
export interface CreditTransaction {
transaction_key: string;
transaction_time: Date;
transaction_type: string;
transaction_type: CreditTransactionType;
amount: number;
balance: number;
running_balance: number;
current_balance: number;
description: string;
usage_graph_id: GraphID;
usage_execution_id: GraphExecutionID;
usage_node_count: number;
usage_starting_time: Date;
user_id: string;
user_email: string | null;
reason: string | null;
admin_email: string | null;
extra_data: string | null;
}
export interface TransactionHistory {
@@ -899,6 +905,23 @@ export type AdminPendingSubmissionsRequest = {
page_size: number;
};
export enum CreditTransactionType {
TOP_UP = "TOP_UP",
USAGE = "USAGE",
GRANT = "GRANT",
REFUND = "REFUND",
CARD_CHECK = "CARD_CHECK",
}
export type UsersBalanceHistoryResponse = {
history: CreditTransaction[];
pagination: Pagination;
};
export type AddUserCreditsResponse = {
new_balance: number;
transaction_key: string;
};
const _stringFormatToDataTypeMap: Partial<Record<string, DataType>> = {
date: DataType.DATE,
time: DataType.TIME,

View File

@@ -57,6 +57,28 @@ docker compose -v
Once you have Docker and Docker Compose installed, you can proceed to the next step.
<details>
<summary>
Raspberry Pi 5 Specific Notes
</summary>
On Raspberry Pi 5 with Raspberry Pi OS, the default 16K page size will cause issues with the <code>supabase-vector</code> container (expected: 4K).
</br>
To fix this, edit <code>/boot/firmware/config.txt</code> and add:
</br>
```ini
kernel=kernel8.img
```
Then reboot. You can check your page size with:
</br>
```bash
getconf PAGESIZE
```
<code>16384</code> means 16K (incorrect), and <code>4096</code> means 4K (correct).
After adjusting, <code>docker compose up -d --build</code> should work normally.
</br>
See <a href="https://github.com/supabase/supabase/issues/33816">supabase/supabase #33816</a> for additional context.
</details>
## Setup
### Cloning the Repository