mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
refactor(platform): address review comments on platform cost tracking
Backend:
- Extract shared _schedule_log into schedule_cost_log() in platform_cost.py
so both cost_tracking and token_tracking drain a single task set
- Add DEFAULT_DASHBOARD_DAYS=30 default for dashboard queries to avoid
full-table scans when no date filter is provided
- Add MAX_PROVIDER_ROWS=500 / MAX_USER_ROWS=100 named constants
- Fix typing.Optional -> X | None union syntax in routes
- Fix logger f-strings to lazy %s format in platform_cost_routes
- Fix token_tracking condition to allow logging when cost_usd is set
even if total_tokens is 0 (fully-cached responses)
- Fix test_get_dashboard_success to use real PlatformCostDashboard instance
- Add invalid input tests (422 for bad dates, page_size=0/201, page=0)
- Add test_does_not_raise_when_block_usage_cost_raises
- Add test_provider_cost_zero_is_not_none
Frontend:
- Fix TrackingBadge dark mode colors using design tokens
- Fix UserTable null key for deleted users (use unknown-{idx} fallback)
- Fix ProviderTable rate input from uncontrolled to controlled
- Fix "use server" directive on page component (not a server action)
- Add ARIA label and tabpanel roles to tab UI
- Fix LogsTable fragile cast with safe formatLogDate helper
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
import logging
|
||||
import typing
|
||||
from datetime import datetime
|
||||
|
||||
from autogpt_libs.auth import get_user_id, requires_admin_user
|
||||
@@ -36,12 +35,12 @@ class PlatformCostLogsResponse(BaseModel):
|
||||
)
|
||||
async def get_cost_dashboard(
|
||||
admin_user_id: str = Security(get_user_id),
|
||||
start: typing.Optional[datetime] = Query(None),
|
||||
end: typing.Optional[datetime] = Query(None),
|
||||
provider: typing.Optional[str] = Query(None),
|
||||
user_id: typing.Optional[str] = Query(None),
|
||||
start: datetime | None = Query(None),
|
||||
end: datetime | None = Query(None),
|
||||
provider: str | None = Query(None),
|
||||
user_id: str | None = Query(None),
|
||||
):
|
||||
logger.info(f"Admin {admin_user_id} fetching platform cost dashboard")
|
||||
logger.info("Admin %s fetching platform cost dashboard", admin_user_id)
|
||||
return await get_platform_cost_dashboard(
|
||||
start=start,
|
||||
end=end,
|
||||
@@ -57,14 +56,14 @@ async def get_cost_dashboard(
|
||||
)
|
||||
async def get_cost_logs(
|
||||
admin_user_id: str = Security(get_user_id),
|
||||
start: typing.Optional[datetime] = Query(None),
|
||||
end: typing.Optional[datetime] = Query(None),
|
||||
provider: typing.Optional[str] = Query(None),
|
||||
user_id: typing.Optional[str] = Query(None),
|
||||
start: datetime | None = Query(None),
|
||||
end: datetime | None = Query(None),
|
||||
provider: str | None = Query(None),
|
||||
user_id: str | None = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=200),
|
||||
):
|
||||
logger.info(f"Admin {admin_user_id} fetching platform cost logs")
|
||||
logger.info("Admin %s fetching platform cost logs", admin_user_id)
|
||||
logs, total = await get_platform_cost_logs(
|
||||
start=start,
|
||||
end=end,
|
||||
|
||||
@@ -6,6 +6,8 @@ import pytest
|
||||
import pytest_mock
|
||||
from autogpt_libs.auth.jwt_utils import get_jwt_payload
|
||||
|
||||
from backend.data.platform_cost import PlatformCostDashboard
|
||||
|
||||
from .platform_cost_routes import router as platform_cost_router
|
||||
|
||||
app = fastapi.FastAPI()
|
||||
@@ -25,25 +27,16 @@ def setup_app_admin_auth(mock_jwt_admin):
|
||||
def test_get_dashboard_success(
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
) -> None:
|
||||
mock_dashboard = AsyncMock(
|
||||
return_value=AsyncMock(
|
||||
by_provider=[],
|
||||
by_user=[],
|
||||
total_cost_microdollars=0,
|
||||
total_requests=0,
|
||||
total_users=0,
|
||||
model_dump=lambda **_: {
|
||||
"by_provider": [],
|
||||
"by_user": [],
|
||||
"total_cost_microdollars": 0,
|
||||
"total_requests": 0,
|
||||
"total_users": 0,
|
||||
},
|
||||
)
|
||||
real_dashboard = PlatformCostDashboard(
|
||||
by_provider=[],
|
||||
by_user=[],
|
||||
total_cost_microdollars=0,
|
||||
total_requests=0,
|
||||
total_users=0,
|
||||
)
|
||||
mocker.patch(
|
||||
"backend.api.features.admin.platform_cost_routes.get_platform_cost_dashboard",
|
||||
mock_dashboard,
|
||||
AsyncMock(return_value=real_dashboard),
|
||||
)
|
||||
|
||||
response = client.get("/platform-costs/dashboard")
|
||||
@@ -72,22 +65,14 @@ def test_get_logs_success(
|
||||
def test_get_dashboard_with_filters(
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
) -> None:
|
||||
mock_dashboard = AsyncMock(
|
||||
return_value=AsyncMock(
|
||||
by_provider=[],
|
||||
by_user=[],
|
||||
total_cost_microdollars=0,
|
||||
total_requests=0,
|
||||
total_users=0,
|
||||
model_dump=lambda **_: {
|
||||
"by_provider": [],
|
||||
"by_user": [],
|
||||
"total_cost_microdollars": 0,
|
||||
"total_requests": 0,
|
||||
"total_users": 0,
|
||||
},
|
||||
)
|
||||
real_dashboard = PlatformCostDashboard(
|
||||
by_provider=[],
|
||||
by_user=[],
|
||||
total_cost_microdollars=0,
|
||||
total_requests=0,
|
||||
total_users=0,
|
||||
)
|
||||
mock_dashboard = AsyncMock(return_value=real_dashboard)
|
||||
mocker.patch(
|
||||
"backend.api.features.admin.platform_cost_routes.get_platform_cost_dashboard",
|
||||
mock_dashboard,
|
||||
@@ -156,3 +141,27 @@ def test_get_dashboard_rejects_non_admin(mock_jwt_user, mock_jwt_admin) -> None:
|
||||
assert response.status_code == 403
|
||||
finally:
|
||||
app.dependency_overrides[get_jwt_payload] = mock_jwt_admin["get_jwt_payload"]
|
||||
|
||||
|
||||
def test_get_logs_invalid_page_size_too_large() -> None:
|
||||
"""page_size > 200 must be rejected with 422."""
|
||||
response = client.get("/platform-costs/logs", params={"page_size": 201})
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_get_logs_invalid_page_size_zero() -> None:
|
||||
"""page_size = 0 (below ge=1) must be rejected with 422."""
|
||||
response = client.get("/platform-costs/logs", params={"page_size": 0})
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_get_logs_invalid_page_negative() -> None:
|
||||
"""page < 1 must be rejected with 422."""
|
||||
response = client.get("/platform-costs/logs", params={"page": 0})
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_get_dashboard_invalid_date_format() -> None:
|
||||
"""Malformed start date must be rejected with 422."""
|
||||
response = client.get("/platform-costs/dashboard", params={"start": "not-a-date"})
|
||||
assert response.status_code == 422
|
||||
|
||||
@@ -9,12 +9,11 @@ Both the baseline (OpenRouter) and SDK (Anthropic) service layers need to:
|
||||
This module extracts that common logic so both paths stay in sync.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from backend.data.platform_cost import (
|
||||
PlatformCostEntry,
|
||||
log_platform_cost_safe,
|
||||
schedule_cost_log,
|
||||
usd_to_microdollars,
|
||||
)
|
||||
|
||||
@@ -28,15 +27,6 @@ logger = logging.getLogger(__name__)
|
||||
COPILOT_BLOCK_ID = "copilot"
|
||||
COPILOT_CREDENTIAL_ID = "copilot_system"
|
||||
|
||||
# Hold strong references to in-flight log tasks to prevent GC.
|
||||
_pending_log_tasks: set[asyncio.Task] = set()
|
||||
|
||||
|
||||
def _schedule_log(entry: PlatformCostEntry) -> None:
|
||||
task = asyncio.create_task(log_platform_cost_safe(entry))
|
||||
_pending_log_tasks.add(task)
|
||||
task.add_done_callback(_pending_log_tasks.discard)
|
||||
|
||||
|
||||
def _copilot_block_name(log_prefix: str) -> str:
|
||||
"""Turn a log prefix like ``"[SDK]"`` into a stable block_name
|
||||
@@ -127,8 +117,11 @@ async def persist_and_record_usage(
|
||||
except Exception as usage_err:
|
||||
logger.warning(f"{log_prefix} Failed to record token usage: {usage_err}")
|
||||
|
||||
# Log to PlatformCostLog for admin cost dashboard
|
||||
if user_id and total_tokens > 0:
|
||||
# Log to PlatformCostLog for admin cost dashboard.
|
||||
# Include entries where cost_usd is set even if token count is 0
|
||||
# (e.g. fully-cached Anthropic responses where only cache tokens
|
||||
# accumulate a charge without incrementing total_tokens).
|
||||
if user_id and (total_tokens > 0 or cost_usd is not None):
|
||||
cost_float = None
|
||||
if cost_usd is not None:
|
||||
try:
|
||||
@@ -146,7 +139,7 @@ async def persist_and_record_usage(
|
||||
tracking_type = "tokens"
|
||||
tracking_amount = total_tokens
|
||||
|
||||
_schedule_log(
|
||||
schedule_cost_log(
|
||||
PlatformCostEntry(
|
||||
user_id=user_id,
|
||||
graph_exec_id=session_id,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
@@ -12,6 +12,14 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
MICRODOLLARS_PER_USD = 1_000_000
|
||||
|
||||
# Dashboard query limits — keep in sync with the SQL queries below
|
||||
MAX_PROVIDER_ROWS = 500
|
||||
MAX_USER_ROWS = 100
|
||||
|
||||
# Default date range for dashboard queries when no start date is provided.
|
||||
# Prevents full-table scans on large deployments.
|
||||
DEFAULT_DASHBOARD_DAYS = 30
|
||||
|
||||
|
||||
def usd_to_microdollars(cost_usd: float | None) -> int | None:
|
||||
"""Convert a USD amount (float) to microdollars (int). None-safe."""
|
||||
@@ -89,6 +97,22 @@ async def log_platform_cost_safe(entry: PlatformCostEntry) -> None:
|
||||
)
|
||||
|
||||
|
||||
# Hold strong references to in-flight log tasks to prevent GC.
|
||||
# Tasks remove themselves on completion via add_done_callback.
|
||||
_pending_log_tasks: set["asyncio.Task[None]"] = set()
|
||||
|
||||
|
||||
def schedule_cost_log(entry: PlatformCostEntry) -> None:
|
||||
"""Schedule a fire-and-forget cost log insert.
|
||||
|
||||
Shared by cost_tracking and token_tracking so both modules drain
|
||||
the same task set during shutdown.
|
||||
"""
|
||||
task = asyncio.create_task(log_platform_cost_safe(entry))
|
||||
_pending_log_tasks.add(task)
|
||||
task.add_done_callback(_pending_log_tasks.discard)
|
||||
|
||||
|
||||
def _json_or_none(data: dict[str, Any] | None) -> str | None:
|
||||
if data is None:
|
||||
return None
|
||||
@@ -185,7 +209,12 @@ async def get_platform_cost_dashboard(
|
||||
different billing models (e.g. "openai" with both "tokens" and "cost_usd"
|
||||
if pricing is later added for some entries). Frontend treats each row
|
||||
independently rather than as a provider primary key.
|
||||
|
||||
Defaults to the last DEFAULT_DASHBOARD_DAYS days when no start date is
|
||||
provided to avoid full-table scans on large deployments.
|
||||
"""
|
||||
if start is None:
|
||||
start = datetime.now(timezone.utc) - timedelta(days=DEFAULT_DASHBOARD_DAYS)
|
||||
where_p, params_p = _build_where(start, end, provider, user_id, "p")
|
||||
|
||||
by_provider_rows, user_count_rows, by_user_rows = await asyncio.gather(
|
||||
@@ -206,7 +235,7 @@ async def get_platform_cost_dashboard(
|
||||
GROUP BY p."provider",
|
||||
COALESCE(p."trackingType", p."metadata"->>'tracking_type')
|
||||
ORDER BY total_cost DESC
|
||||
LIMIT 500
|
||||
LIMIT {MAX_PROVIDER_ROWS}
|
||||
""",
|
||||
*params_p,
|
||||
),
|
||||
@@ -232,7 +261,7 @@ async def get_platform_cost_dashboard(
|
||||
WHERE {where_p}
|
||||
GROUP BY p."userId", u."email"
|
||||
ORDER BY total_cost DESC
|
||||
LIMIT 100
|
||||
LIMIT {MAX_USER_ROWS}
|
||||
""",
|
||||
*params_p,
|
||||
),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Helpers for platform cost tracking on system-credential block executions."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
|
||||
@@ -9,7 +8,7 @@ from backend.data.execution import NodeExecutionEntry
|
||||
from backend.data.model import NodeExecutionStats
|
||||
from backend.data.platform_cost import (
|
||||
PlatformCostEntry,
|
||||
log_platform_cost_safe,
|
||||
schedule_cost_log,
|
||||
usd_to_microdollars,
|
||||
)
|
||||
from backend.executor.utils import block_usage_cost
|
||||
@@ -32,16 +31,6 @@ _WALLTIME_BILLED_PROVIDERS = frozenset(
|
||||
}
|
||||
)
|
||||
|
||||
# Hold strong references to in-flight log tasks so the event loop doesn't
|
||||
# garbage-collect them mid-execution. Tasks remove themselves on completion.
|
||||
_pending_log_tasks: set[asyncio.Task] = set()
|
||||
|
||||
|
||||
def _schedule_log(entry: PlatformCostEntry) -> None:
|
||||
task = asyncio.create_task(log_platform_cost_safe(entry))
|
||||
_pending_log_tasks.add(task)
|
||||
task.add_done_callback(_pending_log_tasks.discard)
|
||||
|
||||
|
||||
def resolve_tracking(
|
||||
provider: str,
|
||||
@@ -160,7 +149,7 @@ async def log_system_credential_cost(
|
||||
if stats.provider_cost is not None:
|
||||
meta["provider_cost_usd"] = stats.provider_cost
|
||||
|
||||
_schedule_log(
|
||||
schedule_cost_log(
|
||||
PlatformCostEntry(
|
||||
user_id=node_exec.user_id,
|
||||
graph_exec_id=node_exec.graph_exec_id,
|
||||
|
||||
@@ -149,6 +149,14 @@ class TestResolveTracking:
|
||||
assert tt == "cost_usd"
|
||||
assert amt == 0.01
|
||||
|
||||
def test_provider_cost_zero_is_not_none(self):
|
||||
"""provider_cost=0.0 is falsy but should still be tracked as cost_usd
|
||||
(e.g. free-tier or fully-cached responses from OpenRouter)."""
|
||||
stats = self._stats(provider_cost=0.0)
|
||||
tt, amt = resolve_tracking("open_router", stats, {})
|
||||
assert tt == "cost_usd"
|
||||
assert amt == 0.0
|
||||
|
||||
def test_tokens_take_precedence_over_provider_specific(self):
|
||||
stats = self._stats(input_token_count=100, walltime=10.0)
|
||||
tt, amt = resolve_tracking("fal", stats, {})
|
||||
@@ -194,9 +202,7 @@ class TestLogSystemCredentialCost:
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_dry_run(self):
|
||||
mock_log = AsyncMock()
|
||||
with patch(
|
||||
"backend.executor.cost_tracking.log_platform_cost_safe", new=mock_log
|
||||
):
|
||||
with patch("backend.data.platform_cost.log_platform_cost_safe", new=mock_log):
|
||||
node_exec = _make_node_exec(dry_run=True)
|
||||
block = _make_block()
|
||||
stats = NodeExecutionStats()
|
||||
@@ -206,9 +212,7 @@ class TestLogSystemCredentialCost:
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_when_no_credential_fields(self):
|
||||
mock_log = AsyncMock()
|
||||
with patch(
|
||||
"backend.executor.cost_tracking.log_platform_cost_safe", new=mock_log
|
||||
):
|
||||
with patch("backend.data.platform_cost.log_platform_cost_safe", new=mock_log):
|
||||
node_exec = _make_node_exec(inputs={})
|
||||
block = _make_block(has_credentials=False)
|
||||
stats = NodeExecutionStats()
|
||||
@@ -218,9 +222,7 @@ class TestLogSystemCredentialCost:
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_when_cred_data_missing(self):
|
||||
mock_log = AsyncMock()
|
||||
with patch(
|
||||
"backend.executor.cost_tracking.log_platform_cost_safe", new=mock_log
|
||||
):
|
||||
with patch("backend.data.platform_cost.log_platform_cost_safe", new=mock_log):
|
||||
node_exec = _make_node_exec(inputs={})
|
||||
block = _make_block()
|
||||
stats = NodeExecutionStats()
|
||||
@@ -231,9 +233,7 @@ class TestLogSystemCredentialCost:
|
||||
async def test_skips_when_not_system_credential(self):
|
||||
mock_log = AsyncMock()
|
||||
with (
|
||||
patch(
|
||||
"backend.executor.cost_tracking.log_platform_cost_safe", new=mock_log
|
||||
),
|
||||
patch("backend.data.platform_cost.log_platform_cost_safe", new=mock_log),
|
||||
patch(
|
||||
"backend.executor.cost_tracking.is_system_credential",
|
||||
return_value=False,
|
||||
@@ -253,9 +253,7 @@ class TestLogSystemCredentialCost:
|
||||
async def test_logs_with_system_credential(self):
|
||||
mock_log = AsyncMock()
|
||||
with (
|
||||
patch(
|
||||
"backend.executor.cost_tracking.log_platform_cost_safe", new=mock_log
|
||||
),
|
||||
patch("backend.data.platform_cost.log_platform_cost_safe", new=mock_log),
|
||||
patch(
|
||||
"backend.executor.cost_tracking.is_system_credential", return_value=True
|
||||
),
|
||||
@@ -292,9 +290,7 @@ class TestLogSystemCredentialCost:
|
||||
async def test_logs_with_provider_cost(self):
|
||||
mock_log = AsyncMock()
|
||||
with (
|
||||
patch(
|
||||
"backend.executor.cost_tracking.log_platform_cost_safe", new=mock_log
|
||||
),
|
||||
patch("backend.data.platform_cost.log_platform_cost_safe", new=mock_log),
|
||||
patch(
|
||||
"backend.executor.cost_tracking.is_system_credential", return_value=True
|
||||
),
|
||||
@@ -323,9 +319,7 @@ class TestLogSystemCredentialCost:
|
||||
async def test_model_name_enum_converted_to_str(self):
|
||||
mock_log = AsyncMock()
|
||||
with (
|
||||
patch(
|
||||
"backend.executor.cost_tracking.log_platform_cost_safe", new=mock_log
|
||||
),
|
||||
patch("backend.data.platform_cost.log_platform_cost_safe", new=mock_log),
|
||||
patch(
|
||||
"backend.executor.cost_tracking.is_system_credential", return_value=True
|
||||
),
|
||||
@@ -357,9 +351,7 @@ class TestLogSystemCredentialCost:
|
||||
async def test_model_name_dict_becomes_none(self):
|
||||
mock_log = AsyncMock()
|
||||
with (
|
||||
patch(
|
||||
"backend.executor.cost_tracking.log_platform_cost_safe", new=mock_log
|
||||
),
|
||||
patch("backend.data.platform_cost.log_platform_cost_safe", new=mock_log),
|
||||
patch(
|
||||
"backend.executor.cost_tracking.is_system_credential", return_value=True
|
||||
),
|
||||
@@ -382,13 +374,34 @@ class TestLogSystemCredentialCost:
|
||||
entry = mock_log.call_args[0][0]
|
||||
assert entry.model is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_does_not_raise_when_block_usage_cost_raises(self):
|
||||
"""log_system_credential_cost must swallow exceptions from block_usage_cost."""
|
||||
with (
|
||||
patch("backend.data.platform_cost.log_platform_cost_safe", new=AsyncMock()),
|
||||
patch(
|
||||
"backend.executor.cost_tracking.is_system_credential", return_value=True
|
||||
),
|
||||
patch(
|
||||
"backend.executor.cost_tracking.block_usage_cost",
|
||||
side_effect=RuntimeError("pricing lookup failed"),
|
||||
),
|
||||
):
|
||||
node_exec = _make_node_exec(
|
||||
inputs={
|
||||
"credentials": {"id": "sys-cred", "provider": "openai"},
|
||||
}
|
||||
)
|
||||
block = _make_block()
|
||||
stats = NodeExecutionStats()
|
||||
# Should not raise — outer except must catch block_usage_cost error
|
||||
await log_system_credential_cost(node_exec, block, stats)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_round_instead_of_int_for_microdollars(self):
|
||||
mock_log = AsyncMock()
|
||||
with (
|
||||
patch(
|
||||
"backend.executor.cost_tracking.log_platform_cost_safe", new=mock_log
|
||||
),
|
||||
patch("backend.data.platform_cost.log_platform_cost_safe", new=mock_log),
|
||||
patch(
|
||||
"backend.executor.cost_tracking.is_system_credential", return_value=True
|
||||
),
|
||||
@@ -475,9 +488,7 @@ class TestManagerCostTrackingIntegration:
|
||||
have been accumulated by merge_stats across multiple yield steps."""
|
||||
mock_log = AsyncMock()
|
||||
with (
|
||||
patch(
|
||||
"backend.executor.cost_tracking.log_platform_cost_safe", new=mock_log
|
||||
),
|
||||
patch("backend.data.platform_cost.log_platform_cost_safe", new=mock_log),
|
||||
patch(
|
||||
"backend.executor.cost_tracking.is_system_credential", return_value=True
|
||||
),
|
||||
|
||||
@@ -3,6 +3,13 @@ import type { Pagination } from "@/app/api/__generated__/models/pagination";
|
||||
import { formatDuration, formatMicrodollars, formatTokens } from "../helpers";
|
||||
import { trackingBadge } from "./TrackingBadge";
|
||||
|
||||
function formatLogDate(value: unknown): string {
|
||||
if (value instanceof Date) return value.toLocaleString();
|
||||
if (typeof value === "string" || typeof value === "number")
|
||||
return new Date(value).toLocaleString();
|
||||
return "-";
|
||||
}
|
||||
|
||||
interface Props {
|
||||
logs: CostLogRow[];
|
||||
pagination: Pagination | null;
|
||||
@@ -32,9 +39,7 @@ function LogsTable({ logs, pagination, onPageChange }: Props) {
|
||||
{logs.map((log) => (
|
||||
<tr key={log.id} className="border-b hover:bg-muted">
|
||||
<td className="whitespace-nowrap px-3 py-2 text-xs">
|
||||
{new Date(
|
||||
log.created_at as unknown as string,
|
||||
).toLocaleString()}
|
||||
{formatLogDate(log.created_at)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs">
|
||||
{log.email ||
|
||||
|
||||
@@ -143,12 +143,18 @@ function PlatformCostContent({ searchParams }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div role="tablist" className="flex gap-2 border-b">
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Cost view tabs"
|
||||
className="flex gap-2 border-b"
|
||||
>
|
||||
{["overview", "by-user", "logs"].map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
id={`tab-${t}`}
|
||||
role="tab"
|
||||
aria-selected={tab === t}
|
||||
aria-controls={`tabpanel-${t}`}
|
||||
onClick={() => updateUrl({ tab: t, page: "1" })}
|
||||
className={`px-4 py-2 text-sm font-medium ${tab === t ? "border-b-2 border-blue-600 text-blue-600" : "text-muted-foreground hover:text-foreground"}`}
|
||||
>
|
||||
@@ -162,21 +168,35 @@ function PlatformCostContent({ searchParams }: Props) {
|
||||
</div>
|
||||
|
||||
{tab === "overview" && dashboard && (
|
||||
<ProviderTable
|
||||
data={dashboard.by_provider}
|
||||
rateOverrides={rateOverrides}
|
||||
onRateOverride={handleRateOverride}
|
||||
/>
|
||||
<div
|
||||
role="tabpanel"
|
||||
id="tabpanel-overview"
|
||||
aria-labelledby="tab-overview"
|
||||
>
|
||||
<ProviderTable
|
||||
data={dashboard.by_provider}
|
||||
rateOverrides={rateOverrides}
|
||||
onRateOverride={handleRateOverride}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{tab === "by-user" && dashboard && (
|
||||
<UserTable data={dashboard.by_user} />
|
||||
<div
|
||||
role="tabpanel"
|
||||
id="tabpanel-by-user"
|
||||
aria-labelledby="tab-by-user"
|
||||
>
|
||||
<UserTable data={dashboard.by_user} />
|
||||
</div>
|
||||
)}
|
||||
{tab === "logs" && (
|
||||
<LogsTable
|
||||
logs={logs}
|
||||
pagination={pagination}
|
||||
onPageChange={(p) => updateUrl({ page: p.toString() })}
|
||||
/>
|
||||
<div role="tabpanel" id="tabpanel-logs" aria-labelledby="tab-logs">
|
||||
<LogsTable
|
||||
logs={logs}
|
||||
pagination={pagination}
|
||||
onPageChange={(p) => updateUrl({ page: p.toString() })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -73,10 +73,12 @@ function ProviderTable({ data, rateOverrides, onRateOverride }: Props) {
|
||||
min="0"
|
||||
className="w-24 rounded border px-2 py-1 text-right text-xs"
|
||||
placeholder={fallback !== null ? String(fallback) : "0"}
|
||||
defaultValue={currentRate ?? ""}
|
||||
value={currentRate ?? ""}
|
||||
onChange={(e) => {
|
||||
const val = parseFloat(e.target.value);
|
||||
if (!isNaN(val)) onRateOverride(key, val);
|
||||
else if (e.target.value === "")
|
||||
onRateOverride(key, 0);
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
function trackingBadge(trackingType: string | null | undefined) {
|
||||
const colors: Record<string, string> = {
|
||||
cost_usd: "bg-green-100 text-green-800",
|
||||
tokens: "bg-blue-100 text-blue-800",
|
||||
duration_seconds: "bg-orange-100 text-orange-800",
|
||||
characters: "bg-purple-100 text-purple-800",
|
||||
sandbox_seconds: "bg-orange-100 text-orange-800",
|
||||
walltime_seconds: "bg-orange-100 text-orange-800",
|
||||
items: "bg-pink-100 text-pink-800",
|
||||
per_run: "bg-gray-100 text-gray-800",
|
||||
cost_usd: "bg-green-500/10 text-green-700 dark:text-green-400",
|
||||
tokens: "bg-blue-500/10 text-blue-700 dark:text-blue-400",
|
||||
duration_seconds: "bg-orange-500/10 text-orange-700 dark:text-orange-400",
|
||||
characters: "bg-purple-500/10 text-purple-700 dark:text-purple-400",
|
||||
sandbox_seconds: "bg-orange-500/10 text-orange-700 dark:text-orange-400",
|
||||
walltime_seconds: "bg-orange-500/10 text-orange-700 dark:text-orange-400",
|
||||
items: "bg-pink-500/10 text-pink-700 dark:text-pink-400",
|
||||
per_run: "bg-muted text-muted-foreground",
|
||||
};
|
||||
const label = trackingType || "per_run";
|
||||
return (
|
||||
|
||||
@@ -19,8 +19,11 @@ function UserTable({ data }: Props) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((row) => (
|
||||
<tr key={row.user_id} className="border-b hover:bg-muted">
|
||||
{data.map((row, idx) => (
|
||||
<tr
|
||||
key={row.user_id ?? `unknown-${idx}`}
|
||||
className="border-b hover:bg-muted"
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium">{row.email || "Unknown"}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -44,7 +44,6 @@ export default async function PlatformCostDashboardPage({
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>;
|
||||
}) {
|
||||
"use server";
|
||||
const withAdminAccess = await withRoleAccess(["admin"]);
|
||||
const ProtectedDashboard = await withAdminAccess(PlatformCostDashboard);
|
||||
return <ProtectedDashboard searchParams={await searchParams} />;
|
||||
|
||||
Reference in New Issue
Block a user