diff --git a/.gitpod.yml b/.gitpod.yml index 307695d657c99d..8e98fa5f3968fb 100644 --- a/.gitpod.yml +++ b/.gitpod.yml @@ -14,15 +14,15 @@ ports: onOpen: ignore - port: 9229 onOpen: ignore - # Go proxy +# Go proxy - port: 9999 onOpen: ignore - port: 13001 onOpen: ignore - # Werft +# Werft - port: 7777 onOpen: ignore - # Dev Theia +# Dev Theia - port: 13444 tasks: - name: Add Harvester kubeconfig @@ -56,3 +56,5 @@ vscode: - timonwong.shellcheck - vscjava.vscode-java-pack - fwcd.kotlin + - dbaeumer.vscode-eslint + - esbenp.prettier-vscode diff --git a/components/dashboard/src/App.tsx b/components/dashboard/src/App.tsx index 1cd3d457088c05..145601de845fc8 100644 --- a/components/dashboard/src/App.tsx +++ b/components/dashboard/src/App.tsx @@ -4,103 +4,123 @@ * See License-AGPL.txt in the project root for license information. */ -import React, { Suspense, useContext, useEffect, useState } from 'react'; -import Menu from './Menu'; +import React, { Suspense, useContext, useEffect, useState } from "react"; +import Menu from "./Menu"; import { Redirect, Route, Switch } from "react-router"; -import { Login } from './Login'; -import { UserContext } from './user-context'; -import { TeamsContext } from './teams/teams-context'; -import { ThemeContext } from './theme-context'; -import { AdminContext } from './admin-context'; -import { getGitpodService } from './service/service'; -import { shouldSeeWhatsNew, WhatsNew } from './whatsnew/WhatsNew'; -import gitpodIcon from './icons/gitpod.svg'; -import { ErrorCodes } from '@gitpod/gitpod-protocol/lib/messaging/error'; -import { useHistory } from 'react-router-dom'; -import { trackButtonOrAnchor, trackPathChange, trackLocation } from './Analytics'; -import { User } from '@gitpod/gitpod-protocol'; -import * as GitpodCookie from '@gitpod/gitpod-protocol/lib/util/gitpod-cookie'; -import { Experiment } from './experiments'; -import { workspacesPathMain } from './workspaces/workspaces.routes'; -import { settingsPathAccount, settingsPathIntegrations, settingsPathMain, settingsPathNotifications, settingsPathPlans, settingsPathPreferences, settingsPathTeams, settingsPathTeamsJoin, settingsPathTeamsNew, settingsPathVariables } from './settings/settings.routes'; -import { projectsPathInstallGitHubApp, projectsPathMain, projectsPathMainWithParams, projectsPathNew } from './projects/projects.routes'; -import { refreshSearchData } from './components/RepositoryFinder'; -import { StartWorkspaceModal } from './workspaces/StartWorkspaceModal'; -import { parseProps } from './start/StartWorkspace'; +import { Login } from "./Login"; +import { UserContext } from "./user-context"; +import { TeamsContext } from "./teams/teams-context"; +import { ThemeContext } from "./theme-context"; +import { AdminContext } from "./admin-context"; +import { getGitpodService } from "./service/service"; +import { shouldSeeWhatsNew, WhatsNew } from "./whatsnew/WhatsNew"; +import gitpodIcon from "./icons/gitpod.svg"; +import { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error"; +import { useHistory } from "react-router-dom"; +import { trackButtonOrAnchor, trackPathChange, trackLocation } from "./Analytics"; +import { User } from "@gitpod/gitpod-protocol"; +import * as GitpodCookie from "@gitpod/gitpod-protocol/lib/util/gitpod-cookie"; +import { Experiment } from "./experiments"; +import { workspacesPathMain } from "./workspaces/workspaces.routes"; +import { + settingsPathAccount, + settingsPathIntegrations, + settingsPathMain, + settingsPathNotifications, + settingsPathPlans, + settingsPathPreferences, + settingsPathTeams, + settingsPathTeamsJoin, + settingsPathTeamsNew, + settingsPathVariables, +} from "./settings/settings.routes"; +import { + projectsPathInstallGitHubApp, + projectsPathMain, + projectsPathMainWithParams, + projectsPathNew, +} from "./projects/projects.routes"; +import { refreshSearchData } from "./components/RepositoryFinder"; +import { StartWorkspaceModal } from "./workspaces/StartWorkspaceModal"; +import { parseProps } from "./start/StartWorkspace"; -const Setup = React.lazy(() => import(/* webpackPrefetch: true */ './Setup')); -const Workspaces = React.lazy(() => import(/* webpackPrefetch: true */ './workspaces/Workspaces')); -const Account = React.lazy(() => import(/* webpackPrefetch: true */ './settings/Account')); -const Notifications = React.lazy(() => import(/* webpackPrefetch: true */ './settings/Notifications')); -const Plans = React.lazy(() => import(/* webpackPrefetch: true */ './settings/Plans')); -const Teams = React.lazy(() => import(/* webpackPrefetch: true */ './settings/Teams')); -const EnvironmentVariables = React.lazy(() => import(/* webpackPrefetch: true */ './settings/EnvironmentVariables')); -const Integrations = React.lazy(() => import(/* webpackPrefetch: true */ './settings/Integrations')); -const Preferences = React.lazy(() => import(/* webpackPrefetch: true */ './settings/Preferences')); -const Open = React.lazy(() => import(/* webpackPrefetch: true */ './start/Open')); -const StartWorkspace = React.lazy(() => import(/* webpackPrefetch: true */ './start/StartWorkspace')); -const CreateWorkspace = React.lazy(() => import(/* webpackPrefetch: true */ './start/CreateWorkspace')); -const NewTeam = React.lazy(() => import(/* webpackPrefetch: true */ './teams/NewTeam')); -const JoinTeam = React.lazy(() => import(/* webpackPrefetch: true */ './teams/JoinTeam')); -const Members = React.lazy(() => import(/* webpackPrefetch: true */ './teams/Members')); -const TeamSettings = React.lazy(() => import(/* webpackPrefetch: true */ './teams/TeamSettings')); -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')); -const Project = React.lazy(() => import(/* webpackPrefetch: true */ './projects/Project')); -const ProjectSettings = React.lazy(() => import(/* webpackPrefetch: true */ './projects/ProjectSettings')); -const ProjectVariables = React.lazy(() => import(/* webpackPrefetch: true */ './projects/ProjectVariables')); -const Prebuilds = React.lazy(() => import(/* webpackPrefetch: true */ './projects/Prebuilds')); -const Prebuild = React.lazy(() => import(/* webpackPrefetch: true */ './projects/Prebuild')); -const InstallGitHubApp = React.lazy(() => import(/* webpackPrefetch: true */ './projects/InstallGitHubApp')); -const FromReferrer = React.lazy(() => import(/* webpackPrefetch: true */ './FromReferrer')); -const UserSearch = React.lazy(() => import(/* webpackPrefetch: true */ './admin/UserSearch')); -const WorkspacesSearch = React.lazy(() => import(/* webpackPrefetch: true */ './admin/WorkspacesSearch')); -const AdminSettings = React.lazy(() => import(/* webpackPrefetch: true */ './admin/Settings')); -const ProjectsSearch = React.lazy(() => import(/* webpackPrefetch: true */ './admin/ProjectsSearch')); -const TeamsSearch = React.lazy(() => import(/* webpackPrefetch: true */ './admin/TeamsSearch')); -const OAuthClientApproval = React.lazy(() => import(/* webpackPrefetch: true */ './OauthClientApproval')); +const Setup = React.lazy(() => import(/* webpackPrefetch: true */ "./Setup")); +const Workspaces = React.lazy(() => import(/* webpackPrefetch: true */ "./workspaces/Workspaces")); +const Account = React.lazy(() => import(/* webpackPrefetch: true */ "./settings/Account")); +const Notifications = React.lazy(() => import(/* webpackPrefetch: true */ "./settings/Notifications")); +const Plans = React.lazy(() => import(/* webpackPrefetch: true */ "./settings/Plans")); +const Teams = React.lazy(() => import(/* webpackPrefetch: true */ "./settings/Teams")); +const EnvironmentVariables = React.lazy(() => import(/* webpackPrefetch: true */ "./settings/EnvironmentVariables")); +const Integrations = React.lazy(() => import(/* webpackPrefetch: true */ "./settings/Integrations")); +const Preferences = React.lazy(() => import(/* webpackPrefetch: true */ "./settings/Preferences")); +const Open = React.lazy(() => import(/* webpackPrefetch: true */ "./start/Open")); +const StartWorkspace = React.lazy(() => import(/* webpackPrefetch: true */ "./start/StartWorkspace")); +const CreateWorkspace = React.lazy(() => import(/* webpackPrefetch: true */ "./start/CreateWorkspace")); +const NewTeam = React.lazy(() => import(/* webpackPrefetch: true */ "./teams/NewTeam")); +const JoinTeam = React.lazy(() => import(/* webpackPrefetch: true */ "./teams/JoinTeam")); +const Members = React.lazy(() => import(/* webpackPrefetch: true */ "./teams/Members")); +const TeamSettings = React.lazy(() => import(/* webpackPrefetch: true */ "./teams/TeamSettings")); +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")); +const Project = React.lazy(() => import(/* webpackPrefetch: true */ "./projects/Project")); +const ProjectSettings = React.lazy(() => import(/* webpackPrefetch: true */ "./projects/ProjectSettings")); +const ProjectVariables = React.lazy(() => import(/* webpackPrefetch: true */ "./projects/ProjectVariables")); +const Prebuilds = React.lazy(() => import(/* webpackPrefetch: true */ "./projects/Prebuilds")); +const Prebuild = React.lazy(() => import(/* webpackPrefetch: true */ "./projects/Prebuild")); +const InstallGitHubApp = React.lazy(() => import(/* webpackPrefetch: true */ "./projects/InstallGitHubApp")); +const FromReferrer = React.lazy(() => import(/* webpackPrefetch: true */ "./FromReferrer")); +const UserSearch = React.lazy(() => import(/* webpackPrefetch: true */ "./admin/UserSearch")); +const WorkspacesSearch = React.lazy(() => import(/* webpackPrefetch: true */ "./admin/WorkspacesSearch")); +const AdminSettings = React.lazy(() => import(/* webpackPrefetch: true */ "./admin/Settings")); +const ProjectsSearch = React.lazy(() => import(/* webpackPrefetch: true */ "./admin/ProjectsSearch")); +const TeamsSearch = React.lazy(() => import(/* webpackPrefetch: true */ "./admin/TeamsSearch")); +const OAuthClientApproval = React.lazy(() => import(/* webpackPrefetch: true */ "./OauthClientApproval")); function Loading() { - return <> - ; + return <>; } function isGitpodIo() { - return window.location.hostname === 'gitpod.io' || window.location.hostname === 'gitpod-staging.com' || window.location.hostname.endsWith('gitpod-dev.com') || window.location.hostname.endsWith('gitpod-io-dev.com') + return ( + window.location.hostname === "gitpod.io" || + window.location.hostname === "gitpod-staging.com" || + window.location.hostname.endsWith("gitpod-dev.com") || + window.location.hostname.endsWith("gitpod-io-dev.com") + ); } function isWebsiteSlug(pathName: string) { const slugs = [ - 'about', - 'blog', - 'careers', - 'changelog', - 'chat', - 'code-of-conduct', - 'contact', - 'docs', - 'features', - 'for', - 'gitpod-vs-github-codespaces', - 'imprint', - 'media-kit', - 'memes', - 'pricing', - 'privacy', - 'security', - 'screencasts', - 'self-hosted', - 'support', - 'terms', - 'values' - ] - return slugs.some(slug => pathName.startsWith('/' + slug + '/') || pathName === ('/' + slug)); + "about", + "blog", + "careers", + "changelog", + "chat", + "code-of-conduct", + "contact", + "docs", + "features", + "for", + "gitpod-vs-github-codespaces", + "imprint", + "media-kit", + "memes", + "pricing", + "privacy", + "security", + "screencasts", + "self-hosted", + "support", + "terms", + "values", + ]; + return slugs.some((slug) => pathName.startsWith("/" + slug + "/") || pathName === "/" + slug); } export function getURLHash() { - return window.location.hash.replace(/^[#/]+/, ''); + return window.location.hash.replace(/^[#/]+/, ""); } function App() { @@ -129,20 +149,19 @@ function App() { // if a team was selected previously and we call the root URL (e.g. "gitpod.io"), // let's continue with the team page const hash = getURLHash(); - const isRoot = window.location.pathname === '/' && hash === ''; + const isRoot = window.location.pathname === "/" && hash === ""; if (isRoot) { try { - const teamSlug = localStorage.getItem('team-selection'); - if (teams.some(t => t.slug === teamSlug)) { + const teamSlug = localStorage.getItem("team-selection"); + if (teams.some((t) => t.slug === teamSlug)) { history.push(`/t/${teamSlug}`); } - } catch { - } + } catch {} } } setTeams(teams); - if (user?.rolesOrPermissions?.includes('admin')) { + if (user?.rolesOrPermissions?.includes("admin")) { const adminSettings = await getGitpodService().server.adminGetSettings(); setAdminSettings(adminSettings); } @@ -159,32 +178,34 @@ function App() { setLoading(false); (window as any)._gp.path = window.location.pathname; //store current path to have access to previous when path changes })(); - }, []); + }, [history, setAdminSettings, setTeams, setUser]); useEffect(() => { const updateTheme = () => { - const isDark = localStorage.theme === 'dark' || (localStorage.theme !== 'light' && window.matchMedia("(prefers-color-scheme: dark)").matches); + const isDark = + localStorage.theme === "dark" || + (localStorage.theme !== "light" && window.matchMedia("(prefers-color-scheme: dark)").matches); setIsDark(isDark); - } + }; updateTheme(); const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); if (mediaQuery instanceof EventTarget) { - mediaQuery.addEventListener('change', updateTheme); + mediaQuery.addEventListener("change", updateTheme); } else { // backward compatibility for Safari < 14 (mediaQuery as MediaQueryList).addListener(updateTheme); } - window.addEventListener('storage', updateTheme); + window.addEventListener("storage", updateTheme); return function cleanup() { if (mediaQuery instanceof EventTarget) { - mediaQuery.removeEventListener('change', updateTheme); + mediaQuery.removeEventListener("change", updateTheme); } else { // backward compatibility for Safari < 14 (mediaQuery as MediaQueryList).removeListener(updateTheme); } - window.removeEventListener('storage', updateTheme); - } - }, []); + window.removeEventListener("storage", updateTheme); + }; + }, [setIsDark]); // listen and notify Segment of client-side path updates useEffect(() => { @@ -192,81 +213,97 @@ function App() { // Choose which experiments to run for this session/user Experiment.set(Experiment.seed(true)); } - }) + }); useEffect(() => { return history.listen((location: any) => { const path = window.location.pathname; trackPathChange({ prev: (window as any)._gp.path, - path: path + path: path, }); (window as any)._gp.path = path; - }) - }, [history]) + }); + }, [history]); useEffect(() => { const handleButtonOrAnchorTracking = (props: MouseEvent) => { var curr = props.target as HTMLElement; //check if current target or any ancestor up to document is button or anchor while (!(curr instanceof Document)) { - if (curr instanceof HTMLButtonElement || curr instanceof HTMLAnchorElement || (curr instanceof HTMLDivElement && curr.onclick)) { + if ( + curr instanceof HTMLButtonElement || + curr instanceof HTMLAnchorElement || + (curr instanceof HTMLDivElement && curr.onclick) + ) { trackButtonOrAnchor(curr); break; //finding first ancestor is sufficient } curr = curr.parentNode as HTMLElement; } - } + }; window.addEventListener("click", handleButtonOrAnchorTracking, true); return () => window.removeEventListener("click", handleButtonOrAnchorTracking, true); }, []); useEffect(() => { if (user) { - refreshSearchData('', user); + refreshSearchData("", user); } }, [user]); // redirect to website for any website slugs if (isGitpodIo() && isWebsiteSlug(window.location.pathname)) { - window.location.host = 'www.gitpod.io'; + window.location.host = "www.gitpod.io"; return
; } - if (isGitpodIo() && window.location.pathname === '/' && window.location.hash === '' && !loading && !user) { + if (isGitpodIo() && window.location.pathname === "/" && window.location.hash === "" && !loading && !user) { if (!GitpodCookie.isPresent(document.cookie)) { window.location.href = `https://www.gitpod.io`; return
; } else { // explicitly render the Login page when the session is out-of-sync with the Gitpod cookie - return (); + return ; } } if (loading) { - return (); + return ; } if (isSetupRequired) { - return (}> - - ); + return ( + }> + + + ); } if (!user) { - return (); + return ; } - if (window.location.pathname.startsWith('/blocked')) { - return
- Gitpod's logo -

Your account has been blocked.

-

Please contact support if you think this is an error. See also terms of service.

- -
; + if (window.location.pathname.startsWith("/blocked")) { + return ( +
+ Gitpod's logo +

Your account has been blocked.

+

+ Please contact support if you think this is an error. See also{" "} + + terms of service + + . +

+ + + +
+ ); } - const shouldWhatsNewShown = shouldSeeWhatsNew(user) + const shouldWhatsNewShown = shouldSeeWhatsNew(user); if (shouldWhatsNewShown !== isWhatsNewShown) { setWhatsNewShown(shouldWhatsNewShown); } - if (window.location.pathname.startsWith('/oauth-approval')) { + if (window.location.pathname.startsWith("/oauth-approval")) { return ( }> @@ -274,142 +311,162 @@ function App() { ); } - window.addEventListener("hashchange", () => { - // Refresh on hash change if the path is '/' (new context URL) - if (window.location.pathname === '/') { - window.location.reload(); - } - }, false); + window.addEventListener( + "hashchange", + () => { + // Refresh on hash change if the path is '/' (new context URL) + if (window.location.pathname === "/") { + window.location.reload(); + } + }, + false, + ); - let toRender: React.ReactElement = -
- - - - - - - - - - - - - - + let toRender: React.ReactElement = ( + +
+ + + + + + + + + + + + + + - - - - - + + + + + - - - - - - - - - - - - - - - - -
-

Oh, no! Something went wrong!

-

{decodeURIComponent(getURLHash())}

-
-
- - - { - const { resourceOrPrebuild } = props.match.params; - if (resourceOrPrebuild === "settings") { - return ; - } - if (resourceOrPrebuild === "configure") { - return ; - } - if (resourceOrPrebuild === "variables") { - return ; - } - if (resourceOrPrebuild === "prebuilds") { - return ; - } - return resourceOrPrebuild ? : ; - }} /> - - - - - - - {(teams || []).map(team => - - - + + + + + + + + + + + + + + + + +
+

Oh, no! Something went wrong!

+

{decodeURIComponent(getURLHash())}

+
+
+ + + { + const { resourceOrPrebuild } = props.match.params; + if (resourceOrPrebuild === "settings") { + return ; + } + if (resourceOrPrebuild === "configure") { + return ; + } + if (resourceOrPrebuild === "variables") { + return ; + } + if (resourceOrPrebuild === "prebuilds") { + return ; + } + return resourceOrPrebuild ? : ; + }} + /> + + + + + + + {(teams || []).map((team) => ( + + + + + { + const { maybeProject, resourceOrPrebuild } = props.match.params; + if (maybeProject === "projects") { + return ; + } + if (maybeProject === "workspaces") { + return ; + } + if (maybeProject === "members") { + return ; + } + if (maybeProject === "settings") { + return ; + } + if (resourceOrPrebuild === "settings") { + return ; + } + if (resourceOrPrebuild === "configure") { + return ; + } + if (resourceOrPrebuild === "variables") { + return ; + } + if (resourceOrPrebuild === "prebuilds") { + return ; + } + return resourceOrPrebuild ? : ; + }} + /> - { - const { maybeProject, resourceOrPrebuild } = props.match.params; - if (maybeProject === "projects") { - return ; - } - if (maybeProject === "workspaces") { - return ; - } - if (maybeProject === "members") { - return ; - } - if (maybeProject === "settings") { - return ; - } - if (resourceOrPrebuild === "settings") { - return ; - } - if (resourceOrPrebuild === "configure") { - return ; - } - if (resourceOrPrebuild === "variables") { - return ; - } - if (resourceOrPrebuild === "prebuilds") { - return ; - } - return resourceOrPrebuild ? : ; - }} /> - )} - { - - return isGitpodIo() ? - // delegate to our website to handle the request - (window.location.host = 'www.gitpod.io') : -
-

404

-

Page not found.

-
; - }}> -
-
- -
-
; + ))} + { + return isGitpodIo() ? ( + // delegate to our website to handle the request + (window.location.host = "www.gitpod.io") + ) : ( +
+

404

+

Page not found.

+
+ ); + }} + >
+
+ +
+
+ ); const hash = getURLHash(); if (/^(https:\/\/)?github\.dev\//i.test(hash)) { - window.location.hash = hash.replace(/^(https:\/\/)?github\.dev\//i, 'https://github.com/') - return
- } else if (/^([^\/]+?=[^\/]*?|prebuild)\/(https:\/\/)?github\.dev\//i.test(hash)) { - window.location.hash = hash.replace(/^([^\/]+?=[^\/]*?|prebuild)\/(https:\/\/)?github\.dev\//i, '$1/https://github.com/') - return
+ window.location.hash = hash.replace(/^(https:\/\/)?github\.dev\//i, "https://github.com/"); + return
; + } else if (/^([^/]+?=[^/]*?|prebuild)\/(https:\/\/)?github\.dev\//i.test(hash)) { + window.location.hash = hash.replace( + /^([^/]+?=[^/]*?|prebuild)\/(https:\/\/)?github\.dev\//i, + "$1/https://github.com/", + ); + return
; } - const isCreation = window.location.pathname === '/' && hash !== ''; - const isWsStart = /\/start\/?/.test(window.location.pathname) && hash !== ''; + const isCreation = window.location.pathname === "/" && hash !== ""; + const isWsStart = /\/start\/?/.test(window.location.pathname) && hash !== ""; if (isWhatsNewShown) { toRender = setWhatsNewShown(false)} />; } else if (isCreation) { @@ -417,18 +474,14 @@ function App() { } else if (isWsStart) { toRender = ; } else if (/^(github|gitlab)\.com\/.+?/i.test(window.location.pathname)) { - let url = new URL(window.location.href) - url.hash = url.pathname - url.pathname = '/' - window.location.replace(url) - return
+ let url = new URL(window.location.href); + url.hash = url.pathname; + url.pathname = "/"; + window.location.replace(url); + return
; } - return ( - }> - {toRender} - - ); + return }>{toRender}; } export default App; diff --git a/components/dashboard/src/Login.tsx b/components/dashboard/src/Login.tsx index 82b2fdd3ed07de..0cd81966e264fc 100644 --- a/components/dashboard/src/Login.tsx +++ b/components/dashboard/src/Login.tsx @@ -5,15 +5,15 @@ */ import { AuthProviderInfo } from "@gitpod/gitpod-protocol"; -import * as GitpodCookie from '@gitpod/gitpod-protocol/lib/util/gitpod-cookie'; +import * as GitpodCookie from "@gitpod/gitpod-protocol/lib/util/gitpod-cookie"; import { useContext, useEffect, useState } from "react"; import { UserContext } from "./user-context"; import { TeamsContext } from "./teams/teams-context"; import { getGitpodService } from "./service/service"; import { iconForAuthProvider, openAuthorizeWindow, simplifyProviderName, getSafeURLRedirect } from "./provider-utils"; -import gitpod from './images/gitpod.svg'; -import gitpodDark from './images/gitpod-dark.svg'; -import gitpodIcon from './icons/gitpod.svg'; +import gitpod from "./images/gitpod.svg"; +import gitpodDark from "./images/gitpod-dark.svg"; +import gitpodIcon from "./icons/gitpod.svg"; import automate from "./images/welcome/automate.svg"; import code from "./images/welcome/code.svg"; import collaborate from "./images/welcome/collaborate.svg"; @@ -23,13 +23,14 @@ import prebuild from "./images/welcome/prebuild.svg"; import exclamation from "./images/exclamation.svg"; import { getURLHash } from "./App"; - -function Item(props: { icon: string, iconSize?: string, text: string }) { +function Item(props: { icon: string; iconSize?: string; text: string }) { const iconSize = props.iconSize || 28; - return
- -
{props.text}
-
; + return ( +
+ +
{props.text}
+
+ ); } export function markLoggedIn() { @@ -72,14 +73,14 @@ export function Login() { (async () => { setAuthProviders(await getGitpodService().server.getAuthProviders()); })(); - }, []) + }, []); useEffect(() => { if (hostFromContext && authProviders) { - const providerFromContext = authProviders.find(provider => provider.host === hostFromContext); + const providerFromContext = authProviders.find((provider) => provider.host === hostFromContext); setProviderFromContext(providerFromContext); } - }, [authProviders]); + }, [authProviders, hostFromContext]); const authorizeSuccessful = async (payload?: string) => { updateUser().catch(console.error); @@ -90,7 +91,7 @@ export function Login() { // ... and if it is, redirect to it window.location.replace(safeReturnTo); } - } + }; const updateUser = async () => { await getGitpodService().reconnect(); @@ -101,7 +102,7 @@ export function Login() { setUser(user); setTeams(teams); markLoggedIn(); - } + }; const openLogin = async (host: string) => { setErrorMessage(undefined); @@ -118,102 +119,148 @@ export function Login() { } else { errorMessage = payload.description ? payload.description : `Error: ${payload.error}`; if (payload.error === "email_taken") { - errorMessage = `Email address already used in another account. Please log in with ${(payload as any).host}.`; + errorMessage = `Email address already used in another account. Please log in with ${ + (payload as any).host + }.`; } } setErrorMessage(errorMessage); - } + }, }); } catch (error) { - console.log(error) + console.log(error); } - } + }; - return (
- {showWelcome ?
-
-
-
- Gitpod light theme logo - Gitpod dark theme logo -
-
-

Welcome to Gitpod

-
- Spin up fresh, automated dev environments for each task in the cloud, in seconds. + return ( +
+ {showWelcome ? ( +
+
+
+
+ Gitpod light theme logo + Gitpod dark theme logo +
+
+

Welcome to Gitpod

+
+ Spin up fresh, automated dev environments for each task in the cloud, in seconds. +
+
+
+ + + +
+
+ + + +
-
- - - -
-
- - - -
-
-
: null} -
-
-
-
-
- Gitpod's logo - Gitpod dark theme logo -
- -
- {providerFromContext - ? <> -

Open a cloud-based developer environment

-

for the repository {repoPathname?.slice(1)}

- - : <> -

Log in{showWelcome ? '' : ' to Gitpod'}

-

ALWAYS READY-TO-CODE

- } -
- + ) : null} +
+
+
+
+
+ Gitpod's logo + Gitpod dark theme logo +
-
- {providerFromContext - ? - - : - authProviders.map(ap => - ) - } -
+
+ {providerFromContext ? ( + <> +

+ Open a cloud-based developer environment +

+

for the repository {repoPathname?.slice(1)}

+ + ) : ( + <> +

Log in{showWelcome ? "" : " to Gitpod"}

+

ALWAYS READY-TO-CODE

+ + )} +
- {errorMessage && ( -
-
- -
-
-

{errorMessage}

-
+
+ {providerFromContext ? ( + + ) : ( + authProviders.map((ap) => ( + + )) + )}
- )} + {errorMessage && ( +
+
+ Error: +
+
+

{errorMessage}

+
+
+ )} +
+
+
+ + By signing in, you agree to our{" "} + + terms of service + {" "} + and{" "} + + privacy policy + + . +
-
-
- - By signing in, you agree to our terms of service and privacy policy. -
-
-
); + ); } diff --git a/components/dashboard/src/Menu.tsx b/components/dashboard/src/Menu.tsx index 67c585a4f20d10..4453501e0256b3 100644 --- a/components/dashboard/src/Menu.tsx +++ b/components/dashboard/src/Menu.tsx @@ -9,14 +9,14 @@ import { useContext, useEffect, useState } from "react"; import { Link } from "react-router-dom"; import { useLocation, useRouteMatch } from "react-router"; import { Location } from "history"; -import gitpodIcon from './icons/gitpod.svg'; +import gitpodIcon from "./icons/gitpod.svg"; import CaretDown from "./icons/CaretDown.svg"; import CaretUpDown from "./icons/CaretUpDown.svg"; import { getGitpodService, gitpodHostUrl } from "./service/service"; import { UserContext } from "./user-context"; import { TeamsContext, getCurrentTeam } from "./teams/teams-context"; -import settingsMenu from './settings/settings-menu'; -import { adminMenu } from './admin/admin-menu'; +import settingsMenu from "./settings/settings-menu"; +import { adminMenu } from "./admin/admin-menu"; import ContextMenu from "./components/ContextMenu"; import Separator from "./components/Separator"; import PillMenuItem from "./components/PillMenuItem"; @@ -26,9 +26,9 @@ import { getProjectSettingsMenu } from "./projects/ProjectSettings"; import { ProjectContext } from "./projects/project-context"; interface Entry { - title: string, - link: string, - alternatives?: string[] + title: string; + link: string; + alternatives?: string[]; } export default function Menu() { @@ -38,7 +38,9 @@ export default function Menu() { const team = getCurrentTeam(location, teams); const { project, setProject } = useContext(ProjectContext); - const match = useRouteMatch<{ segment1?: string, segment2?: string, segment3?: string }>("/(t/)?:segment1/:segment2?/:segment3?"); + const match = useRouteMatch<{ segment1?: string; segment2?: string; segment3?: string }>( + "/(t/)?:segment1/:segment2?/:segment3?", + ); const projectSlug = (() => { const resource = match?.params?.segment2; if (resource && !["projects", "members", "users", "workspaces", "settings", "teams"].includes(resource)) { @@ -47,59 +49,71 @@ export default function Menu() { })(); const prebuildId = (() => { const resource = projectSlug && match?.params?.segment3; - if (resource !== "workspaces" && resource !== "prebuilds" && resource !== "settings" && resource !== "configure" && resource !== "variables") { + if ( + resource !== "workspaces" && + resource !== "prebuilds" && + resource !== "settings" && + resource !== "configure" && + resource !== "variables" + ) { return resource; } })(); function isSelected(entry: Entry, location: Location) { - const all = [entry.link, ...(entry.alternatives||[])].map(l => l.toLowerCase()); + const all = [entry.link, ...(entry.alternatives || [])].map((l) => l.toLowerCase()); const path = location.pathname.toLowerCase(); - return all.some(n => n === path || n+'/' === path); + return all.some((n) => n === path || n + "/" === path); } - const userFullName = user?.fullName || user?.name || '...'; + const userFullName = user?.fullName || user?.name || "..."; + // TODO: This one smells like it could be some clever trickery, + // so opting for keeping it and disabling linting until + // Alex Tugarev chimes-in + // eslint-disable-next-line no-lone-blocks { // updating last team selection try { - localStorage.setItem('team-selection', team ? team.slug : ""); - } catch { - } + localStorage.setItem("team-selection", team ? team.slug : ""); + } catch {} } // Hide most of the top menu when in a full-page form. - const isMinimalUI = ['/new', '/teams/new', '/open'].includes(location.pathname); + const isMinimalUI = ["/new", "/teams/new", "/open"].includes(location.pathname); - const [ teamMembers, setTeamMembers ] = useState>({}); + const [teamMembers, setTeamMembers] = useState>({}); useEffect(() => { if (!teams) { return; } (async () => { const members: Record = {}; - await Promise.all(teams.map(async (team) => { - try { - members[team.id] = await getGitpodService().server.getTeamMembers(team.id); - } catch (error) { - console.error('Could not get members of team', team, error); - } - })); + await Promise.all( + teams.map(async (team) => { + try { + members[team.id] = await getGitpodService().server.getTeamMembers(team.id); + } catch (error) { + console.error("Could not get members of team", team, error); + } + }), + ); setTeamMembers(members); })(); - }, [ teams ]); + }, [teams]); useEffect(() => { if (!teams || !projectSlug) { return; } (async () => { - const projects = (!!team + const projects = !!team ? await getGitpodService().server.getTeamProjects(team.id) - : await getGitpodService().server.getUserProjects()); + : await getGitpodService().server.getUserProjects(); // Find project matching with slug, otherwise with name - const project = projectSlug && projects.find(p => p.slug ? p.slug === projectSlug : p.name === projectSlug); + const project = + projectSlug && projects.find((p) => (p.slug ? p.slug === projectSlug : p.name === projectSlug)); if (!project) { return; } @@ -107,47 +121,47 @@ export default function Menu() { })(); }, [projectSlug, setProject, team, teams]); - const teamOrUserSlug = !!team ? '/t/' + team.slug : '/projects'; + const teamOrUserSlug = !!team ? "/t/" + team.slug : "/projects"; const leftMenu: Entry[] = (() => { // Project menu if (projectSlug) { return [ { - title: 'Branches', + title: "Branches", link: `${teamOrUserSlug}/${projectSlug}`, }, { - title: 'Prebuilds', + title: "Prebuilds", link: `${teamOrUserSlug}/${projectSlug}/prebuilds`, }, { - title: 'Settings', + title: "Settings", link: `${teamOrUserSlug}/${projectSlug}/settings`, - alternatives: getProjectSettingsMenu({ slug: projectSlug } as Project, team).flatMap(e => e.link), + alternatives: getProjectSettingsMenu({ slug: projectSlug } as Project, team).flatMap((e) => e.link), }, ]; } // Team menu if (team) { - const currentUserInTeam = (teamMembers[team.id] || []).find(m => m.userId === user?.id); + const currentUserInTeam = (teamMembers[team.id] || []).find((m) => m.userId === user?.id); const teamSettingsList = [ { - title: 'Projects', + title: "Projects", link: `/t/${team.slug}/projects`, - alternatives: ([] as string[]) + alternatives: [] as string[], }, { - title: 'Members', - link: `/t/${team.slug}/members` - } + title: "Members", + link: `/t/${team.slug}/members`, + }, ]; if (currentUserInTeam?.role === "owner") { teamSettingsList.push({ - title: 'Settings', + title: "Settings", link: `/t/${team.slug}/settings`, - alternatives: getTeamSettingsMenu(team).flatMap(e => e.link), - }) + alternatives: getTeamSettingsMenu(team).flatMap((e) => e.link), + }); } return teamSettingsList; @@ -155,148 +169,226 @@ export default function Menu() { // User menu return [ { - title: 'Workspaces', - link: '/workspaces', - alternatives: ['/'] + title: "Workspaces", + link: "/workspaces", + alternatives: ["/"], }, { - title: 'Projects', - link: '/projects' + title: "Projects", + link: "/projects", }, { - title: 'Settings', - link: '/settings', - alternatives: settingsMenu.flatMap(e => e.link) - } + title: "Settings", + link: "/settings", + alternatives: settingsMenu.flatMap((e) => e.link), + }, ]; })(); const rightMenu: Entry[] = [ - ...(user?.rolesOrPermissions?.includes('admin') ? [{ - title: 'Admin', - link: '/admin', - alternatives: adminMenu.flatMap(e => e.link) - }] : []), + ...(user?.rolesOrPermissions?.includes("admin") + ? [ + { + title: "Admin", + link: "/admin", + alternatives: adminMenu.flatMap((e) => e.link), + }, + ] + : []), { - title: 'Docs', - link: 'https://www.gitpod.io/docs/', + title: "Docs", + link: "https://www.gitpod.io/docs/", }, { - title: 'Help', - link: 'https://www.gitpod.io/support', - } + title: "Help", + link: "https://www.gitpod.io/support", + }, ]; const renderTeamMenu = () => { return (
- { projectSlug &&
- - {team?.name || userFullName} - -
} + {projectSlug && ( +
+ + + {team?.name || userFullName} + + +
+ )}
- - {userFullName} - Personal Account -
, - active: !team, - separator: true, - link: '/', - }, - ...(teams || []).map(t => ({ - title: t.name, - customContent:
- {t.name} - {!!teamMembers[t.id] - ? `${teamMembers[t.id].length} member${teamMembers[t.id].length === 1 ? '' : 's'}` - : '...' - } -
, - active: team && team.id === t.id, - separator: true, - link: `/t/${t.slug}`, - })).sort((a, b) => a.title.toLowerCase() > b.title.toLowerCase() ? 1 : -1), - { - title: 'Create a new team', - customContent:
- New Team - -
, - link: '/teams/new', - } - ]}> + + + {userFullName} + + Personal Account +
+ ), + active: !team, + separator: true, + link: "/", + }, + ...(teams || []) + .map((t) => ({ + title: t.name, + customContent: ( +
+ + {t.name} + + + {!!teamMembers[t.id] + ? `${teamMembers[t.id].length} member${ + teamMembers[t.id].length === 1 ? "" : "s" + }` + : "..."} + +
+ ), + active: team && team.id === t.id, + separator: true, + link: `/t/${t.slug}`, + })) + .sort((a, b) => (a.title.toLowerCase() > b.title.toLowerCase() ? 1 : -1)), + { + title: "Create a new team", + customContent: ( +
+ New Team + + + +
+ ), + link: "/teams/new", + }, + ]} + >
- { !projectSlug && {team?.name || userFullName}} - + {!projectSlug && ( + + {team?.name || userFullName} + + )} +
- { projectSlug && ( + {projectSlug && (
- {project?.name} + + {project?.name} +
)} - { prebuildId && ( + {prebuildId && (
{prebuildId}
)}
- ) - } + ); + }; - return <> -
-
-
- - Gitpod's logo - - {!isMinimalUI &&
- {renderTeamMenu()} -
} -
-
- - ; -} \ No newline at end of file + {!isMinimalUI && !prebuildId && ( + + )} + + + + ); +} diff --git a/components/dashboard/src/admin/ProjectsSearch.tsx b/components/dashboard/src/admin/ProjectsSearch.tsx index 3cd993119b3455..64d937cf521db2 100644 --- a/components/dashboard/src/admin/ProjectsSearch.tsx +++ b/components/dashboard/src/admin/ProjectsSearch.tsx @@ -21,51 +21,56 @@ export default function ProjectsSearchPage() { - ) + ); } export function ProjectsSearch() { const location = useLocation(); const { user } = useContext(UserContext); - const [searchTerm, setSearchTerm] = useState(''); + const [searchTerm, setSearchTerm] = useState(""); const [searching, setSearching] = useState(false); const [searchResult, setSearchResult] = useState>({ total: 0, rows: [] }); const [currentProject, setCurrentProject] = useState(undefined); const [currentProjectOwner, setCurrentProjectOwner] = useState(""); useEffect(() => { - const projectId = location.pathname.split('/')[3]; + const projectId = location.pathname.split("/")[3]; if (projectId && searchResult) { - let currentProject = searchResult.rows.find(project => project.id === projectId); + let currentProject = searchResult.rows.find((project) => project.id === projectId); if (currentProject) { setCurrentProject(currentProject); } else { - getGitpodService().server.adminGetProjectById(projectId) - .then(project => setCurrentProject(project)) - .catch(e => console.error(e)); + getGitpodService() + .server.adminGetProjectById(projectId) + .then((project) => setCurrentProject(project)) + .catch((e) => console.error(e)); } } else { setCurrentProject(undefined); } - }, [location]); + }, [location, searchResult]); useEffect(() => { (async () => { if (currentProject) { if (currentProject.userId) { const owner = await getGitpodService().server.adminGetUser(currentProject.userId); - if (owner) { setCurrentProjectOwner(owner?.name) } + if (owner) { + setCurrentProjectOwner(owner?.name); + } } if (currentProject.teamId) { const owner = await getGitpodService().server.adminGetTeamById(currentProject.teamId); - if (owner) { setCurrentProjectOwner(owner?.name) } + if (owner) { + setCurrentProjectOwner(owner?.name); + } } } })(); - }, [currentProject]) + }, [currentProject]); - if (!user || !user?.rolesOrPermissions?.includes('admin')) { - return + if (!user || !user?.rolesOrPermissions?.includes("admin")) { + return ; } if (currentProject) { @@ -78,43 +83,71 @@ export function ProjectsSearch() { const result = await getGitpodService().server.adminGetProjectsBySearchTerm({ searchTerm, limit: 50, - orderBy: 'creationTime', + orderBy: "creationTime", offset: 0, - orderDir: "desc" - }) + orderDir: "desc", + }); setSearchResult(result); } finally { setSearching(false); } - } + }; - return <> -
-
-
-
- - - + return ( + <> +
+
+
+
+ + + +
+ k.key === "Enter" && search()} + onChange={(v) => { + setSearchTerm(v.target.value.trim()); + }} + />
- k.key === 'Enter' && search()} onChange={(v) => { setSearchTerm((v.target.value).trim()) }} /> +
-
-
-
-
-
Name
-
Clone URL
-
Created
+
+
+
Name
+
Clone URL
+
Created
+
+ {searchResult.rows.map((project) => ( + + ))}
- {searchResult.rows.map(project => )} -
- + + ); function ProjectResultItem(p: { project: Project }) { return ( - +
{p.project.name}
@@ -123,10 +156,12 @@ export function ProjectsSearch() {
{p.project.cloneUrl}
-
{moment(p.project.creationTime).fromNow()}
+
+ {moment(p.project.creationTime).fromNow()} +
- ) + ); } } diff --git a/components/dashboard/src/admin/Settings.tsx b/components/dashboard/src/admin/Settings.tsx index 236608a936ec50..7c32793cd506fb 100644 --- a/components/dashboard/src/admin/Settings.tsx +++ b/components/dashboard/src/admin/Settings.tsx @@ -27,39 +27,56 @@ export default function Settings() { } (async () => { const data = await getGitpodService().server.adminGetTelemetryData(); - setTelemetryData(data) + setTelemetryData(data); const setting = await getGitpodService().server.adminGetSettings(); - setAdminSettings(setting) + setAdminSettings(setting); })(); - }, []); + }, [setAdminSettings]); - if (!user || !user?.rolesOrPermissions?.includes('admin')) { - return + if (!user || !user?.rolesOrPermissions?.includes("admin")) { + return ; } const actuallySetTelemetryPrefs = async (value: InstallationAdminSettings) => { await getGitpodService().server.adminUpdateSettings(value); setAdminSettings(value); - } + }; return (
- +

Usage Statistics

The following usage data is sent to provide insights on how you use your Gitpod instance, so we can provide a better overall experience. Read our Privacy Policy} + desc={ + + The following usage data is sent to provide insights on how you use your Gitpod instance, so + we can provide a better overall experience.{" "} + + Read our Privacy Policy + + + } checked={adminSettings?.sendTelemetry ?? false} - onChange={(evt) => actuallySetTelemetryPrefs({ - sendTelemetry: evt.target.checked, - })} /> -
{JSON.stringify(telemetryData, null, 2)}
-
-
- ) + onChange={(evt) => + actuallySetTelemetryPrefs({ + sendTelemetry: evt.target.checked, + }) + } + /> + +
{JSON.stringify(telemetryData, null, 2)}
+
+ +
+ ); } function isGitpodIo() { - return window.location.hostname === 'gitpod.io' || window.location.hostname === 'gitpod-staging.com'; -} \ No newline at end of file + return window.location.hostname === "gitpod.io" || window.location.hostname === "gitpod-staging.com"; +} diff --git a/components/dashboard/src/admin/TeamsSearch.tsx b/components/dashboard/src/admin/TeamsSearch.tsx index 105c40e5471074..e9c190d36e03a9 100644 --- a/components/dashboard/src/admin/TeamsSearch.tsx +++ b/components/dashboard/src/admin/TeamsSearch.tsx @@ -22,35 +22,36 @@ export default function TeamsSearchPage() { - ) + ); } export function TeamsSearch() { const location = useLocation(); const { user } = useContext(UserContext); const [searching, setSearching] = useState(false); - const [searchTerm, setSearchTerm] = useState(''); + const [searchTerm, setSearchTerm] = useState(""); const [currentTeam, setCurrentTeam] = useState(undefined); const [searchResult, setSearchResult] = useState>({ total: 0, rows: [] }); useEffect(() => { - const teamId = location.pathname.split('/')[3]; + const teamId = location.pathname.split("/")[3]; if (teamId && searchResult) { - let foundTeam = searchResult.rows.find(team => team.id === teamId); + let foundTeam = searchResult.rows.find((team) => team.id === teamId); if (foundTeam) { setCurrentTeam(foundTeam); } else { - getGitpodService().server.adminGetTeamById(teamId) - .then(team => setCurrentTeam(team)) - .catch(e => console.error(e)); + getGitpodService() + .server.adminGetTeamById(teamId) + .then((team) => setCurrentTeam(team)) + .catch((e) => console.error(e)); } } else { setCurrentTeam(undefined); } - }, [location]); + }, [location, searchResult]); - if (!user || !user?.rolesOrPermissions?.includes('admin')) { - return + if (!user || !user?.rolesOrPermissions?.includes("admin")) { + return ; } if (currentTeam) { @@ -63,56 +64,93 @@ export function TeamsSearch() { const result = await getGitpodService().server.adminGetTeams({ searchTerm, limit: 100, - orderBy: 'creationTime', + orderBy: "creationTime", offset: 0, - orderDir: "desc" - }) + orderDir: "desc", + }); setSearchResult(result); } finally { setSearching(false); } - } - return <> -
-
-
-
- - - + }; + return ( + <> +
+
+
+
+ + + +
+ k.key === "Enter" && search()} + onChange={(v) => { + setSearchTerm(v.target.value.trim()); + }} + />
- k.key === 'Enter' && search()} onChange={(v) => { setSearchTerm((v.target.value).trim()) }} /> +
-
-
-
-
-
Name
-
Created - +
+
+
Name
+
+ Created + + + +
- - + {searchResult.rows.map((team) => ( + + ))}
- {searchResult.rows.map(team => )} -
- + + ); -function TeamResultItem(props: { team: Team }) { - return ( - -
-
-
{props.team.name} - {props.team.markedDeleted &&
- - ) + + ); + } } -} \ No newline at end of file diff --git a/components/dashboard/src/admin/UserSearch.tsx b/components/dashboard/src/admin/UserSearch.tsx index b15fb2ff3486a9..5e587e09f18303 100644 --- a/components/dashboard/src/admin/UserSearch.tsx +++ b/components/dashboard/src/admin/UserSearch.tsx @@ -20,32 +20,33 @@ export default function UserSearch() { const location = useLocation(); const { user } = useContext(UserContext); const [searchResult, setSearchResult] = useState>({ rows: [], total: 0 }); - const [searchTerm, setSearchTerm] = useState(''); + const [searchTerm, setSearchTerm] = useState(""); const [searching, setSearching] = useState(false); - const [currentUser, setCurrentUserState] = useState(undefined); + const [currentUser, setCurrentUserState] = useState(undefined); useEffect(() => { - const userId = location.pathname.split('/')[3]; + const userId = location.pathname.split("/")[3]; if (userId) { - let user = searchResult.rows.find(u => u.id === userId); + let user = searchResult.rows.find((u) => u.id === userId); if (user) { setCurrentUserState(user); } else { - getGitpodService().server.adminGetUser(userId).then( - user => setCurrentUserState(user) - ).catch(e => console.error(e)); + getGitpodService() + .server.adminGetUser(userId) + .then((user) => setCurrentUserState(user)) + .catch((e) => console.error(e)); } } else { setCurrentUserState(undefined); } - }, [location]); + }, [location, searchResult.rows]); - if (!user || !user?.rolesOrPermissions?.includes('admin')) { - return + if (!user || !user?.rolesOrPermissions?.includes("admin")) { + return ; } if (currentUser) { - return ; + return ; } const search = async () => { @@ -54,62 +55,94 @@ export default function UserSearch() { const result = await getGitpodService().server.adminGetUsers({ searchTerm, limit: 50, - orderBy: 'creationDate', + orderBy: "creationDate", offset: 0, - orderDir: "desc" + orderDir: "desc", }); setSearchResult(result); } finally { setSearching(false); } - } - return -
-
-
-
- - - + }; + return ( + +
+
+
+
+ + + +
+ ke.key === "Enter" && search()} + onChange={(v) => { + setSearchTerm(v.target.value.trim()); + }} + />
- ke.key === 'Enter' && search() } onChange={(v) => { setSearchTerm((v.target.value).trim()) }} /> +
-
-
-
-
-
-
Name
-
Created
+
+
+
+
Name
+
Created
+
+ {searchResult.rows + .filter((u) => u.identities.length > 0) + .map((u) => ( + + ))}
- {searchResult.rows.filter(u => u.identities.length > 0).map(u => )} -
- + + ); } function UserEntry(p: { user: User }) { if (!p) { return <>; } - let email = '---'; + let email = "---"; try { email = User.getPrimaryEmail(p.user); } catch (e) { log.error(e); } - return -
-
- {p.user.fullName -
-
-
{p.user.fullName}
-
{email}
-
-
-
{moment(p.user.creationDate).fromNow()}
+ return ( + +
+
+ {p.user.fullName +
+
+
+ {p.user.fullName} +
+
+ {email} +
+
+
+
{moment(p.user.creationDate).fromNow()}
+
-
- ; -} \ No newline at end of file + + ); +} diff --git a/components/dashboard/src/admin/WorkspacesSearch.tsx b/components/dashboard/src/admin/WorkspacesSearch.tsx index f7d97c15c23280..af27babcfdc39e 100644 --- a/components/dashboard/src/admin/WorkspacesSearch.tsx +++ b/components/dashboard/src/admin/WorkspacesSearch.tsx @@ -4,10 +4,19 @@ * See License-AGPL.txt in the project root for license information. */ -import { AdminGetListResult, AdminGetWorkspacesQuery, ContextURL, User, WorkspaceAndInstance } from "@gitpod/gitpod-protocol"; -import { matchesInstanceIdOrLegacyWorkspaceIdExactly, matchesNewWorkspaceIdExactly } from "@gitpod/gitpod-protocol/lib/util/parse-workspace-id"; +import { + AdminGetListResult, + AdminGetWorkspacesQuery, + ContextURL, + User, + WorkspaceAndInstance, +} from "@gitpod/gitpod-protocol"; +import { + matchesInstanceIdOrLegacyWorkspaceIdExactly, + matchesNewWorkspaceIdExactly, +} from "@gitpod/gitpod-protocol/lib/util/parse-workspace-id"; import moment from "moment"; -import { useContext, useEffect, useState } from "react"; +import { useCallback, useContext, useEffect, useState } from "react"; import { useLocation } from "react-router"; import { Link, Redirect } from "react-router-dom"; import { PageWithSubMenu } from "../components/PageWithSubMenu"; @@ -16,57 +25,29 @@ import { UserContext } from "../user-context"; import { getProject, WorkspaceStatusIndicator } from "../workspaces/WorkspaceEntry"; import { adminMenu } from "./admin-menu"; import WorkspaceDetail from "./WorkspaceDetail"; -import info from '../images/info.svg'; +import info from "../images/info.svg"; interface Props { - user?: User + user?: User; } export default function WorkspaceSearchPage() { - return - - ; + return ( + + + + ); } export function WorkspaceSearch(props: Props) { const location = useLocation(); const { user } = useContext(UserContext); const [searchResult, setSearchResult] = useState>({ rows: [], total: 0 }); - const [queryTerm, setQueryTerm] = useState(''); + const [queryTerm, setQueryTerm] = useState(""); const [searching, setSearching] = useState(false); - const [currentWorkspace, setCurrentWorkspaceState] = useState(undefined); + const [currentWorkspace, setCurrentWorkspaceState] = useState(undefined); - useEffect(() => { - const workspaceId = location.pathname.split('/')[3]; - if (workspaceId) { - let user = searchResult.rows.find(ws => ws.workspaceId === workspaceId); - if (user) { - setCurrentWorkspaceState(user); - } else { - getGitpodService().server.adminGetWorkspace(workspaceId).then( - ws => setCurrentWorkspaceState(ws) - ).catch(e => console.error(e)); - } - } else { - setCurrentWorkspaceState(undefined); - } - }, [location]); - - useEffect(() => { - if (props.user) { - search(); - } - }, [props.user]); - - if (!user || !user?.rolesOrPermissions?.includes('admin')) { - return - } - - if (currentWorkspace) { - return ; - } - - const search = async () => { + const search = useCallback(async () => { // Disables empty search on the workspace search page if (!props.user && queryTerm.length === 0) { return; @@ -88,7 +69,7 @@ export function WorkspaceSearch(props: Props) { const result = await getGitpodService().server.adminGetWorkspaces({ limit: 100, - orderBy: 'instanceCreationTime', + orderBy: "instanceCreationTime", offset: 0, orderDir: "desc", ...query, @@ -97,56 +78,128 @@ export function WorkspaceSearch(props: Props) { } finally { setSearching(false); } + }, [props.user, queryTerm]); + + useEffect(() => { + const workspaceId = location.pathname.split("/")[3]; + if (workspaceId) { + let user = searchResult.rows.find((ws) => ws.workspaceId === workspaceId); + if (user) { + setCurrentWorkspaceState(user); + } else { + getGitpodService() + .server.adminGetWorkspace(workspaceId) + .then((ws) => setCurrentWorkspaceState(ws)) + .catch((e) => console.error(e)); + } + } else { + setCurrentWorkspaceState(undefined); + } + }, [location, searchResult.rows]); + + useEffect(() => { + if (props.user) { + search(); + } + }, [props.user, search]); + + if (!user || !user?.rolesOrPermissions?.includes("admin")) { + return ; + } + + if (currentWorkspace) { + return ; } - return <> -
-
-
-
- - - + + return ( + <> +
+
+
+
+ + + +
+ ke.key === "Enter" && search()} + onChange={(v) => { + setQueryTerm(v.target.value.trim()); + }} + />
- ke.key === 'Enter' && search() } - onChange={(v) => { setQueryTerm((v.target.value).trim()) }} /> +
-
-
-
- info - Please enter complete IDs - this search does not perform partial-matching. -
-
-
-
-
Name
-
Context
-
Last Started
+
+ info + Please enter complete IDs - this search does not perform partial-matching.
- {searchResult.rows.map(ws => )} -
- +
+
+
+
Name
+
Context
+
Last Started
+
+ {searchResult.rows.map((ws) => ( + + ))} +
+ + ); } function WorkspaceEntry(p: { ws: WorkspaceAndInstance }) { - return -
-
- -
-
-
{p.ws.workspaceId}
-
{getProject(WorkspaceAndInstance.toWorkspace(p.ws))}
-
-
-
{p.ws.description}
-
{ContextURL.getNormalizedURL(p.ws)?.toString()}
-
-
-
{moment(p.ws.instanceCreationTime || p.ws.workspaceCreationTime).fromNow()}
+ return ( + +
+
+ +
+
+
+ {p.ws.workspaceId} +
+
+ {getProject(WorkspaceAndInstance.toWorkspace(p.ws))} +
+
+
+
{p.ws.description}
+
+ {ContextURL.getNormalizedURL(p.ws)?.toString()} +
+
+
+
+ {moment(p.ws.instanceCreationTime || p.ws.workspaceCreationTime).fromNow()} +
+
-
- ; + + ); } diff --git a/components/dashboard/src/components/AlertBox.tsx b/components/dashboard/src/components/AlertBox.tsx index f8bc57d870f50f..9bf5c626ec84b7 100644 --- a/components/dashboard/src/components/AlertBox.tsx +++ b/components/dashboard/src/components/AlertBox.tsx @@ -4,11 +4,13 @@ * See License-AGPL.txt in the project root for license information. */ -import exclamation from '../images/exclamation.svg'; +import exclamation from "../images/exclamation.svg"; -export default function AlertBox(p: { className?: string, children?: React.ReactNode }) { - return
- - {p.children} -
; +export default function AlertBox(p: { className?: string; children?: React.ReactNode }) { + return ( +
+ Heads up! + {p.children} +
+ ); } diff --git a/components/dashboard/src/components/ConfirmationModal.tsx b/components/dashboard/src/components/ConfirmationModal.tsx index 60ebf9e12157e6..7db7f5b64f9a55 100644 --- a/components/dashboard/src/components/ConfirmationModal.tsx +++ b/components/dashboard/src/components/ConfirmationModal.tsx @@ -10,19 +10,18 @@ import { useRef, useEffect } from "react"; export default function ConfirmationModal(props: { title?: string; - areYouSureText?: string, - children?: Entity | React.ReactChild[] | React.ReactChild - buttonText?: string, - buttonDisabled?: boolean, - visible?: boolean, - warningText?: string, - onClose: () => void, - onConfirm: () => void, + areYouSureText?: string; + children?: Entity | React.ReactChild[] | React.ReactChild; + buttonText?: string; + buttonDisabled?: boolean; + visible?: boolean; + warningText?: string; + onClose: () => void; + onConfirm: () => void; }) { - - const child: React.ReactChild[] = [ -

{props.areYouSureText}

, - ] + // TODO: preparing an array of children like this, generates the famous "every item needs a key" React error at runtime. + // Needs a bit of refactoring, moving all the conditions directly inside the returned Modal content. + const child: React.ReactChild[] = [

{props.areYouSureText}

]; if (props.warningText) { child.unshift({props.warningText}); @@ -34,9 +33,11 @@ export default function ConfirmationModal(props: { child.push(

{props.children.name}

- {props.children.description &&

{props.children.description}

} -
- ) + {props.children.description && ( +

{props.children.description}

+ )} +
, + ); } else if (Array.isArray(props.children)) { child.push(...props.children); } else { @@ -46,16 +47,18 @@ export default function ConfirmationModal(props: { const cancelButtonRef = useRef(null); const buttons = [ - , + , , - ] + ]; const buttonDisabled = useRef(props.buttonDisabled); useEffect(() => { buttonDisabled.current = props.buttonDisabled; - }) + }); return ( { setExpanded(!expanded); - } + }; const handler = (evt: KeyboardEvent) => { - if (evt.key === 'Escape') { + if (evt.key === "Escape") { setExpanded(false); } - } + }; const skipClickHandlerRef = React.useRef(false); const setSkipClickHandler = (data: boolean) => { skipClickHandlerRef.current = data; - } - const clickHandler = (evt: MouseEvent) => { - if (skipClickHandlerRef.current) { - // skip only once - setSkipClickHandler(false); - } else { - setExpanded(false); - } - } + }; useEffect(() => { - window.addEventListener('keydown', handler); - window.addEventListener('click', clickHandler); + const clickHandler = () => { + if (skipClickHandlerRef.current) { + // skip only once + setSkipClickHandler(false); + } else { + setExpanded(false); + } + }; + + window.addEventListener("keydown", handler); + window.addEventListener("click", clickHandler); // Remove event listeners on cleanup return () => { - window.removeEventListener('keydown', handler); - window.removeEventListener('click', clickHandler); + window.removeEventListener("keydown", handler); + window.removeEventListener("click", clickHandler); }; }, []); // Empty array ensures that effect is only run on mount and unmount - - const font = "text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-100" + const font = "text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-100"; const menuId = String(Math.random()); // Default 'children' is the three dots hamburger button. - const children = props.children || Actions; + const children = props.children || ( + + Actions + + + + + + + ); return (
-
{ - toggleExpanded(); - // Don't use `e.stopPropagation();` because that prevents that clicks on other context menus closes this one. - setSkipClickHandler(true); - }}> +
{ + toggleExpanded(); + // Don't use `e.stopPropagation();` because that prevents that clicks on other context menus closes this one. + setSkipClickHandler(true); + }} + > {children}
- {expanded ? -
- {props.menuEntries.length === 0 - ?

No actions available

- : props.menuEntries.map((e, index) => { + {expanded ? ( +
+ {props.menuEntries.length === 0 ? ( +

No actions available

+ ) : ( + props.menuEntries.map((e, index) => { const clickable = e.href || e.onClick || e.link; - const entry =
- {e.customContent || <>
{e.title}
{e.active ?
: null}} -
+ const entry = ( +
+ {e.customContent || ( + <> +
{e.title}
+
+ {e.active ?
: null} + + )} +
+ ); const key = `entry-${menuId}-${index}-${e.title}`; if (e.link) { - return - {entry} - ; + return ( + + {entry} + + ); } else if (e.href) { - return - {entry} - ; + return ( + + {entry} + + ); } else { - return
- {entry} -
+ return ( +
+ {entry} +
+ ); } - })} + }) + )}
- : - null - } + ) : null}
); } -export default ContextMenu; \ No newline at end of file +export default ContextMenu; diff --git a/components/dashboard/src/components/Header.tsx b/components/dashboard/src/components/Header.tsx index a98b60baea24a8..476201ef14f6a6 100644 --- a/components/dashboard/src/components/Header.tsx +++ b/components/dashboard/src/components/Header.tsx @@ -18,14 +18,16 @@ export default function Header(p: HeaderProps) { return; } document.title = `${p.title} — Gitpod`; - }, []); - return
-
-
- {typeof p.title === "string" ? (

{p.title}

) : p.title} - {typeof p.subtitle === "string" ? (

{p.subtitle}

) : p.subtitle} + }, [p.title]); + return ( +
+
+
+ {typeof p.title === "string" ?

{p.title}

: p.title} + {typeof p.subtitle === "string" ?

{p.subtitle}

: p.subtitle} +
+
- -
; + ); } diff --git a/components/dashboard/src/components/InfoBox.tsx b/components/dashboard/src/components/InfoBox.tsx index 330f3e32a2475c..35410c21c7e35a 100644 --- a/components/dashboard/src/components/InfoBox.tsx +++ b/components/dashboard/src/components/InfoBox.tsx @@ -4,11 +4,18 @@ * See License-AGPL.txt in the project root for license information. */ -import info from '../images/info.svg'; +import info from "../images/info.svg"; -export default function InfoBox(p: { className?: string, children?: React.ReactNode }) { - return
- - {p.children} -
; +export default function InfoBox(p: { className?: string; children?: React.ReactNode }) { + return ( +
+ + {p.children} +
+ ); } diff --git a/components/dashboard/src/components/MonacoEditor.tsx b/components/dashboard/src/components/MonacoEditor.tsx index 62f49bcddba388..961bc3c77a7f5b 100644 --- a/components/dashboard/src/components/MonacoEditor.tsx +++ b/components/dashboard/src/components/MonacoEditor.tsx @@ -8,92 +8,92 @@ import { useContext, useEffect, useRef } from "react"; import * as monaco from "monaco-editor"; import { ThemeContext } from "../theme-context"; -monaco.editor.defineTheme('gitpod', { - base: 'vs', - inherit: true, - rules: [], - colors: {}, +monaco.editor.defineTheme("gitpod", { + base: "vs", + inherit: true, + rules: [], + colors: {}, }); -monaco.editor.defineTheme('gitpod-disabled', { - base: 'vs', - inherit: true, - rules: [], - colors: { - 'editor.background': '#F5F5F4', // Tailwind's warmGray 100 https://tailwindcss.com/docs/customizing-colors - }, +monaco.editor.defineTheme("gitpod-disabled", { + base: "vs", + inherit: true, + rules: [], + colors: { + "editor.background": "#F5F5F4", // Tailwind's warmGray 100 https://tailwindcss.com/docs/customizing-colors + }, }); -monaco.editor.defineTheme('gitpod-dark', { - base: 'vs-dark', - inherit: true, - rules: [], - colors: { - 'editor.background': '#292524', // Tailwind's warmGray 800 https://tailwindcss.com/docs/customizing-colors - }, +monaco.editor.defineTheme("gitpod-dark", { + base: "vs-dark", + inherit: true, + rules: [], + colors: { + "editor.background": "#292524", // Tailwind's warmGray 800 https://tailwindcss.com/docs/customizing-colors + }, }); -monaco.editor.defineTheme('gitpod-dark-disabled', { - base: 'vs-dark', - inherit: true, - rules: [], - colors: { - 'editor.background': '#44403C', // Tailwind's warmGray 700 https://tailwindcss.com/docs/customizing-colors - }, +monaco.editor.defineTheme("gitpod-dark-disabled", { + base: "vs-dark", + inherit: true, + rules: [], + colors: { + "editor.background": "#44403C", // Tailwind's warmGray 700 https://tailwindcss.com/docs/customizing-colors + }, }); export interface MonacoEditorProps { - classes: string; - disabled?: boolean; - language: string; - value: string; - onChange: (value: string) => void; + classes: string; + disabled?: boolean; + language: string; + value: string; + onChange: (value: string) => void; } export default function MonacoEditor(props: MonacoEditorProps) { - const containerRef = useRef(null); - const editorRef = useRef(); - const { isDark } = useContext(ThemeContext); + const containerRef = useRef(null); + const editorRef = useRef(); + const { isDark } = useContext(ThemeContext); - useEffect(() => { - if (containerRef.current) { - editorRef.current = monaco.editor.create(containerRef.current, { - value: props.value, - language: props.language, - minimap: { - enabled: false, - }, - renderLineHighlight: 'none', - lineNumbers: 'off', - glyphMargin: false, - folding: false, - }); - editorRef.current.onDidChangeModelContent(() => { - props.onChange(editorRef.current!.getValue()); - }); - // 8px top margin: https://github.com/Microsoft/monaco-editor/issues/1333 - editorRef.current.changeViewZones(accessor => { - accessor.addZone({ - afterLineNumber: 0, - heightInPx: 8, - domNode: document.createElement('div'), - }); - }); - } - return () => editorRef.current?.dispose(); - }, []); + useEffect(() => { + if (containerRef.current) { + editorRef.current = monaco.editor.create(containerRef.current, { + value: props.value, + language: props.language, + minimap: { + enabled: false, + }, + renderLineHighlight: "none", + lineNumbers: "off", + glyphMargin: false, + folding: false, + }); + editorRef.current.onDidChangeModelContent(() => { + props.onChange(editorRef.current!.getValue()); + }); + // 8px top margin: https://github.com/Microsoft/monaco-editor/issues/1333 + editorRef.current.changeViewZones((accessor) => { + accessor.addZone({ + afterLineNumber: 0, + heightInPx: 8, + domNode: document.createElement("div"), + }); + }); + } + return () => editorRef.current?.dispose(); + }, [props]); - useEffect(() => { - if (editorRef.current && editorRef.current.getValue() !== props.value) { - editorRef.current.setValue(props.value); - } - }, [ props.value ]); + useEffect(() => { + if (editorRef.current && editorRef.current.getValue() !== props.value) { + editorRef.current.setValue(props.value); + } + }, [props.value]); - useEffect(() => { - monaco.editor.setTheme(props.disabled - ? (isDark ? 'gitpod-dark-disabled' : 'gitpod-disabled') - : (isDark ? 'gitpod-dark' : 'gitpod')); - if (editorRef.current) { - editorRef.current.updateOptions({ readOnly: props.disabled }); - } - }, [ props.disabled, isDark ]); + useEffect(() => { + monaco.editor.setTheme( + props.disabled ? (isDark ? "gitpod-dark-disabled" : "gitpod-disabled") : isDark ? "gitpod-dark" : "gitpod", + ); + if (editorRef.current) { + editorRef.current.updateOptions({ readOnly: props.disabled }); + } + }, [props.disabled, isDark]); - return
; -} \ No newline at end of file + return
; +} diff --git a/components/dashboard/src/components/PendingChangesDropdown.tsx b/components/dashboard/src/components/PendingChangesDropdown.tsx index e96bd7e45e7826..aa081c23a625ab 100644 --- a/components/dashboard/src/components/PendingChangesDropdown.tsx +++ b/components/dashboard/src/components/PendingChangesDropdown.tsx @@ -9,35 +9,45 @@ import ContextMenu, { ContextMenuEntry } from "./ContextMenu"; import CaretDown from "../icons/CaretDown.svg"; export default function PendingChangesDropdown(props: { workspaceInstance?: WorkspaceInstance }) { - const repo = props.workspaceInstance?.status?.repo; - const headingStyle = 'text-gray-500 dark:text-gray-400 text-left'; - const itemStyle = 'text-gray-400 dark:text-gray-500 text-left -mt-5'; - const menuEntries: ContextMenuEntry[] = []; - let totalChanges = 0; - if (repo) { - if ((repo.totalUntrackedFiles || 0) > 0) { - totalChanges += repo.totalUntrackedFiles || 0; - menuEntries.push({ title: 'Untracked Files', customFontStyle: headingStyle }); - (repo.untrackedFiles || []).forEach(item => menuEntries.push({ title: item, customFontStyle: itemStyle })); + const repo = props.workspaceInstance?.status?.repo; + const headingStyle = "text-gray-500 dark:text-gray-400 text-left"; + const itemStyle = "text-gray-400 dark:text-gray-500 text-left -mt-5"; + const menuEntries: ContextMenuEntry[] = []; + let totalChanges = 0; + if (repo) { + if ((repo.totalUntrackedFiles || 0) > 0) { + totalChanges += repo.totalUntrackedFiles || 0; + menuEntries.push({ title: "Untracked Files", customFontStyle: headingStyle }); + (repo.untrackedFiles || []).forEach((item) => + menuEntries.push({ title: item, customFontStyle: itemStyle }), + ); + } + if ((repo.totalUncommitedFiles || 0) > 0) { + totalChanges += repo.totalUncommitedFiles || 0; + menuEntries.push({ title: "Uncommitted Files", customFontStyle: headingStyle }); + (repo.uncommitedFiles || []).forEach((item) => + menuEntries.push({ title: item, customFontStyle: itemStyle }), + ); + } + if ((repo.totalUnpushedCommits || 0) > 0) { + totalChanges += repo.totalUnpushedCommits || 0; + menuEntries.push({ title: "Unpushed Commits", customFontStyle: headingStyle }); + (repo.unpushedCommits || []).forEach((item) => + menuEntries.push({ title: item, customFontStyle: itemStyle }), + ); + } } - if ((repo.totalUncommitedFiles || 0) > 0) { - totalChanges += repo.totalUncommitedFiles || 0; - menuEntries.push({ title: 'Uncommitted Files', customFontStyle: headingStyle }); - (repo.uncommitedFiles || []).forEach(item => menuEntries.push({ title: item, customFontStyle: itemStyle })); + if (totalChanges <= 0) { + return
No Changes
; } - if ((repo.totalUnpushedCommits || 0) > 0) { - totalChanges += repo.totalUnpushedCommits || 0; - menuEntries.push({ title: 'Unpushed Commits', customFontStyle: headingStyle }); - (repo.unpushedCommits || []).forEach(item => menuEntries.push({ title: item, customFontStyle: itemStyle })); - } - } - if (totalChanges <= 0) { - return
No Changes
; - } - return -

- {totalChanges} Change{totalChanges === 1 ? '' : 's'} - -

-
; -} \ No newline at end of file + return ( + +

+ + {totalChanges} Change{totalChanges === 1 ? "" : "s"} + + +

+
+ ); +} diff --git a/components/dashboard/src/components/PrebuildLogs.tsx b/components/dashboard/src/components/PrebuildLogs.tsx index ebdf9048ace1f6..1a9d9f5624f357 100644 --- a/components/dashboard/src/components/PrebuildLogs.tsx +++ b/components/dashboard/src/components/PrebuildLogs.tsx @@ -6,235 +6,258 @@ import EventEmitter from "events"; import React, { Suspense, useEffect, useState } from "react"; -import { Workspace, WorkspaceInstance, DisposableCollection, WorkspaceImageBuild, HEADLESS_LOG_STREAM_STATUS_CODE_REGEX } from "@gitpod/gitpod-protocol"; +import { + Workspace, + WorkspaceInstance, + DisposableCollection, + WorkspaceImageBuild, + HEADLESS_LOG_STREAM_STATUS_CODE_REGEX, +} from "@gitpod/gitpod-protocol"; import { getGitpodService } from "../service/service"; -const WorkspaceLogs = React.lazy(() => import('./WorkspaceLogs')); +const WorkspaceLogs = React.lazy(() => import("./WorkspaceLogs")); export interface PrebuildLogsProps { - workspaceId?: string; - onInstanceUpdate?: (instance: WorkspaceInstance) => void; + workspaceId?: string; + onInstanceUpdate?: (instance: WorkspaceInstance) => void; } export default function PrebuildLogs(props: PrebuildLogsProps) { - const [ workspace, setWorkspace ] = useState(); - const [ workspaceInstance, setWorkspaceInstance ] = useState(); - const [ error, setError ] = useState(); - const [ logsEmitter ] = useState(new EventEmitter()); + const [workspace, setWorkspace] = useState(); + const [workspaceInstance, setWorkspaceInstance] = useState(); + const [error, setError] = useState(); + const [logsEmitter] = useState(new EventEmitter()); - useEffect(() => { - const disposables = new DisposableCollection(); - setWorkspaceInstance(undefined); - (async () => { - if (!props.workspaceId) { - return; - } - try { - const info = await getGitpodService().server.getWorkspace(props.workspaceId); - if (info.latestInstance) { - setWorkspace(info.workspace); - setWorkspaceInstance(info.latestInstance); - } - disposables.push(getGitpodService().registerClient({ - onInstanceUpdate: (instance) => { - if (props.workspaceId === instance.workspaceId) { - setWorkspaceInstance(instance); + useEffect(() => { + const disposables = new DisposableCollection(); + setWorkspaceInstance(undefined); + (async () => { + if (!props.workspaceId) { + return; } - }, - onWorkspaceImageBuildLogs: (info: WorkspaceImageBuild.StateInfo, content?: WorkspaceImageBuild.LogContent) => { - if (!content) { - return; + try { + const info = await getGitpodService().server.getWorkspace(props.workspaceId); + if (info.latestInstance) { + setWorkspace(info.workspace); + setWorkspaceInstance(info.latestInstance); + } + disposables.push( + getGitpodService().registerClient({ + onInstanceUpdate: (instance) => { + if (props.workspaceId === instance.workspaceId) { + setWorkspaceInstance(instance); + } + }, + onWorkspaceImageBuildLogs: ( + info: WorkspaceImageBuild.StateInfo, + content?: WorkspaceImageBuild.LogContent, + ) => { + if (!content) { + return; + } + logsEmitter.emit("logs", content.text); + }, + }), + ); + if (info.latestInstance) { + disposables.push( + watchHeadlessLogs( + info.latestInstance.id, + (chunk) => { + logsEmitter.emit("logs", chunk); + }, + async () => workspaceInstance?.status.phase === "stopped", + ), + ); + } + } catch (err) { + console.error(err); + setError(err); } - logsEmitter.emit('logs', content.text); - }, - })); - if (info.latestInstance) { - disposables.push(watchHeadlessLogs(info.latestInstance.id, chunk => { - logsEmitter.emit('logs', chunk); - }, async () => workspaceInstance?.status.phase === 'stopped')); + })(); + return function cleanUp() { + disposables.dispose(); + }; + }, [logsEmitter, props.workspaceId, workspaceInstance?.status.phase]); + + useEffect(() => { + if (props.onInstanceUpdate && workspaceInstance) { + props.onInstanceUpdate(workspaceInstance); } - } catch (err) { - console.error(err); - setError(err); - } - })(); - return function cleanUp() { - disposables.dispose(); - } - }, [ props.workspaceId ]); + switch (workspaceInstance?.status.phase) { + // unknown indicates an issue within the system in that it cannot determine the actual phase of + // a workspace. This phase is usually accompanied by an error. + case "unknown": + break; - useEffect(() => { - if (props.onInstanceUpdate && workspaceInstance) { - props.onInstanceUpdate(workspaceInstance); - } - switch (workspaceInstance?.status.phase) { - // unknown indicates an issue within the system in that it cannot determine the actual phase of - // a workspace. This phase is usually accompanied by an error. - case "unknown": - break; - - // Preparing means that we haven't actually started the workspace instance just yet, but rather - // are still preparing for launch. This means we're building the Docker image for the workspace. - case "preparing": - getGitpodService().server.watchWorkspaceImageBuildLogs(workspace!.id); - break; - - // Pending means the workspace does not yet consume resources in the cluster, but rather is looking for - // some space within the cluster. If for example the cluster needs to scale up to accomodate the - // workspace, the workspace will be in Pending state until that happened. - case "pending": - break; - - // Creating means the workspace is currently being created. That includes downloading the images required - // to run the workspace over the network. The time spent in this phase varies widely and depends on the current - // network speed, image size and cache states. - case "creating": - break; - - // Initializing is the phase in which the workspace is executing the appropriate workspace initializer (e.g. Git - // clone or backup download). After this phase one can expect the workspace to either be Running or Failed. - case "initializing": - break; - - // Running means the workspace is able to actively perform work, either by serving a user through Theia, - // or as a headless workspace. - case "running": - break; - - // Interrupted is an exceptional state where the container should be running but is temporarily unavailable. - // When in this state, we expect it to become running or stopping anytime soon. - case "interrupted": - break; - - // Stopping means that the workspace is currently shutting down. It could go to stopped every moment. - case "stopping": - break; - - // Stopped means the workspace ended regularly because it was shut down. - case "stopped": - getGitpodService().server.watchWorkspaceImageBuildLogs(workspace!.id); - break; - } - if (workspaceInstance?.status.conditions.headlessTaskFailed) { - setError(new Error(workspaceInstance.status.conditions.headlessTaskFailed)); - } - if (workspaceInstance?.status.conditions.failed) { - setError(new Error(workspaceInstance.status.conditions.failed)); - } - }, [ props.workspaceId, workspaceInstance?.status.phase ]); + // Preparing means that we haven't actually started the workspace instance just yet, but rather + // are still preparing for launch. This means we're building the Docker image for the workspace. + case "preparing": + getGitpodService().server.watchWorkspaceImageBuildLogs(workspace!.id); + break; + + // Pending means the workspace does not yet consume resources in the cluster, but rather is looking for + // some space within the cluster. If for example the cluster needs to scale up to accomodate the + // workspace, the workspace will be in Pending state until that happened. + case "pending": + break; + + // Creating means the workspace is currently being created. That includes downloading the images required + // to run the workspace over the network. The time spent in this phase varies widely and depends on the current + // network speed, image size and cache states. + case "creating": + break; - return }> - - ; + // Initializing is the phase in which the workspace is executing the appropriate workspace initializer (e.g. Git + // clone or backup download). After this phase one can expect the workspace to either be Running or Failed. + case "initializing": + break; + + // Running means the workspace is able to actively perform work, either by serving a user through Theia, + // or as a headless workspace. + case "running": + break; + + // Interrupted is an exceptional state where the container should be running but is temporarily unavailable. + // When in this state, we expect it to become running or stopping anytime soon. + case "interrupted": + break; + + // Stopping means that the workspace is currently shutting down. It could go to stopped every moment. + case "stopping": + break; + + // Stopped means the workspace ended regularly because it was shut down. + case "stopped": + getGitpodService().server.watchWorkspaceImageBuildLogs(workspace!.id); + break; + } + if (workspaceInstance?.status.conditions.headlessTaskFailed) { + setError(new Error(workspaceInstance.status.conditions.headlessTaskFailed)); + } + if (workspaceInstance?.status.conditions.failed) { + setError(new Error(workspaceInstance.status.conditions.failed)); + } + }, [props, props.workspaceId, workspace, workspaceInstance, workspaceInstance?.status.phase]); + + return ( + }> + + + ); } -export function watchHeadlessLogs(instanceId: string, onLog: (chunk: string) => void, checkIsDone: () => Promise): DisposableCollection { - const disposables = new DisposableCollection(); +export function watchHeadlessLogs( + instanceId: string, + onLog: (chunk: string) => void, + checkIsDone: () => Promise, +): DisposableCollection { + const disposables = new DisposableCollection(); - const startWatchingLogs = async () => { - if (await checkIsDone()) { - return; - } + const startWatchingLogs = async () => { + if (await checkIsDone()) { + return; + } - const initialDelaySeconds = 1; - let delayInSeconds = initialDelaySeconds; - const retryBackoff = async (reason: string, err?: Error) => { - const backoffFactor = 1.2; - const maxBackoffSeconds = 5; - delayInSeconds = Math.min(delayInSeconds * backoffFactor, maxBackoffSeconds); - - console.debug("re-trying headless-logs because: " + reason, err); - await new Promise((resolve) => { - setTimeout(resolve, delayInSeconds * 1000); - }); - startWatchingLogs().catch(console.error); - }; + const initialDelaySeconds = 1; + let delayInSeconds = initialDelaySeconds; + const retryBackoff = async (reason: string, err?: Error) => { + const backoffFactor = 1.2; + const maxBackoffSeconds = 5; + delayInSeconds = Math.min(delayInSeconds * backoffFactor, maxBackoffSeconds); - let response: Response | undefined = undefined; - let reader: ReadableStreamDefaultReader | undefined = undefined; - try { - const logSources = await getGitpodService().server.getHeadlessLog(instanceId); - // TODO(gpl) Only listening on first stream for now - const streamIds = Object.keys(logSources.streams); - if (streamIds.length < 1) { - await retryBackoff("no streams"); - return; - } - - const streamUrl = logSources.streams[streamIds[0]]; - console.log("fetching from streamUrl: " + streamUrl); - response = await fetch(streamUrl, { - method: 'GET', - cache: 'no-cache', - credentials: 'include', - keepalive: true, - headers: { - 'TE': 'trailers', // necessary to receive stream status code - }, - }); - reader = response.body?.getReader(); - if (!reader) { - await retryBackoff("no reader"); - return; - } - disposables.push({ dispose: () => reader?.cancel() }); - - const decoder = new TextDecoder('utf-8'); - let chunk = await reader.read(); - while (!chunk.done) { - const msg = decoder.decode(chunk.value, { stream: true }); - - // In an ideal world, we'd use res.addTrailers()/response.trailer here. But despite being introduced with HTTP/1.1 in 1999, trailers are not supported by popular proxies (nginx, for example). - // So we resort to this hand-written solution: - const matches = msg.match(HEADLESS_LOG_STREAM_STATUS_CODE_REGEX); - if (matches) { - if (matches.length < 2) { - console.debug("error parsing log stream status code. msg: " + msg); - } else { - const code = parseStatusCode(matches[1]); - if (code !== 200) { - throw new StreamError(code); + console.debug("re-trying headless-logs because: " + reason, err); + await new Promise((resolve) => { + setTimeout(resolve, delayInSeconds * 1000); + }); + startWatchingLogs().catch(console.error); + }; + + let response: Response | undefined = undefined; + let reader: ReadableStreamDefaultReader | undefined = undefined; + try { + const logSources = await getGitpodService().server.getHeadlessLog(instanceId); + // TODO(gpl) Only listening on first stream for now + const streamIds = Object.keys(logSources.streams); + if (streamIds.length < 1) { + await retryBackoff("no streams"); + return; } - } - } else { - onLog(msg); - } - chunk = await reader.read(); - } - reader.cancel() - - if (await checkIsDone()) { - return; - } - } catch(err) { - reader?.cancel().catch(console.debug); - if (err.code === 400) { - // sth is really off, and we _should not_ retry - console.error("stopped watching headless logs", err); - return; - } - await retryBackoff("error while listening to stream", err); - } - }; - startWatchingLogs().catch(console.error); + const streamUrl = logSources.streams[streamIds[0]]; + console.log("fetching from streamUrl: " + streamUrl); + response = await fetch(streamUrl, { + method: "GET", + cache: "no-cache", + credentials: "include", + keepalive: true, + headers: { + TE: "trailers", // necessary to receive stream status code + }, + }); + reader = response.body?.getReader(); + if (!reader) { + await retryBackoff("no reader"); + return; + } + disposables.push({ dispose: () => reader?.cancel() }); + + const decoder = new TextDecoder("utf-8"); + let chunk = await reader.read(); + while (!chunk.done) { + const msg = decoder.decode(chunk.value, { stream: true }); + + // In an ideal world, we'd use res.addTrailers()/response.trailer here. But despite being introduced with HTTP/1.1 in 1999, trailers are not supported by popular proxies (nginx, for example). + // So we resort to this hand-written solution: + const matches = msg.match(HEADLESS_LOG_STREAM_STATUS_CODE_REGEX); + if (matches) { + if (matches.length < 2) { + console.debug("error parsing log stream status code. msg: " + msg); + } else { + const code = parseStatusCode(matches[1]); + if (code !== 200) { + throw new StreamError(code); + } + } + } else { + onLog(msg); + } + + chunk = await reader.read(); + } + reader.cancel(); + + if (await checkIsDone()) { + return; + } + } catch (err) { + reader?.cancel().catch(console.debug); + if (err.code === 400) { + // sth is really off, and we _should not_ retry + console.error("stopped watching headless logs", err); + return; + } + await retryBackoff("error while listening to stream", err); + } + }; + startWatchingLogs().catch(console.error); - return disposables; + return disposables; } class StreamError extends Error { - constructor(readonly code?: number) { - super(`stream status code: ${code}`) - } + constructor(readonly code?: number) { + super(`stream status code: ${code}`); + } } function parseStatusCode(code: string | undefined): number | undefined { - try { - if (!code) { - return undefined; + try { + if (!code) { + return undefined; + } + return Number.parseInt(code); + } catch (err) { + return undefined; } - return Number.parseInt(code); - } catch(err) { - return undefined; - } -} \ No newline at end of file +} diff --git a/components/dashboard/src/components/RepositoryFinder.tsx b/components/dashboard/src/components/RepositoryFinder.tsx index 21fadab0cb42ea..cca3afe0df2f5f 100644 --- a/components/dashboard/src/components/RepositoryFinder.tsx +++ b/components/dashboard/src/components/RepositoryFinder.tsx @@ -5,41 +5,44 @@ */ import { User } from "@gitpod/gitpod-protocol"; -import React, { useContext, useEffect, useState } from "react"; +import React, { useCallback, useContext, useEffect, useState } from "react"; import { getGitpodService } from "../service/service"; import { UserContext } from "../user-context"; type SearchResult = string; type SearchData = SearchResult[]; -const LOCAL_STORAGE_KEY = 'open-in-gitpod-search-data'; +const LOCAL_STORAGE_KEY = "open-in-gitpod-search-data"; const MAX_DISPLAYED_ITEMS = 20; export default function RepositoryFinder(props: { initialQuery?: string }) { const { user } = useContext(UserContext); - const [searchQuery, setSearchQuery] = useState(props.initialQuery || ''); + const [searchQuery, setSearchQuery] = useState(props.initialQuery || ""); const [searchResults, setSearchResults] = useState([]); const [selectedSearchResult, setSelectedSearchResult] = useState(); - const onResults = (results: SearchResult[]) => { - if (JSON.stringify(results) !== JSON.stringify(searchResults)) { - setSearchResults(results); - setSelectedSearchResult(results[0]); - } - } + const search = useCallback( + async (query: string) => { + const onResults = (results: SearchResult[]) => { + if (JSON.stringify(results) !== JSON.stringify(searchResults)) { + setSearchResults(results); + setSelectedSearchResult(results[0]); + } + }; - const search = async (query: string) => { - setSearchQuery(query); - await findResults(query, onResults); - if (await refreshSearchData(query, user)) { - // Re-run search if the underlying search data has changed + setSearchQuery(query); await findResults(query, onResults); - } - } + if (await refreshSearchData(query, user)) { + // Re-run search if the underlying search data has changed + await findResults(query, onResults); + } + }, + [searchResults, user], + ); useEffect(() => { - search(''); - }, []); + search(""); + }, [search]); // Up/Down keyboard navigation between results const onKeyDown = (event: React.KeyboardEvent) => { @@ -52,55 +55,86 @@ export default function RepositoryFinder(props: { initialQuery?: string }) { // Source: https://stackoverflow.com/a/4467559/3461173 const n = Math.min(searchResults.length, MAX_DISPLAYED_ITEMS); setSelectedSearchResult(searchResults[((index % n) + n) % n]); - } - if (event.key === 'ArrowDown') { + }; + if (event.key === "ArrowDown") { event.preventDefault(); select(selectedIndex + 1); return; } - if (event.key === 'ArrowUp') { + if (event.key === "ArrowUp") { event.preventDefault(); select(selectedIndex - 1); return; } - } + }; useEffect(() => { const element = document.querySelector(`a[href='/#${selectedSearchResult}']`); if (element) { - element.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + element.scrollIntoView({ behavior: "smooth", block: "nearest" }); } }, [selectedSearchResult]); const onSubmit = (event: React.FormEvent) => { event.preventDefault(); if (selectedSearchResult) { - window.location.href = '/#' + selectedSearchResult; + window.location.href = "/#" + selectedSearchResult; } - } - - return
-
-
- + }; + + return ( + +
+
+ + + +
+ search(e.target.value)} + onKeyDown={onKeyDown} + />
- search(e.target.value)} onKeyDown={onKeyDown} /> -
-
- {searchResults.slice(0, MAX_DISPLAYED_ITEMS).map((result, index) => - setSelectedSearchResult(result)}> - {searchQuery.length < 2 - ? {result} - : result.split(searchQuery).map((segment, index) => - {index === 0 ? <> : {searchQuery}} - {segment} - )} - - )} - {searchResults.length > MAX_DISPLAYED_ITEMS && - {searchResults.length - MAX_DISPLAYED_ITEMS} more result{(searchResults.length - MAX_DISPLAYED_ITEMS) === 1 ? '' : 's'} found} -
- ; +
+ {searchResults.slice(0, MAX_DISPLAYED_ITEMS).map((result, index) => ( + setSelectedSearchResult(result)} + > + {searchQuery.length < 2 ? ( + {result} + ) : ( + result.split(searchQuery).map((segment, index) => ( + + {index === 0 ? <> : {searchQuery}} + {segment} + + )) + )} + + ))} + {searchResults.length > MAX_DISPLAYED_ITEMS && ( + + {searchResults.length - MAX_DISPLAYED_ITEMS} more result + {searchResults.length - MAX_DISPLAYED_ITEMS === 1 ? "" : "s"} found + + )} +
+ + ); } function loadSearchData(): SearchData { @@ -112,7 +146,7 @@ function loadSearchData(): SearchData { const data = JSON.parse(string); return data; } catch (error) { - console.warn('Could not load search data from local storage', error); + console.warn("Could not load search data from local storage", error); return []; } } @@ -121,7 +155,7 @@ function saveSearchData(searchData: SearchData): void { try { window.localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(searchData)); } catch (error) { - console.warn('Could not save search data into local storage', error); + console.warn("Could not save search data into local storage", error); } } @@ -139,11 +173,11 @@ export async function refreshSearchData(query: string, user: User | undefined): // Fetch all possible search results and cache them into local storage async function actuallyRefreshSearchData(query: string, user: User | undefined): Promise { - console.log('refreshing search data'); + console.log("refreshing search data"); const oldData = loadSearchData(); const newData = await getGitpodService().server.getSuggestedContextURLs(); if (JSON.stringify(oldData) !== JSON.stringify(newData)) { - console.log('new data:', newData); + console.log("new data:", newData); saveSearchData(newData); return true; } @@ -158,8 +192,7 @@ async function findResults(query: string, onResults: (results: string[]) => void if (!searchData.includes(query)) { searchData.push(query); } - } catch { - } + } catch {} // console.log('searching', query, 'in', searchData); - onResults(searchData.filter(result => result.toLowerCase().includes(query.toLowerCase()))); + onResults(searchData.filter((result) => result.toLowerCase().includes(query.toLowerCase()))); } diff --git a/components/dashboard/src/components/WorkspaceLogs.tsx b/components/dashboard/src/components/WorkspaceLogs.tsx index 8301b2db75598b..c7b67538c367cc 100644 --- a/components/dashboard/src/components/WorkspaceLogs.tsx +++ b/components/dashboard/src/components/WorkspaceLogs.tsx @@ -4,88 +4,94 @@ * See License-AGPL.txt in the project root for license information. */ -import EventEmitter from 'events'; -import { useContext, useEffect, useRef } from 'react'; -import { Terminal, ITerminalOptions, ITheme } from 'xterm'; -import { FitAddon } from 'xterm-addon-fit' -import 'xterm/css/xterm.css'; -import { ThemeContext } from '../theme-context'; +import EventEmitter from "events"; +import { useContext, useEffect, useMemo, useRef } from "react"; +import { Terminal, ITerminalOptions, ITheme } from "xterm"; +import { FitAddon } from "xterm-addon-fit"; +import "xterm/css/xterm.css"; +import { ThemeContext } from "../theme-context"; const darkTheme: ITheme = { - background: '#292524', // Tailwind's warmGray 800 https://tailwindcss.com/docs/customizing-colors + background: "#292524", // Tailwind's warmGray 800 https://tailwindcss.com/docs/customizing-colors }; const lightTheme: ITheme = { - background: '#F5F5F4', // Tailwind's warmGray 100 https://tailwindcss.com/docs/customizing-colors - foreground: '#78716C', // Tailwind's warmGray 500 https://tailwindcss.com/docs/customizing-colors - cursor: '#78716C', // Tailwind's warmGray 500 https://tailwindcss.com/docs/customizing-colors -} + background: "#F5F5F4", // Tailwind's warmGray 100 https://tailwindcss.com/docs/customizing-colors + foreground: "#78716C", // Tailwind's warmGray 500 https://tailwindcss.com/docs/customizing-colors + cursor: "#78716C", // Tailwind's warmGray 500 https://tailwindcss.com/docs/customizing-colors +}; export interface WorkspaceLogsProps { - logsEmitter: EventEmitter; - errorMessage?: string; - classes?: string; + logsEmitter: EventEmitter; + errorMessage?: string; + classes?: string; } export default function WorkspaceLogs(props: WorkspaceLogsProps) { - const xTermParentRef = useRef(null); - const terminalRef = useRef(); - const fitAddon = new FitAddon(); - const { isDark } = useContext(ThemeContext); + const xTermParentRef = useRef(null); + const terminalRef = useRef(); + const fitAddon = useMemo(() => new FitAddon(), []); + const { isDark } = useContext(ThemeContext); - useEffect(() => { - if (!xTermParentRef.current) { - return; - } - const options: ITerminalOptions = { - cursorBlink: false, - disableStdin: true, - fontSize: 14, - theme: darkTheme, - scrollback: 9999999, - }; - const terminal = new Terminal(options); - terminalRef.current = terminal; - terminal.loadAddon(fitAddon); - terminal.open(xTermParentRef.current); - props.logsEmitter.on('logs', logs => { - if (terminal && logs) { - terminal.write(logs); - } - }); - fitAddon.fit(); - return function cleanUp() { - terminal.dispose(); - } - }, []); + useEffect(() => { + if (!xTermParentRef.current) { + return; + } + const options: ITerminalOptions = { + cursorBlink: false, + disableStdin: true, + fontSize: 14, + theme: darkTheme, + scrollback: 9999999, + }; + const terminal = new Terminal(options); + terminalRef.current = terminal; + terminal.loadAddon(fitAddon); + terminal.open(xTermParentRef.current); + props.logsEmitter.on("logs", (logs) => { + if (terminal && logs) { + terminal.write(logs); + } + }); + fitAddon.fit(); + return function cleanUp() { + terminal.dispose(); + }; + }, [fitAddon, props.logsEmitter]); - useEffect(() => { - // Fit terminal on window resize (debounced) - let timeout: NodeJS.Timeout | undefined; - const onWindowResize = () => { - clearTimeout(timeout!); - timeout = setTimeout(() => fitAddon.fit(), 20); - }; - window.addEventListener('resize', onWindowResize); - return function cleanUp() { - clearTimeout(timeout!); - window.removeEventListener('resize', onWindowResize); - } - }, []); + useEffect(() => { + // Fit terminal on window resize (debounced) + let timeout: NodeJS.Timeout | undefined; + const onWindowResize = () => { + clearTimeout(timeout!); + timeout = setTimeout(() => fitAddon.fit(), 20); + }; + window.addEventListener("resize", onWindowResize); + return function cleanUp() { + clearTimeout(timeout!); + window.removeEventListener("resize", onWindowResize); + }; + }, [fitAddon]); - useEffect(() => { - if (terminalRef.current && props.errorMessage) { - terminalRef.current.write(`\r\n\u001b[38;5;196m${props.errorMessage}\u001b[0m\r\n`); - } - }, [ terminalRef.current, props.errorMessage ]); + useEffect(() => { + if (terminalRef.current && props.errorMessage) { + terminalRef.current.write(`\r\n\u001b[38;5;196m${props.errorMessage}\u001b[0m\r\n`); + } + }, [props.errorMessage]); - useEffect(() => { - if (!terminalRef.current) { - return; - } - terminalRef.current.setOption('theme', isDark ? darkTheme : lightTheme); - }, [ terminalRef.current, isDark ]); + useEffect(() => { + if (!terminalRef.current) { + return; + } + terminalRef.current.setOption("theme", isDark ? darkTheme : lightTheme); + }, [isDark]); - return
-
-
; + return ( +
+
+
+ ); } diff --git a/components/dashboard/src/experiments.ts b/components/dashboard/src/experiments.ts index 1270a0d861a72a..d13c5b130708ff 100644 --- a/components/dashboard/src/experiments.ts +++ b/components/dashboard/src/experiments.ts @@ -27,11 +27,18 @@ const Experiments = { /** * Experiment "example" will be activate on login for 10% of all clients. */ - "example": 0.1, + example: 0.1, }; + +// TODO: Both Experiment and Experiments are used for both code and typings here. +// Not sure if it's safe to rename either, so I'm disabling linting while +// I wait for Gero Posmyk-Leinemann to chime in. + +// eslint-disable-next-line @typescript-eslint/no-redeclare type Experiments = Partial<{ [e in Experiment]: boolean }>; -export type Experiment = keyof (typeof Experiments); +export type Experiment = keyof typeof Experiments; +// eslint-disable-next-line @typescript-eslint/no-redeclare export namespace Experiment { /** * Randomly decides what the set of Experiments is the user participates in @@ -93,4 +100,4 @@ export namespace Experiment { return undefined; } } -} \ No newline at end of file +} diff --git a/components/dashboard/src/index.css b/components/dashboard/src/index.css index 7e5035bb1b0689..08069dd2c720f8 100644 --- a/components/dashboard/src/index.css +++ b/components/dashboard/src/index.css @@ -9,7 +9,8 @@ @tailwind utilities; @layer base { - html, body { + html, + body { @apply h-full; } body { @@ -61,30 +62,42 @@ @apply cursor-default opacity-50 pointer-events-none; } - a.gp-link { + button.gp-link { + @apply bg-transparent hover:bg-transparent p-0 rounded-none; + } + + a.gp-link, + button.gp-link { @apply text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-500; } - input[type=text], input[type=search], input[type=password], select { + input[type="text"], + input[type="search"], + input[type="password"], + select { @apply block w-56 text-gray-600 dark:text-gray-400 bg-white dark:bg-gray-800 rounded-md border border-gray-300 dark:border-gray-500 focus:border-gray-400 dark:focus:border-gray-400 focus:ring-0; } - input[type=text]::placeholder, input[type=search]::placeholder, input[type=password]::placeholder { + input[type="text"]::placeholder, + input[type="search"]::placeholder, + input[type="password"]::placeholder { @apply text-gray-400 dark:text-gray-500; } - input[type=text].error, input[type=password].error, select.error { + input[type="text"].error, + input[type="password"].error, + select.error { @apply border-gitpod-red dark:border-gitpod-red focus:border-gitpod-red dark:focus:border-gitpod-red; } input[disabled] { @apply bg-gray-100 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 text-gray-400 dark:text-gray-500; } - input[type=radio] { + input[type="radio"] { @apply border border-gray-300 focus:border-gray-400 focus:bg-white focus:ring-0; } - input[type=search] { + input[type="search"] { @apply border-0 dark:bg-transparent; } - input[type=checkbox] { - @apply disabled:opacity-50 + input[type="checkbox"] { + @apply disabled:opacity-50; } progress { @@ -99,4 +112,4 @@ progress::-moz-progress-bar { @apply rounded-md bg-green-500; } -} \ No newline at end of file +} diff --git a/components/dashboard/src/projects/NewProject.tsx b/components/dashboard/src/projects/NewProject.tsx index 238b32b5b5a8db..e010c388d8a5a4 100644 --- a/components/dashboard/src/projects/NewProject.tsx +++ b/components/dashboard/src/projects/NewProject.tsx @@ -4,7 +4,7 @@ * See License-AGPL.txt in the project root for license information. */ -import { useContext, useEffect, useState } from "react"; +import { useCallback, useContext, useEffect, useState } from "react"; import { getGitpodService, gitpodHostUrl } from "../service/service"; import { iconForAuthProvider, openAuthorizeWindow, simplifyProviderName } from "../provider-utils"; import { AuthProviderInfo, Project, ProviderRepository, Team, TeamMemberInfo, User } from "@gitpod/gitpod-protocol"; @@ -42,49 +42,74 @@ export default function NewProject() { const [authProviders, setAuthProviders] = useState([]); + const updateReposInAccounts = useCallback( + async (installationId?: string) => { + setLoaded(false); + setReposInAccounts([]); + if (!selectedProviderHost) { + return []; + } + try { + const repos = await getGitpodService().server.getProviderRepositoriesForUser({ + provider: selectedProviderHost, + hints: { installationId }, + }); + setReposInAccounts(repos); + setLoaded(true); + return repos; + } catch (error) { + console.log(error); + } + return []; + }, + [selectedProviderHost], + ); + useEffect(() => { if (user && selectedProviderHost === undefined) { - if (user.identities.find(i => i.authProviderId === "Public-GitLab")) { + if (user.identities.find((i) => i.authProviderId === "Public-GitLab")) { setSelectedProviderHost("gitlab.com"); - } else if (user.identities.find(i => i.authProviderId === "Public-GitHub")) { + } else if (user.identities.find((i) => i.authProviderId === "Public-GitHub")) { setSelectedProviderHost("github.com"); - } else if (user.identities.find(i => i.authProviderId === "Public-Bitbucket")) { + } else if (user.identities.find((i) => i.authProviderId === "Public-Bitbucket")) { setSelectedProviderHost("bitbucket.org"); } (async () => { setAuthProviders(await getGitpodService().server.getAuthProviders()); })(); } - }, [user]); + }, [selectedProviderHost, user]); useEffect(() => { const params = new URLSearchParams(location.search); const teamParam = params.get("team"); if (teamParam) { - window.history.replaceState({}, '', window.location.pathname); - const team = teams?.find(t => t.slug === teamParam); + window.history.replaceState({}, "", window.location.pathname); + const team = teams?.find((t) => t.slug === teamParam); setSelectedTeamOrUser(team); } if (params.get("user")) { - window.history.replaceState({}, '', window.location.pathname); + window.history.replaceState({}, "", window.location.pathname); setSelectedTeamOrUser(user); } - }, []); + }, [location.search, teams, user]); - const [ teamMembers, setTeamMembers ] = useState>({}); + const [teamMembers, setTeamMembers] = useState>({}); useEffect(() => { if (!teams) { return; } (async () => { const members: Record = {}; - await Promise.all(teams.map(async (team) => { - try { - members[team.id] = await getGitpodService().server.getTeamMembers(team.id); - } catch (error) { - console.error('Could not get members of team', team, error); - } - })); + await Promise.all( + teams.map(async (team) => { + try { + members[team.id] = await getGitpodService().server.getTeamMembers(team.id); + } catch (error) { + console.error("Could not get members of team", team, error); + } + }), + ); setTeamMembers(members); })(); }, [teams]); @@ -92,34 +117,63 @@ export default function NewProject() { useEffect(() => { if (selectedRepo) { (async () => { - try { - const guessedConfigStringPromise = getGitpodService().server.guessRepositoryConfiguration(selectedRepo.cloneUrl); - const repoConfigString = await getGitpodService().server.fetchRepositoryConfiguration(selectedRepo.cloneUrl); + const guessedConfigStringPromise = getGitpodService().server.guessRepositoryConfiguration( + selectedRepo.cloneUrl, + ); + const repoConfigString = await getGitpodService().server.fetchRepositoryConfiguration( + selectedRepo.cloneUrl, + ); if (repoConfigString) { setSourceOfConfig("repo"); } else { - setGuessedConfigString(await guessedConfigStringPromise || `tasks: + setGuessedConfigString( + (await guessedConfigStringPromise) || + `tasks: - init: | echo 'TODO: build project' command: | - echo 'TODO: start app'`); + echo 'TODO: start app'`, + ); setSourceOfConfig("db"); } } catch (error) { - console.error('Getting project configuration failed', error); + console.error("Getting project configuration failed", error); setSourceOfConfig(undefined); } - })(); } }, [selectedRepo]); useEffect(() => { + const createProject = async (teamOrUser: Team | User, repo: ProviderRepository) => { + if (!selectedProviderHost) { + return; + } + const repoSlug = repo.path || repo.name; + + try { + const project = await getGitpodService().server.createProject({ + name: repo.name, + slug: repoSlug, + cloneUrl: repo.cloneUrl, + account: repo.account, + provider: selectedProviderHost, + ...(User.is(teamOrUser) ? { userId: teamOrUser.id } : { teamId: teamOrUser.id }), + appInstallationId: String(repo.installationId), + }); + + setProject(project); + } catch (error) { + const message = (error && error?.message) || "Failed to create new project."; + window.alert(message); + } + }; + if (selectedTeamOrUser && selectedRepo) { createProject(selectedTeamOrUser, selectedRepo); } - }, [selectedTeamOrUser, selectedRepo]); + }, [selectedTeamOrUser, selectedRepo, selectedProviderHost]); useEffect(() => { if (reposInAccounts.length === 0) { @@ -127,13 +181,14 @@ export default function NewProject() { } else { const first = reposInAccounts[0]; if (!!first.installationUpdatedAt) { - const mostRecent = reposInAccounts.reduce((prev, current) => (prev.installationUpdatedAt || 0) > (current.installationUpdatedAt || 0) ? prev : current); + const mostRecent = reposInAccounts.reduce((prev, current) => + (prev.installationUpdatedAt || 0) > (current.installationUpdatedAt || 0) ? prev : current, + ); setSelectedAccount(mostRecent.account); } else { setSelectedAccount(first.account); } } - }, [reposInAccounts]); useEffect(() => { @@ -147,7 +202,7 @@ export default function NewProject() { (async () => { await updateReposInAccounts(); })(); - }, [selectedProviderHost]); + }, [selectedProviderHost, updateReposInAccounts]); useEffect(() => { if (project && sourceOfConfig) { @@ -158,63 +213,22 @@ export default function NewProject() { await getGitpodService().server.triggerPrebuild(project.id, null); })(); } - }, [project, sourceOfConfig]); + }, [guessedConfigString, project, sourceOfConfig]); const isGitHub = () => selectedProviderHost === "github.com"; - const updateReposInAccounts = async (installationId?: string) => { - setLoaded(false); - setReposInAccounts([]); - if (!selectedProviderHost) { - return []; - } - try { - const repos = await getGitpodService().server.getProviderRepositoriesForUser({ provider: selectedProviderHost, hints: { installationId } }); - setReposInAccounts(repos); - setLoaded(true); - return repos; - } catch (error) { - console.log(error); - } - return []; - } - const reconfigure = () => { openReconfigureWindow({ account: selectedAccount, - onSuccess: (p: { installationId: string, setupAction?: string }) => { + onSuccess: (p: { installationId: string; setupAction?: string }) => { updateReposInAccounts(p.installationId); trackEvent("organisation_authorised", { installation_id: p.installationId, - setup_action: p.setupAction + setup_action: p.setupAction, }); - } + }, }); - } - - const createProject = async (teamOrUser: Team | User, repo: ProviderRepository) => { - if (!selectedProviderHost) { - return; - } - const repoSlug = repo.path || repo.name; - - try { - const project = await getGitpodService().server.createProject({ - name: repo.name, - slug: repoSlug, - cloneUrl: repo.cloneUrl, - account: repo.account, - provider: selectedProviderHost, - ...(User.is(teamOrUser) ? { userId: teamOrUser.id } : { teamId: teamOrUser.id }), - appInstallationId: String(repo.installationId), - }); - - setProject(project); - } catch (error) { - const message = (error && error?.message) || "Failed to create new project." - window.alert(message); - } - } + }; const toSimpleName = (fullName: string) => { const splitted = fullName.split("/"); @@ -222,16 +236,20 @@ export default function NewProject() { return fullName; } return splitted.shift() && splitted.join("/"); - } + }; const accounts = new Map(); - reposInAccounts.forEach(r => { if (!accounts.has(r.account)) accounts.set(r.account, { avatarUrl: r.accountAvatarUrl }) }); + reposInAccounts.forEach((r) => { + if (!accounts.has(r.account)) accounts.set(r.account, { avatarUrl: r.accountAvatarUrl }); + }); const getDropDownEntries = (accounts: Map) => { - const renderItemContent = (label: string, icon: string, addClasses?: string) => (
- - {label} -
) + const renderItemContent = (label: string, icon: string, addClasses?: string) => ( +
+ + {label} +
+ ); const result: ContextMenuEntry[] = []; if (!selectedAccount && user && user.name && user.avatarUrl) { @@ -239,7 +257,7 @@ export default function NewProject() { title: "user", customContent: renderItemContent(user?.name, user?.avatarUrl), separator: true, - }) + }); } for (const [account, props] of accounts.entries()) { result.push({ @@ -247,7 +265,7 @@ export default function NewProject() { customContent: renderItemContent(account, props.avatarUrl, "font-semibold"), separator: true, onClick: () => setSelectedAccount(account), - }) + }); } if (isGitHub()) { result.push({ @@ -255,114 +273,195 @@ export default function NewProject() { customContent: renderItemContent("Add GitHub Orgs or Account", Plus), separator: true, onClick: () => reconfigure(), - }) + }); } result.push({ title: "Select another Git Provider to continue with", customContent: renderItemContent("Select Git Provider", Switch), onClick: () => setShowGitProviders(true), - }) + }); return result; - } + }; const renderSelectRepository = () => { - const noReposAvailable = reposInAccounts.length === 0; - const filteredRepos = Array.from(reposInAccounts).filter(r => r.account === selectedAccount && `${r.name}`.toLowerCase().includes(repoSearchFilter.toLowerCase())); + const filteredRepos = Array.from(reposInAccounts).filter( + (r) => r.account === selectedAccount && `${r.name}`.toLowerCase().includes(repoSearchFilter.toLowerCase()), + ); const icon = selectedAccount && accounts.get(selectedAccount)?.avatarUrl; const showSearchInput = !!repoSearchFilter || filteredRepos.length > 0; const userLink = (r: ProviderRepository) => { - return `https://${new URL(r.cloneUrl).host}/${r.inUse?.userName}` - } + return `https://${new URL(r.cloneUrl).host}/${r.inUse?.userName}`; + }; const projectText = () => { - return

Projects allow you to manage prebuilds and workspaces for your repository. Learn more

- } + return ( +

+ Projects allow you to manage prebuilds and workspaces for your repository.{" "} + + Learn more + +

+ ); + }; - const renderRepos = () => (<> - {projectText()} -

{loaded && noReposAvailable ? 'Select account on ' : 'Select a Git repository on '}{selectedProviderHost} ( setShowGitProviders(true)}>change)

