diff --git a/front/pages/api/w/[wId]/monthly-usage.ts b/front/pages/api/w/[wId]/monthly-usage.ts new file mode 100644 index 000000000000..80f8ac7d6d73 --- /dev/null +++ b/front/pages/api/w/[wId]/monthly-usage.ts @@ -0,0 +1,162 @@ +import { NextApiRequest, NextApiResponse } from "next"; +import { QueryTypes } from "sequelize"; + +import { Authenticator, getSession } from "@app/lib/auth"; +import { front_sequelize } from "@app/lib/databases"; +import { apiError, withLogging } from "@app/logger/withlogging"; + +interface QueryResult { + createdAt: string; + conversationModelId: string; + messageId: string; + userMessageId: string; + agentMessageId: string; + userId: string; + userFirstName: string; + userLastName: string; + assistantId: string; + assistantName: string; + actionType: string; + source: string; +} + +async function handler( + req: NextApiRequest, + res: NextApiResponse +): Promise { + const session = await getSession(req, res); + const auth = await Authenticator.fromSession( + session, + req.query.wId as string + ); + + const owner = auth.workspace(); + if (!owner) { + return apiError(req, res, { + status_code: 404, + api_error: { + type: "workspace_not_found", + message: "The workspace was not found.", + }, + }); + } + + if (!auth.isAdmin()) { + return apiError(req, res, { + status_code: 403, + api_error: { + type: "workspace_auth_error", + message: + "Only users that are `admins` for the current workspace can retrieve its monthly usage.", + }, + }); + } + + switch (req.method) { + case "GET": + if ( + !req.query.referenceDate || + typeof req.query.referenceDate !== "string" || + isNaN(new Date(req.query.referenceDate).getTime()) + ) { + return apiError(req, res, { + status_code: 400, + api_error: { + type: "invalid_request_error", + message: + "The `referenceDate` query parameter is missing or invalid.", + }, + }); + } + const referenceDate = new Date(req.query.referenceDate); + const csvData = await getMonthlyUsage(referenceDate, owner.sId); + res.setHeader("Content-Type", "text/csv"); + res.setHeader( + "Content-Disposition", + `attachment; filename=dust_monthly_usage_${referenceDate.getFullYear()}_${ + referenceDate.getMonth() + 1 + }.csv` + ); + res.status(200).send(csvData); + return; + + default: + return apiError(req, res, { + status_code: 405, + api_error: { + type: "method_not_supported_error", + message: "The method passed is not supported, GET is expected.", + }, + }); + } +} + +export default withLogging(handler); + +async function getMonthlyUsage( + referenceDate: Date, + wId: string +): Promise { + const results = await front_sequelize.query( + ` + SELECT + TO_CHAR(m."createdAt"::timestamp, 'YYYY-MM-DD HH24:MI:SS') AS "createdAt", + c."id" AS "conversationModelId", + m."id" as "messageId", + um."id" AS "userMessageId", + am."id" AS "agentMessageId", + u."id" as "userId", + um."userContextFullName" AS "userFullName", + COALESCE(ac."sId", am."agentConfigurationId") AS "assistantId", + COALESCE(ac."name", am."agentConfigurationId") AS "assistantName", + CASE + WHEN ac."retrievalConfigurationId" IS NOT NULL THEN 'retrieval' + WHEN ac."dustAppRunConfigurationId" IS NOT NULL THEN 'dustAppRun' + ELSE NULL + END AS "actionType", + CASE + WHEN um."id" IS NOT NULL THEN + CASE + WHEN um."userId" IS NOT NULL THEN 'web' + ELSE 'slack' + END + END AS "source" + FROM + "messages" m + JOIN + "conversations" c ON m."conversationId" = c."id" + JOIN + "workspaces" w ON c."workspaceId" = w."id" + LEFT JOIN + "user_messages" um ON m."userMessageId" = um."id" + LEFT JOIN + "users" u ON um."userId" = u."id" + LEFT JOIN + "agent_messages" am ON m."agentMessageId" = am."id" + LEFT JOIN + "agent_configurations" ac ON am."agentConfigurationId" = ac."sId" AND am."agentConfigurationVersion" = ac."version" + WHERE + w."sId" = :wId AND + DATE_TRUNC('month', m."createdAt") = DATE_TRUNC('month', :referenceDate::timestamp) + ORDER BY + "createdAt" DESC + `, + { + replacements: { + wId, + referenceDate: `${referenceDate.getFullYear()}-${ + referenceDate.getMonth() + 1 + }-${referenceDate.getDate()}`, + }, + type: QueryTypes.SELECT, + } + ); + if (!results.length) { + return "You have no data for this month."; + } + const csvContent = results + .map((row) => Object.values(row).join(",")) + .join("\n"); + const csvHeader = Object.keys(results[0]).join(",") + "\n"; + return csvHeader + csvContent; +} diff --git a/front/pages/w/[wId]/workspace/index.tsx b/front/pages/w/[wId]/workspace/index.tsx index 5e981dfb9dc1..9819350aa94f 100644 --- a/front/pages/w/[wId]/workspace/index.tsx +++ b/front/pages/w/[wId]/workspace/index.tsx @@ -1,11 +1,18 @@ -import { Button, KeyIcon, PageHeader } from "@dust-tt/sparkle"; +import { + Button, + CloudArrowDownIcon, + Cog6ToothIcon, + DropdownMenu, + Input, + Page, + PageHeader, +} from "@dust-tt/sparkle"; import { GetServerSideProps, InferGetServerSidePropsType } from "next"; import React, { useCallback, useEffect, useState } from "react"; import AppLayout from "@app/components/sparkle/AppLayout"; import { subNavigationAdmin } from "@app/components/sparkle/navigation"; import { Authenticator, getSession, getUserFromSession } from "@app/lib/auth"; -import { classNames } from "@app/lib/utils"; import { UserType, WorkspaceType } from "@app/types/user"; const { GA_TRACKING_ID = "" } = process.env; @@ -45,9 +52,10 @@ export default function WorkspaceAdmin({ }: InferGetServerSidePropsType) { const [disable, setDisabled] = useState(true); const [updating, setUpdating] = useState(false); + const [selectedMonth, setSelectedMonth] = useState(null); const [workspaceName, setWorkspaceName] = useState(owner.name); - const [workspaceNameError, setWorkspaceNameError] = useState(""); + const [workspaceNameError, setWorkspaceNameError] = useState(""); const formValidation = useCallback(() => { if (workspaceName === owner.name) { @@ -55,7 +63,7 @@ export default function WorkspaceAdmin({ } let valid = true; - if (workspaceName.length == 0) { + if (workspaceName.length === 0) { setWorkspaceNameError(""); valid = false; // eslint-disable-next-line no-useless-escape @@ -95,6 +103,99 @@ export default function WorkspaceAdmin({ } }; + const handleSelectMonth = (selectedOption: string) => { + setSelectedMonth(selectedOption); + }; + + const handleDownload = async (selectedMonth: string | null) => { + if (!selectedMonth) { + return; + } + + try { + const response = await fetch( + `/api/w/${owner.sId}/monthly-usage?referenceDate=${selectedMonth}-01` + ); + + if (!response.ok) { + throw new Error(`Error: ${response.status}`); + } + + const csvData = await response.text(); + const blob = new Blob([csvData], { type: "text/csv" }); + const url = window.URL.createObjectURL(blob); + + const [year, month] = selectedMonth.split("-"); + + const currentDay = new Date().getDate(); + const formattedDay = String(currentDay).padStart(2, "0"); + + const currentMonth = new Date().getMonth() + 1; + + const getMonthName = (monthIndex: number) => { + const months = [ + "jan", + "feb", + "mar", + "apr", + "may", + "jun", + "jul", + "aug", + "sep", + "oct", + "nov", + "dec", + ]; + return months[monthIndex - 1]; + }; + + const monthName = getMonthName(Number(month)); + const currentMonthName = getMonthName(currentMonth); + + let filename = `dust_${owner.name}_monthly_usage_${year}_${monthName}`; + + // If the selected month is the current month, append the day + if (monthName === currentMonthName) { + filename += `_until_${formattedDay}`; + } + + filename += ".csv"; + + const link = document.createElement("a"); + link.href = url; + link.setAttribute("download", filename); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } catch (error) { + alert("Failed to download usage data."); + } + }; + + const monthOptions: string[] = []; + + if (owner.upgradedAt) { + const upgradedAtDate = new Date(owner.upgradedAt); + const upgradedYear = upgradedAtDate.getFullYear(); + const upgradedMonth = upgradedAtDate.getMonth(); + + const currentYear = new Date().getFullYear(); + const currentMonth = new Date().getMonth(); + + for (let year = upgradedYear; year <= currentYear; year++) { + const startMonth = year === upgradedYear ? upgradedMonth : 0; + const endMonth = year === currentYear ? currentMonth : 11; + for (let month = startMonth; month <= endMonth; month++) { + monthOptions.push(`${year}-${String(month + 1).padStart(2, "0")}`); + } + } + + if (!selectedMonth) { + setSelectedMonth(monthOptions[monthOptions.length - 1]); + } + } + return ( - - -
-
-
-
-
- -
-
-
-
- setWorkspaceName(e.target.value)} - /> -
-

- Think GitHub repository names, short and memorable. -

+ +
+ +
+ +
+
+ setWorkspaceName(x)} + error={workspaceNameError} + showErrorLabel={true} + />
-
- -
-
-
+
+ +
+ {!!monthOptions.length && ( + <> + +
+ + +
+ + )}
-
+ ); }