diff --git a/dashboard/src/lib/hooks/usePorterYaml.ts b/dashboard/src/lib/hooks/usePorterYaml.ts index cbb2d67480..70034d4f79 100644 --- a/dashboard/src/lib/hooks/usePorterYaml.ts +++ b/dashboard/src/lib/hooks/usePorterYaml.ts @@ -9,17 +9,17 @@ import { z } from "zod"; type PorterYamlStatus = | { - loading: true; - detectedName: null; - detectedServices: null; - porterYamlFound: false; - } + loading: true; + detectedName: null; + detectedServices: null; + porterYamlFound: false; + } | { - detectedServices: DetectedServices | null; - detectedName: string | null; - loading: false; - porterYamlFound: boolean; - }; + detectedServices: DetectedServices | null; + detectedName: string | null; + loading: false; + porterYamlFound: boolean; + }; /* * @@ -114,6 +114,15 @@ export const usePorterYaml = ({ const data = await z .object({ b64_app_proto: z.string(), + env_variables: z.record(z.string()).nullable(), + env_secrets: z.record(z.string()).nullable(), + preview_app: z + .object({ + b64_app_proto: z.string(), + env_variables: z.record(z.string()).nullable(), + env_secrets: z.record(z.string()).nullable(), + }) + .optional(), }) .parseAsync(res.data); const proto = PorterApp.fromJsonString(atob(data.b64_app_proto)); @@ -131,6 +140,33 @@ export const usePorterYaml = ({ }); } + if (data.preview_app) { + const previewProto = PorterApp.fromJsonString( + atob(data.preview_app.b64_app_proto) + ); + const { + services: previewServices, + predeploy: previewPredeploy, + build: previewBuild, + } = serviceOverrides({ + overrides: previewProto, + useDefaults, + }); + + if (previewServices.length || previewPredeploy || previewBuild) { + setDetectedServices((prev) => ({ + ...prev, + services: prev?.services ? prev.services : [], + previews: { + services: previewServices, + predeploy: previewPredeploy, + build: previewBuild, + variables: data.preview_app?.env_variables ?? {}, + }, + })); + } + } + if (proto.name) { setDetectedName(proto.name); } diff --git a/dashboard/src/lib/porter-apps/index.ts b/dashboard/src/lib/porter-apps/index.ts index af2f45e327..6bb79cef5a 100644 --- a/dashboard/src/lib/porter-apps/index.ts +++ b/dashboard/src/lib/porter-apps/index.ts @@ -394,3 +394,84 @@ export function clientAppFromProto({ }, }; } + +export function applyPreviewOverrides({ + app, + overrides, +}: { + app: ClientPorterApp; + overrides: DetectedServices["previews"]; +}): ClientPorterApp { + if (!overrides) { + return app; + } + + const services = app.services.map((svc) => { + const override = overrides.services.find( + (s) => s.name.value === svc.name.value + ); + if (override) { + const ds = deserializeService({ + service: serializeService(svc), + override: serializeService(override), + }); + + if (ds.config.type == "web") { + ds.config.domains = []; + } + return ds; + } + + if (svc.config.type == "web") { + svc.config.domains = []; + } + return svc; + }); + const additionalServices = overrides.services + .filter((s) => !app.services.find((svc) => svc.name.value === s.name.value)) + .map((svc) => deserializeService({ service: serializeService(svc) })); + + app.services = [...services, ...additionalServices]; + + if (app.predeploy) { + const predeployOverride = overrides.predeploy; + if (predeployOverride) { + app.predeploy = [ + deserializeService({ + service: serializeService(app.predeploy[0]), + override: serializeService(predeployOverride), + }), + ]; + } + } + + const envOverrides = overrides.variables; + if (envOverrides) { + const env = app.env.map((e) => { + const override = envOverrides[e.key]; + if (override) { + return { + ...e, + locked: true, + value: override, + }; + } + + return e; + }); + + const additionalEnv = Object.entries(envOverrides) + .filter(([key]) => !app.env.find((e) => e.key === key)) + .map(([key, value]) => ({ + key, + value, + hidden: false, + locked: true, + deleted: false, + })); + + app.env = [...env, ...additionalEnv]; + } + + return app; +} diff --git a/dashboard/src/lib/porter-apps/services.ts b/dashboard/src/lib/porter-apps/services.ts index 315ef99a15..661f2023eb 100644 --- a/dashboard/src/lib/porter-apps/services.ts +++ b/dashboard/src/lib/porter-apps/services.ts @@ -22,9 +22,43 @@ export type DetectedServices = { services: ClientService[]; predeploy?: ClientService; build?: BuildOptions; + previews?: { + services: ClientService[]; + predeploy?: ClientService; + variables?: Record; + }; }; type ClientServiceType = "web" | "worker" | "job" | "predeploy"; +const webConfigValidator = z.object({ + type: z.literal("web"), + autoscaling: autoscalingValidator.optional(), + domains: domainsValidator, + healthCheck: healthcheckValidator.optional(), + private: serviceBooleanValidator.optional(), +}); +export type ClientWebConfig = z.infer; + +const workerConfigValidator = z.object({ + type: z.literal("worker"), + autoscaling: autoscalingValidator.optional(), +}); +export type ClientWorkerConfig = z.infer; + +const jobConfigValidator = z.object({ + type: z.literal("job"), + allowConcurrent: serviceBooleanValidator.optional(), + cron: serviceStringValidator, + suspendCron: serviceBooleanValidator.optional(), + timeoutSeconds: serviceNumberValidator, +}); +export type ClientJobConfig = z.infer; + +const predeployConfigValidator = z.object({ + type: z.literal("predeploy"), +}); +export type ClientPredeployConfig = z.infer; + // serviceValidator is the validator for a ClientService // This is used to validate a service when creating or updating an app export const serviceValidator = z.object({ @@ -37,27 +71,10 @@ export const serviceValidator = z.object({ cpuCores: serviceNumberValidator, ramMegabytes: serviceNumberValidator, config: z.discriminatedUnion("type", [ - z.object({ - type: z.literal("web"), - autoscaling: autoscalingValidator.optional(), - domains: domainsValidator, - healthCheck: healthcheckValidator.optional(), - private: serviceBooleanValidator.optional(), - }), - z.object({ - type: z.literal("worker"), - autoscaling: autoscalingValidator.optional(), - }), - z.object({ - type: z.literal("job"), - allowConcurrent: serviceBooleanValidator.optional(), - cron: serviceStringValidator, - suspendCron: serviceBooleanValidator.optional(), - timeoutSeconds: serviceNumberValidator, - }), - z.object({ - type: z.literal("predeploy"), - }), + webConfigValidator, + workerConfigValidator, + jobConfigValidator, + predeployConfigValidator, ]), domainDeletions: z .object({ diff --git a/dashboard/src/main/home/Home.tsx b/dashboard/src/main/home/Home.tsx index 13a2fbaa88..f931b570c2 100644 --- a/dashboard/src/main/home/Home.tsx +++ b/dashboard/src/main/home/Home.tsx @@ -44,6 +44,7 @@ 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"; +import SetupApp from "./cluster-dashboard/preview-environments/v2/setup-app/SetupApp"; // Guarded components const GuardedProjectSettings = fakeGuardedRoute("settings", "", [ @@ -451,23 +452,7 @@ const Home: React.FC = (props) => { )} - {currentProject?.validate_apply_v2 && - currentProject.preview_envs_enabled ? ( - <> - - - - - - - - - - - - - - ) : null} + @@ -556,6 +541,29 @@ const Home: React.FC = (props) => { path={"/project-settings"} render={() => } /> + {currentProject?.validate_apply_v2 && + currentProject.preview_envs_enabled ? ( + <> + + + + + + + + + + + + + + + + + ) : null} } /> diff --git a/dashboard/src/main/home/app-dashboard/app-view/tabs/Settings.tsx b/dashboard/src/main/home/app-dashboard/app-view/tabs/Settings.tsx index 927943e11f..6f21a8eb2a 100644 --- a/dashboard/src/main/home/app-dashboard/app-view/tabs/Settings.tsx +++ b/dashboard/src/main/home/app-dashboard/app-view/tabs/Settings.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from "react"; +import React, { useCallback, useContext, useEffect, useState } from "react"; import styled from "styled-components"; import { useHistory } from "react-router"; @@ -11,8 +11,11 @@ import { useLatestRevision } from "../LatestRevisionContext"; import api from "shared/api"; import { useAppAnalytics } from "lib/hooks/useAppAnalytics"; import { useQueryClient } from "@tanstack/react-query"; +import { Link } from "react-router-dom"; +import { Context } from "shared/Context"; const Settings: React.FC = () => { + const { currentProject } = useContext(Context); const queryClient = useQueryClient(); const history = useHistory(); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); @@ -20,7 +23,9 @@ const Settings: React.FC = () => { const { updateAppStep } = useAppAnalytics(); const [isDeleting, setIsDeleting] = useState(false); - const [githubWorkflowFilename, setGithubWorkflowFilename] = useState(`porter_stack_${porterApp.name}.yml`); + const [githubWorkflowFilename, setGithubWorkflowFilename] = useState( + `porter_stack_${porterApp.name}.yml` + ); const workflowFileExists = useCallback(async () => { try { @@ -109,12 +114,20 @@ const Settings: React.FC = () => { window.open(res.data.url, "_blank", "noreferrer"); } - updateAppStep({ step: "stack-deletion", deleteWorkflow: true, appName: porterApp.name }); + updateAppStep({ + step: "stack-deletion", + deleteWorkflow: true, + appName: porterApp.name, + }); history.push("/apps"); return; } - updateAppStep({ step: "stack-deletion", deleteWorkflow: false, appName: porterApp.name }); + updateAppStep({ + step: "stack-deletion", + deleteWorkflow: false, + appName: porterApp.name, + }); history.push("/apps"); } catch (err) { } finally { @@ -126,12 +139,38 @@ const Settings: React.FC = () => { return ( + {currentProject?.preview_envs_enabled && ( + <> + + Enable preview environments for "{porterApp.name}" + + + + Setup your application to automatically create preview environments + for each pull request. + + + + + + + + )} Delete "{porterApp.name}" - + Delete this application and all of its resources. - + , + ].filter((x) => x)} + /> + + + ); +}; + +export default AppTemplateForm; diff --git a/dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/SetupApp.tsx b/dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/SetupApp.tsx new file mode 100644 index 0000000000..05bff27536 --- /dev/null +++ b/dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/SetupApp.tsx @@ -0,0 +1,92 @@ +import React, { useContext, useMemo } from "react"; +import { RouteComponentProps, withRouter } from "react-router"; +import styled from "styled-components"; + +import pull_request from "assets/pull_request_icon.svg"; + +import Back from "components/porter/Back"; +import DashboardHeader from "main/home/cluster-dashboard/DashboardHeader"; +import Spacer from "components/porter/Spacer"; +import AppTemplateForm from "./AppTemplateForm"; +import { LatestRevisionProvider } from "main/home/app-dashboard/app-view/LatestRevisionContext"; + +type Props = RouteComponentProps & {}; + +const SetupApp: React.FC = ({ location }) => { + const params = useMemo(() => { + const queryParams = new URLSearchParams(location.search); + const appName = queryParams.get("app_name"); + + return { + appName, + }; + }, [location.search]); + + const appName = params.appName; + + if (!appName) { + return null; + } + + return ( + + +
+ + + } + title={`Preview environments for ${appName}`} + description="Set preview environment specific configuration for this application below. Any newly created preview environments will use these settings." + capitalize={false} + disableLineBreak + /> + + + + +
+
+
+ ); +}; + +export default withRouter(SetupApp); + +const Div = styled.div` + width: 100%; + max-width: 900px; +`; + +const CenterWrapper = styled.div` + width: 100%; + display: flex; + flex-direction: column; + align-items: center; +`; + +const DarkMatter = styled.div` + width: 100%; + margin-top: -5px; +`; + +const StyledConfigureTemplate = styled.div` + height: 100%; +`; + +const Icon = styled.img` + margin-right: 15px; + height: 28px; + animation: floatIn 0.5s; + animation-fill-mode: forwards; + @keyframes floatIn { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0px); + } + } +`; diff --git a/dashboard/src/main/home/sidebar/Clusters.tsx b/dashboard/src/main/home/sidebar/Clusters.tsx index a7b88ad7ce..90f7291dac 100644 --- a/dashboard/src/main/home/sidebar/Clusters.tsx +++ b/dashboard/src/main/home/sidebar/Clusters.tsx @@ -147,9 +147,9 @@ class Clusters extends Component { let { clusters } = this.state; let { currentCluster, setCurrentCluster, currentProject } = this.context; - if (currentProject?.simplified_view_enabled ) { + if (currentProject?.simplified_view_enabled) { const cluster = clusters[0]; - return currentProject?.preview_envs_enabled && currentCluster?.preview_envs_enabled ? ( + return currentProject?.preview_envs_enabled ? (