-
-
- -
- {!selectedAccount && user && user.name && user.avatarUrl && ( - <> - - - - )} - {selectedAccount && ( - <> - - - - )} - -
-
- {showSearchInput && ( -
- - setRepoSearchFilter(e.target.value)}> -
- )} -
-
- {filteredRepos.length > 0 && ( -
- {filteredRepos.map((r, index) => ( -
- -
-
{toSimpleName(r.name)}
-

Updated {moment(r.updatedAt).fromNow()}

-
-
-
- {!r.inUse ? ( - - ) : ( -

- @{r.inUse.userName} already
added this repo -

- )} + const renderRepos = () => ( + <> + {projectText()} +

+ {loaded && noReposAvailable ? "Select account on " : "Select a Git repository on "} + {selectedProviderHost} ( + + ) +

+
+
+ +
+ {!selectedAccount && user && user.name && user.avatarUrl && ( + <> + + + + )} + {selectedAccount && ( + <> + + + + )} + +
+
+ {showSearchInput && ( +
+ + setRepoSearchFilter(e.target.value)} + > +
+ )} +
+
+ {filteredRepos.length > 0 && ( +
+ {filteredRepos.map((r, index) => ( +
+
+
+ {toSimpleName(r.name)} +
+

Updated {moment(r.updatedAt).fromNow()}

+
+
+
+ {!r.inUse ? ( + + ) : ( +

+ + @{r.inUse.userName} + {" "} + already +
+ added this repo +

+ )} +
+ ))} +
+ )} + {!noReposAvailable && filteredRepos.length === 0 &&

No Results

} + {loaded && noReposAvailable && isGitHub() && ( +
+
+ + Additional authorization is required for our GitHub App to watch your + repositories and trigger prebuilds. + +
+
- ))} -
- )} - {!noReposAvailable && filteredRepos.length === 0 && ( -

No Results

- )} - {loaded && noReposAvailable && isGitHub() && (
-
- - Additional authorization is required for our GitHub App to watch your repositories and trigger prebuilds. - -
- -
-
)} -
- -
- {reposInAccounts.length > 0 && isGitHub() && ( -
- )} -

- Teams & Projects are currently in Beta. Send feedback -

- + {reposInAccounts.length > 0 && isGitHub() && ( +
+
+ Repository not found?{" "} + +
+
+ )} +

+ Teams & Projects are currently in Beta.{" "} + + Send feedback + +

+ ); const renderLoadingState = () => ( @@ -371,13 +470,12 @@ export default function NewProject() {
-

- Loading ... -

+

Loading ...

-
) +
+ ); const onGitProviderSeleted = async (host: string, updateUser?: boolean) => { if (updateUser) { @@ -385,61 +483,81 @@ export default function NewProject() { } setShowGitProviders(false); setSelectedProviderHost(host); - } + }; if (!loaded) { return renderLoadingState(); } if (showGitProviders) { - return (); + return ; } return renderRepos(); }; const renderSelectTeam = () => { - const userFullName = user?.fullName || user?.name || '...'; + const userFullName = user?.fullName || user?.name || "..."; const teamsToRender = teams || []; - return (<> -

Select team or personal account

-
- - {teamsToRender.map((t) => ( -
- ) + ); } -function NewTeam(props: { - onSuccess: (team: Team) => void, -}) { +function NewTeam(props: { onSuccess: (team: Team) => void }) { const { setTeams } = useContext(TeamsContext); const [teamName, setTeamName] = useState(); @@ -580,37 +721,53 @@ function NewTeam(props: { console.error(error); setError(error?.message || "Failed to create new team!"); } - } + }; const onTeamNameChanged = (name: string) => { setTeamName(name); setError(undefined); - } + }; - return <> -
- onTeamNameChanged(e.target.value)} /> - -
- {error &&

{error}

} - ; + return ( + <> +
+ onTeamNameChanged(e.target.value)} + /> + +
+ {error &&

{error}

} + + ); } -async function openReconfigureWindow(params: { account?: string, onSuccess: (p: any) => void }) { +async function openReconfigureWindow(params: { account?: string; onSuccess: (p: any) => void }) { const { account, onSuccess } = params; const state = btoa(JSON.stringify({ from: "/reconfigure", next: "/new" })); - const url = gitpodHostUrl.withApi({ - pathname: '/apps/github/reconfigure', - search: `account=${account}&state=${encodeURIComponent(state)}` - }).toString(); + const url = gitpodHostUrl + .withApi({ + pathname: "/apps/github/reconfigure", + search: `account=${account}&state=${encodeURIComponent(state)}`, + }) + .toString(); const width = 800; const height = 800; - const left = (window.screen.width / 2) - (width / 2); - const top = (window.screen.height / 2) - (height / 2); + const left = window.screen.width / 2 - width / 2; + const top = window.screen.height / 2 - height / 2; // Optimistically assume that the new window was opened. - window.open(url, "gitpod-github-window", `width=${width},height=${height},top=${top},left=${left}status=yes,scrollbars=yes,resizable=yes`); + window.open( + url, + "gitpod-github-window", + `width=${width},height=${height},top=${top},left=${left}status=yes,scrollbars=yes,resizable=yes`, + ); const eventListener = (event: MessageEvent) => { // todo: check event.origin @@ -622,19 +779,21 @@ async function openReconfigureWindow(params: { account?: string, onSuccess: (p: console.log(`Received Window Result. Closing Window.`); event.source.close(); } - } + }; if (typeof event.data === "string" && event.data.startsWith("payload:")) { killWindow(); try { - let payload: { installationId: string, setupAction?: string } = JSON.parse(atob(event.data.substring("payload:".length))); + let payload: { installationId: string; setupAction?: string } = JSON.parse( + atob(event.data.substring("payload:".length)), + ); onSuccess && onSuccess(payload); } catch (error) { console.log(error); } } if (typeof event.data === "string" && event.data.startsWith("error:")) { - let error: string | { error: string, description?: string } = atob(event.data.substring("error:".length)); + let error: string | { error: string; description?: string } = atob(event.data.substring("error:".length)); try { const payload = JSON.parse(error); if (typeof payload === "object" && payload.error) { diff --git a/components/dashboard/src/projects/Prebuild.tsx b/components/dashboard/src/projects/Prebuild.tsx index d10728d8bee249..50e135de1ee0f4 100644 --- a/components/dashboard/src/projects/Prebuild.tsx +++ b/components/dashboard/src/projects/Prebuild.tsx @@ -23,27 +23,28 @@ export default function () { const { teams } = useContext(TeamsContext); const team = getCurrentTeam(location, teams); - const match = useRouteMatch<{ team: string, project: string, prebuildId: string }>("/(t/)?:team/:project/:prebuildId"); + const match = useRouteMatch<{ team: string; project: string; prebuildId: string }>( + "/(t/)?:team/:project/:prebuildId", + ); const projectSlug = match?.params?.project; const prebuildId = match?.params?.prebuildId; - const [ prebuild, setPrebuild ] = useState(); - const [ prebuildInstance, setPrebuildInstance ] = useState(); - const [ isRerunningPrebuild, setIsRerunningPrebuild ] = useState(false); - const [ isCancellingPrebuild, setIsCancellingPrebuild ] = useState(false); + const [prebuild, setPrebuild] = useState(); + const [prebuildInstance, setPrebuildInstance] = useState(); + const [isRerunningPrebuild, setIsRerunningPrebuild] = useState(false); + const [isCancellingPrebuild, setIsCancellingPrebuild] = useState(false); useEffect(() => { if (!teams || !projectSlug || !prebuildId) { return; } (async () => { - const projects = (!!team + const projects = !!team ? await getGitpodService().server.getTeamProjects(team.id) - : await getGitpodService().server.getUserProjects()); + : await getGitpodService().server.getUserProjects(); - const project = projectSlug && projects.find(p => !!p.slug - ? p.slug === projectSlug - : p.name === projectSlug); + const project = + projectSlug && projects.find((p) => (!!p.slug ? p.slug === projectSlug : p.name === projectSlug)); if (!project) { console.error(new Error(`Project not found! (teamId: ${team?.id}, projectName: ${projectSlug})`)); return; @@ -51,7 +52,7 @@ export default function () { const prebuilds = await getGitpodService().server.findPrebuilds({ projectId: project.id, - prebuildId + prebuildId, }); setPrebuild(prebuilds[0]); })(); @@ -61,29 +62,51 @@ export default function () { if (!prebuild) { return "unknown prebuild"; } - return (

{prebuild.info.branch}

); + return

{prebuild.info.branch}

; }; const renderSubtitle = () => { if (!prebuild) { return ""; } - const startedByAvatar = prebuild.info.startedByAvatar && {prebuild.info.startedBy}; - return (
-
-

{startedByAvatar}Triggered {moment(prebuild.info.startedAt).fromNow()}

-
-

·

-
-

{shortCommitMessage(prebuild.info.changeTitle)}

-
- {!!prebuild.info.basedOnPrebuildId && <> + const startedByAvatar = prebuild.info.startedByAvatar && ( + {prebuild.info.startedBy} + ); + return ( +
+
+

+ {startedByAvatar}Triggered {moment(prebuild.info.startedAt).fromNow()} +

+

·

-

Incremental Prebuild (base)

+

{shortCommitMessage(prebuild.info.changeTitle)}

- } -
) + {!!prebuild.info.basedOnPrebuildId && ( + <> +

·

+
+

+ Incremental Prebuild ( + + base + + ) +

+
+ + )} +
+ ); }; const onInstanceUpdate = async (instance: WorkspaceInstance) => { @@ -93,10 +116,10 @@ export default function () { } const prebuilds = await getGitpodService().server.findPrebuilds({ projectId: prebuild.info.projectId, - prebuildId + prebuildId, }); setPrebuild(prebuilds[0]); - } + }; const rerunPrebuild = async () => { if (!prebuild) { @@ -106,13 +129,13 @@ export default function () { setIsRerunningPrebuild(true); await getGitpodService().server.triggerPrebuild(prebuild.info.projectId, prebuild.info.branch); // TODO: Open a Prebuilds page that's specific to `prebuild.info.branch`? - history.push(`/${!!team ? 't/'+team.slug : 'projects'}/${projectSlug}/prebuilds`); + history.push(`/${!!team ? "t/" + team.slug : "projects"}/${projectSlug}/prebuilds`); } catch (error) { - console.error('Could not rerun prebuild', error); + console.error("Could not rerun prebuild", error); } finally { setIsRerunningPrebuild(false); } - } + }; const cancelPrebuild = async () => { if (!prebuild) { @@ -122,40 +145,69 @@ export default function () { setIsCancellingPrebuild(true); await getGitpodService().server.cancelPrebuild(prebuild.info.projectId, prebuild.info.id); } catch (error) { - console.error('Could not cancel prebuild', error); + console.error("Could not cancel prebuild", error); } finally { setIsCancellingPrebuild(false); } - } - - useEffect(() => { document.title = 'Prebuild — Gitpod' }, []); + }; - return <> -
-
-
-
- -
-
- {prebuildInstance && } -
- {(prebuild?.status === 'aborted' || prebuild?.status === 'timeout' || !!prebuild?.error) - ? - : (prebuild?.status === 'building' - ? + ) : prebuild?.status === "building" ? ( + - : (prebuild?.status === 'available' - ? - : ))} + ) : prebuild?.status === "available" ? ( + + + + ) : ( + + )} +
-
- ; - -} \ No newline at end of file + + ); +} diff --git a/components/dashboard/src/projects/Prebuilds.tsx b/components/dashboard/src/projects/Prebuilds.tsx index e72e5fda9b9668..d1bbb4e3d17ec8 100644 --- a/components/dashboard/src/projects/Prebuilds.tsx +++ b/components/dashboard/src/projects/Prebuilds.tsx @@ -24,13 +24,13 @@ import { shortCommitMessage } from "./render-utils"; import { Link } from "react-router-dom"; import { Disposable } from "vscode-jsonrpc"; -export default function (props: { project?: Project, isAdminDashboard?: boolean }) { +export default function (props: { project?: Project; isAdminDashboard?: boolean }) { const location = useLocation(); const { teams } = useContext(TeamsContext); const team = getCurrentTeam(location, teams); - const match = useRouteMatch<{ team: string, resource: string }>("/(t/)?:team/:resource"); + const match = useRouteMatch<{ team: string; resource: string }>("/(t/)?:team/:resource"); const projectSlug = props.isAdminDashboard ? props.project?.slug : match?.params?.resource; const [project, setProject] = useState(); @@ -56,39 +56,41 @@ export default function (props: { project?: Project, isAdminDashboard?: boolean registration = getGitpodService().registerClient({ onPrebuildUpdate: (update: PrebuildWithStatus) => { if (update.info.projectId === project.id) { - setPrebuilds(prev => [update, ...prev.filter(p => p.info.id !== update.info.id)]); + setPrebuilds((prev) => [update, ...prev.filter((p) => p.info.id !== update.info.id)]); setIsLoadingPrebuilds(false); } - } + }, }); } (async () => { setIsLoadingPrebuilds(true); - const prebuilds = props && props.isAdminDashboard ? - await getGitpodService().server.adminFindPrebuilds({ projectId: project.id }) - : await getGitpodService().server.findPrebuilds({ projectId: project.id }); + const prebuilds = + props && props.isAdminDashboard + ? await getGitpodService().server.adminFindPrebuilds({ projectId: project.id }) + : await getGitpodService().server.findPrebuilds({ projectId: project.id }); setPrebuilds(prebuilds); setIsLoadingPrebuilds(false); })(); if (!props.isAdminDashboard) { - return () => { registration.dispose(); } + return () => { + registration.dispose(); + }; } - }, [project]); + }, [project, props]); useEffect(() => { if (!teams) { return; } (async () => { - const projects = (!!team + const projects = !!team ? await getGitpodService().server.getTeamProjects(team.id) - : await getGitpodService().server.getUserProjects()); + : await getGitpodService().server.getUserProjects(); - const newProject = projectSlug && projects.find( - p => p.slug ? p.slug === projectSlug : - p.name === projectSlug); + const newProject = + projectSlug && projects.find((p) => (p.slug ? p.slug === projectSlug : p.name === projectSlug)); if (newProject) { setProject(newProject); @@ -100,51 +102,54 @@ export default function (props: { project?: Project, isAdminDashboard?: boolean if (prebuilds.length === 0) { setIsLoadingPrebuilds(false); } - }, [prebuilds]) + }, [prebuilds]); const prebuildContextMenu = (p: PrebuildWithStatus) => { - const isFailed = p.status === "aborted" || p.status === "timeout" || p.status === "failed"|| !!p.error; + const isFailed = p.status === "aborted" || p.status === "timeout" || p.status === "failed" || !!p.error; const isRunning = p.status === "building"; const entries: ContextMenuEntry[] = []; if (isFailed) { entries.push({ title: `Rerun Prebuild (${p.info.branch})`, onClick: () => triggerPrebuild(p.info.branch), - separator: isRunning + separator: isRunning, }); } if (isRunning) { entries.push({ title: "Cancel Prebuild", - customFontStyle: 'text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300', + customFontStyle: "text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300", onClick: () => cancelPrebuild(p.info.id), - }) + }); } return entries; - } + }; const statusFilterEntries = () => { const entries: DropDownEntry[] = []; entries.push({ - title: 'All', - onClick: () => setStatusFilter(undefined) + title: "All", + onClick: () => setStatusFilter(undefined), }); entries.push({ - title: 'READY', - onClick: () => setStatusFilter("available") + title: "READY", + onClick: () => setStatusFilter("available"), }); return entries; - } + }; const filter = (p: PrebuildWithStatus) => { if (statusFilter && statusFilter !== p.status) { return false; } - if (searchFilter && `${p.info.changeTitle} ${p.info.branch}`.toLowerCase().includes(searchFilter.toLowerCase()) === false) { + if ( + searchFilter && + `${p.info.changeTitle} ${p.info.branch}`.toLowerCase().includes(searchFilter.toLowerCase()) === false + ) { return false; } return true; - } + }; const prebuildSorter = (a: PrebuildWithStatus, b: PrebuildWithStatus) => { if (a.info.startedAt < b.info.startedAt) { @@ -154,113 +159,182 @@ export default function (props: { project?: Project, isAdminDashboard?: boolean return 0; } return -1; - } + }; const triggerPrebuild = (branchName: string | null) => { if (!project) { return; } getGitpodService().server.triggerPrebuild(project.id, branchName); - } + }; const cancelPrebuild = (prebuildId: string) => { if (!project) { return; } getGitpodService().server.cancelPrebuild(project.id, prebuildId); - } + }; const formatDate = (date: string | undefined) => { return date ? moment(date).fromNow() : ""; - } + }; - return <> - {!props.isAdminDashboard &&
} -
-
-
-
- + return ( + <> + {!props.isAdminDashboard && ( +
+ )} +
+
+
+
+ + + +
+ setSearchFilter(e.target.value)} + />
- setSearchFilter(e.target.value)} /> -
-
-
- +
+
+ +
+ {!isLoadingPrebuilds && prebuilds.length === 0 && !props.isAdminDashboard && ( + + )}
- {(!isLoadingPrebuilds && prebuilds.length === 0 && !props.isAdminDashboard) && - } -
- - - - Prebuild - - - Commit - - - Branch - - - {isLoadingPrebuilds &&
- - Fetching prebuilds... -
} - {prebuilds.filter(filter).sort(prebuildSorter).map((p, index) => - - -
-
{prebuildStatusIcon(p)}
- {prebuildStatusLabel(p)} -
-

{p.info.startedByAvatar && {p.info.startedBy}}Triggered {formatDate(p.info.startedAt)}

- -
- -
- -
{shortCommitMessage(p.info.changeTitle)}
-
-

{p.info.changeAuthorAvatar && {p.info.changeAuthor}}Authored {formatDate(p.info.changeDate)} · {p.info.changeHash?.substring(0, 8)}

+ + + + Prebuild + + + Commit + + + Branch + + + {isLoadingPrebuilds && ( +
+ + Fetching prebuilds...
- - - -
- {p.info.branch} -
-
- - {!props.isAdminDashboard && } -
- )} -
- {(!isLoadingPrebuilds && prebuilds.length === 0) && -
No prebuilds found.
} -
- - ; + )} + {prebuilds + .filter(filter) + .sort(prebuildSorter) + .map((p, index) => ( + + + +
+
+ {prebuildStatusIcon(p)} +
+ {prebuildStatusLabel(p)} +
+

+ {p.info.startedByAvatar && ( + {p.info.startedBy} + )} + Triggered {formatDate(p.info.startedAt)} +

+ +
+ +
+ +
+ {shortCommitMessage(p.info.changeTitle)} +
+
+

+ {p.info.changeAuthorAvatar && ( + {p.info.changeAuthor} + )} + Authored {formatDate(p.info.changeDate)} ·{" "} + {p.info.changeHash?.substring(0, 8)} +

+
+
+ + +
+ + {p.info.branch} + +
+
+ + {!props.isAdminDashboard && ( + + )} +
+
+ ))} +
+ {!isLoadingPrebuilds && prebuilds.length === 0 && ( +
No prebuilds found.
+ )} +
+ + ); } export function prebuildStatusLabel(prebuild?: PrebuildWithStatus) { switch (prebuild?.status) { case undefined: // Fall through case "queued": - return (pending); + return pending; case "building": - return (running); + return running; case "aborted": - return (canceled); + return canceled; case "failed": - return (system error); + return system error; case "timeout": - return (timed out); + return timed out; case "available": if (prebuild?.error) { - return (failed); + return failed; } - return (ready); + return ready; } } @@ -287,7 +361,7 @@ export function prebuildStatusIcon(prebuild?: PrebuildWithStatus) { function formatDuration(milliseconds: number) { const hours = Math.floor(milliseconds / (1000 * 60 * 60)); - return (hours > 0 ? `${hours}:` : '') + moment(milliseconds).format('mm:ss'); + return (hours > 0 ? `${hours}:` : "") + moment(milliseconds).format("mm:ss"); } export function PrebuildInstanceStatus(props: { prebuildInstance?: WorkspaceInstance }) { @@ -295,72 +369,106 @@ export function PrebuildInstanceStatus(props: { prebuildInstance?: WorkspaceInst let details = <>; switch (props.prebuildInstance?.status.phase) { case undefined: // Fall through - case 'preparing': // Fall through - case 'pending': // Fall through - case 'creating': // Fall through - case 'unknown': - status =
- - PENDING -
; - details =
- - Preparing prebuild ... -
; + case "preparing": // Fall through + case "pending": // Fall through + case "creating": // Fall through + case "unknown": + status = ( +
+ + PENDING +
+ ); + details = ( +
+ + Preparing prebuild ... +
+ ); break; - case 'initializing': // Fall through - case 'running': // Fall through - case 'interrupted': // Fall through - case 'stopping': - status =
- - RUNNING -
; - details =
- - Prebuild in progress ... -
; + case "initializing": // Fall through + case "running": // Fall through + case "interrupted": // Fall through + case "stopping": + status = ( +
+ + RUNNING +
+ ); + details = ( +
+ + Prebuild in progress ... +
+ ); break; - case 'stopped': - status =
- - READY -
; - details =
- - {!!props.prebuildInstance?.stoppedTime - ? formatDuration((new Date(props.prebuildInstance.stoppedTime).getTime()) - (new Date(props.prebuildInstance.creationTime).getTime())) - : '...'} -
; + case "stopped": + status = ( +
+ + READY +
+ ); + details = ( +
+ + + {!!props.prebuildInstance?.stoppedTime + ? formatDuration( + new Date(props.prebuildInstance.stoppedTime).getTime() - + new Date(props.prebuildInstance.creationTime).getTime(), + ) + : "..."} + +
+ ); break; } if (props.prebuildInstance?.status.conditions.stoppedByRequest) { - status =
- - CANCELED -
; - details =
- Prebuild canceled -
; - } else if (props.prebuildInstance?.status.conditions.failed || props.prebuildInstance?.status.conditions.headlessTaskFailed) { - status =
- - FAILED -
; - details =
- Prebuild failed -
; + status = ( +
+ + CANCELED +
+ ); + details = ( +
+ Prebuild canceled +
+ ); + } else if ( + props.prebuildInstance?.status.conditions.failed || + props.prebuildInstance?.status.conditions.headlessTaskFailed + ) { + status = ( +
+ + FAILED +
+ ); + details = ( +
+ Prebuild failed +
+ ); } else if (props.prebuildInstance?.status.conditions.timeout) { - status =
- - FAILED -
; - details =
- Prebuild timed out -
; + status = ( +
+ + FAILED +
+ ); + details = ( +
+ Prebuild timed out +
+ ); } - return
-
{status}
-
{details}
-
; -} \ No newline at end of file + return ( +
+
{status}
+
{details}
+
+ ); +} diff --git a/components/dashboard/src/projects/Project.tsx b/components/dashboard/src/projects/Project.tsx index 0ae4e93588bdfe..b24643c4229b2f 100644 --- a/components/dashboard/src/projects/Project.tsx +++ b/components/dashboard/src/projects/Project.tsx @@ -6,7 +6,7 @@ import moment from "moment"; import { PrebuildWithStatus, Project } from "@gitpod/gitpod-protocol"; -import { useContext, useEffect, useState } from "react"; +import { useCallback, useContext, useEffect, useState } from "react"; import { useLocation, useRouteMatch } from "react-router"; import Header from "../components/Header"; import { ItemsList, Item, ItemField, ItemFieldContextMenu } from "../components/ItemsList"; @@ -25,7 +25,7 @@ export default function () { const { teams } = useContext(TeamsContext); const team = getCurrentTeam(location, teams); - const match = useRouteMatch<{ team: string, resource: string }>("/(t/)?:team/:resource"); + const match = useRouteMatch<{ team: string; resource: string }>("/(t/)?:team/:resource"); const projectSlug = match?.params?.resource; const [project, setProject] = useState(); @@ -39,9 +39,45 @@ export default function () { const [showAuthBanner, setShowAuthBanner] = useState<{ host: string } | undefined>(undefined); + const updateBranches = useCallback(async () => { + if (!project) { + return; + } + setIsLoadingBranches(true); + try { + const details = await getGitpodService().server.getProjectOverview(project.id); + if (details) { + // default branch on top of the rest + const branches = details.branches.sort((a, b) => (b.isDefault as any) - (a.isDefault as any)) || []; + setBranches(branches); + } + } finally { + setIsLoadingBranches(false); + } + }, [project]); + useEffect(() => { + const updateProject = async () => { + if (!teams || !projectSlug) { + return; + } + const projects = !!team + ? await getGitpodService().server.getTeamProjects(team.id) + : await getGitpodService().server.getUserProjects(); + + // Find project matching with slug, otherwise with name + const project = + projectSlug && projects.find((p) => (p.slug ? p.slug === projectSlug : p.name === projectSlug)); + + if (!project) { + return; + } + + setProject(project); + }; + updateProject(); - }, [teams]); + }, [projectSlug, team, teams]); useEffect(() => { if (!project) { @@ -54,48 +90,11 @@ export default function () { if (error && error.code === ErrorCodes.NOT_AUTHENTICATED) { setShowAuthBanner({ host: new URL(project.cloneUrl).hostname }); } else { - console.error('Getting branches failed', error); + console.error("Getting branches failed", error); } } })(); - }, [project]); - - const updateProject = async () => { - if (!teams || !projectSlug) { - return; - } - const projects = (!!team - ? await getGitpodService().server.getTeamProjects(team.id) - : await getGitpodService().server.getUserProjects()); - - // Find project matching with slug, otherwise with name - const project = projectSlug && projects.find( - p => p.slug ? p.slug === projectSlug : - p.name === projectSlug); - - if (!project) { - return; - } - - setProject(project); - } - - const updateBranches = async () => { - if (!project) { - return; - } - setIsLoadingBranches(true); - try { - const details = await getGitpodService().server.getProjectOverview(project.id); - if (details) { - // default branch on top of the rest - const branches = details.branches.sort((a, b) => (b.isDefault as any) - (a.isDefault as any)) || []; - setBranches(branches); - } - } finally { - setIsLoadingBranches(false); - } - } + }, [project, updateBranches]); const tryAuthorize = async (host: string, onSuccess: () => void) => { try { @@ -104,7 +103,7 @@ export default function () { onSuccess, onError: (error) => { console.log(error); - } + }, }); } catch (error) { console.log(error); @@ -118,7 +117,7 @@ export default function () { await getGitpodService().reconnect(); // retry fetching branches - updateBranches().catch(e => console.log(e)); + updateBranches().catch((e) => console.log(e)); }); }; @@ -129,7 +128,7 @@ export default function () { loadPrebuild(branch); } return lastPrebuild; - } + }; const loadPrebuild = async (branch: Project.BranchDetails) => { if (prebuildLoaders.has(branch.name) || lastPrebuilds.has(branch.name)) { @@ -146,129 +145,216 @@ export default function () { branch: branch.name, latest: true, }); - setLastPrebuilds(prev => new Map(prev).set(branch.name, lastPrebuild[0])); + setLastPrebuilds((prev) => new Map(prev).set(branch.name, lastPrebuild[0])); prebuildLoaders.delete(branch.name); - } + }; const filter = (branch: Project.BranchDetails) => { - if (searchFilter && `${branch.changeTitle} ${branch.name}`.toLowerCase().includes(searchFilter.toLowerCase()) === false) { + if ( + searchFilter && + `${branch.changeTitle} ${branch.name}`.toLowerCase().includes(searchFilter.toLowerCase()) === false + ) { return false; } return true; - } + }; const triggerPrebuild = (branch: Project.BranchDetails) => { if (!project) { return; } getGitpodService().server.triggerPrebuild(project.id, branch.name); - } + }; const cancelPrebuild = (prebuildId: string) => { if (!project) { return; } getGitpodService().server.cancelPrebuild(project.id, prebuildId); - } + }; const formatDate = (date: string | undefined) => { return date ? moment(date).fromNow() : ""; - } + }; - return <> -
View recent active branches for {toRemoteURL(project?.cloneUrl || '')}.} /> -
- {showAuthBanner ? ( -
-
- -
- No Access -
-
- Authorize {showAuthBanner.host}
to access branch information. -
- -
-
- ) : (<> -
-
-
- + return ( + <> +
+ View recent active branches for{" "} + + {toRemoteURL(project?.cloneUrl || "")} + + . + + } + /> +
+ {showAuthBanner ? ( +
+
+ +
No Access
+
+ Authorize {showAuthBanner.host}
+ to access branch information. +
+
- setSearchFilter(e.target.value)} />
-
-
-
-
- - - - Branch - - - Commit - - - Prebuild - - - {isLoadingBranches &&
- - Fetching repository branches... -
} - {branches.filter(filter).slice(0, 10).map((branch, index) => { - - const prebuild = lastPrebuild(branch); // this might lazily trigger fetching of prebuild details - - const avatar = branch.changeAuthorAvatar && {branch.changeAuthor}; - const statusIcon = prebuildStatusIcon(prebuild); - const status = prebuildStatusLabel(prebuild); - - return - -
-
- {branch.name} - {branch.isDefault && (DEFAULT)} -
+ ) : ( + <> +
+
+
+ + +
- - -
-
{shortCommitMessage(branch.changeTitle)}
-

{avatar}Authored {formatDate(branch.changeDate)} · {branch.changeHash?.substring(0, 8)}

+ setSearchFilter(e.target.value)} + /> +
+
+
+
+ + + + Branch + + + Commit + + + Prebuild + + + {isLoadingBranches && ( +
+ + Fetching repository branches...
-
- - - {prebuild ? (<>
{statusIcon}
{status}) : ( )} -
- - - - - triggerPrebuild(branch), - }] - : (prebuild.status === 'building' - ? [{ - title: 'Cancel Prebuild', - customFontStyle: 'text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300', - onClick: () => cancelPrebuild(prebuild.info.id), - }] - : [])} /> -
- - } - )} - - )} -
+ )} + {branches + .filter(filter) + .slice(0, 10) + .map((branch, index) => { + const prebuild = lastPrebuild(branch); // this might lazily trigger fetching of prebuild details + + const avatar = branch.changeAuthorAvatar && ( + {branch.changeAuthor} + ); + const statusIcon = prebuildStatusIcon(prebuild); + const status = prebuildStatusLabel(prebuild); - ; -} \ No newline at end of file + return ( + + + + + +
+
+ {shortCommitMessage(branch.changeTitle)} +
+

+ {avatar}Authored {formatDate(branch.changeDate)} ·{" "} + {branch.changeHash?.substring(0, 8)} +

+
+
+ + {prebuild ? ( + +
+ {statusIcon} +
+ {status} +
+ ) : null} + + + + + triggerPrebuild(branch), + }, + ] + : prebuild.status === "building" + ? [ + { + title: "Cancel Prebuild", + customFontStyle: + "text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300", + onClick: () => cancelPrebuild(prebuild.info.id), + }, + ] + : [] + } + /> +
+
+ ); + })} + + + )} +
+ + ); +} diff --git a/components/dashboard/src/projects/Projects.tsx b/components/dashboard/src/projects/Projects.tsx index 9a8980c83bb3cf..980ca581ca3f8a 100644 --- a/components/dashboard/src/projects/Projects.tsx +++ b/components/dashboard/src/projects/Projects.tsx @@ -7,17 +7,17 @@ import moment from "moment"; import { Link } from "react-router-dom"; import Header from "../components/Header"; -import projectsEmpty from '../images/projects-empty.svg'; -import projectsEmptyDark from '../images/projects-empty-dark.svg'; +import projectsEmpty from "../images/projects-empty.svg"; +import projectsEmptyDark from "../images/projects-empty-dark.svg"; import { useHistory, useLocation } from "react-router"; -import { useContext, useEffect, useState } from "react"; +import { useCallback, useContext, useEffect, useState } from "react"; import { getGitpodService } from "../service/service"; import { getCurrentTeam, TeamsContext } from "../teams/teams-context"; import { ThemeContext } from "../theme-context"; import { PrebuildWithStatus, Project } from "@gitpod/gitpod-protocol"; import { toRemoteURL } from "./render-utils"; import ContextMenu from "../components/ContextMenu"; -import ConfirmationModal from "../components/ConfirmationModal" +import ConfirmationModal from "../components/ConfirmationModal"; import { prebuildStatusIcon } from "./Prebuilds"; export default function () { @@ -26,71 +26,75 @@ export default function () { const { teams } = useContext(TeamsContext); const team = getCurrentTeam(location, teams); - const [ projects, setProjects ] = useState([]); - const [ lastPrebuilds, setLastPrebuilds ] = useState>(new Map()); + const [projects, setProjects] = useState([]); + const [lastPrebuilds, setLastPrebuilds] = useState>(new Map()); const { isDark } = useContext(ThemeContext); const [searchFilter, setSearchFilter] = useState(); - useEffect(() => { - updateProjects(); - }, [ teams ]); - - const updateProjects = async () => { + const updateProjects = useCallback(async () => { if (!teams) { return; } - const infos = (!!team + const infos = !!team ? await getGitpodService().server.getTeamProjects(team.id) - : await getGitpodService().server.getUserProjects()); + : await getGitpodService().server.getUserProjects(); setProjects(infos); const map = new Map(); - await Promise.all(infos.map(async (p) => { - try { - const lastPrebuild = await getGitpodService().server.findPrebuilds({ - projectId: p.id, - latest: true, - }); - if (lastPrebuild[0]) { - map.set(p.id, lastPrebuild[0]); + await Promise.all( + infos.map(async (p) => { + try { + const lastPrebuild = await getGitpodService().server.findPrebuilds({ + projectId: p.id, + latest: true, + }); + if (lastPrebuild[0]) { + map.set(p.id, lastPrebuild[0]); + } + } catch (error) { + console.error("Failed to load prebuilds for project", p, error); } - } catch (error) { - console.error('Failed to load prebuilds for project', p, error); - } - })); + }), + ); setLastPrebuilds(map); - } + }, [team, teams]); + + useEffect(() => { + updateProjects(); + }, [teams, updateProjects]); - const newProjectUrl = !!team ? `/new?team=${team.slug}` : '/new?user=1'; + const newProjectUrl = !!team ? `/new?team=${team.slug}` : "/new?user=1"; const onNewProject = () => { history.push(newProjectUrl); - } + }; const onRemoveProject = async (p: Project) => { - setRemoveModalVisible(false) + setRemoveModalVisible(false); await getGitpodService().server.deleteProject(p.id); await updateProjects(); - } + }; const filter = (project: Project) => { if (searchFilter && `${project.name}`.toLowerCase().includes(searchFilter.toLowerCase()) === false) { return false; } return true; - } + }; function hasNewerPrebuild(p0: Project, p1: Project): number { - return moment(lastPrebuilds.get(p1.id)?.info?.startedAt || '1970-01-01').diff(moment(lastPrebuilds.get(p0.id)?.info?.startedAt || '1970-01-01')); + return moment(lastPrebuilds.get(p1.id)?.info?.startedAt || "1970-01-01").diff( + moment(lastPrebuilds.get(p0.id)?.info?.startedAt || "1970-01-01"), + ); } - let [ isRemoveModalVisible, setRemoveModalVisible ] = useState(false); - let [ removeProjectHandler, setRemoveProjectHandler ] = useState<() => void>(()=> () => {}) - let [ willRemoveProject, setWillRemoveProject ] = useState() + let [isRemoveModalVisible, setRemoveModalVisible] = useState(false); + let [removeProjectHandler, setRemoveProjectHandler] = useState<() => void>(() => () => {}); + let [willRemoveProject, setWillRemoveProject] = useState(); function renderProjectLink(project: Project): React.ReactElement { - let slug = ''; + let slug = ""; const name = project.name; if (project.slug) { @@ -103,127 +107,198 @@ export default function () { return ( {name} - ) + + ); } - const teamOrUserSlug = !!team ? 't/' + team.slug : 'projects'; - - return <> - - {isRemoveModalVisible && setRemoveModalVisible(false)} - onConfirm={removeProjectHandler} - />} -
- {projects.length === 0 && ( -
- Projects (empty) -

No Recent Projects

-

Add projects to enable and manage Prebuilds.
Learn more about Prebuilds

-
- - {team && } + const teamOrUserSlug = !!team ? "t/" + team.slug : "projects"; + + return ( + <> + {isRemoveModalVisible && ( + setRemoveModalVisible(false)} + onConfirm={removeProjectHandler} + /> + )} +
+ {projects.length === 0 && ( +
+ Projects (empty) +

No Recent Projects

+

+ Add projects to enable and manage Prebuilds. +
+ + Learn more about Prebuilds + +

+
+ + + + {team && ( + + + + )} +
-
- - )} - {projects.length > 0 && ( -
-
-
-
- + )} + {projects.length > 0 && ( +
+
+
+
+ + + +
+ setSearchFilter(e.target.value)} + />
- setSearchFilter(e.target.value)} /> -
-
-
+
+
+ {team && ( + + + + )} +
- {team && } - -
-
- {projects.filter(filter).sort(hasNewerPrebuild).map(p => (
-
-
-
- {renderProjectLink(p)} - -
- { - setWillRemoveProject(p) - setRemoveProjectHandler(() => () => { - onRemoveProject(p) - }) - setRemoveModalVisible(true) - } - }, - ]} /> +
+ {projects + .filter(filter) + .sort(hasNewerPrebuild) + .map((p) => ( +
+
+
+
+ {renderProjectLink(p)} + +
+ { + setWillRemoveProject(p); + setRemoveProjectHandler(() => () => { + onRemoveProject(p); + }); + setRemoveModalVisible(true); + }, + }, + ]} + /> +
+
+ +

+ {toRemoteURL(p.cloneUrl)} +

+
+
+
+ + Branches + + · + + + Prebuilds + + +
+
+
+ {lastPrebuilds.get(p.id) ? ( +
+ + {prebuildStatusIcon(lastPrebuilds.get(p.id))} +
+ {lastPrebuilds.get(p.id)?.info?.branch} +
+ + · + +
+ {moment(lastPrebuilds.get(p.id)?.info?.startedAt).fromNow()} +
+ + + View All → + +
+ ) : ( +
+

No recent prebuilds

+
+ )}
- -

{toRemoteURL(p.cloneUrl)}

-
-
-
- - - Branches - - - · - - - Prebuilds - - + ))} + {!searchFilter && ( +
+ +
+
New Project
+
+
-
-
- {lastPrebuilds.get(p.id) - ? (
- - {prebuildStatusIcon(lastPrebuilds.get(p.id))} -
{lastPrebuilds.get(p.id)?.info?.branch}
- · -
{moment(lastPrebuilds.get(p.id)?.info?.startedAt).fromNow()}
- - View All → -
) - : (
-

No recent prebuilds

-
)} -
-
))} - {!searchFilter && ( -
- -
-
New Project
-
- -
- )} + )} +
-
- )} - ; + )} + + ); } diff --git a/components/dashboard/src/provider-utils.tsx b/components/dashboard/src/provider-utils.tsx index 227a63a7cc88af..39956f3b8c4f4b 100644 --- a/components/dashboard/src/provider-utils.tsx +++ b/components/dashboard/src/provider-utils.tsx @@ -4,19 +4,19 @@ * See License-AGPL.txt in the project root for license information. */ -import bitbucket from './images/bitbucket.svg'; -import github from './images/github.svg'; -import gitlab from './images/gitlab.svg'; +import bitbucket from "./images/bitbucket.svg"; +import github from "./images/github.svg"; +import gitlab from "./images/gitlab.svg"; import { gitpodHostUrl } from "./service/service"; function iconForAuthProvider(type: string) { switch (type) { case "GitHub": - return ; + return ; case "GitLab": - return ; + return ; case "Bitbucket": - return ; + return ; default: return <>; } @@ -25,11 +25,11 @@ function iconForAuthProvider(type: string) { function simplifyProviderName(host: string) { switch (host) { case "github.com": - return "GitHub" + return "GitHub"; case "gitlab.com": - return "GitLab" + return "GitLab"; case "bitbucket.org": - return "Bitbucket" + return "Bitbucket"; default: return host; } @@ -42,35 +42,45 @@ interface OpenAuthorizeWindowParams { overrideScopes?: boolean; overrideReturn?: string; onSuccess?: (payload?: string) => void; - onError?: (error: string | { error: string, description?: string }) => void; + onError?: (error: string | { error: string; description?: string }) => void; } async function openAuthorizeWindow(params: OpenAuthorizeWindowParams) { const { login, host, scopes, overrideScopes, onSuccess, onError } = params; - let search = 'message=success'; + let search = "message=success"; const redirectURL = getSafeURLRedirect(); if (redirectURL) { - search = `${search}&returnTo=${encodeURIComponent(redirectURL)}` + search = `${search}&returnTo=${encodeURIComponent(redirectURL)}`; } - const returnTo = gitpodHostUrl.with({ pathname: 'complete-auth', search: search }).toString(); + const returnTo = gitpodHostUrl.with({ pathname: "complete-auth", search: search }).toString(); const requestedScopes = scopes || []; const url = login - ? gitpodHostUrl.withApi({ - pathname: '/login', - search: `host=${host}&returnTo=${encodeURIComponent(returnTo)}` - }).toString() - : gitpodHostUrl.withApi({ - pathname: '/authorize', - search: `returnTo=${encodeURIComponent(returnTo)}&host=${host}${overrideScopes ? "&override=true" : ""}&scopes=${requestedScopes.join(',')}` - }).toString(); + ? gitpodHostUrl + .withApi({ + pathname: "/login", + search: `host=${host}&returnTo=${encodeURIComponent(returnTo)}`, + }) + .toString() + : gitpodHostUrl + .withApi({ + pathname: "/authorize", + search: `returnTo=${encodeURIComponent(returnTo)}&host=${host}${ + overrideScopes ? "&override=true" : "" + }&scopes=${requestedScopes.join(",")}`, + }) + .toString(); const width = 800; const height = 800; - const left = (window.screen.width / 2) - (width / 2); - const top = (window.screen.height / 2) - (height / 2); + const left = window.screen.width / 2 - width / 2; + const top = window.screen.height / 2 - height / 2; // Optimistically assume that the new window was opened. - window.open(url, "gitpod-auth-window", `width=${width},height=${height},top=${top},left=${left}status=yes,scrollbars=yes,resizable=yes`); + window.open( + url, + "gitpod-auth-window", + `width=${width},height=${height},top=${top},left=${left}status=yes,scrollbars=yes,resizable=yes`, + ); const eventListener = (event: MessageEvent) => { // todo: check event.origin @@ -82,14 +92,14 @@ async function openAuthorizeWindow(params: OpenAuthorizeWindowParams) { console.log(`Received Auth Window Result. Closing Window.`); event.source.close(); } - } + }; if (typeof event.data === "string" && event.data.startsWith("success")) { killAuthWindow(); onSuccess && onSuccess(event.data); } if (typeof event.data === "string" && event.data.startsWith("error:")) { - let error: string | { error: string, description?: string } = atob(event.data.substring("error:".length)); + let error: string | { error: string; description?: string } = atob(event.data.substring("error:".length)); try { const payload = JSON.parse(error); if (typeof payload === "object" && payload.error) { @@ -109,11 +119,14 @@ const getSafeURLRedirect = (source?: string) => { const returnToURL: string | null = new URLSearchParams(source ? source : window.location.search).get("returnTo"); if (returnToURL) { // Only allow oauth on the same host - if (returnToURL.toLowerCase().startsWith(`${window.location.protocol}//${window.location.host}/api/oauth/`.toLowerCase())) { + if ( + returnToURL + .toLowerCase() + .startsWith(`${window.location.protocol}//${window.location.host}/api/oauth/`.toLowerCase()) + ) { return returnToURL; } } -} - +}; -export { iconForAuthProvider, simplifyProviderName, openAuthorizeWindow, getSafeURLRedirect } \ No newline at end of file +export { iconForAuthProvider, simplifyProviderName, openAuthorizeWindow, getSafeURLRedirect }; diff --git a/components/dashboard/src/settings/EnvironmentVariables.tsx b/components/dashboard/src/settings/EnvironmentVariables.tsx index 2b085b21f44738..35c6a53c671e80 100644 --- a/components/dashboard/src/settings/EnvironmentVariables.tsx +++ b/components/dashboard/src/settings/EnvironmentVariables.tsx @@ -22,26 +22,26 @@ interface EnvVarModalProps { } function AddEnvVarModal(p: EnvVarModalProps) { - const [ev, setEv] = useState({...p.envVar}); - const [error, setError] = useState(''); + const [ev, setEv] = useState({ ...p.envVar }); + const [error, setError] = useState(""); const ref = useRef(ev); const update = (pev: Partial) => { - const newEnv = { ...ref.current, ... pev}; + const newEnv = { ...ref.current, ...pev }; setEv(newEnv); ref.current = newEnv; }; useEffect(() => { - setEv({...p.envVar}); - setError(''); + setEv({ ...p.envVar }); + setError(""); }, [p.envVar]); const isNew = !p.envVar.id; let save = () => { const v = ref.current; const errorMsg = p.validate(v); - if (errorMsg !== '') { + if (errorMsg !== "") { setError(errorMsg); return false; } else { @@ -51,45 +51,80 @@ function AddEnvVarModal(p: EnvVarModalProps) { } }; - return -

{isNew ? 'New' : 'Edit'} Variable

-
- {error ?
- {error} -
: null} -
-

Name

- { update({name: v.target.value}) }} /> -
-
-

Value

- { update({value: v.target.value}) }} /> -
-
-

Scope

- { update({repositoryPattern: v.target.value}) }} /> + return ( + +

{isNew ? "New" : "Edit"} Variable

+
+ {error ? ( +
{error}
+ ) : null} +
+

Name

+ { + update({ name: v.target.value }); + }} + /> +
+
+

Value

+ { + update({ value: v.target.value }); + }} + /> +
+
+

