Skip to content

Commit 8009fa2

Browse files
committed
[dashboard] license tab in the admin dashboard
1 parent 58ceb91 commit 8009fa2

26 files changed

+516
-37
lines changed

components/dashboard/src/App.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,14 @@ import { UserContext } from "./user-context";
1313
import { TeamsContext } from "./teams/teams-context";
1414
import { ThemeContext } from "./theme-context";
1515
import { AdminContext } from "./admin-context";
16+
import { LicenseContext } from "./license-context";
1617
import { getGitpodService } from "./service/service";
1718
import { shouldSeeWhatsNew, WhatsNew } from "./whatsnew/WhatsNew";
1819
import gitpodIcon from "./icons/gitpod.svg";
1920
import { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error";
2021
import { useHistory } from "react-router-dom";
2122
import { trackButtonOrAnchor, trackPathChange, trackLocation } from "./Analytics";
22-
import { User } from "@gitpod/gitpod-protocol";
23+
import { LicenseInfo, User } from "@gitpod/gitpod-protocol";
2324
import * as GitpodCookie from "@gitpod/gitpod-protocol/lib/util/gitpod-cookie";
2425
import { Experiment } from "./experiments";
2526
import { workspacesPathMain } from "./workspaces/workspaces.routes";
@@ -77,6 +78,7 @@ const AdminSettings = React.lazy(() => import(/* webpackPrefetch: true */ "./adm
7778
const ProjectsSearch = React.lazy(() => import(/* webpackPrefetch: true */ "./admin/ProjectsSearch"));
7879
const TeamsSearch = React.lazy(() => import(/* webpackPrefetch: true */ "./admin/TeamsSearch"));
7980
const OAuthClientApproval = React.lazy(() => import(/* webpackPrefetch: true */ "./OauthClientApproval"));
81+
const License = React.lazy(() => import(/* webpackPrefetch: true */ "./admin/License"));
8082

8183
function Loading() {
8284
return <></>;
@@ -150,6 +152,7 @@ function App() {
150152
const { teams, setTeams } = useContext(TeamsContext);
151153
const { setAdminSettings } = useContext(AdminContext);
152154
const { setIsDark } = useContext(ThemeContext);
155+
const { setLicense } = useContext(LicenseContext);
153156

154157
const [loading, setLoading] = useState<boolean>(true);
155158
const [isWhatsNewShown, setWhatsNewShown] = useState(false);
@@ -186,6 +189,9 @@ function App() {
186189
if (user?.rolesOrPermissions?.includes("admin")) {
187190
const adminSettings = await getGitpodService().server.adminGetSettings();
188191
setAdminSettings(adminSettings);
192+
193+
var license: LicenseInfo = await getGitpodService().server.adminGetLicense();
194+
setLicense(license);
189195
}
190196
} catch (error) {
191197
console.error(error);
@@ -367,6 +373,7 @@ function App() {
367373
<AdminRoute path="/admin/teams" component={TeamsSearch} />
368374
<AdminRoute path="/admin/workspaces" component={WorkspacesSearch} />
369375
<AdminRoute path="/admin/projects" component={ProjectsSearch} />
376+
<AdminRoute path="/admin/license" component={License} />
370377
<AdminRoute path="/admin/settings" component={AdminSettings} />
371378

372379
<Route path={["/", "/login"]} exact>
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
/**
2+
* Copyright (c) 2022 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License-AGPL.txt in the project root for license information.
5+
*/
6+
7+
import { PageWithSubMenu } from "../components/PageWithSubMenu";
8+
import { adminMenu } from "./admin-menu";
9+
10+
import { LicenseContext } from "../license-context";
11+
import { ReactElement, useContext, useEffect } from "react";
12+
import { getGitpodService } from "../service/service";
13+
14+
import { ReactComponent as Alert } from "../images/exclamation.svg";
15+
import { ReactComponent as Success } from "../images/check-circle.svg";
16+
import { LicenseInfo } from "@gitpod/gitpod-protocol";
17+
import { ReactComponent as XSvg } from "../images/x.svg";
18+
import { ReactComponent as CheckSvg } from "../images/check.svg";
19+
import { ReactComponent as LinkSvg } from "../images/external-link.svg";
20+
import SolidCard from "../components/SolidCard";
21+
import Card from "../components/Card";
22+
23+
export default function License() {
24+
const { license, setLicense } = useContext(LicenseContext);
25+
26+
useEffect(() => {
27+
if (isGitpodIo()) {
28+
return; // temporarily disable to avoid hight CPU on the DB
29+
}
30+
(async () => {
31+
const data = await getGitpodService().server.adminGetLicense();
32+
setLicense(data);
33+
})();
34+
}, []);
35+
36+
const featureList = license?.enabledFeatures;
37+
const features = license?.features;
38+
39+
// if user seats is 0, it means that there is no user limit in the license
40+
const userLimit = license?.seats == 0 ? "Unlimited" : license?.seats;
41+
42+
const [licenseLevel, paid, statusMessage] = license ? getSubscriptionLevel(license) : defaultMessage();
43+
44+
return (
45+
<div>
46+
<PageWithSubMenu
47+
subMenu={adminMenu}
48+
title="License"
49+
subtitle="License associated with your Gitpod installation"
50+
>
51+
<div className="flex flex-row space-x-4">
52+
<Card>
53+
{licenseLevel}
54+
{paid}
55+
<div className="mt-4 font-semibold text-sm">Available features:</div>
56+
<div className="flex flex-col items-start text-sm">
57+
{features &&
58+
features.map((feat: string) => (
59+
<span className="inline-flex space-x-1">
60+
{featureList?.includes(feat) ? (
61+
<CheckSvg fill="currentColor" className="self-center mt-1" />
62+
) : (
63+
<XSvg fill="currentColor" className="self-center h-2 mt-1" />
64+
)}
65+
<span>{capitalizeInitials(feat)}</span>
66+
</span>
67+
))}
68+
</div>
69+
</Card>
70+
<SolidCard>
71+
<div className="my-2">{statusMessage}</div>
72+
<p className="dark:text-gray-500 font-semibold">Registered Users</p>
73+
<span className="dark:text-gray-300 text-lg">{license?.userCount || 0}</span>
74+
<span className="dark:text-gray-500 text-gray-400 pt-1 text-lg"> / {userLimit} </span>
75+
<p className="dark:text-gray-500 pt-2 font-semibold">License Type</p>
76+
<h4 className="dark:text-gray-300 text-lg">{capitalizeInitials(license?.type || "")}</h4>
77+
<a
78+
className="gp-link flex flex-row mr-2 justify-end font-semibold space-x-2 mt-6"
79+
href="https://www.gitpod.io/self-hosted"
80+
target="_blank"
81+
>
82+
<span>Compare Plans</span>
83+
<div className="self-end">
84+
<LinkSvg />
85+
</div>
86+
</a>
87+
</SolidCard>
88+
</div>
89+
</PageWithSubMenu>
90+
</div>
91+
);
92+
}
93+
94+
function capitalizeInitials(str: string): string {
95+
return str
96+
.split("-")
97+
.map((item) => {
98+
return item.charAt(0).toUpperCase() + item.slice(1);
99+
})
100+
.join(" ");
101+
}
102+
103+
function getSubscriptionLevel(license: LicenseInfo): ReactElement[] {
104+
switch (license.plan) {
105+
case "prod":
106+
case "trial":
107+
return professionalPlan(license.userCount || 0, license.seats, license.plan == "trial", license.validUntil);
108+
case "community":
109+
return communityPlan(license.userCount || 0, license.seats, license.fallbackAllowed);
110+
default: {
111+
return defaultMessage();
112+
}
113+
}
114+
}
115+
116+
function licenseLevel(level: string): ReactElement {
117+
return <div className="text-white dark:text-black font-semibold mt-4"> {level}</div>;
118+
}
119+
120+
function additionalLicenseInfo(data: string): ReactElement {
121+
return <div className="dark:text-gray-500 text-gray-400 font-semibold text-sm">{data}</div>;
122+
}
123+
124+
function defaultMessage(): ReactElement[] {
125+
const alertMessage = () => {
126+
return (
127+
<span className="text-gray-600 dark:text-gray-50 flex font-semibold items-center">
128+
<div>Inactive or unknown license</div>
129+
<div className="flex justify-right my-4 mx-1">
130+
<Alert fill="grey" className="h-8 w-8" />
131+
</div>
132+
</span>
133+
);
134+
};
135+
136+
return [licenseLevel("Inactive"), additionalLicenseInfo("Free"), alertMessage()];
137+
}
138+
139+
function professionalPlan(userCount: number, seats: number, trial: boolean, validUntil: string): ReactElement[] {
140+
const alertMessage = (aboveLimit: boolean) => {
141+
return aboveLimit ? (
142+
<span className="text-red-700 dark:text-red-400 flex font-semibold items-center">
143+
<div>You have exceeded the usage limit.</div>
144+
<div className="flex justify-right my-4 mx-1">
145+
<Alert className="h-6 w-6" />
146+
</div>
147+
</span>
148+
) : (
149+
<span className="text-green-600 dark:text-green-400 flex font-semibold items-center">
150+
<div>You have an active professional license.</div>
151+
<div className="flex justify-right my-4 mx-1">
152+
<Success className="h-8 w-8" />
153+
</div>
154+
</span>
155+
);
156+
};
157+
158+
const aboveLimit: boolean = userCount > seats;
159+
160+
const licenseTitle = () => {
161+
const expDate = new Date(validUntil);
162+
if (typeof expDate.getTime !== "function") {
163+
return trial ? additionalLicenseInfo("Trial") : additionalLicenseInfo("Paid");
164+
} else {
165+
return additionalLicenseInfo(
166+
"Expires on " +
167+
expDate.toLocaleDateString("en-DB", { year: "numeric", month: "short", day: "numeric" }),
168+
);
169+
}
170+
};
171+
172+
return [licenseLevel("Professional"), licenseTitle(), alertMessage(aboveLimit)];
173+
}
174+
175+
function communityPlan(userCount: number, seats: number, fallbackAllowed: boolean): ReactElement[] {
176+
const alertMessage = (aboveLimit: boolean) => {
177+
if (aboveLimit) {
178+
return fallbackAllowed ? (
179+
<div className="text-gray-600 dark:text-gray-50 flex font-semibold items-center">
180+
<div>No active license. You are using community edition.</div>
181+
<div className="my-4 mx-1 ">
182+
<Success className="h-8 w-8" />
183+
</div>
184+
</div>
185+
) : (
186+
<span className="text-red-700 dark:text-red-400 flex font-semibold items-center">
187+
<div>No active license. You have exceeded the usage limit.</div>
188+
<div className="flex justify-right my-4 mx-1">
189+
<Alert className="h-8 w-8" />
190+
</div>
191+
</span>
192+
);
193+
} else {
194+
return (
195+
<span className="text-green-600 dark:text-green-400 flex font-semibold items-center">
196+
<div>You are using the free community edition.</div>
197+
<div className="flex justify-right my-4 mx-1">
198+
<Success fill="green" className="h-8 w-8" />
199+
</div>
200+
</span>
201+
);
202+
}
203+
};
204+
205+
const aboveLimit: boolean = userCount > seats;
206+
207+
return [licenseLevel("Community"), additionalLicenseInfo("Free"), alertMessage(aboveLimit)];
208+
}
209+
210+
function isGitpodIo() {
211+
return window.location.hostname === "gitpod.io" || window.location.hostname === "gitpod-staging.com";
212+
}

components/dashboard/src/admin/admin-menu.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ export const adminMenu = [
2121
title: "Teams",
2222
link: ["/admin/teams"],
2323
},
24+
{
25+
title: "License",
26+
link: ["/admin/license"],
27+
},
2428
{
2529
title: "Settings",
2630
link: ["/admin/settings"],
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* Copyright (c) 2021 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License-AGPL.txt in the project root for license information.
5+
*/
6+
7+
function Card(p: { className?: string; children?: React.ReactNode }) {
8+
return (
9+
<div
10+
className={
11+
"flex rounded-xl w-72 h-64 px-4 bg-gray-800 dark:bg-gray-100 text-gray-200 dark:text-gray-500" +
12+
(p.className || "")
13+
}
14+
>
15+
<span>{p.children}</span>
16+
</div>
17+
);
18+
}
19+
20+
export default Card;
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* Copyright (c) 2021 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License-AGPL.txt in the project root for license information.
5+
*/
6+
7+
function SolidCard(p: { className?: string; children?: React.ReactNode }) {
8+
return (
9+
<div
10+
className={
11+
"flex rounded-xl w-72 h-64 px-4 bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-600" +
12+
(p.className || "")
13+
}
14+
>
15+
<span>{p.children}</span>
16+
</div>
17+
);
18+
}
19+
20+
export default SolidCard;
Lines changed: 3 additions & 0 deletions
Loading
Lines changed: 3 additions & 0 deletions
Loading
Lines changed: 4 additions & 0 deletions
Loading

components/dashboard/src/index.tsx

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import App from "./App";
1010
import { UserContextProvider } from "./user-context";
1111
import { AdminContextProvider } from "./admin-context";
1212
import { PaymentContextProvider } from "./payment-context";
13+
import { LicenseContextProvider } from "./license-context";
1314
import { TeamsContextProvider } from "./teams/teams-context";
1415
import { ProjectContextProvider } from "./projects/project-context";
1516
import { ThemeContextProvider } from "./theme-context";
@@ -23,17 +24,19 @@ ReactDOM.render(
2324
<UserContextProvider>
2425
<AdminContextProvider>
2526
<PaymentContextProvider>
26-
<TeamsContextProvider>
27-
<ProjectContextProvider>
28-
<ThemeContextProvider>
29-
<StartWorkspaceModalContextProvider>
30-
<BrowserRouter>
31-
<App />
32-
</BrowserRouter>
33-
</StartWorkspaceModalContextProvider>
34-
</ThemeContextProvider>
35-
</ProjectContextProvider>
36-
</TeamsContextProvider>
27+
<LicenseContextProvider>
28+
<TeamsContextProvider>
29+
<ProjectContextProvider>
30+
<ThemeContextProvider>
31+
<StartWorkspaceModalContextProvider>
32+
<BrowserRouter>
33+
<App />
34+
</BrowserRouter>
35+
</StartWorkspaceModalContextProvider>
36+
</ThemeContextProvider>
37+
</ProjectContextProvider>
38+
</TeamsContextProvider>
39+
</LicenseContextProvider>
3740
</PaymentContextProvider>
3841
</AdminContextProvider>
3942
</UserContextProvider>
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/**
2+
* Copyright (c) 2022 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License-AGPL.txt in the project root for license information.
5+
*/
6+
7+
import React, { createContext, useState } from "react";
8+
import { LicenseInfo } from "@gitpod/gitpod-protocol";
9+
10+
const LicenseContext = createContext<{
11+
license?: LicenseInfo;
12+
setLicense: React.Dispatch<LicenseInfo>;
13+
}>({
14+
setLicense: () => null,
15+
});
16+
17+
const LicenseContextProvider: React.FC = ({ children }) => {
18+
const [license, setLicense] = useState<LicenseInfo>();
19+
return <LicenseContext.Provider value={{ license, setLicense }}>{children}</LicenseContext.Provider>;
20+
};
21+
22+
export { LicenseContext, LicenseContextProvider };

0 commit comments

Comments
 (0)