mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
Merge branch 'dev' into abhi/ci-chromatic
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
16
autogpt_platform/backend/backend/server/v2/admin/model.py
Normal file
16
autogpt_platform/backend/backend/server/v2/admin/model.py
Normal 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
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
45
autogpt_platform/frontend/src/app/admin/spending/actions.ts
Normal file
45
autogpt_platform/frontend/src/app/admin/spending/actions.ts
Normal 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;
|
||||
}
|
||||
58
autogpt_platform/frontend/src/app/admin/spending/page.tsx
Normal file
58
autogpt_platform/frontend/src/app/admin/spending/page.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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()}`;
|
||||
};
|
||||
|
||||
|
||||
@@ -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 ////////////
|
||||
////////////////////////////////////////
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user