Ver código fonte

Merge branch '0.8.0-source-settings' of https://github.com/porter-dev/porter into 0.8.0-source-settings

jusrhee 4 anos atrás
pai
commit
a19602ec88
54 arquivos alterados com 4190 adições e 583 exclusões
  1. 3 3
      .air.toml
  2. 3 0
      .gitignore
  3. 1 27
      CONTRIBUTING.md
  4. 14 0
      Makefile
  5. 1 1
      cli/cmd/api/domain.go
  6. 10 9
      cli/cmd/api/github_action.go
  7. 4 0
      dashboard/babel.config.json
  8. 2360 45
      dashboard/package-lock.json
  9. 25 12
      dashboard/package.json
  10. 38 50
      dashboard/src/components/SaveButton.tsx
  11. 0 3
      dashboard/src/components/porter-form/field-components/KeyValueArray.tsx
  12. 0 10
      dashboard/src/components/repo-selector/ActionDetails.tsx
  13. 3 0
      dashboard/src/index.tsx
  14. 100 3
      dashboard/src/main/home/cluster-dashboard/chart/Chart.tsx
  15. 1 0
      dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx
  16. 3 3
      dashboard/src/main/home/cluster-dashboard/dashboard/ClusterSettings.tsx
  17. 13 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  18. 0 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx
  19. 170 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/NotificationSettingsSection.tsx
  20. 19 21
      dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx
  21. 0 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/TempJobList.tsx
  22. 169 242
      dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx
  23. 15 8
      dashboard/src/main/home/launch/launch-flow/SettingsPage.tsx
  24. 21 5
      dashboard/src/main/home/launch/launch-flow/SourcePage.tsx
  25. 184 0
      dashboard/src/main/home/launch/launch-flow/WorkflowPage.tsx
  26. 63 0
      dashboard/src/main/home/provisioner/AWSFormSection.tsx
  27. 52 0
      dashboard/src/main/home/provisioner/DOFormSection.tsx
  28. 53 2
      dashboard/src/main/home/provisioner/GCPFormSection.tsx
  29. 29 1
      dashboard/src/main/home/provisioner/ProvisionerSettings.tsx
  30. 55 23
      dashboard/src/shared/api.tsx
  31. 7 0
      dashboard/src/shared/types.tsx
  32. 95 7
      dashboard/webpack.config.js
  33. 10 3
      docs/deploy/applications/deploying-from-git-repo.md
  34. 53 26
      docs/developing/setup.md
  35. 14 10
      internal/forms/git_action.go
  36. 1 1
      internal/forms/release.go
  37. 22 10
      internal/integrations/ci/actions/actions.go
  38. 16 1
      internal/integrations/slack/notifier.go
  39. 26 0
      internal/models/notification.go
  40. 2 1
      internal/models/release.go
  41. 1 0
      internal/repository/gorm/migrate.go
  42. 44 0
      internal/repository/gorm/notification.go
  43. 1 0
      internal/repository/gorm/repository.go
  44. 11 0
      internal/repository/notification.go
  45. 1 0
      internal/repository/repository.go
  46. 56 0
      scripts/dev-environment/CreateDefaultEnvFiles.sh
  47. 38 0
      scripts/dev-environment/SetupEnvironment.sh
  48. 33 0
      scripts/dev-environment/StartDevServer.sh
  49. 13 2
      server/api/deploy_handler.go
  50. 86 34
      server/api/git_action_handler.go
  51. 139 0
      server/api/notifications_handler.go
  52. 23 0
      server/api/oauth_slack_handler.go
  53. 43 15
      server/api/release_handler.go
  54. 46 1
      server/router/router.go

+ 3 - 3
.air.toml

@@ -7,11 +7,11 @@ tmp_dir = "tmp"
 
 
 [build]
 [build]
 # Just plain old shell command. You could use `make` as well.
 # Just plain old shell command. You could use `make` as well.
-cmd = "go build -o ./tmp/ready ./cmd/ready; go build -o ./tmp/migrate ./cmd/migrate; go build -o ./tmp/app ./cmd/app"
+cmd = "go build -o ./tmp/app ./cmd/app"
 # Binary file yields from `cmd`.
 # Binary file yields from `cmd`.
-bin = "tmp/migrate; tmp/app"
+bin = "tmp/app"
 # Customize binary.
 # Customize binary.
-full_bin = "tmp/migrate; tmp/app"
+full_bin = "tmp/app"
 # Watch these filename extensions.
 # Watch these filename extensions.
 include_ext = ["go", "mod", "sum", "html"]
 include_ext = ["go", "mod", "sum", "html"]
 # Ignore these filename extensions or directories.
 # Ignore these filename extensions or directories.

+ 3 - 0
.gitignore

@@ -13,6 +13,7 @@ gon*.hcl
 staging.sh
 staging.sh
 *.crt
 *.crt
 *.key
 *.key