Scope

+ { + update({ repositoryPattern: v.target.value }); + }} + /> +
+
+

+ You can pass a variable for a specific project or use wildcard character ( + */*) to make it available in more projects. +

+
-
-

You can pass a variable for a specific project or use wildcard character (*/*) to make it available in more projects.

+
+ +
-
-
- - -
-
+ + ); } -function DeleteEnvVarModal(p: { variable: UserEnvVarValue, deleteVariable: () => void, onClose: () => void }) { - return { p.deleteVariable(); p.onClose(); }} - > -
+function DeleteEnvVarModal(p: { variable: UserEnvVarValue; deleteVariable: () => void; onClose: () => void }) { + return ( + { + p.deleteVariable(); + p.onClose(); + }} + > +
Name Scope
@@ -97,7 +132,8 @@ function DeleteEnvVarModal(p: { variable: UserEnvVarValue, deleteVariable: () => {p.variable.name} {p.variable.repositoryPattern}
-
; + + ); } function sortEnvVars(a: UserEnvVarValue, b: UserEnvVarValue) { @@ -109,35 +145,40 @@ function sortEnvVars(a: UserEnvVarValue, b: UserEnvVarValue) { export default function EnvVars() { const [envVars, setEnvVars] = useState([] as UserEnvVarValue[]); - const [currentEnvVar, setCurrentEnvVar] = useState({ name: '', value: '', repositoryPattern: '' } as UserEnvVarValue); + const [currentEnvVar, setCurrentEnvVar] = useState({ + name: "", + value: "", + repositoryPattern: "", + } as UserEnvVarValue); const [isAddEnvVarModalVisible, setAddEnvVarModalVisible] = useState(false); const [isDeleteEnvVarModalVisible, setDeleteEnvVarModalVisible] = useState(false); const update = async () => { - await getGitpodService().server.getAllEnvVars().then(r => setEnvVars(r.sort(sortEnvVars))); - } + await getGitpodService() + .server.getAllEnvVars() + .then((r) => setEnvVars(r.sort(sortEnvVars))); + }; useEffect(() => { - update() + update(); }, []); - const add = () => { - setCurrentEnvVar({ name: '', value: '', repositoryPattern: '' }); + setCurrentEnvVar({ name: "", value: "", repositoryPattern: "" }); setAddEnvVarModalVisible(true); setDeleteEnvVarModalVisible(false); - } + }; const edit = (variable: UserEnvVarValue) => { setCurrentEnvVar(variable); setAddEnvVarModalVisible(true); setDeleteEnvVarModalVisible(false); - } + }; const confirmDeleteVariable = (variable: UserEnvVarValue) => { setCurrentEnvVar(variable); setAddEnvVarModalVisible(false); setDeleteEnvVarModalVisible(true); - } + }; const save = async (variable: UserEnvVarValue) => { await getGitpodService().server.setEnvVar(variable); @@ -152,88 +193,121 @@ export default function EnvVars() { const validate = (variable: UserEnvVarValue) => { const name = variable.name; const pattern = variable.repositoryPattern; - if (name.trim() === '') { - return 'Name must not be empty.'; + if (name.trim() === "") { + return "Name must not be empty."; } if (!/^[a-zA-Z0-9_]*$/.test(name)) { - return 'Name must match /[a-zA-Z_]+[a-zA-Z0-9_]*/.'; + return "Name must match /[a-zA-Z_]+[a-zA-Z0-9_]*/."; } - if (variable.value.trim() === '') { - return 'Value must not be empty.'; + if (variable.value.trim() === "") { + return "Value must not be empty."; } - if (pattern.trim() === '') { - return 'Scope must not be empty.'; + if (pattern.trim() === "") { + return "Scope must not be empty."; } - const split = pattern.split('/'); + const split = pattern.split("/"); if (split.length < 2) { return "A scope must use the form 'organization/repo'."; } for (const name of split) { - if (name !== '*') { - if (!/^[a-zA-Z0-9_\-.\*]+$/.test(name)) { - return 'Invalid scope segment. Only ASCII characters, numbers, -, _, . or * are allowed.'; + if (name !== "*") { + if (!/^[a-zA-Z0-9_\-.*]+$/.test(name)) { + return "Invalid scope segment. Only ASCII characters, numbers, -, _, . or * are allowed."; } } } - if (!variable.id && envVars.some(v => v.name === name && v.repositoryPattern === pattern)) { - return 'A variable with this name and scope already exists'; + if (!variable.id && envVars.some((v) => v.name === name && v.repositoryPattern === pattern)) { + return "A variable with this name and scope already exists"; } - return ''; + return ""; }; - return - {isAddEnvVarModalVisible && setAddEnvVarModalVisible(false)} />} - {isDeleteEnvVarModalVisible && deleteVariable(currentEnvVar)} - onClose={() => setDeleteEnvVarModalVisible(false)} />} -
-
-

Environment Variables

-

Variables are used to store information like passwords.

-
- {envVars.length !== 0 - ?
- -
- : null} -
- {envVars.length === 0 - ?
-
-

No Environment Variables

-
In addition to user-specific environment variables you can also pass variables through a workspace creation URL. Learn more
- + return ( + + {isAddEnvVarModalVisible && ( + setAddEnvVarModalVisible(false)} + /> + )} + {isDeleteEnvVarModalVisible && ( + deleteVariable(currentEnvVar)} + onClose={() => setDeleteEnvVarModalVisible(false)} + /> + )} +
+
+

Environment Variables

+

Variables are used to store information like passwords.

+ {envVars.length !== 0 ? ( +
+ +
+ ) : null}
- : - - Name - Scope - - {envVars.map(variable => { - return - {variable.name} - {variable.repositoryPattern} - edit(variable), - separator: true - }, - { - title: 'Delete', - customFontStyle: 'text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300', - onClick: () => confirmDeleteVariable(variable) - }, - ]} /> + {envVars.length === 0 ? ( +
+
+

No Environment Variables

+
+ In addition to user-specific environment variables you can also pass variables through a + workspace creation URL.{" "} + + Learn more + +
+ +
+
+ ) : ( + + + Name + Scope - })} - - } -
; + {envVars.map((variable) => { + return ( + + + {variable.name} + + + {variable.repositoryPattern} + + edit(variable), + separator: true, + }, + { + title: "Delete", + customFontStyle: + "text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300", + onClick: () => confirmDeleteVariable(variable), + }, + ]} + /> + + ); + })} + + )} + + ); } diff --git a/components/dashboard/src/settings/Integrations.tsx b/components/dashboard/src/settings/Integrations.tsx index f6ea5578ed4607..b6bd0686259646 100644 --- a/components/dashboard/src/settings/Integrations.tsx +++ b/components/dashboard/src/settings/Integrations.tsx @@ -6,16 +6,16 @@ import { AuthProviderEntry, AuthProviderInfo } from "@gitpod/gitpod-protocol"; import { SelectAccountPayload } from "@gitpod/gitpod-protocol/lib/auth"; -import React, { useContext, useEffect, useState } from "react"; +import React, { useCallback, useContext, useEffect, useState } from "react"; import AlertBox from "../components/AlertBox"; -import CheckBox from '../components/CheckBox'; +import CheckBox from "../components/CheckBox"; import ConfirmationModal from "../components/ConfirmationModal"; import { ContextMenuEntry } from "../components/ContextMenu"; import { Item, ItemField, ItemFieldContextMenu, ItemFieldIcon, ItemsList } from "../components/ItemsList"; import Modal from "../components/Modal"; import { PageWithSubMenu } from "../components/PageWithSubMenu"; -import copy from '../images/copy.svg'; -import exclamation from '../images/exclamation.svg'; +import copy from "../images/copy.svg"; +import exclamation from "../images/exclamation.svg"; import { openAuthorizeWindow } from "../provider-utils"; import { getGitpodService, gitpodHostUrl } from "../service/service"; import { UserContext } from "../user-context"; @@ -23,57 +23,64 @@ import { SelectAccountModal } from "./SelectAccountModal"; import settingsMenu from "./settings-menu"; export default function Integrations() { - - return (
- - -
- -
-
); + return ( +
+ + +
+ +
+
+ ); } - function GitProviders() { - const { user, setUser } = useContext(UserContext); const [authProviders, setAuthProviders] = useState([]); const [allScopes, setAllScopes] = useState>(new Map()); const [disconnectModal, setDisconnectModal] = useState<{ provider: AuthProviderInfo } | undefined>(undefined); - const [editModal, setEditModal] = useState<{ provider: AuthProviderInfo, prevScopes: Set, nextScopes: Set } | undefined>(undefined); + const [editModal, setEditModal] = useState< + { provider: AuthProviderInfo; prevScopes: Set; nextScopes: Set } | undefined + >(undefined); const [selectAccountModal, setSelectAccountModal] = useState(undefined); const [errorMessage, setErrorMessage] = useState(); - useEffect(() => { - updateAuthProviders(); - }, []); - - useEffect(() => { - updateCurrentScopes(); - }, [user, authProviders]); - - const updateAuthProviders = async () => { - setAuthProviders(await getGitpodService().server.getAuthProviders()); - } - - const updateCurrentScopes = async () => { + const updateCurrentScopes = useCallback(async () => { if (user) { const scopesByProvider = new Map(); - const connectedProviders = user.identities.map(i => authProviders.find(ap => ap.authProviderId === i.authProviderId)); + const connectedProviders = user.identities.map((i) => + authProviders.find((ap) => ap.authProviderId === i.authProviderId), + ); for (let provider of connectedProviders) { if (!provider) { continue; } const token = await getGitpodService().server.getToken({ host: provider.host }); - scopesByProvider.set(provider.authProviderId, (token?.scopes?.slice() || [])); + scopesByProvider.set(provider.authProviderId, token?.scopes?.slice() || []); } setAllScopes(scopesByProvider); } - } + }, [authProviders, user]); + + useEffect(() => { + updateAuthProviders(); + }, []); + + useEffect(() => { + updateCurrentScopes(); + }, [user, authProviders, updateCurrentScopes]); + + const updateAuthProviders = async () => { + setAuthProviders(await getGitpodService().server.getAuthProviders()); + }; const isConnected = (authProviderId: string) => { - return !!user?.identities?.find(i => i.authProviderId === authProviderId); + return !!user?.identities?.find((i) => i.authProviderId === authProviderId); }; const gitProviderMenu = (provider: AuthProviderInfo) => { @@ -81,7 +88,7 @@ function GitProviders() { const connected = isConnected(provider.authProviderId); if (connected) { result.push({ - title: 'Edit Permissions', + title: "Edit Permissions", onClick: () => startEditPermissions(provider), separator: !provider.settingsUrl, }); @@ -94,26 +101,28 @@ function GitProviders() { separator: true, }); } - const connectedWithSecondProvider = authProviders.some(p => p.authProviderId !== provider.authProviderId && isConnected(p.authProviderId)) + const connectedWithSecondProvider = authProviders.some( + (p) => p.authProviderId !== provider.authProviderId && isConnected(p.authProviderId), + ); if (connectedWithSecondProvider) { result.push({ - title: 'Disconnect', - customFontStyle: 'text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300', - onClick: () => setDisconnectModal({ provider }) + title: "Disconnect", + customFontStyle: "text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300", + onClick: () => setDisconnectModal({ provider }), }); } } else { result.push({ - title: 'Connect', - customFontStyle: 'text-green-600', - onClick: () => connect(provider) - }) + title: "Connect", + customFontStyle: "text-green-600", + onClick: () => connect(provider), + }); } return result; }; const getUsername = (authProviderId: string) => { - return user?.identities?.find(i => i.authProviderId === authProviderId)?.authName; + return user?.identities?.find((i) => i.authProviderId === authProviderId)?.authName; }; const getPermissions = (authProviderId: string) => { @@ -122,15 +131,17 @@ function GitProviders() { const connect = async (ap: AuthProviderInfo) => { await doAuthorize(ap.host, ap.requirements?.default); - } + }; const disconnect = async (ap: AuthProviderInfo) => { setDisconnectModal(undefined); - const returnTo = gitpodHostUrl.with({ pathname: 'complete-auth', search: 'message=success' }).toString(); - const deauthorizeUrl = gitpodHostUrl.withApi({ - pathname: '/deauthorize', - search: `returnTo=${returnTo}&host=${ap.host}` - }).toString(); + const returnTo = gitpodHostUrl.with({ pathname: "complete-auth", search: "message=success" }).toString(); + const deauthorizeUrl = gitpodHostUrl + .withApi({ + pathname: "/deauthorize", + search: `returnTo=${returnTo}&host=${ap.host}`, + }) + .toString(); fetch(deauthorizeUrl) .then((res) => { @@ -140,8 +151,12 @@ function GitProviders() { return res; }) .then((response) => updateUser()) - .catch((error) => setErrorMessage("You cannot disconnect this integration because it is required for authentication and logging in with this account.")) - } + .catch((error) => + setErrorMessage( + "You cannot disconnect this integration because it is required for authentication and logging in with this account.", + ), + ); + }; const startEditPermissions = async (provider: AuthProviderInfo) => { // todo: add spinner @@ -150,12 +165,12 @@ function GitProviders() { if (token) { setEditModal({ provider, prevScopes: new Set(token.scopes), nextScopes: new Set(token.scopes) }); } - } + }; const updateUser = async () => { const user = await getGitpodService().server.getLoggedInUser(); setUser(user); - } + }; const doAuthorize = async (host: string, scopes?: string[]) => { try { @@ -169,18 +184,18 @@ function GitProviders() { try { const payload = JSON.parse(error); if (SelectAccountPayload.is(payload)) { - setSelectAccountModal(payload) + setSelectAccountModal(payload); } } catch (error) { console.log(error); } } - } + }, }); } catch (error) { - console.log(error) + console.log(error); } - } + }; const updatePermissions = async () => { if (!editModal) { @@ -192,7 +207,7 @@ function GitProviders() { console.log(error); } setEditModal(undefined); - } + }; const onChangeScopeHandler = (e: React.ChangeEvent) => { if (!editModal) { return; @@ -205,125 +220,159 @@ function GitProviders() { nextScopes.delete(scope); } setEditModal({ ...editModal, nextScopes }); - } + }; const getDescriptionForScope = (scope: string) => { switch (scope) { - case "user:email": return "Read-only access to your email addresses"; - case "read:user": return "Read-only access to your profile information"; - case "public_repo": return "Write access to code in public repositories and organizations"; - case "repo": return "Read/write access to code in private repositories and organizations"; - case "read:org": return "Read-only access to organizations (used to suggest organizations when forking a repository)"; - case "workflow": return "Allow updating GitHub Actions workflow files"; + case "user:email": + return "Read-only access to your email addresses"; + case "read:user": + return "Read-only access to your profile information"; + case "public_repo": + return "Write access to code in public repositories and organizations"; + case "repo": + return "Read/write access to code in private repositories and organizations"; + case "read:org": + return "Read-only access to organizations (used to suggest organizations when forking a repository)"; + case "workflow": + return "Allow updating GitHub Actions workflow files"; // GitLab - case "read_user": return "Read-only access to your email addresses"; - case "api": return "Allow making API calls (used to set up a webhook when enabling prebuilds for a repository)"; - case "read_repository": return "Read/write access to your repositories"; + case "read_user": + return "Read-only access to your email addresses"; + case "api": + return "Allow making API calls (used to set up a webhook when enabling prebuilds for a repository)"; + case "read_repository": + return "Read/write access to your repositories"; // Bitbucket - case "account": return "Read-only access to your account information"; - case "repository": return "Read-only access to your repositories (note: Bitbucket doesn't support revoking scopes)"; - case "repository:write": return "Read/write access to your repositories (note: Bitbucket doesn't support revoking scopes)"; - case "pullrequest": return "Read access to pull requests and ability to collaborate via comments, tasks, and approvals (note: Bitbucket doesn't support revoking scopes)"; - case "pullrequest:write": return "Allow creating, merging and declining pull requests (note: Bitbucket doesn't support revoking scopes)"; - case "webhook": return "Allow installing webhooks (used when enabling prebuilds for a repository, note: Bitbucket doesn't support revoking scopes)"; - default: return ""; + case "account": + return "Read-only access to your account information"; + case "repository": + return "Read-only access to your repositories (note: Bitbucket doesn't support revoking scopes)"; + case "repository:write": + return "Read/write access to your repositories (note: Bitbucket doesn't support revoking scopes)"; + case "pullrequest": + return "Read access to pull requests and ability to collaborate via comments, tasks, and approvals (note: Bitbucket doesn't support revoking scopes)"; + case "pullrequest:write": + return "Allow creating, merging and declining pull requests (note: Bitbucket doesn't support revoking scopes)"; + case "webhook": + return "Allow installing webhooks (used when enabling prebuilds for a repository, note: Bitbucket doesn't support revoking scopes)"; + default: + return ""; } - } - - return (
- - {selectAccountModal && ( - setSelectAccountModal(undefined)} /> - )} - - {disconnectModal && ( - setDisconnectModal(undefined)} - onConfirm={() => disconnect(disconnectModal.provider)} - /> - )} - - {errorMessage && ( -
- - {errorMessage} -
- )} - - {editModal && ( - setEditModal(undefined)}> -

Edit Permissions

-
-
- Configure provider permissions. + }; + + return ( +
+ {selectAccountModal && ( + setSelectAccountModal(undefined)} /> + )} + + {disconnectModal && ( + setDisconnectModal(undefined)} + onConfirm={() => disconnect(disconnectModal.provider)} + /> + )} + + {errorMessage && ( +
+ Heads up! + {errorMessage} +
+ )} + + {editModal && ( + setEditModal(undefined)}> +

Edit Permissions

+
+
Configure provider permissions.
+ {(editModal.provider.scopes || []).map((scope) => ( +
+ +
+ ))}
- {(editModal.provider.scopes || []).map(scope => ( -
- -
+
+ +
+
+ )} + +

Git Providers

+

Manage permissions for Git providers.

+ + {authProviders && + authProviders.map((ap) => ( + + +
+   +
+
+ + + {ap.authProviderType} + + + {ap.host} + + + + + {getUsername(ap.authProviderId) || "–"} + + Username + + + + {getPermissions(ap.authProviderId)?.join(", ") || "–"} + + Permissions + + +
))} -
-
- -
- - )} - -

