mirror of
https://github.com/BerriAI/litellm.git
synced 2025-04-25 02:34:29 +00:00
(UI - View SpendLogs Table) (#7842)
* litellm log messages / responses * add messages/response to schema.prisma * add support for logging messages / responses in DB * test_spend_logs_payload_with_prompts_enabled * _get_messages_for_spend_logs_payload * ui_view_spend_logs endpoint * add tanstack and moment * add uiSpendLogsCall * ui view logs table * ui view spendLogs table * ui_view_spend_logs * fix code quality * test_spend_logs_payload_with_prompts_enabled * _get_messages_for_spend_logs_payload * test_spend_logs_payload_with_prompts_enabled * test_spend_logs_payload_with_prompts_enabled * ui view spend logs * minor ui fix * ui - update leftnav * ui - clean up ui * fix leftnav * ui fix navbar * ui fix moving chat ui tab
This commit is contained in:
parent
a99deb6d0a
commit
d3c2f4331a
15 changed files with 795 additions and 89 deletions
|
@ -1603,6 +1603,8 @@ class LiteLLM_SpendLogs(LiteLLMPydanticObjectBase):
|
|||
cache_key: Optional[str] = None
|
||||
request_tags: Optional[Json] = None
|
||||
requester_ip_address: Optional[str] = None
|
||||
messages: Optional[Union[str, list, dict]]
|
||||
response: Optional[Union[str, list, dict]]
|
||||
|
||||
|
||||
class LiteLLM_ErrorLogs(LiteLLMPydanticObjectBase):
|
||||
|
@ -1853,6 +1855,8 @@ class SpendLogsPayload(TypedDict):
|
|||
end_user: Optional[str]
|
||||
requester_ip_address: Optional[str]
|
||||
custom_llm_provider: Optional[str]
|
||||
messages: Optional[Union[str, list, dict]]
|
||||
response: Optional[Union[str, list, dict]]
|
||||
|
||||
|
||||
class SpanAttributes(str, enum.Enum):
|
||||
|
|
|
@ -12,5 +12,6 @@ model_list:
|
|||
model_info:
|
||||
health_check_model: anthropic/claude-3-5-sonnet-20240620
|
||||
|
||||
litellm_settings:
|
||||
callbacks: ["datadog_llm_observability"]
|
||||
general_settings:
|
||||
store_prompts_in_spend_logs: true
|
||||
|
||||
|
|
|
@ -204,6 +204,8 @@ model LiteLLM_SpendLogs {
|
|||
team_id String?
|
||||
end_user String?
|
||||
requester_ip_address String?
|
||||
messages Json? @default("{}")
|
||||
response Json? @default("{}")
|
||||
@@index([startTime])
|
||||
@@index([end_user])
|
||||
}
|
||||
|
|
|
@ -13,6 +13,7 @@ from litellm.proxy._types import *
|
|||
from litellm.proxy._types import ProviderBudgetResponse, ProviderBudgetResponseObject
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.spend_tracking.spend_tracking_utils import (
|
||||
_should_store_prompts_and_responses_in_spend_logs,
|
||||
get_spend_by_team_and_customer,
|
||||
)
|
||||
from litellm.proxy.utils import handle_exception_on_proxy
|
||||
|
@ -1607,6 +1608,80 @@ async def calculate_spend(request: SpendCalculateRequest):
|
|||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/spend/logs/ui",
|
||||
tags=["Budget & Spend Tracking"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
include_in_schema=False,
|
||||
responses={
|
||||
200: {"model": List[LiteLLM_SpendLogs]},
|
||||
},
|
||||
)
|
||||
async def ui_view_spend_logs( # noqa: PLR0915
|
||||
api_key: Optional[str] = fastapi.Query(
|
||||
default=None,
|
||||
description="Get spend logs based on api key",
|
||||
),
|
||||
user_id: Optional[str] = fastapi.Query(
|
||||
default=None,
|
||||
description="Get spend logs based on user_id",
|
||||
),
|
||||
request_id: Optional[str] = fastapi.Query(
|
||||
default=None,
|
||||
description="request_id to get spend logs for specific request_id. If none passed then pass spend logs for all requests",
|
||||
),
|
||||
start_date: Optional[str] = fastapi.Query(
|
||||
default=None,
|
||||
description="Time from which to start viewing key spend",
|
||||
),
|
||||
end_date: Optional[str] = fastapi.Query(
|
||||
default=None,
|
||||
description="Time till which to view key spend",
|
||||
),
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
):
|
||||
"""
|
||||
View spend logs for UI
|
||||
"""
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if prisma_client is None:
|
||||
raise ProxyException(
|
||||
message="Prisma Client is not initialized",
|
||||
type="internal_error",
|
||||
param="None",
|
||||
code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
if _should_store_prompts_and_responses_in_spend_logs() is not True:
|
||||
verbose_proxy_logger.debug(
|
||||
"Prompts and responses are not stored in spend logs, returning empty list"
|
||||
)
|
||||
return []
|
||||
if start_date is None or end_date is None:
|
||||
raise ProxyException(
|
||||
message="Start date and end date are required",
|
||||
type="bad_request",
|
||||
param="None",
|
||||
code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
# Convert the date strings to datetime objects
|
||||
start_date_obj = datetime.strptime(start_date, "%Y-%m-%d %H:%M:%S")
|
||||
end_date_obj = datetime.strptime(end_date, "%Y-%m-%d %H:%M:%S")
|
||||
|
||||
# Convert to ISO format strings for Prisma
|
||||
start_date_iso = start_date_obj.isoformat() + "Z" # Add Z to indicate UTC
|
||||
end_date_iso = end_date_obj.isoformat() + "Z" # Add Z to indicate UTC
|
||||
|
||||
return await prisma_client.db.litellm_spendlogs.find_many(
|
||||
where={
|
||||
"startTime": {"gte": start_date_iso, "lte": end_date_iso},
|
||||
},
|
||||
order={
|
||||
"startTime": "desc",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/spend/logs",
|
||||
tags=["Budget & Spend Tracking"],
|
||||
|
|
|
@ -9,6 +9,7 @@ import litellm
|
|||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.proxy._types import SpendLogsMetadata, SpendLogsPayload
|
||||
from litellm.proxy.utils import PrismaClient, hash_token
|
||||
from litellm.types.utils import StandardLoggingPayload
|
||||
|
||||
|
||||
def _is_master_key(api_key: str, _master_key: Optional[str]) -> bool:
|
||||
|
@ -57,6 +58,9 @@ def get_logging_payload(
|
|||
usage = dict(usage)
|
||||
id = cast(dict, response_obj).get("id") or kwargs.get("litellm_call_id")
|
||||
api_key = metadata.get("user_api_key", "")
|
||||
standard_logging_payload: Optional[StandardLoggingPayload] = kwargs.get(
|
||||
"standard_logging_object", None
|
||||
)
|
||||
if api_key is not None and isinstance(api_key, str):
|
||||
if api_key.startswith("sk-"):
|
||||
# hash the api_key
|
||||
|
@ -151,10 +155,13 @@ def get_logging_payload(
|
|||
model_id=_model_id,
|
||||
requester_ip_address=clean_metadata.get("requester_ip_address", None),
|
||||
custom_llm_provider=kwargs.get("custom_llm_provider", ""),
|
||||
messages=_get_messages_for_spend_logs_payload(standard_logging_payload),
|
||||
response=_get_response_for_spend_logs_payload(standard_logging_payload),
|
||||
)
|
||||
|
||||
verbose_proxy_logger.debug(
|
||||
"SpendTable: created payload - payload: %s\n\n", payload
|
||||
"SpendTable: created payload - payload: %s\n\n",
|
||||
json.dumps(payload, indent=4, default=str),
|
||||
)
|
||||
|
||||
return payload
|
||||
|
@ -239,3 +246,29 @@ async def get_spend_by_team_and_customer(
|
|||
return []
|
||||
|
||||
return db_response
|
||||
|
||||
|
||||
def _get_messages_for_spend_logs_payload(
|
||||
payload: Optional[StandardLoggingPayload],
|
||||
) -> str:
|
||||
if payload is None:
|
||||
return "{}"
|
||||
if _should_store_prompts_and_responses_in_spend_logs():
|
||||
return json.dumps(payload.get("messages", {}))
|
||||
return "{}"
|
||||
|
||||
|
||||
def _get_response_for_spend_logs_payload(
|
||||
payload: Optional[StandardLoggingPayload],
|
||||
) -> str:
|
||||
if payload is None:
|
||||
return "{}"
|
||||
if _should_store_prompts_and_responses_in_spend_logs():
|
||||
return json.dumps(payload.get("response", {}))
|
||||
return "{}"
|
||||
|
||||
|
||||
def _should_store_prompts_and_responses_in_spend_logs() -> bool:
|
||||
from litellm.proxy.proxy_server import general_settings
|
||||
|
||||
return general_settings.get("store_prompts_in_spend_logs") is True
|
||||
|
|
|
@ -204,6 +204,8 @@ model LiteLLM_SpendLogs {
|
|||
team_id String?
|
||||
end_user String?
|
||||
requester_ip_address String?
|
||||
messages Json? @default("{}")
|
||||
response Json? @default("{}")
|
||||
@@index([startTime])
|
||||
@@index([end_user])
|
||||
}
|
||||
|
|
|
@ -300,3 +300,65 @@ def test_spend_logs_payload_whisper():
|
|||
|
||||
assert payload["call_type"] == "atranscription"
|
||||
assert payload["spend"] == 0.00023398580000000003
|
||||
|
||||
|
||||
def test_spend_logs_payload_with_prompts_enabled(monkeypatch):
|
||||
"""
|
||||
Test that messages and responses are logged in spend logs when store_prompts_in_spend_logs is enabled
|
||||
"""
|
||||
# Mock general_settings
|
||||
from litellm.proxy.proxy_server import general_settings
|
||||
|
||||
general_settings["store_prompts_in_spend_logs"] = True
|
||||
|
||||
input_args: dict = {
|
||||
"kwargs": {
|
||||
"model": "gpt-3.5-turbo",
|
||||
"messages": [{"role": "user", "content": "Hello!"}],
|
||||
"litellm_params": {
|
||||
"metadata": {
|
||||
"user_api_key": "fake_key",
|
||||
}
|
||||
},
|
||||
},
|
||||
"response_obj": litellm.ModelResponse(
|
||||
id="chatcmpl-123",
|
||||
choices=[
|
||||
litellm.Choices(
|
||||
finish_reason="stop",
|
||||
index=0,
|
||||
message=litellm.Message(content="Hi there!", role="assistant"),
|
||||
)
|
||||
],
|
||||
model="gpt-3.5-turbo",
|
||||
usage=litellm.Usage(completion_tokens=2, prompt_tokens=1, total_tokens=3),
|
||||
),
|
||||
"start_time": datetime.datetime.now(),
|
||||
"end_time": datetime.datetime.now(),
|
||||
"end_user_id": "user123",
|
||||
}
|
||||
|
||||
# Create a standard logging payload
|
||||
standard_logging_payload = {
|
||||
"messages": [{"role": "user", "content": "Hello!"}],
|
||||
"response": {"role": "assistant", "content": "Hi there!"},
|
||||
}
|
||||
input_args["kwargs"]["standard_logging_object"] = standard_logging_payload
|
||||
|
||||
payload: SpendLogsPayload = get_logging_payload(**input_args)
|
||||
|
||||
print("json payload: ", json.dumps(payload, indent=4, default=str))
|
||||
|
||||
# Verify messages and response are included in payload
|
||||
assert payload["messages"] == json.dumps([{"role": "user", "content": "Hello!"}])
|
||||
assert payload["response"] == json.dumps(
|
||||
{"role": "assistant", "content": "Hi there!"}
|
||||
)
|
||||
|
||||
# Clean up - reset general_settings
|
||||
general_settings["store_prompts_in_spend_logs"] = False
|
||||
|
||||
# Verify messages and response are not included when disabled
|
||||
payload_disabled: SpendLogsPayload = get_logging_payload(**input_args)
|
||||
assert payload_disabled["messages"] == "{}"
|
||||
assert payload_disabled["response"] == "{}"
|
||||
|
|
49
ui/litellm-dashboard/package-lock.json
generated
49
ui/litellm-dashboard/package-lock.json
generated
|
@ -12,18 +12,21 @@
|
|||
"@headlessui/tailwindcss": "^0.2.0",
|
||||
"@heroicons/react": "^1.0.6",
|
||||
"@remixicon/react": "^4.1.1",
|
||||
"@tanstack/react-table": "^8.20.6",
|
||||
"@tremor/react": "^3.13.3",
|
||||
"antd": "^5.13.2",
|
||||
"fs": "^0.0.1-security",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"moment": "^2.30.1",
|
||||
"next": "^14.2.15",
|
||||
"openai": "^4.28.0",
|
||||
"react": "^18",
|
||||
"react-copy-to-clipboard": "^5.1.0",
|
||||
"react-dom": "^18",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-syntax-highlighter": "^15.6.1"
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"tanstack": "^1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
|
@ -753,6 +756,25 @@
|
|||
"tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-table": {
|
||||
"version": "8.20.6",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.20.6.tgz",
|
||||
"integrity": "sha512-w0jluT718MrOKthRcr2xsjqzx+oEM7B7s/XXyfs19ll++hlId3fjTm+B2zrR3ijpANpkzBAr15j1XGVOMxpggQ==",
|
||||
"dependencies": {
|
||||
"@tanstack/table-core": "8.20.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8",
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-virtual": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.0.2.tgz",
|
||||
|
@ -769,6 +791,18 @@
|
|||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/table-core": {
|
||||
"version": "8.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.20.5.tgz",
|
||||
"integrity": "sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/virtual-core": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.0.0.tgz",
|
||||
|
@ -4879,6 +4913,14 @@
|
|||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/moment": {
|
||||
"version": "2.30.1",
|
||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
|
||||
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
||||
|
@ -7174,6 +7216,11 @@
|
|||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tanstack": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tanstack/-/tanstack-1.0.0.tgz",
|
||||
"integrity": "sha512-BUpDmwGlWHk2F183Uu1+k85biSLrpSh/zA9ephJwmZ9ze+XDEw3JOyN9vhcbFqrQFrf5yuWImt+0Kn4fUNgzTg=="
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
|
||||
|
|
|
@ -13,18 +13,21 @@
|
|||
"@headlessui/tailwindcss": "^0.2.0",
|
||||
"@heroicons/react": "^1.0.6",
|
||||
"@remixicon/react": "^4.1.1",
|
||||
"@tanstack/react-table": "^8.20.6",
|
||||
"@tremor/react": "^3.13.3",
|
||||
"antd": "^5.13.2",
|
||||
"fs": "^0.0.1-security",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"moment": "^2.30.1",
|
||||
"next": "^14.2.15",
|
||||
"openai": "^4.28.0",
|
||||
"react": "^18",
|
||||
"react-copy-to-clipboard": "^5.1.0",
|
||||
"react-dom": "^18",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-syntax-highlighter": "^15.6.1"
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"tanstack": "^1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
|
|
|
@ -12,6 +12,7 @@ import Settings from "@/components/settings";
|
|||
import GeneralSettings from "@/components/general_settings";
|
||||
import PassThroughSettings from "@/components/pass_through_settings";
|
||||
import BudgetPanel from "@/components/budgets/budget_panel";
|
||||
import SpendLogsTable from "@/components/view_logs";
|
||||
import ModelHub from "@/components/model_hub";
|
||||
import APIRef from "@/components/api_ref";
|
||||
import ChatUI from "@/components/chat_ui";
|
||||
|
@ -304,6 +305,13 @@ const CreateKeyPage = () => {
|
|||
accessToken={accessToken}
|
||||
modelData={modelData}
|
||||
/>
|
||||
) : page == "logs" ? (
|
||||
<SpendLogsTable
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
token={token}
|
||||
accessToken={accessToken}
|
||||
/>
|
||||
) : (
|
||||
<Usage
|
||||
userID={userID}
|
||||
|
|
|
@ -145,7 +145,13 @@ const ChatUI: React.FC<ChatUIProps> = ({
|
|||
useEffect(() => {
|
||||
// Scroll to the bottom of the chat whenever chatHistory updates
|
||||
if (chatEndRef.current) {
|
||||
chatEndRef.current.scrollIntoView({ behavior: "smooth" });
|
||||
// Add a small delay to ensure content is rendered
|
||||
setTimeout(() => {
|
||||
chatEndRef.current?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "end" // Keep the scroll position at the end
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
}, [chatHistory]);
|
||||
|
||||
|
@ -359,7 +365,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
|
|||
))}
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div ref={chatEndRef} />
|
||||
<div ref={chatEndRef} style={{ height: "1px" }} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
|
|
|
@ -2,6 +2,22 @@ import { Layout, Menu } from "antd";
|
|||
import Link from "next/link";
|
||||
import { List } from "postcss/lib/list";
|
||||
import { Text } from "@tremor/react";
|
||||
import {
|
||||
KeyOutlined,
|
||||
PlayCircleOutlined,
|
||||
BlockOutlined,
|
||||
BarChartOutlined,
|
||||
TeamOutlined,
|
||||
BankOutlined,
|
||||
UserOutlined,
|
||||
SettingOutlined,
|
||||
ApiOutlined,
|
||||
AppstoreOutlined,
|
||||
DatabaseOutlined,
|
||||
FileTextOutlined,
|
||||
LineOutlined,
|
||||
LineChartOutlined
|
||||
} from '@ant-design/icons';
|
||||
|
||||
const { Sider } = Layout;
|
||||
|
||||
|
@ -18,6 +34,8 @@ interface MenuItem {
|
|||
page: string;
|
||||
label: string;
|
||||
roles?: string[];
|
||||
children?: MenuItem[]; // Add children property for submenus
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
const old_admin_roles = ["Admin", "Admin Viewer"];
|
||||
|
@ -28,59 +46,103 @@ const rolesAllowedToSeeUsage = ["Admin", "Admin Viewer", "Internal User", "Inter
|
|||
|
||||
// Note: If a menu item does not have a role, it is visible to all roles.
|
||||
const menuItems: MenuItem[] = [
|
||||
{ key: "1", page: "api-keys", label: "Virtual Keys" }, // all roles
|
||||
{ key: "3", page: "llm-playground", label: "Test Key" }, // all roles
|
||||
{ key: "2", page: "models", label: "Models", roles: all_admin_roles },
|
||||
{ key: "4", page: "usage", label: "Usage"}, // all roles
|
||||
{ key: "6", page: "teams", label: "Teams" },
|
||||
{ key: "17", page: "organizations", label: "Organizations", roles: all_admin_roles },
|
||||
{ key: "5", page: "users", label: "Internal Users", roles: all_admin_roles },
|
||||
{ key: "8", page: "settings", label: "Logging & Alerts", roles: all_admin_roles },
|
||||
{ key: "9", page: "caching", label: "Caching", roles: all_admin_roles },
|
||||
{ key: "10", page: "budgets", label: "Budgets", roles: all_admin_roles },
|
||||
{ key: "11", page: "general-settings", label: "Router Settings", roles: all_admin_roles },
|
||||
{ key: "12", page: "pass-through-settings", label: "Pass-Through", roles: all_admin_roles },
|
||||
{ key: "13", page: "admin-panel", label: "Admin Settings", roles: all_admin_roles },
|
||||
{ key: "14", page: "api_ref", label: "API Reference" }, // all roles
|
||||
{ key: "16", page: "model-hub", label: "Model Hub" }, // all roles
|
||||
{ key: "1", page: "api-keys", label: "Virtual Keys", icon: <KeyOutlined /> },
|
||||
{ key: "3", page: "llm-playground", label: "Test Key", icon: <PlayCircleOutlined /> },
|
||||
{ key: "2", page: "models", label: "Models", icon: <BlockOutlined />, roles: all_admin_roles },
|
||||
{ key: "4", page: "usage", label: "Usage", icon: <BarChartOutlined /> },
|
||||
{ key: "6", page: "teams", label: "Teams", icon: <TeamOutlined /> },
|
||||
{ key: "17", page: "organizations", label: "Organizations", icon: <BankOutlined />, roles: all_admin_roles },
|
||||
{ key: "5", page: "users", label: "Internal Users", icon: <UserOutlined />, roles: all_admin_roles },
|
||||
{ key: "14", page: "api_ref", label: "API Reference", icon: <ApiOutlined /> },
|
||||
{ key: "16", page: "model-hub", label: "Model Hub", icon: <AppstoreOutlined /> },
|
||||
{
|
||||
key: "extras",
|
||||
page: "extras",
|
||||
label: "Extras",
|
||||
icon: <DatabaseOutlined />,
|
||||
roles: all_admin_roles,
|
||||
children: [
|
||||
{ key: "15", page: "logs", label: "Logs", icon: <LineChartOutlined />, roles: all_admin_roles },
|
||||
{ key: "9", page: "caching", label: "Caching", icon: <DatabaseOutlined />, roles: all_admin_roles },
|
||||
{ key: "10", page: "budgets", label: "Budgets", icon: <BankOutlined />, roles: all_admin_roles },
|
||||
]
|
||||
},
|
||||
{
|
||||
key: "settings",
|
||||
page: "settings",
|
||||
label: "Settings",
|
||||
icon: <SettingOutlined />,
|
||||
roles: all_admin_roles,
|
||||
children: [
|
||||
{ key: "11", page: "general-settings", label: "Router Settings", icon: <SettingOutlined />, roles: all_admin_roles },
|
||||
{ key: "12", page: "pass-through-settings", label: "Pass-Through", icon: <ApiOutlined />, roles: all_admin_roles },
|
||||
{ key: "8", page: "settings", label: "Logging & Alerts", icon: <SettingOutlined />, roles: all_admin_roles },
|
||||
{ key: "13", page: "admin-panel", label: "Admin Settings", icon: <SettingOutlined />, roles: all_admin_roles },
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// The Sidebar component can now be simplified to:
|
||||
const Sidebar: React.FC<SidebarProps> = ({
|
||||
setPage,
|
||||
userRole,
|
||||
defaultSelectedKey,
|
||||
}) => {
|
||||
// Find the menu item that matches the default page to get its key
|
||||
const selectedMenuItem = menuItems.find(item => item.page === defaultSelectedKey);
|
||||
const selectedMenuKey = selectedMenuItem?.key || "1";
|
||||
// Find the menu item that matches the default page, including in submenus
|
||||
const findMenuItemKey = (page: string): string => {
|
||||
// Check top-level items
|
||||
const topLevelItem = menuItems.find(item => item.page === page);
|
||||
if (topLevelItem) return topLevelItem.key;
|
||||
|
||||
// Check submenu items
|
||||
for (const item of menuItems) {
|
||||
if (item.children) {
|
||||
const childItem = item.children.find(child => child.page === page);
|
||||
if (childItem) return childItem.key;
|
||||
}
|
||||
}
|
||||
return "1"; // Default to first item if not found
|
||||
};
|
||||
|
||||
const selectedMenuKey = findMenuItemKey(defaultSelectedKey);
|
||||
|
||||
const filteredMenuItems = menuItems.filter(item =>
|
||||
!item.roles || item.roles.includes(userRole)
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: "100vh", maxWidth: userRole === "Admin Viewer" ? "120px" : "145px" }}>
|
||||
<Sider width={userRole === "Admin Viewer" ? 120 : 145}>
|
||||
<Layout style={{ minHeight: "100vh" }}>
|
||||
<Sider theme="light" width={220}>
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[selectedMenuKey]}
|
||||
style={{ height: "100%", borderRight: 0 }}
|
||||
>
|
||||
{filteredMenuItems.map(item => (
|
||||
<Menu.Item
|
||||
key={item.key}
|
||||
onClick={() => {
|
||||
style={{
|
||||
borderRight: 0,
|
||||
backgroundColor: 'transparent',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
items={filteredMenuItems.map(item => ({
|
||||
key: item.key,
|
||||
icon: item.icon,
|
||||
label: item.label,
|
||||
children: item.children?.map(child => ({
|
||||
key: child.key,
|
||||
icon: child.icon,
|
||||
label: child.label,
|
||||
onClick: () => {
|
||||
const newSearchParams = new URLSearchParams(window.location.search);
|
||||
newSearchParams.set('page', child.page);
|
||||
window.history.pushState(null, '', `?${newSearchParams.toString()}`);
|
||||
setPage(child.page);
|
||||
}
|
||||
})),
|
||||
onClick: !item.children ? () => {
|
||||
const newSearchParams = new URLSearchParams(window.location.search);
|
||||
newSearchParams.set('page', item.page);
|
||||
window.history.pushState(null, '', `?${newSearchParams.toString()}`);
|
||||
setPage(item.page);
|
||||
}}
|
||||
>
|
||||
<Text>{item.label}</Text>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu>
|
||||
} : undefined
|
||||
}))}
|
||||
/>
|
||||
</Sider>
|
||||
</Layout>
|
||||
);
|
||||
|
|
|
@ -17,6 +17,7 @@ import {
|
|||
Card,
|
||||
} from "@tremor/react";
|
||||
|
||||
|
||||
// Define the props type
|
||||
interface NavbarProps {
|
||||
userID: string | null;
|
||||
|
@ -82,57 +83,46 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
];
|
||||
|
||||
return (
|
||||
<nav className="left-0 right-0 top-0 flex justify-between items-center h-12 mb-4">
|
||||
<div className="text-left my-2 absolute top-0 left-0">
|
||||
<div className="flex flex-col items-center">
|
||||
<Link href="/">
|
||||
<button className="text-gray-800 rounded text-center">
|
||||
<img
|
||||
src={imageUrl}
|
||||
width={160}
|
||||
height={160}
|
||||
alt="LiteLLM Brand"
|
||||
className="mr-2"
|
||||
/>
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right mx-4 my-2 absolute top-0 right-0 flex items-center justify-end space-x-2">
|
||||
{premiumUser ? null : (
|
||||
<div
|
||||
style={{
|
||||
// border: '1px solid #391085',
|
||||
padding: "6px",
|
||||
borderRadius: "8px", // Added border-radius property
|
||||
}}
|
||||
>
|
||||
<a
|
||||
href="https://calendly.com/d/4mp-gd3-k5k/litellm-1-1-onboarding-chat"
|
||||
target="_blank"
|
||||
style={{
|
||||
fontSize: "14px",
|
||||
textDecoration: "underline",
|
||||
}}
|
||||
>
|
||||
Get enterprise license
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<>
|
||||
<nav className="bg-white border-b border-gray-200 sticky top-0 z-10">
|
||||
<div className="w-full px-4">
|
||||
<div className="flex justify-between items-center h-14">
|
||||
{/* Left side - Just Logo */}
|
||||
<div className="flex items-center">
|
||||
<Link href="/" className="flex items-center">
|
||||
<span className="text-xl">🚅</span>
|
||||
<span className="ml-2 text-base font-medium text-gray-700">LiteLLM</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
border: "1px solid #391085",
|
||||
padding: "6px",
|
||||
borderRadius: "8px", // Added border-radius property
|
||||
}}
|
||||
>
|
||||
<Dropdown menu={{ items }}>
|
||||
<Space>{userEmail ? userEmail : userRole}</Space>
|
||||
</Dropdown>
|
||||
{/* Right side - Links and Admin */}
|
||||
<div className="flex items-center space-x-6">
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
Docs
|
||||
</a>
|
||||
<Dropdown menu={{ items }}>
|
||||
<button className="flex items-center text-sm text-gray-600 hover:text-gray-800">
|
||||
Admin
|
||||
<svg
|
||||
className="ml-1 w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -1513,6 +1513,54 @@ export const userSpendLogsCall = async (
|
|||
throw error;
|
||||
}
|
||||
};
|
||||
export const uiSpendLogsCall = async (
|
||||
accessToken: String,
|
||||
api_key?: string,
|
||||
user_id?: string,
|
||||
request_id?: string,
|
||||
start_date?: string,
|
||||
end_date?: string,
|
||||
) => {
|
||||
try {
|
||||
// Construct base URL
|
||||
let url = proxyBaseUrl ? `${proxyBaseUrl}/spend/logs/ui` : `/spend/logs/ui`;
|
||||
|
||||
// Add query parameters if they exist
|
||||
const queryParams = new URLSearchParams();
|
||||
if (api_key) queryParams.append('api_key', api_key);
|
||||
if (user_id) queryParams.append('user_id', user_id);
|
||||
if (request_id) queryParams.append('request_id', request_id);
|
||||
if (start_date) queryParams.append('start_date', start_date);
|
||||
if (end_date) queryParams.append('end_date', end_date);
|
||||
// Append query parameters to URL if any exist
|
||||
const queryString = queryParams.toString();
|
||||
if (queryString) {
|
||||
url += `?${queryString}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.text();
|
||||
handleError(errorData);
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log("Spend Logs UI Response:", data);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch spend logs:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
export const adminSpendLogsCall = async (accessToken: String) => {
|
||||
try {
|
||||
|
|
363
ui/litellm-dashboard/src/components/view_logs.tsx
Normal file
363
ui/litellm-dashboard/src/components/view_logs.tsx
Normal file
|
@ -0,0 +1,363 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { Card } from "@tremor/react";
|
||||
import {
|
||||
Table,
|
||||
TableHead,
|
||||
TableHeaderCell,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableCell,
|
||||
Text
|
||||
} from "@tremor/react";
|
||||
import { uiSpendLogsCall } from './networking';
|
||||
import moment from 'moment';
|
||||
import {
|
||||
createColumnHelper,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
getSortedRowModel,
|
||||
SortingState,
|
||||
} from '@tanstack/react-table';
|
||||
|
||||
interface SpendLogsTableProps {
|
||||
accessToken: string | null;
|
||||
token: string | null;
|
||||
userRole: string | null;
|
||||
userID: string | null;
|
||||
}
|
||||
|
||||
interface LogEntry {
|
||||
request_id: string;
|
||||
api_key: string;
|
||||
model: string;
|
||||
api_base?: string;
|
||||
call_type: string;
|
||||
spend: number;
|
||||
total_tokens: number;
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
user?: string;
|
||||
metadata?: Record<string, any>;
|
||||
cache_hit: string;
|
||||
cache_key?: string;
|
||||
request_tags?: Record<string, any>;
|
||||
requester_ip_address?: string;
|
||||
messages: string | any[] | Record<string, any>;
|
||||
response: string | any[] | Record<string, any>;
|
||||
}
|
||||
|
||||
const formatMessage = (message: any): string => {
|
||||
if (!message) return 'N/A';
|
||||
if (typeof message === 'string') return message;
|
||||
if (typeof message === 'object') {
|
||||
// Handle the {text, type} object specifically
|
||||
if (message.text) return message.text;
|
||||
if (message.content) return message.content;
|
||||
return JSON.stringify(message);
|
||||
}
|
||||
return String(message);
|
||||
};
|
||||
|
||||
const RequestViewer: React.FC<{ data: any }> = ({ data }) => {
|
||||
const formatData = (input: any) => {
|
||||
if (typeof input === 'string') {
|
||||
try {
|
||||
return JSON.parse(input);
|
||||
} catch {
|
||||
return input;
|
||||
}
|
||||
}
|
||||
return input;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 bg-gray-50 space-y-6">
|
||||
{/* Request Card */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="flex justify-between items-center p-4 border-b">
|
||||
<h3 className="text-lg font-medium">Request</h3>
|
||||
<div>
|
||||
<button className="mr-2 px-3 py-1 text-sm border rounded hover:bg-gray-50">Expand</button>
|
||||
<button className="px-3 py-1 text-sm border rounded hover:bg-gray-50">JSON</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre className="p-4 overflow-auto text-sm">
|
||||
{JSON.stringify(formatData(data.request), null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Response Card */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="flex justify-between items-center p-4 border-b">
|
||||
<h3 className="text-lg font-medium">Response</h3>
|
||||
<div>
|
||||
<button className="mr-2 px-3 py-1 text-sm border rounded hover:bg-gray-50">Expand</button>
|
||||
<button className="px-3 py-1 text-sm border rounded hover:bg-gray-50">JSON</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre className="p-4 overflow-auto text-sm">
|
||||
{JSON.stringify(formatData(data.response), null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
{/* Metadata Card */}
|
||||
{data.metadata && Object.keys(data.metadata).length > 0 && (
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="flex justify-between items-center p-4 border-b">
|
||||
<h3 className="text-lg font-medium">Metadata</h3>
|
||||
<div>
|
||||
<button className="px-3 py-1 text-sm border rounded hover:bg-gray-50">JSON</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre className="p-4 overflow-auto text-sm">
|
||||
{JSON.stringify(data.metadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const SpendLogsTable: React.FC<SpendLogsTableProps> = ({ accessToken, token, userRole, userID }) => {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [expandedRow, setExpandedRow] = useState<string | null>(null);
|
||||
|
||||
const columnHelper = createColumnHelper<LogEntry>();
|
||||
|
||||
const columns = [
|
||||
{
|
||||
header: 'Time',
|
||||
accessorKey: 'startTime',
|
||||
cell: (info: any) => (
|
||||
<span>{moment(info.getValue()).format('MMM DD HH:mm:ss')}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Request ID',
|
||||
accessorKey: 'request_id',
|
||||
cell: (info: any) => (
|
||||
<span className="font-mono text-xs">{String(info.getValue() || '')}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Type',
|
||||
accessorKey: 'call_type',
|
||||
cell: (info: any) => (
|
||||
<span>{String(info.getValue() || '')}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Request',
|
||||
accessorKey: 'messages',
|
||||
cell: (info: any) => {
|
||||
const messages = info.getValue();
|
||||
try {
|
||||
const content = typeof messages === 'string' ? JSON.parse(messages) : messages;
|
||||
let displayText = '';
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
displayText = formatMessage(content[0]?.content);
|
||||
} else {
|
||||
displayText = formatMessage(content);
|
||||
}
|
||||
|
||||
return <span className="truncate max-w-md text-sm">{displayText}</span>;
|
||||
} catch (e) {
|
||||
return <span className="truncate max-w-md text-sm">{formatMessage(messages)}</span>;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Model',
|
||||
accessorKey: 'model',
|
||||
cell: (info: any) => <span>{String(info.getValue() || '')}</span>,
|
||||
},
|
||||
{
|
||||
header: 'Tokens',
|
||||
accessorKey: 'total_tokens',
|
||||
cell: (info: any) => {
|
||||
const row = info.row.original;
|
||||
return (
|
||||
<span className="text-sm">
|
||||
{String(row.total_tokens || '0')}
|
||||
<span className="text-gray-400 text-xs ml-1">
|
||||
({String(row.prompt_tokens || '0')}+{String(row.completion_tokens || '0')})
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'User',
|
||||
accessorKey: 'user',
|
||||
cell: (info: any) => <span>{String(info.getValue() || '-')}</span>,
|
||||
},
|
||||
{
|
||||
header: 'Cost',
|
||||
accessorKey: 'spend',
|
||||
cell: (info: any) => <span>${(Number(info.getValue() || 0)).toFixed(6)}</span>,
|
||||
},
|
||||
{
|
||||
header: 'Tags',
|
||||
accessorKey: 'request_tags',
|
||||
cell: (info: any) => {
|
||||
const tags = info.getValue();
|
||||
if (!tags || Object.keys(tags).length === 0) return '-';
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{Object.entries(tags).map(([key, value]) => (
|
||||
<span key={key} className="px-2 py-1 bg-gray-100 rounded-full text-xs">
|
||||
{key}: {String(value)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: logs,
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
},
|
||||
onSortingChange: setSorting,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchLogs = async () => {
|
||||
if (!accessToken || !token || !userRole || !userID) {
|
||||
console.log("got None values for one of accessToken, token, userRole, userID");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
// Get logs for last 24 hours using ISO string and proper date formatting
|
||||
const endTime = moment().format('YYYY-MM-DD HH:mm:ss');
|
||||
const startTime = moment().subtract(24, 'hours').format('YYYY-MM-DD HH:mm:ss');
|
||||
|
||||
const data = await uiSpendLogsCall(
|
||||
accessToken,
|
||||
token,
|
||||
userRole,
|
||||
userID,
|
||||
startTime,
|
||||
endTime
|
||||
);
|
||||
|
||||
console.log("response from uiSpendLogsCall:", data);
|
||||
|
||||
// Transform the data and add unique keys
|
||||
const formattedLogs = data.map((log: LogEntry, index: number) => ({
|
||||
...log,
|
||||
key: index.toString(),
|
||||
}));
|
||||
|
||||
setLogs(formattedLogs);
|
||||
} catch (error) {
|
||||
console.error('Error fetching logs:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchLogs();
|
||||
}, [accessToken, token, userRole, userID]);
|
||||
|
||||
if (!accessToken || !token || !userRole || !userID) {
|
||||
console.log("got None values for one of accessToken, token, userRole, userID");
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-[90%] mx-auto px-4">
|
||||
<div className="bg-white rounded-lg shadow w-full">
|
||||
<div className="p-4 border-b flex justify-between items-center">
|
||||
<div className="flex space-x-4 items-center">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by request ID, model, or user..."
|
||||
className="px-4 py-2 border rounded-lg w-80"
|
||||
/>
|
||||
<button className="px-4 py-2 border rounded-lg flex items-center gap-2">
|
||||
<span>Filters</span>
|
||||
<span className="text-xs bg-gray-100 px-2 py-1 rounded">0</span>
|
||||
</button>
|
||||
<select className="px-4 py-2 border rounded-lg">
|
||||
<option>Last 24 hours</option>
|
||||
<option>Last 7 days</option>
|
||||
<option>Last 30 days</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="px-3 py-1 text-sm border rounded hover:bg-gray-50">Export</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto w-full">
|
||||
<Table className="w-full table-fixed">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{columns.map((column) => (
|
||||
<TableHeaderCell
|
||||
key={column.header}
|
||||
className={`${column.header === 'Request ID' ? 'w-32' : ''} whitespace-nowrap`}
|
||||
>
|
||||
{column.header}
|
||||
</TableHeaderCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{logs.map((log) => (
|
||||
<React.Fragment key={log.request_id || log.startTime}>
|
||||
<TableRow
|
||||
className="cursor-pointer hover:bg-gray-50"
|
||||
onClick={() => setExpandedRow(expandedRow === log.request_id ? null : log.request_id)}
|
||||
>
|
||||
{columns.map((column) => (
|
||||
<TableCell
|
||||
key={column.header}
|
||||
className={column.header === 'Request ID' ? 'w-32 truncate' : ''}
|
||||
>
|
||||
{column.cell ?
|
||||
column.cell({
|
||||
getValue: () => log[column.accessorKey as keyof LogEntry],
|
||||
row: { original: log }
|
||||
}) :
|
||||
<span>{String(log[column.accessorKey as keyof LogEntry] ?? '')}</span>
|
||||
}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
{expandedRow === log.request_id && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="p-0">
|
||||
<RequestViewer data={{
|
||||
request: typeof log.messages === 'string' ? JSON.parse(log.messages) : log.messages,
|
||||
response: typeof log.response === 'string' ? JSON.parse(log.response) : log.response,
|
||||
metadata: log.metadata
|
||||
}} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpendLogsTable;
|
Loading…
Add table
Add a link
Reference in a new issue