From b3995333df9e451480e588562460e63a6f17d27f Mon Sep 17 00:00:00 2001 From: Alex Tugarev Date: Thu, 8 Sep 2022 12:18:28 +0000 Subject: [PATCH 1/3] [dashboard] (refactor) extract UsageView to be reused for individual --- .../dashboard/src/components/UsageView.tsx | 316 +++++++++++++++++ components/dashboard/src/teams/TeamUsage.tsx | 322 +----------------- 2 files changed, 330 insertions(+), 308 deletions(-) create mode 100644 components/dashboard/src/components/UsageView.tsx diff --git a/components/dashboard/src/components/UsageView.tsx b/components/dashboard/src/components/UsageView.tsx new file mode 100644 index 00000000000000..d27567f54a4813 --- /dev/null +++ b/components/dashboard/src/components/UsageView.tsx @@ -0,0 +1,316 @@ +/** + * Copyright (c) 2022 Gitpod GmbH. All rights reserved. + * Licensed under the GNU Affero General Public License (AGPL). + * See License-AGPL.txt in the project root for license information. + */ + +import { useEffect, useState } from "react"; +import { getGitpodService, gitpodHostUrl } from "../service/service"; +import { + ListUsageRequest, + Ordering, + ListUsageResponse, + WorkspaceInstanceUsageData, + Usage, +} from "@gitpod/gitpod-protocol/lib/usage"; +import { AttributionId } from "@gitpod/gitpod-protocol/lib/attribution"; +import { Item, ItemField, ItemsList } from "../components/ItemsList"; +import Pagination from "../Pagination/Pagination"; +import Header from "../components/Header"; +import { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error"; +import { ReactComponent as CreditsSvg } from "../images/credits.svg"; +import { ReactComponent as Spinner } from "../icons/Spinner.svg"; +import { ReactComponent as UsageIcon } from "../images/usage-default.svg"; +import { BillingMode } from "@gitpod/gitpod-protocol/lib/billing-mode"; +import { toRemoteURL } from "../projects/render-utils"; +import { WorkspaceType } from "@gitpod/gitpod-protocol"; + +interface UsageViewProps { + attributionId: AttributionId; + billingMode: BillingMode; +} + +function UsageView({ attributionId, billingMode }: UsageViewProps) { + const [usagePage, setUsagePage] = useState(undefined); + const [errorMessage, setErrorMessage] = useState(""); + const today = new Date(); + const startOfCurrentMonth = new Date(today.getFullYear(), today.getMonth(), 1); + const timestampStartOfCurrentMonth = startOfCurrentMonth.getTime(); + const [startDateOfBillMonth, setStartDateOfBillMonth] = useState(timestampStartOfCurrentMonth); + const [endDateOfBillMonth, setEndDateOfBillMonth] = useState(Date.now()); + const [totalCreditsUsed, setTotalCreditsUsed] = useState(0); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + loadPage(1); + }, [startDateOfBillMonth, endDateOfBillMonth]); + + const loadPage = async (page: number = 1) => { + if (usagePage === undefined) { + setIsLoading(true); + setTotalCreditsUsed(0); + } + const request: ListUsageRequest = { + attributionId: AttributionId.render(attributionId), + from: startDateOfBillMonth, + to: endDateOfBillMonth, + order: Ordering.ORDERING_DESCENDING, + pagination: { + perPage: 50, + page, + }, + }; + try { + const page = await getGitpodService().server.listUsage(request); + setUsagePage(page); + setTotalCreditsUsed(Math.ceil(page.creditBalanceAtEnd)); + } catch (error) { + if (error.code === ErrorCodes.PERMISSION_DENIED) { + setErrorMessage("Access to usage details is restricted to team owners."); + } else { + setErrorMessage(`Error: ${error?.message}`); + } + } finally { + setIsLoading(false); + } + }; + + const getType = (type: WorkspaceType) => { + if (type === "regular") { + return "Workspace"; + } + return "Prebuild"; + }; + + const getMinutes = (usage: Usage) => { + if (usage.kind !== "workspaceinstance") { + return ""; + } + const metaData = usage.metadata as WorkspaceInstanceUsageData; + if (!metaData.endTime) { + return "running"; + } + const end = new Date(metaData.endTime).getTime(); + const start = new Date(metaData.startTime).getTime(); + const lengthOfUsage = Math.floor(end - start); + const inMinutes = (lengthOfUsage / (1000 * 60)).toFixed(1); + return inMinutes + " min"; + }; + + const handleMonthClick = (start: any, end: any) => { + setStartDateOfBillMonth(start); + setEndDateOfBillMonth(end); + }; + + const getBillingHistory = () => { + let rows = []; + // This goes back 6 months from the current month + for (let i = 1; i < 7; i++) { + const endDateVar = i - 1; + const startDate = new Date(today.getFullYear(), today.getMonth() - i); + const endDate = new Date(today.getFullYear(), today.getMonth() - endDateVar, 0); + const timeStampOfStartDate = startDate.getTime(); + const timeStampOfEndDate = endDate.getTime(); + rows.push( +
handleMonthClick(timeStampOfStartDate, timeStampOfEndDate)} + > + {startDate.toLocaleString("default", { month: "long" })} {startDate.getFullYear()} +
, + ); + } + return rows; + }; + + const displayTime = (time: string | number) => { + const options: Intl.DateTimeFormatOptions = { + day: "numeric", + month: "short", + year: "numeric", + hour: "numeric", + minute: "numeric", + }; + return new Date(time).toLocaleDateString(undefined, options).replace("at ", ""); + }; + + const currentPaginatedResults = usagePage?.usageEntriesList ?? []; + + return ( + <> +
+
+ {errorMessage &&

{errorMessage}

} + {!errorMessage && ( +
+
+
+
+
Current Month
+
handleMonthClick(timestampStartOfCurrentMonth, Date.now())} + > + {startOfCurrentMonth.toLocaleString("default", { month: "long" })}{" "} + {startOfCurrentMonth.getFullYear()} +
+
Previous Months
+ {getBillingHistory()} +
+ {!isLoading && ( +
+
Total usage
+
+ + {totalCreditsUsed} Credits +
+
+ )} +
+
+ {!isLoading && usagePage === undefined && !errorMessage && ( +
+

No sessions found.

+

+ Have you started any + + {" "} + workspaces + {" "} + in{" "} + {new Date(startDateOfBillMonth).toLocaleString("default", { + month: "long", + })}{" "} + {new Date(startDateOfBillMonth).getFullYear()} or checked your other teams? +

+
+ )} + {isLoading && ( +
+
+ Fetching usage... +
+ +
+ )} + {!isLoading && currentPaginatedResults.length > 0 && ( +
+ + + + Type + + + ID + + + Credits + + + + Timestamp + + + {currentPaginatedResults && + currentPaginatedResults.map((usage) => { + return ( +
+
+ + {getType( + (usage.metadata as WorkspaceInstanceUsageData) + .workspaceType, + )} + + + { + (usage.metadata as WorkspaceInstanceUsageData) + .workspaceClass + } + +
+
+ + {(usage.metadata as WorkspaceInstanceUsageData).workspaceId} + + + {(usage.metadata as WorkspaceInstanceUsageData) + .contextURL && + toRemoteURL( + (usage.metadata as WorkspaceInstanceUsageData) + .contextURL, + )} + +
+
+ + {usage.credits.toFixed(1)} + + + {getMinutes(usage)} + +
+
+
+ + {displayTime(usage.effectiveTime!)} + +
+ {(usage.metadata as WorkspaceInstanceUsageData) + .workspaceType === "prebuild" ? ( + + ) : ( + "" + )} + {(usage.metadata as WorkspaceInstanceUsageData) + .workspaceType === "prebuild" ? ( + + Gitpod + + ) : ( +
+ user avatar + + {(usage.metadata as WorkspaceInstanceUsageData) + .userName || ""} + +
+ )} +
+
+
+ ); + })} + + {usagePage && usagePage.pagination && usagePage.pagination.totalPages > 1 && ( + loadPage(page)} + totalNumberOfPages={usagePage.pagination.totalPages} + /> + )} +
+ )} +
+ )} +
+ + ); +} + +export default UsageView; diff --git a/components/dashboard/src/teams/TeamUsage.tsx b/components/dashboard/src/teams/TeamUsage.tsx index 254add5aeb00c3..d8283c7d34e2f3 100644 --- a/components/dashboard/src/teams/TeamUsage.tsx +++ b/components/dashboard/src/teams/TeamUsage.tsx @@ -8,336 +8,42 @@ import { useContext, useEffect, useState } from "react"; import { useLocation } from "react-router"; import { getCurrentTeam, TeamsContext } from "./teams-context"; import { getGitpodService, gitpodHostUrl } from "../service/service"; -import { - ListUsageRequest, - Ordering, - ListUsageResponse, - WorkspaceInstanceUsageData, - Usage, -} from "@gitpod/gitpod-protocol/lib/usage"; -import { AttributionId } from "@gitpod/gitpod-protocol/lib/attribution"; -import { Item, ItemField, ItemsList } from "../components/ItemsList"; -import Pagination from "../Pagination/Pagination"; -import Header from "../components/Header"; -import { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error"; -import { ReactComponent as CreditsSvg } from "../images/credits.svg"; -import { ReactComponent as Spinner } from "../icons/Spinner.svg"; -import { ReactComponent as UsageIcon } from "../images/usage-default.svg"; import { BillingMode } from "@gitpod/gitpod-protocol/lib/billing-mode"; -import { toRemoteURL } from "../projects/render-utils"; -import { WorkspaceType } from "@gitpod/gitpod-protocol"; +import UsageView from "../components/UsageView"; +import { AttributionId } from "@gitpod/gitpod-protocol/lib/attribution"; function TeamUsage() { const { teams } = useContext(TeamsContext); const location = useLocation(); const team = getCurrentTeam(location, teams); - const [teamBillingMode, setTeamBillingMode] = useState(undefined); - const [usagePage, setUsagePage] = useState(undefined); - const [errorMessage, setErrorMessage] = useState(""); - const today = new Date(); - const startOfCurrentMonth = new Date(today.getFullYear(), today.getMonth(), 1); - const timestampStartOfCurrentMonth = startOfCurrentMonth.getTime(); - const [startDateOfBillMonth, setStartDateOfBillMonth] = useState(timestampStartOfCurrentMonth); - const [endDateOfBillMonth, setEndDateOfBillMonth] = useState(Date.now()); - const [totalCreditsUsed, setTotalCreditsUsed] = useState(0); - const [isLoading, setIsLoading] = useState(true); + const [billingMode, setBillingMode] = useState(undefined); + const [attributionId, setAttributionId] = useState(); useEffect(() => { if (!team) { return; } + setAttributionId({ kind: "team", teamId: team.id }); (async () => { - const teamBillingMode = await getGitpodService().server.getBillingModeForTeam(team.id); - setTeamBillingMode(teamBillingMode); + const billingMode = await getGitpodService().server.getBillingModeForTeam(team.id); + setBillingMode(billingMode); })(); }, [team]); useEffect(() => { - if (!team) { + if (!billingMode) { return; } - loadPage(1); - }, [team, startDateOfBillMonth, endDateOfBillMonth]); - - useEffect(() => { - if (!teamBillingMode) { - return; - } - if (!BillingMode.showUsageBasedBilling(teamBillingMode)) { + if (!BillingMode.showUsageBasedBilling(billingMode)) { window.location.href = gitpodHostUrl.asDashboard().toString(); } - }, [teamBillingMode]); - - const loadPage = async (page: number = 1) => { - if (!team) { - return; - } - if (usagePage === undefined) { - setIsLoading(true); - setTotalCreditsUsed(0); - } - const attributionId = AttributionId.render({ kind: "team", teamId: team.id }); - const request: ListUsageRequest = { - attributionId, - from: startDateOfBillMonth, - to: endDateOfBillMonth, - order: Ordering.ORDERING_DESCENDING, - pagination: { - perPage: 50, - page, - }, - }; - try { - const page = await getGitpodService().server.listUsage(request); - setUsagePage(page); - setTotalCreditsUsed(Math.ceil(page.creditBalanceAtEnd)); - } catch (error) { - if (error.code === ErrorCodes.PERMISSION_DENIED) { - setErrorMessage("Access to usage details is restricted to team owners."); - } else { - setErrorMessage(`Error: ${error?.message}`); - } - } finally { - setIsLoading(false); - } - }; - - const getType = (type: WorkspaceType) => { - if (type === "regular") { - return "Workspace"; - } - return "Prebuild"; - }; - - const getMinutes = (usage: Usage) => { - if (usage.kind !== "workspaceinstance") { - return ""; - } - const metaData = usage.metadata as WorkspaceInstanceUsageData; - if (!metaData.endTime) { - return "running"; - } - const end = new Date(metaData.endTime).getTime(); - const start = new Date(metaData.startTime).getTime(); - const lengthOfUsage = Math.floor(end - start); - const inMinutes = (lengthOfUsage / (1000 * 60)).toFixed(1); - return inMinutes + " min"; - }; - - const handleMonthClick = (start: any, end: any) => { - setStartDateOfBillMonth(start); - setEndDateOfBillMonth(end); - }; - - const getBillingHistory = () => { - let rows = []; - // This goes back 6 months from the current month - for (let i = 1; i < 7; i++) { - const endDateVar = i - 1; - const startDate = new Date(today.getFullYear(), today.getMonth() - i); - const endDate = new Date(today.getFullYear(), today.getMonth() - endDateVar, 0); - const timeStampOfStartDate = startDate.getTime(); - const timeStampOfEndDate = endDate.getTime(); - rows.push( -
handleMonthClick(timeStampOfStartDate, timeStampOfEndDate)} - > - {startDate.toLocaleString("default", { month: "long" })} {startDate.getFullYear()} -
, - ); - } - return rows; - }; - - const displayTime = (time: string | number) => { - const options: Intl.DateTimeFormatOptions = { - day: "numeric", - month: "short", - year: "numeric", - hour: "numeric", - minute: "numeric", - }; - return new Date(time).toLocaleDateString(undefined, options).replace("at ", ""); - }; + }, [billingMode]); - const currentPaginatedResults = usagePage?.usageEntriesList ?? []; + if (!billingMode || !attributionId) { + return <>; + } - return ( - <> -
-
- {errorMessage &&

{errorMessage}

} - {!errorMessage && ( -
-
-
-
-
Current Month
-
handleMonthClick(timestampStartOfCurrentMonth, Date.now())} - > - {startOfCurrentMonth.toLocaleString("default", { month: "long" })}{" "} - {startOfCurrentMonth.getFullYear()} -
-
Previous Months
- {getBillingHistory()} -
- {!isLoading && ( -
-
Total usage
-
- - {totalCreditsUsed} Credits -
-
- )} -
-
- {!isLoading && usagePage === undefined && !errorMessage && ( -
-

No sessions found.

-

- Have you started any - - {" "} - workspaces - {" "} - in{" "} - {new Date(startDateOfBillMonth).toLocaleString("default", { - month: "long", - })}{" "} - {new Date(startDateOfBillMonth).getFullYear()} or checked your other teams? -

-
- )} - {isLoading && ( -
-
- Fetching usage... -
- -
- )} - {!isLoading && currentPaginatedResults.length > 0 && ( -
- - - - Type - - - ID - - - Credits - - - - Timestamp - - - {currentPaginatedResults && - currentPaginatedResults.map((usage) => { - return ( -
-
- - {getType( - (usage.metadata as WorkspaceInstanceUsageData) - .workspaceType, - )} - - - { - (usage.metadata as WorkspaceInstanceUsageData) - .workspaceClass - } - -
-
- - {(usage.metadata as WorkspaceInstanceUsageData).workspaceId} - - - {(usage.metadata as WorkspaceInstanceUsageData) - .contextURL && - toRemoteURL( - (usage.metadata as WorkspaceInstanceUsageData) - .contextURL, - )} - -
-
- - {usage.credits.toFixed(1)} - - - {getMinutes(usage)} - -
-
-
- - {displayTime(usage.effectiveTime!)} - -
- {(usage.metadata as WorkspaceInstanceUsageData) - .workspaceType === "prebuild" ? ( - - ) : ( - "" - )} - {(usage.metadata as WorkspaceInstanceUsageData) - .workspaceType === "prebuild" ? ( - - Gitpod - - ) : ( -
- user avatar - - {(usage.metadata as WorkspaceInstanceUsageData) - .userName || ""} - -
- )} -
-
-
- ); - })} - - {usagePage && usagePage.pagination && usagePage.pagination.totalPages > 1 && ( - loadPage(page)} - totalNumberOfPages={usagePage.pagination.totalPages} - /> - )} -
- )} -
- )} -
- - ); + return ; } export default TeamUsage; From 4b76bc1ef6b1a6f9291811d2c6d44761a8dfc944 Mon Sep 17 00:00:00 2001 From: Alex Tugarev Date: Thu, 8 Sep 2022 12:47:03 +0000 Subject: [PATCH 2/3] [dashboard] add Usage page to user menu --- components/dashboard/src/App.tsx | 3 ++ components/dashboard/src/Menu.tsx | 28 ++++++----- components/dashboard/src/Usage.tsx | 47 +++++++++++++++++++ .../dashboard/src/settings/settings.routes.ts | 1 + 4 files changed, 68 insertions(+), 11 deletions(-) create mode 100644 components/dashboard/src/Usage.tsx diff --git a/components/dashboard/src/App.tsx b/components/dashboard/src/App.tsx index 87695777bd5fa5..97cd81c4cddc81 100644 --- a/components/dashboard/src/App.tsx +++ b/components/dashboard/src/App.tsx @@ -35,6 +35,7 @@ import { settingsPathTeamsNew, settingsPathVariables, settingsPathSSHKeys, + usagePathMain, } from "./settings/settings.routes"; import { projectsPathInstallGitHubApp, @@ -90,6 +91,7 @@ const ProjectsSearch = React.lazy(() => import(/* webpackPrefetch: true */ "./ad const TeamsSearch = React.lazy(() => import(/* webpackPrefetch: true */ "./admin/TeamsSearch")); const OAuthClientApproval = React.lazy(() => import(/* webpackPrefetch: true */ "./OauthClientApproval")); const License = React.lazy(() => import(/* webpackPrefetch: true */ "./admin/License")); +const Usage = React.lazy(() => import(/* webpackPrefetch: true */ "./Usage")); function Loading() { return <>; @@ -377,6 +379,7 @@ function App() { + diff --git a/components/dashboard/src/Menu.tsx b/components/dashboard/src/Menu.tsx index 19b7012a463326..79432f49a491ce 100644 --- a/components/dashboard/src/Menu.tsx +++ b/components/dashboard/src/Menu.tsx @@ -217,17 +217,23 @@ export default function Menu() { return teamSettingsList; } // User menu - return [ - { - title: "Projects", - link: "/projects", - }, - { - title: "Settings", - link: "/settings", - alternatives: getSettingsMenu({ userBillingMode }).flatMap((e) => e.link), - }, - ]; + const userMenu = []; + userMenu.push({ + title: "Projects", + link: "/projects", + }); + if (userBillingMode?.mode === "usage-based") { + userMenu.push({ + title: "Usage", + link: "/usage", + }); + } + userMenu.push({ + title: "Settings", + link: "/settings", + alternatives: getSettingsMenu({ userBillingMode }).flatMap((e) => e.link), + }); + return userMenu; })(); const rightMenu: Entry[] = [ ...(user?.rolesOrPermissions?.includes("admin") diff --git a/components/dashboard/src/Usage.tsx b/components/dashboard/src/Usage.tsx new file mode 100644 index 00000000000000..ff660d7e475f2b --- /dev/null +++ b/components/dashboard/src/Usage.tsx @@ -0,0 +1,47 @@ +/** + * Copyright (c) 2022 Gitpod GmbH. All rights reserved. + * Licensed under the GNU Affero General Public License (AGPL). + * See License-AGPL.txt in the project root for license information. + */ + +import { useContext, useEffect, useState } from "react"; + +import { getGitpodService, gitpodHostUrl } from "./service/service"; +import { BillingMode } from "@gitpod/gitpod-protocol/lib/billing-mode"; +import UsageView from "./components/UsageView"; +import { AttributionId } from "@gitpod/gitpod-protocol/lib/attribution"; +import { UserContext } from "./user-context"; + +function TeamUsage() { + const { user } = useContext(UserContext); + const [billingMode, setBillingMode] = useState(undefined); + const [attributionId, setAttributionId] = useState(); + + useEffect(() => { + if (!user) { + return; + } + setAttributionId({ kind: "user", userId: user.id }); + (async () => { + const billingMode = await getGitpodService().server.getBillingModeForUser(); + setBillingMode(billingMode); + })(); + }, [user]); + + useEffect(() => { + if (!billingMode) { + return; + } + if (!BillingMode.showUsageBasedBilling(billingMode)) { + window.location.href = gitpodHostUrl.asDashboard().toString(); + } + }, [billingMode]); + + if (!billingMode || !attributionId) { + return <>; + } + + return ; +} + +export default TeamUsage; diff --git a/components/dashboard/src/settings/settings.routes.ts b/components/dashboard/src/settings/settings.routes.ts index 97e1a38480478b..7209364ca323db 100644 --- a/components/dashboard/src/settings/settings.routes.ts +++ b/components/dashboard/src/settings/settings.routes.ts @@ -5,6 +5,7 @@ */ export const settingsPathMain = "/settings"; +export const usagePathMain = "/usage"; export const settingsPathAccount = "/account"; export const settingsPathIntegrations = "/integrations"; From aa894e447e101a180d7f831c663cb835c3f230fc Mon Sep 17 00:00:00 2001 From: Alex Tugarev Date: Fri, 9 Sep 2022 08:24:34 +0000 Subject: [PATCH 3/3] Deactivate personal Usage page in the menu for now ... it still remains navigatable. --- components/dashboard/src/Menu.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/components/dashboard/src/Menu.tsx b/components/dashboard/src/Menu.tsx index 79432f49a491ce..ba1f3928cfc0ff 100644 --- a/components/dashboard/src/Menu.tsx +++ b/components/dashboard/src/Menu.tsx @@ -222,12 +222,12 @@ export default function Menu() { title: "Projects", link: "/projects", }); - if (userBillingMode?.mode === "usage-based") { - userMenu.push({ - title: "Usage", - link: "/usage", - }); - } + // if (userBillingMode?.mode === "usage-based") { + // userMenu.push({ + // title: "Usage", + // link: "/usage", + // }); + // } userMenu.push({ title: "Settings", link: "/settings",