Kaynağa Gözat

Merge pull request #1128 from porter-dev/staging

GH commit links, jobs sort, analytics updates -> production
abelanger5 4 yıl önce
ebeveyn
işleme
5009dd80df
29 değiştirilmiş dosya ile 467 ekleme ve 191 silme
  1. 1 1
      cli/cmd/deploy.go
  2. 5 0
      cli/cmd/deploy/deploy.go
  3. 23 13
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  4. 61 0
      dashboard/src/main/home/cluster-dashboard/LastRunStatusSelector.tsx
  5. 1 1
      dashboard/src/main/home/cluster-dashboard/NamespaceSelector.tsx
  6. 17 77
      dashboard/src/main/home/cluster-dashboard/chart/Chart.tsx
  7. 133 22
      dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx
  8. 14 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  9. 15 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx
  10. 23 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/RevisionSection.tsx
  11. 1 0
      dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx
  12. 2 0
      dashboard/src/main/home/launch/launch-flow/WorkflowPage.tsx
  13. 3 2
      dashboard/src/shared/api.tsx
  14. 83 0
      dashboard/src/shared/baseApi.ts
  15. 0 46
      dashboard/src/shared/baseApi.tsx
  16. 11 0
      dashboard/src/shared/types.tsx
  17. 14 1
      docs/deploy/applications/deploying-from-the-cli.md
  18. 3 2
      internal/analytics/track_events.go
  19. 21 0
      internal/analytics/tracks.go
  20. 1 1
      internal/forms/git_action.go
  21. 7 6
      internal/integrations/ci/actions/actions.go
  22. 8 7
      internal/integrations/ci/actions/steps.go
  23. 2 1
      server/api/deploy_handler.go
  24. 5 3
      server/api/git_action_handler.go
  25. 1 0
      server/api/oauth_github_handler.go
  26. 1 0
      server/api/oauth_google_handler.go
  27. 2 0
      server/api/release_handler.go
  28. 8 2
      server/api/user_handler.go
  29. 1 1
      server/router/router.go

+ 1 - 1
cli/cmd/deploy.go