Git Providers

-

Manage permissions for Git providers.

- - {authProviders && authProviders.map(ap => ( - - -
-   -
-
- - {ap.authProviderType} - {ap.host} - - - {getUsername(ap.authProviderId) || "–"} - Username - - - {getPermissions(ap.authProviderId)?.join(", ") || "–"} - Permissions - - -
- ))} -
-
); + +
+ ); } function GitIntegrations() { - const { user } = useContext(UserContext); const [providers, setProviders] = useState([]); - const [modal, setModal] = useState<{ mode: "new" } | { mode: "edit", provider: AuthProviderEntry } | { mode: "delete", provider: AuthProviderEntry } | undefined>(undefined); + const [modal, setModal] = useState< + | { mode: "new" } + | { mode: "edit"; provider: AuthProviderEntry } + | { mode: "delete"; provider: AuthProviderEntry } + | undefined + >(undefined); useEffect(() => { updateOwnAuthProviders(); @@ -331,7 +380,7 @@ function GitIntegrations() { const updateOwnAuthProviders = async () => { setProviders(await getGitpodService().server.getOwnAuthProviders()); - } + }; const deleteProvider = async (provider: AuthProviderEntry) => { try { @@ -341,7 +390,7 @@ function GitIntegrations() { } setModal(undefined); updateOwnAuthProviders(); - } + }; const gitProviderMenu = (provider: AuthProviderEntry) => { const result: ContextMenuEntry[] = []; @@ -349,100 +398,127 @@ function GitIntegrations() { title: provider.status === "verified" ? "Edit Configuration" : "Activate Integration", onClick: () => setModal({ mode: "edit", provider }), separator: true, - }) + }); result.push({ - title: 'Remove', - customFontStyle: 'text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300', - onClick: () => setModal({ mode: "delete", provider }) + title: "Remove", + customFontStyle: "text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300", + onClick: () => setModal({ mode: "delete", provider }), }); return result; }; - return (
- - {modal?.mode === "new" && ( - setModal(undefined)} onUpdate={updateOwnAuthProviders} /> - )} - {modal?.mode === "edit" && ( - setModal(undefined)} onUpdate={updateOwnAuthProviders} /> - )} - {modal?.mode === "delete" && ( - setModal(undefined)} - onConfirm={() => deleteProvider(modal.provider)} - /> - - )} - -
-
-

