Просмотр исходного кода

Merge pull request #1076 from porter-dev/master

Slack notification toggles + Github actions updates + smaller bundle -> staging
abelanger5 4 лет назад
Родитель
Сommit
82abffdd33
47 измененных файлов с 4027 добавлено и 505 удалено
  1. 3 3
      .air.toml
  2. 3 0
      .gitignore
  3. 14 0
      Makefile
  4. 1 1
      cli/cmd/api/domain.go
  5. 10 9
      cli/cmd/api/github_action.go
  6. 4 0
      dashboard/babel.config.json
  7. 2360 45
      dashboard/package-lock.json
  8. 25 12
      dashboard/package.json
  9. 38 50
      dashboard/src/components/SaveButton.tsx
  10. 0 10
      dashboard/src/components/repo-selector/ActionDetails.tsx
  11. 3 0
      dashboard/src/index.tsx
  12. 100 3
      dashboard/src/main/home/cluster-dashboard/chart/Chart.tsx
  13. 1 0
      dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx
  14. 13 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  15. 71 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx
  16. 170 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/NotificationSettingsSection.tsx
  17. 2 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx
  18. 11 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ValuesYaml.tsx
  19. 169 242
      dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx
  20. 15 8
      dashboard/src/main/home/launch/launch-flow/SettingsPage.tsx
  21. 21 5
      dashboard/src/main/home/launch/launch-flow/SourcePage.tsx
  22. 184 0
      dashboard/src/main/home/launch/launch-flow/WorkflowPage.tsx
  23. 55 23
      dashboard/src/shared/api.tsx
  24. 7 0
      dashboard/src/shared/types.tsx
  25. 95 7
      dashboard/webpack.config.js
  26. 10 3
      docs/deploy/applications/deploying-from-git-repo.md
  27. 26 6
      docs/developing/setup.md
  28. 14 10
      internal/forms/git_action.go
  29. 1 1
      internal/forms/release.go
  30. 22 10
      internal/integrations/ci/actions/actions.go
  31. 16 1
      internal/integrations/slack/notifier.go
  32. 26 0
      internal/models/notification.go
  33. 2 1
      internal/models/release.go
  34. 1 0
      internal/repository/gorm/migrate.go
  35. 44 0
      internal/repository/gorm/notification.go
  36. 1 0
      internal/repository/gorm/repository.go
  37. 11 0
      internal/repository/notification.go
  38. 1 0
      internal/repository/repository.go
  39. 56 0
      scripts/dev-environment/CreateDefaultEnvFiles.sh
  40. 38 0
      scripts/dev-environment/SetupEnvironment.sh
  41. 33 0
      scripts/dev-environment/StartDevServer.sh
  42. 13 2
      server/api/deploy_handler.go
  43. 86 34
      server/api/git_action_handler.go
  44. 139 0
      server/api/notifications_handler.go
  45. 23 0
      server/api/oauth_slack_handler.go
  46. 43 15
      server/api/release_handler.go
  47. 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

+ 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"]
+}

Разница между файлами не показана из-за своего большого размера
+ 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 - 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] || {}}
         />
         />
       );
       );

+ 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";

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

@@ -16,6 +16,7 @@ import TempJobList from "./jobs/TempJobList";
 import SettingsSection from "./SettingsSection";
 import SettingsSection from "./SettingsSection";
 import PorterFormWrapper from "components/porter-form/PorterFormWrapper";
 import PorterFormWrapper from "components/porter-form/PorterFormWrapper";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
+import ValuesYaml from "./ValuesYaml";
 
 
 type PropsType = WithAuthProps & {
 type PropsType = WithAuthProps & {
   namespace: string;
   namespace: string;
@@ -39,6 +40,7 @@ type StateType = {
   deleting: boolean;
   deleting: boolean;
   saveValuesStatus: string | null;
   saveValuesStatus: string | null;
   formData: any;
   formData: any;
+  devOpsMode: boolean;
 };
 };
 
 
 class ExpandedJobChart extends Component<PropsType, StateType> {
 class ExpandedJobChart extends Component<PropsType, StateType> {
@@ -56,6 +58,7 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
     deleting: false,
     deleting: false,
     saveValuesStatus: null as string | null,
     saveValuesStatus: null as string | null,
     formData: {} as any,
     formData: {} as any,
+    devOpsMode: localStorage.getItem("devOpsMode") === "true",
   };
   };
 
 
   // Retrieve full chart data (includes form and values)
   // Retrieve full chart data (includes form and values)
@@ -372,6 +375,12 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
       });
       });
   };
   };
 
 