@@ -417,7 +417,7 @@ func updatePushWithAgent(updateAgent *deploy.DeployAgent) error {
 
 func updateUpgradeWithAgent(updateAgent *deploy.DeployAgent) error {
 	// push the deployment
-	color.New(color.FgGreen).Println("Calling webhook for", app)
+	color.New(color.FgGreen).Println("Upgrading configuration for", app)
 
 	// read the values if necessary
 	valuesObj, err := readValuesFile()

+ 5 - 0
cli/cmd/deploy/deploy.go

@@ -279,6 +279,11 @@ func (d *DeployAgent) UpdateImageAndValues(overrideValues map[string]interface{}
 	// overwrite the tag based on a new image
 	currImageSection := mergedValues["image"].(map[string]interface{})
 
+	// if this is a job chart, set "paused" to false so that the job doesn't run
+	if d.release.Chart.Name() == "job" {
+		mergedValues["paused"] = true
+	}
+
 	// if the current image section is hello-porter, the image must be overriden
 	if currImageSection["repository"] == "public.ecr.aws/o1j4x7p4/hello-porter" ||
 		currImageSection["repository"] == "public.ecr.aws/o1j4x7p4/hello-porter-job" {

+ 23 - 13
dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx

@@ -5,7 +5,7 @@ import monoweb from "assets/monoweb.png";
 import { Route, Switch } from "react-router-dom";
 
 import { Context } from "shared/Context";
-import { ChartType, ClusterType } from "shared/types";
+import { ChartType, ClusterType, JobStatusType } from "shared/types";
 import {
   getQueryParam,
   PorterUrl,
@@ -25,6 +25,7 @@ import api from "shared/api";
 import DashboardRoutes from "./dashboard/Routes";
 import GuardedRoute from "shared/auth/RouteGuard";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
+import LastRunStatusSelector from "./LastRunStatusSelector";
 
 type PropsType = RouteComponentProps &
   WithAuthProps & {
@@ -36,6 +37,7 @@ type PropsType = RouteComponentProps &
 type StateType = {
   namespace: string;
   sortType: string;
+  lastRunStatus: JobStatusType | null;
   currentChart: ChartType | null;
   isMetricsInstalled: boolean;
 };
@@ -47,6 +49,7 @@ class ClusterDashboard extends Component<PropsType, StateType> {
     sortType: localStorage.getItem("SortType")
       ? localStorage.getItem("SortType")
       : "Newest",
+    lastRunStatus: null as null,
     currentChart: null as ChartType | null,
     isMetricsInstalled: false,
   };
@@ -130,7 +133,7 @@ class ClusterDashboard extends Component<PropsType, StateType> {
     );
     return (
       <>
-        <ControlRow hasMultipleChilds={isAuthorizedToAdd}>
+        <ControlRow>
           {isAuthorizedToAdd && (
             <Button
               onClick={() =>
@@ -141,10 +144,14 @@ class ClusterDashboard extends Component<PropsType, StateType> {
             </Button>
           )}
           <SortFilterWrapper>
-            <SortSelector
-              setSortType={(sortType) => this.setState({ sortType })}
-              sortType={this.state.sortType}
-            />
+            {currentView === "jobs" && (
+              <LastRunStatusSelector
+                lastRunStatus={this.state.lastRunStatus}
+                setLastRunStatus={(lastRunStatus: JobStatusType) => {
+                  this.setState({ lastRunStatus });
+                }}
+              />
+            )}
             <NamespaceSelector
               setNamespace={(namespace) =>
                 this.setState({ namespace }, () => {
@@ -155,12 +162,17 @@ class ClusterDashboard extends Component<PropsType, StateType> {
               }
               namespace={this.state.namespace}
             />
+            <SortSelector
+              setSortType={(sortType) => this.setState({ sortType })}
+              sortType={this.state.sortType}
+            />
           </SortFilterWrapper>
         </ControlRow>
 
         <ChartList
           currentView={currentView}
           currentCluster={currentCluster}
+          lastRunStatus={this.state.lastRunStatus}
           namespace={this.state.namespace}
           sortType={this.state.sortType}
         />
@@ -239,12 +251,8 @@ const Br = styled.div`
 
 const ControlRow = styled.div`
   display: flex;
-  justify-content: ${(props: { hasMultipleChilds: boolean }) => {
-    if (props.hasMultipleChilds) {
-      return "space-between";
-    }
-    return "flex-end";
-  }};
+  margin-left: auto;
+  justify-content: space-between;
   align-items: center;
   margin-bottom: 35px;
   padding-left: 0px;
@@ -389,7 +397,9 @@ const Img = styled.img`
 `;
 
 const SortFilterWrapper = styled.div`
-  width: 468px;
   display: flex;
   justify-content: space-between;
+  > div:not(:first-child) {
+    margin-left: 30px;
+  }
 `;

+ 61 - 0
dashboard/src/main/home/cluster-dashboard/LastRunStatusSelector.tsx

@@ -0,0 +1,61 @@
+import React from "react";
+import styled from "styled-components";
+
+import Selector from "components/Selector";
+import { JobStatusType } from "shared/types";
+
+type PropsType = {
+  lastRunStatus: JobStatusType;
+  setLastRunStatus: (lastRunStatus: JobStatusType) => void;
+};
+
+const LastRunStatusSelector = (props: PropsType) => {
+  const options = [
+    {
+      label: "All",
+      value: null,
+    },
+  ].concat(
+    Object.entries(JobStatusType).map((status) => ({
+      label: status[0],
+      value: status[1],
+    }))
+  );
+
+  return (
+    <StyledLastRunStatusSelector>
+      <Label>
+        <i className="material-icons">filter_alt</i>
+        Last Run Status
+      </Label>
+      <Selector
+        activeValue={props.lastRunStatus}
+        setActiveValue={props.setLastRunStatus}
+        options={options}
+        dropdownLabel="Last Run Status"
+        width="150px"
+        dropdownWidth="230px"
+        closeOverlay={true}
+      />
+    </StyledLastRunStatusSelector>
+  );
+};
+
+export default LastRunStatusSelector;
+
+const Label = styled.div`
+  display: flex;
+  align-items: center;
+  margin-right: 12px;
+
+  > i {
+    margin-right: 8px;
+    font-size: 18px;
+  }
+`;
+
+const StyledLastRunStatusSelector = styled.div`
+  display: flex;
+  align-items: center;
+  font-size: 13px;
+`;

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/NamespaceSelector.tsx

@@ -100,7 +100,7 @@ export default class NamespaceSelector extends Component<PropsType, StateType> {
     return (
       <StyledNamespaceSelector>
         <Label>
-          <i className="material-icons">filter_alt</i> Filter
+          <i className="material-icons">filter_alt</i> Namespace
         </Label>
         <Selector
           activeValue={this.props.namespace}

+ 17 - 77
dashboard/src/main/home/cluster-dashboard/chart/Chart.tsx

@@ -2,44 +2,35 @@ 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 {
+  ChartType,
+  JobStatusType,
+  JobStatusWithTimeType,
+  StorageType,
+} from "shared/types";
 import { Context } from "shared/Context";
 import StatusIndicator from "components/StatusIndicator";
 import { pushFiltered } from "shared/routing";
-import { useWebsockets } from "shared/hooks/useWebsockets";
 import api from "shared/api";
 
 type Props = {
   chart: ChartType;
   controllers: Record<string, any>;
-  isJob: boolean;
-};
-
-type JobStatusType = {
-  status: "succeeded" | "running" | "failed";
-  start_time: string;
+  jobStatus: JobStatusWithTimeType;
 };
 
 const Chart: React.FunctionComponent<Props> = ({
   chart,
   controllers,
-  isJob,
+  jobStatus,
 }) => {
   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} />;
@@ -78,59 +69,6 @@ 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();
@@ -177,12 +115,14 @@ const Chart: React.FunctionComponent<Props> = ({
             margin_left={"17px"}
           />
           <LastDeployed>
-            {isJob && jobStatus?.status ? (
+            {jobStatus?.status ? (
               <>
                 <Dot>•</Dot>
                 <JobStatus status={jobStatus.status}>
-                  {jobStatus.status === "running" ? "Started" : "Last run"}{" "}
-                  {jobStatus.status} at {readableDate(jobStatus.start_time)}
+                  {jobStatus.status === JobStatusType.Running
+                    ? "Started running"
+                    : `Last run ${jobStatus.status}`}{" "}
+                  at {readableDate(jobStatus.start_time)}
                 </JobStatus>
               </>
             ) : (
@@ -332,15 +272,15 @@ const Title = styled.div`
   }
 `;
 
-const JobStatus = styled.span<{ status?: string }>`
+const JobStatus = styled.span<{ status?: JobStatusType }>`
   font-size: 13px;
   font-weight: ${(props) =>
-    props.status && props.status !== "running" ? "500" : ""};
+    props.status && props.status !== JobStatusType.Running ? "500" : ""};
   ${(props) => `
   color: ${
-    props.status === "succeeded"
+    props.status === JobStatusType.Succeeded
       ? "rgb(56, 168, 138)"
-      : props.status === "failed"
+      : props.status === JobStatusType.Failed
       ? "#ff385d"
       : "#aaaabb66"
   }`}

+ 133 - 22
dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx

@@ -1,9 +1,16 @@
 import React, { useContext, useEffect, useMemo, useState } from "react";
 import styled from "styled-components";
+import _ from "lodash";
 
 import { Context } from "shared/Context";
 import api from "shared/api";
-import { ChartType, ClusterType, StorageType } from "shared/types";
+import {
+  ChartType,
+  ClusterType,
+  JobStatusType,
+  JobStatusWithTimeType,
+  StorageType,
+} from "shared/types";
 import { PorterUrl } from "shared/routing";
 
 import Chart from "./Chart";
@@ -12,13 +19,19 @@ import { useWebsockets } from "shared/hooks/useWebsockets";
 
 type Props = {
   currentCluster: ClusterType;
+  lastRunStatus?: JobStatusType | null;
   namespace: string;
   // TODO Convert to enum
   sortType: string;
   currentView: PorterUrl;
 };
 
+interface JobStatusWithTimeAndVersion extends JobStatusWithTimeType {
+  resource_version: number;
+}
+
 const ChartList: React.FunctionComponent<Props> = ({
+  lastRunStatus,
   namespace,
   sortType,
   currentView,
@@ -33,11 +46,17 @@ const ChartList: React.FunctionComponent<Props> = ({
   const [controllers, setControllers] = useState<
     Record<string, Record<string, any>>
   >({});
+  const [jobStatus, setJobStatus] = useState<
+    Record<string, JobStatusWithTimeAndVersion>
+  >({});
   const [isLoading, setIsLoading] = useState(false);
   const [isError, setIsError] = useState(false);
 
   const context = useContext(Context);
 
+  const getChartKey = (name: string, namespace: string) =>
+    `${namespace}-${name}`;
+
   const updateCharts = async () => {
     try {
       const { currentCluster, currentProject } = context;
@@ -85,14 +104,14 @@ const ChartList: React.FunctionComponent<Props> = ({
 
     const wsConfig = {
       onopen: () => {
-        console.log("connected to chart live updates websocket");
+        console.log(`connected to websocket: ${websocketID}`);
       },
       onmessage: (evt: MessageEvent) => {
         let event = JSON.parse(evt.data);
         const newChart: ChartType = event.Object;
         const isSameChart = (chart: ChartType) =>
-          chart.name === newChart.name &&
-          chart.namespace === newChart.namespace;
+          getChartKey(chart.name, chart.namespace) ===
+          getChartKey(newChart.name, newChart.namespace);
         setCharts((currentCharts) => {
           switch (event.event_type) {
             case "ADD":
@@ -116,12 +135,12 @@ const ChartList: React.FunctionComponent<Props> = ({
       },
 
       onclose: () => {
-        console.log("closing chart live updates websocket");
+        console.log(`closing websocket: ${websocketID}`);
       },
 
       onerror: (err: ErrorEvent) => {
         console.log(err);
-        closeWebsocket("helm_releases");
+        closeWebsocket(websocketID);
       },
     };
 
@@ -135,7 +154,7 @@ const ChartList: React.FunctionComponent<Props> = ({
 
     const wsConfig = {
       onopen: () => {
-        console.log("connected to websocket");
+        console.log(`connected to websocket: ${kind}`);
       },
       onmessage: (evt: MessageEvent) => {
         let event = JSON.parse(evt.data);
@@ -148,7 +167,7 @@ const ChartList: React.FunctionComponent<Props> = ({
         }));
       },
       onclose: () => {
-        console.log("closing websocket");
+        console.log(`closing websocket: ${kind}`);
       },
       onerror: (err: ErrorEvent) => {
         console.log(err);
@@ -165,6 +184,76 @@ const ChartList: React.FunctionComponent<Props> = ({
     controllers.map((kind) => setupControllerWebsocket(kind));
   };
 
+  const setupJobWebsocket = (websocketID: string) => {
+    const kind = "job";
+    let { currentCluster, currentProject } = context;
+    const apiPath = `/api/projects/${currentProject.id}/k8s/${kind}/status?cluster_id=${currentCluster.id}`;
+
+    const wsConfig = {
+      onopen: () => {
+        console.log(`connected to websocket: ${websocketID}`);
+      },
+      onmessage: (evt: MessageEvent) => {
+        let event = JSON.parse(evt.data);
+        let object = event.Object;
+
+        if (_.get(object.metadata, ["annotations", "helm.sh/hook"])) {
+          return;
+        }
+
+        setJobStatus((currentStatus) => {
+          let nextStatus: JobStatusType = null;
+          for (const status of Object.values(JobStatusType)) {
+            if (_.get(object.status, status, 0) > 0) {
+              nextStatus = status;
+              break;
+            }
+          }
+
+          const chartName =
+            object.metadata.labels["app.kubernetes.io/instance"];
+          const chartNamespace = object.metadata.namespace;
+          const key = getChartKey(chartName, chartNamespace);
+
+          const existingValue: JobStatusWithTimeAndVersion = _.get(
+            currentStatus,
+            key,
+            null
+          );
+          const newValue: JobStatusWithTimeAndVersion = {
+            status: nextStatus,
+            start_time: object.status.startTime,
+            resource_version: object.metadata.resourceVersion,
+          };
+
+          if (
+            !existingValue ||
+            newValue.resource_version > existingValue.resource_version
+          ) {
+            return {
+              ...currentStatus,
+              [key]: newValue,
+            };
+          }
+
+          return currentStatus;
+        });
+      },
+      onclose: () => {
+        console.log(`closing websocket: ${websocketID}`);
+      },
+      onerror: (err: ErrorEvent) => {
+        console.log(err);
+        closeWebsocket(websocketID);
+      },
+    };
+
+    newWebsocket(websocketID, apiPath, wsConfig);
+
+    openWebsocket(websocketID);
+  };
+
+  // Setup basic websockets on start
   useEffect(() => {
     const controllers = [
       "deployment",
@@ -172,11 +261,14 @@ const ChartList: React.FunctionComponent<Props> = ({
       "daemonset",
       "replicaset",
     ];
-
     setupControllerWebsockets(controllers);
 
+    const jobWebsocketID = "job";
+    setupJobWebsocket(jobWebsocketID);
+
     return () => {
       controllers.map((controller) => closeWebsocket(controller));
+      closeWebsocket(jobWebsocketID);
     };
   }, []);
 
@@ -205,14 +297,29 @@ const ChartList: React.FunctionComponent<Props> = ({
   }, [namespace, currentView]);
 
   const filteredCharts = useMemo(() => {
-    const result = charts.filter((chart: ChartType) => {
-      return (
-        (currentView == "jobs" && chart.chart.metadata.name == "job") ||
-        ((currentView == "applications" ||
-          currentView == "cluster-dashboard") &&
-          chart.chart.metadata.name != "job")
-      );
-    });
+    const result = charts
+      .filter((chart: ChartType) => {
+        return (
+          (currentView == "jobs" && chart.chart.metadata.name == "job") ||
+          ((currentView == "applications" ||
+            currentView == "cluster-dashboard") &&
+            chart.chart.metadata.name != "job")
+        );
+      })
+      .filter((chart: ChartType) => {
+        if (currentView !== "jobs") {
+          return true;
+        }
+        if (lastRunStatus === null) {
+          return true;
+        }
+        const status: JobStatusWithTimeAndVersion = _.get(
+          jobStatus,
+          getChartKey(chart.name, chart.namespace),
+          null
+        );
+        return !status || status.status === lastRunStatus;
+      });
 
     if (sortType == "Newest") {
       result.sort((a: any, b: any) =>
@@ -231,7 +338,7 @@ const ChartList: React.FunctionComponent<Props> = ({
     }
 
     return result;
-  }, [charts, sortType]);
+  }, [charts, sortType, jobStatus, lastRunStatus]);
 
   const renderChartList = () => {
     if (isLoading || (!namespace && namespace !== "")) {
@@ -250,8 +357,8 @@ const ChartList: React.FunctionComponent<Props> = ({
       return (
         <Placeholder>
           <i className="material-icons">category</i> No
-          {currentView === "jobs" ? ` jobs` : ` charts`} found in this
-          namespace.
+          {currentView === "jobs" ? ` jobs` : ` charts`} found with the given
+          filters.
         </Placeholder>
       );
     }
@@ -259,10 +366,14 @@ const ChartList: React.FunctionComponent<Props> = ({
     return filteredCharts.map((chart: ChartType, i: number) => {
       return (
         <Chart
-          key={`${chart.namespace}-${chart.name}`}
+          key={getChartKey(chart.name, chart.namespace)}
           chart={chart}
           controllers={controllers || {}}
-          isJob={currentView === "jobs"}
+          jobStatus={_.get(
+            jobStatus,
+            getChartKey(chart.name, chart.namespace),
+            null
+          )}
         />
       );
     });

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

@@ -378,8 +378,14 @@ const ExpandedChart: React.FC<Props> = (props) => {
                   <Spinner src={loadingSrc} /> This application is currently
                   being deployed
                 </Header>
-                Navigate to the "Actions" tab of your GitHub repo to view live
-                build logs.
+                Navigate to the
+                <A
+                  href={`https://github.com/${props.currentChart.git_action_config.git_repo}/actions`}
+                  target={"_blank"}
+                >
+                  Actions tab
+                </A>{" "}
+                of your GitHub repo to view live build logs.
               </TextWrap>
             </Placeholder>
           );
@@ -1036,3 +1042,9 @@ const DeploymentTypeIcon = styled(Icon)`
   width: 20px;
   margin-right: 10px;
 `;
+
+const A = styled.a`
+  color: #8590ff;
+  text-decoration: underline;
+  cursor: pointer;
+`;

+ 15 - 2
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx

@@ -436,8 +436,14 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
                 <Header>
                   <Spinner src={loading} /> This job is currently being deployed
                 </Header>
-                Navigate to the "Actions" tab of your GitHub repo to view live
-                build logs.
+                Navigate to the
+                <A
+                    href={`https://github.com/${this.props.currentChart.git_action_config.git_repo}/actions`}
+                    target={"_blank"}
+                >
+                  Actions tab
+                </A>{" "}
+                of your GitHub repo to view live build logs.
               </TextWrap>
             </Placeholder>
           );
@@ -866,3 +872,10 @@ const TabButton = styled.div`
     margin-right: 9px;
   }
 `;
+
+const A = styled.a`
+  color: #8590ff;
+  text-decoration: underline;
+  margin-left: 5px;
+  cursor: pointer;
+`;

+ 23 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/RevisionSection.tsx

@@ -234,7 +234,23 @@ class RevisionSection extends Component<PropsType, StateType> {
         >
           <Td>{revision.version}</Td>
           <Td>{this.readableDate(revision.info.last_deployed)}</Td>
-          <Td>{parsedImageTag || "N/A"}</Td>
+          <Td>
+            {!imageTag ? (
+              "N/A"
+            ) : isGithubApp && /^[0-9A-Fa-f]{7}$/g.test(imageTag) ? (
+              <A
+                href={`https://github.com/${this.props.chart.git_action_config?.git_repo}/commit/${imageTag}`}
+                target="_blank"
+                onClick={(e) => {
+                  e.stopPropagation();
+                }}
+              >
+                {parsedImageTag}
+              </A>
+            ) : (
+              parsedImageTag
+            )}
+          </Td>
           <Td>v{revision.chart.metadata.version}</Td>
           <Td>
             <RollbackButton
@@ -536,3 +552,9 @@ const RevisionUpdateMessage = styled.div`
     transform: none;
   }
 `;
+
+const A = styled.a`
+  color: #8590ff;
+  text-decoration: underline;
+  cursor: pointer;
+`;

+ 1 - 0
dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx

@@ -329,6 +329,7 @@ const LaunchFlow: React.FC<PropsType> = (props) => {
       return (
         <WorkflowPage
           name={templateName}
+          namespace={"default"}
           fullActionConfig={fullActionConfig}
           shouldCreateWorkflow={shouldCreateWorkflow}
           setShouldCreateWorkflow={setShouldCreateWorkflow}

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

@@ -12,6 +12,7 @@ import SaveButton from "../../../../components/SaveButton";
 
 type PropsType = {
   name: string;
+  namespace: string;
   fullActionConfig: FullActionConfigType;
   shouldCreateWorkflow: boolean;
   setShouldCreateWorkflow: (x: (prevState: boolean) => boolean) => void;
@@ -31,6 +32,7 @@ const WorkflowPage: React.FC<PropsType> = (props) => {
     api
       .generateGHAWorkflow("<token>", props.fullActionConfig, {
         name: props.name,
+        namespace: props.namespace,
         cluster_id: currentCluster.id,
         project_id: currentProject.id,
       })

+ 3 - 2
dashboard/src/shared/api.tsx

@@ -279,11 +279,12 @@ const generateGHAWorkflow = baseApi<
     cluster_id: number;
     project_id: number;
     name: string;
+    namespace: string;
   }
 >("POST", (pathParams) => {
-  const { name, cluster_id, project_id } = pathParams;
+  const { name, namespace, cluster_id, project_id } = pathParams;
 
-  return `/api/projects/${project_id}/ci/actions/generate?cluster_id=${cluster_id}&name=${name}`;
+  return `/api/projects/${project_id}/ci/actions/generate?cluster_id=${cluster_id}&name=${name}&namespace=${namespace}`;
 });
 
 const deployTemplate = baseApi<

+ 83 - 0
dashboard/src/shared/baseApi.ts

@@ -0,0 +1,83 @@
+import axios, { AxiosPromise, AxiosRequestConfig, Method } from "axios";
+import qs from "qs";
+
+type EndpointParam<PathParamsType> =
+  | string
+  | ((pathParams: PathParamsType) => string);
+
+type BuildAxiosConfigFunction = (
+  method: Method,
+  endpoint: EndpointParam<unknown>,
+  token: string,
+  params: unknown,
+  pathParams: unknown
+) => AxiosRequestConfig;
+
+const buildAxiosConfig: BuildAxiosConfigFunction = (
+  method,
+  endpoint,
+  token,
+  params,
+  pathParams
+) => {
+  const config: AxiosRequestConfig = {
+    method,
+    url: typeof endpoint === "function" ? endpoint(pathParams) : endpoint,
+  };
+
+  const AuthHeaders = {
+    Authorization: `Bearer ${token}`,
+  };
+
+  if (method.toUpperCase() === "POST") {
+    return {
+      ...config,
+      data: params,
+      headers: AuthHeaders,
+    };
+  }
+
+  if (method.toUpperCase() === "PUT") {
+    return {
+      ...config,
+      data: params,
+      headers: AuthHeaders,
+    };
+  }
+
+  if (method.toUpperCase() === "DELETE") {
+    const queryParams = qs.stringify(params, {
+      arrayFormat: "repeat",
+    });
+    return {
+      ...config,
+      url: `${config.url}?${queryParams}`,
+    };
+  }
+
+  if (method.toUpperCase() === "GET") {
+    return {
+      ...config,
+      params: params,
+      paramsSerializer: (params) =>
+        qs.stringify(params, { arrayFormat: "repeat" }),
+    };
+  }
+
+  return config;
+};
+
+const apiQueryBuilder = <ParamsType extends {}, PathParamsType = {}>(
+  method: Method = "GET",
+  endpoint: EndpointParam<PathParamsType>
+) => <ResponseType = any>(
+  token: string,
+  params: ParamsType,
+  pathParams: PathParamsType
+) =>
+  axios(
+    buildAxiosConfig(method, endpoint, token, params, pathParams)
+  ) as AxiosPromise<ResponseType>;
+
+export { apiQueryBuilder as baseApi };
+export default apiQueryBuilder;

+ 0 - 46
dashboard/src/shared/baseApi.tsx

@@ -1,46 +0,0 @@
-import axios from "axios";
-import qs from "qs";
-
-// axios.defaults.timeout = 10000;
-
-// Partial function that accepts a generic params type and returns an api method
-export const baseApi = <T extends {}, S = {}>(
-  requestType: string,
-  endpoint: ((pathParams: S) => string) | string
-) => {
-  return (token: string, params: T, pathParams: S) => {
-    // Generate endpoint literal
-    let endpointString: ((pathParams: S) => string) | string;
-    if (typeof endpoint === "string") {
-      endpointString = endpoint;
-    } else {
-      endpointString = endpoint(pathParams);
-    }
-
-    // Handle request type (can refactor)
-    if (requestType === "POST") {
-      return axios.post(endpointString, params, {
-        headers: {
-          Authorization: `Bearer ${token}`,
-        },
-      });
-    } else if (requestType === "PUT") {
-      return axios.put(endpointString, params, {
-        headers: {
-          Authorization: `Bearer ${token}`,
-        },
-      });
-    } else if (requestType === "DELETE") {
-      return axios.delete(
-        endpointString + "?" + qs.stringify(params, { arrayFormat: "repeat" })
-      );
-    } else {
-      return axios.get(endpointString, {
-        params,
-        paramsSerializer: function (params) {
-          return qs.stringify(params, { arrayFormat: "repeat" });
-        },
-      });
-    }
-  };
-};

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

@@ -297,3 +297,14 @@ export interface ContextProps {
   setCapabilities: (capabilities: CapabilityType) => void;
   clearContext: () => void;
 }
+
+export enum JobStatusType {
+  Succeeded = "succeeded",
+  Running = "active",
+  Failed = "failed",
+}
+
+export interface JobStatusWithTimeType {
+  status: JobStatusType;
+  start_time: string;
+}

+ 14 - 1
docs/deploy/applications/deploying-from-the-cli.md

@@ -118,6 +118,19 @@ If you would only like to update the configuration for your application via a `v
 porter update config --app [app-name] --values [values-file]
 ```
 
+For example, to update the app `web-test`, and to programmatically set the environment variables for that application, create a file called `web-test-values.yaml` with the following structure:
+
+```yaml
+container:
+  env:
+    normal:
+      TESTING: test-from-cli
+```
+
+If I then run `porter update config --app web-test --values web-test-values.yaml`, I will now see the new values in the application:
+
+![CLI env vars](https://files.readme.io/1c30b1c-Screen_Shot_2021-08-20_at_11.51.41_AM.png "Screen Shot 2021-08-20 at 11.51.41 AM.png")
+
 # Common Configuration Options
 
 ## Container Port
@@ -150,7 +163,7 @@ This configuration only applies to `web` applications.
 ```yaml
 ingress:
   custom_domain: true
-  custom_paths:
+  hosts:
   - my-app.example.com
 ```
 

+ 3 - 2
internal/analytics/track_events.go

@@ -4,8 +4,9 @@ type SegmentEvent string
 
 const (
 	// onboarding flow
-	UserCreate    SegmentEvent = "New User"
-	ProjectCreate SegmentEvent = "New Project Event"
+	UserCreate      SegmentEvent = "New User"
+	UserVerifyEmail SegmentEvent = "User Verified Email"
+	ProjectCreate   SegmentEvent = "New Project Event"
 
 	ClusterProvisioningStart   SegmentEvent = "Cluster Provisioning Started"
 	ClusterProvisioningError   SegmentEvent = "Cluster Provisioning Error"

+ 21 - 0
internal/analytics/tracks.go

@@ -79,11 +79,14 @@ func (p segmentProperties) addAdditionalProperties(props map[string]interface{})
 // UserCreateTrackOpts are the options for creating a track when a user is created
 type UserCreateTrackOpts struct {
 	*UserScopedTrackOpts
+
+	Email string
 }
 
 // UserCreateTrack returns a track for when a user is created
 func UserCreateTrack(opts *UserCreateTrackOpts) segmentTrack {
 	additionalProps := make(map[string]interface{})
+	additionalProps["email"] = opts.Email
 
 	return getSegmentUserTrack(
 		opts.UserScopedTrackOpts,
@@ -91,6 +94,24 @@ func UserCreateTrack(opts *UserCreateTrackOpts) segmentTrack {
 	)
 }
 
+// UserCreateTrackOpts are the options for creating a track when a user's email is verified
+type UserVerifyEmailTrackOpts struct {
+	*UserScopedTrackOpts
+
+	Email string
+}
+
+// UserVerifyEmailTrack returns a track for when a user's email is verified
+func UserVerifyEmailTrack(opts *UserVerifyEmailTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+	additionalProps["email"] = opts.Email
+
+	return getSegmentUserTrack(
+		opts.UserScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, UserVerifyEmail),
+	)
+}
+
 // ProjectCreateTrackOpts are the options for creating a track when a project is created
 type ProjectCreateTrackOpts struct {
 	*ProjectScopedTrackOpts

+ 1 - 1
internal/forms/git_action.go

@@ -10,7 +10,7 @@ type CreateGitAction struct {
 	Release *models.Release
 
 	GitRepo        string `json:"git_repo" form:"required"`
-	GitBranch      string `json:"git_branch"`
+	GitBranch      string `json:"branch"`
 	ImageRepoURI   string `json:"image_repo_uri" form:"required"`
 	DockerfilePath string `json:"dockerfile_path"`
 	FolderPath     string `json:"folder_path"`

+ 7 - 6
internal/integrations/ci/actions/actions.go

@@ -33,11 +33,12 @@ type GithubActions struct {
 	GithubAppSecretPath  string
 	GithubInstallationID uint
 
-	PorterToken string
-	BuildEnv    map[string]string
-	ProjectID   uint
-	ClusterID   uint
-	ReleaseName string
+	PorterToken      string
+	BuildEnv         map[string]string
+	ProjectID        uint
+	ClusterID        uint
+	ReleaseName      string
+	ReleaseNamespace string
 
 	GitBranch      string
 	DockerFilePath string
@@ -178,7 +179,7 @@ func (g *GithubActions) GetGithubActionYAML() ([]byte, error) {
 	gaSteps := []GithubActionYAMLStep{
 		getCheckoutCodeStep(),
 		getSetTagStep(),
-		getUpdateAppStep(g.ServerURL, g.getPorterTokenSecretName(), g.ProjectID, g.ClusterID, g.ReleaseName, g.Version),
+		getUpdateAppStep(g.ServerURL, g.getPorterTokenSecretName(), g.ProjectID, g.ClusterID, g.ReleaseName, g.ReleaseNamespace, g.Version),
 	}
 
 	branch := g.GitBranch

+ 8 - 7
internal/integrations/ci/actions/steps.go

@@ -21,17 +21,18 @@ func getSetTagStep() GithubActionYAMLStep {
 	}
 }
 
-func getUpdateAppStep(serverURL, porterTokenSecretName string, projectID uint, clusterID uint, appName string, actionVersion string) GithubActionYAMLStep {
+func getUpdateAppStep(serverURL, porterTokenSecretName string, projectID uint, clusterID uint, appName string, appNamespace, actionVersion string) GithubActionYAMLStep {
 	return GithubActionYAMLStep{
 		Name: "Update Porter App",
 		Uses: fmt.Sprintf("%s@%s", updateAppActionName, actionVersion),
 		With: map[string]string{
-			"app":     appName,
-			"cluster": fmt.Sprintf("%d", clusterID),
-			"host":    serverURL,
-			"project": fmt.Sprintf("%d", projectID),
-			"token":   fmt.Sprintf("${{ secrets.%s }}", porterTokenSecretName),
-			"tag":     "${{ steps.vars.outputs.sha_short }}",
+			"app":       appName,
+			"cluster":   fmt.Sprintf("%d", clusterID),
+			"host":      serverURL,
+			"project":   fmt.Sprintf("%d", projectID),
+			"token":     fmt.Sprintf("${{ secrets.%s }}", porterTokenSecretName),
+			"tag":       "${{ steps.vars.outputs.sha_short }}",
+			"namespace": appNamespace,
 		},
 		Timeout: 20,
 	}

+ 2 - 1
server/api/deploy_handler.go

@@ -199,7 +199,7 @@ func (app *App) HandleDeployTemplate(w http.ResponseWriter, r *http.Request) {
 			return
 		}
 
-		app.createGitActionFromForm(projID, clusterID, form.ChartTemplateForm.Name, gaForm, w, r)
+		app.createGitActionFromForm(projID, clusterID, form.ChartTemplateForm.Name, form.ReleaseForm.Form.Namespace, gaForm, w, r)
 	}
 
 	app.AnalyticsClient.Track(analytics.ApplicationLaunchSuccessTrack(
@@ -442,6 +442,7 @@ func (app *App) HandleUninstallTemplate(w http.ResponseWriter, r *http.Request)
 					GithubConf:             app.GithubProjectConf,
 					ProjectID:              uint(projID),
 					ReleaseName:            name,
+					ReleaseNamespace:       release.Namespace,
 					GitBranch:              gitAction.GitBranch,
 					DockerFilePath:         gitAction.DockerfilePath,
 					FolderPath:             gitAction.FolderPath,

+ 5 - 3
server/api/git_action_handler.go

@@ -32,6 +32,7 @@ func (app *App) HandleGenerateGitAction(w http.ResponseWriter, r *http.Request)
 
 	vals, err := url.ParseQuery(r.URL.RawQuery)
 	name := vals["name"][0]
+	namespace := vals["namespace"][0]
 
 	clusterID, err := strconv.ParseUint(vals["cluster_id"][0], 10, 64)
 
@@ -53,7 +54,7 @@ func (app *App) HandleGenerateGitAction(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	_, workflowYAML := app.createGitActionFromForm(projID, clusterID, name, form, w, r)
+	_, workflowYAML := app.createGitActionFromForm(projID, clusterID, name, namespace, form, w, r)
 
 	w.WriteHeader(http.StatusOK)
 
@@ -106,7 +107,7 @@ func (app *App) HandleCreateGitAction(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	gaExt, _ := app.createGitActionFromForm(projID, clusterID, name, form, w, r)
+	gaExt, _ := app.createGitActionFromForm(projID, clusterID, name, namespace, form, w, r)
 
 	w.WriteHeader(http.StatusCreated)
 
@@ -119,7 +120,7 @@ func (app *App) HandleCreateGitAction(w http.ResponseWriter, r *http.Request) {
 func (app *App) createGitActionFromForm(
 	projID,
 	clusterID uint64,
-	name string,
+	name, namespace string,
 	form *forms.CreateGitAction,
 	w http.ResponseWriter,
 	r *http.Request,
@@ -208,6 +209,7 @@ func (app *App) createGitActionFromForm(
 		ProjectID:              uint(projID),
 		ClusterID:              uint(clusterID),
 		ReleaseName:            name,
+		ReleaseNamespace:       namespace,
 		GitBranch:              form.GitBranch,
 		DockerFilePath:         form.DockerfilePath,
 		FolderPath:             form.FolderPath,

+ 1 - 0
server/api/oauth_github_handler.go

@@ -135,6 +135,7 @@ func (app *App) HandleGithubOAuthCallback(w http.ResponseWriter, r *http.Request
 
 		app.AnalyticsClient.Track(analytics.UserCreateTrack(&analytics.UserCreateTrackOpts{
 			UserScopedTrackOpts: analytics.GetUserScopedTrackOpts(user.ID),
+			Email:               user.Email,
 		}))
 
 		// log the user in

+ 1 - 0
server/api/oauth_google_handler.go

@@ -99,6 +99,7 @@ func (app *App) HandleGoogleOAuthCallback(w http.ResponseWriter, r *http.Request
 
 	app.AnalyticsClient.Track(analytics.UserCreateTrack(&analytics.UserCreateTrackOpts{
 		UserScopedTrackOpts: analytics.GetUserScopedTrackOpts(user.ID),
+		Email:               user.Email,
 	}))
 
 	// log the user in

+ 2 - 0
server/api/release_handler.go

@@ -1136,6 +1136,7 @@ func (app *App) HandleUpgradeRelease(w http.ResponseWriter, r *http.Request) {
 					GithubConf:             app.GithubProjectConf,
 					ProjectID:              uint(projID),
 					ReleaseName:            name,
+					ReleaseNamespace:       release.Namespace,
 					GitBranch:              gitAction.GitBranch,
 					DockerFilePath:         gitAction.DockerfilePath,
 					FolderPath:             gitAction.FolderPath,
@@ -1586,6 +1587,7 @@ func (app *App) HandleRollbackRelease(w http.ResponseWriter, r *http.Request) {
 					GithubConf:             app.GithubProjectConf,
 					ProjectID:              uint(projID),
 					ReleaseName:            name,
+					ReleaseNamespace:       release.Namespace,
 					GitBranch:              gitAction.GitBranch,
 					DockerFilePath:         gitAction.DockerfilePath,
 					FolderPath:             gitAction.FolderPath,

+ 8 - 2
server/api/user_handler.go

@@ -58,6 +58,7 @@ func (app *App) HandleCreateUser(w http.ResponseWriter, r *http.Request) {
 
 		app.AnalyticsClient.Track(analytics.UserCreateTrack(&analytics.UserCreateTrackOpts{
 			UserScopedTrackOpts: analytics.GetUserScopedTrackOpts(user.ID),
+			Email:               user.Email,
 		}))
 
 		app.Logger.Info().Msgf("New user created: %d", user.ID)
@@ -402,8 +403,8 @@ func (app *App) InitiateEmailVerifyUser(w http.ResponseWriter, r *http.Request)
 	w.WriteHeader(http.StatusOK)
 }
 
-// FinalizEmailVerifyUser completes the email verification flow for a user.
-func (app *App) FinalizEmailVerifyUser(w http.ResponseWriter, r *http.Request) {
+// FinalizeEmailVerifyUser completes the email verification flow for a user.
+func (app *App) FinalizeEmailVerifyUser(w http.ResponseWriter, r *http.Request) {
 	userID, err := app.getUserIDFromRequest(r)
 
 	if err != nil {
@@ -488,6 +489,11 @@ func (app *App) FinalizEmailVerifyUser(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	app.AnalyticsClient.Track(analytics.UserVerifyEmailTrack(&analytics.UserVerifyEmailTrackOpts{
+		UserScopedTrackOpts: analytics.GetUserScopedTrackOpts(user.ID),
+		Email:               user.Email,
+	}))
+
 	http.Redirect(w, r, "/dashboard", 302)
 	return
 }

+ 1 - 1
server/router/router.go

@@ -128,7 +128,7 @@ func New(a *api.App) *chi.Mux {
 				"GET",
 				"/email/verify/finalize",
 				auth.BasicAuthenticateWithRedirect(
-					requestlog.NewHandler(a.FinalizEmailVerifyUser, l),
+					requestlog.NewHandler(a.FinalizeEmailVerifyUser, l),
 				),
 			)