Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: get build status and trigger builds #3529

Merged
merged 6 commits into from
Feb 27, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 111 additions & 4 deletions client/src/features/sessionsV2/SessionView/EnvironmentCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,28 @@
* limitations under the License.
*/

import { skipToken } from "@reduxjs/toolkit/query";
import cx from "classnames";
import { ReactNode } from "react";
import { ReactNode, useEffect } from "react";
import {
Bricks,
CircleFill,
Clock,
Globe2,
Link45deg,
Tools,
} from "react-bootstrap-icons";
import { Badge, Card, CardBody, Col, Row } from "reactstrap";

import { Loader } from "../../../components/Loader";
import { RtkOrNotebooksError } from "../../../components/errors/RtkErrorAlert";
import { ErrorLabel } from "../../../components/formlabels/FormLabels";
import useAppDispatch from "../../../utils/customHooks/useAppDispatch.hook";
import { toHumanDateTime } from "../../../utils/helpers/DateTimeUtils";
import type { SessionLauncher } from "../api/sessionLaunchersV2.api";
import type { Build, SessionLauncher } from "../api/sessionLaunchersV2.api";
import {
sessionLaunchersV2Api,
useGetEnvironmentsByEnvironmentIdBuildsQuery as useGetBuildsQuery,
} from "../api/sessionLaunchersV2.api";
import { BUILDER_IMAGE_NOT_READY_VALUE } from "../session.constants";
import { safeStringify } from "../session.utils";