Git Integrations

-

Manage Git integrations for GitLab or GitHub self-hosted instances.

-
- {providers.length !== 0 - ? -
- -
- : null} -
+ return ( +
+ {modal?.mode === "new" && ( + setModal(undefined)} + onUpdate={updateOwnAuthProviders} + /> + )} + {modal?.mode === "edit" && ( + setModal(undefined)} + onUpdate={updateOwnAuthProviders} + /> + )} + {modal?.mode === "delete" && ( + setModal(undefined)} + onConfirm={() => deleteProvider(modal.provider)} + /> + )} - {providers && providers.length === 0 && ( -
-
-

No Git Integrations

-
In addition to the default Git Providers you can authorize
with a self-hosted instance of a provider.
- +
+
+

Git Integrations

+

Manage Git integrations for GitLab or GitHub self-hosted instances.

+ {providers.length !== 0 ? ( +
+ +
+ ) : null}
- )} - - {providers && providers.map(ap => ( - - -
-   + + {providers && providers.length === 0 && ( +
+
+

No Git Integrations

+
+ In addition to the default Git Providers you can authorize +
with a self-hosted instance of a provider.
- - - {ap.type} - - - {ap.host} - - - - ))} - -
); + +
+
+ )} + + {providers && + providers.map((ap) => ( + + +
+   +
+
+ + {ap.type} + + + {ap.host} + + +
+ ))} +
+
+ ); } -export function GitIntegrationModal(props: ({ - mode: "new", -} | { - mode: "edit", - provider: AuthProviderEntry -}) & { - login?: boolean, - headerText?: string, - userId: string, - onClose?: () => void, - closeable?: boolean, - onUpdate?: () => void, - onAuthorize?: (payload?: string) => void -}) { - +export function GitIntegrationModal( + props: ( + | { + mode: "new"; + } + | { + mode: "edit"; + provider: AuthProviderEntry; + } + ) & { + login?: boolean; + headerText?: string; + userId: string; + onClose?: () => void; + closeable?: boolean; + onUpdate?: () => void; + onAuthorize?: (payload?: string) => void; + }, +) { const callbackUrl = (host: string) => { const pathname = `/auth/${host}/callback`; return gitpodHostUrl.with({ pathname }).toString(); - } + }; const [mode, setMode] = useState<"new" | "edit">("new"); const [providerEntry, setProviderEntry] = useState(undefined); @@ -456,6 +532,40 @@ export function GitIntegrationModal(props: ({ const [errorMessage, setErrorMessage] = useState(); const [validationError, setValidationError] = useState(); + const validate = useCallback(() => { + const errors: string[] = []; + if (clientId.trim().length === 0) { + errors.push(`${type === "GitLab" ? "Application ID" : "Client ID"} is missing.`); + } + if (clientSecret.trim().length === 0) { + errors.push(`${type === "GitLab" ? "Secret" : "Client Secret"} is missing.`); + } + if (errors.length === 0) { + setValidationError(undefined); + return true; + } else { + setValidationError(errors.join("\n")); + return false; + } + }, [clientId, clientSecret, type]); + + const updateHostValue = useCallback( + (host: string) => { + if (mode === "new") { + let newHostValue = host; + + if (host.startsWith("https://")) { + newHostValue = host.replace("https://", ""); + } + + setHost(newHostValue); + setRedirectURL(callbackUrl(newHostValue)); + setErrorMessage(undefined); + } + }, + [mode], + ); + useEffect(() => { setMode(props.mode); if (props.mode === "edit") { @@ -466,12 +576,16 @@ export function GitIntegrationModal(props: ({ setClientSecret(props.provider.oauth.clientSecret); setRedirectURL(props.provider.oauth.callBackUrl); } + // TODO: the correct dependency array here would be [props.mode, props.provider] + // but the inferred type doesn't include props.provider (see the union in the function declaration above). + // So this needs to be refactored into something less clever, or even more clever (maybe with a type guard?) + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { setErrorMessage(undefined); validate(); - }, [clientId, clientSecret, type]) + }, [clientId, clientSecret, type, validate]); useEffect(() => { if (props.mode === "new") { @@ -480,24 +594,27 @@ export function GitIntegrationModal(props: ({ const exampleHostname = `${type.toLowerCase()}.example.com`; updateHostValue(exampleHostname); } - }, [type]); + }, [host, props.mode, type, updateHostValue]); const onClose = () => props.onClose && props.onClose(); const onUpdate = () => props.onUpdate && props.onUpdate(); const activate = async () => { - let entry = (mode === "new") ? { - host, - type, - clientId, - clientSecret, - ownerId: props.userId - } as AuthProviderEntry.NewEntry : { - id: providerEntry?.id, - ownerId: props.userId, - clientId, - clientSecret: clientSecret === "redacted" ? undefined : clientSecret - } as AuthProviderEntry.UpdateEntry; + let entry = + mode === "new" + ? ({ + host, + type, + clientId, + clientSecret, + ownerId: props.userId, + } as AuthProviderEntry.NewEntry) + : ({ + id: providerEntry?.id, + ownerId: props.userId, + clientId, + clientSecret: clientSecret === "redacted" ? undefined : clientSecret, + } as AuthProviderEntry.UpdateEntry); setBusy(true); setErrorMessage(undefined); @@ -506,16 +623,18 @@ export function GitIntegrationModal(props: ({ // the server is checking periodically for updates of dynamic providers, thus we need to // wait at least 2 seconds for the changes to be propagated before we try to use this provider. - await new Promise(resolve => setTimeout(resolve, 2000)); + await new Promise((resolve) => setTimeout(resolve, 2000)); onUpdate(); const updateProviderEntry = async () => { - const provider = (await getGitpodService().server.getOwnAuthProviders()).find(ap => ap.id === newProvider.id); + const provider = (await getGitpodService().server.getOwnAuthProviders()).find( + (ap) => ap.id === newProvider.id, + ); if (provider) { setProviderEntry(provider); } - } + }; // just open the authorization window and do *not* await openAuthorizeWindow({ @@ -536,7 +655,7 @@ export function GitIntegrationModal(props: ({ errorMessage = payload.description ? payload.description : `Error: ${payload.error}`; } setErrorMessage(errorMessage); - } + }, }); if (props.closeable) { @@ -553,46 +672,14 @@ export function GitIntegrationModal(props: ({ setErrorMessage("message" in error ? error.message : "Failed to update Git provider"); } setBusy(false); - } - - const updateHostValue = (host: string) => { - if (mode === "new") { - - let newHostValue = host; - - if (host.startsWith("https://")) { - newHostValue = host.replace("https://",""); - } - - setHost(newHostValue); - setRedirectURL(callbackUrl(newHostValue)); - setErrorMessage(undefined); - } - } + }; const updateClientId = (value: string) => { setClientId(value.trim()); - } + }; const updateClientSecret = (value: string) => { setClientSecret(value.trim()); - } - - const validate = () => { - const errors: string[] = []; - if (clientId.trim().length === 0) { - errors.push(`${type === "GitLab" ? "Application ID" : "Client ID"} is missing.`); - } - if (clientSecret.trim().length === 0) { - errors.push(`${type === "GitLab" ? "Secret" : "Client Secret"} is missing.`); - } - if (errors.length === 0) { - setValidationError(undefined); - return true; - } else { - setValidationError(errors.join("\n")); - return false; - } - } + }; const getRedirectUrlDescription = (type: string, host: string) => { let settingsUrl = ``; @@ -603,7 +690,8 @@ export function GitIntegrationModal(props: ({ case "GitLab": settingsUrl = `${host}/-/profile/applications`; break; - default: return undefined; + default: + return undefined; } let docsUrl = ``; switch (type) { @@ -613,15 +701,24 @@ export function GitIntegrationModal(props: ({ case "GitLab": docsUrl = `https://www.gitpod.io/docs/gitlab-integration/#oauth-application`; break; - default: return undefined; + default: + return undefined; } - return ( - Use this redirect URL to update the OAuth application. - Go to developer settings and setup the OAuth application.  - Learn more. - ); - } + return ( + + Use this redirect URL to update the OAuth application. Go to{" "} + + developer settings + {" "} + and setup the OAuth application.  + + Learn more + + . + + ); + }; const copyRedirectUrl = () => { const el = document.createElement("textarea"); @@ -635,67 +732,117 @@ export function GitIntegrationModal(props: ({ } }; - return ( -

{mode === "new" ? "New Git Integration" : "Git Integration"}

-
- {mode === "edit" && providerEntry?.status !== "verified" && ( - You need to activate this integration. - )} -
- {props.headerText || "Configure a Git integration with a GitLab or GitHub self-hosted instance."} -
+ return ( + +

{mode === "new" ? "New Git Integration" : "Git Integration"}

+
+ {mode === "edit" && providerEntry?.status !== "verified" && ( + You need to activate this integration. + )} +
+ + {props.headerText || + "Configure a Git integration with a GitLab or GitHub self-hosted instance."} + +
-
- {mode === "new" && ( +
+ {mode === "new" && ( +
+ + +
+ )}
- - + + updateHostValue(e.target.value)} + />
- )} -
- - updateHostValue(e.target.value)} /> -
-
- -
- -
copyRedirectUrl()}> - +
+ +
+ +
copyRedirectUrl()}> + Copy the Redirect URL to clipboard +
+ {getRedirectUrlDescription(type, host)} +
+
+ + updateClientId(e.target.value)} + /> +
+
+ + updateClientSecret(e.target.value)} + />
- {getRedirectUrlDescription(type, host)} -
-
- - updateClientId(e.target.value)} /> -
-
- - updateClientSecret(e.target.value)} />
-
- {(errorMessage || validationError) && ( -
- - {errorMessage || validationError} -
- )} -
-
- -
- ); + {(errorMessage || validationError) && ( +
+ Heads up! + {errorMessage || validationError} +
+ )} +
+
+ +
+ + ); } function equals(a: Set, b: Set): boolean { - return a.size === b.size && Array.from(a).every(e => b.has(e)); + return a.size === b.size && Array.from(a).every((e) => b.has(e)); } diff --git a/components/dashboard/src/settings/Plans.tsx b/components/dashboard/src/settings/Plans.tsx index 7ba7845817ce44..42db2558c88957 100644 --- a/components/dashboard/src/settings/Plans.tsx +++ b/components/dashboard/src/settings/Plans.tsx @@ -4,9 +4,24 @@ * See License-AGPL.txt in the project root for license information. */ +// TODO: there's a number of elements used as a -
- } - {!!confirmDowngradeToPlan && setConfirmDowngradeToPlan(undefined)}> -

Downgrade to {confirmDowngradeToPlan.name}

-
-

You are about to downgrade to {confirmDowngradeToPlan.name}.

- - {!Plans.isFreePlan(confirmDowngradeToPlan.chargebeeId) - ? Your account will downgrade to {confirmDowngradeToPlan.name} on the next billing cycle. - : Your account will downgrade to {confirmDowngradeToPlan.name}. The remaining hours in your current plan will be available to use until the next billing cycle.} - -
-
- -
-
} - {isConfirmCancelDowngrade && setIsConfirmCancelDowngrade(false)}> -

Cancel downgrade and stay with {currentPlan.name}

-
-

You are about to cancel the scheduled downgrade and stay with {currentPlan.name}.

- You can continue using it right away. -
-
- -
-
} - {!!teamClaimModal && ( setTeamClaimModal(undefined)}> -

Team Invitation

-
-

{teamClaimModal.mode === "error" ? teamClaimModal.errorText : teamClaimModal.text}

-
-
- {teamClaimModal.mode === "confirmation" && ( - <> - - - - )} - {teamClaimModal.mode === "error" && ( - - )} -
-
)} - -
; +
{planCards}
+ + If you are interested in purchasing a plan for a team, purchase a Team plan with one centralized + billing.{" "} + + Learn more + + + {!!confirmUpgradeToPlan && ( + setConfirmUpgradeToPlan(undefined)}> +

Upgrade to {confirmUpgradeToPlan.name}

+
+

+ You are about to upgrade to {confirmUpgradeToPlan.name}. +

+ {!Plans.isFreePlan(currentPlan.chargebeeId) && ( + + For this billing cycle you will be charged only the total difference ( + {(confirmUpgradeToPlan.currency === "EUR" ? "€" : "$") + + (confirmUpgradeToPlan.pricePerMonth - + applyCoupons(currentPlan, appliedCoupons).pricePerMonth)} + ). The new total will be effective from the next billing cycle. + + )} + + Total:{" "} + {(confirmUpgradeToPlan.currency === "EUR" ? "€" : "$") + + confirmUpgradeToPlan.pricePerMonth}{" "} + per month + +
+
+ +
+
+ )} + {!!confirmDowngradeToPlan && ( + setConfirmDowngradeToPlan(undefined)}> +

Downgrade to {confirmDowngradeToPlan.name}

+
+

+ You are about to downgrade to {confirmDowngradeToPlan.name}. +

+ + {!Plans.isFreePlan(confirmDowngradeToPlan.chargebeeId) ? ( + + Your account will downgrade to {confirmDowngradeToPlan.name} on the next billing + cycle. + + ) : ( + + Your account will downgrade to {confirmDowngradeToPlan.name}. The remaining + hours in your current plan will be available to use until the next billing + cycle. + + )} + +
+
+ +
+
+ )} + {isConfirmCancelDowngrade && ( + setIsConfirmCancelDowngrade(false)}> +

Cancel downgrade and stay with {currentPlan.name}

+
+

+ You are about to cancel the scheduled downgrade and stay with {currentPlan.name}. +

+ You can continue using it right away. +
+
+ +
+
+ )} + {!!teamClaimModal && ( + setTeamClaimModal(undefined)}> +

Team Invitation

+
+

+ {teamClaimModal.mode === "error" ? teamClaimModal.errorText : teamClaimModal.text} +

+
+
+ {teamClaimModal.mode === "confirmation" && ( + <> + + + + )} + {teamClaimModal.mode === "error" && ( + + )} +
+
+ )} + +
+ ); } interface PlanCardProps { - plan: PlanWithOriginalPrice; - isCurrent: boolean; - children: React.ReactNode; - onUpgrade?: () => void; - onDowngrade?: () => void; - bottomLabel?: React.ReactNode; - isDisabled?: boolean; - isTsAssigned?: boolean; + plan: PlanWithOriginalPrice; + isCurrent: boolean; + children: React.ReactNode; + onUpgrade?: () => void; + onDowngrade?: () => void; + bottomLabel?: React.ReactNode; + isDisabled?: boolean; + isTsAssigned?: boolean; } function PlanCard(p: PlanCardProps) { - return {}}> -
-

