فهرست منبع

Merge branch 'master' into 0.8.0-provisioning-cost-info

Ivan Galakhov 4 سال پیش
والد
کامیت
5515c9097a
62فایلهای تغییر یافته به همراه4716 افزوده شده و 594 حذف شده
  1. 3 3
      .air.toml
  2. 3 0
      .gitignore
  3. 1 27
      CONTRIBUTING.md
  4. 14 0
      Makefile
  5. 1 1
      cli/cmd/api/domain.go
  6. 10 9
      cli/cmd/api/github_action.go
  7. 4 2
      cli/cmd/docker/agent.go
  8. 4 0
      dashboard/babel.config.json
  9. 2360 45
      dashboard/package-lock.json
  10. 25 12
      dashboard/package.json
  11. 38 50
      dashboard/src/components/SaveButton.tsx
  12. 5 2
      dashboard/src/components/porter-form/PorterFormContextProvider.tsx
  13. 3 0
      dashboard/src/components/porter-form/PorterFormWrapper.tsx
  14. 5 1
      dashboard/src/components/porter-form/field-components/Input.tsx
  15. 0 10
      dashboard/src/components/repo-selector/ActionDetails.tsx
  16. 3 0
      dashboard/src/index.tsx
  17. 100 3
      dashboard/src/main/home/cluster-dashboard/chart/Chart.tsx
  18. 1 0
      dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx
  19. 5 1
      dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx
  20. 539 0
      dashboard/src/main/home/cluster-dashboard/dashboard/Metrics.tsx
  21. 13 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  22. 72 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx
  23. 170 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/NotificationSettingsSection.tsx
  24. 2 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx
  25. 11 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ValuesYaml.tsx
  26. 18 3
      dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/MetricNormalizer.ts
  27. 2 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/MetricsSection.tsx
  28. 10 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/types.ts
  29. 169 242
      dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx
  30. 15 8
      dashboard/src/main/home/launch/launch-flow/SettingsPage.tsx
  31. 21 5
      dashboard/src/main/home/launch/launch-flow/SourcePage.tsx
  32. 184 0
      dashboard/src/main/home/launch/launch-flow/WorkflowPage.tsx
  33. 57 24
      dashboard/src/shared/api.tsx
  34. 7 0
      dashboard/src/shared/types.tsx
  35. 95 7
      dashboard/webpack.config.js
  36. 10 3
      docs/deploy/applications/deploying-from-git-repo.md
  37. 53 26
      docs/developing/setup.md
  38. 21 9
      docs/guides/running-porter-locally.md
  39. 14 10
      internal/forms/git_action.go
  40. 1 1
      internal/forms/release.go
  41. 22 10
      internal/integrations/ci/actions/actions.go
  42. 16 1
      internal/integrations/slack/notifier.go
  43. 1 1
      internal/kubernetes/config.go
  44. 20 8
      internal/kubernetes/prometheus/metrics.go
  45. 9 5
      internal/models/integrations/gcp.go
  46. 26 0
      internal/models/notification.go
  47. 2 1
      internal/models/release.go
  48. 16 7
      internal/registry/registry.go
  49. 1 0
      internal/repository/gorm/migrate.go
  50. 44 0
      internal/repository/gorm/notification.go
  51. 1 0
      internal/repository/gorm/repository.go
  52. 11 0
      internal/repository/notification.go
  53. 1 0
      internal/repository/repository.go
  54. 56 0
      scripts/dev-environment/CreateDefaultEnvFiles.sh
  55. 38 0
      scripts/dev-environment/SetupEnvironment.sh
  56. 33 0
      scripts/dev-environment/StartDevServer.sh
  57. 13 2
      server/api/deploy_handler.go
  58. 86 34
      server/api/git_action_handler.go
  59. 139 0
      server/api/notifications_handler.go
  60. 23 0
      server/api/oauth_slack_handler.go
  61. 43 15
      server/api/release_handler.go
  62. 46 1
      server/router/router.go

+ 3 - 3
.air.toml

@@ -7,11 +7,11 @@ tmp_dir = "tmp"
 
 [build]
 # 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`.
-bin = "tmp/migrate; tmp/app"
+bin = "tmp/app"
 # Customize binary.
-full_bin = "tmp/migrate; tmp/app"
+full_bin = "tmp/app"
 # Watch these filename extensions.
 include_ext = ["go", "mod", "sum", "html"]
 # Ignore these filename extensions or directories.

+ 3 - 0
.gitignore

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

+ 1 - 27
CONTRIBUTING.md

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

+ 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
 type CreateDNSRecordResponse models.DNSRecordExternal
 
