Path: blob/main/components/dashboard/src/workspaces/Workspaces.tsx
2500 views
/**1* Copyright (c) 2021 Gitpod GmbH. All rights reserved.2* Licensed under the GNU Affero General Public License (AGPL).3* See License.AGPL.txt in the project root for license information.4*/56import { hoursBefore, isDateSmallerOrEqual } from "@gitpod/gitpod-protocol/lib/util/timeutil";7import { Workspace, WorkspacePhase_Phase } from "@gitpod/public-api/lib/gitpod/v1/workspace_pb";8import { Button } from "@podkit/buttons/Button";9import { cn } from "@podkit/lib/cn";10import { Subheading } from "@podkit/typography/Headings";11import { Book, BookOpen, Building, ChevronRight, Code, Video } from "lucide-react";12import { FunctionComponent, useCallback, useEffect, useMemo, useState } from "react";13import { Link } from "react-router-dom";14import { trackVideoClick } from "../Analytics";15import Arrow from "../components/Arrow";16import ConfirmationModal from "../components/ConfirmationModal";17import Header from "../components/Header";18import { ItemsList } from "../components/ItemsList";19import Modal, { ModalBaseFooter, ModalBody, ModalHeader } from "../components/Modal";20import PillLabel from "../components/PillLabel";21import { useToast } from "../components/toasts/Toasts";22import Tooltip from "../components/Tooltip";23import { useUpdateCurrentUserMutation } from "../data/current-user/update-mutation";24import { useFeatureFlag } from "../data/featureflag-query";25import { useSuggestedRepositories } from "../data/git-providers/suggested-repositories-query";26import { useOrgSettingsQuery } from "../data/organizations/org-settings-query";27import { useCurrentOrg } from "../data/organizations/orgs-query";28import { SuggestedOrgRepository, useOrgSuggestedRepos } from "../data/organizations/suggested-repositories-query";29import { useDeleteInactiveWorkspacesMutation } from "../data/workspaces/delete-inactive-workspaces-mutation";30import { useListWorkspacesQuery } from "../data/workspaces/list-workspaces-query";31import { useListenToWorkspacesWSMessages as useListenToWorkspacesStatusUpdates } from "../data/workspaces/listen-to-workspace-ws-messages";32import { useUserLoader } from "../hooks/use-user-loader";33import { ReactComponent as GitpodStrokedSVG } from "../icons/gitpod-stroked.svg";34import { VideoSection } from "../onboarding/VideoSection";35import { OrganizationJoinModal } from "../teams/onboarding/OrganizationJoinModal";36// import { BlogBanners } from "./BlogBanners";37import { EmptyWorkspacesContent } from "./EmptyWorkspacesContent";38import PersonalizedContent from "./PersonalizedContent";39import { VideoCarousel } from "./VideoCarousel";40import { WorkspaceEntry } from "./WorkspaceEntry";41import { WorkspacesSearchBar } from "./WorkspacesSearchBar";42import { useInstallationConfiguration } from "../data/installation/installation-config-query";43import { SkeletonBlock } from "@podkit/loading/Skeleton";4445export const GETTING_STARTED_DISMISSAL_KEY = "workspace-list-getting-started";4647const WorkspacesPage: FunctionComponent = () => {48const [limit, setLimit] = useState(50);49const [searchTerm, setSearchTerm] = useState("");50const [showInactive, setShowInactive] = useState(false);51const [deleteModalVisible, setDeleteModalVisible] = useState(false);5253const { data, isLoading } = useListWorkspacesQuery({ limit });54const deleteInactiveWorkspaces = useDeleteInactiveWorkspacesMutation();55useListenToWorkspacesStatusUpdates();5657const { data: org } = useCurrentOrg();58const { data: orgSettings } = useOrgSettingsQuery();5960const { user } = useUserLoader();61const { mutate: mutateUser } = useUpdateCurrentUserMutation();6263const { toast } = useToast();6465// Sort workspaces into active/inactive groups66const { activeWorkspaces, inactiveWorkspaces } = useMemo(() => {67const sortedWorkspaces = (data || []).sort(sortWorkspaces);68const activeWorkspaces = sortedWorkspaces.filter((ws) => isWorkspaceActive(ws));6970// respecting the limit, return inactive workspaces as well71const inactiveWorkspaces = sortedWorkspaces72.filter((ws) => !isWorkspaceActive(ws))73.slice(0, limit - activeWorkspaces.length);7475return {76activeWorkspaces,77inactiveWorkspaces,78};79}, [data, limit]);8081const handlePlay = () => {82trackVideoClick("create-new-workspace");83};8485const { data: installationConfig } = useInstallationConfiguration();86const isDedicatedInstallation = !!installationConfig?.isDedicatedInstallation;8788const isEnterpriseOnboardingEnabled = useFeatureFlag("enterprise_onboarding_enabled");8990const { filteredActiveWorkspaces, filteredInactiveWorkspaces } = useMemo(() => {91const filteredActiveWorkspaces = activeWorkspaces.filter(92(info) =>93`${info.metadata!.name}${info.id}${info.metadata!.originalContextUrl}${94info.status?.gitStatus?.cloneUrl95}${info.status?.gitStatus?.branch}`96.toLowerCase()97.indexOf(searchTerm.toLowerCase()) !== -1,98);99100const filteredInactiveWorkspaces = inactiveWorkspaces.filter(101(info) =>102`${info.metadata!.name}${info.id}${info.metadata!.originalContextUrl}${103info.status?.gitStatus?.cloneUrl104}${info.status?.gitStatus?.branch}`105.toLowerCase()106.indexOf(searchTerm.toLowerCase()) !== -1,107);108109return {110filteredActiveWorkspaces,111filteredInactiveWorkspaces,112};113}, [activeWorkspaces, inactiveWorkspaces, searchTerm]);114115const handleDeleteInactiveWorkspacesConfirmation = useCallback(async () => {116try {117await deleteInactiveWorkspaces.mutateAsync({118workspaceIds: inactiveWorkspaces.map((info) => info.id),119});120121setDeleteModalVisible(false);122toast("Your workspace was deleted");123} catch (e) {}124}, [deleteInactiveWorkspaces, inactiveWorkspaces, toast]);125126// initialize a state so that we can be optimistic and reactive, but also use an effect to sync the state with the user's actual profile127const [showGettingStarted, setShowGettingStarted] = useState<boolean | undefined>(undefined);128useEffect(() => {129if (!user?.profile?.coachmarksDismissals[GETTING_STARTED_DISMISSAL_KEY]) {130setShowGettingStarted(true);131} else {132setShowGettingStarted(false);133}134}, [user?.profile?.coachmarksDismissals]);135136const { data: userSuggestedRepos, isLoading: isUserSuggestedReposLoading } = useSuggestedRepositories({137excludeConfigurations: false,138});139const { data: orgSuggestedRepos, isLoading: isOrgSuggestedReposLoading } = useOrgSuggestedRepos();140141const suggestedRepos = useMemo(() => {142const userSuggestions =143userSuggestedRepos144?.filter((repo) => {145const autostartMatch = user?.workspaceAutostartOptions.find((option) => {146return option.cloneUrl.includes(repo.url);147});148return autostartMatch;149})150.slice(0, 3) ?? [];151const orgSuggestions = (orgSuggestedRepos ?? []).filter((repo) => {152return !userSuggestions.find((userSuggestion) => userSuggestion.configurationId === repo.configurationId); // don't show duplicates from user's autostart options153});154155return [...userSuggestions, ...orgSuggestions].slice(0, 3);156}, [userSuggestedRepos, orgSuggestedRepos, user?.workspaceAutostartOptions]);157158const suggestedReposLoading = isUserSuggestedReposLoading || isOrgSuggestedReposLoading;159160const toggleGettingStarted = useCallback(161(show: boolean) => {162setShowGettingStarted(show);163164mutateUser(165{166additionalData: {167profile: {168coachmarksDismissals: {169[GETTING_STARTED_DISMISSAL_KEY]: !show ? new Date().toISOString() : "",170},171},172},173},174{175onError: (e) => {176toast("Failed to dismiss getting started");177setShowGettingStarted(true);178},179},180);181},182[mutateUser, toast],183);184185const [isVideoModalVisible, setVideoModalVisible] = useState(false);186const handleVideoModalClose = useCallback(() => {187setVideoModalVisible(false);188}, []);189190const welcomeMessage = orgSettings?.onboardingSettings?.welcomeMessage;191192return (193<>194<Header195title="Workspaces"196subtitle="Manage, start and stop your personal development environments in the cloud."197/>198199{isEnterpriseOnboardingEnabled && isDedicatedInstallation && (200<>201<div className="app-container flex flex-row items-center justify-end mt-4 mb-2">202<Tooltip content="Toggle helpful resources for getting started with Gitpod">203<Button204variant="ghost"205onClick={() => toggleGettingStarted(!showGettingStarted)}206className="p-2"207>208<div className="flex flex-row items-center gap-2">209<Subheading className="text-pk-content-primary">Getting started</Subheading>210<ChevronRight211className={`transform transition-transform duration-100 ${212showGettingStarted ? "rotate-90" : ""213}`}214size={20}215/>216</div>217</Button>218</Tooltip>219</div>220221{showGettingStarted && (222<>223<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5 lg:px-28 px-4 pb-4">224<Card onClick={() => setVideoModalVisible(true)}>225<Video className="flex-shrink-0" size={24} />226<div className="min-w-0">227<CardTitle>Learn how Gitpod works</CardTitle>228<CardDescription>229We've put together resources for you to get the most out of Gitpod.230</CardDescription>231</div>232</Card>233234{orgSettings?.onboardingSettings?.internalLink ? (235<Card href={orgSettings.onboardingSettings.internalLink} isLinkExternal>236<Building className="flex-shrink-0" size={24} />237<div className="min-w-0">238<CardTitle>Learn more about Gitpod at {org?.name}</CardTitle>239<CardDescription>240Read through the internal Gitpod landing page of your organization.241</CardDescription>242</div>243</Card>244) : (245<Card href={"/new?showExamples=true"}>246<Code className="flex-shrink-0" size={24} />247<div className="min-w-0">248<CardTitle>Open a sample repository</CardTitle>249<CardDescription>250Explore{" "}251{orgSuggestedRepos?.length252? "repositories recommended by your organization"253: "a sample repository"}{" "}254to quickly experience Gitpod.255</CardDescription>256</div>257</Card>258)}259260<Card href="https://www.gitpod.io/docs/introduction" isLinkExternal>261<Book className="flex-shrink-0" size={24} />262<div className="min-w-0">263<CardTitle>Visit the docs</CardTitle>264<CardDescription>265We have extensive documentation to help if you get stuck.266</CardDescription>267</div>268</Card>269</div>270271<>272<Subheading className="font-semibold text-pk-content-primary mb-2 app-container">273Suggested274</Subheading>275276<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 lg:px-28 px-4">277{suggestedReposLoading ? (278<>279<SkeletonBlock className="w-full h-24" ready={false} />280<SkeletonBlock className="w-full h-24" ready={false} />281<SkeletonBlock className="w-full h-24" ready={false} />282</>283) : (284<>285{suggestedRepos.map((repo) => {286const isOrgSuggested =287(repo as SuggestedOrgRepository).orgSuggested ?? false;288289return (290<Card291key={repo.url}292href={`/new#${repo.url}`}293className={cn(294"border-[0.5px] hover:bg-pk-surface-tertiary transition-colors w-full",295{296"border-[#D79A45]": isOrgSuggested,297"border-pk-border-base": !isOrgSuggested,298},299)}300>301<div className="min-w-0 w-full space-y-1.5">302<CardTitle className="flex flex-row items-center gap-2 w-full">303<span className="truncate block min-w-0 text-base">304{repo.configurationName || repo.repoName}305</span>306{isOrgSuggested && (307<PillLabel308className="capitalize bg-kumquat-light shrink-0 text-sm"309type="warn"310>311Recommended312</PillLabel>313)}314</CardTitle>315<CardDescription className="truncate text-sm opacity-75">316{repo.url}317</CardDescription>318</div>319</Card>320);321})}322{suggestedRepos.length === 0 && (323<Card324className={cn(325"border-[0.5px] hover:bg-pk-surface-tertiary w-full border-pk-border-base h-24",326)}327>328<div className="min-w-0 w-full space-y-1.5">329<CardTitle className="flex flex-row items-center gap-2 w-full">330<span className="truncate block min-w-0 text-base">331No suggestions yet332</span>333</CardTitle>334<CardDescription className="truncate text-sm opacity-75">335Start some workspaces to start seeing suggestions here.336</CardDescription>337</div>338</Card>339)}340</>341)}342</div>343</>344345<Modal346visible={isVideoModalVisible}347onClose={handleVideoModalClose}348containerClassName="min-[576px]:max-w-[600px]"349>350<ModalHeader>Demo video</ModalHeader>351<ModalBody>352<div className="flex flex-row items-center justify-center">353<VideoSection354metadataVideoTitle="Gitpod demo"355playbackId="m01BUvCkTz7HzQKFoIcQmK00Rx5laLLoMViWBstetmvLs"356poster="https://i.ytimg.com/vi_webp/1ZBN-b2cIB8/maxresdefault.webp"357playerProps={{ onPlay: handlePlay, defaultHiddenCaptions: true }}358className="w-[535px] rounded-xl"359/>360</div>361</ModalBody>362<ModalBaseFooter>363<Button variant="secondary" onClick={handleVideoModalClose}>364Close365</Button>366</ModalBaseFooter>367</Modal>368</>369)}370</>371)}372373{deleteModalVisible && (374<ConfirmationModal375title="Delete Inactive Workspaces"376areYouSureText="Are you sure you want to delete all inactive workspaces?"377buttonText="Delete Inactive Workspaces"378onClose={() => setDeleteModalVisible(false)}379onConfirm={handleDeleteInactiveWorkspacesConfirmation}380visible381/>382)}383384{!isLoading &&385(activeWorkspaces.length > 0 || inactiveWorkspaces.length > 0 || searchTerm ? (386<>387<div388className={389!isDedicatedInstallation ? "!pl-0 app-container flex flex-1 flex-row" : "app-container"390}391>392<div>393<WorkspacesSearchBar394limit={limit}395searchTerm={searchTerm}396onLimitUpdated={setLimit}397onSearchTermUpdated={setSearchTerm}398/>399<ItemsList className={!isDedicatedInstallation ? "app-container xl:!pr-4 pb-40" : ""}>400<div className="border-t border-gray-200 dark:border-gray-800"></div>401{filteredActiveWorkspaces.map((info) => {402return <WorkspaceEntry key={info.id} info={info} />;403})}404{filteredActiveWorkspaces.length > 0 && <div className="py-6"></div>}405{filteredInactiveWorkspaces.length > 0 && (406<div>407<div408onClick={() => setShowInactive(!showInactive)}409className="flex cursor-pointer p-6 flex-row bg-pk-surface-secondary hover:bg-pk-surface-tertiary text-pk-content-tertiary rounded-xl mb-2"410>411<div className="pr-2">412<Arrow direction={showInactive ? "down" : "right"} />413</div>414<div className="flex flex-grow flex-col ">415<div className="font-medium truncate">416<span>Inactive Workspaces </span>417<span className="text-gray-400 dark:text-gray-400 bg-gray-200 dark:bg-gray-600 rounded-xl px-2 py-0.5 text-xs">418{filteredInactiveWorkspaces.length}419</span>420</div>421<div className="text-sm flex-auto">422Workspaces that have been stopped for more than 24 hours.423Inactive workspaces are automatically deleted after 14 days.{" "}424<a425target="_blank"426rel="noreferrer"427className="gp-link"428href="https://www.gitpod.io/docs/configure/workspaces/workspace-lifecycle#workspace-deletion"429onClick={(evt) => evt.stopPropagation()}430>431Learn more432</a>433</div>434</div>435<div className="self-center">436{showInactive ? (437<Button438variant="ghost"439// TODO: Remove these classes once we decide on the new button style440// Leaving these to emulate the old button's danger.secondary style until we decide if we want that style or not441className="bg-red-50 dark:bg-red-300 hover:bg-red-100 dark:hover:bg-red-200 text-red-600 hover:text-red-700 hover:opacity-100"442onClick={(evt) => {443setDeleteModalVisible(true);444evt.stopPropagation();445}}446>447Delete Inactive Workspaces448</Button>449) : null}450</div>451</div>452{showInactive ? (453<>454{filteredInactiveWorkspaces.map((info) => {455return <WorkspaceEntry key={info.id} info={info} />;456})}457</>458) : null}459</div>460)}461</ItemsList>462</div>463{/* Show Educational if user is in gitpodIo */}464{!isDedicatedInstallation && (465<div className="max-xl:hidden border-l border-gray-200 dark:border-gray-800 pl-6 pt-5 pb-4 space-y-8">466<VideoCarousel />467<div className="flex flex-col gap-2">468<h3 className="text-lg font-semibold text-pk-content-primary">Documentation</h3>469<div className="flex flex-col gap-1 w-fit">470<a471href="https://www.gitpod.io/docs/introduction"472target="_blank"473rel="noopener noreferrer"474className="text-sm text-pk-content-primary items-center gap-x-2 flex flex-row"475>476<BookOpen width={20} />{" "}477<span className="hover:text-blue-600 dark:hover:text-blue-400">478Read the docs479</span>480</a>481<a482href="https://www.gitpod.io/docs/configure/workspaces"483target="_blank"484rel="noopener noreferrer"485className="text-sm text-pk-content-primary items-center gap-x-2 flex flex-row"486>487<GitpodStrokedSVG />488<span className="hover:text-blue-600 dark:hover:text-blue-400">489Configuring a workspace490</span>491</a>492<a493href="https://www.gitpod.io/docs/references/gitpod-yml"494target="_blank"495rel="noopener noreferrer"496className="text-sm text-pk-content-primary items-center gap-x-2 flex flex-row"497>498<Code width={20} />{" "}499<span className="hover:text-blue-600 dark:hover:text-blue-400">500.gitpod.yml reference501</span>502</a>503</div>504</div>505<PersonalizedContent />506{/* Uncomment the following, if you need side banners in future */}507{/* <BlogBanners /> */}508</div>509)}510</div>511</>512) : (513<EmptyWorkspacesContent />514))}515516{isEnterpriseOnboardingEnabled && isDedicatedInstallation && welcomeMessage && user && (517<OrganizationJoinModal welcomeMessage={welcomeMessage} user={user} />518)}519</>520);521};522523export default WorkspacesPage;524525const CardTitle = ({ children, className }: { className?: string; children: React.ReactNode }) => {526return <span className={cn("text-lg font-semibold text-pk-content-primary", className)}>{children}</span>;527};528const CardDescription = ({ children, className }: { className?: string; children: React.ReactNode }) => {529return <p className={cn("text-pk-content-secondary", className)}>{children}</p>;530};531type CardProps = {532children: React.ReactNode;533href?: string;534isLinkExternal?: boolean;535className?: string;536onClick?: () => void;537};538const Card = ({ children, href, isLinkExternal, className: classNameFromProps, onClick }: CardProps) => {539const className = cn(540"bg-pk-surface-secondary flex gap-3 py-4 px-5 rounded-xl text-left w-full h-full",541classNameFromProps,542);543544if (href && isLinkExternal) {545return (546<a href={href} className={className} target="_blank" rel="noreferrer">547{children}548</a>549);550}551552if (href) {553return (554<Link to={href} className={className}>555{children}556</Link>557);558}559560if (onClick) {561return (562<button className={className} onClick={onClick}>563{children}564</button>565);566}567568return <div className={className}>{children}</div>;569};570571const sortWorkspaces = (a: Workspace, b: Workspace) => {572const result = workspaceActiveDate(b).localeCompare(workspaceActiveDate(a));573if (result === 0) {574// both active now? order by workspace id575return b.id.localeCompare(a.id);576}577return result;578};579580/**581* Given a WorkspaceInfo, return a ISO string of the last related activity582*/583function workspaceActiveDate(info: Workspace): string {584return info.status!.phase!.lastTransitionTime!.toDate().toISOString();585}586587/**588* Returns a boolean indicating if the workspace should be considered active.589* A workspace is considered active if it is pinned, not stopped, or was active within the last 24 hours590*591* @param info WorkspaceInfo592* @returns boolean If workspace is considered active593*/594function isWorkspaceActive(info: Workspace): boolean {595const lastSessionStart = info.status!.phase!.lastTransitionTime!.toDate().toISOString();596const twentyfourHoursAgo = hoursBefore(new Date().toISOString(), 24);597598const isStopped = info.status?.phase?.name === WorkspacePhase_Phase.STOPPED;599return info.metadata!.pinned || !isStopped || isDateSmallerOrEqual(twentyfourHoursAgo, lastSessionStart);600}601602603