forked from phoenix/litellm-mirror
feat(proxy_server.py): show admin global spend as time series data
This commit is contained in:
parent
e39ce9b119
commit
43da22ae13
8 changed files with 422 additions and 98 deletions
|
@ -3015,16 +3015,7 @@ async def info_key_fn(
|
|||
tags=["budget & spend Tracking"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
)
|
||||
async def spend_key_fn(
|
||||
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",
|
||||
),
|
||||
):
|
||||
async def spend_key_fn():
|
||||
"""
|
||||
View all keys created, ordered by spend
|
||||
|
||||
|
@ -3041,40 +3032,7 @@ async def spend_key_fn(
|
|||
f"Database not connected. Connect a database to your proxy - https://docs.litellm.ai/docs/simple_proxy#managing-auth---virtual-keys"
|
||||
)
|
||||
|
||||
if (
|
||||
start_date is not None
|
||||
and isinstance(start_date, str)
|
||||
and end_date is not None
|
||||
and isinstance(end_date, str)
|
||||
):
|
||||
# Convert the date strings to datetime objects
|
||||
start_date_obj = datetime.strptime(start_date, "%Y-%m-%d")
|
||||
end_date_obj = datetime.strptime(end_date, "%Y-%m-%d")
|
||||
|
||||
# SQL query
|
||||
response = await prisma_client.db.litellm_spendlogs.group_by(
|
||||
by=["api_key", "startTime"],
|
||||
where={
|
||||
"startTime": {
|
||||
"gte": start_date_obj, # Greater than or equal to Start Date
|
||||
"lte": end_date_obj, # Less than or equal to End Date
|
||||
}
|
||||
},
|
||||
sum={
|
||||
"spend": True,
|
||||
},
|
||||
)
|
||||
|
||||
# TODO: Execute SQL query and return the results
|
||||
|
||||
return {
|
||||
"message": "This is your SQL query",
|
||||
"response": response,
|
||||
}
|
||||
else:
|
||||
key_info = await prisma_client.get_data(
|
||||
table_name="key", query_type="find_all"
|
||||
)
|
||||
key_info = await prisma_client.get_data(table_name="key", query_type="find_all")
|
||||
return key_info
|
||||
|
||||
except Exception as e:
|
||||
|
@ -3157,6 +3115,14 @@ async def view_spend_logs(
|
|||
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",
|
||||
),
|
||||
):
|
||||
"""
|
||||
View all spend logs, if request_id is provided, only logs for that request_id will be returned
|
||||
|
@ -3193,7 +3159,76 @@ async def view_spend_logs(
|
|||
f"Database not connected. Connect a database to your proxy - https://docs.litellm.ai/docs/simple_proxy#managing-auth---virtual-keys"
|
||||
)
|
||||
spend_logs = []
|
||||
if (
|
||||
start_date is not None
|
||||
and isinstance(start_date, str)
|
||||
and end_date is not None
|
||||
and isinstance(end_date, str)
|
||||
):
|
||||
# Convert the date strings to datetime objects
|
||||
start_date_obj = datetime.strptime(start_date, "%Y-%m-%d")
|
||||
end_date_obj = datetime.strptime(end_date, "%Y-%m-%d")
|
||||
|
||||
filter_query = {
|
||||
"startTime": {
|
||||
"gte": start_date_obj, # Greater than or equal to Start Date
|
||||
"lte": end_date_obj, # Less than or equal to End Date
|
||||
}
|
||||
}
|
||||
|
||||
if api_key is not None and isinstance(api_key, str):
|
||||
filter_query["api_key"] = api_key # type: ignore
|
||||
elif request_id is not None and isinstance(request_id, str):
|
||||
filter_query["request_id"] = request_id # type: ignore
|
||||
elif user_id is not None and isinstance(user_id, str):
|
||||
filter_query["user"] = user_id # type: ignore
|
||||
|
||||
# SQL query
|
||||
response = await prisma_client.db.litellm_spendlogs.group_by(
|
||||
by=["startTime"],
|
||||
where=filter_query, # type: ignore
|
||||
sum={
|
||||
"spend": True,
|
||||
},
|
||||
)
|
||||
|
||||
if (
|
||||
isinstance(response, list)
|
||||
and len(response) > 0
|
||||
and isinstance(response[0], dict)
|
||||
):
|
||||
result: dict = {}
|
||||
for record in response:
|
||||
dt_object = datetime.strptime(
|
||||
str(record["startTime"]), "%Y-%m-%dT%H:%M:%S.%fZ"
|
||||
) # type: ignore
|
||||
date = dt_object.date()
|
||||
if date not in result:
|
||||
result[date] = {}
|
||||
result[date]["spend"] = (
|
||||
result[date].get("spend", 0) + record["_sum"]["spend"]
|
||||
)
|
||||
return_list = []
|
||||
final_date = None
|
||||
for k, v in sorted(result.items()):
|
||||
return_list.append({**v, "startTime": k})
|
||||
final_date = k
|
||||
|
||||
end_date_date = end_date_obj.date()
|
||||
if final_date is not None and final_date < end_date_date:
|
||||
current_date = final_date + timedelta(days=1)
|
||||
while current_date <= end_date_date:
|
||||
# Represent current_date as string because original response has it this way
|
||||
return_list.append(
|
||||
{"startTime": current_date, "spend": 0}
|
||||
) # If no data, will stay as zero
|
||||
current_date += timedelta(days=1) # Move on to the next day
|
||||
|
||||
return return_list
|
||||
|
||||
return response
|
||||
|
||||
elif api_key is not None and isinstance(api_key, str):
|
||||
if api_key.startswith("sk-"):
|
||||
hashed_token = prisma_client.hash_token(token=api_key)
|
||||
else:
|
||||
|
@ -3478,12 +3513,22 @@ async def login(request: Request):
|
|||
if secrets.compare_digest(username, ui_username) and secrets.compare_digest(
|
||||
password, ui_password
|
||||
):
|
||||
user_role = "app_owner"
|
||||
user_id = username
|
||||
# User is Authe'd in - generate key for the UI to access Proxy
|
||||
key_user_id = user_id
|
||||
if (
|
||||
os.getenv("PROXY_ADMIN_ID", None) is not None
|
||||
and os.environ["PROXY_ADMIN_ID"] == user_id
|
||||
) or user_id == "admin":
|
||||
# checks if user is admin
|
||||
user_role = "app_admin"
|
||||
key_user_id = os.getenv("PROXY_ADMIN_ID", "default_user_id")
|
||||
|
||||
# Admin is Authe'd in - generate key for the UI to access Proxy
|
||||
|
||||
if os.getenv("DATABASE_URL") is not None:
|
||||
response = await generate_key_helper_fn(
|
||||
**{"duration": "1hr", "key_max_budget": 0, "models": [], "aliases": {}, "config": {}, "spend": 0, "user_id": user_id, "team_id": "litellm-dashboard"} # type: ignore
|
||||
**{"duration": "1hr", "key_max_budget": 0, "models": [], "aliases": {}, "config": {}, "spend": 0, "user_id": key_user_id, "team_id": "litellm-dashboard"} # type: ignore
|
||||
)
|
||||
else:
|
||||
response = {
|
||||
|
@ -3492,18 +3537,9 @@ async def login(request: Request):
|
|||
}
|
||||
|
||||
key = response["token"] # type: ignore
|
||||
user_id = response["user_id"] # type: ignore
|
||||
|
||||
litellm_dashboard_ui = "/ui/"
|
||||
|
||||
user_role = "app_owner"
|
||||
if (
|
||||
os.getenv("PROXY_ADMIN_ID", None) is not None
|
||||
and os.environ["PROXY_ADMIN_ID"] == user_id
|
||||
):
|
||||
# checks if user is admin
|
||||
user_role = "app_admin"
|
||||
|
||||
import jwt
|
||||
|
||||
jwt_token = jwt.encode(
|
||||
|
|
|
@ -240,7 +240,10 @@ class ProxyLogging:
|
|||
else:
|
||||
user_info = str(user_info)
|
||||
# percent of max_budget left to spend
|
||||
if user_max_budget > 0:
|
||||
percent_left = (user_max_budget - user_current_spend) / user_max_budget
|
||||
else:
|
||||
percent_left = 0
|
||||
verbose_proxy_logger.debug(
|
||||
f"Budget Alerts: Percent left: {percent_left} for {user_info}"
|
||||
)
|
||||
|
|
|
@ -1,11 +1,92 @@
|
|||
import React, { Suspense } from "react";
|
||||
"use client";
|
||||
import React, { Suspense, useEffect, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Navbar from "../components/navbar";
|
||||
import UserDashboard from "../components/user_dashboard";
|
||||
import Sidebar from "../components/leftnav";
|
||||
import Usage from "../components/usage";
|
||||
import { jwtDecode } from "jwt-decode";
|
||||
|
||||
const CreateKeyPage = () => {
|
||||
const [userRole, setUserRole] = useState<null | string>(null);
|
||||
const [userEmail, setUserEmail] = useState<null | string>(null);
|
||||
const [page, setPage] = useState("api-keys");
|
||||
const searchParams = useSearchParams();
|
||||
const userID = searchParams.get("userID");
|
||||
const token = searchParams.get("token");
|
||||
const [accessToken, setAccessToken] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
const decoded = jwtDecode(token) as { [key: string]: any };
|
||||
if (decoded) {
|
||||
// cast decoded to dictionary
|
||||
console.log("Decoded token:", decoded);
|
||||
|
||||
console.log("Decoded key:", decoded.key);
|
||||
// set accessToken
|
||||
setAccessToken(decoded.key);
|
||||
|
||||
// check if userRole is defined
|
||||
if (decoded.user_role) {
|
||||
const formattedUserRole = formatUserRole(decoded.user_role);
|
||||
console.log("Decoded user_role:", formattedUserRole);
|
||||
setUserRole(formattedUserRole);
|
||||
} else {
|
||||
console.log("User role not defined");
|
||||
}
|
||||
|
||||
if (decoded.user_email) {
|
||||
setUserEmail(decoded.user_email);
|
||||
} else {
|
||||
console.log(`User Email is not set ${decoded}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
function formatUserRole(userRole: string) {
|
||||
if (!userRole) {
|
||||
return "Undefined Role";
|
||||
}
|
||||
console.log(`Received user role: ${userRole}`);
|
||||
switch (userRole.toLowerCase()) {
|
||||
case "app_owner":
|
||||
return "App Owner";
|
||||
case "demo_app_owner":
|
||||
return "App Owner";
|
||||
case "app_admin":
|
||||
return "Admin";
|
||||
case "app_user":
|
||||
return "App User";
|
||||
default:
|
||||
return "Unknown Role";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<div className="flex min-h-screen flex-col ">
|
||||
<UserDashboard />
|
||||
<div className="flex flex-col min-h-screen">
|
||||
<Navbar userID={userID} userRole={userRole} userEmail={userEmail} />
|
||||
<div className="flex flex-1 overflow-auto">
|
||||
<Sidebar setPage={setPage} />
|
||||
{page == "api-keys" ? (
|
||||
<UserDashboard
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
setUserRole={setUserRole}
|
||||
userEmail={userEmail}
|
||||
setUserEmail={setUserEmail}
|
||||
/>
|
||||
) : (
|
||||
<Usage
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
token={token}
|
||||
accessToken={accessToken}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Suspense>
|
||||
);
|
||||
|
|
32
ui/litellm-dashboard/src/components/leftnav.tsx
Normal file
32
ui/litellm-dashboard/src/components/leftnav.tsx
Normal file
|
@ -0,0 +1,32 @@
|
|||
import { Layout, Menu } from "antd";
|
||||
import Link from "next/link";
|
||||
|
||||
const { Sider } = Layout;
|
||||
|
||||
// Define the props type
|
||||
interface SidebarProps {
|
||||
setPage: React.Dispatch<React.SetStateAction<string>>;
|
||||
}
|
||||
|
||||
const Sidebar: React.FC<SidebarProps> = ({ setPage }) => {
|
||||
return (
|
||||
<Layout style={{ minHeight: "100vh", maxWidth: "120px" }}>
|
||||
<Sider width={120}>
|
||||
<Menu
|
||||
mode="inline"
|
||||
defaultSelectedKeys={["1"]}
|
||||
style={{ height: "100%", borderRight: 0 }}
|
||||
>
|
||||
<Menu.Item key="1" onClick={() => setPage("api-keys")}>
|
||||
API Keys
|
||||
</Menu.Item>
|
||||
<Menu.Item key="2" onClick={() => setPage("usage")}>
|
||||
Usage
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
</Sider>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
|
@ -162,3 +162,40 @@ export const keySpendLogsCall = async (accessToken: String, token: String) => {
|
|||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const userSpendLogsCall = async (
|
||||
accessToken: String,
|
||||
token: String,
|
||||
userRole: String,
|
||||
userID: String,
|
||||
startTime: String,
|
||||
endTime: String
|
||||
) => {
|
||||
try {
|
||||
let url = proxyBaseUrl ? `${proxyBaseUrl}/spend/logs` : `/spend/logs`;
|
||||
if (userRole == "App Owner") {
|
||||
url = `${url}/?user_id=${userID}&start_date=${startTime}&end_date=${endTime}`;
|
||||
} else {
|
||||
url = `${url}/?start_date=${startTime}&end_date=${endTime}`;
|
||||
}
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.text();
|
||||
message.error(errorData);
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log(data);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error("Failed to create key:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
|
114
ui/litellm-dashboard/src/components/usage.tsx
Normal file
114
ui/litellm-dashboard/src/components/usage.tsx
Normal file
|
@ -0,0 +1,114 @@
|
|||
import { BarChart, Card, Title } from "@tremor/react";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Grid, Col, Text } from "@tremor/react";
|
||||
import { userSpendLogsCall } from "./networking";
|
||||
import { AreaChart, Flex, Switch, Subtitle } from "@tremor/react";
|
||||
|
||||
interface UsagePageProps {
|
||||
accessToken: string | null;
|
||||
token: string | null;
|
||||
userRole: string | null;
|
||||
userID: string | null;
|
||||
}
|
||||
|
||||
type DataType = {
|
||||
api_key: string;
|
||||
startTime: string;
|
||||
_sum: {
|
||||
spend: number;
|
||||
};
|
||||
};
|
||||
|
||||
const UsagePage: React.FC<UsagePageProps> = ({
|
||||
accessToken,
|
||||
token,
|
||||
userRole,
|
||||
userID,
|
||||
}) => {
|
||||
const currentDate = new Date();
|
||||
const [keySpendData, setKeySpendData] = useState<any[]>([]);
|
||||
|
||||
const firstDay = new Date(
|
||||
currentDate.getFullYear(),
|
||||
currentDate.getMonth(),
|
||||
1
|
||||
);
|
||||
const lastDay = new Date(
|
||||
currentDate.getFullYear(),
|
||||
currentDate.getMonth() + 1,
|
||||
0
|
||||
);
|
||||
|
||||
let startTime = formatDate(firstDay);
|
||||
let endTime = formatDate(lastDay);
|
||||
|
||||
function formatDate(date: Date) {
|
||||
const year = date.getFullYear();
|
||||
let month = date.getMonth() + 1; // JS month index starts from 0
|
||||
let day = date.getDate();
|
||||
|
||||
// Pad with 0 if month or day is less than 10
|
||||
const monthStr = month < 10 ? "0" + month : month;
|
||||
const dayStr = day < 10 ? "0" + day : day;
|
||||
|
||||
return `${year}-${monthStr}-${dayStr}`;
|
||||
}
|
||||
|
||||
console.log(`Start date is ${startTime}`);
|
||||
console.log(`End date is ${endTime}`);
|
||||
|
||||
const valueFormatter = (number: number) =>
|
||||
`$ ${new Intl.NumberFormat("us").format(number).toString()}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (accessToken && token && userRole && userID) {
|
||||
const cachedKeySpendData = localStorage.getItem("keySpendData");
|
||||
if (cachedKeySpendData) {
|
||||
setKeySpendData(JSON.parse(cachedKeySpendData));
|
||||
} else {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const response = await userSpendLogsCall(
|
||||
accessToken,
|
||||
token,
|
||||
userRole,
|
||||
userID,
|
||||
startTime,
|
||||
endTime
|
||||
);
|
||||
setKeySpendData(response);
|
||||
localStorage.setItem("keySpendData", JSON.stringify(response));
|
||||
} catch (error) {
|
||||
console.error("There was an error fetching the data", error);
|
||||
// Optionally, update your UI to reflect the error state here as well
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}
|
||||
}
|
||||
}, [accessToken, token, userRole, userID]);
|
||||
|
||||
return (
|
||||
<div style={{ width: "100%" }}>
|
||||
<Grid numItems={1} className="gap-0 p-10 h-[75vh] w-full">
|
||||
<Col numColSpan={1}>
|
||||
<Card>
|
||||
<Title>Monthly Spend</Title>
|
||||
<BarChart
|
||||
data={keySpendData}
|
||||
index="startTime"
|
||||
categories={["spend"]}
|
||||
colors={["blue"]}
|
||||
valueFormatter={valueFormatter}
|
||||
yAxisWidth={100}
|
||||
tickGap={5}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Grid>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsagePage;
|
|
@ -20,7 +20,21 @@ type UserSpendData = {
|
|||
max_budget?: number | null;
|
||||
};
|
||||
|
||||
const UserDashboard = () => {
|
||||
interface UserDashboardProps {
|
||||
userID: string | null;
|
||||
userRole: string | null;
|
||||
userEmail: string | null;
|
||||
setUserRole: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
setUserEmail: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
}
|
||||
|
||||
const UserDashboard: React.FC<UserDashboardProps> = ({
|
||||
userID,
|
||||
userRole,
|
||||
setUserRole,
|
||||
userEmail,
|
||||
setUserEmail,
|
||||
}) => {
|
||||
const [data, setData] = useState<null | any[]>(null); // Keep the initialization of state here
|
||||
const [userSpendData, setUserSpendData] = useState<UserSpendData | null>(
|
||||
null
|
||||
|
@ -28,13 +42,10 @@ const UserDashboard = () => {
|
|||
|
||||
// Assuming useSearchParams() hook exists and works in your setup
|
||||
const searchParams = useSearchParams();
|
||||
const userID = searchParams.get("userID");
|
||||
const viewSpend = searchParams.get("viewSpend");
|
||||
|
||||
const token = searchParams.get("token");
|
||||
const [accessToken, setAccessToken] = useState<string | null>(null);
|
||||
const [userRole, setUserRole] = useState<string | null>(null);
|
||||
const [userEmail, setUserEmail] = useState<string | null>(null);
|
||||
|
||||
function formatUserRole(userRole: string) {
|
||||
if (!userRole) {
|
||||
|
@ -84,11 +95,22 @@ const UserDashboard = () => {
|
|||
}
|
||||
}
|
||||
if (userID && accessToken && userRole && !data) {
|
||||
const cachedData = localStorage.getItem("userData");
|
||||
const cachedSpendData = localStorage.getItem("userSpendData");
|
||||
if (cachedData && cachedSpendData) {
|
||||
setData(JSON.parse(cachedData));
|
||||
setUserSpendData(JSON.parse(cachedSpendData));
|
||||
} else {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const response = await userInfoCall(accessToken, userID, userRole);
|
||||
setUserSpendData(response["user_info"]);
|
||||
setData(response["keys"]); // Assuming this is the correct path to your data
|
||||
localStorage.setItem("userData", JSON.stringify(response["keys"]));
|
||||
localStorage.setItem(
|
||||
"userSpendData",
|
||||
JSON.stringify(response["user_info"])
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("There was an error fetching the data", error);
|
||||
// Optionally, update your UI to reflect the error state here as well
|
||||
|
@ -96,6 +118,7 @@ const UserDashboard = () => {
|
|||
};
|
||||
fetchData();
|
||||
}
|
||||
}
|
||||
}, [userID, token, accessToken, data]);
|
||||
|
||||
if (userID == null || token == null) {
|
||||
|
@ -117,7 +140,6 @@ const UserDashboard = () => {
|
|||
|
||||
return (
|
||||
<div>
|
||||
<Navbar userID={userID} userRole={userRole} userEmail={userEmail} />
|
||||
<Grid numItems={1} className="gap-0 p-10 h-[75vh] w-full">
|
||||
<Col numColSpan={1}>
|
||||
<ViewUserSpend userID={userID} userSpendData={userSpendData} />
|
||||
|
|
|
@ -72,21 +72,6 @@ const ViewKeyTable: React.FC<ViewKeyTableProps> = ({
|
|||
setKeyToDelete(null);
|
||||
};
|
||||
|
||||
// const handleDelete = async (token: String) => {
|
||||
// if (data == null) {
|
||||
// return;
|
||||
// }
|
||||
// try {
|
||||
// await keyDeleteCall(accessToken, token);
|
||||
// // Successfully completed the deletion. Update the state to trigger a rerender.
|
||||
// const filteredData = data.filter((item) => item.token !== token);
|
||||
// setData(filteredData);
|
||||
// } catch (error) {
|
||||
// console.error("Error deleting the key:", error);
|
||||
// // Handle any error situations, such as displaying an error message to the user.
|
||||
// }
|
||||
// };
|
||||
|
||||
if (data == null) {
|
||||
return;
|
||||
}
|
||||
|
@ -147,7 +132,11 @@ const ViewKeyTable: React.FC<ViewKeyTableProps> = ({
|
|||
<Text>{JSON.stringify(item.models)}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text>TPM Limit: {item.tpm_limit? item.tpm_limit : "Unlimited"} <br></br> RPM Limit: {item.rpm_limit? item.rpm_limit : "Unlimited"}</Text>
|
||||
<Text>
|
||||
TPM Limit: {item.tpm_limit ? item.tpm_limit : "Unlimited"}{" "}
|
||||
<br></br> RPM Limit:{" "}
|
||||
{item.rpm_limit ? item.rpm_limit : "Unlimited"}
|
||||
</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{item.expires != null ? (
|
||||
|
@ -180,12 +169,18 @@ const ViewKeyTable: React.FC<ViewKeyTableProps> = ({
|
|||
{isDeleteModalOpen && (
|
||||
<div className="fixed z-10 inset-0 overflow-y-auto">
|
||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div className="fixed inset-0 transition-opacity" aria-hidden="true">
|
||||
<div
|
||||
className="fixed inset-0 transition-opacity"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="absolute inset-0 bg-gray-500 opacity-75"></div>
|
||||
</div>
|
||||
|
||||
{/* Modal Panel */}
|
||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
|
||||
<span
|
||||
className="hidden sm:inline-block sm:align-middle sm:h-screen"
|
||||
aria-hidden="true"
|
||||
>
|
||||
​
|
||||
</span>
|
||||
|
||||
|
@ -194,9 +189,13 @@ const ViewKeyTable: React.FC<ViewKeyTableProps> = ({
|
|||
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div className="sm:flex sm:items-start">
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">Delete Key</h3>
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
||||
Delete Key
|
||||
</h3>
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-gray-500">Are you sure you want to delete this key ?</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Are you sure you want to delete this key ?
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue