diff --git a/api/server/handlers/deployment_target/list.go b/api/server/handlers/deployment_target/list.go new file mode 100644 index 0000000000..c920dd5e59 --- /dev/null +++ b/api/server/handlers/deployment_target/list.go @@ -0,0 +1,81 @@ +package deployment_target + +import ( + "net/http" + + "github.com/porter-dev/porter/api/server/handlers" + "github.com/porter-dev/porter/api/server/shared" + "github.com/porter-dev/porter/api/server/shared/apierrors" + "github.com/porter-dev/porter/api/server/shared/config" + "github.com/porter-dev/porter/api/types" + "github.com/porter-dev/porter/internal/models" + "github.com/porter-dev/porter/internal/telemetry" +) + +// ListDeploymentTargetsHandler is the handler for the /deployment-targets endpoint +type ListDeploymentTargetsHandler struct { + handlers.PorterHandlerReadWriter +} + +// NewListDeploymentTargetsHandler handles GET requests to the endpoint /deployment-targets +func NewListDeploymentTargetsHandler( + config *config.Config, + decoderValidator shared.RequestDecoderValidator, + writer shared.ResultWriter, +) *ListDeploymentTargetsHandler { + return &ListDeploymentTargetsHandler{ + PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer), + } +} + +// ListDeploymentTargetsRequest is the request object for the /deployment-targets GET endpoint +type ListDeploymentTargetsRequest struct { + Preview bool `json:"preview"` +} + +// ListDeploymentTargetsResponse is the response object for the /deployment-targets GET endpoint +type ListDeploymentTargetsResponse struct { + DeploymentTargets []types.DeploymentTarget `json:"deployment_targets"` +} + +func (c *ListDeploymentTargetsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx, span := telemetry.NewSpan(r.Context(), "serve-list-deployment-targets") + defer span.End() + + project, _ := ctx.Value(types.ProjectScope).(*models.Project) + cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster) + + if !project.GetFeatureFlag(models.ValidateApplyV2, c.Config().LaunchDarklyClient) { + err := telemetry.Error(ctx, span, nil, "project does not have validate apply v2 enabled") + c.HandleAPIError(w, r, apierrors.NewErrForbidden(err)) + return + } + + request := &ListDeploymentTargetsRequest{} + if ok := c.DecodeAndValidate(w, r, request); !ok { + err := telemetry.Error(ctx, span, nil, "error decoding request") + c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest)) + return + } + + deploymentTargets, err := c.Repo().DeploymentTarget().List(project.ID, cluster.ID, request.Preview) + if err != nil { + err := telemetry.Error(ctx, span, err, "error retrieving deployment targets") + c.HandleAPIError(w, r, apierrors.NewErrInternal(err)) + return + } + + response := ListDeploymentTargetsResponse{ + DeploymentTargets: make([]types.DeploymentTarget, 0), + } + + for _, dt := range deploymentTargets { + if dt == nil { + continue + } + + response.DeploymentTargets = append(response.DeploymentTargets, *dt.ToDeploymentTargetType()) + } + + c.WriteResult(w, r, response) +} diff --git a/api/server/handlers/porter_app/latest_app_revisions.go b/api/server/handlers/porter_app/latest_app_revisions.go index a6ddd22363..48050812c6 100644 --- a/api/server/handlers/porter_app/latest_app_revisions.go +++ b/api/server/handlers/porter_app/latest_app_revisions.go @@ -32,8 +32,10 @@ func NewLatestAppRevisionsHandler( } } -// LatestAppRevisionsRequest represents the response from the /apps/revisions endpoint -type LatestAppRevisionsRequest struct{} +// LatestAppRevisionsRequest represents the request for the /apps/revisions endpoint +type LatestAppRevisionsRequest struct { + DeploymentTargetID string `schema:"deployment_target_id"` +} // LatestRevisionWithSource is an app revision and its source porter app type LatestRevisionWithSource struct { @@ -53,22 +55,28 @@ func (c *LatestAppRevisionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Req project, _ := r.Context().Value(types.ProjectScope).(*models.Project) cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster) - // todo(ianedwards): once we have a way to select a deployment target, we can add it to the request - defaultDeploymentTarget, err := c.Repo().DeploymentTarget().DeploymentTargetBySelectorAndSelectorType(project.ID, cluster.ID, DeploymentTargetSelector_Default, DeploymentTargetSelectorType_Default) + request := &LatestAppRevisionsRequest{} + if ok := c.DecodeAndValidate(w, r, request); !ok { + err := telemetry.Error(ctx, span, nil, "error decoding request") + c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest)) + return + } + + deploymentTargetID, err := uuid.Parse(request.DeploymentTargetID) if err != nil { - err := telemetry.Error(ctx, span, err, "error getting default deployment target from repo") + err := telemetry.Error(ctx, span, err, "error parsing deployment target id") c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest)) return } - if defaultDeploymentTarget.ID == uuid.Nil { - err := telemetry.Error(ctx, span, err, "default deployment target not found") - c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError)) + if deploymentTargetID == uuid.Nil { + err := telemetry.Error(ctx, span, err, "deployment target id is nil") + c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest)) return } listAppRevisionsReq := connect.NewRequest(&porterv1.LatestAppRevisionsRequest{ ProjectId: int64(project.ID), - DeploymentTargetId: defaultDeploymentTarget.ID.String(), + DeploymentTargetId: deploymentTargetID.String(), }) latestAppRevisionsResp, err := c.Config().ClusterControlPlaneClient.LatestAppRevisions(ctx, listAppRevisionsReq) diff --git a/api/server/router/deployment_target.go b/api/server/router/deployment_target.go index bfc063ac57..75f343174a 100644 --- a/api/server/router/deployment_target.go +++ b/api/server/router/deployment_target.go @@ -85,5 +85,34 @@ func getDeploymentTargetRoutes( Router: r, }) + // GET /api/projects/{project_id}/clusters/{cluster_id}/deployment-targets -> deployment_target.ListDeploymentTargetsHandler + listDeploymentTargetsEndpoint := factory.NewAPIEndpoint( + &types.APIRequestMetadata{ + Verb: types.APIVerbList, + Method: types.HTTPVerbGet, + Path: &types.Path{ + Parent: basePath, + RelativePath: relPath, + }, + Scopes: []types.PermissionScope{ + types.UserScope, + types.ProjectScope, + types.ClusterScope, + }, + }, + ) + + listDeploymentTargetsHandler := deployment_target.NewListDeploymentTargetsHandler( + config, + factory.GetDecoderValidator(), + factory.GetResultWriter(), + ) + + routes = append(routes, &router.Route{ + Endpoint: listDeploymentTargetsEndpoint, + Handler: listDeploymentTargetsHandler, + Router: r, + }) + return routes, newPath } diff --git a/api/types/deployment_target.go b/api/types/deployment_target.go new file mode 100644 index 0000000000..b04e6046ab --- /dev/null +++ b/api/types/deployment_target.go @@ -0,0 +1,18 @@ +package types + +import ( + "time" + + "github.com/google/uuid" +) + +type DeploymentTarget struct { + ID uuid.UUID `json:"id"` + ProjectID uint `json:"project_id"` + ClusterID uint `json:"cluster_id"` + + Selector string `json:"selector"` + SelectorType string `json:"selector_type"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/dashboard/src/lib/revisions/types.ts b/dashboard/src/lib/revisions/types.ts index 239ce19afc..701f0bb00d 100644 --- a/dashboard/src/lib/revisions/types.ts +++ b/dashboard/src/lib/revisions/types.ts @@ -13,6 +13,7 @@ export const appRevisionValidator = z.object({ ]), b64_app_proto: z.string(), revision_number: z.number(), + deployment_target_id: z.string(), id: z.string(), created_at: z.string(), updated_at: z.string(), diff --git a/dashboard/src/main/home/Home.tsx b/dashboard/src/main/home/Home.tsx index 241c67aaa4..4fc0fa6abc 100644 --- a/dashboard/src/main/home/Home.tsx +++ b/dashboard/src/main/home/Home.tsx @@ -42,6 +42,8 @@ import ExpandedApp from "./app-dashboard/expanded-app/ExpandedApp"; import CreateApp from "./app-dashboard/create-app/CreateApp"; import AppView from "./app-dashboard/app-view/AppView"; import Apps from "./app-dashboard/apps/Apps"; +import DeploymentTargetProvider from "shared/DeploymentTargetContext"; +import PreviewEnvs from "./cluster-dashboard/preview-environments/v2/PreviewEnvs"; // Guarded components const GuardedProjectSettings = fakeGuardedRoute("settings", "", [ @@ -378,187 +380,211 @@ const Home: React.FC = (props) => { - - - {currentOverlay && - createPortal( - , - document.body + + + + {currentOverlay && + createPortal( + , + document.body + )} + {/* Render sidebar when there's at least one project */} + {projects?.length > 0 && baseRoute !== "new-project" ? ( + + ) : ( + + + Join Our Discord + )} - {/* Render sidebar when there's at least one project */} - {projects?.length > 0 && baseRoute !== "new-project" ? ( - - ) : ( - - - Join Our Discord - - )} - - - - - - {currentProject?.validate_apply_v2 ? ( - - ) : ( - - )} - - - {currentProject?.validate_apply_v2 ? ( - - ) : ( - - )} - - + + + + + + {currentProject?.validate_apply_v2 ? ( + + ) : ( + + )} + + + {currentProject?.validate_apply_v2 ? ( + + ) : ( + + )} + + + {currentProject?.validate_apply_v2 ? ( + + ) : ( + + )} + + + {currentProject?.validate_apply_v2 ? ( + + ) : ( + + )} + {currentProject?.validate_apply_v2 ? ( - - ) : ( - + <> + + + + + + + + + + + + + + ) : null} + + + + + + + { + return ; + }} + > + { + return ; + }} + /> + {(user?.isPorterUser || + overrideInfraTabEnabled({ + projectID: currentProject?.id, + })) && ( + { + return ( + + + + ); + }} + /> )} - - - {currentProject?.validate_apply_v2 ? : } - - - - - - - - { - return ; - }} - > - { - return ; - }} - /> - {(user?.isPorterUser || - overrideInfraTabEnabled({ - projectID: currentProject?.id, - })) && ( { return ( - + ); }} /> - )} - { - return ( - - - - ); - }} - /> - { - if (currentCluster?.id === -1) { - return ; - } else if (!currentCluster || !currentCluster.name) { + { + if (currentCluster?.id === -1) { + return ; + } else if (!currentCluster || !currentCluster.name) { + return ( + + + + ); + } return ( - + ); - } - return ( - - - - ); - }} - /> - } - /> - } - /> - } /> - - - {createPortal( - setCurrentModal(null, null)} - />, - document.body - )} - {showWrongEmailModal && ( - - - Oops! This invite link wasn't for {user?.email} - - - - Your account email does not match the email associated with this - project invite. Please log out and sign up again with the correct - email using the invite link. - - - - You should reach out to the person who sent you the invite link to - get the correct email. - - - - - )} - + }} + /> + } + /> + } + /> + } /> + + + {createPortal( + setCurrentModal(null, null)} + />, + document.body + )} + {showWrongEmailModal && ( + + + Oops! This invite link wasn't for {user?.email} + + + + Your account email does not match the email associated with this + project invite. Please log out and sign up again with the + correct email using the invite link. + + + + You should reach out to the person who sent you the invite link + to get the correct email. + + + + + )} + + ); }; diff --git a/dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx b/dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx index e32da35458..f92dbc1f95 100644 --- a/dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx +++ b/dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx @@ -66,12 +66,12 @@ const AppDataContainer: React.FC = ({ tabParam }) => { latestRevision, projectId, clusterId, - deploymentTargetId, + deploymentTarget, servicesFromYaml, setPreviewRevision, } = useLatestRevision(); const { validateApp } = useAppValidation({ - deploymentTargetID: deploymentTargetId, + deploymentTargetID: deploymentTarget.id, }); const currentTab = useMemo(() => { @@ -167,7 +167,7 @@ const AppDataContainer: React.FC = ({ tabParam }) => { const res = await api.updateEnvironmentGroupV2( "", { - deployment_target_id: deploymentTargetId, + deployment_target_id: deploymentTarget.id, variables, secrets, b64_app_proto: btoa(validatedAppProto.toJsonString()), @@ -203,7 +203,7 @@ const AppDataContainer: React.FC = ({ tabParam }) => { "", { b64_app_proto: btoa(protoWithUpdatedEnv.toJsonString()), - deployment_target_id: deploymentTargetId, + deployment_target_id: deploymentTarget.id, }, { project_id: projectId, @@ -241,11 +241,18 @@ const AppDataContainer: React.FC = ({ tabParam }) => { "getLatestRevision", projectId, clusterId, - deploymentTargetId, + deploymentTarget.id, porterApp.name, ]); setPreviewRevision(null); + if (deploymentTarget.preview) { + history.push( + `/preview-environments/apps/${porterApp.name}/${DEFAULT_TAB}?target=${deploymentTarget.id}` + ); + return; + } + // redirect to the default tab after save history.push(`/apps/${porterApp.name}/${DEFAULT_TAB}`); } catch (err) {} @@ -270,7 +277,7 @@ const AppDataContainer: React.FC = ({ tabParam }) => {
= ({ tabParam }) => { ]} currentTab={currentTab} setCurrentTab={(tab) => { + if (deploymentTarget.preview) { + history.push( + `/preview-environments/apps/${porterApp.name}/${tab}?target=${deploymentTarget.id}` + ); + return; + } history.push(`/apps/${porterApp.name}/${tab}`); }} /> @@ -334,7 +347,9 @@ const AppDataContainer: React.FC = ({ tabParam }) => { setRedeployOnSave={setRedeployOnSave} /> )) - .with("environment", () => ) + .with("environment", () => ( + + )) .with("settings", () => ) .with("logs", () => ) .with("metrics", () => ) diff --git a/dashboard/src/main/home/app-dashboard/app-view/LatestRevisionContext.tsx b/dashboard/src/main/home/app-dashboard/app-view/LatestRevisionContext.tsx index 49a1afa8fb..69cb470d0f 100644 --- a/dashboard/src/main/home/app-dashboard/app-view/LatestRevisionContext.tsx +++ b/dashboard/src/main/home/app-dashboard/app-view/LatestRevisionContext.tsx @@ -1,7 +1,6 @@ import React, { Dispatch, SetStateAction, useMemo, useState } from "react"; import { PorterApp } from "@porter-dev/api-contracts"; import { useQuery } from "@tanstack/react-query"; -import { useDefaultDeploymentTarget } from "lib/hooks/useDeploymentTarget"; import { createContext, useContext } from "react"; import { Context } from "shared/Context"; import api from "shared/api"; @@ -18,6 +17,10 @@ import styled from "styled-components"; import { SourceOptions } from "lib/porter-apps"; import { usePorterYaml } from "lib/hooks/usePorterYaml"; import { DetectedServices } from "lib/porter-apps/services"; +import { + DeploymentTarget, + useDeploymentTarget, +} from "shared/DeploymentTargetContext"; export const LatestRevisionContext = createContext<{ porterApp: PorterAppRecord; @@ -26,7 +29,7 @@ export const LatestRevisionContext = createContext<{ servicesFromYaml: DetectedServices | null; clusterId: number; projectId: number; - deploymentTargetId: string; + deploymentTarget: DeploymentTarget; previewRevision: AppRevision | null; setPreviewRevision: Dispatch>; } | null>(null); @@ -48,12 +51,17 @@ export const LatestRevisionProvider = ({ appName?: string; children: JSX.Element; }) => { - const [previewRevision, setPreviewRevision] = useState(null); + const [previewRevision, setPreviewRevision] = useState( + null + ); const { currentCluster, currentProject } = useContext(Context); - const deploymentTarget = useDefaultDeploymentTarget(); + const { currentDeploymentTarget } = useDeploymentTarget(); const appParamsExist = - !!appName && !!currentCluster && !!currentProject && !!deploymentTarget; + !!appName && + !!currentCluster && + !!currentProject && + !!currentDeploymentTarget; const { data: porterApp, status: porterAppStatus } = useQuery( ["getPorterApp", currentCluster?.id, currentProject?.id, appName], @@ -85,7 +93,7 @@ export const LatestRevisionProvider = ({ "getLatestRevision", currentProject?.id, currentCluster?.id, - deploymentTarget?.deployment_target_id, + currentDeploymentTarget, appName, ], async () => { @@ -95,7 +103,7 @@ export const LatestRevisionProvider = ({ const res = await api.getLatestRevision( "", { - deployment_target_id: deploymentTarget.deployment_target_id, + deployment_target_id: currentDeploymentTarget.id, }, { project_id: currentProject.id, @@ -195,7 +203,7 @@ export const LatestRevisionProvider = ({ porterApp, clusterId: currentCluster.id, projectId: currentProject.id, - deploymentTargetId: deploymentTarget.deployment_target_id, + deploymentTarget: currentDeploymentTarget, servicesFromYaml: detectedServices, previewRevision, setPreviewRevision, diff --git a/dashboard/src/main/home/app-dashboard/app-view/tabs/Activity.tsx b/dashboard/src/main/home/app-dashboard/app-view/tabs/Activity.tsx index 78341e4e31..e49d40b243 100644 --- a/dashboard/src/main/home/app-dashboard/app-view/tabs/Activity.tsx +++ b/dashboard/src/main/home/app-dashboard/app-view/tabs/Activity.tsx @@ -3,18 +3,23 @@ import { useLatestRevision } from "../LatestRevisionContext"; import ActivityFeed from "./activity-feed/ActivityFeed"; const Activity: React.FC = () => { - const { projectId, clusterId, latestProto, deploymentTargetId } = useLatestRevision(); + const { + projectId, + clusterId, + latestProto, + deploymentTarget, + } = useLatestRevision(); - return ( - <> - - - ); + return ( + <> + + + ); }; export default Activity; diff --git a/dashboard/src/main/home/app-dashboard/app-view/tabs/JobsTab.tsx b/dashboard/src/main/home/app-dashboard/app-view/tabs/JobsTab.tsx index 92b626dc35..74e7bf4c7a 100644 --- a/dashboard/src/main/home/app-dashboard/app-view/tabs/JobsTab.tsx +++ b/dashboard/src/main/home/app-dashboard/app-view/tabs/JobsTab.tsx @@ -3,7 +3,7 @@ import { useLatestRevision } from "../LatestRevisionContext"; import JobsSection from "../../validate-apply/jobs/JobsSection"; const JobsTab: React.FC = () => { - const { projectId, clusterId, latestProto, deploymentTargetId } = useLatestRevision(); + const { projectId, clusterId, latestProto, deploymentTarget } = useLatestRevision(); const appName = latestProto.name @@ -12,7 +12,7 @@ const JobsTab: React.FC = () => { latestProto.services[name].config.case === "jobConfig")} /> diff --git a/dashboard/src/main/home/app-dashboard/app-view/tabs/MetricsTab.tsx b/dashboard/src/main/home/app-dashboard/app-view/tabs/MetricsTab.tsx index 5d4eb331fa..978cb22aa2 100644 --- a/dashboard/src/main/home/app-dashboard/app-view/tabs/MetricsTab.tsx +++ b/dashboard/src/main/home/app-dashboard/app-view/tabs/MetricsTab.tsx @@ -3,7 +3,7 @@ import { useLatestRevision } from "../LatestRevisionContext"; import MetricsSection from "../../validate-apply/metrics/MetricsSection"; const MetricsTab: React.FC = () => { - const { projectId, clusterId, latestProto , deploymentTargetId} = useLatestRevision(); + const { projectId, clusterId, latestProto , deploymentTarget} = useLatestRevision(); const appName = latestProto.name @@ -14,7 +14,7 @@ const MetricsTab: React.FC = () => { clusterId={clusterId} appName={appName} services={latestProto.services} - deploymentTargetId={deploymentTargetId} + deploymentTargetId={deploymentTarget.id} /> ); diff --git a/dashboard/src/main/home/app-dashboard/app-view/tabs/Overview.tsx b/dashboard/src/main/home/app-dashboard/app-view/tabs/Overview.tsx index e6e48eb49c..dc86c47fb8 100644 --- a/dashboard/src/main/home/app-dashboard/app-view/tabs/Overview.tsx +++ b/dashboard/src/main/home/app-dashboard/app-view/tabs/Overview.tsx @@ -16,13 +16,13 @@ import { useAppStatus } from "lib/hooks/useAppStatus"; const Overview: React.FC = () => { const { formState } = useFormContext(); - const { porterApp, latestProto, latestRevision, projectId, clusterId, deploymentTargetId } = useLatestRevision(); + const { porterApp, latestProto, latestRevision, projectId, clusterId, deploymentTarget } = useLatestRevision(); const { serviceVersionStatus } = useAppStatus({ projectId, clusterId, serviceNames: Object.keys(latestProto.services), - deploymentTargetId, + deploymentTargetId: deploymentTarget.id, appName: latestProto.name, }); diff --git a/dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/focus-views/EventFocusView.tsx b/dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/focus-views/EventFocusView.tsx index 5c3df07663..892eefa697 100644 --- a/dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/focus-views/EventFocusView.tsx +++ b/dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/focus-views/EventFocusView.tsx @@ -21,7 +21,7 @@ const EventFocusView: React.FC = ({ }) => { const { search } = useLocation(); const queryParams = new URLSearchParams(search); const eventId = queryParams.get("event_id"); - const { projectId, clusterId, latestProto, deploymentTargetId, latestRevision } = useLatestRevision(); + const { projectId, clusterId, latestProto } = useLatestRevision(); const [event, setEvent] = useState(null); diff --git a/dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/focus-views/PredeployEventFocusView.tsx b/dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/focus-views/PredeployEventFocusView.tsx index 4b23473a80..06e1f3ea8c 100644 --- a/dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/focus-views/PredeployEventFocusView.tsx +++ b/dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/focus-views/PredeployEventFocusView.tsx @@ -19,7 +19,7 @@ type Props = { const PreDeployEventFocusView: React.FC = ({ event, }) => { - const { projectId, clusterId, latestProto, deploymentTargetId, latestRevision } = useLatestRevision(); + const { projectId, clusterId, latestProto, deploymentTarget } = useLatestRevision(); const appName = latestProto.name const serviceNames = [`${latestProto.name}-predeploy`] @@ -63,7 +63,7 @@ const PreDeployEventFocusView: React.FC = ({ clusterId={clusterId} appName={appName} serviceNames={serviceNames} - deploymentTargetId={deploymentTargetId} + deploymentTargetId={deploymentTarget.id} appRevisionId={event.metadata.app_revision_id} logFilterNames={["service_name"]} /> diff --git a/dashboard/src/main/home/app-dashboard/apps/AppGrid.tsx b/dashboard/src/main/home/app-dashboard/apps/AppGrid.tsx index 226ac435ab..25d813d70b 100644 --- a/dashboard/src/main/home/app-dashboard/apps/AppGrid.tsx +++ b/dashboard/src/main/home/app-dashboard/apps/AppGrid.tsx @@ -20,6 +20,7 @@ import { PorterApp } from "@porter-dev/api-contracts"; import Icon from "components/porter/Icon"; import Spacer from "components/porter/Spacer"; import { readableDate } from "shared/string_utils"; +import { useDeploymentTarget } from "shared/DeploymentTargetContext"; type AppGridProps = { apps: AppRevisionWithSource[]; @@ -37,6 +38,7 @@ const icons = [ ]; const AppGrid: React.FC = ({ apps, searchValue, view, sort }) => { + const { currentDeploymentTarget } = useDeploymentTarget(); const appsWithProto = useMemo(() => { return apps.map((app) => { return { @@ -138,7 +140,14 @@ const AppGrid: React.FC = ({ apps, searchValue, view, sort }) => { {(filteredApps ?? []).map( ({ app_revision: { proto, updated_at }, source }, i) => { return ( - + {renderIcon(proto.build?.buildpacks ?? [])} @@ -166,7 +175,14 @@ const AppGrid: React.FC = ({ apps, searchValue, view, sort }) => { {(filteredApps ?? []).map( ({ app_revision: { proto, updated_at }, source }, i) => { return ( - + diff --git a/dashboard/src/main/home/app-dashboard/apps/Apps.tsx b/dashboard/src/main/home/app-dashboard/apps/Apps.tsx index e46e0999eb..d09ed90ec8 100644 --- a/dashboard/src/main/home/app-dashboard/apps/Apps.tsx +++ b/dashboard/src/main/home/app-dashboard/apps/Apps.tsx @@ -27,12 +27,14 @@ import { appRevisionWithSourceValidator } from "./types"; import AppGrid from "./AppGrid"; import DashboardHeader from "main/home/cluster-dashboard/DashboardHeader"; import { z } from "zod"; +import { useDeploymentTarget } from "shared/DeploymentTargetContext"; type Props = {}; const Apps: React.FC = ({}) => { const { currentProject, currentCluster } = useContext(Context); const { updateAppStep } = useAppAnalytics(); + const { currentDeploymentTarget } = useDeploymentTarget(); const [searchValue, setSearchValue] = useState(""); const [view, setView] = useState<"grid" | "list">("grid"); @@ -41,21 +43,28 @@ const Apps: React.FC = ({}) => { const { data: apps = [], status } = useQuery( [ "getLatestAppRevisions", - { cluster_id: currentCluster?.id, project_id: currentProject?.id }, + { + cluster_id: currentCluster?.id, + project_id: currentProject?.id, + deployment_target_id: currentDeploymentTarget?.id, + }, ], async () => { if ( !currentCluster || !currentProject || currentCluster.id === -1 || - currentProject.id === -1 + currentProject.id === -1 || + !currentDeploymentTarget ) { return; } const res = await api.getLatestAppRevisions( "", - {}, + { + deployment_target_id: currentDeploymentTarget?.id, + }, { cluster_id: currentCluster.id, project_id: currentProject.id } ); @@ -79,28 +88,30 @@ const Apps: React.FC = ({}) => { } if (apps.length === 0) { -
- - No apps have been deployed yet. - - - Get started by deploying your app. - - - - - -
; + return ( +
+ + No apps have been deployed yet. + + + Get started by deploying your app. + + + + + +
+ ); } return ( diff --git a/dashboard/src/main/home/app-dashboard/validate-apply/jobs/JobRunDetails.tsx b/dashboard/src/main/home/app-dashboard/validate-apply/jobs/JobRunDetails.tsx index efd402e21f..d2cbc9da75 100644 --- a/dashboard/src/main/home/app-dashboard/validate-apply/jobs/JobRunDetails.tsx +++ b/dashboard/src/main/home/app-dashboard/validate-apply/jobs/JobRunDetails.tsx @@ -23,7 +23,7 @@ type Props = { const JobRunDetails: React.FC = ({ jobRun, }) => { - const { projectId, clusterId, latestProto, deploymentTargetId } = useLatestRevision(); + const { projectId, clusterId, latestProto, deploymentTarget } = useLatestRevision(); const appName = latestProto.name @@ -67,7 +67,7 @@ const JobRunDetails: React.FC = ({ clusterId={clusterId} appName={appName} serviceNames={[jobRun.jobName ?? "all"]} - deploymentTargetId={deploymentTargetId} + deploymentTargetId={deploymentTarget.id} appRevisionId={jobRun.metadata.labels["porter.run/app-revision-id"]} logFilterNames={["service_name"]} timeRange={{ diff --git a/dashboard/src/main/home/cluster-dashboard/preview-environments/v2/PreviewEnvGrid.tsx b/dashboard/src/main/home/cluster-dashboard/preview-environments/v2/PreviewEnvGrid.tsx new file mode 100644 index 0000000000..dc1b65f721 --- /dev/null +++ b/dashboard/src/main/home/cluster-dashboard/preview-environments/v2/PreviewEnvGrid.tsx @@ -0,0 +1,193 @@ +import React, { useMemo } from "react"; +import { RawDeploymentTarget } from "./PreviewEnvs"; +import { match } from "ts-pattern"; +import _ from "lodash"; +import styled from "styled-components"; +import { Link } from "react-router-dom"; + +import time from "assets/time.png"; +import healthy from "assets/status-healthy.png"; +import notFound from "assets/not-found.png"; +import pull_request from "assets/pull_request_icon.svg"; + +import { search } from "shared/search"; +import Fieldset from "components/porter/Fieldset"; +import Container from "components/porter/Container"; +import Icon from "components/porter/Icon"; +import Spacer from "components/porter/Spacer"; +import Text from "components/porter/Text"; +import { readableDate } from "shared/string_utils"; + +type PreviewEnvGridProps = { + deploymentTargets: RawDeploymentTarget[]; + searchValue: string; + view: "grid" | "list"; + sort: "letter" | "calendar"; +}; + +const PreviewEnvGrid: React.FC = ({ + deploymentTargets, + searchValue, + view, + sort, +}) => { + const filteredEnvs = useMemo(() => { + const filteredBySearch = search(deploymentTargets ?? [], searchValue, { + keys: ["selector"], + isCaseSensitive: false, + }); + + return match(sort) + .with("calendar", () => + _.sortBy(filteredBySearch, ["created_at"]).reverse() + ) + .with("letter", () => _.sortBy(filteredBySearch, ["selector"])) + .exhaustive(); + }, [deploymentTargets, searchValue, sort]); + + if (filteredEnvs.length === 0) { + return ( +
+ + + No matching environments were found. + +
+ ); + } + + return match(view) + .with("grid", () => ( + + {(filteredEnvs ?? []).map((env) => { + return ( + + + + + + {env.selector} + + + + + + + {readableDate(env.created_at)} + + + + + ); + })} + + )) + .with("list", () => ( + + {(filteredEnvs ?? []).map((env) => { + return ( + + + + + + + {env.selector} + + + + + + + + + {readableDate(env.created_at)} + + + + + ); + })} + + )) + .exhaustive(); +}; + +export default PreviewEnvGrid; + +const PlaceholderIcon = styled.img` + height: 13px; + margin-right: 12px; + opacity: 0.65; +`; + +const GridList = styled.div` + display: grid; + grid-column-gap: 25px; + grid-row-gap: 25px; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); +`; + +const Block = styled.div` + height: 150px; + flex-direction: column; + display: flex; + justify-content: space-between; + cursor: pointer; + padding: 20px; + color: ${(props) => props.theme.text.primary}; + position: relative; + border-radius: 5px; + background: ${(props) => props.theme.clickable.bg}; + border: 1px solid #494b4f; + :hover { + border: 1px solid #7a7b80; + } + animation: fadeIn 0.3s 0s; + @keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } + } +`; + +const StatusIcon = styled.img` + position: absolute; + top: 20px; + right: 20px; + height: 18px; +`; + +const List = styled.div` + overflow: hidden; +`; + +const Row = styled.div<{ isAtBottom?: boolean }>` + cursor: pointer; + padding: 15px; + border-bottom: ${(props) => + props.isAtBottom ? "none" : "1px solid #494b4f"}; + background: ${(props) => props.theme.clickable.bg}; + position: relative; + border: 1px solid #494b4f; + border-radius: 5px; + margin-bottom: 15px; + animation: fadeIn 0.3s 0s; +`; + +const SmallIcon = styled.img<{ opacity?: string; height?: string }>` + margin-left: 2px; + height: ${(props) => props.height || "14px"}; + opacity: ${(props) => props.opacity || 1}; + filter: grayscale(100%); + margin-right: 10px; +`; diff --git a/dashboard/src/main/home/cluster-dashboard/preview-environments/v2/PreviewEnvs.tsx b/dashboard/src/main/home/cluster-dashboard/preview-environments/v2/PreviewEnvs.tsx new file mode 100644 index 0000000000..b3406b3dbb --- /dev/null +++ b/dashboard/src/main/home/cluster-dashboard/preview-environments/v2/PreviewEnvs.tsx @@ -0,0 +1,229 @@ +import { useQuery } from "@tanstack/react-query"; +import Loading from "components/Loading"; +import Container from "components/porter/Container"; +import Link from "components/porter/Link"; +import Spacer from "components/porter/Spacer"; +import Text from "components/porter/Text"; +import React, { useContext, useState } from "react"; +import { Context } from "shared/Context"; +import api from "shared/api"; +import styled from "styled-components"; +import { z } from "zod"; + +import PullRequestIcon from "assets/pull_request_icon.svg"; +import grid from "assets/grid.png"; +import list from "assets/list.png"; +import letter from "assets/vector.svg"; +import calendar from "assets/calendar-number.svg"; + +import PorterLink from "components/porter/Link"; +import SearchBar from "components/porter/SearchBar"; +import Toggle from "components/porter/Toggle"; +import DashboardHeader from "../../DashboardHeader"; +import Fieldset from "components/porter/Fieldset"; +import Button from "components/porter/Button"; +import PreviewEnvGrid from "./PreviewEnvGrid"; + +const rawDeploymentTargetValidator = z.object({ + id: z.string(), + project_id: z.number(), + cluster_id: z.number(), + selector: z.string(), + selector_type: z.string(), + created_at: z.string(), + updated_at: z.string(), +}); +export type RawDeploymentTarget = z.infer; + +const PreviewEnvs: React.FC = () => { + const { currentProject, currentCluster } = useContext(Context); + + const [searchValue, setSearchValue] = useState(""); + const [view, setView] = useState<"grid" | "list">("grid"); + const [sort, setSort] = useState<"calendar" | "letter">("calendar"); + + const { data: deploymentTargets = [], status } = useQuery( + ["listDeploymentTargets", currentProject?.id, currentCluster?.id], + async () => { + if (!currentProject || !currentCluster) { + return; + } + + const res = await api.listDeploymentTargets( + "", + { + preview: true, + }, + { + project_id: currentProject?.id, + cluster_id: currentCluster?.id, + } + ); + + const deploymentTargets = await z + .object({ + deployment_targets: z.array(rawDeploymentTargetValidator), + }) + .parseAsync(res.data); + + return deploymentTargets.deployment_targets; + }, + { + enabled: !!currentProject && !!currentCluster, + } + ); + + const renderContents = () => { + if (status === "loading") { + return ; + } + + if (!deploymentTargets || deploymentTargets.length === 0) { +
+ + No preview environments have been deployed yet. + + + + Get started by enabling preview envs for your apps. + + + +
; + } + + return ( + <> + + { + setSearchValue(x); + }} + placeholder="Search environments . . ." + width="100%" + /> + + , value: "calendar" }, + { label: , value: "letter" }, + ]} + active={sort} + setActive={(x) => { + if (x === "calendar") { + setSort("calendar"); + } else { + setSort("letter"); + } + }} + /> + + + , value: "grid" }, + { label: , value: "list" }, + ]} + active={view} + setActive={(x) => { + if (x === "grid") { + setView("grid"); + } else { + setView("list"); + } + }} + /> + + + + + ); + }; + + return ( + + + {renderContents()} + + + ); +}; + +export default PreviewEnvs; + +const StyledAppDashboard = styled.div` + width: 100%; + height: 100%; +`; + +const CentralContainer = styled.div` + display: flex; + flex-direction: column; + justify-content: left; + align-items: left; +`; + +const ToggleIcon = styled.img` + height: 12px; + margin: 0 5px; + min-width: 12px; +`; + +const GridList = styled.div` + display: grid; + grid-column-gap: 25px; + grid-row-gap: 25px; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); +`; + +const Block = styled.div` + height: 150px; + flex-direction: column; + display: flex; + justify-content: space-between; + cursor: pointer; + padding: 20px; + color: ${(props) => props.theme.text.primary}; + position: relative; + border-radius: 5px; + background: ${(props) => props.theme.clickable.bg}; + border: 1px solid #494b4f; + :hover { + border: 1px solid #7a7b80; + } + animation: fadeIn 0.3s 0s; + @keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } + } +`; + +const StatusIcon = styled.img` + position: absolute; + top: 20px; + right: 20px; + height: 18px; +`; + +const SmallIcon = styled.img<{ opacity?: string; height?: string }>` + margin-left: 2px; + height: ${(props) => props.height || "14px"}; + opacity: ${(props) => props.opacity || 1}; + filter: grayscale(100%); + margin-right: 10px; +`; diff --git a/dashboard/src/shared/DeploymentTargetContext.tsx b/dashboard/src/shared/DeploymentTargetContext.tsx new file mode 100644 index 0000000000..868578e0ac --- /dev/null +++ b/dashboard/src/shared/DeploymentTargetContext.tsx @@ -0,0 +1,71 @@ +import { useDefaultDeploymentTarget } from "lib/hooks/useDeploymentTarget"; +import React, { + Dispatch, + SetStateAction, + createContext, + useContext, + useMemo, + useState, +} from "react"; +import { useLocation } from "react-router"; + +export type DeploymentTarget = { + id: string; + preview: boolean; +}; + +export const DeploymentTargetContext = createContext<{ + currentDeploymentTarget: DeploymentTarget | null; +} | null>(null); + +export const useDeploymentTarget = () => { + const context = useContext(DeploymentTargetContext); + if (context === null) { + throw new Error( + "useDeploymentTarget must be used within a DeploymentTargetContext" + ); + } + return context; +}; + +const DeploymentTargetProvider = ({ children }: { children: JSX.Element }) => { + const { search } = useLocation(); + const queryParams = new URLSearchParams(search); + + const idParam = queryParams.get("target"); + const defaultDeploymentTarget = useDefaultDeploymentTarget(); + + const deploymentTarget: DeploymentTarget | null = useMemo(() => { + if (!idParam && !defaultDeploymentTarget) { + return null; + } + + if (idParam) { + return { + id: idParam, + preview: true, + }; + } + + if (defaultDeploymentTarget) { + return { + id: defaultDeploymentTarget.deployment_target_id, + preview: false, + }; + } + + return null; + }, [idParam, defaultDeploymentTarget]); + + return ( + + {children} + + ); +}; + +export default DeploymentTargetProvider; diff --git a/dashboard/src/shared/api.tsx b/dashboard/src/shared/api.tsx index 43fb85d6c3..93f02f57cc 100644 --- a/dashboard/src/shared/api.tsx +++ b/dashboard/src/shared/api.tsx @@ -322,7 +322,7 @@ const appPodStatus = baseApi< deployment_target_id: string; service: string; }, - { project_id: number; cluster_id: number, app_name: string } + { project_id: number; cluster_id: number; app_name: string } >("GET", ({ project_id, cluster_id, app_name }) => { return `/api/projects/${project_id}/clusters/${cluster_id}/apps/${app_name}/pods`; }); @@ -996,7 +996,9 @@ const listAppRevisions = baseApi< }); const getLatestAppRevisions = baseApi< - {}, + { + deployment_target_id: string; + }, { project_id: number; cluster_id: number; @@ -1005,6 +1007,18 @@ const getLatestAppRevisions = baseApi< return `/api/projects/${project_id}/clusters/${cluster_id}/apps/revisions`; }); +const listDeploymentTargets = baseApi< + { + preview: boolean; + }, + { + project_id: number; + cluster_id: number; + } +>("GET", ({ project_id, cluster_id }) => { + return `/api/projects/${project_id}/clusters/${cluster_id}/deployment-targets`; +}); + const getGitlabProcfileContents = baseApi< { path: string; @@ -3129,6 +3143,7 @@ export default { getRevision, listAppRevisions, getLatestAppRevisions, + listDeploymentTargets, getGitlabProcfileContents, getProjectClusters, getProjectRegistries, diff --git a/internal/models/deployment_target.go b/internal/models/deployment_target.go index 67270769d4..a0125e8df1 100644 --- a/internal/models/deployment_target.go +++ b/internal/models/deployment_target.go @@ -2,6 +2,7 @@ package models import ( "github.com/google/uuid" + "github.com/porter-dev/porter/api/types" "gorm.io/gorm" ) @@ -35,3 +36,16 @@ type DeploymentTarget struct { // Preview is a boolean indicating whether this target is a preview target. Preview bool `gorm:"default:false" json:"preview"` } + +// ToDeploymentTargetType generates an external types.PorterApp to be shared over REST +func (d *DeploymentTarget) ToDeploymentTargetType() *types.DeploymentTarget { + return &types.DeploymentTarget{ + ID: d.ID, + ProjectID: uint(d.ProjectID), + ClusterID: uint(d.ClusterID), + Selector: d.Selector, + SelectorType: string(d.SelectorType), + CreatedAt: d.CreatedAt, + UpdatedAt: d.UpdatedAt, + } +} diff --git a/internal/repository/deployment_target.go b/internal/repository/deployment_target.go index e32c07d238..624fef5d67 100644 --- a/internal/repository/deployment_target.go +++ b/internal/repository/deployment_target.go @@ -9,7 +9,7 @@ type DeploymentTargetRepository interface { // DeploymentTargetBySelectorAndSelectorType finds a deployment target for a projectID and clusterID by its selector and selector type DeploymentTargetBySelectorAndSelectorType(projectID uint, clusterID uint, selector, selectorType string) (*models.DeploymentTarget, error) // List returns all deployment targets for a project - List(projectID uint) ([]*models.DeploymentTarget, error) + List(projectID uint, clusterID uint, preview bool) ([]*models.DeploymentTarget, error) // CreateDeploymentTarget creates a new deployment target CreateDeploymentTarget(deploymentTarget *models.DeploymentTarget) (*models.DeploymentTarget, error) } diff --git a/internal/repository/gorm/deployment_target.go b/internal/repository/gorm/deployment_target.go index b57d47b386..5db297bbc7 100644 --- a/internal/repository/gorm/deployment_target.go +++ b/internal/repository/gorm/deployment_target.go @@ -29,18 +29,13 @@ func (repo *DeploymentTargetRepository) DeploymentTargetBySelectorAndSelectorTyp return nil, err } - if deploymentTarget.ID == uuid.Nil { - return nil, errors.New("deployment target not found") - } - return deploymentTarget, nil } // List finds all deployment targets for a given project -func (repo *DeploymentTargetRepository) List(projectID uint) ([]*models.DeploymentTarget, error) { +func (repo *DeploymentTargetRepository) List(projectID uint, clusterID uint, preview bool) ([]*models.DeploymentTarget, error) { deploymentTargets := []*models.DeploymentTarget{} - - if err := repo.db.Where("project_id = ?", projectID).Find(&deploymentTargets).Error; err != nil { + if err := repo.db.Where("project_id = ? AND cluster_id = ? AND preview = ?", projectID, clusterID, preview).Find(&deploymentTargets).Error; err != nil { return nil, err } diff --git a/internal/repository/test/deployment_target.go b/internal/repository/test/deployment_target.go index 084776fecd..803a6806ce 100644 --- a/internal/repository/test/deployment_target.go +++ b/internal/repository/test/deployment_target.go @@ -23,7 +23,7 @@ func (repo *DeploymentTargetRepository) DeploymentTargetBySelectorAndSelectorTyp } // List returns all deployment targets for a project -func (repo *DeploymentTargetRepository) List(projectID uint) ([]*models.DeploymentTarget, error) { +func (repo *DeploymentTargetRepository) List(projectID uint, clusterID uint, preview bool) ([]*models.DeploymentTarget, error) { return nil, errors.New("cannot read database") }