Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
gitpod-io
GitHub Repository: gitpod-io/gitpod
Path: blob/main/components/dashboard/src/prebuilds/list/PrebuildList.tsx
2501 views
1
/**
2
* Copyright (c) 2024 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 { useCallback, useEffect, useMemo, useState } from "react";
8
import { Link, useHistory } from "react-router-dom";
9
import { useQueryParams } from "../../hooks/use-query-params";
10
import { PrebuildListEmptyState } from "./PrebuildListEmptyState";
11
import { PrebuildListErrorState } from "./PrebuildListErrorState";
12
import { PrebuildsTable } from "./PrebuildTable";
13
import { LoadingState } from "@podkit/loading/LoadingState";
14
import { useListOrganizationPrebuildsQuery } from "../../data/prebuilds/organization-prebuilds-query";
15
import { ListOrganizationPrebuildsRequest_Filter_State, Prebuild } from "@gitpod/public-api/lib/gitpod/v1/prebuild_pb";
16
import { validate } from "uuid";
17
import type { TableSortOrder } from "@podkit/tables/SortableTable";
18
import { SortOrder } from "@gitpod/public-api/lib/gitpod/v1/sorting_pb";
19
import { RunPrebuildModal } from "./RunPrebuildModal";
20
import { isPrebuildDone, watchPrebuild } from "../../data/prebuilds/prebuild-queries";
21
import { Disposable } from "@gitpod/gitpod-protocol";
22
23
const STATUS_FILTER_VALUES = ["succeeded", "failed", "unfinished", undefined] as const; // undefined means any status
24
export type StatusOption = typeof STATUS_FILTER_VALUES[number];
25
export type Filter = {
26
status?: StatusOption;
27
configurationId?: string;
28
};
29
30
const SORT_FIELD_VALUES = ["creationTime"] as const;
31
export type SortField = typeof SORT_FIELD_VALUES[number];
32
export type Sort = {
33
sortBy: SortField;
34
sortOrder: TableSortOrder;
35
};
36
37
const pageSize = 30;
38
39
type Props = {
40
initialFilter?: Filter;
41
organizationId?: string;
42
/**
43
* If true, the configuration dropdown and the "Run Prebuild" button will be hidden.
44
*/
45
hideOrgSpecificControls?: boolean;
46
};
47
export const PrebuildsList = ({ initialFilter, organizationId, hideOrgSpecificControls }: Props) => {
48
const history = useHistory();
49
const params = useQueryParams();
50
51
const [statusFilter, setPrebuildsFilter] = useState(parseStatus(params) ?? initialFilter?.status);
52
const [configurationFilter, setConfigurationFilter] = useState(
53
parseConfigurationId(params) ?? initialFilter?.configurationId,
54
);
55
56
const [sortBy, setSortBy] = useState(parseSortBy(params));
57
const [sortOrder, setSortOrder] = useState<TableSortOrder>(parseSortOrder(params));
58
59
const [prebuilds, setPrebuilds] = useState<Prebuild[]>([]);
60
61
const [showRunPrebuildModal, setShowRunPrebuildModal] = useState(false);
62
63
const handleFilterChange = useCallback((filter: Filter) => {
64
setPrebuildsFilter(filter.status);
65
setConfigurationFilter(filter.configurationId);
66
}, []);
67
const filter = useMemo<Filter>(() => {
68
return {
69
status: statusFilter,
70
configurationId: configurationFilter,
71
};
72
}, [configurationFilter, statusFilter]);
73
const apiFilter = useMemo(() => {
74
return {
75
state: toApiStatus(statusFilter),
76
...(configurationFilter ? { configuration: { id: configurationFilter } } : {}),
77
};
78
}, [statusFilter, configurationFilter]);
79
80
const sort = useMemo<Sort>(() => {
81
return {
82
sortBy,
83
sortOrder,
84
};
85
}, [sortBy, sortOrder]);
86
const apiSort = useMemo(() => {
87
return {
88
order: sortOrder === "desc" ? SortOrder.DESC : SortOrder.ASC,
89
field: sortBy,
90
};
91
}, [sortBy, sortOrder]);
92
const handleSort = useCallback(
93
(columnName: SortField, newSortOrder: TableSortOrder) => {
94
setSortBy(columnName);
95
setSortOrder(newSortOrder);
96
},
97
[setSortOrder],
98
);
99
100
useEffect(() => {
101
const params = new URLSearchParams();
102
103
if (statusFilter) {
104
params.set("prebuilds", statusFilter);
105
}
106
107
if (configurationFilter && configurationFilter !== initialFilter?.configurationId) {
108
params.set("configurationId", configurationFilter);
109
}
110
111
params.toString();
112
history.replace({ search: `?${params.toString()}` });
113
}, [history, statusFilter, configurationFilter, initialFilter?.configurationId]);
114
115
const {
116
data,
117
isLoading,
118
isFetching,
119
isFetchingNextPage,
120
isPreviousData,
121
hasNextPage,
122
refetch: refetchPrebuilds,
123
fetchNextPage,
124
isError,
125
error,
126
} = useListOrganizationPrebuildsQuery({
127
filter: apiFilter,
128
organizationId,
129
sort: apiSort,
130
pageSize,
131
});
132
133
const prebuildsData = useMemo(() => {
134
return data?.pages.map((page) => page.prebuilds).flat() ?? [];
135
}, [data?.pages]);
136
137
useEffect(() => {
138
// Watch prebuilds that are not done yet, and update their status
139
const prebuilds = [...prebuildsData];
140
const listeners = prebuilds.map((prebuild) => {
141
if (isPrebuildDone(prebuild)) {
142
return Disposable.NULL;
143
}
144
145
return watchPrebuild(prebuild.id, (update) => {
146
const index = prebuilds.findIndex((p) => p.id === prebuild.id);
147
if (index === -1) {
148
console.warn("Can't handle prebuild update");
149
return false;
150
}
151
152
prebuilds.splice(index, 1, update);
153
setPrebuilds([...prebuilds]);
154
155
return isPrebuildDone(update);
156
});
157
});
158
setPrebuilds(prebuilds);
159
160
return () => {
161
listeners.forEach((l) => l?.dispose());
162
};
163
}, [prebuildsData, setPrebuilds]);
164
165
const hasMoreThanOnePage = (data?.pages.length ?? 0) > 1;
166
167
// This tracks any filters/search params applied
168
const hasFilters = !!filter.status || !!filter.configurationId;
169
170
// Show the table once we're done loading and either have results, or have filters applied
171
const showTable = !isLoading && (prebuilds.length > 0 || hasFilters);
172
173
return (
174
<>
175
{isLoading && <LoadingState />}
176
177
{showTable && (
178
<>
179
<PrebuildsTable
180
prebuilds={prebuilds}
181
// we check isPreviousData too so we don't show spinner if it's a background refresh
182
isSearching={isFetching && isPreviousData}
183
isFetchingNextPage={isFetchingNextPage}
184
hasNextPage={!!hasNextPage}
185
filter={filter}
186
sort={sort}
187
hasMoreThanOnePage={hasMoreThanOnePage}
188
hideOrgSpecificControls={!!hideOrgSpecificControls}
189
onLoadNextPage={() => fetchNextPage()}
190
onFilterChange={handleFilterChange}
191
onSort={handleSort}
192
onTriggerPrebuild={() => setShowRunPrebuildModal(true)}
193
/>
194
<div className="flex justify-center mt-4">
195
<span className="text-pk-content-secondary text-xs max-w-md text-center">
196
Looking for older prebuilds? Prebuilds are garbage-collected if no workspace is started from
197
them within seven days. To view records of older prebuilds, please refer to the{" "}
198
<Link to={"/usage"} className="gp-link">
199
usage report
200
</Link>
201
.
202
</span>
203
</div>
204
</>
205
)}
206
207
{showRunPrebuildModal && (
208
<RunPrebuildModal
209
onClose={() => setShowRunPrebuildModal(false)}
210
onRun={() => {
211
refetchPrebuilds();
212
}}
213
defaultRepositoryId={configurationFilter}
214
/>
215
)}
216
217
{!showTable && !isLoading && (
218
<PrebuildListEmptyState onTriggerPrebuild={() => setShowRunPrebuildModal(true)} />
219
)}
220
{isError && <PrebuildListErrorState error={error} />}
221
</>
222
);
223
};
224
225
const toApiStatus = (status: StatusOption): ListOrganizationPrebuildsRequest_Filter_State | undefined => {
226
switch (status) {
227
case "failed":
228
return ListOrganizationPrebuildsRequest_Filter_State.FAILED;
229
case "succeeded":
230
return ListOrganizationPrebuildsRequest_Filter_State.SUCCEEDED;
231
case "unfinished":
232
return ListOrganizationPrebuildsRequest_Filter_State.UNFINISHED;
233
}
234
235
return undefined;
236
};
237
238
const isStatusOption = (value: any): value is StatusOption => {
239
return STATUS_FILTER_VALUES.includes(value);
240
};
241
const parseStatus = (params: URLSearchParams): StatusOption => {
242
const filter = params.get("prebuilds");
243
if (filter && isStatusOption(filter)) {
244
return filter;
245
}
246
247
return undefined;
248
};
249
250
const parseSortOrder = (params: URLSearchParams): TableSortOrder => {
251
const sortOrder = params.get("sortOrder");
252
if (sortOrder === "asc" || sortOrder === "desc") {
253
return sortOrder;
254
}
255
return "desc";
256
};
257
258
const parseSortBy = (params: URLSearchParams): SortField => {
259
const sortBy = params.get("sortBy");
260
261
// todo: potentially allow more fields
262
if (sortBy === "creationTime") {
263
return sortBy;
264
}
265
return "creationTime";
266
};
267
268
const parseConfigurationId = (params: URLSearchParams): string | undefined => {
269
const configuration = params.get("configurationId");
270
if (configuration && validate(configuration)) {
271
return configuration;
272
}
273
274
return undefined;
275
};
276
277