+  toggleDevOpsMode = () => {
+    this.setState((prevState) => ({
+      devOpsMode: !prevState.devOpsMode,
+    }));
+  };
+
   getJobs = async (chart: ChartType) => {
   getJobs = async (chart: ChartType) => {
     let { currentCluster, currentProject, setCurrentError } = this.context;
     let { currentCluster, currentProject, setCurrentError } = this.context;
 
 
@@ -443,6 +452,14 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
             />
             />
           </TabWrapper>
           </TabWrapper>
         );
         );
+      case "values":
+        return (
+          <ValuesYaml
+            currentChart={this.state.currentChart}
+            refreshChart={() => this.refreshChart(0)}
+            disabled={!this.props.isAuthorized("job", "", ["get", "update"])}
+          />
+        );
       case "settings":
       case "settings":
         return (
         return (
           this.props.isAuthorized("job", "", ["get", "delete"]) && (
           this.props.isAuthorized("job", "", ["get", "delete"]) && (
@@ -478,6 +495,11 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
       });
       });
     }
     }
     let rightTabOptions = [] as any[];
     let rightTabOptions = [] as any[];
+
+    if (this.state.devOpsMode) {
+      rightTabOptions.push({ label: "Helm Values", value: "values" });
+    }
+
     if (this.props.isAuthorized("job", "", ["get", "delete"])) {
     if (this.props.isAuthorized("job", "", ["get", "delete"])) {
       rightTabOptions.push({ label: "Settings", value: "settings" });
       rightTabOptions.push({ label: "Settings", value: "settings" });
     }
     }
@@ -512,6 +534,18 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
     this.setupCronJobWebsocket(currentChart);
     this.setupCronJobWebsocket(currentChart);
   }
   }
 
 
+  componentDidUpdate(
+    prevProps: Readonly<PropsType>,
+    prevState: Readonly<StateType>
+  ) {
+    const { devOpsMode } = this.state;
+
+    if (devOpsMode !== prevState.devOpsMode) {
+      this.updateTabs();
+      localStorage.setItem("devOpsMode", devOpsMode.toString());
+    }
+  }
+
   handleUninstallChart = () => {
   handleUninstallChart = () => {
     let { currentProject, currentCluster, setCurrentOverlay } = this.context;
     let { currentProject, currentCluster, setCurrentOverlay } = this.context;
     let { currentChart } = this.state;
     let { currentChart } = this.state;
@@ -603,6 +637,14 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
                   saveValuesStatus={this.state.saveValuesStatus}
                   saveValuesStatus={this.state.saveValuesStatus}
                   saveButtonText="Save Config"
                   saveButtonText="Save Config"
                   includeHiddenFields
                   includeHiddenFields
+                  addendum={
+                    <TabButton
+                      onClick={this.toggleDevOpsMode}
+                      devOpsMode={this.state.devOpsMode}
+                    >
+                      <i className="material-icons">offline_bolt</i> DevOps Mode
+                    </TabButton>
+                  }
                 />
                 />
               )}
               )}
             </BodyWrapper>
             </BodyWrapper>
