Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
gitpod-io
GitHub Repository: gitpod-io/gitpod
Path: blob/main/components/dashboard/src/workspaces/Workspaces.tsx
2500 views
1
/**
2
* Copyright (c) 2021 Gitpod GmbH. All rights reserved.
3
* Licensed under the GNU Affero General Public License (AGPL).
4
* See License.AGPL.txt in the project root for license information.
5
*/
6
7
import { hoursBefore, isDateSmallerOrEqual } from "@gitpod/gitpod-protocol/lib/util/timeutil";
8
import { Workspace, WorkspacePhase_Phase } from "@gitpod/public-api/lib/gitpod/v1/workspace_pb";
9
import { Button } from "@podkit/buttons/Button";
10
import { cn } from "@podkit/lib/cn";
11
import { Subheading } from "@podkit/typography/Headings";
12
import { Book, BookOpen, Building, ChevronRight, Code, Video } from "lucide-react";
13
import { FunctionComponent, useCallback, useEffect, useMemo, useState } from "react";
14
import { Link } from "react-router-dom";
15
import { trackVideoClick } from "../Analytics";
16
import Arrow from "../components/Arrow";
17
import ConfirmationModal from "../components/ConfirmationModal";
18
import Header from "../components/Header";
19
import { ItemsList } from "../components/ItemsList";
20
import Modal, { ModalBaseFooter, ModalBody, ModalHeader } from "../components/Modal";
21
import PillLabel from "../components/PillLabel";
22
import { useToast } from "../components/toasts/Toasts";
23
import Tooltip from "../components/Tooltip";
24
import { useUpdateCurrentUserMutation } from "../data/current-user/update-mutation";
25
import { useFeatureFlag } from "../data/featureflag-query";
26
import { useSuggestedRepositories } from "../data/git-providers/suggested-repositories-query";
27
import { useOrgSettingsQuery } from "../data/organizations/org-settings-query";
28
import { useCurrentOrg } from "../data/organizations/orgs-query";
29
import { SuggestedOrgRepository, useOrgSuggestedRepos } from "../data/organizations/suggested-repositories-query";
30
import { useDeleteInactiveWorkspacesMutation } from "../data/workspaces/delete-inactive-workspaces-mutation";
31
import { useListWorkspacesQuery } from "../data/workspaces/list-workspaces-query";
32
import { useListenToWorkspacesWSMessages as useListenToWorkspacesStatusUpdates } from "../data/workspaces/listen-to-workspace-ws-messages";
33
import { useUserLoader } from "../hooks/use-user-loader";
34
import { ReactComponent as GitpodStrokedSVG } from "../icons/gitpod-stroked.svg";
35
import { VideoSection } from "../onboarding/VideoSection";
36
import { OrganizationJoinModal } from "../teams/onboarding/OrganizationJoinModal";
37
// import { BlogBanners } from "./BlogBanners";
38
import { EmptyWorkspacesContent } from "./EmptyWorkspacesContent";
39
import PersonalizedContent from "./PersonalizedContent";
40
import { VideoCarousel } from "./VideoCarousel";
41
import { WorkspaceEntry } from "./WorkspaceEntry";
42
import { WorkspacesSearchBar } from "./WorkspacesSearchBar";
43
import { useInstallationConfiguration } from "../data/installation/installation-config-query";
44
import { SkeletonBlock } from "@podkit/loading/Skeleton";
45
46
export const GETTING_STARTED_DISMISSAL_KEY = "workspace-list-getting-started";
47
48
const WorkspacesPage: FunctionComponent = () => {
49
const [limit, setLimit] = useState(50);
50
const [searchTerm, setSearchTerm] = useState("");
51
const [showInactive, setShowInactive] = useState(false);
52
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
53
54
const { data, isLoading } = useListWorkspacesQuery({ limit });
55
const deleteInactiveWorkspaces = useDeleteInactiveWorkspacesMutation();
56
useListenToWorkspacesStatusUpdates();
57
58
const { data: org } = useCurrentOrg();
59
const { data: orgSettings } = useOrgSettingsQuery();
60
61
const { user } = useUserLoader();
62
const { mutate: mutateUser } = useUpdateCurrentUserMutation();
63
64
const { toast } = useToast();
65
66
// Sort workspaces into active/inactive groups
67
const { activeWorkspaces, inactiveWorkspaces } = useMemo(() => {
68
const sortedWorkspaces = (data || []).sort(sortWorkspaces);
69
const activeWorkspaces = sortedWorkspaces.filter((ws) => isWorkspaceActive(ws));
70
71
// respecting the limit, return inactive workspaces as well
72
const inactiveWorkspaces = sortedWorkspaces
73
.filter((ws) => !isWorkspaceActive(ws))
74
.slice(0, limit - activeWorkspaces.length);
75
76
return {
77
activeWorkspaces,
78
inactiveWorkspaces,
79
};
80
}, [data, limit]);
81
82
const handlePlay = () => {
83
trackVideoClick("create-new-workspace");
84
};
85
86
const { data: installationConfig } = useInstallationConfiguration();
87
const isDedicatedInstallation = !!installationConfig?.isDedicatedInstallation;
88
89
const isEnterpriseOnboardingEnabled = useFeatureFlag("enterprise_onboarding_enabled");
90
91
const { filteredActiveWorkspaces, filteredInactiveWorkspaces } = useMemo(() => {
92
const filteredActiveWorkspaces = activeWorkspaces.filter(
93
(info) =>
94
`${info.metadata!.name}${info.id}${info.metadata!.originalContextUrl}${
95
info.status?.gitStatus?.cloneUrl
96
}${info.status?.gitStatus?.branch}`
97
.toLowerCase()
98
.indexOf(searchTerm.toLowerCase()) !== -1,
99
);
100
101
const filteredInactiveWorkspaces = inactiveWorkspaces.filter(
102
(info) =>
103
`${info.metadata!.name}${info.id}${info.metadata!.originalContextUrl}${
104
info.status?.gitStatus?.cloneUrl
105
}${info.status?.gitStatus?.branch}`
106
.toLowerCase()
107
.indexOf(searchTerm.toLowerCase()) !== -1,
108
);
109
110
return {
111
filteredActiveWorkspaces,
112
filteredInactiveWorkspaces,
113
};
114
}, [activeWorkspaces, inactiveWorkspaces, searchTerm]);
115
116
const handleDeleteInactiveWorkspacesConfirmation = useCallback(async () => {
117
try {
118
await deleteInactiveWorkspaces.mutateAsync({
119
workspaceIds: inactiveWorkspaces.map((info) => info.id),
120
});
121
122
setDeleteModalVisible(false);
123
toast("Your workspace was deleted");
124
} catch (e) {}
125
}, [deleteInactiveWorkspaces, inactiveWorkspaces, toast]);
126
127
// 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 profile
128
const [showGettingStarted, setShowGettingStarted] = useState<boolean | undefined>(undefined);
129
useEffect(() => {
130
if (!user?.profile?.coachmarksDismissals[GETTING_STARTED_DISMISSAL_KEY]) {
131
setShowGettingStarted(true);
132
} else {
133
setShowGettingStarted(false);
134
}
135
}, [user?.profile?.coachmarksDismissals]);
136
137
const { data: userSuggestedRepos, isLoading: isUserSuggestedReposLoading } = useSuggestedRepositories({
138
excludeConfigurations: false,
139
});
140
const { data: orgSuggestedRepos, isLoading: isOrgSuggestedReposLoading } = useOrgSuggestedRepos();
141
142
const suggestedRepos = useMemo(() => {
143
const userSuggestions =
144
userSuggestedRepos
145
?.filter((repo) => {
146
const autostartMatch = user?.workspaceAutostartOptions.find((option) => {
147
return option.cloneUrl.includes(repo.url);
148
});
149
return autostartMatch;
150
})
151
.slice(0, 3) ?? [];
152
const orgSuggestions = (orgSuggestedRepos ?? []).filter((repo) => {
153
return !userSuggestions.find((userSuggestion) => userSuggestion.configurationId === repo.configurationId); // don't show duplicates from user's autostart options
154
});
155
156
return [...userSuggestions, ...orgSuggestions].slice(0, 3);
157
}, [userSuggestedRepos, orgSuggestedRepos, user?.workspaceAutostartOptions]);
158
159
const suggestedReposLoading = isUserSuggestedReposLoading || isOrgSuggestedReposLoading;
160
161
const toggleGettingStarted = useCallback(
162
(show: boolean) => {
163
setShowGettingStarted(show);
164
165
mutateUser(
166
{
167
additionalData: {
168
profile: {
169
coachmarksDismissals: {
170
[GETTING_STARTED_DISMISSAL_KEY]: !show ? new Date().toISOString() : "",
171
},
172
},
173
},
174
},
175
{
176
onError: (e) => {
177
toast("Failed to dismiss getting started");
178
setShowGettingStarted(true);
179
},
180
},
181
);
182
},
183
[mutateUser, toast],
184
);
185
186
const [isVideoModalVisible, setVideoModalVisible] = useState(false);
187
const handleVideoModalClose = useCallback(() => {
188
setVideoModalVisible(false);
189
}, []);
190
191
const welcomeMessage = orgSettings?.onboardingSettings?.welcomeMessage;
192
193
return (
194
<>
195
<Header
196
title="Workspaces"
197
subtitle="Manage, start and stop your personal development environments in the cloud."
198
/>
199
200
{isEnterpriseOnboardingEnabled && isDedicatedInstallation && (
201
<>
202
<div className="app-container flex flex-row items-center justify-end mt-4 mb-2">
203
<Tooltip content="Toggle helpful resources for getting started with Gitpod">
204
<Button
205
variant="ghost"
206
onClick={() => toggleGettingStarted(!showGettingStarted)}
207
className="p-2"
208
>
209
<div className="flex flex-row items-center gap-2">
210
<Subheading className="text-pk-content-primary">Getting started</Subheading>
211
<ChevronRight
212
className={`transform transition-transform duration-100 ${
213
showGettingStarted ? "rotate-90" : ""
214
}`}
215
size={20}
216
/>
217
</div>
218
</Button>
219
</Tooltip>
220
</div>
221
222
{showGettingStarted && (
223
<>
224
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5 lg:px-28 px-4 pb-4">
225
<Card onClick={() => setVideoModalVisible(true)}>
226
<Video className="flex-shrink-0" size={24} />
227
<div className="min-w-0">
228
<CardTitle>Learn how Gitpod works</CardTitle>
229
<CardDescription>
230
We've put together resources for you to get the most out of Gitpod.
231
</CardDescription>
232
</div>
233
</Card>
234
235
{orgSettings?.onboardingSettings?.internalLink ? (
236
<Card href={orgSettings.onboardingSettings.internalLink} isLinkExternal>
237
<Building className="flex-shrink-0" size={24} />
238
<div className="min-w-0">
239
<CardTitle>Learn more about Gitpod at {org?.name}</CardTitle>
240
<CardDescription>
241
Read through the internal Gitpod landing page of your organization.
242
</CardDescription>
243
</div>
244
</Card>
245
) : (
246
<Card href={"/new?showExamples=true"}>
247
<Code className="flex-shrink-0" size={24} />
248
<div className="min-w-0">
249
<CardTitle>Open a sample repository</CardTitle>
250
<CardDescription>
251
Explore{" "}
252
{orgSuggestedRepos?.length
253
? "repositories recommended by your organization"
254
: "a sample repository"}{" "}
255
to quickly experience Gitpod.
256
</CardDescription>
257
</div>
258
</Card>
259
)}
260
261
<Card href="https://www.gitpod.io/docs/introduction" isLinkExternal>
262
<Book className="flex-shrink-0" size={24} />
263
<div className="min-w-0">
264
<CardTitle>Visit the docs</CardTitle>
265
<CardDescription>
266
We have extensive documentation to help if you get stuck.
267
</CardDescription>
268
</div>
269
</Card>
270
</div>
271
272
<>
273
<Subheading className="font-semibold text-pk-content-primary mb-2 app-container">
274
Suggested
275
</Subheading>
276
277
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 lg:px-28 px-4">
278
{suggestedReposLoading ? (
279
<>
280
<SkeletonBlock className="w-full h-24" ready={false} />
281
<SkeletonBlock className="w-full h-24" ready={false} />
282
<SkeletonBlock className="w-full h-24" ready={false} />
283
</>
284
) : (
285
<>
286
{suggestedRepos.map((repo) => {
287
const isOrgSuggested =
288
(repo as SuggestedOrgRepository).orgSuggested ?? false;
289
290
return (
291
<Card
292
key={repo.url}
293
href={`/new#${repo.url}`}
294
className={cn(
295
"border-[0.5px] hover:bg-pk-surface-tertiary transition-colors w-full",
296
{
297
"border-[#D79A45]": isOrgSuggested,
298
"border-pk-border-base": !isOrgSuggested,
299
},
300
)}
301
>
302
<div className="min-w-0 w-full space-y-1.5">
303
<CardTitle className="flex flex-row items-center gap-2 w-full">
304
<span className="truncate block min-w-0 text-base">
305
{repo.configurationName || repo.repoName}
306
</span>
307
{isOrgSuggested && (
308
<PillLabel
309
className="capitalize bg-kumquat-light shrink-0 text-sm"
310
type="warn"
311
>
312
Recommended
313
</PillLabel>
314
)}
315
</CardTitle>
316
<CardDescription className="truncate text-sm opacity-75">
317
{repo.url}
318
</CardDescription>
319
</div>
320
</Card>
321
);
322
})}
323
{suggestedRepos.length === 0 && (
324
<Card
325
className={cn(
326
"border-[0.5px] hover:bg-pk-surface-tertiary w-full border-pk-border-base h-24",
327
)}
328
>
329
<div className="min-w-0 w-full space-y-1.5">
330
<CardTitle className="flex flex-row items-center gap-2 w-full">
331
<span className="truncate block min-w-0 text-base">
332
No suggestions yet
333
</span>
334
</CardTitle>
335
<CardDescription className="truncate text-sm opacity-75">
336
Start some workspaces to start seeing suggestions here.
337
</CardDescription>
338
</div>
339
</Card>
340
)}
341
</>
342
)}
343
</div>
344
</>
345
346
<Modal
347
visible={isVideoModalVisible}
348
onClose={handleVideoModalClose}
349
containerClassName="min-[576px]:max-w-[600px]"
350
>
351
<ModalHeader>Demo video</ModalHeader>
352
<ModalBody>
353
<div className="flex flex-row items-center justify-center">
354
<VideoSection
355
metadataVideoTitle="Gitpod demo"
356
playbackId="m01BUvCkTz7HzQKFoIcQmK00Rx5laLLoMViWBstetmvLs"
357
poster="https://i.ytimg.com/vi_webp/1ZBN-b2cIB8/maxresdefault.webp"
358
playerProps={{ onPlay: handlePlay, defaultHiddenCaptions: true }}
359
className="w-[535px] rounded-xl"
360
/>
361
</div>
362
</ModalBody>
363
<ModalBaseFooter>
364
<Button variant="secondary" onClick={handleVideoModalClose}>
365
Close
366
</Button>
367
</ModalBaseFooter>
368
</Modal>
369
</>
370
)}
371
</>
372
)}
373
374
{deleteModalVisible && (
375
<ConfirmationModal
376
title="Delete Inactive Workspaces"
377
areYouSureText="Are you sure you want to delete all inactive workspaces?"
378
buttonText="Delete Inactive Workspaces"
379
onClose={() => setDeleteModalVisible(false)}
380
onConfirm={handleDeleteInactiveWorkspacesConfirmation}
381
visible
382
/>
383
)}
384
385
{!isLoading &&
386
(activeWorkspaces.length > 0 || inactiveWorkspaces.length > 0 || searchTerm ? (
387
<>
388
<div
389
className={
390
!isDedicatedInstallation ? "!pl-0 app-container flex flex-1 flex-row" : "app-container"
391
}
392
>
393
<div>
394
<WorkspacesSearchBar
395
limit={limit}
396
searchTerm={searchTerm}
397
onLimitUpdated={setLimit}
398
onSearchTermUpdated={setSearchTerm}
399
/>
400
<ItemsList className={!isDedicatedInstallation ? "app-container xl:!pr-4 pb-40" : ""}>
401
<div className="border-t border-gray-200 dark:border-gray-800"></div>
402
{filteredActiveWorkspaces.map((info) => {
403
return <WorkspaceEntry key={info.id} info={info} />;
404
})}
405
{filteredActiveWorkspaces.length > 0 && <div className="py-6"></div>}
406
{filteredInactiveWorkspaces.length > 0 && (
407
<div>
408
<div
409
onClick={() => setShowInactive(!showInactive)}
410
className="flex cursor-pointer p-6 flex-row bg-pk-surface-secondary hover:bg-pk-surface-tertiary text-pk-content-tertiary rounded-xl mb-2"
411
>
412
<div className="pr-2">
413
<Arrow direction={showInactive ? "down" : "right"} />
414
</div>
415
<div className="flex flex-grow flex-col ">
416
<div className="font-medium truncate">
417
<span>Inactive Workspaces&nbsp;</span>
418
<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">
419
{filteredInactiveWorkspaces.length}
420
</span>
421
</div>
422
<div className="text-sm flex-auto">
423
Workspaces that have been stopped for more than 24 hours.
424
Inactive workspaces are automatically deleted after 14 days.{" "}
425
<a
426
target="_blank"
427
rel="noreferrer"
428
className="gp-link"
429
href="https://www.gitpod.io/docs/configure/workspaces/workspace-lifecycle#workspace-deletion"
430
onClick={(evt) => evt.stopPropagation()}
431
>
432
Learn more
433
</a>
434
</div>
435
</div>
436
<div className="self-center">
437
{showInactive ? (
438
<Button
439
variant="ghost"
440
// TODO: Remove these classes once we decide on the new button style
441
// Leaving these to emulate the old button's danger.secondary style until we decide if we want that style or not
442
className="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"
443
onClick={(evt) => {
444
setDeleteModalVisible(true);
445
evt.stopPropagation();
446
}}
447
>
448
Delete Inactive Workspaces
449
</Button>
450
) : null}
451
</div>
452
</div>
453
{showInactive ? (
454
<>
455
{filteredInactiveWorkspaces.map((info) => {
456
return <WorkspaceEntry key={info.id} info={info} />;
457
})}
458
</>
459
) : null}
460
</div>
461
)}
462
</ItemsList>
463
</div>
464
{/* Show Educational if user is in gitpodIo */}
465
{!isDedicatedInstallation && (
466
<div className="max-xl:hidden border-l border-gray-200 dark:border-gray-800 pl-6 pt-5 pb-4 space-y-8">
467
<VideoCarousel />
468
<div className="flex flex-col gap-2">
469
<h3 className="text-lg font-semibold text-pk-content-primary">Documentation</h3>
470
<div className="flex flex-col gap-1 w-fit">
471
<a
472
href="https://www.gitpod.io/docs/introduction"
473
target="_blank"
474
rel="noopener noreferrer"
475
className="text-sm text-pk-content-primary items-center gap-x-2 flex flex-row"
476
>
477
<BookOpen width={20} />{" "}
478
<span className="hover:text-blue-600 dark:hover:text-blue-400">
479
Read the docs
480
</span>
481
</a>
482
<a
483
href="https://www.gitpod.io/docs/configure/workspaces"
484
target="_blank"
485
rel="noopener noreferrer"
486
className="text-sm text-pk-content-primary items-center gap-x-2 flex flex-row"
487
>
488
<GitpodStrokedSVG />
489
<span className="hover:text-blue-600 dark:hover:text-blue-400">
490
Configuring a workspace
491
</span>
492
</a>
493
<a
494
href="https://www.gitpod.io/docs/references/gitpod-yml"
495
target="_blank"
496
rel="noopener noreferrer"
497
className="text-sm text-pk-content-primary items-center gap-x-2 flex flex-row"
498
>
499
<Code width={20} />{" "}
500
<span className="hover:text-blue-600 dark:hover:text-blue-400">
501
.gitpod.yml reference
502
</span>
503
</a>
504
</div>
505
</div>
506
<PersonalizedContent />
507
{/* Uncomment the following, if you need side banners in future */}
508
{/* <BlogBanners /> */}
509
</div>
510
)}
511
</div>
512
</>
513
) : (
514
<EmptyWorkspacesContent />
515
))}
516
517
{isEnterpriseOnboardingEnabled && isDedicatedInstallation && welcomeMessage && user && (
518
<OrganizationJoinModal welcomeMessage={welcomeMessage} user={user} />
519
)}
520
</>
521
);
522
};
523
524
export default WorkspacesPage;
525
526
const CardTitle = ({ children, className }: { className?: string; children: React.ReactNode }) => {
527
return <span className={cn("text-lg font-semibold text-pk-content-primary", className)}>{children}</span>;
528
};
529
const CardDescription = ({ children, className }: { className?: string; children: React.ReactNode }) => {
530
return <p className={cn("text-pk-content-secondary", className)}>{children}</p>;
531
};
532
type CardProps = {
533
children: React.ReactNode;
534
href?: string;
535
isLinkExternal?: boolean;
536
className?: string;
537
onClick?: () => void;
538
};
539
const Card = ({ children, href, isLinkExternal, className: classNameFromProps, onClick }: CardProps) => {
540
const className = cn(
541
"bg-pk-surface-secondary flex gap-3 py-4 px-5 rounded-xl text-left w-full h-full",
542
classNameFromProps,
543
);
544
545
if (href && isLinkExternal) {
546
return (
547
<a href={href} className={className} target="_blank" rel="noreferrer">
548
{children}
549
</a>
550
);
551
}
552
553
if (href) {
554
return (
555
<Link to={href} className={className}>
556
{children}
557
</Link>
558
);
559
}
560
561
if (onClick) {
562
return (
563
<button className={className} onClick={onClick}>
564
{children}
565
</button>
566
);
567
}
568
569
return <div className={className}>{children}</div>;
570
};
571
572
const sortWorkspaces = (a: Workspace, b: Workspace) => {
573
const result = workspaceActiveDate(b).localeCompare(workspaceActiveDate(a));
574
if (result === 0) {
575
// both active now? order by workspace id
576
return b.id.localeCompare(a.id);
577
}
578
return result;
579
};
580
581
/**
582
* Given a WorkspaceInfo, return a ISO string of the last related activity
583
*/
584
function workspaceActiveDate(info: Workspace): string {
585
return info.status!.phase!.lastTransitionTime!.toDate().toISOString();
586
}
587
588
/**
589
* Returns a boolean indicating if the workspace should be considered active.
590
* A workspace is considered active if it is pinned, not stopped, or was active within the last 24 hours
591
*
592
* @param info WorkspaceInfo
593
* @returns boolean If workspace is considered active
594
*/
595
function isWorkspaceActive(info: Workspace): boolean {
596
const lastSessionStart = info.status!.phase!.lastTransitionTime!.toDate().toISOString();
597
const twentyfourHoursAgo = hoursBefore(new Date().toISOString(), 24);
598
599
const isStopped = info.status?.phase?.name === WorkspacePhase_Phase.STOPPED;
600
return info.metadata!.pinned || !isStopped || isDateSmallerOrEqual(twentyfourHoursAgo, lastSessionStart);
601
}
602
603