{p.plan.hoursPerMonth}

-

hours

-
-
{p.children}
-
-

{p.plan.pricePerMonth <= 0.001 - ? 'FREE' - : (p.plan.currency === 'EUR' ? '€' : '$') + p.plan.pricePerMonth + ' per month' - }

- {p.isCurrent - ? - : ((p.onUpgrade && ) - || (p.onDowngrade && ) - || )} -
-
- {p.isTsAssigned && ( -
Team seat assigned
- )} -
{p.bottomLabel}
-
-
; + return ( + {}} + > +
+

{p.plan.hoursPerMonth}

+

hours

+
+
{p.children}
+
+

+ {p.plan.pricePerMonth <= 0.001 + ? "FREE" + : (p.plan.currency === "EUR" ? "€" : "$") + p.plan.pricePerMonth + " per month"} +

+ {p.isCurrent ? ( + + ) : ( + (p.onUpgrade && ( + + )) || + (p.onDowngrade && ( + + )) || ( + + ) + )} +
+
+ {p.isTsAssigned && ( +
+ Team seat assigned +
+ )} +
{p.bottomLabel}
+
+
+ ); } function getLocalStorageObject(key: string): PendingPlan | undefined { @@ -584,20 +964,20 @@ function setLocalStorageObject(key: string, object: Object): void { try { window.localStorage.setItem(key, JSON.stringify(object)); } catch (error) { - console.error('Setting localstorage item failed', key, object, error); + console.error("Setting localstorage item failed", key, object, error); } } function applyCoupons(plan: Plan, coupons: PlanCoupon[] | undefined): PlanWithOriginalPrice { - let coupon = (coupons || []).find(c => c.chargebeePlanID == plan.chargebeeId); + let coupon = (coupons || []).find((c) => c.chargebeePlanID === plan.chargebeeId); if (!coupon) { return plan; } return { ...plan, pricePerMonth: coupon.newPrice || 0, - originalPrice: plan.pricePerMonth - } + originalPrice: plan.pricePerMonth, + }; } // Look for relevant billing cycle dates in the account statement's computed credits. @@ -607,7 +987,12 @@ function guessCurrentBillingCycle(currentPlan: Plan, accountStatement?: AccountS } try { const now = new Date().toISOString(); - const credit = accountStatement.credits.find(c => c.date < now && c.expiryDate >= now && (c.description as CreditDescription)?.planId === currentPlan.chargebeeId); + const credit = accountStatement.credits.find( + (c) => + c.date < now && + c.expiryDate >= now && + (c.description as CreditDescription)?.planId === currentPlan.chargebeeId, + ); if (!!credit) { return [new Date(credit.date), new Date(credit.expiryDate)]; } diff --git a/components/dashboard/src/settings/Preferences.tsx b/components/dashboard/src/settings/Preferences.tsx index 882ffe76d8e554..8bdc59f45785cf 100644 --- a/components/dashboard/src/settings/Preferences.tsx +++ b/components/dashboard/src/settings/Preferences.tsx @@ -15,19 +15,19 @@ import { getGitpodService } from "../service/service"; import { ThemeContext } from "../theme-context"; import { UserContext } from "../user-context"; import settingsMenu from "./settings-menu"; -import IDENone from '../icons/IDENone.svg'; -import IDENoneDark from '../icons/IDENoneDark.svg'; +import IDENone from "../icons/IDENone.svg"; +import IDENoneDark from "../icons/IDENoneDark.svg"; import CheckBox from "../components/CheckBox"; -type Theme = 'light' | 'dark' | 'system'; +type Theme = "light" | "dark" | "system"; const DesktopNoneId = "none"; const DesktopNone: IDEOption = { - "image": "", - "logo": IDENone, - "orderKey": "-1", - "title": "None", - "type": "desktop" + image: "", + logo: IDENone, + orderKey: "-1", + title: "None", + type: "desktop", }; export default function Preferences() { @@ -44,36 +44,43 @@ export default function Preferences() { settings.defaultDesktopIde = desktopIde; settings.useLatestVersion = useLatestVersion; additionalData.ideSettings = settings; - getGitpodService().server.trackEvent({ - event: "ide_configuration_changed", - properties: { - useDesktopIde, - defaultIde, - defaultDesktopIde: desktopIde, - useLatestVersion, - }, - }).then().catch(console.error); + getGitpodService() + .server.trackEvent({ + event: "ide_configuration_changed", + properties: { + useDesktopIde, + defaultIde, + defaultDesktopIde: desktopIde, + useLatestVersion, + }, + }) + .then() + .catch(console.error); await getGitpodService().server.updateLoggedInUser({ additionalData }); - } + }; const [defaultIde, setDefaultIde] = useState(user?.additionalData?.ideSettings?.defaultIde || ""); const actuallySetDefaultIde = async (value: string) => { await updateUserIDEInfo(defaultDesktopIde, value, useLatestVersion); setDefaultIde(value); - } + }; - const [defaultDesktopIde, setDefaultDesktopIde] = useState((user?.additionalData?.ideSettings?.useDesktopIde && user?.additionalData?.ideSettings?.defaultDesktopIde) || DesktopNoneId); + const [defaultDesktopIde, setDefaultDesktopIde] = useState( + (user?.additionalData?.ideSettings?.useDesktopIde && user?.additionalData?.ideSettings?.defaultDesktopIde) || + DesktopNoneId, + ); const actuallySetDefaultDesktopIde = async (value: string) => { await updateUserIDEInfo(value, defaultIde, useLatestVersion); setDefaultDesktopIde(value); - } + }; - const [useLatestVersion, setUseLatestVersion] = useState(user?.additionalData?.ideSettings?.useLatestVersion ?? false); + const [useLatestVersion, setUseLatestVersion] = useState( + user?.additionalData?.ideSettings?.useLatestVersion ?? false, + ); const actuallySetUseLatestVersion = async (value: boolean) => { await updateUserIDEInfo(defaultDesktopIde, defaultIde, value); setUseLatestVersion(value); - } - + }; const [ideOptions, setIdeOptions] = useState(undefined); useEffect(() => { @@ -81,26 +88,28 @@ export default function Preferences() { const ideopts = await getGitpodService().server.getIDEOptions(); ideopts.options[DesktopNoneId] = DesktopNone; setIdeOptions(ideopts); - if (!(defaultIde)) { + if (!defaultIde) { setDefaultIde(ideopts.defaultIde); } if (!defaultDesktopIde) { setDefaultDesktopIde(ideopts.defaultDesktopIde); } })(); - }, []); + }, [defaultDesktopIde, defaultIde]); - const [theme, setTheme] = useState(localStorage.theme || 'system'); + const [theme, setTheme] = useState(localStorage.theme || "system"); const actuallySetTheme = (theme: Theme) => { - if (theme === 'dark' || theme === 'light') { + if (theme === "dark" || theme === "light") { localStorage.theme = theme; } else { - localStorage.removeItem('theme'); + localStorage.removeItem("theme"); } - const isDark = localStorage.theme === 'dark' || (localStorage.theme !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches); + const isDark = + localStorage.theme === "dark" || + (localStorage.theme !== "light" && window.matchMedia("(prefers-color-scheme: dark)").matches); setIsDark(isDark); setTheme(theme); - } + }; const browserIdeOptions = ideOptions && orderedIdeOptions(ideOptions, "browser"); const desktopIdeOptions = ideOptions && orderedIdeOptions(ideOptions, "desktop"); @@ -112,90 +121,178 @@ export default function Preferences() { await getGitpodService().server.updateLoggedInUser({ additionalData }); }; - return
- - {ideOptions && <> - {browserIdeOptions && <> -

Browser Editor

-

Choose the default editor for opening workspaces in the browser.

-
- { - browserIdeOptions.map(([id, option]) => { - const selected = defaultIde === id; - const onSelect = () => actuallySetDefaultIde(id); - return renderIdeOption(option, selected, onSelect); - }) - } -
- {ideOptions.options[defaultIde]?.notes && -
    - {ideOptions.options[defaultIde].notes?.map((x, idx) =>
  • 0 ? "mt-2" : ""}>{x}
  • )} -
- } - } - {desktopIdeOptions && <> -

- Desktop Editor - Beta -

-

Optionally, choose the default desktop editor for opening workspaces.

-
- { - desktopIdeOptions.map(([id, option]) => { - const selected = defaultDesktopIde === id; - const onSelect = () => actuallySetDefaultDesktopIde(id); - if (id === DesktopNoneId) { - option.logo = isDark ? IDENoneDark : IDENone - } - return renderIdeOption(option, selected, onSelect); - }) - } -
- {ideOptions.options[defaultDesktopIde]?.notes && -
    - {ideOptions.options[defaultDesktopIde].notes?.map((x, idx) =>
  • 0 ? "mt-2" : ""}>{x}
  • )} -
- } -

- The JetBrains desktop IDEs are currently in beta. Send feedback · Documentation -

- } - actuallySetUseLatestVersion(e.target.checked)}/> - } -

Theme

-

Early bird or night owl? Choose your side.

-
- actuallySetTheme('light')}> -
- -
-
- actuallySetTheme('dark')}> -
- -
-
- actuallySetTheme('system')}> -
- -
-
-
- -

Dotfiles Beta

-

Customize workspaces using dotfiles.

-
-

Repository URL

- setDotfileRepo(e.target.value)} /> -
-

Add a repository URL that includes dotfiles. Gitpod will clone and install your dotfiles for every new workspace.

+ return ( +
+ + {ideOptions && ( + <> + {browserIdeOptions && ( + <> +

Browser Editor

+

+ Choose the default editor for opening workspaces in the browser. +

+
+ {browserIdeOptions.map(([id, option]) => { + const selected = defaultIde === id; + const onSelect = () => actuallySetDefaultIde(id); + return renderIdeOption(option, selected, onSelect); + })} +
+ {ideOptions.options[defaultIde]?.notes && ( + +
    + {ideOptions.options[defaultIde].notes?.map((x, idx) => ( +
  • 0 ? "mt-2" : ""}>{x}
  • + ))} +
+
+ )} + + )} + {desktopIdeOptions && ( + <> +

+ Desktop Editor + + Beta + +

+

+ Optionally, choose the default desktop editor for opening workspaces. +

+
+ {desktopIdeOptions.map(([id, option]) => { + const selected = defaultDesktopIde === id; + const onSelect = () => actuallySetDefaultDesktopIde(id); + if (id === DesktopNoneId) { + option.logo = isDark ? IDENoneDark : IDENone; + } + return renderIdeOption(option, selected, onSelect); + })} +
+ {ideOptions.options[defaultDesktopIde]?.notes && ( + +
    + {ideOptions.options[defaultDesktopIde].notes?.map((x, idx) => ( +
  • 0 ? "mt-2" : ""}>{x}
  • + ))} +
+
+ )} +

+ The JetBrains desktop IDEs are currently in beta.{" "} + + Send feedback + {" "} + ·{" "} + + Documentation + +

+ + )} + actuallySetUseLatestVersion(e.target.checked)} + /> + + )} +

Theme

+

Early bird or night owl? Choose your side.

+
+ actuallySetTheme("light")} + > +
+ + + + + + +
+
+ actuallySetTheme("dark")} + > +
+ + + + + + +
+
+ actuallySetTheme("system")} + > +
+ + + + + + + +
+
+ +

+ Dotfiles{" "} + + Beta + +

+

Customize workspaces using dotfiles.

- +

Repository URL

+ setDotfileRepo(e.target.value)} + /> +
+

+ Add a repository URL that includes dotfiles. Gitpod will clone and install your dotfiles for + every new workspace. +

+
+
+ +
-
- -
; + +
+ ); } function orderedIdeOptions(ideOptions: IDEOptions, type: "browser" | "desktop") { @@ -210,18 +307,27 @@ function orderedIdeOptions(ideOptions: IDEOptions, type: "browser" | "desktop") } function renderIdeOption(option: IDEOption, selected: boolean, onSelect: () => void): JSX.Element { - const card = -
- logo -
- {option.label ?
{option.label}
: <>} -
; + const card = ( + +
+ logo +
+ {option.label ? ( +
+ {option.label} +
+ ) : ( + <> + )} +
+ ); if (option.tooltip) { - return - {card} - ; + return {card}; } return card; } diff --git a/components/dashboard/src/settings/Teams.tsx b/components/dashboard/src/settings/Teams.tsx index 1af34b0f52f17d..4540ffebad774c 100644 --- a/components/dashboard/src/settings/Teams.tsx +++ b/components/dashboard/src/settings/Teams.tsx @@ -4,30 +4,39 @@ * See License-AGPL.txt in the project root for license information. */ -import React, { useEffect, useRef, useState } from "react"; -import { countries } from 'countries-list'; +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { countries } from "countries-list"; import ContextMenu, { ContextMenuEntry } from "../components/ContextMenu"; import { PageWithSubMenu } from "../components/PageWithSubMenu"; import { getGitpodService } from "../service/service"; import AlertBox from "../components/AlertBox"; import Modal from "../components/Modal"; -import { AssigneeIdentifier, TeamSubscription, TeamSubscriptionSlotResolved } from "@gitpod/gitpod-protocol/lib/team-subscription-protocol"; +import { + AssigneeIdentifier, + TeamSubscription, + TeamSubscriptionSlotResolved, +} from "@gitpod/gitpod-protocol/lib/team-subscription-protocol"; import { Currency, Plan, Plans } from "@gitpod/gitpod-protocol/lib/plans"; import { ChargebeeClient } from "../chargebee/chargebee-client"; -import copy from '../images/copy.svg'; -import exclamation from '../images/exclamation.svg'; +import copy from "../images/copy.svg"; +import exclamation from "../images/exclamation.svg"; import { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error"; import { poll, PollOptions } from "../utils"; import settingsMenu from "./settings-menu"; import { Disposable } from "@gitpod/gitpod-protocol"; export default function Teams() { - - return (
- - - -
); + return ( +
+ + + +
+ ); } interface Slot extends TeamSubscriptionSlotResolved { @@ -36,7 +45,6 @@ interface Slot extends TeamSubscriptionSlotResolved { } function AllTeams() { - const [defaultCurrency, setDefaultCurrency] = useState("USD"); const [slots, setSlots] = useState([]); @@ -45,7 +53,9 @@ function AllTeams() { const [isStudent, setIsStudent] = useState(false); const [teamSubscriptions, setTeamSubscriptions] = useState([]); - const [createTeamModal, setCreateTeamModal] = useState<{ types: string[], defaultCurrency: string } | undefined>(undefined); + const [createTeamModal, setCreateTeamModal] = useState<{ types: string[]; defaultCurrency: string } | undefined>( + undefined, + ); const [manageTeamModal, setManageTeamModal] = useState<{ sub: TeamSubscription } | undefined>(undefined); const [inviteMembersModal, setInviteMembersModal] = useState<{ sub: TeamSubscription } | undefined>(undefined); const [addMembersModal, setAddMembersModal] = useState<{ sub: TeamSubscription } | undefined>(undefined); @@ -53,12 +63,12 @@ function AllTeams() { const restorePendingPlanPurchase = () => { const pendingState = restorePendingState("pendingPlanPurchase") as { planId: string } | undefined; return pendingState; - } + }; const restorePendingSlotsPurchase = () => { const pendingState = restorePendingState("pendingSlotsPurchase") as { tsId: string } | undefined; return pendingState; - } + }; const restorePendingState = (key: string) => { const obj = getLocalStorageObject(key); @@ -70,14 +80,18 @@ function AllTeams() { removeLocalStorageObject(key); } } - } + }; const storePendingState = (key: string, obj: any) => { - const expirationDate = new Date(Date.now() + 5 * 60 * 1000).toISOString() + const expirationDate = new Date(Date.now() + 5 * 60 * 1000).toISOString(); setLocalStorageObject(key, { ...obj, expirationDate }); - } + }; - const [pendingPlanPurchase, setPendingPlanPurchase] = useState<{ planId: string } | undefined>(restorePendingPlanPurchase()); - const [pendingSlotsPurchase, setPendingSlotsPurchase] = useState<{ tsId: string } | undefined>(restorePendingSlotsPurchase()); + const [pendingPlanPurchase, setPendingPlanPurchase] = useState<{ planId: string } | undefined>( + restorePendingPlanPurchase(), + ); + const [pendingSlotsPurchase, setPendingSlotsPurchase] = useState<{ tsId: string } | undefined>( + restorePendingSlotsPurchase(), + ); const pendingPlanPurchasePoller = useRef(); const pendingSlotsPurchasePoller = useRef(); @@ -85,12 +99,45 @@ function AllTeams() { const cancelPollers = () => { pendingPlanPurchasePoller.current?.dispose(); pendingSlotsPurchasePoller.current?.dispose(); - } + }; + + const pollForAdditionalSlotsBought = useCallback(() => { + let token: { cancelled?: boolean } = {}; + pendingSlotsPurchasePoller.current?.dispose(); + pendingSlotsPurchasePoller.current = Disposable.create(() => { + token.cancelled = true; + }); + + const opts: PollOptions = { + token, + backoffFactor: 1.4, + retryUntilSeconds: 240, + success: (result) => { + setSlots(result || []); + + setPendingSlotsPurchase(undefined); + }, + stop: () => {}, + }; + poll( + 2, + async () => { + const freshSlots = await getGitpodService().server.tsGetSlots(); + if (freshSlots.length > slots.length) { + return { done: true, result: freshSlots }; + } + return { done: false }; + }, + opts, + ); + }, [slots.length]); useEffect(() => { if (pendingPlanPurchase) { storePendingState("pendingPlanPurchase", pendingPlanPurchase); - pollForPlanPurchased(Plans.getAvailableTeamPlans().filter(p => p.chargebeeId === pendingPlanPurchase.planId)[0]) + pollForPlanPurchased( + Plans.getAvailableTeamPlans().filter((p) => p.chargebeeId === pendingPlanPurchase.planId)[0], + ); } else { pendingPlanPurchasePoller.current?.dispose(); removeLocalStorageObject("pendingPlanPurchase"); @@ -105,14 +152,14 @@ function AllTeams() { pendingSlotsPurchasePoller.current?.dispose(); removeLocalStorageObject("pendingSlotsPurchase"); } - }, [pendingSlotsPurchase]); + }, [pendingSlotsPurchase, pollForAdditionalSlotsBought]); useEffect(() => { queryState(); return function cleanup() { cancelPollers(); - } + }; }, []); const queryState = async () => { @@ -125,7 +172,7 @@ function AllTeams() { getGitpodService().server.isStudent(), ]); - setDefaultCurrency((clientRegion && (countries as any)[clientRegion]?.currency === 'EUR') ? 'EUR' : 'USD'); + setDefaultCurrency(clientRegion && (countries as any)[clientRegion]?.currency === "EUR" ? "EUR" : "USD"); setIsStudent(isStudent); setSlots(slots); @@ -134,7 +181,7 @@ function AllTeams() { } catch (error) { console.log(error); } - } + }; useEffect(() => { if (!isChargebeeCustomer) { @@ -144,53 +191,54 @@ function AllTeams() { } catch (error) { console.log(error); } - })() + })(); } if (pendingPlanPurchase) { - if (teamSubscriptions.some(ts => ts.planId === pendingPlanPurchase.planId)) { + if (teamSubscriptions.some((ts) => ts.planId === pendingPlanPurchase.planId)) { setPendingPlanPurchase(undefined); } } - }, [teamSubscriptions]); + }, [isChargebeeCustomer, pendingPlanPurchase, teamSubscriptions]); useEffect(() => { if (pendingSlotsPurchase) { - if (slots.some(s => s.teamSubscription.id === pendingSlotsPurchase.tsId)) { + if (slots.some((s) => s.teamSubscription.id === pendingSlotsPurchase.tsId)) { setPendingSlotsPurchase(undefined); } } - }, [slots]); + }, [pendingSlotsPurchase, slots]); - const getSubscriptionTypes = () => isStudent ? ['professional', 'professional-new', 'student'] : ['professional', 'professional-new']; + const getSubscriptionTypes = () => + isStudent ? ["professional", "professional-new", "student"] : ["professional", "professional-new"]; const getActiveSubs = () => { const now = new Date().toISOString(); - const activeSubs = (teamSubscriptions || []).filter(ts => TeamSubscription.isActive(ts, now)); + const activeSubs = (teamSubscriptions || []).filter((ts) => TeamSubscription.isActive(ts, now)); return activeSubs; - } + }; const getAvailableSubTypes = () => { - const usedTypes = getActiveSubs().map(sub => getPlan(sub)?.type as string); - const types = getSubscriptionTypes().filter(t => !usedTypes.includes(t)); + const usedTypes = getActiveSubs().map((sub) => getPlan(sub)?.type as string); + const types = getSubscriptionTypes().filter((t) => !usedTypes.includes(t)); return types; - } + }; const getSlotsForSub = (sub: TeamSubscription) => { const result: Slot[] = []; const plan = getPlan(sub); - slots.forEach(s => { + slots.forEach((s) => { if (s.teamSubscription.planId === plan.chargebeeId) { result.push(s); } - }) + }); return result; - } + }; const onBuy = (plan: Plan, quantity: number, sub?: TeamSubscription) => { inputHandler(sub).buySlots(plan, quantity); setCreateTeamModal(undefined); setAddMembersModal(undefined); - } + }; const slotDoPoll = (slot: TeamSubscriptionSlotResolved) => () => pollForSlotUpdate(slot); @@ -201,46 +249,49 @@ function AllTeams() { success: (result) => { if (result) { updateSlot(slot, (s) => { - return { ...result, loading: false } - }) + return { ...result, loading: false }; + }); } }, stop: () => { - updateSlot(slot, (s) => ({ ...s, loading: false })) + updateSlot(slot, (s) => ({ ...s, loading: false })); }, - }; - poll(1, async () => { - const freshSlots = await getGitpodService().server.tsGetSlots(); - const freshSlot = freshSlots.find(s => s.id === slot.id); - if (!freshSlot) { - // Our slot is not included any more: looks like an update, better fetch complete state - queryState(); - return { done: true }; - } + poll( + 1, + async () => { + const freshSlots = await getGitpodService().server.tsGetSlots(); + const freshSlot = freshSlots.find((s) => s.id === slot.id); + if (!freshSlot) { + // Our slot is not included any more: looks like an update, better fetch complete state + queryState(); + return { done: true }; + } - return { - done: freshSlot.state !== slot.state || freshSlot.assigneeId !== slot.assigneeId, - result: freshSlot - }; - // tslint:disable-next-line:align - }, opts); - } + return { + done: freshSlot.state !== slot.state || freshSlot.assigneeId !== slot.assigneeId, + result: freshSlot, + }; + // tslint:disable-next-line:align + }, + opts, + ); + }; const updateSlot = (slot: TeamSubscriptionSlotResolved, update: (s: Slot) => Slot) => { setSlots((prevState) => { - const next = [...prevState] - const i = next.findIndex(s => s.id === slot.id); + const next = [...prevState]; + const i = next.findIndex((s) => s.id === slot.id); if (i >= 0) { next[i] = update(next[i]); } return next; - }) - } + }); + }; const slotSetErrorMessage = (slot: TeamSubscriptionSlotResolved) => (err: any) => { updateSlot(slot, (s) => { - const result = { ...s } + const result = { ...s }; result.errorMsg = err && ((err.data && err.data.msg) || err.message || String(err)); result.loading = false; return result; @@ -250,28 +301,32 @@ function AllTeams() { const slotInputHandler = { assign: (slot: TeamSubscriptionSlotResolved, assigneeIdentifier: string) => { updateSlot(slot, (s) => ({ ...s, loading: true })); - getGitpodService().server.tsAssignSlot(slot.teamSubscription.id, slot.id, assigneeIdentifier) + getGitpodService() + .server.tsAssignSlot(slot.teamSubscription.id, slot.id, assigneeIdentifier) .then(slotDoPoll(slot)) .catch(slotSetErrorMessage(slot)); }, reassign: (slot: TeamSubscriptionSlotResolved, newAssigneeIdentifier: string) => { updateSlot(slot, (s) => ({ ...s, loading: true })); - getGitpodService().server.tsReassignSlot(slot.teamSubscription.id, slot.id, newAssigneeIdentifier) + getGitpodService() + .server.tsReassignSlot(slot.teamSubscription.id, slot.id, newAssigneeIdentifier) .then(slotDoPoll(slot)) .catch(slotSetErrorMessage(slot)); }, deactivate: (slot: TeamSubscriptionSlotResolved) => { updateSlot(slot, (s) => ({ ...s, loading: true })); - getGitpodService().server.tsDeactivateSlot(slot.teamSubscription.id, slot.id) + getGitpodService() + .server.tsDeactivateSlot(slot.teamSubscription.id, slot.id) .then(slotDoPoll(slot)) .catch(slotSetErrorMessage(slot)); }, reactivate: (slot: TeamSubscriptionSlotResolved) => { updateSlot(slot, (s) => ({ ...s, loading: true })); - getGitpodService().server.tsReactivateSlot(slot.teamSubscription.id, slot.id) + getGitpodService() + .server.tsReactivateSlot(slot.teamSubscription.id, slot.id) .then(slotDoPoll(slot)) .catch(slotSetErrorMessage(slot)); - } + }, }; const inputHandler = (ts: TeamSubscription | undefined) => { @@ -284,12 +339,13 @@ function AllTeams() { return; } + getGitpodService() + .server.tsAddSlots(ts.id, quantity) + .then(() => { + setPendingSlotsPurchase({ tsId: ts.id }); - getGitpodService().server.tsAddSlots(ts.id, quantity).then(() => { - setPendingSlotsPurchase({ tsId: ts.id }); - - pollForAdditionalSlotsBought(); - }) + pollForAdditionalSlotsBought(); + }) .catch((err) => { setPendingSlotsPurchase(undefined); @@ -301,30 +357,35 @@ function AllTeams() { // Buy new subscription + initial slots let successful = false; - (await ChargebeeClient.getOrCreate()).checkout(async (server) => { - setPendingPlanPurchase({ planId: plan.chargebeeId }); - - return server.checkout(plan.chargebeeId, quantity) - }, { - success: () => { - successful = true; - pollForPlanPurchased(plan); + (await ChargebeeClient.getOrCreate()).checkout( + async (server) => { + setPendingPlanPurchase({ planId: plan.chargebeeId }); + + return server.checkout(plan.chargebeeId, quantity); }, - close: () => { - if (!successful) { - // Close gets triggered after success, too: Only close if necessary - setPendingPlanPurchase(undefined); - } - } - }); + { + success: () => { + successful = true; + pollForPlanPurchased(plan); + }, + close: () => { + if (!successful) { + // Close gets triggered after success, too: Only close if necessary + setPendingPlanPurchase(undefined); + } + }, + }, + ); } - } + }, }; }; const pollForPlanPurchased = (plan: Plan) => { let token: { cancelled?: boolean } = {}; pendingPlanPurchasePoller.current?.dispose(); - pendingPlanPurchasePoller.current = Disposable.create(() => { token.cancelled = true }); + pendingPlanPurchasePoller.current = Disposable.create(() => { + token.cancelled = true; + }); const opts: PollOptions = { token, @@ -337,215 +398,249 @@ function AllTeams() { setTeamSubscriptions(result || []); setPendingPlanPurchase(undefined); - } - }; - poll(2, async () => { - const now = new Date().toISOString(); - const teamSubscriptions = await getGitpodService().server.tsGet(); - // Has active subscription with given plan? - if (teamSubscriptions.some(t => TeamSubscription.isActive(t, now) && t.planId === plan.chargebeeId)) { - return { done: true, result: teamSubscriptions }; - } else { - return { done: false }; - } - }, opts); - } - const pollForAdditionalSlotsBought = () => { - let token: { cancelled?: boolean } = {}; - pendingSlotsPurchasePoller.current?.dispose(); - pendingSlotsPurchasePoller.current = Disposable.create(() => { token.cancelled = true }); - - const opts: PollOptions = { - token, - backoffFactor: 1.4, - retryUntilSeconds: 240, - success: (result) => { - setSlots(result || []); - - setPendingSlotsPurchase(undefined); }, - stop: () => { - } }; - poll(2, async () => { - const freshSlots = await getGitpodService().server.tsGetSlots(); - if (freshSlots.length > slots.length) { - return { done: true, result: freshSlots }; - } - return { done: false }; - }, opts); - } + poll( + 2, + async () => { + const now = new Date().toISOString(); + const teamSubscriptions = await getGitpodService().server.tsGet(); + // Has active subscription with given plan? + if (teamSubscriptions.some((t) => TeamSubscription.isActive(t, now) && t.planId === plan.chargebeeId)) { + return { done: true, result: teamSubscriptions }; + } else { + return { done: false }; + } + }, + opts, + ); + }; const getPlan = (sub: TeamSubscription) => { - return Plans.getAvailableTeamPlans().filter(p => p.chargebeeId === sub.planId)[0]; - } + return Plans.getAvailableTeamPlans().filter((p) => p.chargebeeId === sub.planId)[0]; + }; const subscriptionMenu = (sub: TeamSubscription) => { const result: ContextMenuEntry[] = []; result.push({ - title: 'Manage Members', - onClick: () => manageMembers(sub) - }) + title: "Manage Members", + onClick: () => manageMembers(sub), + }); result.push({ - title: 'Add Members', - onClick: () => addMembers(sub) - }) + title: "Add Members", + onClick: () => addMembers(sub), + }); result.push({ - title: 'Invite Members', - onClick: () => inviteMembers(sub) - }) + title: "Invite Members", + onClick: () => inviteMembers(sub), + }); return result; }; const showCreateTeamModal = () => { const types = getAvailableSubTypes(); if (types && types.length > 0) { - setCreateTeamModal({ types, defaultCurrency }) + setCreateTeamModal({ types, defaultCurrency }); } - } + }; const manageMembers = (sub: TeamSubscription) => { setManageTeamModal({ sub }); - } + }; const addMembers = (sub: TeamSubscription) => { setAddMembersModal({ sub }); - } + }; const inviteMembers = (sub: TeamSubscription) => { setInviteMembersModal({ sub }); - } + }; const showBilling = async () => { (await ChargebeeClient.getOrCreate()).openPortal(); - } + }; const isPaymentInProgress = (ts: TeamSubscription) => { - return pendingSlotsPurchase && pendingSlotsPurchase.tsId === ts.id - } - - const renderTeams = () => ( -
-
-

All Team Plans

-

Manage team plans and team members.

-
-
- {isChargebeeCustomer && ( - - )} - {getActiveSubs().length > 0 && ( - - )} + return pendingSlotsPurchase && pendingSlotsPurchase.tsId === ts.id; + }; + + const renderTeams = () => ( + +
+
+

All Team Plans

+

Manage team plans and team members.

+
+
+ {isChargebeeCustomer && ( + + )} + {getActiveSubs().length > 0 && ( + + )} +
-
- {createTeamModal && ( - setCreateTeamModal(undefined)} onBuy={onBuy} {...createTeamModal} /> - )} + {createTeamModal && ( + setCreateTeamModal(undefined)} onBuy={onBuy} {...createTeamModal} /> + )} - {manageTeamModal && ( - { queryState(); setManageTeamModal(undefined) }} slotInputHandler={slotInputHandler} slots={getSlotsForSub(manageTeamModal.sub)} /> - )} + {manageTeamModal && ( + { + queryState(); + setManageTeamModal(undefined); + }} + slotInputHandler={slotInputHandler} + slots={getSlotsForSub(manageTeamModal.sub)} + /> + )} - {inviteMembersModal && ( - setInviteMembersModal(undefined)} {...inviteMembersModal} /> - )} + {inviteMembersModal && ( + setInviteMembersModal(undefined)} {...inviteMembersModal} /> + )} - {addMembersModal && ( - setAddMembersModal(undefined)} onBuy={onBuy} {...addMembersModal} /> - )} + {addMembersModal && ( + setAddMembersModal(undefined)} onBuy={onBuy} {...addMembersModal} /> + )} - {(getActiveSubs().length === 0 && !pendingPlanPurchase) && ( -
-
-

No Active Team Plans

-
Get started by creating a team plan
and adding team members. Learn more
- -
-
- )} - - {(getActiveSubs().length > 0 || !!pendingPlanPurchase) && ( -
- {pendingPlanPurchase && ( -
-
-
-   -
-
-
- {Plans.getAvailableTeamPlans().filter(p => p.chargebeeId === pendingPlanPurchase.planId)[0]?.name} - Purchased on {formatDate(new Date().toString())} -
-
-
- Payment in Progress -
-
-
+ {getActiveSubs().length === 0 && !pendingPlanPurchase && ( +
+
+

No Active Team Plans

+
+ Get started by creating a team plan +
and adding team members.{" "} + + Learn more +
+
- )} - {getActiveSubs().map((sub, index) => ( -
-
-
-   -
-
-
- {getPlan(sub)?.name} - Purchased on {formatDate(sub?.startDate)} -
-
- {slots.filter(s => s.state !== 'cancelled' && s.teamSubscription.id === sub.id).length || "–"} - Members -
-
- {isPaymentInProgress(sub) && ( +
+ )} + + {(getActiveSubs().length > 0 || !!pendingPlanPurchase) && ( +
+ {pendingPlanPurchase && ( +
+
+
+   +
+
+
+ + { + Plans.getAvailableTeamPlans().filter( + (p) => p.chargebeeId === pendingPlanPurchase.planId, + )[0]?.name + } + + + Purchased on {formatDate(new Date().toString())} + +
+
Payment in Progress
- )} +
+
-
-
- + )} + {getActiveSubs().map((sub, index) => ( +
+
+
+   +
+
+
+ + {getPlan(sub)?.name} + + + Purchased on {formatDate(sub?.startDate)} + +
+
+ + {slots.filter((s) => s.state !== "cancelled" && s.teamSubscription.id === sub.id) + .length || "–"} + + Members +
+
+ {isPaymentInProgress(sub) && ( +
+ Payment in Progress +
+ )} +
+
+
+ +
-
- ))} -
- )} - ); + ))} +
+ )} + + ); - return (
- {showPaymentUI ? renderTeams() : ( -
-
-

All Team Plans

-

Manage team plans and team members.

+ return ( +
+ {showPaymentUI ? ( + renderTeams() + ) : ( +
+
+

All Team Plans

+

Manage team plans and team members.

+
-
- )} -
); + )} +
+ ); } -function InviteMembersModal(props: { - sub: TeamSubscription, - onClose: () => void -}) { - +function InviteMembersModal(props: { sub: TeamSubscription; onClose: () => void }) { const [copied, setCopied] = useState(false); const getInviteURL = () => { const link = new URL(window.location.href); - link.pathname = '/plans' - link.search = '?teamid=' + props.sub.id; + link.pathname = "/plans"; + link.search = "?teamid=" + props.sub.id; return link.href; - } + }; const copyToClipboard = (text: string) => { const el = document.createElement("textarea"); @@ -561,85 +656,113 @@ function InviteMembersModal(props: { setTimeout(() => setCopied(false), 2000); }; - return ( -

Invite Members

-
-

Invite members to the team plan using the URL below.

- -
- -
- -
copyToClipboard(getInviteURL())}> - + return ( + +

Invite Members

+
+

Invite members to the team plan using the URL below.

+ +
+ +
+ +
copyToClipboard(getInviteURL())}> + Copy Invite URL +
+

+ {copied ? "Copied to clipboard!" : "Use this URL to join this team plan."} +

-

{copied ? "Copied to clipboard!" : "Use this URL to join this team plan."}

- -
-
- -
- ); +
+ +
+ + ); } const quantities = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]; function AddMembersModal(props: { - sub: TeamSubscription, - onBuy: (plan: Plan, quantity: number, sub: TeamSubscription) => void, - onClose: () => void + sub: TeamSubscription; + onBuy: (plan: Plan, quantity: number, sub: TeamSubscription) => void; + onClose: () => void; }) { - const [quantity, setQuantity] = useState(5); const [expectedPrice, setExpectedPrice] = useState(""); + const getPlan = useCallback(() => { + return Plans.getAvailableTeamPlans().filter((p) => p.chargebeeId === props.sub.planId)[0]; + }, [props.sub.planId]); + useEffect(() => { const plan = getPlan(); const expectedPrice = quantity * plan.pricePerMonth; setExpectedPrice(`${Currency.getSymbol(plan.currency)}${expectedPrice}`); - }, [quantity]) - - const getPlan = () => { - return Plans.getAvailableTeamPlans().filter(p => p.chargebeeId === props.sub.planId)[0]; - } - - return ( -

Add Members

-
-

Select the number of members to add to the team plan.

- -
- - -
+ }, [getPlan, quantity]); - Additional Charge: {expectedPrice} per month + return ( + +

Add Members

+
+

Select the number of members to add to the team plan.

+ +
+ + +
-
-
- -
-
); + Additional Charge: {expectedPrice} per month +
+
+ +
+
+ ); } function NewTeamModal(props: { - types: string[], + types: string[]; defaultCurrency: string; - onBuy: (plan: Plan, quantity: number) => void, - onClose: () => void, + onBuy: (plan: Plan, quantity: number) => void; + onClose: () => void; }) { - const getPlan = (type: string, currency: string) => { - return Plans.getAvailableTeamPlans().filter(p => p.type === type && p.currency === currency)[0]; - } + return Plans.getAvailableTeamPlans().filter((p) => p.type === type && p.currency === currency)[0]; + }; const [currency, setCurrency] = useState(props.defaultCurrency); const [type, setType] = useState(props.types[0]); @@ -655,86 +778,112 @@ function NewTeamModal(props: { } const expectedPrice = quantity * newPlan.pricePerMonth; setExpectedPrice(`${Currency.getSymbol(newPlan.currency)}${expectedPrice}`); - }, [currency, type, quantity]) + }, [currency, type, quantity, plan.chargebeeId]); const teamTypeLabel = (type: string) => { return getPlan(type, currency)?.name; - } - - return ( -

New Team Plan

-
-

Create a team plan and add team members.

- -
- - -
+ }; -
- - -
+ return ( + +

New Team Plan

+
+

Create a team plan and add team members.

+ +
+ + +
-
- - -
+
+ + +
- Total: {expectedPrice} per month +
+ + +
-
-
- -
-
); + Total: {expectedPrice} per month +
+
+ +
+
+ ); } -function ManageTeamModal(props: { - slots: Slot[], - slotInputHandler: SlotInputHandler, - onClose: () => void, -}) { - +function ManageTeamModal(props: { slots: Slot[]; slotInputHandler: SlotInputHandler; onClose: () => void }) { const [slots, setSlots] = useState([]); useEffect(() => { - const activeSlots = props.slots.filter(s => s.state !== 'cancelled'); + const activeSlots = props.slots.filter((s) => s.state !== "cancelled"); setSlots(activeSlots); - }, [props.slots]) - - return ( -

Manage Team

-
-

Add members using their username prefixed by the Git Provider's host.

- -
- {slots.map((slot, index) => { - return ( - - ) - })} + }, [props.slots]); + + return ( + +

Manage Team

+
+

+ Add members using their username prefixed by the Git Provider's host. +

+ +
+ {slots.map((slot, index) => { + return ; + })} +
-
-
- -
- ); +
+ +
+ + ); } interface SlotInputHandler { @@ -744,11 +893,7 @@ interface SlotInputHandler { reactivate: (slot: TeamSubscriptionSlotResolved) => void; } -function SlotInput(props: { - slot: Slot, - inputHandler: SlotInputHandler; -}) { - +function SlotInput(props: { slot: Slot; inputHandler: SlotInputHandler }) { const [slot, setSlot] = useState(props.slot); const [editMode, setEditMode] = useState(false); const [assigneeIdentifier, setAssigneeIdentifier] = useState(); @@ -758,14 +903,14 @@ function SlotInput(props: { setEditMode((prev) => { const newEditMode = (prev && !!props.slot.loading) || !!props.slot.errorMsg; return newEditMode; - }) + }); - setSlot(props.slot) - }, [props.slot]) + setSlot(props.slot); + }, [props.slot]); useEffect(() => { setErrorMsg(slot.errorMsg); - }, [slot]) + }, [slot]); const key = `assignee-${props.slot.id}`; @@ -776,10 +921,10 @@ function SlotInput(props: { }; const handleAssignment = () => { - if (slot.state === 'assigned') { - props.inputHandler.reassign(slot, assigneeIdentifier || ''); + if (slot.state === "assigned") { + props.inputHandler.reassign(slot, assigneeIdentifier || ""); } else { - props.inputHandler.assign(slot, assigneeIdentifier || ''); + props.inputHandler.assign(slot, assigneeIdentifier || ""); } }; @@ -792,83 +937,128 @@ function SlotInput(props: { const handleEdit = () => { setEditMode(true); setErrorMsg(undefined); - setAssigneeIdentifier(''); + setAssigneeIdentifier(""); }; const handleDeactivation = () => { props.inputHandler.deactivate(slot); - } + }; const handleReactivation = () => { props.inputHandler.reactivate(slot); - } - + }; const getActions = (slot: Slot) => { const actions: JSX.Element[] = []; if (editMode) { - actions.push(()); - actions.push(()); + actions.push( + , + ); + actions.push( + , + ); } else { switch (slot.state) { - case 'unassigned': - case 'assigned': - actions.push(); + case "unassigned": + case "assigned": + actions.push( + , + ); break; - case 'deactivated': - actions.push(); + case "deactivated": + actions.push( + , + ); break; } } return actions; - } + }; return (
{/* */}
- editMode && setAssigneeIdentifier(e.target.value)} onClick={() => !editMode && handleEdit()} onKeyDown={(e) => { if (e.key === "Enter") { - e.preventDefault() - handleAssignment() + e.preventDefault(); + handleAssignment(); } if (e.key === "Escape") { - e.preventDefault() - handleCancel() + e.preventDefault(); + handleCancel(); } }} /> {getActions(slot)}
- {slot.state === 'deactivated' && ( -

You will no longer be billed for this seat starting {formatDate(slot.cancellationDate)}.

+ {slot.state === "deactivated" && ( +

+ You will no longer be billed for this seat starting {formatDate(slot.cancellationDate)}. +

)} {errorMsg && (
- + Heads up! {errorMsg}
)}
- ) - + ); } function formatDate(date?: string) { try { if (date) { - return new Date(Date.parse(date)).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); + return new Date(Date.parse(date)).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); } - } catch { - } - return "" + } catch {} + return ""; } function getLocalStorageObject(key: string) { @@ -878,20 +1068,17 @@ function getLocalStorageObject(key: string) { return; } return JSON.parse(string); - } catch { - } + } catch {} } function removeLocalStorageObject(key: string): void { try { window.localStorage.removeItem(key); - } catch { - } + } catch {} } function setLocalStorageObject(key: string, object: Object): void { try { window.localStorage.setItem(key, JSON.stringify(object)); - } catch { - } + } catch {} } diff --git a/components/dashboard/src/start/CreateWorkspace.tsx b/components/dashboard/src/start/CreateWorkspace.tsx index e67e3ba250a441..9652441ee9262c 100644 --- a/components/dashboard/src/start/CreateWorkspace.tsx +++ b/components/dashboard/src/start/CreateWorkspace.tsx @@ -6,7 +6,12 @@ import EventEmitter from "events"; import React, { useEffect, Suspense, useContext, useState } from "react"; -import { CreateWorkspaceMode, WorkspaceCreationResult, RunningWorkspacePrebuildStarting, ContextURL } from "@gitpod/gitpod-protocol"; +import { + CreateWorkspaceMode, + WorkspaceCreationResult, + RunningWorkspacePrebuildStarting, + ContextURL, +} from "@gitpod/gitpod-protocol"; import { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error"; import Modal from "../components/Modal"; import { getGitpodService, gitpodHostUrl } from "../service/service"; @@ -19,352 +24,489 @@ import { SelectAccountModal } from "../settings/SelectAccountModal"; import { watchHeadlessLogs } from "../components/PrebuildLogs"; import CodeText from "../components/CodeText"; -const WorkspaceLogs = React.lazy(() => import('../components/WorkspaceLogs')); +const WorkspaceLogs = React.lazy(() => import("../components/WorkspaceLogs")); export interface CreateWorkspaceProps { - contextUrl: string; + contextUrl: string; } export interface CreateWorkspaceState { - result?: WorkspaceCreationResult; - error?: StartWorkspaceError; - selectAccountError?: SelectAccountPayload; - stillParsing: boolean; + result?: WorkspaceCreationResult; + error?: StartWorkspaceError; + selectAccountError?: SelectAccountPayload; + stillParsing: boolean; } export default class CreateWorkspace extends React.Component { + constructor(props: CreateWorkspaceProps) { + super(props); + this.state = { stillParsing: true }; + } - constructor(props: CreateWorkspaceProps) { - super(props); - this.state = { stillParsing: true }; - } - - componentDidMount() { - this.createWorkspace(); - } - - async createWorkspace(mode = CreateWorkspaceMode.SelectIfRunning, forceDefaultConfig = false) { - // Invalidate any previous result. - this.setState({ result: undefined, stillParsing: true }); - - // We assume anything longer than 3 seconds is no longer just parsing the context URL (i.e. it's now creating a workspace). - let timeout = setTimeout(() => this.setState({ stillParsing: false }), 3000); - - try { - const result = await getGitpodService().server.createWorkspace({ - contextUrl: this.props.contextUrl, - mode, - forceDefaultConfig - }); - if (result.workspaceURL) { - window.location.href = result.workspaceURL; - return; - } - clearTimeout(timeout); - this.setState({ result, stillParsing: false }); - } catch (error) { - clearTimeout(timeout); - console.error(error); - this.setState({ error, stillParsing: false }); + componentDidMount() { + this.createWorkspace(); } - } - - async tryAuthorize(host: string, scopes?: string[]) { - try { - await openAuthorizeWindow({ - host, - scopes, - onSuccess: () => { - window.location.reload(); - }, - onError: (error) => { - if (typeof error === "string") { - try { - const payload = JSON.parse(error); - if (SelectAccountPayload.is(payload)) { - this.setState({ selectAccountError: payload }); - } - } catch (error) { - console.log(error); + + async createWorkspace(mode = CreateWorkspaceMode.SelectIfRunning, forceDefaultConfig = false) { + // Invalidate any previous result. + this.setState({ result: undefined, stillParsing: true }); + + // We assume anything longer than 3 seconds is no longer just parsing the context URL (i.e. it's now creating a workspace). + let timeout = setTimeout(() => this.setState({ stillParsing: false }), 3000); + + try { + const result = await getGitpodService().server.createWorkspace({ + contextUrl: this.props.contextUrl, + mode, + forceDefaultConfig, + }); + if (result.workspaceURL) { + window.location.href = result.workspaceURL; + return; } - } + clearTimeout(timeout); + this.setState({ result, stillParsing: false }); + } catch (error) { + clearTimeout(timeout); + console.error(error); + this.setState({ error, stillParsing: false }); } - }); - } catch (error) { - console.log(error) - } - }; - - render() { - if (SelectAccountPayload.is(this.state.selectAccountError)) { - return ( -
- { - window.location.href = gitpodHostUrl.asAccessControl().toString(); - }} /> -
-
); } - let phase = StartPhase.Checking; - let statusMessage =