+bin
 
 
 # Local .terraform directories
 # Local .terraform directories
 **/.terraform/*
 **/.terraform/*
@@ -55,3 +56,5 @@ override.tf.json
 # Ignore CLI configuration files
 # Ignore CLI configuration files
 .terraformrc
 .terraformrc
 terraform.rc
 terraform.rc
+
+tmp

+ 1 - 27
CONTRIBUTING.md

@@ -70,33 +70,7 @@ Here's an annotated directory structure to assist you in navigating the codebase
 
 
 ### Getting started
 ### Getting started
 
 
-If you've made it this far, you have all the information required to get your dev environment up and running! After forking and cloning the repo, you should save two `.env` files in the repo. 
-
-First, in `/dashboard/.env`:
-
-```
-NODE_ENV=development
-API_SERVER=localhost:8080
-```
-
-Next, in `/docker/.env`:
-
-```
-SERVER_URL=http://localhost:8080
-SERVER_PORT=8080
-DB_HOST=postgres
-DB_PORT=5432
-DB_USER=porter
-DB_PASS=porter
-DB_NAME=porter
-SQL_LITE=false
-```
-
-Once you've done this, go to the root repository, and run `docker-compose -f docker-compose.dev.yaml up`. You should see postgres, webpack, and porter containers spin up. When the webpack and porter containers have finished compiling and have spun up successfully (this will take 5-10 minutes after the containers start), you can navigate to `localhost:8080` and you should be greeted with the "Log In" screen. 
-
-At this point, you can make a change to any `.go` file to trigger a backend rebuild, and any file in `/dashboard/src` to trigger a hot reload. 
-
-For a more detailed development guide, [go here](/docs/developing/setup.md). 
+If you've made it this far, you have all the information required to get your dev environment up and running! After forking and cloning the repo, you should [follow this guide](/docs/developing/setup.md) for the development setup. 
 
 
 Happy developing!
 Happy developing!
 
 

+ 14 - 0
Makefile

@@ -0,0 +1,14 @@
+BINDIR      := $(CURDIR)/bin
+VERSION ?= dev
+
+start-dev: install setup-env-files
+	bash ./scripts/dev-environment/StartDevServer.sh
+
+install: 
+	bash ./scripts/dev-environment/SetupEnvironment.sh
+
+setup-env-files: 
+	bash ./scripts/dev-environment/CreateDefaultEnvFiles.sh
+
+build-cli: 
+	go build -ldflags="-w -s -X 'github.com/porter-dev/porter/cli/cmd.Version=${VERSION}'" -a -tags cli -o $(BINDIR)/porter ./cli

+ 1 - 1
cli/cmd/api/domain.go

@@ -17,7 +17,7 @@ type CreateDNSRecordRequest struct {
 // CreateDNSRecordResponse is the DNS record that was created
 // CreateDNSRecordResponse is the DNS record that was created
 type CreateDNSRecordResponse models.DNSRecordExternal
 type CreateDNSRecordResponse models.DNSRecordExternal
 
 
-// CreateGithubAction creates a Github action with basic authentication
+// CreateDNSRecord creates a Github action with basic authentication
 func (c *Client) CreateDNSRecord(
 func (c *Client) CreateDNSRecord(
 	ctx context.Context,
 	ctx context.Context,
 	projectID, clusterID uint,
 	projectID, clusterID uint,

+ 10 - 9
cli/cmd/api/github_action.go

@@ -11,14 +11,15 @@ import (
 // CreateGithubActionRequest represents the accepted fields for creating
 // CreateGithubActionRequest represents the accepted fields for creating
 // a Github action
 // a Github action
 type CreateGithubActionRequest struct {
 type CreateGithubActionRequest struct {
-	ReleaseID      uint   `json:"release_id" form:"required"`
-	GitRepo        string `json:"git_repo" form:"required"`
-	GitBranch      string `json:"git_branch"`
-	ImageRepoURI   string `json:"image_repo_uri" form:"required"`
-	DockerfilePath string `json:"dockerfile_path"`
-	FolderPath     string `json:"folder_path"`
-	GitRepoID      uint   `json:"git_repo_id" form:"required"`
-	RegistryID     uint   `json:"registry_id"`
+	ReleaseID            uint   `json:"release_id" form:"required"`
+	GitRepo              string `json:"git_repo" form:"required"`
+	GitBranch            string `json:"git_branch"`
+	ImageRepoURI         string `json:"image_repo_uri" form:"required"`
+	DockerfilePath       string `json:"dockerfile_path"`
+	FolderPath           string `json:"folder_path"`
+	GitRepoID            uint   `json:"git_repo_id" form:"required"`
+	RegistryID           uint   `json:"registry_id"`
+	ShouldCreateWorkflow bool   `json:"should_create_workflow"`
 }
 }
 
 
 // CreateGithubAction creates a Github action with basic authentication
 // CreateGithubAction creates a Github action with basic authentication
@@ -37,7 +38,7 @@ func (c *Client) CreateGithubAction(
 	req, err := http.NewRequest(
 	req, err := http.NewRequest(
 		"POST",
 		"POST",
 		fmt.Sprintf(
 		fmt.Sprintf(
-			"%s/projects/%d/ci/actions?cluster_id=%d&name=%s&namespace=%s",
+			"%s/projects/%d/ci/actions/create?cluster_id=%d&name=%s&namespace=%s",
 			c.BaseURL,
 			c.BaseURL,
 			projectID,
 			projectID,
 			clusterID,
 			clusterID,

+ 4 - 0
dashboard/babel.config.json

@@ -0,0 +1,4 @@
+{
+  "plugins": ["lodash"],
+  "presets": ["@babel/preset-env", "@babel/preset-react", "@babel/preset-typescript"]
+}

Diferenças do arquivo suprimidas por serem muito extensas
+ 2360 - 45
dashboard/package-lock.json


+ 25 - 12
dashboard/package.json

@@ -4,14 +4,6 @@
   "private": true,
   "private": true,
   "dependencies": {
   "dependencies": {
     "@material-ui/core": "^4.11.3",
     "@material-ui/core": "^4.11.3",
-    "@types/d3-array": "^2.9.0",
-    "@types/d3-time-format": "^3.0.0",
-    "@types/js-yaml": "^4.0.1",
-    "@types/lodash": "^4.14.165",
-    "@types/markdown-to-jsx": "^6.11.3",
-    "@types/material-ui": "^0.21.8",
-    "@types/qs": "^6.9.5",
-    "@types/random-words": "^1.1.0",
     "@visx/axis": "^1.6.1",
     "@visx/axis": "^1.6.1",
     "@visx/curve": "^1.0.0",
     "@visx/curve": "^1.0.0",
     "@visx/event": "^1.3.0",
     "@visx/event": "^1.3.0",
@@ -27,6 +19,7 @@
     "axios": "^0.20.0",
     "axios": "^0.20.0",
     "brace": "^0.11.1",
     "brace": "^0.11.1",
     "clipboard": "^2.0.8",
     "clipboard": "^2.0.8",
+    "core-js": "^3.16.1",
     "d3-array": "^2.11.0",
     "d3-array": "^2.11.0",
     "d3-time-format": "^3.0.0",
     "d3-time-format": "^3.0.0",
     "dotenv": "^8.2.0",
     "dotenv": "^8.2.0",
@@ -41,24 +34,37 @@
     "react": "^16.13.1",
     "react": "^16.13.1",
     "react-ace": "^9.1.3",
     "react-ace": "^9.1.3",
     "react-dom": "^16.13.1",
     "react-dom": "^16.13.1",
+    "react-error-boundary": "^3.1.3",
     "react-modal": "^3.11.2",
     "react-modal": "^3.11.2",
     "react-router-dom": "^5.2.0",
     "react-router-dom": "^5.2.0",
     "react-table": "^7.7.0",
     "react-table": "^7.7.0",
+    "regenerator-runtime": "^0.13.9",
     "semver": "^7.3.5",
     "semver": "^7.3.5",
-    "styled-components": "^5.2.0",
-    "react-error-boundary": "^3.1.3"
+    "styled-components": "^5.2.0"
   },
   },
   "scripts": {
   "scripts": {
     "test": "echo \"Error: no test specified\" && exit 1",
     "test": "echo \"Error: no test specified\" && exit 1",
-    "start": "webpack-dev-server --host 0.0.0.0 --hot --inline --port 8080",
-    "build": "webpack"
+    "start": "webpack-dev-server",
+    "build": "NODE_ENV=\"production\" webpack",
+    "build-and-analyze": "ENABLE_ANALYZER=true NODE_ENV=\"production\" webpack"
   },
   },
   "devDependencies": {
   "devDependencies": {
+    "@babel/core": "^7.15.0",
+    "@babel/preset-env": "^7.15.0",
+    "@babel/preset-react": "^7.14.5",
+    "@babel/preset-typescript": "^7.15.0",
+    "@pmmmwh/react-refresh-webpack-plugin": "^0.4.3",
     "@testing-library/jest-dom": "^4.2.4",
     "@testing-library/jest-dom": "^4.2.4",
     "@testing-library/react": "^9.3.2",
     "@testing-library/react": "^9.3.2",
     "@testing-library/user-event": "^7.1.2",
     "@testing-library/user-event": "^7.1.2",
+    "@types/d3-array": "^2.9.0",
+    "@types/d3-time-format": "^3.0.0",
     "@types/jest": "^24.0.0",
     "@types/jest": "^24.0.0",
     "@types/js-base64": "^3.0.0",
     "@types/js-base64": "^3.0.0",
+    "@types/js-yaml": "^4.0.1",
+    "@types/lodash": "^4.14.165",
+    "@types/markdown-to-jsx": "^6.11.3",
+    "@types/material-ui": "^0.21.8",
     "@types/node": "^12.12.62",
     "@types/node": "^12.12.62",
     "@types/qs": "^6.9.5",
     "@types/qs": "^6.9.5",
     "@types/random-words": "^1.1.0",
     "@types/random-words": "^1.1.0",
@@ -70,14 +76,21 @@
     "@types/react-table": "^7.7.1",
     "@types/react-table": "^7.7.1",
     "@types/semver": "^7.3.5",
     "@types/semver": "^7.3.5",
     "@types/styled-components": "^5.1.3",
     "@types/styled-components": "^5.1.3",
+    "@types/terser-webpack-plugin": "^4.2.2",
+    "@types/webpack-dev-server": "^3.11.5",
+    "babel-loader": "^8.2.2",
+    "babel-plugin-lodash": "^3.3.4",
     "file-loader": "^6.1.0",
     "file-loader": "^6.1.0",
     "html-webpack-plugin": "^4.5.0",
     "html-webpack-plugin": "^4.5.0",
     "prettier": "2.2.1",
     "prettier": "2.2.1",
     "qs": "^6.9.4",
     "qs": "^6.9.4",
+    "react-refresh": "^0.10.0",
     "source-map-loader": "^1.1.0",
     "source-map-loader": "^1.1.0",
+    "terser-webpack-plugin": "^4.2.3",
     "ts-loader": "^8.0.4",
     "ts-loader": "^8.0.4",
     "typescript": "^4.1.2",
     "typescript": "^4.1.2",
     "webpack": "^4.44.2",
     "webpack": "^4.44.2",
+    "webpack-bundle-analyzer": "^4.4.2",
     "webpack-cli": "^3.3.12",
     "webpack-cli": "^3.3.12",
     "webpack-dev-server": "^3.11.0"
     "webpack-dev-server": "^3.11.0"
   }
   }

+ 38 - 50
dashboard/src/components/SaveButton.tsx

@@ -2,7 +2,7 @@ import React, { Component } from "react";
 import styled from "styled-components";
 import styled from "styled-components";
 import loading from "assets/loading.gif";
 import loading from "assets/loading.gif";
 
 
-type PropsType = {
+type Props = {
   text?: string;
   text?: string;
   onClick: () => void;
   onClick: () => void;
   disabled?: boolean;
   disabled?: boolean;
@@ -10,6 +10,7 @@ type PropsType = {
   color?: string;
   color?: string;
   rounded?: boolean;
   rounded?: boolean;
   helper?: string | null;
   helper?: string | null;
+  saveText?: string | null;
 
 
   // Makes flush with corner if not within a modal
   // Makes flush with corner if not within a modal
   makeFlush?: boolean;
   makeFlush?: boolean;
@@ -17,82 +18,69 @@ type PropsType = {
   statusPosition?: "right" | "left";
   statusPosition?: "right" | "left";
 };
 };
 
 
-type StateType = {};
-
-export default class SaveButton extends Component<PropsType, StateType> {
-  renderStatus = () => {
-    if (this.props.status) {
-      if (this.props.status === "successful") {
+const SaveButton: React.FC<Props> = (props) => {
+  const renderStatus = () => {
+    if (props.status) {
+      if (props.status === "successful") {
         return (
         return (
-          <StatusWrapper position={this.props.statusPosition} successful={true}>
+          <StatusWrapper position={props.statusPosition} successful={true}>
             <i className="material-icons">done</i>
             <i className="material-icons">done</i>
             <StatusTextWrapper>Successfully updated</StatusTextWrapper>
             <StatusTextWrapper>Successfully updated</StatusTextWrapper>
           </StatusWrapper>
           </StatusWrapper>
         );
         );
-      } else if (this.props.status === "loading") {
+      } else if (props.status === "loading") {
         return (
         return (
-          <StatusWrapper
-            position={this.props.statusPosition}
-            successful={false}
-          >
+          <StatusWrapper position={props.statusPosition} successful={false}>
             <LoadingGif src={loading} />
             <LoadingGif src={loading} />
-            <StatusTextWrapper>Updating . . .</StatusTextWrapper>
+            <StatusTextWrapper>
+              {props.saveText || "Updating . . ."}
+            </StatusTextWrapper>
           </StatusWrapper>
           </StatusWrapper>
         );
         );
-      } else if (this.props.status === "error") {
+      } else if (props.status === "error") {
         return (
         return (
-          <StatusWrapper
-            position={this.props.statusPosition}
-            successful={false}
-          >
+          <StatusWrapper position={props.statusPosition} successful={false}>
             <i className="material-icons">error_outline</i>
             <i className="material-icons">error_outline</i>
             <StatusTextWrapper>Could not update</StatusTextWrapper>
             <StatusTextWrapper>Could not update</StatusTextWrapper>
           </StatusWrapper>
           </StatusWrapper>
         );
         );
       } else {
       } else {
         return (
         return (
-          <StatusWrapper
-            position={this.props.statusPosition}
-            successful={false}
-          >
+          <StatusWrapper position={props.statusPosition} successful={false}>
             <i className="material-icons">error_outline</i>
             <i className="material-icons">error_outline</i>
-            <StatusTextWrapper>{this.props.status}</StatusTextWrapper>
+            <StatusTextWrapper>{props.status}</StatusTextWrapper>
           </StatusWrapper>
           </StatusWrapper>
         );
         );
       }
       }
-    } else if (this.props.helper) {
+    } else if (props.helper) {
       return (
       return (
-        <StatusWrapper position={this.props.statusPosition} successful={true}>
-          {this.props.helper}
+        <StatusWrapper position={props.statusPosition} successful={true}>
+          {props.helper}
         </StatusWrapper>
         </StatusWrapper>
       );
       );
     }
     }
   };
   };
 
 
-  render() {
-    return (
-      <ButtonWrapper
-        makeFlush={this.props.makeFlush}
-        clearPosition={this.props.clearPosition}
+  return (
+    <ButtonWrapper
+      makeFlush={props.makeFlush}
+      clearPosition={props.clearPosition}
+    >
+      {props.statusPosition !== "right" && <div>{renderStatus()}</div>}
+      <Button
+        rounded={props.rounded}
+        disabled={props.disabled}
+        onClick={props.onClick}
+        color={props.color || "#616FEEcc"}
       >
       >
-        {this.props.statusPosition !== "right" && (
-          <div>{this.renderStatus()}</div>
-        )}
-        <Button
-          rounded={this.props.rounded}
-          disabled={this.props.disabled}
-          onClick={this.props.onClick}
-          color={this.props.color || "#616FEEcc"}
-        >
-          {this.props.children || this.props.text}
-        </Button>
-        {this.props.statusPosition === "right" && (
-          <div>{this.renderStatus()}</div>
-        )}
-      </ButtonWrapper>
-    );
-  }
-}
+        {props.children || props.text}
+      </Button>
+      {props.statusPosition === "right" && <div>{renderStatus()}</div>}
+    </ButtonWrapper>
+  );
+};
+
+export default SaveButton;
 
 
 const LoadingGif = styled.img`
 const LoadingGif = styled.img`
   width: 15px;
   width: 15px;

+ 0 - 3
dashboard/src/components/porter-form/field-components/KeyValueArray.tsx

@@ -349,9 +349,6 @@ export const getFinalVariablesForKeyValueArray: GetFinalVariablesFunction = (
   props: KeyValueArrayField,
   props: KeyValueArrayField,
   state: KeyValueArrayFieldState
   state: KeyValueArrayFieldState
 ) => {
 ) => {
-  console.log(vars);
-  console.log(props);
-  console.log(state);
   if (!state) {
   if (!state) {
     return {
     return {
       [props.variable]: props.value ? props.value[0] : [],
       [props.variable]: props.value ? props.value[0] : [],

+ 0 - 10
dashboard/src/components/repo-selector/ActionDetails.tsx

@@ -142,16 +142,6 @@ export default class ActionDetails extends Component<PropsType, StateType> {
           />
           />
         )}
         )}
         {this.renderRegistrySection()}
         {this.renderRegistrySection()}
-        <SubtitleAlt>
-          <Bold>Note:</Bold> To auto-deploy each time you push changes, Porter
-          will write Github Secrets and a GitHub Actions file to your repo.
-          <Highlight
-            href="https://docs.getporter.dev/docs/auto-deploy-requirements#cicd-with-github-actions"
-            target="_blank"
-          >
-            Learn more
-          </Highlight>
-        </SubtitleAlt>
         <Br />
         <Br />
 
 
         <Flex>
         <Flex>

+ 3 - 0
dashboard/src/index.tsx

@@ -1,3 +1,6 @@
+import "core-js/stable";
+import "regenerator-runtime/runtime";
+
 import * as React from "react";
 import * as React from "react";
 import * as ReactDOM from "react-dom";
 import * as ReactDOM from "react-dom";
 import App from "./App";
 import App from "./App";

+ 100 - 3
dashboard/src/main/home/cluster-dashboard/chart/Chart.tsx

@@ -1,31 +1,47 @@
 import React, { useContext, useEffect, useMemo, useState } from "react";
 import React, { useContext, useEffect, useMemo, useState } from "react";
 import styled from "styled-components";
 import styled from "styled-components";
+import { useHistory, useLocation, useRouteMatch } from "react-router";
 
 
 import { ChartType, StorageType } from "shared/types";
 import { ChartType, StorageType } from "shared/types";
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
 import StatusIndicator from "components/StatusIndicator";
 import StatusIndicator from "components/StatusIndicator";
 import { pushFiltered } from "shared/routing";
 import { pushFiltered } from "shared/routing";
-import { useHistory, useLocation, useRouteMatch } from "react-router";
+import { useWebsockets } from "shared/hooks/useWebsockets";
 import api from "shared/api";
 import api from "shared/api";
 
 
 type Props = {
 type Props = {
   chart: ChartType;
   chart: ChartType;
   controllers: Record<string, any>;
   controllers: Record<string, any>;
+  isJob: boolean;
   release: any;
   release: any;
 };
 };
 
 
+type JobStatusType = {
+  status: "succeeded" | "running" | "failed";
+  start_time: string;
+};
+
 const Chart: React.FunctionComponent<Props> = ({
 const Chart: React.FunctionComponent<Props> = ({
   chart,
   chart,
   controllers,
   controllers,
+  isJob,
   release,
   release,
 }) => {
 }) => {
   const [expand, setExpand] = useState<boolean>(false);
   const [expand, setExpand] = useState<boolean>(false);
   const [chartControllers, setChartControllers] = useState<any>([]);
   const [chartControllers, setChartControllers] = useState<any>([]);
+  const [jobStatus, setJobStatus] = useState<JobStatusType>(null);
   const context = useContext(Context);
   const context = useContext(Context);
   const location = useLocation();
   const location = useLocation();
   const history = useHistory();
   const history = useHistory();
   const match = useRouteMatch();
   const match = useRouteMatch();
 
 
+  const {
+    newWebsocket,
+    openWebsocket,
+    closeAllWebsockets,
+    closeWebsocket,
+  } = useWebsockets();
+
   const renderIcon = () => {
   const renderIcon = () => {
     if (chart.chart.metadata.icon && chart.chart.metadata.icon !== "") {
     if (chart.chart.metadata.icon && chart.chart.metadata.icon !== "") {
       return <Icon src={chart.chart.metadata.icon} />;
       return <Icon src={chart.chart.metadata.icon} />;
@@ -64,6 +80,59 @@ const Chart: React.FunctionComponent<Props> = ({
     getControllerForChart(chart);
     getControllerForChart(chart);
   }, [chart]);
   }, [chart]);
 
 
+  const setupWebsocket = (kind: string) => {
+    const { currentProject, currentCluster } = context;
+
+    const apiEndpoint = `/api/projects/${currentProject.id}/k8s/${kind}/status?cluster_id=${currentCluster.id}`;
+
+    const wsConfig = {
+      onmessage(evt: MessageEvent) {
+        const event = JSON.parse(evt.data);
+        let object = event.Object;
+        object.metadata.kind = event.Kind;
+        if (event.event_type != "UPDATE") {
+          return;
+        }
+        getJobStatus();
+      },
+      onerror() {
+        closeWebsocket(kind);
+      },
+    };
+
+    newWebsocket(kind, apiEndpoint, wsConfig);
+    openWebsocket(kind);
+  };
+
+  const getJobStatus = () => {
+    let { currentCluster, currentProject, setCurrentError } = context;
+
+    api
+      .getJobStatus(
+        "<token>",
+        {
+          cluster_id: currentCluster.id,
+        },
+        {
+          id: currentProject.id,
+          name: chart.name,
+          namespace: chart.namespace,
+        }
+      )
+      .then((res) => {
+        setJobStatus(res.data);
+      })
+      .catch((err) => setCurrentError(err));
+  };
+
+  useEffect(() => {
+    if (isJob) {
+      getJobStatus();
+      setupWebsocket("job");
+    }
+    return () => closeAllWebsockets();
+  }, [isJob]);
+
   const readableDate = (s: string) => {
   const readableDate = (s: string) => {
     const ts = new Date(s);
     const ts = new Date(s);
     const date = ts.toLocaleDateString();
     const date = ts.toLocaleDateString();
@@ -123,7 +192,18 @@ const Chart: React.FunctionComponent<Props> = ({
         </TagWrapper>
         </TagWrapper>
       </BottomWrapper>
       </BottomWrapper>
 
 
-      <Version>v{release?.version || chart.version}</Version>
+      <TopRightContainer>
+        {isJob && jobStatus?.status && (
+          <>
+            <JobStatus status={jobStatus.status}>
+              Last run {jobStatus.status.toUpperCase()} at{" "}
+              {readableDate(jobStatus.start_time)}
+            </JobStatus>
+            <StatusDot>•</StatusDot>
+          </>
+        )}
+        <span>v{release?.version || chart.version}</span>
+      </TopRightContainer>
     </StyledChart>
     </StyledChart>
   );
   );
 };
 };
@@ -138,7 +218,7 @@ const BottomWrapper = styled.div`
   margin-top: 12px;
   margin-top: 12px;
 `;
 `;
 
 
-const Version = styled.div`
+const TopRightContainer = styled.div`
   position: absolute;
   position: absolute;
   top: 12px;
   top: 12px;
   right: 12px;
   right: 12px;
@@ -150,6 +230,10 @@ const Dot = styled.div`
   margin-right: 9px;
   margin-right: 9px;
 `;
 `;
 
 
+const StatusDot = styled.span`
+  margin: 0 9px;
+`;
+
 const InfoWrapper = styled.div`
 const InfoWrapper = styled.div`
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
@@ -247,6 +331,19 @@ const Title = styled.div`
   }
   }
 `;
 `;
 
 
+const JobStatus = styled.span`
+  font-weight: bold;
+  ${(props: { status: string }) => `
+  color: ${
+    props.status === "succeeded"
+      ? "rgb(56, 168, 138)"
+      : props.status === "failed"
+      ? "rgb(204, 61, 66)"
+      : "#aaaabb"
+  }
+`}
+`;
+
 const StyledChart = styled.div`
 const StyledChart = styled.div`
   background: #26282f;
   background: #26282f;
   cursor: pointer;
   cursor: pointer;

+ 1 - 0
dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx

@@ -241,6 +241,7 @@ const ChartList: React.FunctionComponent<Props> = ({
           key={`${chart.namespace}-${chart.name}`}
           key={`${chart.namespace}-${chart.name}`}
           chart={chart}
           chart={chart}
           controllers={controllers || {}}
           controllers={controllers || {}}
+          isJob={currentView === "jobs"}
           release={releases[chart.name] || {}}
           release={releases[chart.name] || {}}
         />
         />
       );
       );

+ 3 - 3
dashboard/src/main/home/cluster-dashboard/dashboard/ClusterSettings.tsx

@@ -120,7 +120,7 @@ const ClusterSettings: React.FC = () => {
 
 
   return (
   return (
     <div>
     <div>
-      <StyledSettingsSection showSource={false}>
+      <StyledSettingsSection>
         {keyRotationSection}
         {keyRotationSection}
         <DarkMatter />
         <DarkMatter />
         <Heading>Delete Cluster</Heading>
         <Heading>Delete Cluster</Heading>
@@ -143,7 +143,7 @@ const DarkMatter = styled.div`
   margin-top: -15px;
   margin-top: -15px;
 `;
 `;
 
 
-const StyledSettingsSection = styled.div<{ showSource: boolean }>`
+const StyledSettingsSection = styled.div`
   margin-top: 35px;
   margin-top: 35px;
   width: 100%;
   width: 100%;
   background: #ffffff11;
   background: #ffffff11;
@@ -152,7 +152,7 @@ const StyledSettingsSection = styled.div<{ showSource: boolean }>`
   position: relative;
   position: relative;
   border-radius: 8px;
   border-radius: 8px;
   overflow: auto;
   overflow: auto;
-  height: ${(props) => (props.showSource ? "calc(100% - 55px)" : "100%")};
+  height: 100%;
 `;
 `;
 
 
 const Button = styled.button`
 const Button = styled.button`

+ 13 - 2
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -1,11 +1,22 @@
-import React, { useCallback, useContext, useEffect, useMemo, useState } from "react";
+import React, {
+  useCallback,
+  useContext,
+  useEffect,
+  useMemo,
+  useState,
+} from "react";
 import styled from "styled-components";
 import styled from "styled-components";
 import yaml from "js-yaml";
 import yaml from "js-yaml";
 import backArrow from "assets/back_arrow.png";
 import backArrow from "assets/back_arrow.png";
 import _ from "lodash";
 import _ from "lodash";
 import loadingSrc from "assets/loading.gif";
 import loadingSrc from "assets/loading.gif";
 
 
-import { ChartType, ClusterType, ResourceType, StorageType } from "shared/types";
+import {
+  ChartType,
+  ClusterType,
+  ResourceType,
+  StorageType,
+} from "shared/types";
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
 import api from "shared/api";
 import api from "shared/api";
 import StatusIndicator from "components/StatusIndicator";
 import StatusIndicator from "components/StatusIndicator";

+ 0 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx

@@ -464,7 +464,6 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
         return (
         return (
           this.props.isAuthorized("job", "", ["get", "delete"]) && (
           this.props.isAuthorized("job", "", ["get", "delete"]) && (
             <SettingsSection
             <SettingsSection
-              showSource={true}
               currentChart={this.state.currentChart}
               currentChart={this.state.currentChart}
               refreshChart={() => this.refreshChart(0)}
               refreshChart={() => this.refreshChart(0)}
               setShowDeleteOverlay={(x: boolean) => {
               setShowDeleteOverlay={(x: boolean) => {

+ 170 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/NotificationSettingsSection.tsx

@@ -0,0 +1,170 @@
+import React, { useContext, useState, useEffect } from "react";
+import Heading from "../../../../components/form-components/Heading";
+import CheckboxRow from "../../../../components/form-components/CheckboxRow";
+import Helper from "../../../../components/form-components/Helper";
+import SaveButton from "../../../../components/SaveButton";
+import api from "../../../../shared/api";
+import { Context } from "../../../../shared/Context";
+import { ChartType } from "../../../../shared/types";
+import Loading from "../../../../components/Loading";
+
+const NOTIF_CATEGORIES = ["success", "fail"];
+
+interface Props {
+  disabled?: boolean;
+  currentChart: ChartType;
+}
+
+const NotificationSettingsSection: React.FC<Props> = (props) => {
+  const [notificationsOn, setNotificationsOn] = useState(true);
+  const [categories, setCategories] = useState(
+    NOTIF_CATEGORIES.reduce((p, c) => {
+      return {
+        ...p,
+        [c]: true,
+      };
+    }, {})
+  );
+  const [initLoading, setInitLoading] = useState(true);
+  const [saveLoading, setSaveLoading] = useState(false);
+  const [numSaves, setNumSaves] = useState(0);
+  const [hasNotifications, setHasNotifications] = useState(null);
+  const [hasRelease, setHasRelease] = useState(true);
+
+  const { currentProject, currentCluster } = useContext(Context);
+
+  useEffect(() => {
+    api
+      .getNotificationConfig(
+        "<token>",
+        {
+          namespace: props.currentChart.namespace,
+          cluster_id: currentCluster.id,
+        },
+        {
+          project_id: currentProject.id,
+          name: props.currentChart.name,
+        }
+      )
+      .then(({ data }) => {
+        setNotificationsOn(data.enabled);
+        delete data.enabled;
+        setCategories({
+          success: data.success,
+          failure: data.failure,
+        });
+        setInitLoading(false);
+      })
+      .catch(() => {
+        setHasRelease(false);
+        setInitLoading(false);
+      });
+    api
+      .getSlackIntegrations(
+        "<token>",
+        {},
+        {
+          id: currentProject.id,
+        }
+      )
+      .then(({ data }) => {
+        setHasNotifications(data.length > 0);
+      });
+  }, []);
+
+  const saveChanges = () => {
+    setSaveLoading(true);
+    let payload = {
+      enabled: notificationsOn,
+      ...categories,
+    };
+
+    api
+      .updateNotificationConfig(
+        "<token>",
+        {
+          namespace: props.currentChart.namespace,
+          cluster_id: currentCluster.id,
+          payload,
+        },
+        {
+          project_id: currentProject.id,
+          name: props.currentChart.name,
+        }
+      )
+      .then(() => {
+        setNumSaves(numSaves + 1);
+        setSaveLoading(false);
+      })
+      .catch(() => {
+        setHasRelease(false);
+        setSaveLoading(false);
+      });
+  };
+
+  return (
+    <>
+      <Heading>Notification Settings</Heading>
+      {initLoading ? (
+        <Loading />
+      ) : !hasRelease ? (
+        <Heading>
+          This message appears when the release isn't in the database, so Porter
+          can't laod in notifications for it
+        </Heading>
+      ) : (
+        <>
+          {hasNotifications != null && !hasNotifications && (
+            <Helper>
+              This message appears when there are no notification integrations
+              for the project
+            </Helper>
+          )}
+          <CheckboxRow
+            label={"Notifications Enabled"}
+            checked={notificationsOn}
+            toggle={() => setNotificationsOn(!notificationsOn)}
+            disabled={props.disabled}
+          />
+          {notificationsOn && (
+            <>
+              <Helper>Send notifications on:</Helper>
+              {Object.entries(categories).map(([k, v]: [string, boolean]) => {
+                return (
+                  <React.Fragment key={k}>
+                    <CheckboxRow
+                      label={k}
+                      checked={v}
+                      toggle={() =>
+                        setCategories((prev) => {
+                          return {
+                            ...prev,
+                            [k]: !v,
+                          };
+                        })
+                      }
+                      disabled={props.disabled}
+                    />
+                  </React.Fragment>
+                );
+              })}
+            </>
+          )}
+          <SaveButton
+            onClick={() => saveChanges()}
+            text={"Save Changes"}
+            clearPosition={true}
+            statusPosition={"right"}
+            disabled={props.disabled || initLoading || saveLoading}
+            status={
+              saveLoading ? "loading" : numSaves > 0 ? "successful" : null
+            }
+            saveText={"Saving . . ."}
+          />
+        </>
+      )}
+    </>
+  );
+};
+
+export default NotificationSettingsSection;

+ 19 - 21
dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx

@@ -14,12 +14,12 @@ import _ from "lodash";
 import CopyToClipboard from "components/CopyToClipboard";
 import CopyToClipboard from "components/CopyToClipboard";
 import useAuth from "shared/auth/useAuth";
 import useAuth from "shared/auth/useAuth";
 import Loading from "components/Loading";
 import Loading from "components/Loading";
+import NotificationSettingsSection from "./NotificationSettingsSection";
 
 
 type PropsType = {
 type PropsType = {
   currentChart: ChartType;
   currentChart: ChartType;
   refreshChart: () => void;
   refreshChart: () => void;
   setShowDeleteOverlay: (x: boolean) => void;
   setShowDeleteOverlay: (x: boolean) => void;
-  showSource?: boolean;
   saveButtonText?: string | null;
   saveButtonText?: string | null;
 };
 };
 
 
@@ -27,7 +27,6 @@ const SettingsSection: React.FC<PropsType> = ({
   currentChart,
   currentChart,
   refreshChart,
   refreshChart,
   setShowDeleteOverlay,
   setShowDeleteOverlay,
-  showSource,
   saveButtonText,
   saveButtonText,
 }) => {
 }) => {
   const [selectedImageUrl, setSelectedImageUrl] = useState<string | null>("");
   const [selectedImageUrl, setSelectedImageUrl] = useState<string | null>("");
@@ -200,21 +199,19 @@ const SettingsSection: React.FC<PropsType> = ({
 
 
     return (
     return (
       <>
       <>
-        {showSource && (
-          <>
-            <Heading>Source Settings</Heading>
-            <Helper>Specify an image tag to use.</Helper>
-            <ImageSelector
-              selectedTag={selectedTag}
-              selectedImageUrl={selectedImageUrl}
-              setSelectedImageUrl={(x: string) => setSelectedImageUrl(x)}
-              setSelectedTag={(x: string) => setSelectedTag(x)}
-              forceExpanded={true}
-              disableImageSelect={true}
-            />
-            <Br />
-          </>
-        )}
+        <>
+          <Heading>Source Settings</Heading>
+          <Helper>Specify an image tag to use.</Helper>
+          <ImageSelector
+            selectedTag={selectedTag}
+            selectedImageUrl={selectedImageUrl}
+            setSelectedImageUrl={(x: string) => setSelectedImageUrl(x)}
+            setSelectedTag={(x: string) => setSelectedTag(x)}
+            forceExpanded={true}
+            disableImageSelect={true}
+          />
+          <Br />
+        </>
 
 
         <>
         <>
           <Heading>Redeploy Webhook</Heading>
           <Heading>Redeploy Webhook</Heading>
@@ -256,8 +253,9 @@ const SettingsSection: React.FC<PropsType> = ({
   return (
   return (
     <Wrapper>
     <Wrapper>
       {!loadingWebhookToken ? (
       {!loadingWebhookToken ? (
-        <StyledSettingsSection showSource={showSource}>
+        <StyledSettingsSection>
           {renderWebhookSection()}
           {renderWebhookSection()}
+          <NotificationSettingsSection currentChart={currentChart} />
           <Heading>Additional Settings</Heading>
           <Heading>Additional Settings</Heading>
           <Button color="#b91133" onClick={() => setShowDeleteOverlay(true)}>
           <Button color="#b91133" onClick={() => setShowDeleteOverlay(true)}>
             Delete {currentChart.name}
             Delete {currentChart.name}
@@ -266,7 +264,7 @@ const SettingsSection: React.FC<PropsType> = ({
       ) : (
       ) : (
         <Loading />
         <Loading />
       )}
       )}
-      {!loadingWebhookToken && showSource && (
+      {!loadingWebhookToken && (
         <SaveButton
         <SaveButton
           text={saveButtonText || "Save Config"}
           text={saveButtonText || "Save Config"}
           status={saveValuesStatus}
           status={saveValuesStatus}
@@ -370,7 +368,7 @@ const Wrapper = styled.div`
   height: 100%;
   height: 100%;
 `;
 `;
 
 
-const StyledSettingsSection = styled.div<{ showSource: boolean }>`
+const StyledSettingsSection = styled.div`
   width: 100%;
   width: 100%;
   background: #ffffff11;
   background: #ffffff11;
   padding: 0 35px;
   padding: 0 35px;
@@ -378,7 +376,7 @@ const StyledSettingsSection = styled.div<{ showSource: boolean }>`
   position: relative;
   position: relative;
   border-radius: 8px;
   border-radius: 8px;
   overflow: auto;
   overflow: auto;
-  height: ${(props) => (props.showSource ? "calc(100% - 55px)" : "100%")};
+  height: calc(100% - 55px);
 `;
 `;
 
 
 const Holder = styled.div`
 const Holder = styled.div`

+ 0 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/TempJobList.tsx

@@ -25,7 +25,6 @@ const TempJobList: React.FC<Props> = (props) => {
     <ButtonWrapper>
     <ButtonWrapper>
       <SaveButton
       <SaveButton
         onClick={() => {
         onClick={() => {
-          console.log(getSubmitValues());
           props.handleSaveValues(getSubmitValues(), true);
           props.handleSaveValues(getSubmitValues(), true);
         }}
         }}
         status={props.saveValuesStatus}
         status={props.saveValuesStatus}

+ 169 - 242
dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx

@@ -1,4 +1,4 @@
-import React, { Component } from "react";
+import React, { useContext, useState } from "react";
 import styled from "styled-components";
 import styled from "styled-components";
 import _ from "lodash";
 import _ from "lodash";
 import randomWords from "random-words";
 import randomWords from "random-words";
@@ -10,10 +10,16 @@ import { pushFiltered } from "shared/routing";
 
 
 import { hardcodedNames } from "shared/hardcodedNameDict";
 import { hardcodedNames } from "shared/hardcodedNameDict";
 import SourcePage from "./SourcePage";
 import SourcePage from "./SourcePage";
+import WorkflowPage from "./WorkflowPage";
 import SettingsPage from "./SettingsPage";
 import SettingsPage from "./SettingsPage";
 import TitleSection from "components/TitleSection";
 import TitleSection from "components/TitleSection";
 
 
-import { ActionConfigType, PorterTemplate, StorageType } from "shared/types";
+import {
+  ActionConfigType,
+  FullActionConfigType,
+  PorterTemplate,
+  StorageType,
+} from "shared/types";
 
 
 type PropsType = RouteComponentProps & {
 type PropsType = RouteComponentProps & {
   currentTab?: string;
   currentTab?: string;
@@ -22,28 +28,6 @@ type PropsType = RouteComponentProps & {
   form: any;
   form: any;
 };
 };
 
 
-type StateType = {
-  currentPage: string;
-  templateName: string;
-  sourceType: string;
-  valuesToOverride: any;
-
-  imageUrl: string;
-  imageTag: string;
-
-  actionConfig: ActionConfigType;
-  procfileProcess: string;
-  branch: string;
-  repoType: string;
-  dockerfilePath: string | null;
-  procfilePath: string | null;
-  folderPath: string | null;
-  selectedRegistry: any;
-
-  selectedNamespace: string;
-  saveValuesStatus: string;
-};
-
 const defaultActionConfig: ActionConfigType = {
 const defaultActionConfig: ActionConfigType = {
   git_repo: "",
   git_repo: "",
   image_repo_uri: "",
   image_repo_uri: "",
@@ -51,83 +35,61 @@ const defaultActionConfig: ActionConfigType = {
   git_repo_id: 0,
   git_repo_id: 0,
 };
 };
 
 
-class LaunchFlow extends Component<PropsType, StateType> {
-  state = {
-    currentPage: "source",
-    templateName: "",
-    saveValuesStatus: "",
-    sourceType: "",
-    selectedNamespace: "default",
-    valuesToOverride: {} as any,
-
-    imageUrl: "",
-    imageTag: "",
-
-    actionConfig: { ...defaultActionConfig },
-    procfileProcess: "",
-    branch: "",
-    repoType: "",
-    dockerfilePath: null as string | null,
-    procfilePath: null as string | null,
-    folderPath: null as string | null,
-    selectedRegistry: null as any,
+const LaunchFlow: React.FC<PropsType> = (props) => {
+  const context = useContext(Context);
+
+  const [currentPage, setCurrentPage] = useState("source");
+  const [templateName, setTemplateName] = useState("");
+  const [saveValuesStatus, setSaveValuesStatus] = useState("");
+  const [sourceType, setSourceType] = useState("");
+  const [selectedNamespace, setSelectedNamespace] = useState("default");
+  const [valuesToOverride, setValuesToOverride] = useState({});
+
+  const [imageUrl, setImageUrl] = useState("");
+  const [imageTag, setImageTag] = useState("");
+
+  const [actionConfig, setActionConfig] = useState<ActionConfigType>({
+    ...defaultActionConfig,
+  });
+  const [procfileProcess, setProcfileProcess] = useState("");
+  const [branch, setBranch] = useState("");
+  const [repoType, setRepoType] = useState("");
+  const [dockerfilePath, setDockerfilePath] = useState(null);
+  const [procfilePath, setProcfilePath] = useState(null);
+  const [folderPath, setFolderPath] = useState(null);
+  const [selectedRegistry, setSelectedRegistry] = useState(null);
+  const [shouldCreateWorkflow, setShouldCreateWorkflow] = useState(true);
+
+  const setRandomNameIfEmpty = () => {
+    if (!templateName) {
+      const randomTemplateName = randomWords({ exactly: 3, join: "-" });
+      setTemplateName(randomTemplateName);
+    }
   };
   };
 
 
-  createGHAction = (chartName: string, chartNamespace: string) => {
-    let { currentProject, currentCluster, setCurrentError } = this.context;
-    let {
-      actionConfig,
-      branch,
-      selectedRegistry,
-      dockerfilePath,
-      folderPath,
-    } = this.state;
-    let imageRepoUri = `${selectedRegistry.url}/${chartName}-${chartNamespace}`;
+  const getFullActionConfig = (): FullActionConfigType => {
+    let imageRepoUri = `${selectedRegistry.url}/${templateName}-${selectedNamespace}`;
 
 
     // DockerHub registry integration is per repo
     // DockerHub registry integration is per repo
     if (selectedRegistry.service === "dockerhub") {
     if (selectedRegistry.service === "dockerhub") {
       imageRepoUri = selectedRegistry.url;
       imageRepoUri = selectedRegistry.url;
     }
     }
 
 
-    api
-      .createGHAction(
-        "<token>",
-        {
-          git_repo: actionConfig.git_repo,
-          git_branch: branch,
-          registry_id: selectedRegistry.id,
-          dockerfile_path: dockerfilePath,
-          folder_path: folderPath,
-          image_repo_uri: imageRepoUri,
-          git_repo_id: actionConfig.git_repo_id,
-        },
-        {
-          project_id: currentProject.id,
-          CLUSTER_ID: currentCluster.id,
-          RELEASE_NAME: chartName,
-          RELEASE_NAMESPACE: chartNamespace,
-        }
-      )
-      .then((res) => console.log(""))
-      .catch((err) => {
-        let parsedErr =
-          err?.response?.data?.errors && err.response.data.errors[0];
-        err = parsedErr || err.message || JSON.stringify(err);
-
-        this.setState({
-          saveValuesStatus: `Could not create GitHub Action: ${err}`,
-        });
-
-        setCurrentError(err);
-      });
+    return {
+      git_repo: actionConfig.git_repo,
+      branch: branch,
+      registry_id: selectedRegistry.id,
+      dockerfile_path: dockerfilePath,
+      folder_path: folderPath,
+      image_repo_uri: imageRepoUri,
+      git_repo_id: actionConfig.git_repo_id,
+      should_create_workflow: shouldCreateWorkflow,
+    };
   };
   };
 
 
-  onSubmitAddon = (wildcard?: any) => {
-    let { selectedNamespace } = this.state;
-    let { currentCluster, currentProject, setCurrentError } = this.context;
-    let name =
-      this.state.templateName || randomWords({ exactly: 3, join: "-" });
-    this.setState({ saveValuesStatus: "loading" });
+  const handleSubmitAddon = (wildcard?: any) => {
+    let { currentCluster, currentProject, setCurrentError } = context;
+    setSaveValuesStatus("loading");
 
 
     let values = {};
     let values = {};
     for (let key in wildcard) {
     for (let key in wildcard) {
@@ -138,38 +100,35 @@ class LaunchFlow extends Component<PropsType, StateType> {
       .deployAddon(
       .deployAddon(
         "<token>",
         "<token>",
         {
         {
-          templateName: this.props.currentTemplate.name,
+          templateName: props.currentTemplate.name,
           storage: StorageType.Secret,
           storage: StorageType.Secret,
           formValues: values,
           formValues: values,
           namespace: selectedNamespace,
           namespace: selectedNamespace,
-          name,
+          name: templateName,
         },
         },
         {
         {
           id: currentProject.id,
           id: currentProject.id,
           cluster_id: currentCluster.id,
           cluster_id: currentCluster.id,
-          name: this.props.currentTemplate.name.toLowerCase().trim(),
-          version: this.props.currentTemplate?.currentVersion || "latest",
+          name: props.currentTemplate.name.toLowerCase().trim(),
+          version: props.currentTemplate?.currentVersion || "latest",
           repo_url: process.env.ADDON_CHART_REPO_URL,
           repo_url: process.env.ADDON_CHART_REPO_URL,
         }
         }
       )
       )
       .then((_) => {
       .then((_) => {
-        // this.props.setCurrentView('cluster-dashboard');
-        this.setState({ saveValuesStatus: "successful" }, () => {
-          // redirect to dashboard
-          let dst =
-            this.props.currentTemplate.name === "job"
-              ? "/jobs"
-              : "/applications";
-          setTimeout(() => {
-            pushFiltered(this.props, dst, ["project_id"], {
-              cluster: currentCluster.name,
-            });
-          }, 500);
-          window.analytics.track("Deployed Add-on", {
-            name: this.props.currentTemplate.name,
-            namespace: selectedNamespace,
-            values: values,
+        // props.setCurrentView('cluster-dashboard');
+        setSaveValuesStatus("successful");
+        // redirect to dashboard
+        let dst =
+          props.currentTemplate.name === "job" ? "/jobs" : "/applications";
+        setTimeout(() => {
+          pushFiltered(props, dst, ["project_id"], {
+            cluster: currentCluster.name,
           });
           });
+        }, 500);
+        window.analytics.track("Deployed Add-on", {
+          name: props.currentTemplate.name,
+          namespace: selectedNamespace,
+          values: values,
         });
         });
       })
       })
       .catch((err) => {
       .catch((err) => {
@@ -178,13 +137,11 @@ class LaunchFlow extends Component<PropsType, StateType> {
 
 
         err = parsedErr || err.message || JSON.stringify(err);
         err = parsedErr || err.message || JSON.stringify(err);
 
 
-        this.setState({
-          saveValuesStatus: err,
-        });
+        setSaveValuesStatus(err);
 
 
         setCurrentError(err);
         setCurrentError(err);
         window.analytics.track("Failed to Deploy Add-on", {
         window.analytics.track("Failed to Deploy Add-on", {
-          name: this.props.currentTemplate.name,
+          name: props.currentTemplate.name,
           namespace: selectedNamespace,
           namespace: selectedNamespace,
           values: values,
           values: values,
           error: err,
           error: err,
@@ -192,17 +149,9 @@ class LaunchFlow extends Component<PropsType, StateType> {
       });
       });
   };
   };
 
 
-  onSubmit = async (rawValues: any) => {
-    let { currentCluster, currentProject, setCurrentError } = this.context;
-    let {
-      selectedNamespace,
-      templateName,
-      imageUrl,
-      imageTag,
-      sourceType,
-    } = this.state;
-    let name = templateName || randomWords({ exactly: 3, join: "-" });
-    this.setState({ saveValuesStatus: "loading" });
+  const handleSubmit = async (rawValues: any) => {
+    let { currentCluster, currentProject, setCurrentError } = context;
+    setSaveValuesStatus("loading");
 
 
     // Convert dotted keys to nested objects
     // Convert dotted keys to nested objects
     let values: any = {};
     let values: any = {};
@@ -210,21 +159,22 @@ class LaunchFlow extends Component<PropsType, StateType> {
       _.set(values, key, rawValues[key]);
       _.set(values, key, rawValues[key]);
     }
     }
 
 
-    let tag = imageTag;
-    if (imageUrl.includes(":")) {
-      let splits = imageUrl.split(":");
-      imageUrl = splits[0];
+    let url = imageUrl,
+      tag = imageTag;
+    if (url.includes(":")) {
+      let splits = url.split(":");
+      url = splits[0];
       tag = splits[1];
       tag = splits[1];
     } else if (!tag) {
     } else if (!tag) {
       tag = "latest";
       tag = "latest";
     }
     }
 
 
     if (sourceType === "repo") {
     if (sourceType === "repo") {
-      if (this.props.currentTemplate?.name == "job") {
-        imageUrl = "public.ecr.aws/o1j4x7p4/hello-porter-job";
+      if (props.currentTemplate?.name == "job") {
+        url = "public.ecr.aws/o1j4x7p4/hello-porter-job";
         tag = "latest";
         tag = "latest";
       } else {
       } else {
-        imageUrl = "public.ecr.aws/o1j4x7p4/hello-porter";
+        url = "public.ecr.aws/o1j4x7p4/hello-porter";
         tag = "latest";
         tag = "latest";
       }
       }
     }
     }
@@ -245,28 +195,28 @@ class LaunchFlow extends Component<PropsType, StateType> {
     }
     }
 
 
     // don't overwrite for templates that already have a source (i.e. non-Docker templates)
     // don't overwrite for templates that already have a source (i.e. non-Docker templates)
-    if (imageUrl && tag) {
-      _.set(values, "image.repository", imageUrl);
+    if (url && tag) {
+      _.set(values, "image.repository", url);
       _.set(values, "image.tag", tag);
       _.set(values, "image.tag", tag);
     }
     }
 
 
     _.set(values, "ingress.provider", provider);
     _.set(values, "ingress.provider", provider);
 
 
     // pause jobs automatically
     // pause jobs automatically
-    if (this.props.currentTemplate?.name == "job") {
+    if (props.currentTemplate?.name == "job") {
       _.set(values, "paused", true);
       _.set(values, "paused", true);
     }
     }
 
 
-    var url: string;
+    var external_domain: string;
     // check if template is docker and create external domain if necessary
     // check if template is docker and create external domain if necessary
-    if (this.props.currentTemplate.name == "web") {
+    if (props.currentTemplate.name == "web") {
       if (values?.ingress?.enabled && !values?.ingress?.custom_domain) {
       if (values?.ingress?.enabled && !values?.ingress?.custom_domain) {
-        url = await new Promise((resolve, reject) => {
+        external_domain = await new Promise((resolve, reject) => {
           api
           api
             .createSubdomain(
             .createSubdomain(
               "<token>",
               "<token>",
               {
               {
-                release_name: name,
+                release_name: templateName,
               },
               },
               {
               {
                 id: currentProject.id,
                 id: currentProject.id,
@@ -280,128 +230,109 @@ class LaunchFlow extends Component<PropsType, StateType> {
               let parsedErr =
               let parsedErr =
                 err?.response?.data?.errors && err.response.data.errors[0];
                 err?.response?.data?.errors && err.response.data.errors[0];
               err = parsedErr || err.message || JSON.stringify(err);
               err = parsedErr || err.message || JSON.stringify(err);
-              this.setState({
-                saveValuesStatus: `Could not create subdomain: ${err}`,
-              });
+              setSaveValuesStatus(`Could not create subdomain: ${err}`);
 
 
               setCurrentError(err);
               setCurrentError(err);
             });
             });
         });
         });
 
 
-        values.ingress.porter_hosts = [url];
+        values.ingress.porter_hosts = [external_domain];
       }
       }
     }
     }
 
 
+    let githubActionConfig: FullActionConfigType = null;
+    if (sourceType === "repo") {
+      githubActionConfig = getFullActionConfig();
+    }
+
     api
     api
       .deployTemplate(
       .deployTemplate(
         "<token>",
         "<token>",
         {
         {
-          templateName: this.props.currentTemplate.name,
-          imageURL: imageUrl,
+          templateName: props.currentTemplate.name,
+          imageURL: url,
           storage: StorageType.Secret,
           storage: StorageType.Secret,
           formValues: values,
           formValues: values,
           namespace: selectedNamespace,
           namespace: selectedNamespace,
-          name,
+          name: templateName,
+          githubActionConfig,
         },
         },
         {
         {
           id: currentProject.id,
           id: currentProject.id,
           cluster_id: currentCluster.id,
           cluster_id: currentCluster.id,
-          name: this.props.currentTemplate.name.toLowerCase().trim(),
-          version: this.props.currentTemplate?.currentVersion || "latest",
+          name: props.currentTemplate.name.toLowerCase().trim(),
+          version: props.currentTemplate?.currentVersion || "latest",
           repo_url: process.env.APPLICATION_CHART_REPO_URL,
           repo_url: process.env.APPLICATION_CHART_REPO_URL,
         }
         }
       )
       )
       .then((res: any) => {
       .then((res: any) => {
-        if (sourceType === "repo") {
-          this.createGHAction(name, selectedNamespace);
-        }
-        // this.props.setCurrentView('cluster-dashboard');
-        this.setState({ saveValuesStatus: "successful" }, () => {
-          // redirect to dashboard with namespace
-          setTimeout(() => {
-            let dst =
-              this.props.currentTemplate.name === "job"
-                ? "/jobs"
-                : "/applications";
-            pushFiltered(this.props, dst, ["project_id"], {
-              cluster: currentCluster.name,
-            });
-          }, 1000);
-        });
+        // props.setCurrentView('cluster-dashboard');
+        setSaveValuesStatus("successful");
+        // redirect to dashboard with namespace
+        setTimeout(() => {
+          let dst =
+            props.currentTemplate.name === "job" ? "/jobs" : "/applications";
+          pushFiltered(props, dst, ["project_id"], {
+            cluster: currentCluster.name,
+          });
+        }, 1000);
       })
       })
       .catch((err: any) => {
       .catch((err: any) => {
         let parsedErr =
         let parsedErr =
           err?.response?.data?.errors && err.response.data.errors[0];
           err?.response?.data?.errors && err.response.data.errors[0];
         err = parsedErr || err.message || JSON.stringify(err);
         err = parsedErr || err.message || JSON.stringify(err);
-        this.setState({
-          saveValuesStatus: `Could not deploy template: ${err}`,
-        });
+        setSaveValuesStatus(`Could not deploy template: ${err}`);
         setCurrentError(err);
         setCurrentError(err);
       });
       });
   };
   };
 
 
-  renderCurrentPage = () => {
-    let { form, currentTab } = this.props;
-    let {
-      currentPage,
-      valuesToOverride,
-      templateName,
-      imageUrl,
-      imageTag,
-      actionConfig,
-      branch,
-      repoType,
-      dockerfilePath,
-      procfileProcess,
-      procfilePath,
-      folderPath,
-      selectedNamespace,
-      selectedRegistry,
-      saveValuesStatus,
-      sourceType,
-    } = this.state;
+  const renderCurrentPage = () => {
+    let { form, currentTab } = props;
 
 
     if (currentPage === "source" && currentTab === "porter") {
     if (currentPage === "source" && currentTab === "porter") {
       return (
       return (
         <SourcePage
         <SourcePage
           sourceType={sourceType}
           sourceType={sourceType}
-          setSourceType={(x: string) => this.setState({ sourceType: x })}
+          setSourceType={setSourceType}
           templateName={templateName}
           templateName={templateName}
-          setPage={(x: string) => {
-            this.setState({ currentPage: x });
-          }}
-          setTemplateName={(x: string) => this.setState({ templateName: x })}
-          setValuesToOverride={(x: any) =>
-            this.setState({ valuesToOverride: x })
-          }
+          setPage={setCurrentPage}
+          setTemplateName={setTemplateName}
+          setValuesToOverride={setValuesToOverride}
           imageUrl={imageUrl}
           imageUrl={imageUrl}
-          setImageUrl={(x: string) => this.setState({ imageUrl: x })}
+          setImageUrl={setImageUrl}
           imageTag={imageTag}
           imageTag={imageTag}
-          setImageTag={(x: string) => this.setState({ imageTag: x })}
+          setImageTag={setImageTag}
           actionConfig={actionConfig}
           actionConfig={actionConfig}
-          setActionConfig={(x: ActionConfigType) =>
-            this.setState({ actionConfig: x })
-          }
+          setActionConfig={setActionConfig}
           branch={branch}
           branch={branch}
-          setBranch={(x: string) => this.setState({ branch: x })}
+          setBranch={setBranch}
           procfileProcess={procfileProcess}
           procfileProcess={procfileProcess}
-          setProcfileProcess={(x: string) =>
-            this.setState({ procfileProcess: x })
-          }
+          setProcfileProcess={setProcfileProcess}
           repoType={repoType}
           repoType={repoType}
-          setRepoType={(x: string) => this.setState({ repoType: x })}
+          setRepoType={setRepoType}
           dockerfilePath={dockerfilePath}
           dockerfilePath={dockerfilePath}
-          setDockerfilePath={(x: string) =>
-            this.setState({ dockerfilePath: x })
-          }
+          setDockerfilePath={setDockerfilePath}
           folderPath={folderPath}
           folderPath={folderPath}
-          setFolderPath={(x: string) => this.setState({ folderPath: x })}
+          setFolderPath={setFolderPath}
           procfilePath={procfilePath}
           procfilePath={procfilePath}
-          setProcfilePath={(x: string) => this.setState({ procfilePath: x })}
+          setProcfilePath={setProcfilePath}
           selectedRegistry={selectedRegistry}
           selectedRegistry={selectedRegistry}
-          setSelectedRegistry={(x: string) =>
-            this.setState({ selectedRegistry: x })
-          }
+          setSelectedRegistry={setSelectedRegistry}
+        />
+      );
+    }
+
+    setRandomNameIfEmpty();
+
+    if (currentPage === "workflow" && currentTab === "porter") {
+      const fullActionConfig = getFullActionConfig();
+      return (
+        <WorkflowPage
+          name={templateName}
+          fullActionConfig={fullActionConfig}
+          shouldCreateWorkflow={shouldCreateWorkflow}
+          setShouldCreateWorkflow={setShouldCreateWorkflow}
+          setPage={setCurrentPage}
         />
         />
       );
       );
     }
     }
@@ -409,25 +340,24 @@ class LaunchFlow extends Component<PropsType, StateType> {
     // Display main (non-source) settings page
     // Display main (non-source) settings page
     return (
     return (
       <SettingsPage
       <SettingsPage
-        onSubmit={currentTab === "porter" ? this.onSubmit : this.onSubmitAddon}
+        onSubmit={currentTab === "porter" ? handleSubmit : handleSubmitAddon}
         saveValuesStatus={saveValuesStatus}
         saveValuesStatus={saveValuesStatus}
         selectedNamespace={selectedNamespace}
         selectedNamespace={selectedNamespace}
-        setSelectedNamespace={(x: string) =>
-          this.setState({ selectedNamespace: x })
-        }
+        setSelectedNamespace={setSelectedNamespace}
         templateName={templateName}
         templateName={templateName}
-        setTemplateName={(x: string) => this.setState({ templateName: x })}
+        setTemplateName={setTemplateName}
         hasSource={currentTab === "porter"}
         hasSource={currentTab === "porter"}
-        setPage={(x: string) => this.setState({ currentPage: x })}
+        sourceType={sourceType}
+        setPage={setCurrentPage}
         form={form}
         form={form}
         valuesToOverride={valuesToOverride}
         valuesToOverride={valuesToOverride}
-        clearValuesToOverride={() => this.setState({ valuesToOverride: null })}
+        clearValuesToOverride={() => setValuesToOverride(null)}
       />
       />
     );
     );
   };
   };
 
 
-  renderIcon = () => {
-    let icon = this.props.currentTemplate?.icon;
+  const renderIcon = () => {
+    let icon = props.currentTemplate?.icon;
     if (icon) {
     if (icon) {
       return <Icon src={icon} />;
       return <Icon src={icon} />;
     }
     }
@@ -439,27 +369,24 @@ class LaunchFlow extends Component<PropsType, StateType> {
     );
     );
   };
   };
 
 
-  render() {
-    let { currentTab } = this.props;
-    let { name } = this.props.currentTemplate;
-    if (hardcodedNames[name]) {
-      name = hardcodedNames[name];
-    }
-
-    return (
-      <StyledLaunchFlow>
-        <TitleSection handleNavBack={this.props.hideLaunchFlow}>
-          {this.renderIcon()}
-          New {name} {currentTab === "porter" ? null : "Instance"}
-        </TitleSection>
-        {this.renderCurrentPage()}
-        <Br />
-      </StyledLaunchFlow>
-    );
+  let { currentTab } = props;
+  let currentTemplateName = props.currentTemplate.name;
+  if (hardcodedNames[currentTemplateName]) {
+    currentTemplateName = hardcodedNames[currentTemplateName];
   }
   }
-}
 
 
-LaunchFlow.contextType = Context;
+  return (
+    <StyledLaunchFlow>
+      <TitleSection handleNavBack={props.hideLaunchFlow}>
+        {renderIcon()}
+        New {currentTemplateName} {currentTab === "porter" ? null : "Instance"}
+      </TitleSection>
+      {renderCurrentPage()}
+      <Br />
+    </StyledLaunchFlow>
+  );
+};
+
 export default withRouter(LaunchFlow);
 export default withRouter(LaunchFlow);
 
 
 const Br = styled.div`
 const Br = styled.div`

+ 15 - 8
dashboard/src/main/home/launch/launch-flow/SettingsPage.tsx

@@ -19,6 +19,7 @@ import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 type PropsType = WithAuthProps & {
 type PropsType = WithAuthProps & {
   onSubmit: (x?: any) => void;
   onSubmit: (x?: any) => void;
   hasSource: boolean;
   hasSource: boolean;
+  sourceType: string;
   setPage: (x: string) => void;
   setPage: (x: string) => void;
   form: any;
   form: any;
   valuesToOverride: any;
   valuesToOverride: any;
@@ -182,18 +183,24 @@ class SettingsPage extends Component<PropsType, StateType> {
   };
   };
 
 
   renderHeaderSection = () => {
   renderHeaderSection = () => {
-    let { hasSource, templateName, setTemplateName } = this.props;
+    let {
+      hasSource,
+      sourceType,
+      templateName,
+      setPage,
+      setTemplateName,
+    } = this.props;
 
 
     if (hasSource) {
     if (hasSource) {
+      const [pageKey, pageName] =
+        sourceType === "repo"
+          ? ["workflow", "GitHub Actions"]
+          : ["source", "Source Settings"];
+
       return (
       return (
-        <BackButton
-          width="155px"
-          onClick={() => {
-            this.props.setPage("source");
-          }}
-        >
+        <BackButton width="155px" onClick={() => setPage(pageKey)}>
           <i className="material-icons">first_page</i>
           <i className="material-icons">first_page</i>
-          Source Settings
+          {pageName}
         </BackButton>
         </BackButton>
       );
       );
     }
     }

+ 21 - 5
dashboard/src/main/home/launch/launch-flow/SourcePage.tsx

@@ -28,7 +28,9 @@ type PropsType = RouteComponentProps & {
   setImageTag: (x: string) => void;
   setImageTag: (x: string) => void;
 
 
   actionConfig: ActionConfigType;
   actionConfig: ActionConfigType;
-  setActionConfig: (x: ActionConfigType) => void;
+  setActionConfig: (
+    x: ActionConfigType | ((prevState: ActionConfigType) => ActionConfigType)
+  ) => void;
   procfileProcess: string;
   procfileProcess: string;
   setProcfileProcess: (x: string) => void;
   setProcfileProcess: (x: string) => void;
   branch: string;
   branch: string;
@@ -162,7 +164,10 @@ class SourcePage extends Component<PropsType, StateType> {
           actionConfig={actionConfig}
           actionConfig={actionConfig}
           branch={branch}
           branch={branch}
           setActionConfig={(actionConfig: ActionConfigType) => {
           setActionConfig={(actionConfig: ActionConfigType) => {
-            setActionConfig(actionConfig);
+            setActionConfig((currentActionConfig: ActionConfigType) => ({
+              ...currentActionConfig,
+              ...actionConfig,
+            }));
             setImageUrl(actionConfig.image_repo_uri);
             setImageUrl(actionConfig.image_repo_uri);
             /*
             /*
             setParentState({ actionConfig }, () =>
             setParentState({ actionConfig }, () =>
@@ -173,10 +178,11 @@ class SourcePage extends Component<PropsType, StateType> {
           procfileProcess={procfileProcess}
           procfileProcess={procfileProcess}
           setProcfileProcess={(procfileProcess: string) => {
           setProcfileProcess={(procfileProcess: string) => {
             setProcfileProcess(procfileProcess);
             setProcfileProcess(procfileProcess);
-            setValuesToOverride({
+            setValuesToOverride((v: any) => ({
+              ...v,
               "container.command": procfileProcess || "",
               "container.command": procfileProcess || "",
               showStartCommand: !procfileProcess,
               showStartCommand: !procfileProcess,
-            });
+            }));
           }}
           }}
           setBranch={setBranch}
           setBranch={setBranch}
           setDockerfilePath={setDockerfilePath}
           setDockerfilePath={setDockerfilePath}
@@ -223,6 +229,16 @@ class SourcePage extends Component<PropsType, StateType> {
     }
     }
   };
   };
 
 
+  handleContinue = () => {
+    const { sourceType, setPage } = this.props;
+
+    if (sourceType === "repo") {
+      setPage("workflow");
+    } else {
+      setPage("settings");
+    }
+  };
+
   render() {
   render() {
     let { templateName, setTemplateName, setPage } = this.props;
     let { templateName, setTemplateName, setPage } = this.props;
 
 
@@ -266,7 +282,7 @@ class SourcePage extends Component<PropsType, StateType> {
         <SaveButton
         <SaveButton
           text="Continue"
           text="Continue"
           disabled={!this.checkSourceSelected()}
           disabled={!this.checkSourceSelected()}
-          onClick={() => setPage("settings")}
+          onClick={this.handleContinue}
           status={this.getButtonStatus()}
           status={this.getButtonStatus()}
           makeFlush={true}
           makeFlush={true}
           helper={this.getButtonHelper()}
           helper={this.getButtonHelper()}

+ 184 - 0
dashboard/src/main/home/launch/launch-flow/WorkflowPage.tsx

@@ -0,0 +1,184 @@
+import React, { useContext, useEffect, useState } from "react";
+import { RouteComponentProps } from "react-router";
+import { FullActionConfigType } from "../../../../shared/types";
+import api from "../../../../shared/api";
+import { Context } from "../../../../shared/Context";
+import styled from "styled-components";
+import YamlEditor from "../../../../components/YamlEditor";
+import Loading from "../../../../components/Loading";
+import Helper from "../../../../components/form-components/Helper";
+import CheckboxRow from "../../../../components/form-components/CheckboxRow";
+import SaveButton from "../../../../components/SaveButton";
+
+type PropsType = {
+  name: string;
+  fullActionConfig: FullActionConfigType;
+  shouldCreateWorkflow: boolean;
+  setShouldCreateWorkflow: (x: (prevState: boolean) => boolean) => void;
+  setPage: (x: string) => void;
+};
+
+const WorkflowPage: React.FC<PropsType> = (props) => {
+  const context = useContext(Context);
+
+  const [isLoading, setIsLoading] = useState(true);
+  const [hasError, setHasError] = useState(false);
+  const [workflowYAML, setWorkflowYAML] = useState("");
+
+  useEffect(() => {
+    const { currentCluster, currentProject } = context;
+
+    api
+      .generateGHAWorkflow("<token>", props.fullActionConfig, {
+        name: props.name,
+        cluster_id: currentCluster.id,
+        project_id: currentProject.id,
+      })
+      .then((res) => {
+        setWorkflowYAML(res.data);
+        setIsLoading(false);
+      })
+      .catch((err) => setHasError(true))
+      .finally(() => setIsLoading(false));
+  }, []);
+
+  const renderWorkflow = () => {
+    if (isLoading) {
+      return (
+        <LoadingWrapper>
+          <Loading />
+        </LoadingWrapper>
+      );
+    } else if (hasError) {
+      return (
+        <Placeholder>
+          <i className="material-icons">error</i> Error retrieving workflow.
+        </Placeholder>
+      );
+    }
+    return <YamlEditor value={workflowYAML} readOnly={true} />;
+  };
+
+  const getButtonHelper = () => {
+    if (props.shouldCreateWorkflow) {
+      return "Both secrets and workflow will be created";
+    } else {
+      return "Only secrets will be created";
+    }
+  };
+
+  return (
+    <StyledWorkflowPage>
+      <BackButton width="155px" onClick={() => props.setPage("source")}>
+        <i className="material-icons">first_page</i>
+        Source Settings
+      </BackButton>
+      <Heading>GitHub Actions</Heading>
+      <Helper>
+        To auto-deploy each time you push changes, Porter will write GitHub
+        Secrets and this GitHub Actions workflow to your repository.
+      </Helper>
+      {renderWorkflow()}
+      <CheckboxRow
+        toggle={() => props.setShouldCreateWorkflow((x: boolean) => !x)}
+        checked={props.shouldCreateWorkflow}
+        label="Create workflow file"
+      />
+      <Helper>
+        You may copy the YAML to an existing workflow and uncheck this box to
+        prevent Porter from creating a new workflow file.
+        <GitHubActionLink show={!props.shouldCreateWorkflow}>
+          The GitHub Action can be found at{" "}
+          <a
+            href="https://github.com/porter-dev/porter-update-action"
+            target="_blank"
+          >
+            porter-dev/porter-update-action
+          </a>
+        </GitHubActionLink>
+      </Helper>
+      <Buffer />
+      <SaveButton
+        text="Continue"
+        makeFlush={true}
+        disabled={hasError}
+        onClick={() => props.setPage("settings")}
+        helper={getButtonHelper()}
+      />
+    </StyledWorkflowPage>
+  );
+};
+
+export default WorkflowPage;
+
+const StyledWorkflowPage = styled.div`
+  position: relative;
+  margin-top: -5px;
+`;
+
+const Heading = styled.div<{ isAtTop?: boolean }>`
+  color: white;
+  font-weight: 500;
+  font-size: 16px;
+  margin-bottom: 5px;
+  margin-top: ${(props) => (props.isAtTop ? "10px" : "30px")};
+  display: flex;
+  align-items: center;
+`;
+
+const LoadingWrapper = styled.div`
+  padding: 200px;
+`;
+
+const Placeholder = styled.div`
+  padding: 200px;
+  width: 100%;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  color: #ffffff44;
+  font-size: 14px;
+
+  > i {
+    font-size: 18px;
+    margin-right: 12px;
+  }
+`;
+
+const Buffer = styled.div`
+  width: 100%;
+  height: 35px;
+`;
+
+const BackButton = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  cursor: pointer;
+  font-size: 13px;
+  margin-top: 25px;
+  height: 35px;
+  padding: 5px 13px;
+  padding-right: 15px;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  width: ${(props: { width: string }) => props.width};
+  color: white;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    color: white;
+    font-size: 16px;
+    margin-right: 6px;
+    margin-left: -2px;
+  }
+`;
+
+const GitHubActionLink = styled.p`
+  visibility: ${(props: { show: boolean }) =>
+    props.show ? "visible" : "hidden"};
+`;

+ 63 - 0
dashboard/src/main/home/provisioner/AWSFormSection.tsx

@@ -16,12 +16,14 @@ import Heading from "components/form-components/Heading";
 import SaveButton from "components/SaveButton";
 import SaveButton from "components/SaveButton";
 import CheckboxList from "components/form-components/CheckboxList";
 import CheckboxList from "components/form-components/CheckboxList";
 import { RouteComponentProps, withRouter } from "react-router";
 import { RouteComponentProps, withRouter } from "react-router";
+import Tooltip from "@material-ui/core/Tooltip";
 
 
 type PropsType = RouteComponentProps & {
 type PropsType = RouteComponentProps & {
   setSelectedProvisioner: (x: string | null) => void;
   setSelectedProvisioner: (x: string | null) => void;
   handleError: () => void;
   handleError: () => void;
   projectName: string;
   projectName: string;
   infras: InfraType[];
   infras: InfraType[];
+  highlightCosts?: boolean;
 };
 };
 
 
 type StateType = {
 type StateType = {
@@ -73,6 +75,15 @@ const machineTypeOptions = [
   { value: "t3.2xlarge", label: "t3.2xlarge" },
   { value: "t3.2xlarge", label: "t3.2xlarge" },
 ];
 ];
 
 
+const costMapping: Record<string, number> = {
+  "t2.medium": 35,
+  "t2.xlarge": 135,
+  "t2.2xlarge": 270,
+  "t3.medium": 30,
+  "t3.xlarge": 120,
+  "t3.2xlarge": 240,
+};
+
 // TODO: Consolidate across forms w/ HOC
 // TODO: Consolidate across forms w/ HOC
 class AWSFormSection extends Component<PropsType, StateType> {
 class AWSFormSection extends Component<PropsType, StateType> {
   state = {
   state = {
@@ -393,6 +404,36 @@ class AWSFormSection extends Component<PropsType, StateType> {
             setActiveValue={(x: string) => this.setState({ awsMachineType: x })}
             setActiveValue={(x: string) => this.setState({ awsMachineType: x })}
             label="⚙️ AWS Machine Type"
             label="⚙️ AWS Machine Type"
           />
           />
+          <Helper>
+            Estimated Cost:{" "}
+            <CostHighlight highlight={this.props.highlightCosts}>
+              {`\$${
+                70 + 3 * costMapping[this.state.awsMachineType] + 30
+              }/Month`}
+            </CostHighlight>
+            <Tooltip
+              title={
+                <div
+                  style={{
+                    fontFamily: "Work Sans, sans-serif",
+                    fontSize: "12px",
+                    fontWeight: "normal",
+                    padding: "5px 6px",
+                  }}
+                >
+                  EKS cost: ~$70/month <br />
+                  Machine (x3) cost: ~$
+                  {`${3 * costMapping[this.state.awsMachineType]}`}/month <br />
+                  Networking cost: ~$30/month
+                </div>
+              }
+              placement="top"
+            >
+              <StyledInfoTooltip>
+                <i className="material-icons">help_outline</i>
+              </StyledInfoTooltip>
+            </Tooltip>
+          </Helper>
           <InputRow
           <InputRow
             type="text"
             type="text"
             value={awsAccessId}
             value={awsAccessId}
@@ -551,3 +592,25 @@ const CloseButtonImg = styled.img`
   width: 14px;
   width: 14px;
   margin: 0 auto;
   margin: 0 auto;
 `;
 `;
+
+const CostHighlight = styled.span<{ highlight: boolean }>`
+  background-color: ${(props) => props.highlight && "yellow"};
+`;
+
+const StyledInfoTooltip = styled.div`
+  display: inline-block;
+  position: relative;
+  margin-right: 2px;
+  > i {
+    display: flex;
+    align-items: center;
+    position: absolute;
+    top: -10px;
+    font-size: 10px;
+    color: #858faaaa;
+    cursor: pointer;
+    :hover {
+      color: #aaaabb;
+    }
+  }
+`;

+ 52 - 0
dashboard/src/main/home/provisioner/DOFormSection.tsx

@@ -14,11 +14,14 @@ import Helper from "components/form-components/Helper";
 import Heading from "components/form-components/Heading";
 import Heading from "components/form-components/Heading";
 import SaveButton from "components/SaveButton";
 import SaveButton from "components/SaveButton";
 import CheckboxList from "components/form-components/CheckboxList";
 import CheckboxList from "components/form-components/CheckboxList";
+import InfoTooltip from "../../../components/InfoTooltip";
+import Tooltip from "@material-ui/core/Tooltip";
 
 
 type PropsType = {
 type PropsType = {
   setSelectedProvisioner: (x: string | null) => void;
   setSelectedProvisioner: (x: string | null) => void;
   handleError: () => void;
   handleError: () => void;
   projectName: string;
   projectName: string;
+  highlightCosts?: boolean;
   infras: InfraType[];
   infras: InfraType[];
 };
 };
 
 
@@ -277,6 +280,33 @@ export default class DOFormSection extends Component<PropsType, StateType> {
             </Highlight>
             </Highlight>
             .
             .
           </Helper>
           </Helper>
+          <Helper>
+            Estimated Cost:{" "}
+            <CostHighlight highlight={this.props.highlightCosts}>
+              $90/Month
+            </CostHighlight>
+            <Tooltip
+              title={
+                <div
+                  style={{
+                    fontFamily: "Work Sans, sans-serif",
+                    fontSize: "12px",
+                    fontWeight: "normal",
+                    padding: "5px 6px",
+                  }}
+                >
+                  Cluster cost: ~$10/month <br />
+                  Machine (x3) cost: ~$60/month <br />
+                  Networking cost: ~$20/month
+                </div>
+              }
+              placement="top"
+            >
+              <StyledInfoTooltip>
+                <i className="material-icons">help_outline</i>
+              </StyledInfoTooltip>
+            </Tooltip>
+          </Helper>
           <CheckboxRow
           <CheckboxRow
             isRequired={true}
             isRequired={true}
             checked={this.state.provisionConfirmed}
             checked={this.state.provisionConfirmed}
@@ -388,3 +418,25 @@ const CloseButtonImg = styled.img`
   width: 14px;
   width: 14px;
   margin: 0 auto;
   margin: 0 auto;
 `;
 `;
+
+const CostHighlight = styled.span<{ highlight: boolean }>`
+  background-color: ${(props) => props.highlight && "yellow"};
+`;
+
+const StyledInfoTooltip = styled.div`
+  display: inline-block;
+  position: relative;
+  margin-right: 2px;
+  > i {
+    display: flex;
+    align-items: center;
+    position: absolute;
+    top: -10px;
+    font-size: 10px;
+    color: #858faaaa;
+    cursor: pointer;
+    :hover {
+      color: #aaaabb;
+    }
+  }
+`;

+ 53 - 2
dashboard/src/main/home/provisioner/GCPFormSection.tsx

@@ -17,11 +17,13 @@ import Heading from "components/form-components/Heading";
 import SaveButton from "components/SaveButton";
 import SaveButton from "components/SaveButton";
 import CheckboxList from "components/form-components/CheckboxList";
 import CheckboxList from "components/form-components/CheckboxList";
 import { RouteComponentProps, withRouter } from "react-router";
 import { RouteComponentProps, withRouter } from "react-router";
+import Tooltip from "@material-ui/core/Tooltip";
 
 
 type PropsType = RouteComponentProps & {
 type PropsType = RouteComponentProps & {
   setSelectedProvisioner: (x: string | null) => void;
   setSelectedProvisioner: (x: string | null) => void;
   handleError: () => void;
   handleError: () => void;
   projectName: string;
   projectName: string;
+  highlightCosts?: boolean;
   infras: InfraType[];
   infras: InfraType[];
 };
 };
 
 
@@ -377,8 +379,8 @@ class GCPFormSection extends Component<PropsType, StateType> {
           />
           />
           {this.renderClusterNameSection()}
           {this.renderClusterNameSection()}
           <Helper>
           <Helper>
-            By default, Porter creates a cluster with three e2-medium instances
-            (2vCPUs and 4GB RAM each). Google Cloud will bill you for any
+            By default, Porter creates a cluster with three custom-2-4096
+            instances (2 CPU, 4 GB RAM each). Google Cloud will bill you for any
             provisioned resources. Learn more about GKE pricing
             provisioned resources. Learn more about GKE pricing
             <Highlight
             <Highlight
               href="https://cloud.google.com/kubernetes-engine/pricing"
               href="https://cloud.google.com/kubernetes-engine/pricing"
@@ -388,6 +390,33 @@ class GCPFormSection extends Component<PropsType, StateType> {
             </Highlight>
             </Highlight>
             .
             .
           </Helper>
           </Helper>
+          <Helper>
+            Estimated Cost:{" "}
+            <CostHighlight highlight={this.props.highlightCosts}>
+              $250/Month
+            </CostHighlight>
+            <Tooltip
+              title={
+                <div
+                  style={{
+                    fontFamily: "Work Sans, sans-serif",
+                    fontSize: "12px",
+                    fontWeight: "normal",
+                    padding: "5px 6px",
+                  }}
+                >
+                  GKE cost: ~$70/month <br />
+                  Machine (x3) cost: ~$150/month <br />
+                  Networking cost: ~$30/month
+                </div>
+              }
+              placement="top"
+            >
+              <StyledInfoTooltip>
+                <i className="material-icons">help_outline</i>
+              </StyledInfoTooltip>
+            </Tooltip>
+          </Helper>
           <CheckboxRow
           <CheckboxRow
             isRequired={true}
             isRequired={true}
             checked={this.state.provisionConfirmed}
             checked={this.state.provisionConfirmed}
@@ -503,3 +532,25 @@ const CloseButtonImg = styled.img`
   width: 14px;
   width: 14px;
   margin: 0 auto;
   margin: 0 auto;
 `;
 `;
+
+const CostHighlight = styled.span<{ highlight: boolean }>`
+  background-color: ${(props) => props.highlight && "yellow"};
+`;
+
+const StyledInfoTooltip = styled.div`
+  display: inline-block;
+  position: relative;
+  margin-right: 2px;
+  > i {
+    display: flex;
+    align-items: center;
+    position: absolute;
+    top: -10px;
+    font-size: 10px;
+    color: #858faaaa;
+    cursor: pointer;
+    :hover {
+      color: #aaaabb;
+    }
+  }
+`;

+ 29 - 1
dashboard/src/main/home/provisioner/ProvisionerSettings.tsx

@@ -13,6 +13,7 @@ import SaveButton from "components/SaveButton";
 import ExistingClusterSection from "./ExistingClusterSection";
 import ExistingClusterSection from "./ExistingClusterSection";
 import { RouteComponentProps, withRouter } from "react-router";
 import { RouteComponentProps, withRouter } from "react-router";
 import { pushFiltered } from "shared/routing";
 import { pushFiltered } from "shared/routing";
+import InfoTooltip from "../../../components/InfoTooltip";
 
 
 type PropsType = RouteComponentProps & {
 type PropsType = RouteComponentProps & {
   isInNewProject?: boolean;
   isInNewProject?: boolean;
@@ -23,6 +24,7 @@ type PropsType = RouteComponentProps & {
 
 
 type StateType = {
 type StateType = {
   selectedProvider: string | null;
   selectedProvider: string | null;
+  highlightCosts: boolean;
   infras: InfraType[];
   infras: InfraType[];
 };
 };
 
 
@@ -31,6 +33,7 @@ const providers = ["aws", "gcp", "do"];
 class NewProject extends Component<PropsType, StateType> {
 class NewProject extends Component<PropsType, StateType> {
   state = {
   state = {
     selectedProvider: null as string | null,
     selectedProvider: null as string | null,
+    highlightCosts: true,
     infras: [] as InfraType[],
     infras: [] as InfraType[],
   };
   };
 
 
@@ -95,6 +98,7 @@ class NewProject extends Component<PropsType, StateType> {
             handleError={this.handleError}
             handleError={this.handleError}
             projectName={projectName}
             projectName={projectName}
             infras={infras}
             infras={infras}
+            highlightCosts={this.state.highlightCosts}
             setSelectedProvisioner={(x: string | null) => {
             setSelectedProvisioner={(x: string | null) => {
               this.setState({ selectedProvider: x });
               this.setState({ selectedProvider: x });
             }}
             }}
@@ -108,6 +112,7 @@ class NewProject extends Component<PropsType, StateType> {
             handleError={this.handleError}
             handleError={this.handleError}
             projectName={projectName}
             projectName={projectName}
             infras={infras}
             infras={infras}
+            highlightCosts={this.state.highlightCosts}
             setSelectedProvisioner={(x: string | null) => {
             setSelectedProvisioner={(x: string | null) => {
               this.setState({ selectedProvider: x });
               this.setState({ selectedProvider: x });
             }}
             }}
@@ -121,6 +126,7 @@ class NewProject extends Component<PropsType, StateType> {
             handleError={this.handleError}
             handleError={this.handleError}
             projectName={projectName}
             projectName={projectName}
             infras={infras}
             infras={infras}
+            highlightCosts={this.state.highlightCosts}
             setSelectedProvisioner={(x: string | null) => {
             setSelectedProvisioner={(x: string | null) => {
               this.setState({ selectedProvider: x });
               this.setState({ selectedProvider: x });
             }}
             }}
@@ -214,11 +220,28 @@ class NewProject extends Component<PropsType, StateType> {
                 <Block
                 <Block
                   key={i}
                   key={i}
                   onClick={() => {
                   onClick={() => {
-                    this.setState({ selectedProvider: provider });
+                    this.setState({
+                      selectedProvider: provider,
+                      highlightCosts: false,
+                    });
                   }}
                   }}
                 >
                 >
                   <Icon src={providerInfo.icon} />
                   <Icon src={providerInfo.icon} />
                   <BlockTitle>{providerInfo.label}</BlockTitle>
                   <BlockTitle>{providerInfo.label}</BlockTitle>
+                  <CostSection
+                    onClick={(e) => {
+                      e.stopPropagation();
+                      this.setState({
+                        selectedProvider: provider,
+                        highlightCosts: true,
+                      });
+                    }}
+                  >
+                    {provider == "aws" && "$205/Month"}
+                    {provider == "gcp" && "$250/Month"}
+                    {provider == "do" && "$90/Month"}
+                    <InfoTooltip text={""} />
+                  </CostSection>
                   <BlockDescription>Hosted in your own cloud.</BlockDescription>
                   <BlockDescription>Hosted in your own cloud.</BlockDescription>
                 </Block>
                 </Block>
               );
               );
@@ -335,3 +358,8 @@ const Block = styled.div<{ disabled?: boolean }>`
     }
     }
   }
   }
 `;
 `;
+
+const CostSection = styled.p`
+  position: absolute;
+  left: 0;
+`;

+ 55 - 23
dashboard/src/shared/api.tsx

@@ -1,6 +1,6 @@
 import { baseApi } from "./baseApi";
 import { baseApi } from "./baseApi";
 
 
-import { StorageType } from "./types";
+import { FullActionConfigType, StorageType } from "./types";
 
 
 /**
 /**
  * Generic api call format
  * Generic api call format
@@ -113,27 +113,6 @@ const createGCR = baseApi<
   return `/api/projects/${pathParams.project_id}/provision/gcr`;
   return `/api/projects/${pathParams.project_id}/provision/gcr`;
 });
 });
 
 
-const createGHAction = baseApi<
-  {
-    git_repo: string;
-    git_branch: string;
-    registry_id: number;
-    image_repo_uri: string;
-    dockerfile_path: string;
-    folder_path: string;
-    git_repo_id: number;
-  },
-  {
-    project_id: number;
-    CLUSTER_ID: number;
-    RELEASE_NAME: string;
-    RELEASE_NAMESPACE: string;
-  }
->("POST", (pathParams) => {
-  let { project_id, CLUSTER_ID, RELEASE_NAME, RELEASE_NAMESPACE } = pathParams;
-  return `/api/projects/${project_id}/ci/actions?cluster_id=${CLUSTER_ID}&name=${RELEASE_NAME}&namespace=${RELEASE_NAMESPACE}`;
-});
-
 const createGKE = baseApi<
 const createGKE = baseApi<
   {
   {
     gcp_integration_id: number;
     gcp_integration_id: number;
@@ -267,6 +246,46 @@ const deleteSlackIntegration = baseApi<
   return `/api/projects/${pathParams.project_id}/slack_integrations/${pathParams.slack_integration_id}`;
   return `/api/projects/${pathParams.project_id}/slack_integrations/${pathParams.slack_integration_id}`;
 });
 });
 
 
+const updateNotificationConfig = baseApi<
+  {
+    payload: any;
+    namespace: string;
+    cluster_id: number;
+  },
+  {
+    project_id: number;
+    name: string;
+  }
+>("POST", (pathParams) => {
+  return `/api/projects/${pathParams.project_id}/releases/${pathParams.name}/notifications`;
+});
+
+const getNotificationConfig = baseApi<
+  {
+    namespace: string;
+    cluster_id: number;
+  },
+  {
+    project_id: number;
+    name: string;
+  }
+>("GET", (pathParams) => {
+  return `/api/projects/${pathParams.project_id}/releases/${pathParams.name}/notifications`;
+});
+
+const generateGHAWorkflow = baseApi<
+  FullActionConfigType,
+  {
+    cluster_id: number;
+    project_id: number;
+    name: string;
+  }
+>("POST", (pathParams) => {
+  const { name, cluster_id, project_id } = pathParams;
+
+  return `/api/projects/${project_id}/ci/actions/generate?cluster_id=${cluster_id}&name=${name}`;
+});
+
 const deployTemplate = baseApi<
 const deployTemplate = baseApi<
   {
   {
     templateName: string;
     templateName: string;
@@ -275,6 +294,7 @@ const deployTemplate = baseApi<
     storage: StorageType;
     storage: StorageType;
     namespace: string;
     namespace: string;
     name: string;
     name: string;
+    githubActionConfig?: FullActionConfigType;
   },
   },
   {
   {
     id: number;
     id: number;
@@ -542,6 +562,15 @@ const getJobs = baseApi<
   return `/api/projects/${pathParams.id}/k8s/${pathParams.namespace}/${pathParams.chart}/${pathParams.release_name}/jobs`;
   return `/api/projects/${pathParams.id}/k8s/${pathParams.namespace}/${pathParams.chart}/${pathParams.release_name}/jobs`;
 });
 });
 
 
+const getJobStatus = baseApi<
+  {
+    cluster_id: number;
+  },
+  { name: string; namespace: string; id: number }
+>("GET", (pathParams) => {
+  return `/api/projects/${pathParams.id}/k8s/${pathParams.namespace}/${pathParams.name}/jobs/status`;
+});
+
 const getJobPods = baseApi<
 const getJobPods = baseApi<
   {
   {
     cluster_id: number;
     cluster_id: number;
@@ -1017,7 +1046,6 @@ export default {
   createEmailVerification,
   createEmailVerification,
   createGCPIntegration,
   createGCPIntegration,
   createGCR,
   createGCR,
-  createGHAction,
   createGKE,
   createGKE,
   createInvite,
   createInvite,
   createNamespace,
   createNamespace,
@@ -1034,6 +1062,8 @@ export default {
   deleteProject,
   deleteProject,
   deleteRegistryIntegration,
   deleteRegistryIntegration,
   deleteSlackIntegration,
   deleteSlackIntegration,
+  updateNotificationConfig,
+  getNotificationConfig,
   createSubdomain,
   createSubdomain,
   deployTemplate,
   deployTemplate,
   deployAddon,
   deployAddon,
@@ -1054,6 +1084,7 @@ export default {
   getClusterNodes,
   getClusterNodes,
   getClusterNode,
   getClusterNode,
   getConfigMap,
   getConfigMap,
+  generateGHAWorkflow,
   getGitRepoList,
   getGitRepoList,
   getGitRepos,
   getGitRepos,
   getImageRepos,
   getImageRepos,
@@ -1062,6 +1093,7 @@ export default {
   getIngress,
   getIngress,
   getInvites,
   getInvites,
   getJobs,
   getJobs,
+  getJobStatus,
   getJobPods,
   getJobPods,
   getMatchingPods,
   getMatchingPods,
   getMetrics,
   getMetrics,

+ 7 - 0
dashboard/src/shared/types.tsx

@@ -259,6 +259,13 @@ export interface ActionConfigType {
   git_repo_id: number;
   git_repo_id: number;
 }
 }
 
 
+export interface FullActionConfigType extends ActionConfigType {
+  dockerfile_path: string;
+  folder_path: string;
+  registry_id: number;
+  should_create_workflow: boolean;
+}
+
 export interface CapabilityType {
 export interface CapabilityType {
   github: boolean;
   github: boolean;
   provisioner: boolean;
   provisioner: boolean;

+ 95 - 7
dashboard/webpack.config.js

@@ -1,24 +1,48 @@
 const path = require("path");
 const path = require("path");
 const HtmlWebpackPlugin = require("html-webpack-plugin");
 const HtmlWebpackPlugin = require("html-webpack-plugin");
 const webpack = require("webpack");
 const webpack = require("webpack");
+const ReactRefreshWebpackPlugin = require("@pmmmwh/react-refresh-webpack-plugin");
+
 const dotenv = require("dotenv");
 const dotenv = require("dotenv");
 
 
+const BundleAnalyzerPlugin = require("webpack-bundle-analyzer")
+  .BundleAnalyzerPlugin;
+
+const TerserPlugin = require("terser-webpack-plugin");
+
 module.exports = () => {
 module.exports = () => {
   const env = dotenv.config().parsed;
   const env = dotenv.config().parsed;
   const envKeys = Object.keys(env).reduce((prev, next) => {
   const envKeys = Object.keys(env).reduce((prev, next) => {
     prev[`process.env.${next}`] = JSON.stringify(env[next]);
     prev[`process.env.${next}`] = JSON.stringify(env[next]);
     return prev;
     return prev;
   }, {});
   }, {});
-
-  return {
-    entry: "./src/index.tsx",
+  // Check first the env file and if it's empty, check out the node env of the process.
+  let isDevelopment = env.NODE_ENV !== "production";
+  if (process.env.NODE_ENV !== env.NODE_ENV) {
+    isDevelopment = process.env.NODE_ENV !== "production";
+  }
+  /**
+   * @type {webpack.Configuration}
+   */
+  const config = {
+    entry: ["./src/index.tsx"],
     target: "web",
     target: "web",
-    mode: "development",
+    mode: isDevelopment ? "development" : "production",
     module: {
     module: {
       rules: [
       rules: [
         {
         {
-          test: /\.(ts|tsx)$/,
-          loader: "ts-loader",
+          test: /\.(ts|tsx|mjs|js|jsx)$/,
+          exclude: /node_modules/,
+          use: [
+            {
+              loader: require.resolve("babel-loader"),
+              options: {
+                plugins: [
+                  isDevelopment && require.resolve("react-refresh/babel"),
+                ].filter(Boolean),
+              },
+            },
+          ],
         },
         },
         {
         {
           enforce: "pre",
           enforce: "pre",
@@ -54,8 +78,12 @@ module.exports = () => {
       publicPath: "/",
       publicPath: "/",
     },
     },
     devServer: {
     devServer: {
+      port: env["PORT"],
       historyApiFallback: true,
       historyApiFallback: true,
       disableHostCheck: true,
       disableHostCheck: true,
+      host: "0.0.0.0",
+      port: env.DEV_SERVER_PORT || 8080,
+      hot: true,
     },
     },
     plugins: [
     plugins: [
       new HtmlWebpackPlugin({
       new HtmlWebpackPlugin({
@@ -63,6 +91,66 @@ module.exports = () => {
         segmentKey: `${process.env.SEGMENT_PUBLIC_KEY}`,
         segmentKey: `${process.env.SEGMENT_PUBLIC_KEY}`,
       }),
       }),
       new webpack.DefinePlugin(envKeys),
       new webpack.DefinePlugin(envKeys),
-    ],
+      isDevelopment && new ReactRefreshWebpackPlugin(),
+    ].filter(Boolean),
   };
   };
+
+  if (!isDevelopment) {
+    config.optimization = {
+      minimize: true,
+      minimizer: [
+        new TerserPlugin({
+          test: /\.(ts|tsx|mjs|js|jsx)$/,
+          terserOptions: {
+            parse: {
+              // We want terser to parse ecma 8 code. However, we don't want it
+              // to apply minification steps that turns valid ecma 5 code
+              // into invalid ecma 5 code. This is why the `compress` and `output`
+              ecma: 8,
+            },
+            compress: {
+              ecma: 5,
+              warnings: false,
+              inline: 2,
+            },
+            mangle: {
+              // Find work around for Safari 10+
+              safari10: true,
+            },
+            output: {
+              ecma: 5,
+              comments: false,
+              ascii_only: true,
+            },
+          },
+
+          // Use multi-process parallel running to improve the build speed
+          parallel: true,
+        }),
+      ],
+    };
+  }
+
+  if (env.ENABLE_ANALYZER) {
+    config.plugins.push(new BundleAnalyzerPlugin());
+  }
+  console.log(env);
+  if (env.ENABLE_PROXY) {
+    console.log("WORKED!");
+    if (!env.API_SERVER) {
+      throw new Error(
+        "API_SERVER is not present on .env! Please setup the api server url if you want the proxy to work! API_SERVER example: http://localhost:8080"
+      );
+    }
+    config.devServer.proxy = {
+      "/api": {
+        logLevel: "debug",
+        target: env.API_SERVER, // target host
+        changeOrigin: true, // needed for virtual hosted sites
+        ws: true, // proxy websockets
+      },
+    };
+  }
+
+  return config;
 };
 };

+ 10 - 3
docs/deploy/applications/deploying-from-git-repo.md

@@ -16,7 +16,7 @@ Let's get started!
 > 
 > 
 > Porter will set up CI/CD with [Github Actions](https://github.com/features/actions) to automatically build and deploy new versions of your code. You can learn more about how Porter uses Github Actions [here](https://docs.getporter.dev/docs/auto-deploy-requirements#cicd-with-github-actions).
 > Porter will set up CI/CD with [Github Actions](https://github.com/features/actions) to automatically build and deploy new versions of your code. You can learn more about how Porter uses Github Actions [here](https://docs.getporter.dev/docs/auto-deploy-requirements#cicd-with-github-actions).
 
 
-![Github Actions](https://files.readme.io/0660e91-Screen_Shot_2021-03-17_at_7.20.44_PM.png "Screen Shot 2021-03-17 at 7.20.44 PM.png")
+![Select Repository](https://files.readme.io/0660e91-Screen_Shot_2021-03-17_at_7.20.44_PM.png "Screen Shot 2021-03-17 at 7.20.44 PM.png")
 
 
 3. After returning to the **Launch** tab you will be prompted to select a repository and source folder. Select the root folder of your service (this is usually where you run a start command like `npm start` or `python -m flask run`) and click **Continue**. If you have an existing Dockerfile, you can select it directly instead of using a folder. 
 3. After returning to the **Launch** tab you will be prompted to select a repository and source folder. Select the root folder of your service (this is usually where you run a start command like `npm start` or `python -m flask run`) and click **Continue**. If you have an existing Dockerfile, you can select it directly instead of using a folder. 
 
 
@@ -24,12 +24,19 @@ Let's get started!
 > 
 > 
 > If you specify a folder in your repo to use as source, Porter will autodetect the language runtime and build your application using Cloud Native Buildpacks. For more details refer to our guide on [requirements for auto build](https://docs.getporter.dev/docs/auto-deploy-requirements).
 > If you specify a folder in your repo to use as source, Porter will autodetect the language runtime and build your application using Cloud Native Buildpacks. For more details refer to our guide on [requirements for auto build](https://docs.getporter.dev/docs/auto-deploy-requirements).
 
 
-4. Select "Continue" once your source has been connected. Under **Additional Settings**, you can configure remaining options like your service's port and computing resources. Once you're ready, click the **Deploy** button to launch. You will be redirected to the cluster dashboard where you should see your newly deployed service.
+4. Click **Continue** once your source has been connected. This will take you to the **GitHub Actions** page, where you can see a workflow that will be created in the selected repository for automatically deploying new changes as they are pushed.  
+You can skip the creation of this workflow using the **Create workflow file** toggle, in case you wish to manually add the [`porter-update-action`](https://github.com/porter-dev/porter-update-action) to a different workflow of your choice.  
+You can proceed further by clicking **Continue** after this step.
+
+
+![GitHub Actions page](https://user-images.githubusercontent.com/44864521/129893348-44d63d54-115b-436b-bc41-48c6d8c94dc2.png)
+
+5. Under **Additional Settings**, you can configure remaining options like your service's port and computing resources. Once you're ready, click the **Deploy** button to launch. You will be redirected to the cluster dashboard where you should see your newly deployed service.
 
 
 ![Deployed service](https://files.readme.io/4f731ca-Screen_Shot_2021-03-17_at_7.53.40_PM.png "Screen Shot 2021-03-17 at 7.53.40 PM.png")
 ![Deployed service](https://files.readme.io/4f731ca-Screen_Shot_2021-03-17_at_7.53.40_PM.png "Screen Shot 2021-03-17 at 7.53.40 PM.png")
 
 
 5. The first time your service is being built, your deployment will use a placeholder Docker image until the GitHub Action has completed. You can monitor the status of the generated GitHub Action by checking the **Actions** tab in your linked repository.
 5. The first time your service is being built, your deployment will use a placeholder Docker image until the GitHub Action has completed. You can monitor the status of the generated GitHub Action by checking the **Actions** tab in your linked repository.
 
 
-![Actions tab](https://files.readme.io/ffe7b14-d1046ba-Screen_Shot_2021-02-26_at_11.33.55_AM.png "Screen_Shot_2021-02-26_at_11.33.55_AM.png")
+![Actions tab on GitHub repository](https://files.readme.io/ffe7b14-d1046ba-Screen_Shot_2021-02-26_at_11.33.55_AM.png "Screen_Shot_2021-02-26_at_11.33.55_AM.png")
 
 
 After the GitHub Action has finished running, you can refresh the Porter dashboard. The new version of your service should have been successfully deployed.
 After the GitHub Action has finished running, you can refresh the Porter dashboard. The new version of your service should have been successfully deployed.

+ 53 - 26
docs/developing/setup.md

@@ -1,5 +1,27 @@
+> **Note:** if you run into any issues at all, don't hesitate to reach out on the **#contributing** channel in [Discord](https://discord.gg/GJynMR3KXK)!
+
+### Table of Contents
+
+- [Getting Started](#getting-started)
+  - [Makefile Quickstart](#makefile-quickstart)
+  - [Docker Quickstart](#docker-quickstart)
+    - [Getting PostgreSQL Access](#getting-postgresql-access)
+- [Project and Cluster Setup](#project-and-cluster-setup)
+  - [Setting up a Cluster](#setting-up-a-cluster)
+  - [Minikube on MacOS](#minikube-on-macos)
+- [Setup for WSL](#setup-for-wsl)
+- [Secure Localhost Setup](#secure-localhost-setup)
+
 # Getting Started
 # Getting Started
 
 
+## Makefile Quickstart
+
+> Prequisites: [Go 1.15+](https://golang.org/doc/install) installed and [Node.js/npm](https://nodejs.org/en/download/) installed.
+
+If working under a bash environment, the easiest way to get started is by running `make start-dev`. This will verify that `go`, `npm` and `node` are found in your path, and will start a development server on `localhost:8081` with live reloading set up for both the backend and frontend. After the services are running successfully, go to [project and cluster setup](#project-and-cluster-setup) to complete the set up. 
+
+## Docker Quickstart
+
 After forking and cloning the repo, you should save two `.env` files in the repo.
 After forking and cloning the repo, you should save two `.env` files in the repo.
 
 
 First, in `/dashboard/.env`:
 First, in `/dashboard/.env`:
@@ -22,39 +44,41 @@ DB_NAME=porter
 SQL_LITE=false
 SQL_LITE=false
 ```
 ```
 
 
-Once you've done this, go to the root repository, and run `docker-compose -f docker-compose.dev.yaml up`. You should see postgres, webpack, and porter containers spin up. When the webpack and porter containers have finished compiling and have spun up successfully (this will take 5-10 minutes after the containers start), you can navigate to `localhost:8080` and you should be greeted with the "Log In" screen. Create a user by entering an email/password on the "Register" screen. 
+Once you've done this, go to the root repository, and run `docker-compose -f docker-compose.dev.yaml up`. You should see postgres, webpack, and porter containers spin up. When the webpack and porter containers have finished compiling and have spun up successfully (this will take 5-10 minutes after the containers start), .
 
 
-At this point, you can make a change to any `.go` file to trigger a backend rebuild, and any file in `/dashboard/src` to trigger a hot reload. 
+At this point, you can make a change to any `.go` file to trigger a backend rebuild, and any file in `/dashboard/src` to trigger a hot reload.
 
 
-## Getting PostgreSQL Access
+### Getting PostgreSQL Access
 
 
-You can get `psql` access by running the following:
+The `docker-compose` command automatically starts a PostgreSQL instance on port 5400. You can get `psql` access by running the following:
 
 
 `psql --host localhost --port 5400 --username porter --dbname porter -W`
 `psql --host localhost --port 5400 --username porter --dbname porter -W`
 
 
 This will prompt you for a password. Enter `porter`, and you should see the `psql` shell!
 This will prompt you for a password. Enter `porter`, and you should see the `psql` shell!
 
 
-### Setting your email to be verified 
+# Project and Cluster Setup
 
 
-If you are getting blocked out of the dashboard because your email is not verified (fixed in `v0.6.2` of Porter, so make sure you've pulled from `master` recently), you can update your email in the database to `verified":
-
-`UPDATE users SET email_verified='t' WHERE id=1;`
+After the project has spun up, you can navigate to `localhost:8081` (for `make` quickstart) or `localhost:8080` (for `docker-compose` quickstart) and you should be greeted with the "Log In" screen. Create a user by entering an email/password on the "Register" screen. 
 
 
-## Setting up Minikube
+## Setting up a Cluster 
 
 
 These steps will help you get set up with a minikube cluster that can be used for development. Prerequisities:
 These steps will help you get set up with a minikube cluster that can be used for development. Prerequisities:
+
 - `kubectl` installed locally
 - `kubectl` installed locally
 - Development instance of Porter is running
 - Development instance of Porter is running
+- Download the [Porter CLI](https://docs.porter.run/docs/cli-documentation#installation) or build it using `make build-cli`
+
+At the moment, we only have instructions for setting up [Minikube on MacOS](#minikube-on-macos). However, Porter is compatible with most Kubernetes clusters, as long as the server is reachable from your host network. To connect a cluster that is currently accessible via `kubectl`, you can run the following steps:
 
 
-Following the OS-specific steps to get minikube running:
-- [MacOS](#macos)
-- [Linux](#linux)
+1. `porter config set-host http://localhost:8080` (for `docker-compose` quickstart) or `porter config set-host http://localhost:8081` (for `make` quickstart). 
+2. `porter auth login`
+3. `porter connect kubeconfig` 
 
 
 If you now navigate to `http://localhost:8080`, you should see the minikube cluster attached! There will be some limitations:
 If you now navigate to `http://localhost:8080`, you should see the minikube cluster attached! There will be some limitations:
-- **It is not possible to expose a service that you create. Whenever you create a web service, de-select the "Expose to external traffic" option.**
 
 
+- **When you launch a web application, it is not possible to expose a service that you create. Whenever you create a web service, de-select the "Expose to external traffic" option.**
 
 
-### MacOS
+### Minikube on MacOS
 
 
 1. [Install minikube](https://minikube.sigs.k8s.io/docs/start/), and install the `hyperkit` driver. The easiest way to do this is via:
 1. [Install minikube](https://minikube.sigs.k8s.io/docs/start/), and install the `hyperkit` driver. The easiest way to do this is via:
 
 
@@ -82,23 +106,26 @@ porter auth login
 porter connect kubeconfig
 porter connect kubeconfig
 ```
 ```
 
 
-## Setup for WSL
+## Setting your email to be verified
 
 
-Follow the steps to install WSL on Windows here https://docs.microsoft.com/en-us/windows/wsl/install-win10
+If you are getting blocked out of the dashboard because your email is not verified (fixed in `v0.6.2` of Porter, so make sure you've pulled from `master` recently), you can update your email in the database to `verified":
 
 
-### Requirements
+`UPDATE users SET email_verified='t' WHERE id=1;`
 
 
-`sudo apt install xdg-utils` <br/>
-`sudo apt install postgresql`
+# Setup for WSL
 
 
-### Setup Process
+Follow the steps to install WSL on Windows here: https://docs.microsoft.com/en-us/windows/wsl/install-win10
 
 
-Once WSL is installed, head to docker and enable WSL Integration.
-![Docker Enable WSL Integration](https://i.imgur.com/QzMyxQx.png)
+```sh
+sudo apt install xdg-utils
+sudo apt install postgresql
+```
 
 
-Next, continue with the Getting Started Section
+Once WSL is installed, head to Docker Desktop and enable WSL Integration.
+
+![Docker Enable WSL Integration](https://i.imgur.com/QzMyxQx.png)
 
 
-## Secure Localhost Setup
+# Secure Localhost Setup
 
 
 Sometimes, it may be necessary to serve securely over `https://localhost` (for example, required by Slack integrations). Run the following command from the repository root:
 Sometimes, it may be necessary to serve securely over `https://localhost` (for example, required by Slack integrations). Run the following command from the repository root:
 
 
@@ -119,6 +146,6 @@ If using Chrome, paste the following into the Chrome address bar:
 
 
 > chrome://flags/#allow-insecure-localhost
 > chrome://flags/#allow-insecure-localhost
 
 
-And then Enable the **Allow invalid certificates for resources loaded from localhost** field. 
+And then Enable the **Allow invalid certificates for resources loaded from localhost** field.
 
 
-Finally, run `docker-compose -f docker-compose.dev-secure.yaml up` instead of the standard docker-compose file. 
+Finally, run `docker-compose -f docker-compose.dev-secure.yaml up` instead of the standard docker-compose file.

+ 14 - 10
internal/forms/git_action.go

@@ -7,7 +7,8 @@ import (
 // CreateGitAction represents the accepted values for creating a
 // CreateGitAction represents the accepted values for creating a
 // github action integration
 // github action integration
 type CreateGitAction struct {
 type CreateGitAction struct {
-	ReleaseID      uint   `json:"release_id" form:"required"`
+	Release *models.Release
+
 	GitRepo        string `json:"git_repo" form:"required"`
 	GitRepo        string `json:"git_repo" form:"required"`
 	GitBranch      string `json:"git_branch"`
 	GitBranch      string `json:"git_branch"`
 	ImageRepoURI   string `json:"image_repo_uri" form:"required"`
 	ImageRepoURI   string `json:"image_repo_uri" form:"required"`
@@ -15,12 +16,15 @@ type CreateGitAction struct {
 	FolderPath     string `json:"folder_path"`
 	FolderPath     string `json:"folder_path"`
 	GitRepoID      uint   `json:"git_repo_id" form:"required"`
 	GitRepoID      uint   `json:"git_repo_id" form:"required"`
 	RegistryID     uint   `json:"registry_id"`
 	RegistryID     uint   `json:"registry_id"`
+
+	ShouldCreateWorkflow bool `json:"should_create_workflow"`
+	ShouldGenerateOnly   bool
 }
 }
 
 
 // ToGitActionConfig converts the form to a gorm git action config model
 // ToGitActionConfig converts the form to a gorm git action config model
 func (ca *CreateGitAction) ToGitActionConfig(version string) (*models.GitActionConfig, error) {
 func (ca *CreateGitAction) ToGitActionConfig(version string) (*models.GitActionConfig, error) {
 	return &models.GitActionConfig{
 	return &models.GitActionConfig{
-		ReleaseID:            ca.ReleaseID,
+		ReleaseID:            ca.Release.Model.ID,
 		GitRepo:              ca.GitRepo,
 		GitRepo:              ca.GitRepo,
 		GitBranch:            ca.GitBranch,
 		GitBranch:            ca.GitBranch,
 		ImageRepoURI:         ca.ImageRepoURI,
 		ImageRepoURI:         ca.ImageRepoURI,
@@ -33,12 +37,12 @@ func (ca *CreateGitAction) ToGitActionConfig(version string) (*models.GitActionC
 }
 }
 
 
 type CreateGitActionOptional struct {
 type CreateGitActionOptional struct {
-	GitRepo        string            `json:"git_repo"`
-	GitBranch      string            `json:"git_branch"`
-	ImageRepoURI   string            `json:"image_repo_uri"`
-	DockerfilePath string            `json:"dockerfile_path"`
-	FolderPath     string            `json:"folder_path"`
-	GitRepoID      uint              `json:"git_repo_id"`
-	BuildEnv       map[string]string `json:"env"`
-	RegistryID     uint              `json:"registry_id"`
+	GitRepo              string `json:"git_repo"`
+	GitBranch            string `json:"branch"`
+	ImageRepoURI         string `json:"image_repo_uri"`
+	DockerfilePath       string `json:"dockerfile_path"`
+	FolderPath           string `json:"folder_path"`
+	GitRepoID            uint   `json:"git_repo_id"`
+	RegistryID           uint   `json:"registry_id"`
+	ShouldCreateWorkflow bool   `json:"should_create_workflow"`
 }
 }

+ 1 - 1
internal/forms/release.go

@@ -128,7 +128,7 @@ type InstallChartTemplateForm struct {
 	*ChartTemplateForm
 	*ChartTemplateForm
 
 
 	// optional git action config
 	// optional git action config
-	GithubActionConfig *CreateGitActionOptional `json:"github_action,omitempty"`
+	GithubActionConfig *CreateGitActionOptional `json:"githubActionConfig,omitempty"`
 }
 }
 
 
 // UpdateImageForm represents the accepted values for updating a Helm release's image
 // UpdateImageForm represents the accepted values for updating a Helm release's image

+ 22 - 10
internal/integrations/ci/actions/actions.go

@@ -45,13 +45,16 @@ type GithubActions struct {
 
 
 	defaultBranch string
 	defaultBranch string
 	Version       string
 	Version       string
+
+	ShouldGenerateOnly   bool
+	ShouldCreateWorkflow bool
 }
 }
 
 
-func (g *GithubActions) Setup() (string, error) {
+func (g *GithubActions) Setup() ([]byte, error) {
 	client, err := g.getClient()
 	client, err := g.getClient()
 
 
 	if err != nil {
 	if err != nil {
-		return "", err
+		return nil, err
 	}
 	}
 
 
 	// get the repository to find the default branch
 	// get the repository to find the default branch
@@ -62,23 +65,32 @@ func (g *GithubActions) Setup() (string, error) {
 	)
 	)
 
 
 	if err != nil {
 	if err != nil {
-		return "", err
+		return nil, err
 	}
 	}
 
 
 	g.defaultBranch = repo.GetDefaultBranch()
 	g.defaultBranch = repo.GetDefaultBranch()
 
 
-	// create porter token secret
-	if err := g.createGithubSecret(client, g.getPorterTokenSecretName(), g.PorterToken); err != nil {
-		return "", err
+	if !g.ShouldGenerateOnly {
+		// create porter token secret
+		if err := g.createGithubSecret(client, g.getPorterTokenSecretName(), g.PorterToken); err != nil {
+			return nil, err
+		}
 	}
 	}
 
 
-	fileBytes, err := g.GetGithubActionYAML()
+	workflowYAML, err := g.GetGithubActionYAML()
 
 
 	if err != nil {
 	if err != nil {
-		return "", err
+		return nil, err
+	}
+
+	if !g.ShouldGenerateOnly && g.ShouldCreateWorkflow {
+		_, err = g.commitGithubFile(client, g.getPorterYMLFileName(), workflowYAML)
+		if err != nil {
+			return workflowYAML, err
+		}
 	}
 	}
 
 
-	return g.commitGithubFile(client, g.getPorterYMLFileName(), fileBytes)
+	return workflowYAML, err
 }
 }
 
 
 func (g *GithubActions) Cleanup() error {
 func (g *GithubActions) Cleanup() error {
@@ -161,7 +173,7 @@ func (g *GithubActions) GetGithubActionYAML() ([]byte, error) {
 		branch = g.defaultBranch
 		branch = g.defaultBranch
 	}
 	}
 
 
-	actionYAML := &GithubActionYAML{
+	actionYAML := GithubActionYAML{
 		On: GithubActionYAMLOnPush{
 		On: GithubActionYAMLOnPush{
 			Push: GithubActionYAMLOnPushBranches{
 			Push: GithubActionYAMLOnPushBranches{
 				Branches: []string{
 				Branches: []string{

+ 16 - 1
internal/integrations/slack/notifier.go

@@ -4,6 +4,7 @@ import (
 	"bytes"
 	"bytes"
 	"encoding/json"
 	"encoding/json"
 	"fmt"
 	"fmt"
+	"github.com/porter-dev/porter/internal/models"
 	"net/http"
 	"net/http"
 	"strings"
 	"strings"
 	"time"
 	"time"
@@ -52,11 +53,13 @@ type NotifyOpts struct {
 
 
 type SlackNotifier struct {
 type SlackNotifier struct {
 	slackInts []*integrations.SlackIntegration
 	slackInts []*integrations.SlackIntegration
+	Config    *models.NotificationConfigExternal
 }
 }
 
 
-func NewSlackNotifier(slackInts ...*integrations.SlackIntegration) Notifier {
+func NewSlackNotifier(conf *models.NotificationConfigExternal, slackInts ...*integrations.SlackIntegration) Notifier {
 	return &SlackNotifier{
 	return &SlackNotifier{
 		slackInts: slackInts,
 		slackInts: slackInts,
+		Config:    conf,
 	}
 	}
 }
 }
 
 
@@ -75,6 +78,18 @@ type SlackText struct {
 }
 }
 
 
 func (s *SlackNotifier) Notify(opts *NotifyOpts) error {
 func (s *SlackNotifier) Notify(opts *NotifyOpts) error {
+	if s.Config != nil {
+		if !s.Config.Enabled {
+			return nil
+		}
+		if opts.Status == StatusDeployed && !s.Config.Success {
+			return nil
+		}
+		if opts.Status == StatusFailed && !s.Config.Failure {
+			return nil
+		}
+	}
+
 	blocks := []*SlackBlock{
 	blocks := []*SlackBlock{
 		getMessageBlock(opts),
 		getMessageBlock(opts),
 		getDividerBlock(),
 		getDividerBlock(),

+ 26 - 0
internal/models/notification.go

@@ -0,0 +1,26 @@
+package models
+
+import "gorm.io/gorm"
+
+type NotificationConfig struct {
+	gorm.Model
+
+	Enabled bool // if notifications are enabled at all
+
+	Success bool
+	Failure bool
+}
+
+type NotificationConfigExternal struct {
+	Enabled bool `json:"enabled"`
+	Success bool `json:"success"`
+	Failure bool `json:"failure"`
+}
+
+func (conf *NotificationConfig) Externalize() *NotificationConfigExternal {
+	return &NotificationConfigExternal{
+		Enabled: conf.Enabled,
+		Success: conf.Success,
+		Failure: conf.Failure,
+	}
+}

+ 2 - 1
internal/models/release.go

@@ -20,7 +20,8 @@ type Release struct {
 	// but this should be used for the source of truth going forward.
 	// but this should be used for the source of truth going forward.
 	ImageRepoURI string `json:"image_repo_uri,omitempty"`
 	ImageRepoURI string `json:"image_repo_uri,omitempty"`
 
 
-	GitActionConfig GitActionConfig `json:"git_action_config"`
+	GitActionConfig    GitActionConfig `json:"git_action_config"`
+	NotificationConfig uint
 }
 }
 
 
 // ReleaseExternal represents the Release type that is sent over REST
 // ReleaseExternal represents the Release type that is sent over REST

+ 1 - 0
internal/repository/gorm/migrate.go

@@ -26,6 +26,7 @@ func AutoMigrate(db *gorm.DB) error {
 		&models.AuthCode{},
 		&models.AuthCode{},
 		&models.DNSRecord{},
 		&models.DNSRecord{},
 		&models.PWResetToken{},
 		&models.PWResetToken{},
+		&models.NotificationConfig{},
 		&ints.KubeIntegration{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},
 		&ints.OIDCIntegration{},

+ 44 - 0
internal/repository/gorm/notification.go

@@ -0,0 +1,44 @@
+package gorm
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+type NotificationConfigRepository struct {
+	db *gorm.DB
+}
+
+// NewNotificationConfigRepository creates a new NotificationConfigRepository
+func NewNotificationConfigRepository(db *gorm.DB) repository.NotificationConfigRepository {
+	return NotificationConfigRepository{db: db}
+}
+
+// CreateNotificationConfig creates a new NotificationConfig
+func (repo NotificationConfigRepository) CreateNotificationConfig(am *models.NotificationConfig) (*models.NotificationConfig, error) {
+	if err := repo.db.Create(am).Error; err != nil {
+		return nil, err
+	}
+	return am, nil
+}
+
+// ReadNotificationConfig reads a NotificationConfig by Id
+func (repo NotificationConfigRepository) ReadNotificationConfig(id uint) (*models.NotificationConfig, error) {
+	ret := &models.NotificationConfig{}
+
+	if err := repo.db.Where("id = ?", id).First(&ret).Error; err != nil {
+		return nil, err
+	}
+
+	return ret, nil
+}
+
+// UpdateNotificationConfig updates a given NotificationConfig
+func (repo NotificationConfigRepository) UpdateNotificationConfig(am *models.NotificationConfig) (*models.NotificationConfig, error) {
+	if err := repo.db.Save(am).Error; err != nil {
+		return nil, err
+	}
+
+	return am, nil
+}

+ 1 - 0
internal/repository/gorm/repository.go

@@ -32,5 +32,6 @@ func NewRepository(db *gorm.DB, key *[32]byte) *repository.Repository {
 		GithubAppInstallation:     NewGithubAppInstallationRepository(db),
 		GithubAppInstallation:     NewGithubAppInstallationRepository(db),
 		GithubAppOAuthIntegration: NewGithubAppOAuthIntegrationRepository(db),
 		GithubAppOAuthIntegration: NewGithubAppOAuthIntegrationRepository(db),
 		SlackIntegration:          NewSlackIntegrationRepository(db, key),
 		SlackIntegration:          NewSlackIntegrationRepository(db, key),
+		NotificationConfig:        NewNotificationConfigRepository(db),
 	}
 	}
 }
 }

+ 11 - 0
internal/repository/notification.go

@@ -0,0 +1,11 @@
+package repository
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type NotificationConfigRepository interface {
+	CreateNotificationConfig(am *models.NotificationConfig) (*models.NotificationConfig, error)
+	ReadNotificationConfig(id uint) (*models.NotificationConfig, error)
+	UpdateNotificationConfig(am *models.NotificationConfig) (*models.NotificationConfig, error)
+}

+ 1 - 0
internal/repository/repository.go

@@ -25,4 +25,5 @@ type Repository struct {
 	GithubAppInstallation     GithubAppInstallationRepository
 	GithubAppInstallation     GithubAppInstallationRepository
 	GithubAppOAuthIntegration GithubAppOAuthIntegrationRepository
 	GithubAppOAuthIntegration GithubAppOAuthIntegrationRepository
 	SlackIntegration          SlackIntegrationRepository
 	SlackIntegration          SlackIntegrationRepository
+	NotificationConfig        NotificationConfigRepository
 }
 }

+ 56 - 0
scripts/dev-environment/CreateDefaultEnvFiles.sh

@@ -0,0 +1,56 @@
+#!/bin/bash
+
+create-backend-env-file() {
+  FILE=./docker/.env
+cat > $FILE <<- EOM
+SERVER_URL=http://localhost:8080
+SERVER_PORT=8080 # Be sure that doesn't colision with the frontend port
+SQL_LITE=true
+SQL_LITE_PATH=./porter.db
+
+# Disable redis by default on non docker environment, if you want to setup redis you can complete the variables commented down below
+REDIS_ENABLED=false
+# REDIS_HOST=redis
+# REDIS_PORT=6379
+# REDIS_USER=foo
+# REDIS_PASS=bar
+# REDIS_DB=0
+
+# If you don't wanna use SQL lite you should fill this data with the postgres connection details
+# DB_HOST=localhost 
+# DB_PORT=5400
+# DB_USER=porter
+# DB_PASS=porter
+# DB_NAME=porter
+
+EOM
+}
+
+create-frontend-env-file() {
+  FILE=./dashboard/.env
+cat > $FILE <<- EOM
+NODE_ENV=development
+
+# Tell the webpack dev server in wich port we wanna run, it defaults to 8080 but we have to be carefull this is not the same port as the backend
+DEV_SERVER_PORT=8081
+
+# Usually we would use nginx, but for this environment we're going to enable webpack-dev-server proxy 
+ENABLE_PROXY=true 
+
+# API server url, this url will be used for the proxy to redirect all /api calls
+API_SERVER=http://localhost:8080 
+EOM
+}
+
+if [[ ! -e ./dashboard/.env ]]
+then
+  echo "Dashboard env file (./dashboard/.env) not found, creating one with defaults"
+  create-frontend-env-file
+fi
+
+if [[ ! -e ./docker/.env ]]
+then
+  echo "Server env file (./docker/.env) not found, creating one with defaults"
+  create-backend-env-file
+fi
+

+ 38 - 0
scripts/dev-environment/SetupEnvironment.sh

@@ -0,0 +1,38 @@
+#!/bin/bash
+
+# Setup all the environment requirements.
+
+REQUIRED_APPLICATIONS=('node' 'go' 'npm')
+for i in "${REQUIRED_APPLICATIONS[@]}"; do
+  if ! command -v $i &> /dev/null
+  then
+    echo "${i} could not be found, please install to be able to execute dev environment"
+    exit
+  fi
+done
+
+
+if ! command -v air &> /dev/null
+then 
+  printf "\n"
+  read -p "cosmtrek/air is required to continue, do you want to install it? y/N: " -n 1 -r
+  if [[ $REPLY =~ ^[Yy]$ ]]
+  then
+    echo "Yes"
+    curl -sSfL https://raw.githubusercontent.com/cosmtrek/air/master/install.sh | sh -s -- -b $(go env GOPATH)/bin
+    printf "\nInstalled Air\n"
+    air -v
+  else 
+    printf "\nCanceled script, exiting program\n"
+    exit
+  fi
+fi
+
+
+if [[ ! -d ./dashboard/node_modules ]]; then 
+  echo "Couldn't find node_modules, installing npm packages"
+  cd ./dashboard && npm install;
+  cd ../;  
+else
+  echo "Node modules found! Proceeding to start server"
+fi

+ 33 - 0
scripts/dev-environment/StartDevServer.sh

@@ -0,0 +1,33 @@
+#!/bin/bash
+
+printf "\033c"
+
+startFrontend() {
+  cd ./dashboard && npm start;
+}
+
+startBackend() {
+  # Load env variables for backend
+  if [[ -e ./docker/.env ]]
+  then
+    set -a # automatically export all variables
+    source ./docker/.env
+    set +a
+  else 
+    echo "Couldn't find any backend env variables, exiting process"
+    exit
+  fi
+  air -c .air.toml
+}
+
+startBackend &
+startFrontend &
+wait
+
+cleanup() {
+    rv=$?
+    clear
+    exit $rv
+}
+
+trap "cleanup" EXIT

+ 13 - 2
server/api/deploy_handler.go

@@ -50,6 +50,13 @@ func (app *App) HandleDeployTemplate(w http.ResponseWriter, r *http.Request) {
 		return
 		return
 	}
 	}
 
 
+	clusterID, err := strconv.ParseUint(vals["cluster_id"][0], 10, 64)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
 	getChartForm.PopulateRepoURLFromQueryParams(vals)
 	getChartForm.PopulateRepoURLFromQueryParams(vals)
 
 
 	chart, err := loader.LoadChartPublic(getChartForm.RepoURL, getChartForm.Name, getChartForm.Version)
 	chart, err := loader.LoadChartPublic(getChartForm.RepoURL, getChartForm.Name, getChartForm.Version)
@@ -161,13 +168,17 @@ func (app *App) HandleDeployTemplate(w http.ResponseWriter, r *http.Request) {
 	// if github action config is linked, call the github action config handler
 	// if github action config is linked, call the github action config handler
 	if form.GithubActionConfig != nil {
 	if form.GithubActionConfig != nil {
 		gaForm := &forms.CreateGitAction{
 		gaForm := &forms.CreateGitAction{
-			ReleaseID:      release.ID,
+			Release: release,
+
 			GitRepo:        form.GithubActionConfig.GitRepo,
 			GitRepo:        form.GithubActionConfig.GitRepo,
 			GitBranch:      form.GithubActionConfig.GitBranch,
 			GitBranch:      form.GithubActionConfig.GitBranch,
 			ImageRepoURI:   form.GithubActionConfig.ImageRepoURI,
 			ImageRepoURI:   form.GithubActionConfig.ImageRepoURI,
 			DockerfilePath: form.GithubActionConfig.DockerfilePath,
 			DockerfilePath: form.GithubActionConfig.DockerfilePath,
 			GitRepoID:      form.GithubActionConfig.GitRepoID,
 			GitRepoID:      form.GithubActionConfig.GitRepoID,
 			RegistryID:     form.GithubActionConfig.RegistryID,
 			RegistryID:     form.GithubActionConfig.RegistryID,
+
+			ShouldGenerateOnly:   false,
+			ShouldCreateWorkflow: form.GithubActionConfig.ShouldCreateWorkflow,
 		}
 		}
 
 
 		// validate the form
 		// validate the form
@@ -176,7 +187,7 @@ func (app *App) HandleDeployTemplate(w http.ResponseWriter, r *http.Request) {
 			return
 			return
 		}
 		}
 
 
-		app.createGitActionFromForm(projID, release, form.ChartTemplateForm.Name, gaForm, w, r)
+		app.createGitActionFromForm(projID, clusterID, form.ChartTemplateForm.Name, gaForm, w, r)
 	}
 	}
 
 
 	w.WriteHeader(http.StatusOK)
 	w.WriteHeader(http.StatusOK)

+ 86 - 34
server/api/git_action_handler.go

@@ -20,6 +20,49 @@ const (
 	updateAppActionVersion = "v0.1.0"
 	updateAppActionVersion = "v0.1.0"
 )
 )
 
 
+// HandleGenerateGitAction returns the Github action that will be created in a repository
+// for a given release
+func (app *App) HandleGenerateGitAction(w http.ResponseWriter, r *http.Request) {
+	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 10, 64)
+
+	if err != nil || projID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+	name := vals["name"][0]
+
+	clusterID, err := strconv.ParseUint(vals["cluster_id"][0], 10, 64)
+
+	if err != nil {
+		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
+			Code:   ErrReleaseReadData,
+			Errors: []string{"release not found"},
+		}, w)
+		return
+	}
+
+	form := &forms.CreateGitAction{
+		ShouldGenerateOnly: true,
+	}
+
+	// decode from JSON to form value
+	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	_, workflowYAML := app.createGitActionFromForm(projID, clusterID, name, form, w, r)
+
+	w.WriteHeader(http.StatusOK)
+
+	if _, err := w.Write(workflowYAML); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}
+
 // HandleCreateGitAction creates a new Github action in a repository for a given
 // HandleCreateGitAction creates a new Github action in a repository for a given
 // release
 // release
 func (app *App) HandleCreateGitAction(w http.ResponseWriter, r *http.Request) {
 func (app *App) HandleCreateGitAction(w http.ResponseWriter, r *http.Request) {
@@ -53,7 +96,8 @@ func (app *App) HandleCreateGitAction(w http.ResponseWriter, r *http.Request) {
 	}
 	}
 
 
 	form := &forms.CreateGitAction{
 	form := &forms.CreateGitAction{
-		ReleaseID: release.Model.ID,
+		Release:            release,
+		ShouldGenerateOnly: false,
 	}
 	}
 
 
 	// decode from JSON to form value
 	// decode from JSON to form value
@@ -62,7 +106,7 @@ func (app *App) HandleCreateGitAction(w http.ResponseWriter, r *http.Request) {
 		return
 		return
 	}
 	}
 
 
-	gaExt := app.createGitActionFromForm(projID, release, name, form, w, r)
+	gaExt, _ := app.createGitActionFromForm(projID, clusterID, name, form, w, r)
 
 
 	w.WriteHeader(http.StatusCreated)
 	w.WriteHeader(http.StatusCreated)
 
 
@@ -73,17 +117,17 @@ func (app *App) HandleCreateGitAction(w http.ResponseWriter, r *http.Request) {
 }
 }
 
 
 func (app *App) createGitActionFromForm(
 func (app *App) createGitActionFromForm(
-	projID uint64,
-	release *models.Release,
+	projID,
+	clusterID uint64,
 	name string,
 	name string,
 	form *forms.CreateGitAction,
 	form *forms.CreateGitAction,
 	w http.ResponseWriter,
 	w http.ResponseWriter,
 	r *http.Request,
 	r *http.Request,
-) *models.GitActionConfigExternal {
+) (gaExt *models.GitActionConfigExternal, workflowYAML []byte) {
 	// validate the form
 	// validate the form
 	if err := app.validator.Struct(form); err != nil {
 	if err := app.validator.Struct(form); err != nil {
 		app.handleErrorFormValidation(err, ErrProjectValidateFields, w)
 		app.handleErrorFormValidation(err, ErrProjectValidateFields, w)
-		return nil
+		return
 	}
 	}
 
 
 	// if the registry was provisioned through Porter, create a repository if necessary
 	// if the registry was provisioned through Porter, create a repository if necessary
@@ -93,7 +137,7 @@ func (app *App) createGitActionFromForm(
 
 
 		if err != nil {
 		if err != nil {
 			app.handleErrorDataRead(err, w)
 			app.handleErrorDataRead(err, w)
-			return nil
+			return
 		}
 		}
 
 
 		_reg := registry.Registry(*reg)
 		_reg := registry.Registry(*reg)
@@ -107,30 +151,22 @@ func (app *App) createGitActionFromForm(
 
 
 		if err != nil {
 		if err != nil {
 			app.handleErrorInternal(err, w)
 			app.handleErrorInternal(err, w)
-			return nil
+			return
 		}
 		}
 	}
 	}
 
 
-	// convert the form to a git action config
-	gitAction, err := form.ToGitActionConfig(updateAppActionVersion)
-
-	if err != nil {
-		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
-		return nil
-	}
-
-	repoSplit := strings.Split(gitAction.GitRepo, "/")
+	repoSplit := strings.Split(form.GitRepo, "/")
 
 
 	if len(repoSplit) != 2 {
 	if len(repoSplit) != 2 {
 		app.handleErrorFormDecoding(fmt.Errorf("invalid formatting of repo name"), ErrProjectDecode, w)
 		app.handleErrorFormDecoding(fmt.Errorf("invalid formatting of repo name"), ErrProjectDecode, w)
-		return nil
+		return
 	}
 	}
 
 
 	session, err := app.Store.Get(r, app.ServerConf.CookieName)
 	session, err := app.Store.Get(r, app.ServerConf.CookieName)
 
 
 	if err != nil {
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		http.Error(w, err.Error(), http.StatusInternalServerError)
-		return nil
+		return
 	}
 	}
 
 
 	userID, _ := session.Values["user_id"].(uint)
 	userID, _ := session.Values["user_id"].(uint)
@@ -142,7 +178,7 @@ func (app *App) createGitActionFromForm(
 			userID = tok.IBy
 			userID = tok.IBy
 		} else if tok == nil || tok.IBy == 0 {
 		} else if tok == nil || tok.IBy == 0 {
 			http.Error(w, "no user id found in request", http.StatusInternalServerError)
 			http.Error(w, "no user id found in request", http.StatusInternalServerError)
-			return nil
+			return
 		}
 		}
 	}
 	}
 
 
@@ -155,7 +191,7 @@ func (app *App) createGitActionFromForm(
 
 
 	if err != nil {
 	if err != nil {
 		app.handleErrorInternal(err, w)
 		app.handleErrorInternal(err, w)
-		return nil
+		return
 	}
 	}
 
 
 	// create the commit in the git repo
 	// create the commit in the git repo
@@ -170,21 +206,35 @@ func (app *App) createGitActionFromForm(
 		Repo:                   *app.Repo,
 		Repo:                   *app.Repo,
 		GithubConf:             app.GithubProjectConf,
 		GithubConf:             app.GithubProjectConf,
 		ProjectID:              uint(projID),
 		ProjectID:              uint(projID),
+		ClusterID:              uint(clusterID),
 		ReleaseName:            name,
 		ReleaseName:            name,
-		GitBranch:              gitAction.GitBranch,
-		DockerFilePath:         gitAction.DockerfilePath,
-		FolderPath:             gitAction.FolderPath,
-		ImageRepoURL:           gitAction.ImageRepoURI,
+		GitBranch:              form.GitBranch,
+		DockerFilePath:         form.DockerfilePath,
+		FolderPath:             form.FolderPath,
+		ImageRepoURL:           form.ImageRepoURI,
 		PorterToken:            encoded,
 		PorterToken:            encoded,
-		ClusterID:              release.ClusterID,
-		Version:                gitAction.Version,
+		Version:                updateAppActionVersion,
+		ShouldGenerateOnly:     form.ShouldGenerateOnly,
+		ShouldCreateWorkflow:   form.ShouldCreateWorkflow,
 	}
 	}
 
 
-	_, err = gaRunner.Setup()
+	workflowYAML, err = gaRunner.Setup()
 
 
 	if err != nil {
 	if err != nil {
 		app.handleErrorInternal(err, w)
 		app.handleErrorInternal(err, w)
-		return nil
+		return
+	}
+
+	if form.Release == nil {
+		return
+	}
+
+	// convert the form to a git action config
+	gitAction, err := form.ToGitActionConfig(gaRunner.Version)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
 	}
 	}
 
 
 	// handle write to the database
 	// handle write to the database
@@ -192,20 +242,22 @@ func (app *App) createGitActionFromForm(
 
 
 	if err != nil {
 	if err != nil {
 		app.handleErrorDataWrite(err, w)
 		app.handleErrorDataWrite(err, w)
-		return nil
+		return
 	}
 	}
 
 
 	app.Logger.Info().Msgf("New git action created: %d", ga.ID)
 	app.Logger.Info().Msgf("New git action created: %d", ga.ID)
 
 
 	// update the release in the db with the image repo uri
 	// update the release in the db with the image repo uri
-	release.ImageRepoURI = gitAction.ImageRepoURI
+	form.Release.ImageRepoURI = gitAction.ImageRepoURI
 
 
-	_, err = app.Repo.Release.UpdateRelease(release)
+	_, err = app.Repo.Release.UpdateRelease(form.Release)
 
 
 	if err != nil {
 	if err != nil {
 		app.handleErrorDataWrite(err, w)
 		app.handleErrorDataWrite(err, w)
-		return nil
+		return
 	}
 	}
 
 
-	return ga.Externalize()
+	gaExt = ga.Externalize()
+
+	return
 }
 }

+ 139 - 0
server/api/notifications_handler.go

@@ -0,0 +1,139 @@
+package api
+
+import (
+	"encoding/json"
+	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
+	"net/http"
+	"net/url"
+	"strconv"
+)
+
+type HandleUpdateNotificationConfigForm struct {
+	Payload struct {
+		Enabled bool `json:"enabled"`
+		Success bool `json:"success"`
+		Failure bool `json:"failure"`
+	} `json:"payload"`
+	Namespace string `json:"namespace"`
+	ClusterID uint   `json:"cluster_id"`
+}
+
+// HandleUpdateNotificationConfig updates notification settings for a given release
+func (app *App) HandleUpdateNotificationConfig(w http.ResponseWriter, r *http.Request) {
+	name := chi.URLParam(r, "name")
+
+	form := &HandleUpdateNotificationConfigForm{}
+
+	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	release, err := app.Repo.Release.ReadRelease(form.ClusterID, name, form.Namespace)
+
+	if err != nil {
+		if err == gorm.ErrRecordNotFound {
+			w.WriteHeader(http.StatusNotFound)
+			return
+		}
+
+		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
+			Code:   ErrReleaseReadData,
+			Errors: []string{"release not found"},
+		}, w)
+	}
+
+	// either create a new notification config or update the current one
+	newConfig := &models.NotificationConfig{
+		Enabled: form.Payload.Enabled,
+		Success: form.Payload.Success,
+		Failure: form.Payload.Failure,
+	}
+
+	if release.NotificationConfig == 0 {
+		newConfig, err = app.Repo.NotificationConfig.CreateNotificationConfig(newConfig)
+
+		if err != nil {
+			app.handleErrorInternal(err, w)
+			return
+		}
+
+		release.NotificationConfig = newConfig.ID
+
+		release, err = app.Repo.Release.UpdateRelease(release)
+
+	} else {
+		newConfig.ID = release.NotificationConfig
+		newConfig, err = app.Repo.NotificationConfig.UpdateNotificationConfig(newConfig)
+	}
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+}
+
+// HandleGetNotificationConfig gets the notification config for a given release
+func (app *App) HandleGetNotificationConfig(w http.ResponseWriter, r *http.Request) {
+	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || projID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+	name := chi.URLParam(r, "name")
+	namespace := vals["namespace"][0]
+
+	clusterID, err := strconv.ParseUint(vals["cluster_id"][0], 10, 64)
+
+	if err != nil {
+		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
+			Code:   ErrReleaseReadData,
+			Errors: []string{"release not found"},
+		}, w)
+		return
+	}
+
+	release, err := app.Repo.Release.ReadRelease(uint(clusterID), name, namespace)
+
+	if err != nil {
+		if err == gorm.ErrRecordNotFound {
+			w.WriteHeader(http.StatusNotFound)
+			return
+		}
+
+		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
+			Code:   ErrReleaseReadData,
+			Errors: []string{"release not found"},
+		}, w)
+		return
+	}
+
+	config := &models.NotificationConfigExternal{
+		Enabled: true,
+		Success: true,
+		Failure: true,
+	}
+
+	if release.NotificationConfig != 0 {
+		notifConfig, err := app.Repo.NotificationConfig.ReadNotificationConfig(release.NotificationConfig)
+
+		if err != nil {
+			app.handleErrorInternal(err, w)
+		}
+
+		config = notifConfig.Externalize()
+	}
+
+	err = json.NewEncoder(w).Encode(config)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+	}
+}

+ 23 - 0
server/api/oauth_slack_handler.go

@@ -129,6 +129,29 @@ func (app *App) HandleListSlackIntegrations(w http.ResponseWriter, r *http.Reque
 	}
 	}
 }
 }
 
 
+// HandleSlackIntegrationExists does 200 if at least one slack integration exists and 404 otherwise
+func (app *App) HandleSlackIntegrationExists(w http.ResponseWriter, r *http.Request) {
+	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || projID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	slackInts, err := app.Repo.SlackIntegration.ListSlackIntegrationsByProjectID(uint(projID))
+
+	if err != nil {
+		app.handleErrorRead(err, ErrProjectDataRead, w)
+		return
+	}
+
+	if len(slackInts) != 0 {
+		w.WriteHeader(http.StatusOK)
+	} else {
+		w.WriteHeader(http.StatusNotFound)
+	}
+}
+
 // HandleDeleteSlackIntegration deletes a slack integration for a project by ID
 // HandleDeleteSlackIntegration deletes a slack integration for a project by ID
 func (app *App) HandleDeleteSlackIntegration(w http.ResponseWriter, r *http.Request) {
 func (app *App) HandleDeleteSlackIntegration(w http.ResponseWriter, r *http.Request) {
 	// check that slack integration belongs to given project
 	// check that slack integration belongs to given project

+ 43 - 15
server/api/release_handler.go

@@ -602,7 +602,8 @@ func (app *App) HandleGetReleaseAllPods(w http.ResponseWriter, r *http.Request)
 }
 }
 
 
 type GetJobStatusResult struct {
 type GetJobStatusResult struct {
-	Status string `json:"status"`
+	Status    string       `json:"status,omitempty"`
+	StartTime *metav1.Time `json:"start_time,omitempty"`
 }
 }
 
 
 // HandleGetJobStatus gets the status for a specific job
 // HandleGetJobStatus gets the status for a specific job
@@ -692,9 +693,7 @@ func (app *App) HandleGetJobStatus(w http.ResponseWriter, r *http.Request) {
 		return
 		return
 	}
 	}
 
 
-	res := &GetJobStatusResult{
-		Status: "succeeded",
-	}
+	res := &GetJobStatusResult{}
 
 
 	// get the most recent job
 	// get the most recent job
 	if len(jobs) > 0 {
 	if len(jobs) > 0 {
@@ -708,6 +707,8 @@ func (app *App) HandleGetJobStatus(w http.ResponseWriter, r *http.Request) {
 			}
 			}
 		}
 		}
 
 
+		res.StartTime = mostRecentJob.Status.StartTime
+
 		// get the status of the most recent job
 		// get the status of the most recent job
 		if mostRecentJob.Status.Succeeded >= 1 {
 		if mostRecentJob.Status.Succeeded >= 1 {
 			res.Status = "succeeded"
 			res.Status = "succeeded"
@@ -1017,8 +1018,27 @@ func (app *App) HandleUpgradeRelease(w http.ResponseWriter, r *http.Request) {
 		conf.Chart = chart
 		conf.Chart = chart
 	}
 	}
 
 
+	rel, upgradeErr := agent.UpgradeRelease(conf, form.Values, app.DOConf)
+
 	slackInts, _ := app.Repo.SlackIntegration.ListSlackIntegrationsByProjectID(uint(projID))
 	slackInts, _ := app.Repo.SlackIntegration.ListSlackIntegrationsByProjectID(uint(projID))
-	notifier := slack.NewSlackNotifier(slackInts...)
+
+	clusterID, err := strconv.ParseUint(vals["cluster_id"][0], 10, 64)
+	release, _ := app.Repo.Release.ReadRelease(uint(clusterID), name, form.Namespace)
+
+	var notifConf *models.NotificationConfigExternal
+	notifConf = nil
+	if release != nil && release.NotificationConfig != 0 {
+		conf, err := app.Repo.NotificationConfig.ReadNotificationConfig(release.NotificationConfig)
+
+		if err != nil {
+			app.handleErrorInternal(err, w)
+			return
+		}
+
+		notifConf = conf.Externalize()
+	}
+
+	notifier := slack.NewSlackNotifier(notifConf, slackInts...)
 
 
 	notifyOpts := &slack.NotifyOpts{
 	notifyOpts := &slack.NotifyOpts{
 		ProjectID:   uint(projID),
 		ProjectID:   uint(projID),
@@ -1035,18 +1055,16 @@ func (app *App) HandleUpgradeRelease(w http.ResponseWriter, r *http.Request) {
 		) + fmt.Sprintf("?project_id=%d", uint(projID)),
 		) + fmt.Sprintf("?project_id=%d", uint(projID)),
 	}
 	}
 
 
-	rel, err := agent.UpgradeRelease(conf, form.Values, app.DOConf)
-
-	if err != nil {
+	if upgradeErr != nil {
 		notifyOpts.Status = slack.StatusFailed
 		notifyOpts.Status = slack.StatusFailed
-		notifyOpts.Info = err.Error()
+		notifyOpts.Info = upgradeErr.Error()
 
 
 		slackErr := notifier.Notify(notifyOpts)
 		slackErr := notifier.Notify(notifyOpts)
 		fmt.Println("SLACK ERROR IS", slackErr)
 		fmt.Println("SLACK ERROR IS", slackErr)
 
 
 		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
 		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
 			Code:   ErrReleaseDeploy,
 			Code:   ErrReleaseDeploy,
-			Errors: []string{err.Error()},
+			Errors: []string{upgradeErr.Error()},
 		}, w)
 		}, w)
 
 
 		return
 		return
@@ -1059,8 +1077,6 @@ func (app *App) HandleUpgradeRelease(w http.ResponseWriter, r *http.Request) {
 
 
 	// update the github actions env if the release exists and is built from source
 	// update the github actions env if the release exists and is built from source
 	if cName := rel.Chart.Metadata.Name; cName == "job" || cName == "web" || cName == "worker" {
 	if cName := rel.Chart.Metadata.Name; cName == "job" || cName == "web" || cName == "worker" {
-		clusterID, err := strconv.ParseUint(vals["cluster_id"][0], 10, 64)
-
 		if err != nil {
 		if err != nil {
 			app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
 			app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
 				Code:   ErrReleaseReadData,
 				Code:   ErrReleaseReadData,
@@ -1070,8 +1086,6 @@ func (app *App) HandleUpgradeRelease(w http.ResponseWriter, r *http.Request) {
 			return
 			return
 		}
 		}
 
 
-		release, err := app.Repo.Release.ReadRelease(uint(clusterID), name, rel.Namespace)
-
 		if release != nil {
 		if release != nil {
 			// update image repo uri if changed
 			// update image repo uri if changed
 			repository := rel.Config["image"].(map[string]interface{})["repository"]
 			repository := rel.Config["image"].(map[string]interface{})["repository"]
@@ -1250,7 +1264,21 @@ func (app *App) HandleReleaseDeployWebhook(w http.ResponseWriter, r *http.Reques
 	}
 	}
 
 
 	slackInts, _ := app.Repo.SlackIntegration.ListSlackIntegrationsByProjectID(uint(form.ReleaseForm.Cluster.ProjectID))
 	slackInts, _ := app.Repo.SlackIntegration.ListSlackIntegrationsByProjectID(uint(form.ReleaseForm.Cluster.ProjectID))
-	notifier := slack.NewSlackNotifier(slackInts...)
+
+	var notifConf *models.NotificationConfigExternal
+	notifConf = nil
+	if release != nil && release.NotificationConfig != 0 {
+		conf, err := app.Repo.NotificationConfig.ReadNotificationConfig(release.NotificationConfig)
+
+		if err != nil {
+			app.handleErrorInternal(err, w)
+			return
+		}
+
+		notifConf = conf.Externalize()
+	}
+
+	notifier := slack.NewSlackNotifier(notifConf, slackInts...)
 
 
 	notifyOpts := &slack.NotifyOpts{
 	notifyOpts := &slack.NotifyOpts{
 		ProjectID:   uint(form.ReleaseForm.Cluster.ProjectID),
 		ProjectID:   uint(form.ReleaseForm.Cluster.ProjectID),

+ 46 - 1
server/router/router.go

@@ -394,7 +394,21 @@ func New(a *api.App) *chi.Mux {
 			// /api/projects/{project_id}/ci routes
 			// /api/projects/{project_id}/ci routes
 			r.Method(
 			r.Method(
 				"POST",
 				"POST",
-				"/projects/{project_id}/ci/actions",
+				"/projects/{project_id}/ci/actions/generate",
+				auth.DoesUserHaveProjectAccess(
+					auth.DoesUserHaveClusterAccess(
+						requestlog.NewHandler(a.HandleGenerateGitAction, l),
+						mw.URLParam,
+						mw.QueryParam,
+					),
+					mw.URLParam,
+					mw.WriteAccess,
+				),
+			)
+
+			r.Method(
+				"POST",
+				"/projects/{project_id}/ci/actions/create",
 				auth.DoesUserHaveProjectAccess(
 				auth.DoesUserHaveProjectAccess(
 					auth.DoesUserHaveClusterAccess(
 					auth.DoesUserHaveClusterAccess(
 						requestlog.NewHandler(a.HandleCreateGitAction, l),
 						requestlog.NewHandler(a.HandleCreateGitAction, l),
@@ -886,6 +900,37 @@ func New(a *api.App) *chi.Mux {
 				),
 				),
 			)
 			)
 
 
+			r.Method(
+				"GET",
+				"/projects/{project_id}/slack_integrations/exists",
+				auth.DoesUserHaveProjectAccess(
+					requestlog.NewHandler(a.HandleSlackIntegrationExists, l),
+					mw.URLParam,
+					mw.WriteAccess,
+				),
+			)
+
+			// /projects/{project_id}/releases/{name}/notifications routes
+			r.Method(
+				"POST",
+				"/projects/{project_id}/releases/{name}/notifications",
+				auth.DoesUserHaveProjectAccess(
+					requestlog.NewHandler(a.HandleUpdateNotificationConfig, l),
+					mw.URLParam,
+					mw.WriteAccess,
+				),
+			)
+
+			r.Method(
+				"GET",
+				"/projects/{project_id}/releases/{name}/notifications",
+				auth.DoesUserHaveProjectAccess(
+					requestlog.NewHandler(a.HandleGetNotificationConfig, l),
+					mw.URLParam,
+					mw.WriteAccess,
+				),
+			)
+
 			// /api/projects/{project_id}/helmrepos routes
 			// /api/projects/{project_id}/helmrepos routes
 			r.Method(
 			r.Method(
 				"POST",
 				"POST",

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff