Path: blob/main/components/dashboard/src/prebuilds/list/PrebuildList.tsx
2501 views
/**1* Copyright (c) 2024 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 { useCallback, useEffect, useMemo, useState } from "react";7import { Link, useHistory } from "react-router-dom";8import { useQueryParams } from "../../hooks/use-query-params";9import { PrebuildListEmptyState } from "./PrebuildListEmptyState";10import { PrebuildListErrorState } from "./PrebuildListErrorState";11import { PrebuildsTable } from "./PrebuildTable";12import { LoadingState } from "@podkit/loading/LoadingState";13import { useListOrganizationPrebuildsQuery } from "../../data/prebuilds/organization-prebuilds-query";14import { ListOrganizationPrebuildsRequest_Filter_State, Prebuild } from "@gitpod/public-api/lib/gitpod/v1/prebuild_pb";15import { validate } from "uuid";16import type { TableSortOrder } from "@podkit/tables/SortableTable";17import { SortOrder } from "@gitpod/public-api/lib/gitpod/v1/sorting_pb";18import { RunPrebuildModal } from "./RunPrebuildModal";19import { isPrebuildDone, watchPrebuild } from "../../data/prebuilds/prebuild-queries";20import { Disposable } from "@gitpod/gitpod-protocol";2122const STATUS_FILTER_VALUES = ["succeeded", "failed", "unfinished", undefined] as const; // undefined means any status23export type StatusOption = typeof STATUS_FILTER_VALUES[number];24export type Filter = {25status?: StatusOption;26configurationId?: string;27};2829const SORT_FIELD_VALUES = ["creationTime"] as const;30export type SortField = typeof SORT_FIELD_VALUES[number];31export type Sort = {32sortBy: SortField;33sortOrder: TableSortOrder;34};3536const pageSize = 30;3738type Props = {39initialFilter?: Filter;40organizationId?: string;41/**42* If true, the configuration dropdown and the "Run Prebuild" button will be hidden.43*/44hideOrgSpecificControls?: boolean;45};46export const PrebuildsList = ({ initialFilter, organizationId, hideOrgSpecificControls }: Props) => {47const history = useHistory();48const params = useQueryParams();4950const [statusFilter, setPrebuildsFilter] = useState(parseStatus(params) ?? initialFilter?.status);51const [configurationFilter, setConfigurationFilter] = useState(52parseConfigurationId(params) ?? initialFilter?.configurationId,53);5455const [sortBy, setSortBy] = useState(parseSortBy(params));56const [sortOrder, setSortOrder] = useState<TableSortOrder>(parseSortOrder(params));5758const [prebuilds, setPrebuilds] = useState<Prebuild[]>([]);5960const [showRunPrebuildModal, setShowRunPrebuildModal] = useState(false);6162const handleFilterChange = useCallback((filter: Filter) => {63setPrebuildsFilter(filter.status);64setConfigurationFilter(filter.configurationId);65}, []);66const filter = useMemo<Filter>(() => {67return {68status: statusFilter,69configurationId: configurationFilter,70};71}, [configurationFilter, statusFilter]);72const apiFilter = useMemo(() => {73return {74state: toApiStatus(statusFilter),75...(configurationFilter ? { configuration: { id: configurationFilter } } : {}),76};77}, [statusFilter, configurationFilter]);7879const sort = useMemo<Sort>(() => {80return {81sortBy,82sortOrder,83};84}, [sortBy, sortOrder]);85const apiSort = useMemo(() => {86return {87order: sortOrder === "desc" ? SortOrder.DESC : SortOrder.ASC,88field: sortBy,89};90}, [sortBy, sortOrder]);91const handleSort = useCallback(92(columnName: SortField, newSortOrder: TableSortOrder) => {93setSortBy(columnName);94setSortOrder(newSortOrder);95},96[setSortOrder],97);9899useEffect(() => {100const params = new URLSearchParams();101102if (statusFilter) {103params.set("prebuilds", statusFilter);104}105106if (configurationFilter && configurationFilter !== initialFilter?.configurationId) {107params.set("configurationId", configurationFilter);108}109110params.toString();111history.replace({ search: `?${params.toString()}` });112}, [history, statusFilter, configurationFilter, initialFilter?.configurationId]);113114const {115data,116isLoading,117isFetching,118isFetchingNextPage,119isPreviousData,120hasNextPage,121refetch: refetchPrebuilds,122fetchNextPage,123isError,124error,125} = useListOrganizationPrebuildsQuery({126filter: apiFilter,127organizationId,128sort: apiSort,129pageSize,130});131132const prebuildsData = useMemo(() => {133return data?.pages.map((page) => page.prebuilds).flat() ?? [];134}, [data?.pages]);135136useEffect(() => {137// Watch prebuilds that are not done yet, and update their status138const prebuilds = [...prebuildsData];139const listeners = prebuilds.map((prebuild) => {140if (isPrebuildDone(prebuild)) {141return Disposable.NULL;142}143144return watchPrebuild(prebuild.id, (update) => {145const index = prebuilds.findIndex((p) => p.id === prebuild.id);146if (index === -1) {147console.warn("Can't handle prebuild update");148return false;149}150151prebuilds.splice(index, 1, update);152setPrebuilds([...prebuilds]);153154return isPrebuildDone(update);155});156});157setPrebuilds(prebuilds);158159return () => {160listeners.forEach((l) => l?.dispose());161};162}, [prebuildsData, setPrebuilds]);163164const hasMoreThanOnePage = (data?.pages.length ?? 0) > 1;165166// This tracks any filters/search params applied167const hasFilters = !!filter.status || !!filter.configurationId;168169// Show the table once we're done loading and either have results, or have filters applied170const showTable = !isLoading && (prebuilds.length > 0 || hasFilters);171172return (173<>174{isLoading && <LoadingState />}175176{showTable && (177<>178<PrebuildsTable179prebuilds={prebuilds}180// we check isPreviousData too so we don't show spinner if it's a background refresh181isSearching={isFetching && isPreviousData}182isFetchingNextPage={isFetchingNextPage}183hasNextPage={!!hasNextPage}184filter={filter}185sort={sort}186hasMoreThanOnePage={hasMoreThanOnePage}187hideOrgSpecificControls={!!hideOrgSpecificControls}188onLoadNextPage={() => fetchNextPage()}189onFilterChange={handleFilterChange}190onSort={handleSort}191onTriggerPrebuild={() => setShowRunPrebuildModal(true)}192/>193<div className="flex justify-center mt-4">194<span className="text-pk-content-secondary text-xs max-w-md text-center">195Looking for older prebuilds? Prebuilds are garbage-collected if no workspace is started from196them within seven days. To view records of older prebuilds, please refer to the{" "}197<Link to={"/usage"} className="gp-link">198usage report199</Link>200.201</span>202</div>203</>204)}205206{showRunPrebuildModal && (207<RunPrebuildModal208onClose={() => setShowRunPrebuildModal(false)}209onRun={() => {210refetchPrebuilds();211}}212defaultRepositoryId={configurationFilter}213/>214)}215216{!showTable && !isLoading && (217<PrebuildListEmptyState onTriggerPrebuild={() => setShowRunPrebuildModal(true)} />218)}219{isError && <PrebuildListErrorState error={error} />}220</>221);222};223224const toApiStatus = (status: StatusOption): ListOrganizationPrebuildsRequest_Filter_State | undefined => {225switch (status) {226case "failed":227return ListOrganizationPrebuildsRequest_Filter_State.FAILED;228case "succeeded":229return ListOrganizationPrebuildsRequest_Filter_State.SUCCEEDED;230case "unfinished":231return ListOrganizationPrebuildsRequest_Filter_State.UNFINISHED;232}233234return undefined;235};236237const isStatusOption = (value: any): value is StatusOption => {238return STATUS_FILTER_VALUES.includes(value);239};240const parseStatus = (params: URLSearchParams): StatusOption => {241const filter = params.get("prebuilds");242if (filter && isStatusOption(filter)) {243return filter;244}245246return undefined;247};248249const parseSortOrder = (params: URLSearchParams): TableSortOrder => {250const sortOrder = params.get("sortOrder");251if (sortOrder === "asc" || sortOrder === "desc") {252return sortOrder;253}254return "desc";255};256257const parseSortBy = (params: URLSearchParams): SortField => {258const sortBy = params.get("sortBy");259260// todo: potentially allow more fields261if (sortBy === "creationTime") {262return sortBy;263}264return "creationTime";265};266267const parseConfigurationId = (params: URLSearchParams): string | undefined => {268const configuration = params.get("configurationId");269if (configuration && validate(configuration)) {270return configuration;271}272273return undefined;274};275276277