-// CreateGithubAction creates a Github action with basic authentication
+// CreateDNSRecord creates a Github action with basic authentication
 func (c *Client) CreateDNSRecord(
 	ctx context.Context,
 	projectID, clusterID uint,

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

@@ -11,14 +11,15 @@ import (
 // CreateGithubActionRequest represents the accepted fields for creating
 // a Github action
 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
@@ -37,7 +38,7 @@ func (c *Client) CreateGithubAction(
 	req, err := http.NewRequest(
 		"POST",
 		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,
 			projectID,
 			clusterID,

+ 4 - 2
cli/cmd/docker/agent.go

@@ -200,12 +200,14 @@ func (a *Agent) PushImage(image string) error {
 		opts,
 	)
 
+	if out != nil {
+		defer out.Close()
+	}
+
 	if err != nil {
 		return err
 	}
 
-	defer out.Close()
-
 	termFd, isTerm := term.GetFdInfo(os.Stderr)
 
 	return jsonmessage.DisplayJSONMessagesStream(out, os.Stderr, termFd, isTerm, nil)

+ 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,
   "dependencies": {
     "@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/curve": "^1.0.0",
     "@visx/event": "^1.3.0",
@@ -27,6 +19,7 @@
     "axios": "^0.20.0",
     "brace": "^0.11.1",
     "clipboard": "^2.0.8",
+    "core-js": "^3.16.1",
     "d3-array": "^2.11.0",
     "d3-time-format": "^3.0.0",
     "dotenv": "^8.2.0",
@@ -41,24 +34,37 @@
     "react": "^16.13.1",
     "react-ace": "^9.1.3",
     "react-dom": "^16.13.1",
+    "react-error-boundary": "^3.1.3",
     "react-modal": "^3.11.2",
     "react-router-dom": "^5.2.0",
     "react-table": "^7.7.0",
+    "regenerator-runtime": "^0.13.9",
     "semver": "^7.3.5",
-    "styled-components": "^5.2.0",
-    "react-error-boundary": "^3.1.3"
+    "styled-components": "^5.2.0"
   },
   "scripts": {
     "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": {
+    "@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/react": "^9.3.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/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/qs": "^6.9.5",
     "@types/random-words": "^1.1.0",
@@ -70,14 +76,21 @@
     "@types/react-table": "^7.7.1",
     "@types/semver": "^7.3.5",
     "@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",
     "html-webpack-plugin": "^4.5.0",
     "prettier": "2.2.1",
     "qs": "^6.9.4",
+    "react-refresh": "^0.10.0",
     "source-map-loader": "^1.1.0",
+    "terser-webpack-plugin": "^4.2.3",
     "ts-loader": "^8.0.4",
     "typescript": "^4.1.2",
     "webpack": "^4.44.2",
+    "webpack-bundle-analyzer": "^4.4.2",
     "webpack-cli": "^3.3.12",
     "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 loading from "assets/loading.gif";
 
-type PropsType = {
+type Props = {
   text?: string;
   onClick: () => void;
   disabled?: boolean;
@@ -10,6 +10,7 @@ type PropsType = {
   color?: string;
   rounded?: boolean;
   helper?: string | null;
+  saveText?: string | null;
 
   // Makes flush with corner if not within a modal
   makeFlush?: boolean;
@@ -17,82 +18,69 @@ type PropsType = {
   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 (
-          <StatusWrapper position={this.props.statusPosition} successful={true}>
+          <StatusWrapper position={props.statusPosition} successful={true}>
             <i className="material-icons">done</i>
             <StatusTextWrapper>Successfully updated</StatusTextWrapper>
           </StatusWrapper>
         );
-      } else if (this.props.status === "loading") {
+      } else if (props.status === "loading") {
         return (
-          <StatusWrapper
-            position={this.props.statusPosition}
-            successful={false}
-          >
+          <StatusWrapper position={props.statusPosition} successful={false}>
             <LoadingGif src={loading} />
-            <StatusTextWrapper>Updating . . .</StatusTextWrapper>
+            <StatusTextWrapper>
+              {props.saveText || "Updating . . ."}
+            </StatusTextWrapper>
           </StatusWrapper>
         );
-      } else if (this.props.status === "error") {
+      } else if (props.status === "error") {
         return (
-          <StatusWrapper
-            position={this.props.statusPosition}
-            successful={false}
-          >
+          <StatusWrapper position={props.statusPosition} successful={false}>
             <i className="material-icons">error_outline</i>
             <StatusTextWrapper>Could not update</StatusTextWrapper>
           </StatusWrapper>
         );
       } else {
         return (
-          <StatusWrapper
-            position={this.props.statusPosition}
-            successful={false}
-          >
+          <StatusWrapper position={props.statusPosition} successful={false}>
             <i className="material-icons">error_outline</i>
-            <StatusTextWrapper>{this.props.status}</StatusTextWrapper>
+            <StatusTextWrapper>{props.status}</StatusTextWrapper>
           </StatusWrapper>
         );
       }
-    } else if (this.props.helper) {
+    } else if (props.helper) {
       return (
-        <StatusWrapper position={this.props.statusPosition} successful={true}>
-          {this.props.helper}
+        <StatusWrapper position={props.statusPosition} successful={true}>
+          {props.helper}
         </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`
   width: 15px;

+ 5 - 2
dashboard/src/components/porter-form/PorterFormContextProvider.tsx

@@ -5,7 +5,7 @@ import {
   PorterFormData,
   PorterFormState,
   PorterFormValidationInfo,
-  PorterFormVariableList
+  PorterFormVariableList,
 } from "./types";
 import { ShowIf, ShowIfAnd, ShowIfNot, ShowIfOr } from "../../shared/types";
 import { getFinalVariablesForStringInput } from "./field-components/Input";
@@ -20,6 +20,7 @@ interface Props {
   onSubmit: (vars: PorterFormVariableList) => void;
   initialVariables?: PorterFormVariableList;
   overrideVariables?: PorterFormVariableList;
+  includeHiddenFields?: boolean;
   isReadOnly?: boolean;
   doDebug?: boolean;
 }
@@ -402,7 +403,9 @@ export const PorterFormContextProvider: React.FC<Props> = (props) => {
       select: getFinalVariablesForSelect,
     };
 
-    const data = props.rawFormData.includeHiddenFields
+    const data = props.includeHiddenFields
+      ? restructureToNewFields(props.rawFormData)
+      : props.rawFormData.includeHiddenFields
       ? restructureToNewFields(props.rawFormData)
       : formData;
 

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

@@ -19,6 +19,7 @@ type PropsType = {
   saveValuesStatus?: string;
   showStateDebugger?: boolean;
   isLaunch?: boolean;
+  includeHiddenFields?: boolean;
 };
 
 const PorterFormWrapper: React.FunctionComponent<PropsType> = ({
@@ -36,6 +37,7 @@ const PorterFormWrapper: React.FunctionComponent<PropsType> = ({
   saveValuesStatus,
   showStateDebugger,
   isLaunch,
+  includeHiddenFields,
 }) => {
   const hashCode = (s: string) => {
     return s?.split("").reduce(function (a, b) {
@@ -72,6 +74,7 @@ const PorterFormWrapper: React.FunctionComponent<PropsType> = ({
         overrideVariables={valuesToOverride}
         isReadOnly={isReadOnly}
         onSubmit={onSubmit}
+        includeHiddenFields={includeHiddenFields}
       >
         <PorterForm
           showStateDebugger={showStateDebugger}

+ 5 - 1
dashboard/src/components/porter-form/field-components/Input.tsx

@@ -1,7 +1,11 @@
 import React from "react";
 import InputRow from "../../form-components/InputRow";
 import useFormField from "../hooks/useFormField";
-import { GetFinalVariablesFunction, InputField, StringInputFieldState } from "../types";
+import {
+  GetFinalVariablesFunction,
+  InputField,
+  StringInputFieldState,
+} from "../types";
 
 const clipOffUnit = (unit: string, x: string) => {
   if (typeof x === "string" && unit) {

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

@@ -142,16 +142,6 @@ export default class ActionDetails extends Component<PropsType, StateType> {
           />
         )}
         {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 />
 
         <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 ReactDOM from "react-dom";
 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 styled from "styled-components";
+import { useHistory, useLocation, useRouteMatch } from "react-router";
 
 import { ChartType, StorageType } from "shared/types";
 import { Context } from "shared/Context";
 import StatusIndicator from "components/StatusIndicator";
 import { pushFiltered } from "shared/routing";
-import { useHistory, useLocation, useRouteMatch } from "react-router";
+import { useWebsockets } from "shared/hooks/useWebsockets";
 import api from "shared/api";
 
 type Props = {
   chart: ChartType;
   controllers: Record<string, any>;
+  isJob: boolean;
   release: any;
 };
 
+type JobStatusType = {
+  status: "succeeded" | "running" | "failed";
+  start_time: string;
+};
+
 const Chart: React.FunctionComponent<Props> = ({
   chart,
   controllers,
+  isJob,
   release,
 }) => {
   const [expand, setExpand] = useState<boolean>(false);
   const [chartControllers, setChartControllers] = useState<any>([]);
+  const [jobStatus, setJobStatus] = useState<JobStatusType>(null);
   const context = useContext(Context);
   const location = useLocation();
   const history = useHistory();
   const match = useRouteMatch();
 
+  const {
+    newWebsocket,
+    openWebsocket,
+    closeAllWebsockets,
+    closeWebsocket,
+  } = useWebsockets();
+
   const renderIcon = () => {
     if (chart.chart.metadata.icon && chart.chart.metadata.icon !== "") {
       return <Icon src={chart.chart.metadata.icon} />;
@@ -64,6 +80,59 @@ const Chart: React.FunctionComponent<Props> = ({
     getControllerForChart(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 ts = new Date(s);
     const date = ts.toLocaleDateString();
@@ -123,7 +192,18 @@ const Chart: React.FunctionComponent<Props> = ({
         </TagWrapper>
       </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>
   );
 };
@@ -138,7 +218,7 @@ const BottomWrapper = styled.div`
   margin-top: 12px;
 `;
 
-const Version = styled.div`
+const TopRightContainer = styled.div`
   position: absolute;
   top: 12px;
   right: 12px;
@@ -150,6 +230,10 @@ const Dot = styled.div`
   margin-right: 9px;
 `;
 
+const StatusDot = styled.span`
+  margin: 0 9px;
+`;
+
 const InfoWrapper = styled.div`
   display: flex;
   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`
   background: #26282f;
   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}`}
           chart={chart}
           controllers={controllers || {}}
+          isJob={currentView === "jobs"}
           release={releases[chart.name] || {}}
         />
       );

+ 5 - 1
dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx

@@ -10,14 +10,16 @@ import NodeList from "./NodeList";
 import { NamespaceList } from "./NamespaceList";
 import ClusterSettings from "./ClusterSettings";
 import useAuth from "shared/auth/useAuth";
+import Metrics from "./Metrics";
 
-type TabEnum = "nodes" | "settings" | "namespaces";
+type TabEnum = "nodes" | "settings" | "namespaces" | "metrics";
 
 const tabOptions: {
   label: string;
   value: TabEnum;
 }[] = [
   { label: "Nodes", value: "nodes" },
+  { label: "Metrics", value: "metrics" },
   { label: "Namespaces", value: "namespaces" },
   { label: "Settings", value: "settings" },
 ];
@@ -32,6 +34,8 @@ export const Dashboard: React.FunctionComponent = () => {
     switch (currentTab) {
       case "settings":
         return <ClusterSettings />;
+      case "metrics":
+        return <Metrics />;
       case "namespaces":
         return <NamespaceList />;
       case "nodes":

+ 539 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/Metrics.tsx

@@ -0,0 +1,539 @@
+import React, { useContext, useState, useEffect } from "react";
+import { Context } from "../../../../shared/Context";
+import api from "../../../../shared/api";
+import styled from "styled-components";
+import Loading from "../../../../components/Loading";
+import settings from "../../../../assets/settings.svg";
+import TabSelector from "../../../../components/TabSelector";
+import CheckboxRow from "../../../../components/form-components/CheckboxRow";
+import ParentSize from "@visx/responsive/lib/components/ParentSize";
+import AreaChart from "../expanded-chart/metrics/AreaChart";
+import {
+  AvailableMetrics,
+  NormalizedMetricsData,
+} from "../expanded-chart/metrics/types";
+import SelectRow from "../../../../components/form-components/SelectRow";
+import { MetricNormalizer } from "../expanded-chart/metrics/MetricNormalizer";
+import {
+  resolutions,
+  secondsBeforeNow,
+} from "../expanded-chart/metrics/MetricsSection";
+
+const Metrics: React.FC = () => {
+  const { currentProject, currentCluster, setCurrentError } = useContext(
+    Context
+  );
+  const [loading, setLoading] = useState(true);
+  const [detected, setDetected] = useState(false);
+  const [metricsOptions, setMetricsOptions] = useState([]);
+  const [dropdownExpanded, setDropdownExpanded] = useState(false);
+  const [ingressOptions, setIngressOptions] = useState([]);
+  const [selectedIngress, setSelectedIngress] = useState(null);
+  const [selectedRange, setSelectedRange] = useState("1H");
+  const [selectedMetric, setSelectedMetric] = useState("nginx:errors");
+  const [selectedMetricLabel, setSelectedMetricLabel] = useState(
+    "5XX Error Percentage"
+  );
+  const [selectedPercentile, setSelectedPercentile] = useState("0.99");
+  const [data, setData] = useState<NormalizedMetricsData[]>([]);
+  const [showMetricsSettings, setShowMetricsSettings] = useState(false);
+  const [isLoading, setIsLoading] = useState(0);
+  const [hpaData, setHpaData] = useState([]);
+
+  useEffect(() => {
+    if (selectedMetric && selectedRange && selectedIngress) {
+      getMetrics();
+    }
+  }, [selectedMetric, selectedRange, selectedIngress, selectedPercentile]);
+
+  useEffect(() => {
+    Promise.all([
+      api.getCluster(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      ),
+      api.getPrometheusIsInstalled(
+        "<token>",
+        {
+          cluster_id: currentCluster.id,
+        },
+        {
+          id: currentProject.id,
+        }
+      ),
+    ])
+      .then(() => {
+        setDetected(true);
+        setIsLoading((prev) => prev + 1);
+
+        api
+          .getNGINXIngresses(
+            "<token>",
+            {
+              cluster_id: currentCluster.id,
+            },
+            {
+              id: currentProject.id,
+            }
+          )
+          .then((res) => {
+            const ingressOptions = res.data.map((ingress: any) => ({
+              value: ingress,
+              label: ingress.name,
+            }));
+            setIngressOptions(ingressOptions);
+            setSelectedIngress(ingressOptions[0]?.value);
+            setMetricsOptions([
+              ...metricsOptions,
+              {
+                value: "nginx:errors",
+                label: "5XX Error Percentage",
+              },
+              {
+                value: "nginx:latency",
+                label: "Request Latency (s)",
+              },
+              {
+                value: "nginx:latency-histogram",
+                label: "Percentile Response Times (s)",
+              },
+            ]);
+            setLoading(false);
+          })
+          .catch((err) => {
+            setCurrentError(JSON.stringify(err));
+          })
+          .finally(() => {
+            setIsLoading((prev) => prev - 1);
+          });
+      })
+      .catch(() => {
+        setDetected(false);
+        setLoading(false);
+      });
+  }, []);
+
+  const renderMetricsSettings = () => {
+    if (showMetricsSettings) {
+      return (
+        <>
+          <DropdownOverlay onClick={() => setShowMetricsSettings(false)} />
+          <DropdownAlt dropdownWidth="330px" dropdownMaxHeight="300px">
+            <Label>Additional Settings</Label>
+            <SelectRow
+              label="Target Ingress"
+              value={selectedIngress}
+              setActiveValue={(x: any) => setSelectedIngress(x)}
+              options={ingressOptions}
+              width="100%"
+            />
+            {selectedMetric == "nginx:latency-histogram" && (
+              <SelectRow
+                label="Percentile"
+                value={selectedPercentile}
+                setActiveValue={(x) => {
+                  setSelectedPercentile(x);
+                }}
+                options={[
+                  {
+                    label: "99",
+                    value: "0.99",
+                  },
+                  {
+                    label: "95",
+                    value: "0.95",
+                  },
+                  {
+                    label: "50",
+                    value: "0.5",
+                  },
+                ]}
+                width="100%"
+              />
+            )}
+          </DropdownAlt>
+        </>
+      );
+    }
+  };
+
+  const renderDropdown = () => {
+    if (dropdownExpanded) {
+      return (
+        <>
+          <DropdownOverlay onClick={() => setDropdownExpanded(false)} />
+          <Dropdown
+            dropdownWidth="230px"
+            dropdownMaxHeight="200px"
+            onClick={() => setDropdownExpanded(false)}
+          >
+            {renderOptionList()}
+          </Dropdown>
+        </>
+      );
+    }
+  };
+
+  const renderOptionList = () => {
+    return metricsOptions.map(
+      (option: { value: string; label: string }, i: number) => {
+        return (
+          <Option
+            key={i}
+            selected={option.value === selectedMetric}
+            onClick={() => {
+              setSelectedMetric(option.value);
+              setSelectedMetricLabel(option.label);
+            }}
+            lastItem={i === metricsOptions.length - 1}
+          >
+            {option.label}
+          </Option>
+        );
+      }
+    );
+  };
+
+  const getMetrics = async () => {
+    try {
+      let shouldsum = true;
+      let namespace = "default";
+
+      // calculate start and end range
+      const d = new Date();
+      const end = Math.round(d.getTime() / 1000);
+      const start = end - secondsBeforeNow[selectedRange];
+
+      let podNames = [] as string[];
+
+      podNames = [selectedIngress?.name];
+
+      setIsLoading((prev) => prev + 1);
+      setData([]);
+
+      const res = await api.getMetrics(
+        "<token>",
+        {
+          cluster_id: currentCluster.id,
+          metric: selectedMetric,
+          shouldsum: false,
+          kind: "Ingress",
+          namespace: selectedIngress?.namespace || "default",
+          percentile:
+            selectedMetric == "nginx:latency-histogram"
+              ? parseFloat(selectedPercentile)
+              : undefined,
+          startrange: start,
+          endrange: end,
+          resolution: resolutions[selectedRange],
+          pods: podNames,
+          name: selectedIngress?.name,
+        },
+        {
+          id: currentProject.id,
+        }
+      );
+
+      setHpaData([]);
+
+      const metrics = new MetricNormalizer(
+        res.data,
+        selectedMetric as AvailableMetrics
+      );
+
+      // transform the metrics to expected form
+      setData(metrics.getParsedData());
+    } catch (error) {
+      setCurrentError(JSON.stringify(error));
+    } finally {
+      setIsLoading((prev) => prev - 1);
+    }
+  };
+
+  return loading ? (
+    <LoadingWrapper>
+      <Loading />
+    </LoadingWrapper>
+  ) : !detected ? (
+    <p>
+      This message displays when either there's no ingress controller or nginx
+      is not installed
+    </p>
+  ) : (
+    <StyledMetricsSection>
+      <Header>
+        <Flex>
+          <MetricSelector
+            onClick={() => setDropdownExpanded(!dropdownExpanded)}
+          >
+            <MetricsLabel>{selectedMetricLabel}</MetricsLabel>
+            <i className="material-icons">arrow_drop_down</i>
+            {renderDropdown()}
+          </MetricSelector>
+          <Relative>
+            <IconWrapper onClick={() => setShowMetricsSettings(true)}>
+              <SettingsIcon src={settings} />
+            </IconWrapper>
+            {renderMetricsSettings()}
+          </Relative>
+
+          <Highlight color={"#7d7d81"} onClick={getMetrics}>
+            <i className="material-icons">autorenew</i>
+          </Highlight>
+        </Flex>
+        <RangeWrapper>
+          <TabSelector
+            noBuffer={true}
+            options={[
+              { value: "1H", label: "1H" },
+              { value: "6H", label: "6H" },
+              { value: "1D", label: "1D" },
+              { value: "1M", label: "1M" },
+            ]}
+            currentTab={selectedRange}
+            setCurrentTab={(x: string) => setSelectedRange(x)}
+          />
+        </RangeWrapper>
+      </Header>
+      {isLoading > 0 && <Loading />}
+      {data.length === 0 && isLoading === 0 && (
+        <Message>
+          No data available yet.
+          <Highlight color={"#8590ff"} onClick={getMetrics}>
+            <i className="material-icons">autorenew</i>
+            Refresh
+          </Highlight>
+        </Message>
+      )}
+      {data.length > 0 && isLoading === 0 && (
+        <>
+          <ParentSize>
+            {({ width, height }) => (
+              <AreaChart
+                dataKey={selectedMetricLabel}
+                data={data}
+                hpaData={hpaData}
+                hpaEnabled={false}
+                width={width}
+                height={height - 10}
+                resolution={selectedRange}
+                margin={{ top: 40, right: -40, bottom: 0, left: 50 }}
+              />
+            )}
+          </ParentSize>
+        </>
+      )}
+    </StyledMetricsSection>
+  );
+};
+
+export default Metrics;
+
+const LoadingWrapper = styled.div`
+  padding: 30px 0px;
+  display: flex;
+  align-items: center;
+  font-size: 13px;
+  justify-content: center;
+  color: #ffffff44;
+`;
+
+const Highlight = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-left: 8px;
+  color: ${(props: { color: string }) => props.color};
+  cursor: pointer;
+
+  > i {
+    font-size: 20px;
+    margin-right: 3px;
+  }
+`;
+
+const Label = styled.div`
+  font-weight: bold;
+`;
+
+const Relative = styled.div`
+  position: relative;
+`;
+
+const Message = styled.div`
+  display: flex;
+  height: 100%;
+  width: calc(100% - 150px);
+  align-items: center;
+  justify-content: center;
+  margin-left: 75px;
+  text-align: center;
+  color: #ffffff44;
+  font-size: 13px;
+`;
+
+const IconWrapper = styled.div`
+  display: flex;
+  position: relative;
+  align-items: center;
+  justify-content: center;
+  margin-top: 2px;
+  border-radius: 30px;
+  height: 25px;
+  width: 25px;
+  margin-left: 8px;
+  cursor: pointer;
+  :hover {
+    background: #ffffff22;
+  }
+`;
+
+const SettingsIcon = styled.img`
+  opacity: 0.4;
+  width: 20px;
+  height: 20px;
+  margin-left: -1px;
+  margin-bottom: -2px;
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const MetricsHeader = styled.div`
+  width: 100%;
+  display: flex;
+  align-items: center;
+  overflow: visible;
+  justify-content: space-between;
+`;
+
+const DropdownOverlay = styled.div`
+  position: fixed;
+  width: 100%;
+  height: 100%;
+  z-index: 10;
+  left: 0px;
+  top: 0px;
+  cursor: default;
+`;
+
+const Option = styled.div`
+  width: 100%;
+  border-top: 1px solid #00000000;
+  border-bottom: 1px solid
+    ${(props: { selected: boolean; lastItem: boolean }) =>
+      props.lastItem ? "#ffffff00" : "#ffffff15"};
+  height: 37px;
+  font-size: 13px;
+  padding-top: 9px;
+  align-items: center;
+  padding-left: 15px;
+  cursor: pointer;
+  padding-right: 10px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  background: ${(props: { selected: boolean; lastItem: boolean }) =>
+    props.selected ? "#ffffff11" : ""};
+
+  :hover {
+    background: #ffffff22;
+  }
+`;
+
+const Dropdown = styled.div`
+  position: absolute;
+  left: 0;
+  top: calc(100% + 10px);
+  background: #26282f;
+  width: ${(props: { dropdownWidth: string; dropdownMaxHeight: string }) =>
+    props.dropdownWidth};
+  max-height: ${(props: { dropdownWidth: string; dropdownMaxHeight: string }) =>
+    props.dropdownMaxHeight || "300px"};
+  border-radius: 3px;
+  z-index: 999;
+  overflow-y: auto;
+  margin-bottom: 20px;
+  box-shadow: 0px 4px 10px 0px #00000088;
+`;
+
+const DropdownAlt = styled(Dropdown)`
+  padding: 20px 20px 7px;
+  overflow: visible;
+`;
+
+const RangeWrapper = styled.div`
+  float: right;
+  font-weight: bold;
+  width: 156px;
+  margin-top: -8px;
+`;
+
+const MetricSelector = styled.div`
+  font-size: 13px;
+  font-weight: 500;
+  position: relative;
+  color: #ffffff;
+  display: flex;
+  align-items: center;
+  cursor: pointer;
+  border-radius: 5px;
+  :hover {
+    > i {
+      background: #ffffff22;
+    }
+  }
+
+  > i {
+    border-radius: 20px;
+    font-size: 20px;
+    margin-left: 10px;
+  }
+`;
+
+const MetricsLabel = styled.div`
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  overflow: hidden;
+  max-width: 200px;
+`;
+
+const StyledMetricsSection = styled.div`
+  width: 100%;
+  min-height: 400px;
+  height: 50vh;
+  display: flex;
+  flex-direction: column;
+  position: relative;
+  font-size: 13px;
+  border-radius: 8px;
+  border: 1px solid #ffffff33;
+  padding: 18px 22px;
+  animation: floatIn 0.3s;
+  animation-timing-function: ease-out;
+  animation-fill-mode: forwards;
+  margin-top: 20px;
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(10px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;
+
+const Header = styled.div`
+  font-weight: 500;
+  color: #aaaabb;
+  font-size: 16px;
+  margin-bottom: 15px;
+`;

+ 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 yaml from "js-yaml";
 import backArrow from "assets/back_arrow.png";
 import _ from "lodash";
 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 api from "shared/api";
 import StatusIndicator from "components/StatusIndicator";

+ 72 - 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 PorterFormWrapper from "components/porter-form/PorterFormWrapper";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
+import ValuesYaml from "./ValuesYaml";
 
 type PropsType = WithAuthProps & {
   namespace: string;
@@ -39,6 +40,7 @@ type StateType = {
   deleting: boolean;
   saveValuesStatus: string | null;
   formData: any;
+  devOpsMode: boolean;
 };
 
 class ExpandedJobChart extends Component<PropsType, StateType> {
@@ -56,6 +58,7 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
     deleting: false,
     saveValuesStatus: null as string | null,
     formData: {} as any,
+    devOpsMode: localStorage.getItem("devOpsMode") === "true",
   };
 
   // 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) => {
     let { currentCluster, currentProject, setCurrentError } = this.context;
 
@@ -443,6 +452,14 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
             />
           </TabWrapper>
         );
+      case "values":
+        return (
+          <ValuesYaml
+            currentChart={this.state.currentChart}
+            refreshChart={() => this.refreshChart(0)}
+            disabled={!this.props.isAuthorized("job", "", ["get", "update"])}
+          />
+        );
       case "settings":
         return (
           this.props.isAuthorized("job", "", ["get", "delete"]) && (
@@ -478,6 +495,11 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
       });
     }
     let rightTabOptions = [] as any[];
+
+    if (this.state.devOpsMode) {
+      rightTabOptions.push({ label: "Helm Values", value: "values" });
+    }
+
     if (this.props.isAuthorized("job", "", ["get", "delete"])) {
       rightTabOptions.push({ label: "Settings", value: "settings" });
     }
@@ -512,6 +534,18 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
     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 = () => {
     let { currentProject, currentCluster, setCurrentOverlay } = this.context;
     let { currentChart } = this.state;
@@ -602,6 +636,15 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
                   rightTabOptions={this.state.rightTabOptions}
                   saveValuesStatus={this.state.saveValuesStatus}
                   saveButtonText="Save Config"
+                  includeHiddenFields
+                  addendum={
+                    <TabButton
+                      onClick={this.toggleDevOpsMode}
+                      devOpsMode={this.state.devOpsMode}
+                    >
+                      <i className="material-icons">offline_bolt</i> DevOps Mode
+                    </TabButton>
+                  }
                 />
               )}
             </BodyWrapper>
@@ -793,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 useAuth from "shared/auth/useAuth";
 import Loading from "components/Loading";
+import NotificationSettingsSection from "./NotificationSettingsSection";
 
 type PropsType = {
   currentChart: ChartType;
@@ -258,6 +259,7 @@ const SettingsSection: React.FC<PropsType> = ({
       {!loadingWebhookToken ? (
         <StyledSettingsSection showSource={showSource}>
           {renderWebhookSection()}
+          <NotificationSettingsSection currentChart={currentChart} />
           <Heading>Additional Settings</Heading>
           <Button color="#b91133" onClick={() => setShowDeleteOverlay(true)}>
             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 styled from "styled-components";
 import yaml from "js-yaml";
+import _ from "lodash";
 
 import { ChartType, StorageType } from "shared/types";
 import api from "shared/api";
@@ -49,13 +50,22 @@ export default class ValuesYaml extends Component<PropsType, StateType> {
     let { currentCluster, setCurrentError, currentProject } = this.context;
     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
       .upgradeChartValues(
         "<token>",
         {
           namespace: this.props.currentChart.namespace,
           storage: StorageType.Secret,
-          values: this.state.values,
+          values: valuesString,
         },
         {
           id: currentProject.id,

+ 18 - 3
dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/MetricNormalizer.ts

@@ -1,12 +1,13 @@
 import {
-  AvailableMetrics,
   GenericMetricResponse,
   MetricsCPUDataResponse,
-  MetricsHpaReplicasDataResponse,
   MetricsMemoryDataResponse,
   MetricsNetworkDataResponse,
   MetricsNGINXErrorsDataResponse,
-  NormalizedMetricsData
+  AvailableMetrics,
+  MetricsHpaReplicasDataResponse, 
+  MetricsNGINXLatencyDataResponse,
+  NormalizedMetricsData,
 } from "./types";
 
 /**
@@ -39,6 +40,9 @@ export class MetricNormalizer {
     if (this.kind.includes("nginx:errors")) {
       return this.parseNGINXErrorsMetrics(this.metric_results);
     }
+    if (this.kind.includes("nginx:latency") || this.kind.includes("nginx:latency-histogram")) {
+      return this.parseNGINXLatencyMetrics(this.metric_results);
+    }
     if (this.kind.includes("hpa_replicas")) {
       return this.parseHpaReplicaMetrics(this.metric_results);
     }
@@ -83,6 +87,17 @@ export class MetricNormalizer {
     });
   }
 
+  private parseNGINXLatencyMetrics(
+    arr: MetricsNGINXLatencyDataResponse["results"]
+  ) {
+    return arr.map((d) => {
+      return {
+        date: d.date,
+        value: d.latency != "NaN" ? parseFloat(d.latency) : 0,
+      };
+    });
+  }
+
   private parseHpaReplicaMetrics(
     arr: MetricsHpaReplicasDataResponse["results"]
   ) {

+ 2 - 2
dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/MetricsSection.tsx

@@ -19,14 +19,14 @@ type PropsType = {
   currentChart: ChartTypeWithExtendedConfig;
 };
 
-const resolutions: { [range: string]: string } = {
+export const resolutions: { [range: string]: string } = {
   "1H": "1s",
   "6H": "15s",
   "1D": "15s",
   "1M": "5h",
 };
 
-const secondsBeforeNow: { [range: string]: number } = {
+export const secondsBeforeNow: { [range: string]: number } = {
   "1H": 60 * 60,
   "6H": 60 * 60 * 6,
   "1D": 60 * 60 * 24,

+ 10 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/types.ts

@@ -30,6 +30,14 @@ export type MetricsNGINXErrorsDataResponse = {
   }[];
 };
 
+export type MetricsNGINXLatencyDataResponse = {
+  pod?: string;
+  results: {
+    date: number;
+    latency: string;
+  }[];
+};
+
 export type MetricsHpaReplicasDataResponse = {
   pod?: string;
   results: {
@@ -47,6 +55,7 @@ export type GenericMetricResponse = {
     bytes: string;
     error_pct: string;
     replicas: string;
+    latency: string;
   }[];
 };
 
@@ -60,6 +69,7 @@ export type AvailableMetrics =
   | "memory"
   | "network"
   | "nginx:errors"
+  | "nginx:latency"
   | "cpu_hpa_threshold"
   | "memory_hpa_threshold"
   | "hpa_replicas";

+ 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 _ from "lodash";
 import randomWords from "random-words";
@@ -10,10 +10,16 @@ import { pushFiltered } from "shared/routing";
 
 import { hardcodedNames } from "shared/hardcodedNameDict";
 import SourcePage from "./SourcePage";
+import WorkflowPage from "./WorkflowPage";
 import SettingsPage from "./SettingsPage";
 import TitleSection from "components/TitleSection";
 
-import { ActionConfigType, PorterTemplate, StorageType } from "shared/types";
+import {
+  ActionConfigType,
+  FullActionConfigType,
+  PorterTemplate,
+  StorageType,
+} from "shared/types";
 
 type PropsType = RouteComponentProps & {
   currentTab?: string;
@@ -22,28 +28,6 @@ type PropsType = RouteComponentProps & {
   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 = {
   git_repo: "",
   image_repo_uri: "",
@@ -51,83 +35,61 @@ const defaultActionConfig: ActionConfigType = {
   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
     if (selectedRegistry.service === "dockerhub") {
       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 = {};
     for (let key in wildcard) {
@@ -138,38 +100,35 @@ class LaunchFlow extends Component<PropsType, StateType> {
       .deployAddon(
         "<token>",
         {
-          templateName: this.props.currentTemplate.name,
+          templateName: props.currentTemplate.name,
           storage: StorageType.Secret,
           formValues: values,
           namespace: selectedNamespace,
-          name,
+          name: templateName,
         },
         {
           id: currentProject.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,
         }
       )
       .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) => {
@@ -178,13 +137,11 @@ class LaunchFlow extends Component<PropsType, StateType> {
 
         err = parsedErr || err.message || JSON.stringify(err);
 
-        this.setState({
-          saveValuesStatus: err,
-        });
+        setSaveValuesStatus(err);
 
         setCurrentError(err);
         window.analytics.track("Failed to Deploy Add-on", {
-          name: this.props.currentTemplate.name,
+          name: props.currentTemplate.name,
           namespace: selectedNamespace,
           values: values,
           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
     let values: any = {};
@@ -210,21 +159,22 @@ class LaunchFlow extends Component<PropsType, StateType> {
       _.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];
     } else if (!tag) {
       tag = "latest";
     }
 
     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";
       } else {
-        imageUrl = "public.ecr.aws/o1j4x7p4/hello-porter";
+        url = "public.ecr.aws/o1j4x7p4/hello-porter";
         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)
-    if (imageUrl && tag) {
-      _.set(values, "image.repository", imageUrl);
+    if (url && tag) {
+      _.set(values, "image.repository", url);
       _.set(values, "image.tag", tag);
     }
 
     _.set(values, "ingress.provider", provider);
 
     // pause jobs automatically
-    if (this.props.currentTemplate?.name == "job") {
+    if (props.currentTemplate?.name == "job") {
       _.set(values, "paused", true);
     }
 
-    var url: string;
+    var external_domain: string;
     // 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) {
-        url = await new Promise((resolve, reject) => {
+        external_domain = await new Promise((resolve, reject) => {
           api
             .createSubdomain(
               "<token>",
               {
-                release_name: name,
+                release_name: templateName,
               },
               {
                 id: currentProject.id,
@@ -280,128 +230,109 @@ class LaunchFlow extends Component<PropsType, StateType> {
               let parsedErr =
                 err?.response?.data?.errors && err.response.data.errors[0];
               err = parsedErr || err.message || JSON.stringify(err);
-              this.setState({
-                saveValuesStatus: `Could not create subdomain: ${err}`,
-              });
+              setSaveValuesStatus(`Could not create subdomain: ${err}`);
 
               setCurrentError(err);
             });
         });
 
-        values.ingress.porter_hosts = [url];
+        values.ingress.porter_hosts = [external_domain];
       }
     }
 
+    let githubActionConfig: FullActionConfigType = null;
+    if (sourceType === "repo") {
+      githubActionConfig = getFullActionConfig();
+    }
+
     api
       .deployTemplate(
         "<token>",
         {
-          templateName: this.props.currentTemplate.name,
-          imageURL: imageUrl,
+          templateName: props.currentTemplate.name,
+          imageURL: url,
           storage: StorageType.Secret,
           formValues: values,
           namespace: selectedNamespace,
-          name,
+          name: templateName,
+          githubActionConfig,
         },
         {
           id: currentProject.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,
         }
       )
       .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) => {
         let parsedErr =
           err?.response?.data?.errors && err.response.data.errors[0];
         err = parsedErr || err.message || JSON.stringify(err);
-        this.setState({
-          saveValuesStatus: `Could not deploy template: ${err}`,
-        });
+        setSaveValuesStatus(`Could not deploy template: ${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") {
       return (
         <SourcePage
           sourceType={sourceType}
-          setSourceType={(x: string) => this.setState({ sourceType: x })}
+          setSourceType={setSourceType}
           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}
-          setImageUrl={(x: string) => this.setState({ imageUrl: x })}
+          setImageUrl={setImageUrl}
           imageTag={imageTag}
-          setImageTag={(x: string) => this.setState({ imageTag: x })}
+          setImageTag={setImageTag}
           actionConfig={actionConfig}
-          setActionConfig={(x: ActionConfigType) =>
-            this.setState({ actionConfig: x })
-          }
+          setActionConfig={setActionConfig}
           branch={branch}
-          setBranch={(x: string) => this.setState({ branch: x })}
+          setBranch={setBranch}
           procfileProcess={procfileProcess}
-          setProcfileProcess={(x: string) =>
-            this.setState({ procfileProcess: x })
-          }
+          setProcfileProcess={setProcfileProcess}
           repoType={repoType}
-          setRepoType={(x: string) => this.setState({ repoType: x })}
+          setRepoType={setRepoType}
           dockerfilePath={dockerfilePath}
-          setDockerfilePath={(x: string) =>
-            this.setState({ dockerfilePath: x })
-          }
+          setDockerfilePath={setDockerfilePath}
           folderPath={folderPath}
-          setFolderPath={(x: string) => this.setState({ folderPath: x })}
+          setFolderPath={setFolderPath}
           procfilePath={procfilePath}
-          setProcfilePath={(x: string) => this.setState({ procfilePath: x })}
+          setProcfilePath={setProcfilePath}
           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
     return (
       <SettingsPage
-        onSubmit={currentTab === "porter" ? this.onSubmit : this.onSubmitAddon}
+        onSubmit={currentTab === "porter" ? handleSubmit : handleSubmitAddon}
         saveValuesStatus={saveValuesStatus}
         selectedNamespace={selectedNamespace}
-        setSelectedNamespace={(x: string) =>
-          this.setState({ selectedNamespace: x })
-        }
+        setSelectedNamespace={setSelectedNamespace}
         templateName={templateName}
-        setTemplateName={(x: string) => this.setState({ templateName: x })}
+        setTemplateName={setTemplateName}
         hasSource={currentTab === "porter"}
-        setPage={(x: string) => this.setState({ currentPage: x })}
+        sourceType={sourceType}
+        setPage={setCurrentPage}
         form={form}
         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) {
       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);
 
 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 & {
   onSubmit: (x?: any) => void;
   hasSource: boolean;
+  sourceType: string;
   setPage: (x: string) => void;
   form: any;
   valuesToOverride: any;
@@ -182,18 +183,24 @@ class SettingsPage extends Component<PropsType, StateType> {
   };
 
   renderHeaderSection = () => {
-    let { hasSource, templateName, setTemplateName } = this.props;
+    let {
+      hasSource,
+      sourceType,
+      templateName,
+      setPage,
+      setTemplateName,
+    } = this.props;
 
     if (hasSource) {
+      const [pageKey, pageName] =
+        sourceType === "repo"
+          ? ["workflow", "GitHub Actions"]
+          : ["source", "Source Settings"];
+
       return (
-        <BackButton
-          width="155px"
-          onClick={() => {
-            this.props.setPage("source");
-          }}
-        >
+        <BackButton width="155px" onClick={() => setPage(pageKey)}>
           <i className="material-icons">first_page</i>
-          Source Settings
+          {pageName}
         </BackButton>
       );
     }

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

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

+ 57 - 24
dashboard/src/shared/api.tsx

@@ -1,6 +1,6 @@
 import { baseApi } from "./baseApi";
 
-import { StorageType } from "./types";
+import { FullActionConfigType, StorageType } from "./types";
 
 /**
  * Generic api call format
@@ -113,27 +113,6 @@ const createGCR = baseApi<
   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<
   {
     gcp_integration_id: number;
@@ -267,6 +246,46 @@ const deleteSlackIntegration = baseApi<
   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<
   {
     templateName: string;
@@ -275,6 +294,7 @@ const deployTemplate = baseApi<
     storage: StorageType;
     namespace: string;
     name: string;
+    githubActionConfig?: FullActionConfigType;
   },
   {
     id: number;
@@ -542,6 +562,15 @@ const getJobs = baseApi<
   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<
   {
     cluster_id: number;
@@ -569,7 +598,8 @@ const getMetrics = baseApi<
     shouldsum: boolean;
     pods?: string[];
     kind?: string; // the controller kind
-    name: string;
+    name?: string;
+    percentile?: number;
     namespace: string;
     startrange: number;
     endrange: number;
@@ -1016,7 +1046,6 @@ export default {
   createEmailVerification,
   createGCPIntegration,
   createGCR,
-  createGHAction,
   createGKE,
   createInvite,
   createNamespace,
@@ -1033,6 +1062,8 @@ export default {
   deleteProject,
   deleteRegistryIntegration,
   deleteSlackIntegration,
+  updateNotificationConfig,
+  getNotificationConfig,
   createSubdomain,
   deployTemplate,
   deployAddon,
@@ -1053,6 +1084,7 @@ export default {
   getClusterNodes,
   getClusterNode,
   getConfigMap,
+  generateGHAWorkflow,
   getGitRepoList,
   getGitRepos,
   getImageRepos,
@@ -1061,6 +1093,7 @@ export default {
   getIngress,
   getInvites,
   getJobs,
+  getJobStatus,
   getJobPods,
   getMatchingPods,
   getMetrics,

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

@@ -259,6 +259,13 @@ export interface ActionConfigType {
   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 {
   github: boolean;
   provisioner: boolean;

+ 95 - 7
dashboard/webpack.config.js

@@ -1,24 +1,48 @@
 const path = require("path");
 const HtmlWebpackPlugin = require("html-webpack-plugin");
 const webpack = require("webpack");
+const ReactRefreshWebpackPlugin = require("@pmmmwh/react-refresh-webpack-plugin");
+
 const dotenv = require("dotenv");
 
+const BundleAnalyzerPlugin = require("webpack-bundle-analyzer")
+  .BundleAnalyzerPlugin;
+
+const TerserPlugin = require("terser-webpack-plugin");
+
 module.exports = () => {
   const env = dotenv.config().parsed;
   const envKeys = Object.keys(env).reduce((prev, next) => {
     prev[`process.env.${next}`] = JSON.stringify(env[next]);
     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",
-    mode: "development",
+    mode: isDevelopment ? "development" : "production",
     module: {
       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",
@@ -54,8 +78,12 @@ module.exports = () => {
       publicPath: "/",
     },
     devServer: {
+      port: env["PORT"],
       historyApiFallback: true,
       disableHostCheck: true,
+      host: "0.0.0.0",
+      port: env.DEV_SERVER_PORT || 8080,
+      hot: true,
     },
     plugins: [
       new HtmlWebpackPlugin({
@@ -63,6 +91,66 @@ module.exports = () => {
         segmentKey: `${process.env.SEGMENT_PUBLIC_KEY}`,
       }),
       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).
 
-![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. 
 
@@ -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).
 
-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")
 
 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.

+ 53 - 26
docs/developing/setup.md

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

+ 21 - 9
docs/guides/running-porter-locally.md

@@ -1,19 +1,31 @@
-While it requires a few additional steps, it is possible to run Porter locally. These are the steps to start using Porter on your local machine.
+While it requires a few additional steps, it is possible to run Porter locally. Porter can either be run inside a Docker container, or the binary can be run directly.
+
+## Running the Binary
+
+To run the Porter binary, follow these steps: 
 
 1. [Install our CLI](https://docs.getporter.dev/docs/cli-documentation#installation)
 
 2. Run `porter server start`. This will spin up a local Porter instance on port 8080.
 
-By default, GitHub login and the deploying from GitHub repo is disabled on the local version of Porter - this is due to security reasons. However, you can add these functionalities to your local instance by creating your own GitHub OAuth application. These are the steps to enable the GitHub features on the local version of Porter:
+3. Navigate to http://localhost:8080/register, and create a new user with an email and password. 
 
-1. [Create a new GitHub Oauth App](https://docs.github.com/en/developers/apps/creating-an-oauth-app). This app should be created with `http://localhost:8080/api/oauth/github/callback` as the callback URL. 
+## Running with Docker
 
-2. Copy the Client ID and the Client secrets. Then add these lines into your `.bashrc` file:
+The easiest way to run the Docker container is to use SQLite as the persistence option. To accomplish this, you can simply run:
 
-```txt
-export GITHUB_CLIENT_ID=YOUR_CLIENT_ID
-export GITHUB_CLIENT_SECRET=YOUR_CLIENT_SECRET
-export GITHUB_ENABLED=true
 ```
+docker volume create porter_sqlite
+docker run \
+  --mount type=volume,source=porter_sqlite,target=/sqlite,readonly=false \
+  -e REDIS_ENABLED=false \
+  -e SQL_LITE_PATH=/sqlite/porter.db \
+  -p 8080:8080 \
+  -d porter1/porter:latest
+```
+
+Then navigate to http://localhost:8080/register, and create a new user with an email and password. 
+
+## Setting up Integrations
 
-3. When you run `porter server start`, Porter will automatically read these variables in and enable the GitHub features.
+While basic functionality is supported on the local binary/Docker image, more configuration is required to support various integrations. See [this document](https://docs.porter.run/docs/sso) for instructions on adding integrations like Github application access.

+ 14 - 10
internal/forms/git_action.go

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

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

@@ -45,13 +45,16 @@ type GithubActions struct {
 
 	defaultBranch string
 	Version       string
+
+	ShouldGenerateOnly   bool
+	ShouldCreateWorkflow bool
 }
 
-func (g *GithubActions) Setup() (string, error) {
+func (g *GithubActions) Setup() ([]byte, error) {
 	client, err := g.getClient()
 
 	if err != nil {
-		return "", err
+		return nil, err
 	}
 
 	// get the repository to find the default branch
@@ -62,23 +65,32 @@ func (g *GithubActions) Setup() (string, error) {
 	)
 
 	if err != nil {
-		return "", err
+		return nil, err
 	}
 
 	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 {
-		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 {
@@ -161,7 +173,7 @@ func (g *GithubActions) GetGithubActionYAML() ([]byte, error) {
 		branch = g.defaultBranch
 	}
 
-	actionYAML := &GithubActionYAML{
+	actionYAML := GithubActionYAML{
 		On: GithubActionYAMLOnPush{
 			Push: GithubActionYAMLOnPushBranches{
 				Branches: []string{

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

@@ -4,6 +4,7 @@ import (
 	"bytes"
 	"encoding/json"
 	"fmt"
+	"github.com/porter-dev/porter/internal/models"
 	"net/http"
 	"strings"
 	"time"
@@ -52,11 +53,13 @@ type NotifyOpts struct {
 
 type SlackNotifier struct {
 	slackInts []*integrations.SlackIntegration
+	Config    *models.NotificationConfigExternal
 }
 
-func NewSlackNotifier(slackInts ...*integrations.SlackIntegration) Notifier {
+func NewSlackNotifier(conf *models.NotificationConfigExternal, slackInts ...*integrations.SlackIntegration) Notifier {
 	return &SlackNotifier{
 		slackInts: slackInts,
+		Config:    conf,
 	}
 }
 
@@ -75,6 +78,18 @@ type SlackText struct {
 }
 
 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{
 		getMessageBlock(opts),
 		getDividerBlock(),

+ 1 - 1
internal/kubernetes/config.go

@@ -312,7 +312,7 @@ func (conf *OutOfClusterConfig) CreateRawConfigFromCluster() (*api.Config, error
 		}
 
 		// add this as a bearer token
-		authInfoMap[authInfoName].Token = tok
+		authInfoMap[authInfoName].Token = tok.AccessToken
 	case models.AWS:
 		awsAuth, err := conf.Repo.AWSIntegration.ReadAWSIntegration(
 			cluster.AWSIntegrationID,

+ 20 - 8
internal/kubernetes/prometheus/metrics.go

@@ -86,6 +86,7 @@ type QueryOpts struct {
 	StartRange uint     `schema:"startrange"`
 	EndRange   uint     `schema:"endrange"`
 	Resolution string   `schema:"resolution"`
+	Percentile float64  `schema:"percentile"`
 }
 
 func QueryPrometheus(
@@ -97,7 +98,7 @@ func QueryPrometheus(
 		return nil, fmt.Errorf("prometheus service has no exposed ports to query")
 	}
 
-	podSelectionRegex, err := getPodSelectionRegex(opts.Kind, opts.Name)
+	selectionRegex, err := getSelectionRegex(opts.Kind, opts.Name)
 
 	if err != nil {
 		return nil, err
@@ -108,7 +109,7 @@ func QueryPrometheus(
 	if len(opts.PodList) > 0 {
 		podSelector = fmt.Sprintf(`namespace="%s",pod=~"%s",container!="POD",container!=""`, opts.Namespace, strings.Join(opts.PodList, "|"))
 	} else {
-		podSelector = fmt.Sprintf(`namespace="%s",pod=~"%s",container!="POD",container!=""`, opts.Namespace, podSelectionRegex)
+		podSelector = fmt.Sprintf(`namespace="%s",pod=~"%s",container!="POD",container!=""`, opts.Namespace, selectionRegex)
 	}
 
 	query := ""
@@ -118,12 +119,18 @@ func QueryPrometheus(
 	} else if opts.Metric == "memory" {
 		query = fmt.Sprintf("container_memory_usage_bytes{%s}", podSelector)
 	} else if opts.Metric == "network" {
-		netPodSelector := fmt.Sprintf(`namespace="%s",pod=~"%s",container="POD"`, opts.Namespace, podSelectionRegex)
+		netPodSelector := fmt.Sprintf(`namespace="%s",pod=~"%s",container="POD"`, opts.Namespace, selectionRegex)
 		query = fmt.Sprintf("rate(container_network_receive_bytes_total{%s}[5m])", netPodSelector)
 	} else if opts.Metric == "nginx:errors" {
-		num := fmt.Sprintf(`sum(rate(nginx_ingress_controller_requests{status=~"5.*",namespace="%s",ingress=~"%s"}[5m]) OR on() vector(0))`, opts.Namespace, podSelectionRegex)
-		denom := fmt.Sprintf(`sum(rate(nginx_ingress_controller_requests{namespace="%s",ingress=~"%s"}[5m]) > 0)`, opts.Namespace, podSelectionRegex)
+		num := fmt.Sprintf(`sum(rate(nginx_ingress_controller_requests{status=~"5.*",namespace="%s",ingress=~"%s"}[5m]) OR on() vector(0))`, opts.Namespace, selectionRegex)
+		denom := fmt.Sprintf(`sum(rate(nginx_ingress_controller_requests{namespace="%s",ingress=~"%s"}[5m]) > 0)`, opts.Namespace, selectionRegex)
 		query = fmt.Sprintf(`%s / %s * 100 OR on() vector(0)`, num, denom)
+	} else if opts.Metric == "nginx:latency" {
+		num := fmt.Sprintf(`sum(rate(nginx_ingress_controller_request_duration_seconds_sum{namespace=~"%s",ingress=~"%s"}[5m]) OR on() vector(0))`, opts.Namespace, selectionRegex)
+		denom := fmt.Sprintf(`sum(rate(nginx_ingress_controller_request_duration_seconds_count{namespace=~"%s",ingress=~"%s"}[5m]))`, opts.Namespace, selectionRegex)
+		query = fmt.Sprintf(`%s / %s OR on() vector(0)`, num, denom)
+	} else if opts.Metric == "nginx:latency-histogram" {
+		query = fmt.Sprintf(`histogram_quantile(%f, sum(rate(nginx_ingress_controller_request_duration_seconds_bucket{status!="404",status!="500",namespace=~"%s",ingress=~"%s"}[5m])) by (le, ingress))`, opts.Percentile, opts.Namespace, selectionRegex)
 	} else if opts.Metric == "cpu_hpa_threshold" {
 		// get the name of the kube hpa metric
 		metricName, hpaMetricName := getKubeHPAMetricName(clientset, service, opts, "spec_target_metric")
@@ -135,7 +142,7 @@ func QueryPrometheus(
 			appLabel = ksmSvc.ObjectMeta.Labels["app.kubernetes.io/instance"]
 		}
 
-		query = createHPAAbsoluteCPUThresholdQuery(cpuMetricName, metricName, podSelectionRegex, opts.Name, opts.Namespace, appLabel, hpaMetricName)
+		query = createHPAAbsoluteCPUThresholdQuery(cpuMetricName, metricName, selectionRegex, opts.Name, opts.Namespace, appLabel, hpaMetricName)
 	} else if opts.Metric == "memory_hpa_threshold" {
 		metricName, hpaMetricName := getKubeHPAMetricName(clientset, service, opts, "spec_target_metric")
 		memMetricName := getKubeMemoryMetricName(clientset, service, opts)
@@ -146,7 +153,7 @@ func QueryPrometheus(
 			appLabel = ksmSvc.ObjectMeta.Labels["app.kubernetes.io/instance"]
 		}
 
-		query = createHPAAbsoluteMemoryThresholdQuery(memMetricName, metricName, podSelectionRegex, opts.Name, opts.Namespace, appLabel, hpaMetricName)
+		query = createHPAAbsoluteMemoryThresholdQuery(memMetricName, metricName, selectionRegex, opts.Name, opts.Namespace, appLabel, hpaMetricName)
 	} else if opts.Metric == "hpa_replicas" {
 		metricName, hpaMetricName := getKubeHPAMetricName(clientset, service, opts, "status_current_replicas")
 		ksmSvc, found, _ := getKubeStateMetricsService(clientset)
@@ -208,6 +215,7 @@ type promParsedSingletonQueryResult struct {
 	Memory   interface{} `json:"memory,omitempty"`
 	Bytes    interface{} `json:"bytes,omitempty"`
 	ErrorPct interface{} `json:"error_pct,omitempty"`
+	Latency  interface{} `json:"latency,omitempty"`
 }
 
 type promParsedSingletonQuery struct {
@@ -248,6 +256,8 @@ func parseQuery(rawQuery []byte, metric string) ([]byte, error) {
 				singletonResult.Memory = values[1]
 			} else if metric == "hpa_replicas" {
 				singletonResult.Replicas = values[1]
+			} else if metric == "nginx:latency" || metric == "nginx:latency-histogram" {
+				singletonResult.Latency = values[1]
 			}
 
 			singletonResults = append(singletonResults, *singletonResult)
@@ -261,7 +271,7 @@ func parseQuery(rawQuery []byte, metric string) ([]byte, error) {
 	return json.Marshal(res)
 }
 
-func getPodSelectionRegex(kind, name string) (string, error) {
+func getSelectionRegex(kind, name string) (string, error) {
 	var suffix string
 
 	switch strings.ToLower(kind) {
@@ -273,6 +283,8 @@ func getPodSelectionRegex(kind, name string) (string, error) {
 		suffix = "[a-z0-9]+"
 	case "cronjob":
 		suffix = "[a-z0-9]+-[a-z0-9]+"
+	case "ingress":
+		return name, nil
 	default:
 		return "", fmt.Errorf("not a supported controller to query for metrics")
 	}

+ 9 - 5
internal/models/integrations/gcp.go

@@ -4,6 +4,7 @@ import (
 	"context"
 	"encoding/json"
 
+	"golang.org/x/oauth2"
 	"golang.org/x/oauth2/google"
 	"gorm.io/gorm"
 )
@@ -83,13 +84,16 @@ func (g *GCPIntegration) GetBearerToken(
 	getTokenCache GetTokenCacheFunc,
 	setTokenCache SetTokenCacheFunc,
 	scopes ...string,
-) (string, error) {
+) (*oauth2.Token, error) {
 	cache, err := getTokenCache()
 
 	// check the token cache for a non-expired token
 	if cache != nil {
 		if tok := cache.Token; err == nil && !cache.IsExpired() && len(tok) > 0 {
-			return string(tok), nil
+			return &oauth2.Token{
+				AccessToken: string(cache.Token),
+				Expiry:      cache.Expiry,
+			}, nil
 		}
 	}
 
@@ -100,19 +104,19 @@ func (g *GCPIntegration) GetBearerToken(
 	)
 
 	if err != nil {
-		return "", err
+		return nil, err
 	}
 
 	tok, err := creds.TokenSource.Token()
 
 	if err != nil {
-		return "", err
+		return nil, err
 	}
 
 	// update the token cache
 	setTokenCache(tok.AccessToken, tok.Expiry)
 
-	return tok.AccessToken, nil
+	return tok, nil
 }
 
 // credentialsFile is the unmarshalled representation of a GCP credentials file.

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

+ 16 - 7
internal/registry/registry.go

@@ -92,6 +92,8 @@ type gcrRepositoryResp struct {
 }
 
 func (r *Registry) GetGCRToken(repo repository.Repository) (*ints.TokenCache, error) {
+	getTokenCache := r.getTokenCacheFunc(repo)
+
 	gcp, err := repo.GCPIntegration.ReadGCPIntegration(
 		r.GCPIntegrationID,
 	)
@@ -102,7 +104,7 @@ func (r *Registry) GetGCRToken(repo repository.Repository) (*ints.TokenCache, er
 
 	// get oauth2 access token
 	_, err = gcp.GetBearerToken(
-		r.getTokenCache,
+		getTokenCache,
 		r.setTokenCacheFunc(repo),
 		"https://www.googleapis.com/auth/devstorage.read_write",
 	)
@@ -112,7 +114,7 @@ func (r *Registry) GetGCRToken(repo repository.Repository) (*ints.TokenCache, er
 	}
 
 	// it's now written to the token cache, so return
-	cache, err := r.getTokenCache()
+	cache, err := getTokenCache()
 
 	if err != nil {
 		return nil, err
@@ -352,11 +354,18 @@ func (r *Registry) listPrivateRegistryRepositories(
 	return res, nil
 }
 
-func (r *Registry) getTokenCache() (tok *ints.TokenCache, err error) {
-	return &ints.TokenCache{
-		Token:  r.TokenCache.Token,
-		Expiry: r.TokenCache.Expiry,
-	}, nil
+func (r *Registry) getTokenCacheFunc(
+	repo repository.Repository,
+) ints.GetTokenCacheFunc {
+	return func() (tok *ints.TokenCache, err error) {
+		reg, err := repo.Registry.ReadRegistry(r.ID)
+
+		if err != nil {
+			return nil, err
+		}
+
+		return &reg.TokenCache.TokenCache, nil
+	}
 }
 
 func (r *Registry) setTokenCacheFunc(

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

@@ -26,6 +26,7 @@ func AutoMigrate(db *gorm.DB) error {
 		&models.AuthCode{},
 		&models.DNSRecord{},
 		&models.PWResetToken{},
+		&models.NotificationConfig{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&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),
 		GithubAppOAuthIntegration: NewGithubAppOAuthIntegrationRepository(db),
 		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
 	GithubAppOAuthIntegration GithubAppOAuthIntegrationRepository
 	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
 	}
 
+	clusterID, err := strconv.ParseUint(vals["cluster_id"][0], 10, 64)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
 	getChartForm.PopulateRepoURLFromQueryParams(vals)
 
 	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 form.GithubActionConfig != nil {
 		gaForm := &forms.CreateGitAction{
-			ReleaseID:      release.ID,
+			Release: release,
+
 			GitRepo:        form.GithubActionConfig.GitRepo,
 			GitBranch:      form.GithubActionConfig.GitBranch,
 			ImageRepoURI:   form.GithubActionConfig.ImageRepoURI,
 			DockerfilePath: form.GithubActionConfig.DockerfilePath,
 			GitRepoID:      form.GithubActionConfig.GitRepoID,
 			RegistryID:     form.GithubActionConfig.RegistryID,
+
+			ShouldGenerateOnly:   false,
+			ShouldCreateWorkflow: form.GithubActionConfig.ShouldCreateWorkflow,
 		}
 
 		// validate the form
@@ -176,7 +187,7 @@ func (app *App) HandleDeployTemplate(w http.ResponseWriter, r *http.Request) {
 			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)

+ 86 - 34
server/api/git_action_handler.go

@@ -20,6 +20,49 @@ const (
 	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
 // release
 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{
-		ReleaseID: release.Model.ID,
+		Release:            release,
+		ShouldGenerateOnly: false,
 	}
 
 	// decode from JSON to form value
@@ -62,7 +106,7 @@ func (app *App) HandleCreateGitAction(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	gaExt := app.createGitActionFromForm(projID, release, name, form, w, r)
+	gaExt, _ := app.createGitActionFromForm(projID, clusterID, name, form, w, r)
 
 	w.WriteHeader(http.StatusCreated)
 
@@ -73,17 +117,17 @@ func (app *App) HandleCreateGitAction(w http.ResponseWriter, r *http.Request) {
 }
 
 func (app *App) createGitActionFromForm(
-	projID uint64,
-	release *models.Release,
+	projID,
+	clusterID uint64,
 	name string,
 	form *forms.CreateGitAction,
 	w http.ResponseWriter,
 	r *http.Request,
-) *models.GitActionConfigExternal {
+) (gaExt *models.GitActionConfigExternal, workflowYAML []byte) {
 	// validate the form
 	if err := app.validator.Struct(form); err != nil {
 		app.handleErrorFormValidation(err, ErrProjectValidateFields, w)
-		return nil
+		return
 	}
 
 	// if the registry was provisioned through Porter, create a repository if necessary
@@ -93,7 +137,7 @@ func (app *App) createGitActionFromForm(
 
 		if err != nil {
 			app.handleErrorDataRead(err, w)
-			return nil
+			return
 		}
 
 		_reg := registry.Registry(*reg)
@@ -107,30 +151,22 @@ func (app *App) createGitActionFromForm(
 
 		if err != nil {
 			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 {
 		app.handleErrorFormDecoding(fmt.Errorf("invalid formatting of repo name"), ErrProjectDecode, w)
-		return nil
+		return
 	}
 
 	session, err := app.Store.Get(r, app.ServerConf.CookieName)
 
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
-		return nil
+		return
 	}
 
 	userID, _ := session.Values["user_id"].(uint)
@@ -142,7 +178,7 @@ func (app *App) createGitActionFromForm(
 			userID = tok.IBy
 		} else if tok == nil || tok.IBy == 0 {
 			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 {
 		app.handleErrorInternal(err, w)
-		return nil
+		return
 	}
 
 	// create the commit in the git repo
@@ -170,21 +206,35 @@ func (app *App) createGitActionFromForm(
 		Repo:                   *app.Repo,
 		GithubConf:             app.GithubProjectConf,
 		ProjectID:              uint(projID),
+		ClusterID:              uint(clusterID),
 		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,
-		ClusterID:              release.ClusterID,
-		Version:                gitAction.Version,
+		Version:                updateAppActionVersion,
+		ShouldGenerateOnly:     form.ShouldGenerateOnly,
+		ShouldCreateWorkflow:   form.ShouldCreateWorkflow,
 	}
 
-	_, err = gaRunner.Setup()
+	workflowYAML, err = gaRunner.Setup()
 
 	if err != nil {
 		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
@@ -192,20 +242,22 @@ func (app *App) createGitActionFromForm(
 
 	if err != nil {
 		app.handleErrorDataWrite(err, w)
-		return nil
+		return
 	}
 
 	app.Logger.Info().Msgf("New git action created: %d", ga.ID)
 
 	// 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 {
 		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
 func (app *App) HandleDeleteSlackIntegration(w http.ResponseWriter, r *http.Request) {
 	// 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 {
-	Status string `json:"status"`
+	Status    string       `json:"status,omitempty"`
+	StartTime *metav1.Time `json:"start_time,omitempty"`
 }
 
 // HandleGetJobStatus gets the status for a specific job
@@ -692,9 +693,7 @@ func (app *App) HandleGetJobStatus(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	res := &GetJobStatusResult{
-		Status: "succeeded",
-	}
+	res := &GetJobStatusResult{}
 
 	// get the most recent job
 	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
 		if mostRecentJob.Status.Succeeded >= 1 {
 			res.Status = "succeeded"
@@ -1017,8 +1018,27 @@ func (app *App) HandleUpgradeRelease(w http.ResponseWriter, r *http.Request) {
 		conf.Chart = chart
 	}
 
+	rel, upgradeErr := agent.UpgradeRelease(conf, form.Values, app.DOConf)
+
 	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{
 		ProjectID:   uint(projID),
@@ -1035,18 +1055,16 @@ func (app *App) HandleUpgradeRelease(w http.ResponseWriter, r *http.Request) {
 		) + 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.Info = err.Error()
+		notifyOpts.Info = upgradeErr.Error()
 
 		slackErr := notifier.Notify(notifyOpts)
 		fmt.Println("SLACK ERROR IS", slackErr)
 
 		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
 			Code:   ErrReleaseDeploy,
-			Errors: []string{err.Error()},
+			Errors: []string{upgradeErr.Error()},
 		}, w)
 
 		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
 	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 {
 			app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
 				Code:   ErrReleaseReadData,
@@ -1070,8 +1086,6 @@ func (app *App) HandleUpgradeRelease(w http.ResponseWriter, r *http.Request) {
 			return
 		}
 
-		release, err := app.Repo.Release.ReadRelease(uint(clusterID), name, rel.Namespace)
-
 		if release != nil {
 			// update image repo uri if changed
 			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))
-	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{
 		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
 			r.Method(
 				"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.DoesUserHaveClusterAccess(
 						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
 			r.Method(
 				"POST",

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است