diff --git a/components/dashboard/src/App.tsx b/components/dashboard/src/App.tsx index 266ecb80473d27..cb3228106d0d7a 100644 --- a/components/dashboard/src/App.tsx +++ b/components/dashboard/src/App.tsx @@ -68,6 +68,7 @@ const JoinTeam = React.lazy(() => import(/* webpackPrefetch: true */ "./teams/Jo const Members = React.lazy(() => import(/* webpackPrefetch: true */ "./teams/Members")); const TeamSettings = React.lazy(() => import(/* webpackPrefetch: true */ "./teams/TeamSettings")); const TeamBilling = React.lazy(() => import(/* webpackPrefetch: true */ "./teams/TeamBilling")); +const TeamUsage = React.lazy(() => import(/* webpackPrefetch: true */ "./teams/TeamUsage")); const NewProject = React.lazy(() => import(/* webpackPrefetch: true */ "./projects/NewProject")); const ConfigureProject = React.lazy(() => import(/* webpackPrefetch: true */ "./projects/ConfigureProject")); const Projects = React.lazy(() => import(/* webpackPrefetch: true */ "./projects/Projects")); @@ -440,6 +441,9 @@ function App() { if (maybeProject === "billing") { return ; } + if (maybeProject === "usage") { + return ; + } if (resourceOrPrebuild === "prebuilds") { return ; } diff --git a/components/dashboard/src/Menu.tsx b/components/dashboard/src/Menu.tsx index 83e5b97ec73e42..dd84481ffe7355 100644 --- a/components/dashboard/src/Menu.tsx +++ b/components/dashboard/src/Menu.tsx @@ -64,6 +64,7 @@ export default function Menu() { "members", "settings", "billing", + "usage", // admin sub-pages "users", "workspaces", @@ -220,7 +221,7 @@ export default function Menu() { teamSettingsList.push({ title: "Settings", link: `/t/${team.slug}/settings`, - alternatives: getTeamSettingsMenu({ team, showPaymentUI }).flatMap((e) => e.link), + alternatives: getTeamSettingsMenu({ team, showPaymentUI, showUsageBasedUI }).flatMap((e) => e.link), }); } diff --git a/components/dashboard/src/teams/TeamBilling.tsx b/components/dashboard/src/teams/TeamBilling.tsx index f5eb98c41436fc..17489439282c69 100644 --- a/components/dashboard/src/teams/TeamBilling.tsx +++ b/components/dashboard/src/teams/TeamBilling.tsx @@ -31,7 +31,7 @@ export default function TeamBilling() { const team = getCurrentTeam(location, teams); const [members, setMembers] = useState([]); const [teamSubscription, setTeamSubscription] = useState(); - const { showPaymentUI, currency, setCurrency } = useContext(PaymentContext); + const { showPaymentUI, showUsageBasedUI, currency, setCurrency } = useContext(PaymentContext); const [pendingTeamPlan, setPendingTeamPlan] = useState(); const [pollTeamSubscriptionTimeout, setPollTeamSubscriptionTimeout] = useState(); @@ -140,7 +140,7 @@ export default function TeamBilling() { return ( diff --git a/components/dashboard/src/teams/TeamSettings.tsx b/components/dashboard/src/teams/TeamSettings.tsx index 310feab4b2f145..f2a32f7ac98eba 100644 --- a/components/dashboard/src/teams/TeamSettings.tsx +++ b/components/dashboard/src/teams/TeamSettings.tsx @@ -15,8 +15,8 @@ import { getGitpodService, gitpodHostUrl } from "../service/service"; import { UserContext } from "../user-context"; import { getCurrentTeam, TeamsContext } from "./teams-context"; -export function getTeamSettingsMenu(params: { team?: Team; showPaymentUI?: boolean }) { - const { team, showPaymentUI } = params; +export function getTeamSettingsMenu(params: { team?: Team; showPaymentUI?: boolean; showUsageBasedUI?: boolean }) { + const { team, showPaymentUI, showUsageBasedUI } = params; return [ { title: "General", @@ -30,6 +30,14 @@ export function getTeamSettingsMenu(params: { team?: Team; showPaymentUI?: boole }, ] : []), + ...(showUsageBasedUI + ? [ + { + title: "Usage", + link: [`/t/${team?.slug}/usage`], + }, + ] + : []), ]; } @@ -41,7 +49,7 @@ export default function TeamSettings() { const { user } = useContext(UserContext); const location = useLocation(); const team = getCurrentTeam(location, teams); - const { showPaymentUI } = useContext(PaymentContext); + const { showPaymentUI, showUsageBasedUI } = useContext(PaymentContext); const close = () => setModal(false); @@ -68,7 +76,7 @@ export default function TeamSettings() { return ( <> diff --git a/components/dashboard/src/teams/TeamUsage.tsx b/components/dashboard/src/teams/TeamUsage.tsx new file mode 100644 index 00000000000000..8bbfa3b01c9dfe --- /dev/null +++ b/components/dashboard/src/teams/TeamUsage.tsx @@ -0,0 +1,115 @@ +/** + * 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 { Redirect, useLocation } from "react-router"; +import { PageWithSubMenu } from "../components/PageWithSubMenu"; +import { getCurrentTeam, TeamsContext } from "./teams-context"; +import { getTeamSettingsMenu } from "./TeamSettings"; +import { PaymentContext } from "../payment-context"; +import { getGitpodService } from "../service/service"; +import { BillableSession } from "@gitpod/gitpod-protocol/lib/usage"; +import { Item, ItemField, ItemsList } from "../components/ItemsList"; +import moment from "moment"; +import Property from "../admin/Property"; +import Arrow from "../components/Arrow"; + +function TeamUsage() { + const { teams } = useContext(TeamsContext); + const { showPaymentUI, showUsageBasedUI } = useContext(PaymentContext); + const location = useLocation(); + const team = getCurrentTeam(location, teams); + const [billedUsage, setBilledUsage] = useState([]); + + useEffect(() => { + if (!team) { + return; + } + (async () => { + const billedUsageResult = await getGitpodService().server.getBilledUsage("some-attribution-id"); + setBilledUsage(billedUsageResult); + })(); + }, [team]); + + if (!showUsageBasedUI) { + return ; + } + + const getType = (type: string) => { + if (type === "regular") { + return "Workspace"; + } + return "Prebuild"; + }; + + const getHours = (endTime: number, startTime: number) => { + return (endTime - startTime) / (1000 * 60 * 60) + "hrs"; + }; + + return ( + +
+
+ Jun 1 - June 30 + 4,200 Min + 12,334 Min +
+
+ + + + Type + + + Class + + + Amount + + + Credits + + + + {billedUsage.map((usage) => ( +
+
+ + {getType(usage.workspaceType)} + +
+
+ {usage.workspaceClass} +
+
+ {getHours(usage.endTime, usage.startTime)} +
+
+ {usage.credits} +
+
+ + {moment(new Date(usage.startTime).toDateString()).fromNow()} + +
+
+ +
+
+ ))} +
+
+ ); +} + +export default TeamUsage; diff --git a/components/gitpod-protocol/src/gitpod-service.ts b/components/gitpod-protocol/src/gitpod-service.ts index 9c068dfc90457f..010dc4cc9ef0e7 100644 --- a/components/gitpod-protocol/src/gitpod-service.ts +++ b/components/gitpod-protocol/src/gitpod-service.ts @@ -60,6 +60,7 @@ import { RemotePageMessage, RemoteTrackMessage, RemoteIdentifyMessage } from "./ import { IDEServer } from "./ide-protocol"; import { InstallationAdminSettings, TelemetryData } from "./installation-admin-protocol"; import { Currency } from "./plans"; +import { BillableSession } from "./usage"; export interface GitpodClient { onInstanceUpdate(instance: WorkspaceInstance): void; @@ -288,6 +289,8 @@ export interface GitpodServer extends JsonRpcServer, AdminServer, subscribeTeamToStripe(teamId: string, setupIntentId: string, currency: Currency): Promise; getStripePortalUrlForTeam(teamId: string): Promise; + getBilledUsage(attributionId: string): Promise; + /** * Analytics */ diff --git a/components/gitpod-protocol/src/usage.ts b/components/gitpod-protocol/src/usage.ts new file mode 100644 index 00000000000000..5df8c36d2dc60d --- /dev/null +++ b/components/gitpod-protocol/src/usage.ts @@ -0,0 +1,144 @@ +/** + * 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 { WorkspaceType } from "./protocol"; + +export interface BillableSession { + // The id of the one paying the bill + attributionId: string; + + // Relevant for workspace type. When prebuild, shows "prebuild" + userId?: string; + teamId?: string; + + instanceId: string; + + workspaceId: string; + + workspaceType: WorkspaceType; + + // "standard" or "XL" + workspaceClass: string; + + // When the workspace started + startTime: number; + + // When the workspace ended + endTime: number; + + // The credits used for this session + credits: number; + + // TODO - maybe + projectId?: string; +} + +export const billableSessionDummyData: BillableSession[] = [ + { + attributionId: "some-attribution-id", + userId: "prebuild", + teamId: "prebuild", + instanceId: "some-instance-id", + workspaceId: "some-workspace-id", + workspaceType: "prebuild", + workspaceClass: "XL", + startTime: Date.now() + -3 * 24 * 3600 * 1000, // 3 days ago + endTime: Date.now(), + credits: 320, + projectId: "project-123", + }, + { + attributionId: "some-attribution-id2", + userId: "some-user", + teamId: "some-team", + instanceId: "some-instance-id2", + workspaceId: "some-workspace-id2", + workspaceType: "regular", + workspaceClass: "standard", + startTime: Date.now() + -5 * 24 * 3600 * 1000, + endTime: Date.now(), + credits: 130, + projectId: "project-123", + }, + { + attributionId: "some-attribution-id3", + userId: "some-other-user", + teamId: "some-other-team", + instanceId: "some-instance-id3", + workspaceId: "some-workspace-id3", + workspaceType: "regular", + workspaceClass: "XL", + startTime: Date.now() + -5 * 24 * 3600 * 1000, + endTime: Date.now() + -4 * 24 * 3600 * 1000, + credits: 150, + projectId: "project-134", + }, + { + attributionId: "some-attribution-id4", + userId: "some-other-user2", + teamId: "some-other-team2", + instanceId: "some-instance-id4", + workspaceId: "some-workspace-id4", + workspaceType: "regular", + workspaceClass: "standard", + startTime: Date.now() + -10 * 24 * 3600 * 1000, + endTime: Date.now() + -9 * 24 * 3600 * 1000, + credits: 330, + projectId: "project-137", + }, + { + attributionId: "some-attribution-id5", + userId: "some-other-user3", + teamId: "some-other-team3", + instanceId: "some-instance-id5", + workspaceId: "some-workspace-id5", + workspaceType: "regular", + workspaceClass: "XL", + startTime: Date.now() + -2 * 24 * 3600 * 1000, + endTime: Date.now(), + credits: 222, + projectId: "project-138", + }, + { + attributionId: "some-attribution-id6", + userId: "some-other-user4", + teamId: "some-other-team4", + instanceId: "some-instance-id6", + workspaceId: "some-workspace-id3", + workspaceType: "regular", + workspaceClass: "XL", + startTime: Date.now() + -7 * 24 * 3600 * 1000, + endTime: Date.now() + -6 * 24 * 3600 * 1000, + credits: 300, + projectId: "project-134", + }, + { + attributionId: "some-attribution-id8", + userId: "some-other-user5", + teamId: "some-other-team5", + instanceId: "some-instance-id8", + workspaceId: "some-workspace-id3", + workspaceType: "regular", + workspaceClass: "standard", + startTime: Date.now() + -1 * 24 * 3600 * 1000, + endTime: Date.now(), + credits: 100, + projectId: "project-567", + }, + { + attributionId: "some-attribution-id7", + userId: "prebuild", + teamId: "some-other-team7", + instanceId: "some-instance-id7", + workspaceId: "some-workspace-id7", + workspaceType: "prebuild", + workspaceClass: "XL", + startTime: Date.now() + -1 * 24 * 3600 * 1000, + endTime: Date.now(), + credits: 200, + projectId: "project-345", + }, +]; diff --git a/components/server/ee/src/workspace/gitpod-server-impl.ts b/components/server/ee/src/workspace/gitpod-server-impl.ts index 7ad0b05076ab08..c3e17398d38000 100644 --- a/components/server/ee/src/workspace/gitpod-server-impl.ts +++ b/components/server/ee/src/workspace/gitpod-server-impl.ts @@ -70,6 +70,7 @@ import { BlockedRepository } from "@gitpod/gitpod-protocol/lib/blocked-repositor import { EligibilityService } from "../user/eligibility-service"; import { AccountStatementProvider } from "../user/account-statement-provider"; import { GithubUpgradeURL, PlanCoupon } from "@gitpod/gitpod-protocol/lib/payment-protocol"; +import { BillableSession, billableSessionDummyData } from "@gitpod/gitpod-protocol/lib/usage"; import { AssigneeIdentityIdentifier, TeamSubscription, @@ -2056,6 +2057,10 @@ export class GitpodServerEEImpl extends GitpodServerImpl { } } + async getBilledUsage(ctx: TraceContext, attributionId: string): Promise { + return billableSessionDummyData; + } + // (SaaS) – admin async adminGetAccountStatement(ctx: TraceContext, userId: string): Promise { traceAPIParams(ctx, { userId }); diff --git a/components/server/src/auth/rate-limiter.ts b/components/server/src/auth/rate-limiter.ts index 05061ebf27a60f..4690a04d46cba0 100644 --- a/components/server/src/auth/rate-limiter.ts +++ b/components/server/src/auth/rate-limiter.ts @@ -210,6 +210,7 @@ function getConfig(config: RateLimiterConfig): RateLimiterConfig { findStripeSubscriptionIdForTeam: { group: "default", points: 1 }, subscribeTeamToStripe: { group: "default", points: 1 }, getStripePortalUrlForTeam: { group: "default", points: 1 }, + getBilledUsage: { group: "default", points: 1 }, trackEvent: { group: "default", points: 1 }, trackLocation: { group: "default", points: 1 }, identifyUser: { group: "default", points: 1 }, diff --git a/components/server/src/workspace/gitpod-server-impl.ts b/components/server/src/workspace/gitpod-server-impl.ts index dc5fcc3bd9d7cb..4915ccf85a0ad1 100644 --- a/components/server/src/workspace/gitpod-server-impl.ts +++ b/components/server/src/workspace/gitpod-server-impl.ts @@ -167,6 +167,7 @@ import { LicenseEvaluator } from "@gitpod/licensor/lib"; import { Feature } from "@gitpod/licensor/lib/api"; import { Currency } from "@gitpod/gitpod-protocol/lib/plans"; import { getExperimentsClientForBackend } from "@gitpod/gitpod-protocol/lib/experiments/configcat-server"; +import { BillableSession } from "@gitpod/gitpod-protocol/lib/usage"; // shortcut export const traceWI = (ctx: TraceContext, wi: Omit) => TraceContext.setOWI(ctx, wi); // userId is already taken care of in WebsocketConnectionManager @@ -3196,6 +3197,11 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { async getStripePortalUrlForTeam(ctx: TraceContext, teamId: string): Promise { throw new ResponseError(ErrorCodes.SAAS_FEATURE, `Not implemented in this version`); } + + async getBilledUsage(ctx: TraceContext, attributionId: string): Promise { + throw new ResponseError(ErrorCodes.SAAS_FEATURE, `Not implemented in this version`); + } + // //#endregion }