{this.state.stillParsing ? 'Parsing context …' : 'Preparing workspace …'}

; - - let error = this.state?.error; - if (error) { - switch (error.code) { - case ErrorCodes.CONTEXT_PARSE_ERROR: - statusMessage =
-

Are you trying to open a Git repository from a self-hosted instance? Add integration

-
; - break; - case ErrorCodes.INVALID_GITPOD_YML: - statusMessage =
- -
; - break; - case ErrorCodes.NOT_AUTHENTICATED: - statusMessage =
- -
; - break; - case ErrorCodes.PERMISSION_DENIED: - statusMessage =

Access is not allowed

; - break; - case ErrorCodes.USER_BLOCKED: - window.location.href = '/blocked'; - return; - case ErrorCodes.NOT_FOUND: - return ; - case ErrorCodes.TOO_MANY_RUNNING_WORKSPACES: - // HACK: Hide the error (behind the modal) - error = undefined; - phase = StartPhase.Stopped; - statusMessage = ; - break; - case ErrorCodes.NOT_ENOUGH_CREDIT: - // HACK: Hide the error (behind the modal) - error = undefined; - phase = StartPhase.Stopped; - statusMessage = ; - break; - default: - statusMessage =

Unknown Error: {JSON.stringify(this.state?.error, null, 2)}

; - break; - } + async tryAuthorize(host: string, scopes?: string[]) { + try { + await openAuthorizeWindow({ + host, + scopes, + onSuccess: () => { + window.location.reload(); + }, + onError: (error) => { + if (typeof error === "string") { + try { + const payload = JSON.parse(error); + if (SelectAccountPayload.is(payload)) { + this.setState({ selectAccountError: payload }); + } + } catch (error) { + console.log(error); + } + } + }, + }); + } catch (error) { + console.log(error); + } } - const result = this.state?.result; - if (result?.createdWorkspaceId) { - return ; - } + render() { + if (SelectAccountPayload.is(this.state.selectAccountError)) { + return ( + +
+ { + window.location.href = gitpodHostUrl.asAccessControl().toString(); + }} + /> +
+
+ ); + } - else if (result?.existingWorkspaces) { - statusMessage = { }}> -

Running Workspaces

-
-

You already have running workspaces with the same context. You can open an existing one or open a new workspace.

- <> - {result?.existingWorkspaces?.map(w => { - const normalizedContextUrl = ContextURL.getNormalizedURL(w.workspace)?.toString() || "undefined"; - return ( - -
-

{w.workspace.id}

-

{normalizedContextUrl}

-
-
- ); - })} - -
-
- -
-
; - } + let phase = StartPhase.Checking; + let statusMessage = ( +

+ {this.state.stillParsing ? "Parsing context …" : "Preparing workspace …"} +

+ ); + + let error = this.state?.error; + if (error) { + switch (error.code) { + case ErrorCodes.CONTEXT_PARSE_ERROR: + statusMessage = ( +
+

+ Are you trying to open a Git repository from a self-hosted instance?{" "} + + Add integration + +

+
+ ); + break; + case ErrorCodes.INVALID_GITPOD_YML: + statusMessage = ( +
+ +
+ ); + break; + case ErrorCodes.NOT_AUTHENTICATED: + statusMessage = ( +
+ +
+ ); + break; + case ErrorCodes.PERMISSION_DENIED: + statusMessage =

Access is not allowed

; + break; + case ErrorCodes.USER_BLOCKED: + window.location.href = "/blocked"; + return; + case ErrorCodes.NOT_FOUND: + return ; + case ErrorCodes.TOO_MANY_RUNNING_WORKSPACES: + // HACK: Hide the error (behind the modal) + error = undefined; + phase = StartPhase.Stopped; + statusMessage = ; + break; + case ErrorCodes.NOT_ENOUGH_CREDIT: + // HACK: Hide the error (behind the modal) + error = undefined; + phase = StartPhase.Stopped; + statusMessage = ; + break; + default: + statusMessage = ( +

+ Unknown Error: {JSON.stringify(this.state?.error, null, 2)} +

+ ); + break; + } + } - else if (result?.runningWorkspacePrebuild) { - return this.createWorkspace(CreateWorkspaceMode.ForceNew)} - onPrebuildSucceeded={() => this.createWorkspace(CreateWorkspaceMode.UsePrebuild)} - />; - } + const result = this.state?.result; + if (result?.createdWorkspaceId) { + return ; + } else if (result?.existingWorkspaces) { + statusMessage = ( + {}}> +

Running Workspaces

+
+

+ You already have running workspaces with the same context. You can open an existing one or + open a new workspace. +

+ <> + {result?.existingWorkspaces?.map((w) => { + const normalizedContextUrl = + ContextURL.getNormalizedURL(w.workspace)?.toString() || "undefined"; + return ( + +
+

+ {w.workspace.id} +

+

+ {normalizedContextUrl} +

+
+
+ ); + })} + +
+
+ +
+
+ ); + } else if (result?.runningWorkspacePrebuild) { + return ( + this.createWorkspace(CreateWorkspaceMode.ForceNew)} + onPrebuildSucceeded={() => this.createWorkspace(CreateWorkspaceMode.UsePrebuild)} + /> + ); + } - return - {statusMessage} - {error &&
- -

- Docs - - Status - - Blog -

-
} -
; - } + return ( + + {statusMessage} + {error && ( + + )} + + ); + } } function LimitReachedModal(p: { children: React.ReactNode }) { - const { user } = useContext(UserContext); - return { }}> -

- Limit Reached - {user?.name -

-
- {p.children} -
- -
; + const { user } = useContext(UserContext); + return ( + {}}> +

+ Limit Reached + {user?.name +

+
+ {p.children} +
+ +
+ ); } function LimitReachedParallelWorkspacesModal() { - return -

You have reached the limit of parallel running workspaces for your account. Please, upgrade or stop one of the running workspaces.

-
; + return ( + +

+ You have reached the limit of parallel running workspaces for your account. Please, upgrade or stop one + of the running workspaces. +

+
+ ); } function LimitReachedOutOfHours() { - return -

You have reached the limit of monthly workspace hours for your account. Please upgrade to get more hours for your workspaces.

-
; + return ( + +

+ You have reached the limit of monthly workspace hours for your account. Please upgrade to get more hours + for your workspaces. +

+
+ ); } function RepositoryNotFoundView(p: { error: StartWorkspaceError }) { - const [statusMessage, setStatusMessage] = useState(); - const { host, owner, repoName, userIsOwner, userScopes, lastUpdate } = p.error.data; - const repoFullName = (owner && repoName) ? `${owner}/${repoName}` : ''; - - useEffect(() => { - (async () => { - console.log('host', host); - console.log('owner', owner); - console.log('repoName', repoName); - console.log('userIsOwner', userIsOwner); - console.log('userScopes', userScopes); - console.log('lastUpdate', lastUpdate); - - const authProvider = (await getGitpodService().server.getAuthProviders()).find(p => p.host === host); - if (!authProvider) { - return; - } - - // TODO: this should be aware of already granted permissions - const missingScope = authProvider.authProviderType === 'GitHub' ? 'repo' : 'read_repository'; - const authorizeURL = gitpodHostUrl.withApi({ - pathname: '/authorize', - search: `returnTo=${encodeURIComponent(window.location.toString())}&host=${host}&scopes=${missingScope}` - }).toString(); - - if (!userScopes.includes(missingScope)) { - setStatusMessage(
-

The repository may be private. Please authorize Gitpod to access to private repositories.

- -
); - return; - } - - if (userIsOwner) { - setStatusMessage(
-

The repository was not found in your account.

-
); - return; - } - - let updatedRecently = false; - if (lastUpdate && typeof lastUpdate === 'string') { - try { - const minutes = (Date.now() - Date.parse(lastUpdate)) / 1000 / 60; - updatedRecently = minutes < 5; - } catch { - // ignore - } - } - - if (!updatedRecently) { - setStatusMessage(
-

Permission to access private repositories has been granted. If you are a member of {owner}, please try to request access for Gitpod.

- -
); - return; - } - - setStatusMessage(
-

Your access token was updated recently. Please try again if the repository exists and Gitpod was approved for {owner}.

- -
); - })(); - }, []); - - return ( - -

- {repoFullName} -

- {statusMessage} -
- ); + const [statusMessage, setStatusMessage] = useState(); + const { host, owner, repoName, userIsOwner, userScopes, lastUpdate } = p.error.data; + const repoFullName = owner && repoName ? `${owner}/${repoName}` : ""; + + useEffect(() => { + (async () => { + console.log("host", host); + console.log("owner", owner); + console.log("repoName", repoName); + console.log("userIsOwner", userIsOwner); + console.log("userScopes", userScopes); + console.log("lastUpdate", lastUpdate); + + const authProvider = (await getGitpodService().server.getAuthProviders()).find((p) => p.host === host); + if (!authProvider) { + return; + } + + // TODO: this should be aware of already granted permissions + const missingScope = authProvider.authProviderType === "GitHub" ? "repo" : "read_repository"; + const authorizeURL = gitpodHostUrl + .withApi({ + pathname: "/authorize", + search: `returnTo=${encodeURIComponent( + window.location.toString(), + )}&host=${host}&scopes=${missingScope}`, + }) + .toString(); + + if (!userScopes.includes(missingScope)) { + setStatusMessage( +
+

+ The repository may be private. Please authorize Gitpod to access to private repositories. +

+ + + +
, + ); + return; + } + + if (userIsOwner) { + setStatusMessage( +
+

The repository was not found in your account.

+
, + ); + return; + } + + let updatedRecently = false; + if (lastUpdate && typeof lastUpdate === "string") { + try { + const minutes = (Date.now() - Date.parse(lastUpdate)) / 1000 / 60; + updatedRecently = minutes < 5; + } catch { + // ignore + } + } + + if (!updatedRecently) { + setStatusMessage( +
+

+ Permission to access private repositories has been granted. If you are a member of{" "} + {owner}, please try to request access for Gitpod. +

+ + + +
, + ); + return; + } + + setStatusMessage( +
+

+ Your access token was updated recently. Please try again if the repository exists and Gitpod was + approved for {owner}. +

+ + + +
, + ); + })(); + }, [host, lastUpdate, owner, repoName, userIsOwner, userScopes]); + + return ( + +

+ {repoFullName} +

+ {statusMessage} +
+ ); } interface RunningPrebuildViewProps { - runningPrebuild: { - prebuildID: string - workspaceID: string - instanceID: string - starting: RunningWorkspacePrebuildStarting - sameCluster: boolean - }; - onIgnorePrebuild: () => void; - onPrebuildSucceeded: () => void; + runningPrebuild: { + prebuildID: string; + workspaceID: string; + instanceID: string; + starting: RunningWorkspacePrebuildStarting; + sameCluster: boolean; + }; + onIgnorePrebuild: () => void; + onPrebuildSucceeded: () => void; } function RunningPrebuildView(props: RunningPrebuildViewProps) { - const logsEmitter = new EventEmitter(); - let pollTimeout: NodeJS.Timeout | undefined; - let prebuildDoneTriggered: boolean = false; - - useEffect(() => { - const checkIsPrebuildDone = async (): Promise => { - if (prebuildDoneTriggered) { - console.debug("prebuild done already triggered, doing nothing"); - return true; - } - - const done = await getGitpodService().server.isPrebuildDone(props.runningPrebuild.prebuildID); - if (done) { - // note: this treats "done" as "available" which is not equivalent. - // This works because the backend ignores prebuilds which are not "available", and happily starts a workspace as if there was no prebuild at all. - prebuildDoneTriggered = true; - props.onPrebuildSucceeded(); - return true; - } - return false; - }; - const pollIsPrebuildDone = async () => { - clearTimeout(pollTimeout!); - await checkIsPrebuildDone(); - pollTimeout = setTimeout(pollIsPrebuildDone, 10000); - }; + const logsEmitter = new EventEmitter(); + let pollTimeout: NodeJS.Timeout | undefined; + let prebuildDoneTriggered: boolean = false; + + useEffect(() => { + const checkIsPrebuildDone = async (): Promise => { + if (prebuildDoneTriggered) { + console.debug("prebuild done already triggered, doing nothing"); + return true; + } - const disposables = watchHeadlessLogs(props.runningPrebuild.instanceID, (chunk) => logsEmitter.emit('logs', chunk), checkIsPrebuildDone); - return function cleanup() { - clearTimeout(pollTimeout!); - disposables.dispose(); - }; - }, []); - - return - }> - - - - ; -} \ No newline at end of file + const done = await getGitpodService().server.isPrebuildDone(props.runningPrebuild.prebuildID); + if (done) { + // note: this treats "done" as "available" which is not equivalent. + // This works because the backend ignores prebuilds which are not "available", and happily starts a workspace as if there was no prebuild at all. + // TODO: this doesn't really work, or it works by chance, because the following assignment + // will be lost on each re-render. + // eslint-disable-next-line react-hooks/exhaustive-deps + prebuildDoneTriggered = true; + props.onPrebuildSucceeded(); + return true; + } + return false; + }; + const pollIsPrebuildDone = async () => { + clearTimeout(pollTimeout!); + await checkIsPrebuildDone(); + // TODO: this doesn't really work, or it works by chance, because the following assignment + // will be lost on each re-render. + // eslint-disable-next-line react-hooks/exhaustive-deps, @typescript-eslint/no-unused-vars + pollTimeout = setTimeout(pollIsPrebuildDone, 10000); + }; + + const disposables = watchHeadlessLogs( + props.runningPrebuild.instanceID, + (chunk) => logsEmitter.emit("logs", chunk), + checkIsPrebuildDone, + ); + return function cleanup() { + clearTimeout(pollTimeout!); + disposables.dispose(); + }; + }, []); + + return ( + + }> + + + + + ); +} diff --git a/components/dashboard/src/start/StartWorkspace.tsx b/components/dashboard/src/start/StartWorkspace.tsx index fe2f866b453632..bd23381bfdb536 100644 --- a/components/dashboard/src/start/StartWorkspace.tsx +++ b/components/dashboard/src/start/StartWorkspace.tsx @@ -4,13 +4,23 @@ * See License-AGPL.txt in the project root for license information. */ -import { ContextURL, DisposableCollection, GitpodServer, RateLimiterError, StartWorkspaceResult, WithPrebuild, Workspace, WorkspaceImageBuild, WorkspaceInstance } from "@gitpod/gitpod-protocol"; +import { + ContextURL, + DisposableCollection, + GitpodServer, + RateLimiterError, + StartWorkspaceResult, + WithPrebuild, + Workspace, + WorkspaceImageBuild, + WorkspaceInstance, +} from "@gitpod/gitpod-protocol"; import { IDEOptions } from "@gitpod/gitpod-protocol/lib/ide-protocol"; import { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error"; import EventEmitter from "events"; import * as queryString from "query-string"; -import React, { Suspense, useEffect } from "react"; -import { v4 } from 'uuid'; +import React, { Suspense, useEffect, useMemo } from "react"; +import { v4 } from "uuid"; import Arrow from "../components/Arrow"; import ContextMenu from "../components/ContextMenu"; import PendingChangesDropdown from "../components/PendingChangesDropdown"; @@ -19,604 +29,729 @@ import { getGitpodService, gitpodHostUrl } from "../service/service"; import { StartPage, StartPhase, StartWorkspaceError } from "./StartPage"; const sessionId = v4(); -const WorkspaceLogs = React.lazy(() => import('../components/WorkspaceLogs')); +const WorkspaceLogs = React.lazy(() => import("../components/WorkspaceLogs")); export interface StartWorkspaceProps { - workspaceId: string, - runsInIFrame: boolean, - /** - * This flag is used to break the autostart-cycle explained in https://github.com/gitpod-io/gitpod/issues/8043 - */ - dontAutostart: boolean, + workspaceId: string; + runsInIFrame: boolean; + /** + * This flag is used to break the autostart-cycle explained in https://github.com/gitpod-io/gitpod/issues/8043 + */ + dontAutostart: boolean; } export function parseProps(workspaceId: string, search?: string): StartWorkspaceProps { - const params = parseParameters(search); - const runsInIFrame = window.top !== window.self; - return { - workspaceId, - runsInIFrame: window.top !== window.self, - // Either: - // - not_found: we were sent back from a workspace cluster/IDE URL where we expected a workspace to be running but it wasn't because either: - // - this is a (very) old tab and the workspace already timed out - // - due to a start error our workspace terminated very quickly between: - // a) us being redirected to that IDEUrl (based on the first ws-manager update) and - // b) our requests being validated by ws-proxy - // - runsInIFrame (IDE case): - // - we assume the workspace has already been started for us - // - we don't know it's instanceId - dontAutostart: params.notFound || runsInIFrame, - } + const params = parseParameters(search); + const runsInIFrame = window.top !== window.self; + return { + workspaceId, + runsInIFrame: window.top !== window.self, + // Either: + // - not_found: we were sent back from a workspace cluster/IDE URL where we expected a workspace to be running but it wasn't because either: + // - this is a (very) old tab and the workspace already timed out + // - due to a start error our workspace terminated very quickly between: + // a) us being redirected to that IDEUrl (based on the first ws-manager update) and + // b) our requests being validated by ws-proxy + // - runsInIFrame (IDE case): + // - we assume the workspace has already been started for us + // - we don't know it's instanceId + dontAutostart: params.notFound || runsInIFrame, + }; } function parseParameters(search?: string): { notFound?: boolean } { - try { - if (search === undefined) { - return {}; + try { + if (search === undefined) { + return {}; + } + const params = queryString.parse(search, { parseBooleans: true }); + const notFound = !!(params && params["not_found"]); + return { + notFound, + }; + } catch (err) { + console.error("/start: error parsing search params", err); + return {}; } - const params = queryString.parse(search, {parseBooleans: true}); - const notFound = !!(params && params["not_found"]); - return { - notFound, - }; - } catch (err) { - console.error("/start: error parsing search params", err); - return {}; - } } export interface StartWorkspaceState { - /** - * This is set to the istanceId we started (think we started on). - * We only receive updates for this particular instance, or none if not set. - */ - startedInstanceId?: string; - workspaceInstance?: WorkspaceInstance; - workspace?: Workspace; - hasImageBuildLogs?: boolean; - error?: StartWorkspaceError; - desktopIde?: { - link: string - label: string - clientID?: string - }; - ideOptions?: IDEOptions; + /** + * This is set to the istanceId we started (think we started on). + * We only receive updates for this particular instance, or none if not set. + */ + startedInstanceId?: string; + workspaceInstance?: WorkspaceInstance; + workspace?: Workspace; + hasImageBuildLogs?: boolean; + error?: StartWorkspaceError; + desktopIde?: { + link: string; + label: string; + clientID?: string; + }; + ideOptions?: IDEOptions; } export default class StartWorkspace extends React.Component { + constructor(props: StartWorkspaceProps) { + super(props); + this.state = {}; + } - constructor(props: StartWorkspaceProps) { - super(props); - this.state = {}; - } - - private readonly toDispose = new DisposableCollection(); - componentWillMount() { - if (this.props.runsInIFrame) { - window.parent.postMessage({ type: '$setSessionId', sessionId }, '*'); - const setStateEventListener = (event: MessageEvent) => { - if (event.data.type === 'setState' && 'state' in event.data && typeof event.data['state'] === 'object') { - if (event.data.state.ideFrontendFailureCause) { - const error = { message: event.data.state.ideFrontendFailureCause }; + private readonly toDispose = new DisposableCollection(); + componentWillMount() { + if (this.props.runsInIFrame) { + window.parent.postMessage({ type: "$setSessionId", sessionId }, "*"); + const setStateEventListener = (event: MessageEvent) => { + if ( + event.data.type === "setState" && + "state" in event.data && + typeof event.data["state"] === "object" + ) { + if (event.data.state.ideFrontendFailureCause) { + const error = { message: event.data.state.ideFrontendFailureCause }; + this.setState({ error }); + } + if (event.data.state.desktopIdeLink) { + const label = event.data.state.desktopIdeLabel || "Open Desktop IDE"; + const clientID = event.data.state.desktopIdeClientID; + this.setState({ desktopIde: { link: event.data.state.desktopIdeLink, label, clientID } }); + } + } + }; + window.addEventListener("message", setStateEventListener, false); + this.toDispose.push({ + dispose: () => window.removeEventListener("message", setStateEventListener), + }); + } + + try { + this.toDispose.push(getGitpodService().registerClient(this)); + } catch (error) { + console.error(error); this.setState({ error }); - } - if (event.data.state.desktopIdeLink) { - const label = event.data.state.desktopIdeLabel || "Open Desktop IDE"; - const clientID = event.data.state.desktopIdeClientID; - this.setState({ desktopIde: { link: event.data.state.desktopIdeLink, label, clientID } }); - } } - } - window.addEventListener('message', setStateEventListener, false); - this.toDispose.push({ - dispose: () => window.removeEventListener('message', setStateEventListener) - }); - } - try { - this.toDispose.push(getGitpodService().registerClient(this)); - } catch (error) { - console.error(error); - this.setState({ error }); - } + if (this.props.dontAutostart) { + // we saw errors previously, or run in-frame + this.fetchWorkspaceInfo(undefined); + } else { + // dashboard case (w/o previous errors): start workspace as quickly as possible + this.startWorkspace(); + } - if (this.props.dontAutostart) { - // we saw errors previously, or run in-frame - this.fetchWorkspaceInfo(undefined); - } else { - // dashboard case (w/o previous errors): start workspace as quickly as possible - this.startWorkspace(); + // query IDE options so we can show them if necessary once the workspace is running + this.fetchIDEOptions(); } - // query IDE options so we can show them if necessary once the workspace is running - this.fetchIDEOptions(); - } - - componentWillUnmount() { - this.toDispose.dispose(); - } - - componentDidUpdate(prevPros: StartWorkspaceProps, prevState: StartWorkspaceState) { - const newPhase = this.state?.workspaceInstance?.status.phase; - const oldPhase = prevState.workspaceInstance?.status.phase; - if (newPhase !== oldPhase) { - getGitpodService().server.trackEvent({ - event: "status_rendered", - properties: { - sessionId, - instanceId: this.state.workspaceInstance?.id, - workspaceId: this.props.workspaceId, - type: this.state.workspace?.type, - phase: newPhase - }, - }); + componentWillUnmount() { + this.toDispose.dispose(); } - if (!!this.state.error && this.state.error !== prevState.error) { - getGitpodService().server.trackEvent({ - event: "error_rendered", - properties: { - sessionId, - instanceId: this.state.workspaceInstance?.id, - workspaceId: this.state?.workspace?.id, - type: this.state.workspace?.type, - error: this.state.error - }, - }); - } - } - - async startWorkspace(restart = false, forceDefaultImage = false) { - const state = this.state; - if (state) { - if (!restart && (state.startedInstanceId /* || state.errorMessage */)) { - // We stick with a started instance until we're explicitly told not to - return; - } - } + componentDidUpdate(prevPros: StartWorkspaceProps, prevState: StartWorkspaceState) { + const newPhase = this.state?.workspaceInstance?.status.phase; + const oldPhase = prevState.workspaceInstance?.status.phase; + if (newPhase !== oldPhase) { + getGitpodService().server.trackEvent({ + event: "status_rendered", + properties: { + sessionId, + instanceId: this.state.workspaceInstance?.id, + workspaceId: this.props.workspaceId, + type: this.state.workspace?.type, + phase: newPhase, + }, + }); + } - const { workspaceId } = this.props; - try { - const result = await this.startWorkspaceRateLimited(workspaceId, { forceDefaultImage }); - if (!result) { - throw new Error("No result!"); - } - console.log("/start: started workspace instance: " + result.instanceID); - // redirect to workspaceURL if we are not yet running in an iframe - if (!this.props.runsInIFrame && result.workspaceURL) { - this.redirectTo(result.workspaceURL); - return; - } - // Start listening too instance updates - and explicitly query state once to guarantee we get at least one update - // (needed for already started workspaces, and not hanging in 'Starting ...' for too long) - this.fetchWorkspaceInfo(result.instanceID); - } catch (error) { - console.error(error); - if (typeof error === 'string') { - error = { message: error }; - } - if (error?.code === ErrorCodes.USER_BLOCKED) { - this.redirectTo(gitpodHostUrl.with({ pathname: '/blocked' }).toString()); - return; - } - this.setState({ error }); - } - } - - /** - * TODO(gpl) Ideally this can be pushed into the GitpodService implementation. But to get started we hand-roll it here. - * @param workspaceId - * @param options - * @returns - */ - protected async startWorkspaceRateLimited(workspaceId: string, options: GitpodServer.StartWorkspaceOptions): Promise { - let retries = 0; - while (true) { - try { - return await getGitpodService().server.startWorkspace(workspaceId, options); - } catch (err) { - if (err?.code !== ErrorCodes.TOO_MANY_REQUESTS) { - throw err; + if (!!this.state.error && this.state.error !== prevState.error) { + getGitpodService().server.trackEvent({ + event: "error_rendered", + properties: { + sessionId, + instanceId: this.state.workspaceInstance?.id, + workspaceId: this.state?.workspace?.id, + type: this.state.workspace?.type, + error: this.state.error, + }, + }); } + } - if (retries >= 10) { - throw err; + async startWorkspace(restart = false, forceDefaultImage = false) { + const state = this.state; + if (state) { + if (!restart && state.startedInstanceId /* || state.errorMessage */) { + // We stick with a started instance until we're explicitly told not to + return; + } } - retries++; - const data = err?.data as RateLimiterError | undefined; - const timeoutSeconds = data?.retryAfter || 5; - console.log(`startWorkspace was rate-limited: waiting for ${timeoutSeconds}s before doing ${retries}nd retry...`) - await new Promise(resolve => setTimeout(resolve, timeoutSeconds * 1000)); - } - } - } - - /** - * Fetches initial WorkspaceInfo from the server. If there is a WorkspaceInstance for workspaceId, we feed it - * into "onInstanceUpdate" and start accepting further updates. - * - * @param startedInstanceId The instanceId we want to listen on - */ - async fetchWorkspaceInfo(startedInstanceId: string | undefined) { - // this ensures we're receiving updates for this instance - if (startedInstanceId) { - this.setState({ startedInstanceId }); + const { workspaceId } = this.props; + try { + const result = await this.startWorkspaceRateLimited(workspaceId, { forceDefaultImage }); + if (!result) { + throw new Error("No result!"); + } + console.log("/start: started workspace instance: " + result.instanceID); + // redirect to workspaceURL if we are not yet running in an iframe + if (!this.props.runsInIFrame && result.workspaceURL) { + this.redirectTo(result.workspaceURL); + return; + } + // Start listening too instance updates - and explicitly query state once to guarantee we get at least one update + // (needed for already started workspaces, and not hanging in 'Starting ...' for too long) + this.fetchWorkspaceInfo(result.instanceID); + } catch (error) { + console.error(error); + if (typeof error === "string") { + // TODO: this needs a bit of refactoring, possibly server-side to always ensure a properly + // structured error message. + // eslint-disable-next-line no-ex-assign + error = { message: error }; + } + if (error?.code === ErrorCodes.USER_BLOCKED) { + this.redirectTo(gitpodHostUrl.with({ pathname: "/blocked" }).toString()); + return; + } + this.setState({ error }); + } } - const { workspaceId } = this.props; - try { - const info = await getGitpodService().server.getWorkspace(workspaceId); - if (info.latestInstance) { - const instance = info.latestInstance; - this.setState((s) => ({ - workspace: info.workspace, - startedInstanceId: s.startedInstanceId || instance.id, // note: here's a potential mismatch between startedInstanceId and instance.id. TODO(gpl) How to handle this? - })); - this.onInstanceUpdate(instance); - } - } catch (error) { - console.error(error); - this.setState({ error }); - } - } - - /** - * Fetches the current IDEOptions config for this user - * - * TODO(gpl) Ideally this would be part of the WorkspaceInstance shape, really. And we'd display options based on - * what support it was started with. - */ - protected async fetchIDEOptions() { - const ideOptions = await getGitpodService().server.getIDEOptions(); - this.setState({ ideOptions }); - } - - notifyDidOpenConnection() { - this.fetchWorkspaceInfo(undefined); - } - - async onInstanceUpdate(workspaceInstance: WorkspaceInstance) { - if (workspaceInstance.workspaceId !== this.props.workspaceId) { - return; - } + /** + * TODO(gpl) Ideally this can be pushed into the GitpodService implementation. But to get started we hand-roll it here. + * @param workspaceId + * @param options + * @returns + */ + protected async startWorkspaceRateLimited( + workspaceId: string, + options: GitpodServer.StartWorkspaceOptions, + ): Promise { + let retries = 0; + while (true) { + try { + return await getGitpodService().server.startWorkspace(workspaceId, options); + } catch (err) { + if (err?.code !== ErrorCodes.TOO_MANY_REQUESTS) { + throw err; + } - // Here we filter out updates to instances we haven't started to avoid issues with updates coming in out-of-order - // (e.g., multiple "stopped" events from the older instance, where we already started a fresh one after the first) - // Only exception is when we do the switch from the "old" to the "new" one. - const startedInstanceId = this.state?.startedInstanceId; - if (startedInstanceId !== workspaceInstance.id) { - // do we want to switch to "new" instance we just received an update for? - const switchToNewInstance = this.state.workspaceInstance?.status.phase === "stopped" && workspaceInstance.status.phase !== "stopped"; - if (!switchToNewInstance) { - return; - } - this.setState({ - startedInstanceId: workspaceInstance.id, - workspaceInstance, - }); - - // now we're listening to a new instance, which might have been started with other IDEoptions - this.fetchIDEOptions(); + if (retries >= 10) { + throw err; + } + retries++; + + const data = err?.data as RateLimiterError | undefined; + const timeoutSeconds = data?.retryAfter || 5; + console.log( + `startWorkspace was rate-limited: waiting for ${timeoutSeconds}s before doing ${retries}nd retry...`, + ); + await new Promise((resolve) => setTimeout(resolve, timeoutSeconds * 1000)); + } + } } - await this.ensureWorkspaceAuth(workspaceInstance.id); + /** + * Fetches initial WorkspaceInfo from the server. If there is a WorkspaceInstance for workspaceId, we feed it + * into "onInstanceUpdate" and start accepting further updates. + * + * @param startedInstanceId The instanceId we want to listen on + */ + async fetchWorkspaceInfo(startedInstanceId: string | undefined) { + // this ensures we're receiving updates for this instance + if (startedInstanceId) { + this.setState({ startedInstanceId }); + } - // Redirect to workspaceURL if we are not yet running in an iframe. - // It happens this late if we were waiting for a docker build. - if (!this.props.runsInIFrame && workspaceInstance.ideUrl && !this.props.dontAutostart) { - this.redirectTo(workspaceInstance.ideUrl); - return; + const { workspaceId } = this.props; + try { + const info = await getGitpodService().server.getWorkspace(workspaceId); + if (info.latestInstance) { + const instance = info.latestInstance; + this.setState((s) => ({ + workspace: info.workspace, + startedInstanceId: s.startedInstanceId || instance.id, // note: here's a potential mismatch between startedInstanceId and instance.id. TODO(gpl) How to handle this? + })); + this.onInstanceUpdate(instance); + } + } catch (error) { + console.error(error); + this.setState({ error }); + } } - if (workspaceInstance.status.phase === 'preparing') { - this.setState({ hasImageBuildLogs: true }); + /** + * Fetches the current IDEOptions config for this user + * + * TODO(gpl) Ideally this would be part of the WorkspaceInstance shape, really. And we'd display options based on + * what support it was started with. + */ + protected async fetchIDEOptions() { + const ideOptions = await getGitpodService().server.getIDEOptions(); + this.setState({ ideOptions }); } - let error: StartWorkspaceError | undefined; - if (workspaceInstance.status.conditions.failed) { - error = { message: workspaceInstance.status.conditions.failed }; + notifyDidOpenConnection() { + this.fetchWorkspaceInfo(undefined); } - // Successfully stopped and headless: the prebuild is done, let's try to use it! - if (!error && workspaceInstance.status.phase === 'stopped' && this.state.workspace?.type !== 'regular') { - // here we want to point to the original context, w/o any modifiers "workspace" was started with (as this might have been a manually triggered prebuild!) - const contextURL = ContextURL.getNormalizedURL(this.state.workspace); - if (contextURL) { - this.redirectTo(gitpodHostUrl.withContext(contextURL.toString()).toString()); - } else { - console.error(`unable to parse contextURL: ${contextURL}`); - } - } + async onInstanceUpdate(workspaceInstance: WorkspaceInstance) { + if (workspaceInstance.workspaceId !== this.props.workspaceId) { + return; + } - this.setState({ workspaceInstance, error }); - } - - async ensureWorkspaceAuth(instanceID: string) { - if (!document.cookie.includes(`${instanceID}_owner_`)) { - const authURL = gitpodHostUrl.asWorkspaceAuth(instanceID); - const response = await fetch(authURL.toString()); - if (response.redirected) { - this.redirectTo(response.url); - return; - } - if (!response.ok) { - // getting workspace auth didn't work as planned - redirect - this.redirectTo(authURL.asWorkspaceAuth(instanceID, true).toString()); - return; - } - } - } + // Here we filter out updates to instances we haven't started to avoid issues with updates coming in out-of-order + // (e.g., multiple "stopped" events from the older instance, where we already started a fresh one after the first) + // Only exception is when we do the switch from the "old" to the "new" one. + const startedInstanceId = this.state?.startedInstanceId; + if (startedInstanceId !== workspaceInstance.id) { + // do we want to switch to "new" instance we just received an update for? + const switchToNewInstance = + this.state.workspaceInstance?.status.phase === "stopped" && + workspaceInstance.status.phase !== "stopped"; + if (!switchToNewInstance) { + return; + } + this.setState({ + startedInstanceId: workspaceInstance.id, + workspaceInstance, + }); + + // now we're listening to a new instance, which might have been started with other IDEoptions + this.fetchIDEOptions(); + } + + await this.ensureWorkspaceAuth(workspaceInstance.id); + + // Redirect to workspaceURL if we are not yet running in an iframe. + // It happens this late if we were waiting for a docker build. + if (!this.props.runsInIFrame && workspaceInstance.ideUrl && !this.props.dontAutostart) { + this.redirectTo(workspaceInstance.ideUrl); + return; + } - redirectTo(url: string) { - if (this.props.runsInIFrame) { - window.parent.postMessage({ type: 'relocate', url }, '*'); - } else { - window.location.href = url; + if (workspaceInstance.status.phase === "preparing") { + this.setState({ hasImageBuildLogs: true }); + } + + let error: StartWorkspaceError | undefined; + if (workspaceInstance.status.conditions.failed) { + error = { message: workspaceInstance.status.conditions.failed }; + } + + // Successfully stopped and headless: the prebuild is done, let's try to use it! + if (!error && workspaceInstance.status.phase === "stopped" && this.state.workspace?.type !== "regular") { + // here we want to point to the original context, w/o any modifiers "workspace" was started with (as this might have been a manually triggered prebuild!) + const contextURL = ContextURL.getNormalizedURL(this.state.workspace); + if (contextURL) { + this.redirectTo(gitpodHostUrl.withContext(contextURL.toString()).toString()); + } else { + console.error(`unable to parse contextURL: ${contextURL}`); + } + } + + this.setState({ workspaceInstance, error }); } - } - - render() { - const { error } = this.state; - const isHeadless = this.state.workspace?.type !== 'regular'; - const isPrebuilt = WithPrebuild.is(this.state.workspace?.context); - let phase: StartPhase | undefined = StartPhase.Preparing; - let title = undefined; - let statusMessage = !!error ? undefined :

Preparing workspace …

; - const contextURL = ContextURL.getNormalizedURL(this.state.workspace)?.toString(); - - switch (this.state?.workspaceInstance?.status.phase) { - // unknown indicates an issue within the system in that it cannot determine the actual phase of - // a workspace. This phase is usually accompanied by an error. - case "unknown": - break; - - // Preparing means that we haven't actually started the workspace instance just yet, but rather - // are still preparing for launch. This means we're building the Docker image for the workspace. - case "preparing": - return ; - - // Pending means the workspace does not yet consume resources in the cluster, but rather is looking for - // some space within the cluster. If for example the cluster needs to scale up to accomodate the - // workspace, the workspace will be in Pending state until that happened. - case "pending": - phase = StartPhase.Preparing; - statusMessage =

Allocating resources …

; - break; - - // Creating means the workspace is currently being created. That includes downloading the images required - // to run the workspace over the network. The time spent in this phase varies widely and depends on the current - // network speed, image size and cache states. - case "creating": - phase = StartPhase.Creating; - statusMessage =

Pulling container image …

; - break; - - // Initializing is the phase in which the workspace is executing the appropriate workspace initializer (e.g. Git - // clone or backup download). After this phase one can expect the workspace to either be Running or Failed. - case "initializing": - phase = StartPhase.Starting; - statusMessage =

{isPrebuilt ? 'Loading prebuild …' : 'Initializing content …'}

; - break; - - // Running means the workspace is able to actively perform work, either by serving a user through Theia, - // or as a headless workspace. - case "running": - if (isHeadless) { - return ; + + async ensureWorkspaceAuth(instanceID: string) { + if (!document.cookie.includes(`${instanceID}_owner_`)) { + const authURL = gitpodHostUrl.asWorkspaceAuth(instanceID); + const response = await fetch(authURL.toString()); + if (response.redirected) { + this.redirectTo(response.url); + return; + } + if (!response.ok) { + // getting workspace auth didn't work as planned - redirect + this.redirectTo(authURL.asWorkspaceAuth(instanceID, true).toString()); + return; + } } - if (!this.state.desktopIde) { - phase = StartPhase.Running; - - if (this.props.dontAutostart) { - // hide the progress bar, as we're already running - phase = undefined; - title = 'Running'; - - // in case we dontAutostart the IDE we have to provide controls to do so - statusMessage =
-
-
 
-
-

{this.state.workspaceInstance.workspaceId}

-

{contextURL}

-
-
- -
; - } else { - statusMessage =

Opening Workspace …

; - } + } + + redirectTo(url: string) { + if (this.props.runsInIFrame) { + window.parent.postMessage({ type: "relocate", url }, "*"); } else { - phase = StartPhase.IdeReady; - const openLink = this.state.desktopIde.link; - const openLinkLabel = this.state.desktopIde.label; - const clientID = this.state.desktopIde.clientID - const client = clientID ? this.state.ideOptions?.clients?.[clientID] : undefined; - const installationSteps = client?.installationSteps?.length &&
- {client.installationSteps.map(step =>
)} -
- statusMessage =
-

Opening Workspace …

-
-
 
-
-

{this.state.workspaceInstance.workspaceId}

-

{contextURL}

-
-
- {installationSteps} -
- window.parent.postMessage({ type: 'openBrowserIde' }, '*'), - }, - { - title: 'Stop Workspace', - onClick: () => getGitpodService().server.stopWorkspace(this.props.workspaceId), - }, - { - title: 'Go to Dashboard', - href: gitpodHostUrl.asDashboard().toString(), - target: "_parent", - }, - ]} > - - - + + + + +
+
+ ); + } else { + statusMessage =

Opening Workspace …

; + } } else { - window.open(openLink, '_blank', 'noopener'); + phase = StartPhase.IdeReady; + const openLink = this.state.desktopIde.link; + const openLinkLabel = this.state.desktopIde.label; + const clientID = this.state.desktopIde.clientID; + const client = clientID ? this.state.ideOptions?.clients?.[clientID] : undefined; + const installationSteps = client?.installationSteps?.length && ( +
+ {client.installationSteps.map((step) => ( +
+ ))} +
+ ); + statusMessage = ( +
+

Opening Workspace …

+
+
 
+
+

+ {this.state.workspaceInstance.workspaceId} +

+ +

+ {contextURL} +

+
+
+
+ {installationSteps} +
+ window.parent.postMessage({ type: "openBrowserIde" }, "*"), + }, + { + title: "Stop Workspace", + onClick: () => + getGitpodService().server.stopWorkspace(this.props.workspaceId), + }, + { + title: "Go to Dashboard", + href: gitpodHostUrl.asDashboard().toString(), + target: "_parent", + }, + ]} + > + + + +
+
+ These IDE options are based on{" "} + + your user preferences + + . +
+
+ ); } - }}>{openLinkLabel} -
-
These IDE options are based on your user preferences.
-
; - } - break; + break; - // Interrupted is an exceptional state where the container should be running but is temporarily unavailable. - // When in this state, we expect it to become running or stopping anytime soon. - case "interrupted": - phase = StartPhase.Running; - statusMessage =