@@ -794,3 +836,32 @@ const StyledExpandedChart = styled.div`
     }
     }
   }
   }
 `;
 `;
+
+const TabButton = styled.div`
+  position: absolute;
+  right: 0px;
+  height: 30px;
+  background: linear-gradient(to right, #20222700, #202227 20%);
+  padding-left: 30px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 13px;
+  color: ${(props: { devOpsMode: boolean }) =>
+    props.devOpsMode ? "#aaaabb" : "#aaaabb55"};
+  margin-left: 35px;
+  border-radius: 20px;
+  text-shadow: 0px 0px 8px
+    ${(props: { devOpsMode: boolean }) =>
+      props.devOpsMode ? "#ffffff66" : "none"};
+  cursor: pointer;
+  :hover {
+    color: ${(props: { devOpsMode: boolean }) =>
+      props.devOpsMode ? "" : "#aaaabb99"};
+  }
+
+  > i {
+    font-size: 17px;
+    margin-right: 9px;
+  }
+`;

+ 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;

+ 2 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx

@@ -14,6 +14,7 @@ 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;
@@ -258,6 +259,7 @@ const SettingsSection: React.FC<PropsType> = ({
       {!loadingWebhookToken ? (
       {!loadingWebhookToken ? (
         <StyledSettingsSection showSource={showSource}>
         <StyledSettingsSection showSource={showSource}>
           {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}

+ 11 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/ValuesYaml.tsx

@@ -1,6 +1,7 @@
 import React, { Component } from "react";
 import React, { Component } from "react";
 import styled from "styled-components";
 import styled from "styled-components";
 import yaml from "js-yaml";
 import yaml from "js-yaml";
+import _ from "lodash";
 
 
 import { ChartType, StorageType } from "shared/types";
 import { ChartType, StorageType } from "shared/types";
 import api from "shared/api";
 import api from "shared/api";
@@ -49,13 +50,22 @@ export default class ValuesYaml extends Component<PropsType, StateType> {
     let { currentCluster, setCurrentError, currentProject } = this.context;
     let { currentCluster, setCurrentError, currentProject } = this.context;
     this.setState({ saveValuesStatus: "loading" });
     this.setState({ saveValuesStatus: "loading" });
 
 
+    let valuesString = this.state.values;
+
+    // if this is a job, set it to paused
+    if (this.props.currentChart?.chart?.metadata?.name == "job") {
+      const valuesYAML = yaml.load(this.state.values);
+      _.set(valuesYAML, "paused", true);
+      valuesString = yaml.dump(valuesYAML);
+    }
+
     api
     api
       .upgradeChartValues(
       .upgradeChartValues(
         "<token>",
         "<token>",
         {
         {
           namespace: this.props.currentChart.namespace,
           namespace: this.props.currentChart.namespace,
           storage: StorageType.Secret,
           storage: StorageType.Secret,
-          values: this.state.values,
+          values: valuesString,
         },
         },
         {
         {
           id: currentProject.id,
           id: currentProject.id,

+ 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"};
+`;

+ 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.

+ 26 - 6
docs/developing/setup.md

@@ -22,9 +22,27 @@ 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), 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.
 
 
-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.
+
+## Setup without docker
+
+While docker is an awesome way of getting started as it simulates the real environment that we use on our hosted dashboard, for some people this may bee too much.
+
+In order to decrease the complexity of all the environment, you can just run the development environment locally without docker.
+
+After cloning the repo you should only execute `make start-dev` this will ask you to install the dependencies and also run the server and the frontend in a single console.
+It will also create some env files with default values that will simplify the process!
+
+### Disclaimer
+
+For the command `make start-dev` to work, you should be working under a bash environment (WSL on windows, Linux or MacOS), also you will need go, node and npm installed
+for it to work.
+
+### Disclaimer
+
+This environment is experimental, if you run into any issue don't doubt in contact us through our [discord!](https://discord.gg/GJynMR3KXK)
 
 
 ## Getting PostgreSQL Access
 ## Getting PostgreSQL Access
 
 
@@ -34,7 +52,7 @@ You can get `psql` access by running the following:
 
 
 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 
+### Setting your email to be verified
 
 
 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":
 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":
 
 
@@ -43,16 +61,18 @@ If you are getting blocked out of the dashboard because your email is not verifi
 ## Setting up Minikube
 ## Setting up Minikube
 
 
 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
 
 
 Following the OS-specific steps to get minikube running:
 Following the OS-specific steps to get minikube running:
+
 - [MacOS](#macos)
 - [MacOS](#macos)
 - [Linux](#linux)
 - [Linux](#linux)
 
 
 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.**
 
 
+- **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
 ### MacOS
 
 
@@ -119,6 +139,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",

Некоторые файлы не были показаны из-за большого количества измененных файлов