Expand Down Expand Up @@ -57,7 +65,7 @@ export function EnvironmentCard({ launcher }: { launcher: SessionLauncher }) {
</>
) : environment.environment_image_source === "build" ? (
<>
<Tools size={24} />
<Bricks size={24} />
Built by RenkuLab
</>
) : (
Expand Down Expand Up @@ -163,6 +171,39 @@ function CustomBuildEnvironmentValues({
}) {
const { environment } = launcher;

const {
data: builds,
isLoading,
error,
} = useGetBuildsQuery(
environment.environment_image_source === "build"
? { environmentId: environment.id }
: skipToken
);

const lastBuild = builds?.at(0);

sessionLaunchersV2Api.endpoints.getEnvironmentsByEnvironmentIdBuilds.useQuerySubscription(
lastBuild?.status === "in_progress"
? { environmentId: environment.id }
: skipToken,
{
pollingInterval: 1_000,
}
);

// Invalidate launchers if the container image is not the same as the
// image from the last successful build
const dispatch = useAppDispatch();
useEffect(() => {
if (
lastBuild?.status === "succeeded" &&
lastBuild.result.image !== launcher.environment.container_image
) {
dispatch(sessionLaunchersV2Api.endpoints.invalidateLaunchers.initiate());
}
}, [dispatch, lastBuild, launcher.environment.container_image]);

if (environment.environment_image_source !== "build") {
return null;
}
Expand All @@ -179,6 +220,32 @@ function CustomBuildEnvironmentValues({
<ReadyStatusBadge />
)}
</EnvironmentRow>
<EnvironmentRow>
{isLoading ? (
<span>
<Loader className="me-1" inline size={16} />
Loading build status...
</span>
) : error || !builds ? (
<div>
<p className="mb-0">Error: could not load build status</p>
{error && <RtkOrNotebooksError error={error} dismissible={false} />}
</div>
) : lastBuild == null ? (
<span className="fst-italic">
This session environment does not have a build yet.
</span>
) : (
<div className="d-block">
<label className={cx("text-nowrap", "mb-0", "me-2")}>
Last build status:
</label>
<span>
<BuildStatusBadge status={lastBuild.status} />
</span>
</div>
)}
Comment on lines +224 to +247
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: would it make sense to keep the "label + span" pattern to have more consistency? E.G. always <label>Last build</label> and then "loading" | "no builds yet" | badge.
Of course, it doesn't apply in the case of an error.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure I feel a need for consistency here. For example, we can have this:
image

This does not look out-of-place to me.

Also, on another note, <label> outside of a <form> should be forbidden. <label> is an HTML element made to label form inputs and not other things. In this case the <dl> element would be the most fitting (https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dl).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd say consistent is usually better than non-consistent. The content might be easier to parse for users.
However, on the offcanvas, we don't have consistency in other places so this isn't a blocker. Feel free to merge it as-is if you think it's good enough.

</EnvironmentRow>

<EnvironmentRowWithLabel label="Repository" value={repository || ""} />
<EnvironmentRowWithLabel
Expand All @@ -189,6 +256,10 @@ function CustomBuildEnvironmentValues({
label="User interface"
value={frontend_variant || ""}
/>

{environment.container_image !== BUILDER_IMAGE_NOT_READY_VALUE && (
<CustomImageEnvironmentValues launcher={launcher} />
)}
</>
);
}
Expand Down Expand Up @@ -279,3 +350,39 @@ function NotReadyStatusBadge() {
</Badge>
);
}

interface BuildStatusBadgeProps {
status: Build["status"];
}

function BuildStatusBadge({ status }: BuildStatusBadgeProps) {
const badgeIcon =
status === "in_progress" ? (
<Loader className="me-1" inline size={12} />
) : (
<CircleFill className={cx("me-1", "bi")} />
);

const badgeText =
status === "in_progress"
? "In progress"
: status === "cancelled"
? "Cancelled"
: status === "succeeded"
? "Succeeded"
: "Failed";

const badgeColorClasses =
status === "in_progress"
? ["border-warning", "bg-warning-subtle", "text-warning-emphasis"]
: status === "succeeded"
? ["border-success", "bg-success-subtle", "text-success-emphasis"]
: ["border-danger", "bg-danger-subtle", "text-danger-emphasis"];

return (
<Badge pill className={cx("border", badgeColorClasses)}>
{badgeIcon}
{badgeText && <span className="fw-normal">{badgeText}</span>}
</Badge>
);
}
147 changes: 111 additions & 36 deletions client/src/features/sessionsV2/SessionsV2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@
* limitations under the License.
*/

import { SerializedError } from "@reduxjs/toolkit";
import { FetchBaseQueryError, skipToken } from "@reduxjs/toolkit/query";
import cx from "classnames";
import { useCallback, useMemo, useState } from "react";
import { Pencil, PlayCircle, Trash } from "react-bootstrap-icons";
import { Bricks, Pencil, PlayCircle, Trash, XLg } from "react-bootstrap-icons";
import { generatePath } from "react-router-dom-v5-compat";
import {
Badge,
Expand All @@ -28,11 +30,18 @@ import {
CardHeader,
DropdownItem,
ListGroup,
ModalBody,
ModalFooter,
ModalHeader,
} from "reactstrap";

import { Loader } from "../../components/Loader";
import { ButtonWithMenuV2 } from "../../components/buttons/Button";
import { RtkErrorAlert } from "../../components/errors/RtkErrorAlert";
import {
RtkErrorAlert,
RtkOrNotebooksError,
} from "../../components/errors/RtkErrorAlert";
import ScrollableModal from "../../components/modal/ScrollableModal";
import { ABSOLUTE_ROUTES } from "../../routing/routes.constants";
import useLocationHash from "../../utils/customHooks/useLocationHash.hook";
import useProjectPermissions from "../ProjectPageV2/utils/useProjectPermissions.hook";
Expand All @@ -44,7 +53,11 @@ import SessionItem from "./SessionList/SessionItem";
import { SessionItemDisplay } from "./SessionList/SessionItemDisplay";
import { SessionView } from "./SessionView/SessionView";
import type { SessionLauncher } from "./api/sessionLaunchersV2.api";
import { useGetProjectsByProjectIdSessionLaunchersQuery as useGetProjectSessionLaunchersQuery } from "./api/sessionLaunchersV2.api";
import {
useGetEnvironmentsByEnvironmentIdBuildsQuery as useGetBuildsQuery,
useGetProjectsByProjectIdSessionLaunchersQuery as useGetProjectSessionLaunchersQuery,
usePostEnvironmentsByEnvironmentIdBuildsMutation as usePostBuildMutation,
} from "./api/sessionLaunchersV2.api";
import { useGetSessionsQuery as useGetSessionsQueryV2 } from "./api/sessionsV2.api";
import UpdateSessionLauncherModal from "./components/SessionModals/UpdateSessionLauncherModal";
import { SessionV2 } from "./sessionsV2.types";
Expand Down Expand Up @@ -192,6 +205,22 @@ export function SessionV2Actions({
setIsDeleteOpen((open) => !open);
}, []);

const [postBuild, result] = usePostBuildMutation();
const triggerBuild = useCallback(() => {
postBuild({ environmentId: launcher.environment.id });
}, [launcher.environment.id, postBuild]);

const { data: builds } = useGetBuildsQuery(
launcher.environment.environment_image_source === "build"
? { environmentId: launcher.environment.id }
: skipToken
);
const hasInProgressBuild = useMemo(
() =>
builds ? !!builds.find(({ status }) => status === "in_progress") : false,
[builds]
);

const defaultAction = (
<Button
className="text-nowrap"
Expand All @@ -205,41 +234,57 @@ export function SessionV2Actions({
</Button>
);

const rebuildAction = launcher.environment.environment_kind === "CUSTOM" &&
launcher.environment.environment_image_source === "build" && (
<DropdownItem
data-cy="session-view-menu-rebuild"
disabled={hasInProgressBuild}
onClick={triggerBuild}
>
<Bricks className={cx("bi", "me-1")} />
Rebuild session image
</DropdownItem>
);

return (
<PermissionsGuard
disabled={null}
enabled={
<>
<ButtonWithMenuV2
color="outline-primary"
default={defaultAction}
preventPropagation
size="sm"
>
<DropdownItem
data-cy="session-view-menu-delete"
onClick={toggleDelete}
<>
<PermissionsGuard
disabled={null}
enabled={
<>
<ButtonWithMenuV2
color="outline-primary"
default={defaultAction}
preventPropagation
size="sm"
>
<Trash className={cx("bi", "me-1")} />
Delete
</DropdownItem>
</ButtonWithMenuV2>{" "}
<UpdateSessionLauncherModal
isOpen={isUpdateOpen}
launcher={launcher}
toggle={toggleUpdate}
/>
<DeleteSessionV2Modal
isOpen={isDeleteOpen}
launcher={launcher}
toggle={toggleDelete}
sessionsLength={sessionsLength}
/>
</>
}
requestedPermission="write"
userPermissions={permissions}
/>
<DropdownItem
data-cy="session-view-menu-delete"
onClick={toggleDelete}
>
<Trash className={cx("bi", "me-1")} />
Delete
</DropdownItem>
{rebuildAction}
</ButtonWithMenuV2>
<UpdateSessionLauncherModal
isOpen={isUpdateOpen}
launcher={launcher}
toggle={toggleUpdate}
/>
<DeleteSessionV2Modal
isOpen={isDeleteOpen}
launcher={launcher}
toggle={toggleDelete}
sessionsLength={sessionsLength}
/>
</>
}
requestedPermission="write"
userPermissions={permissions}
/>
<RebuildFailedModal error={result.error} reset={result.reset} />
</>
);
}

Expand Down Expand Up @@ -282,3 +327,33 @@ function OrphanSession({ session, project }: OrphanSessionProps) {
</>
);
}

interface RebuildFailedModalProps {
error: FetchBaseQueryError | SerializedError | undefined;
reset: () => void;
}

function RebuildFailedModal({ error, reset }: RebuildFailedModalProps) {
return (
<ScrollableModal
backdrop="static"
centered
isOpen={error != null}
size="lg"
toggle={reset}
>
<ModalHeader toggle={reset}>
Error: could not rebuild session image
</ModalHeader>
<ModalBody>
<RtkOrNotebooksError error={error} dismissible={false} />
</ModalBody>
<ModalFooter>
<Button color="outline-primary" onClick={reset}>
<XLg className={cx("bi", "me-1")} />
Close
</Button>
</ModalFooter>
</ScrollableModal>
);
}
Loading
Loading