Checking workspace …

; - break; + // Interrupted is an exceptional state where the container should be running but is temporarily unavailable. + // When in this state, we expect it to become running or stopping anytime soon. + case "interrupted": + phase = StartPhase.Running; + statusMessage =

Checking workspace …

; + break; - // Stopping means that the workspace is currently shutting down. It could go to stopped every moment. - case "stopping": - if (isHeadless) { - return ; - } - phase = StartPhase.Stopping; - statusMessage =
-
-
 
-
-

{this.state.workspaceInstance.workspaceId}

-

{contextURL}

-
-
- -
; - break; - - // Stopped means the workspace ended regularly because it was shut down. - case "stopped": - phase = StartPhase.Stopped; - if (this.state.hasImageBuildLogs) { - const restartWithDefaultImage = (event: React.MouseEvent) => { - (event.target as HTMLButtonElement).disabled = true; - this.startWorkspace(true, true); - } - return ; - } - if (!isHeadless && this.state.workspaceInstance.status.conditions.timeout) { - title = 'Timed Out'; + // Stopping means that the workspace is currently shutting down. It could go to stopped every moment. + case "stopping": + if (isHeadless) { + return ; + } + phase = StartPhase.Stopping; + statusMessage = ( +
+
+
 
+
+

+ {this.state.workspaceInstance.workspaceId} +

+ +

+ {contextURL} +

+
+
+
+ +
+ ); + break; + + // Stopped means the workspace ended regularly because it was shut down. + case "stopped": + phase = StartPhase.Stopped; + if (this.state.hasImageBuildLogs) { + const restartWithDefaultImage = (event: React.MouseEvent) => { + (event.target as HTMLButtonElement).disabled = true; + this.startWorkspace(true, true); + }; + return ( + + ); + } + if (!isHeadless && this.state.workspaceInstance.status.conditions.timeout) { + title = "Timed Out"; + } + statusMessage = ( +
+
+
 
+
+

+ {this.state.workspaceInstance.workspaceId} +

+ +

+ {contextURL} +

+
+
+
+ + +
+ ); + break; } - statusMessage =
-
-
 
-
-

{this.state.workspaceInstance.workspaceId}

-

{contextURL}

-
-
- - -
; - break; - } - return - {statusMessage} - ; - } + return ( + + {statusMessage} + + ); + } } interface ImageBuildViewProps { - workspaceId: string; - onStartWithDefaultImage?: (event: React.MouseEvent) => void; - phase?: StartPhase; - error?: StartWorkspaceError; + workspaceId: string; + onStartWithDefaultImage?: (event: React.MouseEvent) => void; + phase?: StartPhase; + error?: StartWorkspaceError; } function ImageBuildView(props: ImageBuildViewProps) { - const logsEmitter = new EventEmitter(); - - useEffect(() => { - let registered = false; - const watchBuild = () => { - if (registered) { - return; - } - - getGitpodService().server.watchWorkspaceImageBuildLogs(props.workspaceId) - .then(() => registered = true) - .catch(err => { - - if (err?.code === ErrorCodes.HEADLESS_LOG_NOT_YET_AVAILABLE) { - // wait, and then retry - setTimeout(watchBuild, 5000); - } - }) - } - watchBuild(); - - const toDispose = getGitpodService().registerClient({ - notifyDidOpenConnection: () => { - registered = false; // new connection, we're not registered anymore + const logsEmitter = useMemo(() => new EventEmitter(), []); + + useEffect(() => { + let registered = false; + const watchBuild = () => { + if (registered) { + return; + } + + getGitpodService() + .server.watchWorkspaceImageBuildLogs(props.workspaceId) + .then(() => (registered = true)) + .catch((err) => { + if (err?.code === ErrorCodes.HEADLESS_LOG_NOT_YET_AVAILABLE) { + // wait, and then retry + setTimeout(watchBuild, 5000); + } + }); + }; watchBuild(); - }, - onWorkspaceImageBuildLogs: (info: WorkspaceImageBuild.StateInfo, content?: WorkspaceImageBuild.LogContent) => { - if (!content) { - return; - } - logsEmitter.emit('logs', content.text); - }, - }); - return function cleanup() { - toDispose.dispose(); - }; - }, []); - - return - }> - - - {!!props.onStartWithDefaultImage && } - ; + const toDispose = getGitpodService().registerClient({ + notifyDidOpenConnection: () => { + registered = false; // new connection, we're not registered anymore + watchBuild(); + }, + onWorkspaceImageBuildLogs: ( + info: WorkspaceImageBuild.StateInfo, + content?: WorkspaceImageBuild.LogContent, + ) => { + if (!content) { + return; + } + logsEmitter.emit("logs", content.text); + }, + }); + + return function cleanup() { + toDispose.dispose(); + }; + }, [logsEmitter, props.workspaceId]); + + return ( + + }> + + + {!!props.onStartWithDefaultImage && ( + + )} + + ); } function HeadlessWorkspaceView(props: { instanceId: string }) { - const logsEmitter = new EventEmitter(); - - useEffect(() => { - const disposables = watchHeadlessLogs(props.instanceId, (chunk) => logsEmitter.emit('logs', chunk), async () => { return false; }); - return function cleanup() { - disposables.dispose(); - }; - }, []); - - return - }> - - - ; + const logsEmitter = useMemo(() => new EventEmitter(), []); + + useEffect(() => { + const disposables = watchHeadlessLogs( + props.instanceId, + (chunk) => logsEmitter.emit("logs", chunk), + async () => { + return false; + }, + ); + return function cleanup() { + disposables.dispose(); + }; + }, [logsEmitter, props.instanceId]); + + return ( + + }> + + + + ); } diff --git a/components/dashboard/src/teams/JoinTeam.tsx b/components/dashboard/src/teams/JoinTeam.tsx index cb8bdb24f8463e..92625b312f5e57 100644 --- a/components/dashboard/src/teams/JoinTeam.tsx +++ b/components/dashboard/src/teams/JoinTeam.tsx @@ -9,18 +9,18 @@ import { useHistory } from "react-router-dom"; import { getGitpodService } from "../service/service"; import { TeamsContext } from "./teams-context"; -export default function() { +export default function () { const { setTeams } = useContext(TeamsContext); const history = useHistory(); - const [ joinError, setJoinError ] = useState(); - const inviteId = new URL(window.location.href).searchParams.get('inviteId'); + const [joinError, setJoinError] = useState(); + const inviteId = new URL(window.location.href).searchParams.get("inviteId"); useEffect(() => { (async () => { try { if (!inviteId) { - throw new Error('This invite URL is incorrect.'); + throw new Error("This invite URL is incorrect."); } let team; @@ -46,9 +46,11 @@ export default function() { setJoinError(error); } })(); - }, []); + }, [history, inviteId, setTeams]); - useEffect(() => { document.title = 'Joining Team — Gitpod' }, []); + useEffect(() => { + document.title = "Joining Team — Gitpod"; + }, []); return joinError ?
{String(joinError)}
: <>; -} \ No newline at end of file +} diff --git a/components/dashboard/src/teams/Members.tsx b/components/dashboard/src/teams/Members.tsx index 78b8978bf244f3..e79852c6b108ae 100644 --- a/components/dashboard/src/teams/Members.tsx +++ b/components/dashboard/src/teams/Members.tsx @@ -13,25 +13,24 @@ import DropDown from "../components/DropDown"; import { ItemsList, Item, ItemField, ItemFieldContextMenu } from "../components/ItemsList"; import Modal from "../components/Modal"; import Tooltip from "../components/Tooltip"; -import copy from '../images/copy.svg'; +import copy from "../images/copy.svg"; import { getGitpodService } from "../service/service"; import { UserContext } from "../user-context"; import { TeamsContext, getCurrentTeam } from "./teams-context"; import { trackEvent } from "../Analytics"; - -export default function() { +export default function () { const { user } = useContext(UserContext); const { teams, setTeams } = useContext(TeamsContext); const history = useHistory(); const location = useLocation(); const team = getCurrentTeam(location, teams); - const [ members, setMembers ] = useState([]); - const [ genericInvite, setGenericInvite ] = useState(); - const [ showInviteModal, setShowInviteModal ] = useState(false); - const [ searchText, setSearchText ] = useState(''); - const [ roleFilter, setRoleFilter ] = useState(); - const [ leaveTeamEnabled, setLeaveTeamEnabled ] = useState(false); + const [members, setMembers] = useState([]); + const [genericInvite, setGenericInvite] = useState(); + const [showInviteModal, setShowInviteModal] = useState(false); + const [searchText, setSearchText] = useState(""); + const [roleFilter, setRoleFilter] = useState(); + const [leaveTeamEnabled, setLeaveTeamEnabled] = useState(false); useEffect(() => { if (!team) { @@ -45,24 +44,24 @@ export default function() { setMembers(infos); setGenericInvite(invite); })(); - }, [ team ]); + }, [team]); useEffect(() => { - const owners = members.filter(m => m.role === "owner"); - const isOwner = owners.some(o => o.userId === user?.id); + const owners = members.filter((m) => m.role === "owner"); + const isOwner = owners.some((o) => o.userId === user?.id); setLeaveTeamEnabled(!isOwner || owners.length > 1); - }, [ members ]); + }, [members, user?.id]); - const ownMemberInfo = members.find(m => m.userId === user?.id); + const ownMemberInfo = members.find((m) => m.userId === user?.id); const getInviteURL = (inviteId: string) => { const link = new URL(window.location.href); - link.pathname = '/teams/join'; - link.search = '?inviteId=' + inviteId; + link.pathname = "/teams/join"; + link.search = "?inviteId=" + inviteId; return link.href; - } + }; - const [ copied, setCopied ] = useState(false); + const [copied, setCopied] = useState(false); const copyToClipboard = (text: string) => { const el = document.createElement("textarea"); el.value = text; @@ -84,143 +83,234 @@ export default function() { const newInvite = await getGitpodService().server.resetGenericInvite(team!.id); setGenericInvite(newInvite); } - } + }; const setTeamMemberRole = async (userId: string, role: TeamMemberRole) => { await getGitpodService().server.setTeamMemberRole(team!.id, userId, role); setMembers(await getGitpodService().server.getTeamMembers(team!.id)); - } + }; const removeTeamMember = async (userId: string) => { await getGitpodService().server.removeTeamMember(team!.id, userId); const newTeams = await getGitpodService().server.getTeams(); - if (newTeams.some(t => t.id === team!.id)) { + if (newTeams.some((t) => t.id === team!.id)) { // We're still a member of this team. const newMembers = await getGitpodService().server.getTeamMembers(team!.id); setMembers(newMembers); } else { // We're no longer a member of this team (note: we navigate away first in order to avoid a 404). - history.push('/'); + history.push("/"); setTeams(newTeams); } - } + }; - const filteredMembers = members.filter(m => { + const filteredMembers = members.filter((m) => { if (!!roleFilter && m.role !== roleFilter) { return false; } - const memberSearchText = `${m.fullName||''}${m.primaryEmail||''}`.toLocaleLowerCase(); + const memberSearchText = `${m.fullName || ""}${m.primaryEmail || ""}`.toLocaleLowerCase(); if (!memberSearchText.includes(searchText.toLocaleLowerCase())) { return false; } return true; }); - return <> -
-
-
-
-
- + return ( + <> +
+
+
+
+
+ + + +
+ setSearchText(e.target.value)} + />
- setSearchText(e.target.value)} /> -
-
-
- setRoleFilter(undefined) - }, { - title: 'Owner', - onClick: () => setRoleFilter('owner') - }, { - title: 'Member', - onClick: () => setRoleFilter('member') - }]} /> +
+
+ setRoleFilter(undefined), + }, + { + title: "Owner", + onClick: () => setRoleFilter("owner"), + }, + { + title: "Member", + onClick: () => setRoleFilter("member"), + }, + ]} + /> +
+
- -
- - - - Name - - - Joined - - - - Role - - - {filteredMembers.length === 0 - ?

No members found

- : filteredMembers.map(m => - -
{m.avatarUrl && {m.fullName}}
-
-
{m.fullName}
-

{m.primaryEmail}

-
-
+ + - {moment(m.memberSince).fromNow()} + Name + + + Joined + + + - {ownMemberInfo?.role !== 'owner' - ? m.role - : setTeamMemberRole(m.userId, 'owner') - }, { - title: 'member', - onClick: () => setTeamMemberRole(m.userId, 'member') - }]} />} - - leaveTeamEnabled && removeTeamMember(m.userId) - }] - : (ownMemberInfo?.role === 'owner' - ? [{ - title: 'Remove', - customFontStyle: 'text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300', - onClick: () => removeTeamMember(m.userId) - }] - : [])} /> + Role - )} - -
- {genericInvite && showInviteModal && setShowInviteModal(false)}> -

Invite Members

-
- -
- -
copyToClipboard(getInviteURL(genericInvite.id))}> -
- - - + + {filteredMembers.length === 0 ? ( +

No members found

+ ) : ( + filteredMembers.map((m) => ( + + +
+ {m.avatarUrl && ( + {m.fullName} + )} +
+
+
+ {m.fullName} +
+

{m.primaryEmail}

+
+
+ + {moment(m.memberSince).fromNow()} + + + + {ownMemberInfo?.role !== "owner" ? ( + m.role + ) : ( + setTeamMemberRole(m.userId, "owner"), + }, + { + title: "member", + onClick: () => setTeamMemberRole(m.userId, "member"), + }, + ]} + /> + )} + + + leaveTeamEnabled && removeTeamMember(m.userId), + }, + ] + : ownMemberInfo?.role === "owner" + ? [ + { + title: "Remove", + customFontStyle: + "text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300", + onClick: () => removeTeamMember(m.userId), + }, + ] + : [] + } + /> + +
+ )) + )} + +
+ {genericInvite && showInviteModal && ( + setShowInviteModal(false)}> +

Invite Members

+
+ +
+ +
copyToClipboard(getInviteURL(genericInvite.id))} + > +
+ + Copy Invite URL + +
+
+

Use this URL to join this team as a Member.

-
-

Use this URL to join this team as a Member.

-
-
- - -
- } - ; -} \ No newline at end of file +
+ + +
+ + )} + + ); +} diff --git a/components/dashboard/src/teams/TeamSettings.tsx b/components/dashboard/src/teams/TeamSettings.tsx index 8ac24586574c1f..51648073bac10d 100644 --- a/components/dashboard/src/teams/TeamSettings.tsx +++ b/components/dashboard/src/teams/TeamSettings.tsx @@ -17,7 +17,7 @@ import { getCurrentTeam, TeamsContext } from "./teams-context"; export function getTeamSettingsMenu(team?: Team) { return [ { - title: 'General', + title: "General", link: [`/t/${team?.slug}/settings`], }, ]; @@ -25,7 +25,7 @@ export function getTeamSettingsMenu(team?: Team) { export default function TeamSettings() { const [modal, setModal] = useState(false); - const [teamSlug, setTeamSlug] = useState(''); + const [teamSlug, setTeamSlug] = useState(""); const [isUserOwner, setIsUserOwner] = useState(true); const { teams } = useContext(TeamsContext); const { user } = useContext(UserContext); @@ -38,46 +38,70 @@ export default function TeamSettings() { (async () => { if (!team) return; const members = await getGitpodService().server.getTeamMembers(team.id); - const currentUserInTeam = members.find(member => member.userId === user?.id); - setIsUserOwner(currentUserInTeam?.role === 'owner'); + const currentUserInTeam = members.find((member) => member.userId === user?.id); + setIsUserOwner(currentUserInTeam?.role === "owner"); })(); - }, []); + }, [team, user?.id]); if (!isUserOwner) { - return - }; + return ; + } const deleteTeam = async () => { if (!team || !user) { - return + return; } await getGitpodService().server.deleteTeam(team.id, user.id); document.location.href = gitpodHostUrl.asDashboard().toString(); }; - return <> - -

Delete Team

-

Deleting this team will also remove all associated data with this team, including projects and workspaces. Deleted teams cannot be restored!

- -
+ return ( + <> + +

Delete Team

+

+ Deleting this team will also remove all associated data with this team, including projects and + workspaces. Deleted teams cannot be restored! +

+ +
- -

You are about to permanently delete {team?.slug} including all associated data with this team.

-
    -
  1. All projects added in this team will be deleted and cannot be restored afterwards.
  2. -
  3. All workspaces opened for projects within this team will be deleted for all team members and cannot be restored afterwards.
  4. -
  5. All members of this team will lose access to this team, associated projects and workspaces.
  6. -
-

Type {team?.slug} to confirm

- setTeamSlug(e.target.value)}> -
- + +

+ You are about to permanently delete {team?.slug} including all associated data with this + team. +

+
    +
  1. + All projects added in this team will be deleted and cannot be restored afterwards. +
  2. +
  3. + All workspaces opened for projects within this team will be deleted for all team members + and cannot be restored afterwards. +
  4. +
  5. + All members of this team will lose access to this team, associated projects and + workspaces. +
  6. +
+

+ Type {team?.slug} to confirm +

+ setTeamSlug(e.target.value)}> +
+ + ); } diff --git a/components/dashboard/src/theme-context.tsx b/components/dashboard/src/theme-context.tsx index 155cd7312ec07c..80d735179e9b43 100644 --- a/components/dashboard/src/theme-context.tsx +++ b/components/dashboard/src/theme-context.tsx @@ -4,37 +4,33 @@ * See License-AGPL.txt in the project root for license information. */ -import React, { createContext, useEffect, useState } from 'react'; +import React, { createContext, useEffect, useState } from "react"; export const ThemeContext = createContext<{ - isDark?: boolean, - setIsDark: React.Dispatch, + isDark?: boolean; + setIsDark: React.Dispatch; }>({ setIsDark: () => null, }); export const ThemeContextProvider: React.FC = ({ children }) => { - const [ isDark, setIsDark ] = useState(document.documentElement.classList.contains('dark')); + const [isDark, setIsDark] = useState(document.documentElement.classList.contains("dark")); const actuallySetIsDark = (dark: boolean) => { - document.documentElement.classList.toggle('dark', dark); - setIsDark(dark); - } + document.documentElement.classList.toggle("dark", dark); + setIsDark(dark); + }; useEffect(() => { const observer = new MutationObserver(() => { - if (document.documentElement.classList.contains('dark') !== isDark) { + if (document.documentElement.classList.contains("dark") !== isDark) { setIsDark(!isDark); } }); observer.observe(document.documentElement, { attributes: true }); return function cleanUp() { observer.disconnect(); - } - }, []); + }; + }, [isDark]); - return ( - - {children} - - ) -} + return {children}; +}; diff --git a/components/dashboard/src/utils.ts b/components/dashboard/src/utils.ts index 2f3a873a043f8a..8bab75233f4d02 100644 --- a/components/dashboard/src/utils.ts +++ b/components/dashboard/src/utils.ts @@ -4,30 +4,35 @@ * See License-AGPL.txt in the project root for license information. */ - - export interface PollOptions { +export interface PollOptions { backoffFactor: number; retryUntilSeconds: number; stop?: () => void; success: (result?: T) => void; - token?: { cancelled?: boolean } + token?: { cancelled?: boolean }; } -export const poll = async (initialDelayInSeconds: number, callback: () => Promise<{done: boolean, result?: T}>, opts: PollOptions) => { +export const poll = async ( + initialDelayInSeconds: number, + callback: () => Promise<{ done: boolean; result?: T }>, + opts: PollOptions, +) => { const start = new Date(); let delayInSeconds = initialDelayInSeconds; while (true) { - const runSinceSeconds = ((new Date().getTime()) - start.getTime()) / 1000; + const runSinceSeconds = (new Date().getTime() - start.getTime()) / 1000; if (runSinceSeconds > opts.retryUntilSeconds) { if (opts.stop) { opts.stop(); } return; } - await new Promise(resolve => setTimeout(resolve, delayInSeconds * 1000)); + // TODO: this requires some thought (or someone with fresh experience in how to write the same thing in a safer way) + // eslint-disable-next-line no-loop-func + await new Promise((resolve) => setTimeout(resolve, delayInSeconds * 1000)); if (opts.token?.cancelled) { return; } @@ -43,7 +48,5 @@ export const poll = async (initialDelayInSeconds: number, callback: () => Pro } else { delayInSeconds = opts.backoffFactor * delayInSeconds; } - } }; - diff --git a/components/dashboard/src/workspaces/StartWorkspaceModal.tsx b/components/dashboard/src/workspaces/StartWorkspaceModal.tsx index fb353d903933d3..dca6d7835105d7 100644 --- a/components/dashboard/src/workspaces/StartWorkspaceModal.tsx +++ b/components/dashboard/src/workspaces/StartWorkspaceModal.tsx @@ -17,12 +17,18 @@ export function StartWorkspaceModal() { // Close the modal on navigation events. useEffect(() => { setIsStartWorkspaceModalVisible(false); - }, [location]); + }, [location, setIsStartWorkspaceModalVisible]); - return setIsStartWorkspaceModalVisible(false)} onEnter={() => false} visible={!!isStartWorkspaceModalVisible}> -

Open in Gitpod

-
- -
-
; + return ( + setIsStartWorkspaceModalVisible(false)} + onEnter={() => false} + visible={!!isStartWorkspaceModalVisible} + > +

Open in Gitpod

+
+ +
+
+ ); } diff --git a/gitpod-ws.code-workspace b/gitpod-ws.code-workspace index 691f67db077e80..e4a15b7dd5baa7 100644 --- a/gitpod-ws.code-workspace +++ b/gitpod-ws.code-workspace @@ -1,60 +1,69 @@ { - "folders": [ - { "path": "." }, - { "path": "components/blobserve" }, - { "path": "components/common-go" }, - { "path": "components/content-service" }, - { "path": "components/docker-up" }, - { "path": "components/ee/agent-smith" }, - { "path": "components/gitpod-cli" }, - { "path": "components/gitpod-protocol" }, - { "path": "components/image-builder-bob" }, - { "path": "components/image-builder-mk3" }, - { "path": "components/licensor" }, - { "path": "components/local-app" }, - { "path": "components/registry-facade" }, - { "path": "components/service-waiter" }, - { "path": "components/supervisor" }, - { "path": "components/workspacekit" }, - { "path": "components/ws-daemon" }, - { "path": "components/ws-manager" }, - { "path": "components/ws-proxy" }, - { "path": "test" }, - { "path": "dev/blowtorch" }, - { "path": "dev/changelog" }, - { "path": "dev/gpctl" }, - { "path": "dev/loadgen" }, - { "path": "dev/poolkeeper" }, - { "path": "dev/sweeper" }, - { "path": "install/installer" } - ], - "settings": { - "typescript.tsdk": "gitpod/node_modules/typescript/lib", - "[json]": { - "editor.insertSpaces": true, - "editor.tabSize": 2 - }, - "[yaml]": { - "editor.insertSpaces": true, - "editor.tabSize": 2 - }, - "[go]": { - "editor.formatOnSave": true - }, - "[tf]": { - "editor.insertSpaces": true, - "editor.tabSize": 2 - }, - "go.formatTool": "goimports", - "go.useLanguageServer": true, - "workspace.supportMultiRootWorkspace": true, - "launch": {}, - "files.exclude": { - "**/.git": true - }, - "go.lintTool": "golangci-lint", - "gopls": { - "allowModfileModifications": true - } - } + "folders": [ + { "path": "." }, + { "path": "components/blobserve" }, + { "path": "components/common-go" }, + { "path": "components/content-service" }, + { "path": "components/docker-up" }, + { "path": "components/ee/agent-smith" }, + { "path": "components/gitpod-cli" }, + { "path": "components/gitpod-protocol" }, + { "path": "components/image-builder-bob" }, + { "path": "components/image-builder-mk3" }, + { "path": "components/licensor" }, + { "path": "components/local-app" }, + { "path": "components/registry-facade" }, + { "path": "components/service-waiter" }, + { "path": "components/supervisor" }, + { "path": "components/workspacekit" }, + { "path": "components/ws-daemon" }, + { "path": "components/ws-manager" }, + { "path": "components/ws-proxy" }, + { "path": "test" }, + { "path": "dev/blowtorch" }, + { "path": "dev/changelog" }, + { "path": "dev/gpctl" }, + { "path": "dev/loadgen" }, + { "path": "dev/poolkeeper" }, + { "path": "dev/sweeper" }, + { "path": "install/installer" } + ], + "settings": { + "typescript.tsdk": "gitpod/node_modules/typescript/lib", + "[javascript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true + }, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true + }, + "[json]": { + "editor.insertSpaces": true, + "editor.tabSize": 2 + }, + "[yaml]": { + "editor.insertSpaces": true, + "editor.tabSize": 2 + }, + "[go]": { + "editor.formatOnSave": true + }, + "[tf]": { + "editor.insertSpaces": true, + "editor.tabSize": 2 + }, + "go.formatTool": "goimports", + "go.useLanguageServer": true, + "workspace.supportMultiRootWorkspace": true, + "launch": {}, + "files.exclude": { + "**/.git": true + }, + "go.lintTool": "golangci-lint", + "gopls": { + "allowModfileModifications": true + }, + "prettier.configPath": ".prettierrc.json" + } }