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

POR-2025 deprecate revision list and handle reverts from activity feed (#3927)

ianedwards 2 лет назад
Родитель
Сommit
46da895645

+ 82 - 0
api/server/handlers/porter_app/update_build_settings.go

@@ -0,0 +1,82 @@
+package porter_app
+
+import (
+	"net/http"
+
+	"connectrpc.com/connect"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// UpdateAppBuildSettingsHandler handles requests to the POST /apps/{porter_app_name}/build endpoint
+type UpdateAppBuildSettingsHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+// NewUpdateAppBuildSettingsHandler returns a new UpdateAppBuildSettingsHandler
+func NewUpdateAppBuildSettingsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *UpdateAppBuildSettingsHandler {
+	return &UpdateAppBuildSettingsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+// UpdateAppBuildSettingsRequest is the request object for the POST /apps/{porter_app_name}/build endpoint
+type UpdateAppBuildSettingsRequest struct {
+	BuildSettings BuildSettings `json:"build_settings"`
+}
+
+// UpdateAppBuildSettingsResponse is the response object for the POST /apps/{porter_app_name}/build endpoint
+type UpdateAppBuildSettingsResponse struct{}
+
+// ServeHTTP sends an update build settings request to CCP and processes the response
+func (c *UpdateAppBuildSettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-update-app-build-settings")
+	defer span.End()
+
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+
+	// read the request object from the decoder
+	request := &UpdateAppBuildSettingsRequest{}
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		err := telemetry.Error(ctx, span, nil, "error decoding request")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	updateReq := connect.NewRequest(&porterv1.UpdateAppBuildSettingsRequest{
+		ProjectId: int64(project.ID),
+		Build: &porterv1.Build{
+			Method:     request.BuildSettings.Method,
+			Context:    request.BuildSettings.Context,
+			Dockerfile: request.BuildSettings.Dockerfile,
+			Builder:    request.BuildSettings.Builder,
+			Buildpacks: request.BuildSettings.Buildpacks,
+			CommitSha:  request.BuildSettings.CommitSHA,
+		},
+	})
+
+	ccpResp, err := c.Config().ClusterControlPlaneClient.UpdateAppBuildSettings(ctx, updateReq)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error updating app build settings")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	if ccpResp == nil || ccpResp.Msg == nil {
+		err := telemetry.Error(ctx, span, nil, "ccp response is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	c.WriteResult(w, r, &UpdateAppBuildSettingsResponse{})
+}

+ 29 - 0
api/server/router/porter_app.go

@@ -891,6 +891,35 @@ func getPorterAppRoutes(
 		Router:   r,
 	})
 
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/apps/{porter_app_name}/build -> porter_app.NewUpdateAppBuildSettingsHandler
+	updateAppBuildSettingsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/{%s}/build", relPathV2, types.URLParamPorterAppName),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	updateAppBuildSettingsHandler := porter_app.NewUpdateAppBuildSettingsHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: updateAppBuildSettingsEndpoint,
+		Handler:  updateAppBuildSettingsHandler,
+		Router:   r,
+	})
+
 	// GET /api/projects/{project_id}/clusters/{cluster_id}/apps/revisions -> porter_app.NewCurrentAppRevisionHandler
 	latestAppRevisionsEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

+ 7 - 0
dashboard/.eslintrc.json

@@ -44,6 +44,13 @@
       {
         "ignoreVoid": true
       }
+    ],
+    "@typescript-eslint/prefer-nullish-coalescing": [
+      "error",
+      {
+        "ignoreConditionalTests": true,
+        "ignoreMixedLogicalExpressions": true
+      }
     ]
   }
 }

+ 1 - 0
dashboard/.prettierrc.json

@@ -15,6 +15,7 @@
     "^shared/(.*)$",
     "^utils/(.*)$",
     "^assets/(.*)$",
+    "",
     "^[./]"
   ],
   "importOrderSeparation": false,

+ 37 - 18
dashboard/src/lib/hooks/useRevisionList.ts

@@ -1,31 +1,46 @@
-import { useQuery } from "@tanstack/react-query";
 import { useEffect, useState } from "react";
-import api from "shared/api";
+import { useQuery } from "@tanstack/react-query";
 import { z } from "zod";
-import { AppRevision, appRevisionValidator } from "../revisions/types";
+
 import { useLatestRevision } from "main/home/app-dashboard/app-view/LatestRevisionContext";
 
+import api from "shared/api";
+
+import { appRevisionValidator, type AppRevision } from "../revisions/types";
+
 export function useRevisionList({
   appName,
   deploymentTargetId,
   projectId,
   clusterId,
 }: {
-  appName: string,
-  deploymentTargetId: string,
-  projectId: number,
-  clusterId: number
-}): { revisionList: AppRevision[], revisionIdToNumber: Record<string, number>, numberToRevisionId: Record<number, string> } {
-  const [
-    revisionList,
-    setRevisionList,
-  ] = useState<AppRevision[]>([]);
-  const [revisionIdToNumber, setRevisionIdToNumber] = useState<Record<string, number>>({});
-  const [numberToRevisionId, setNumberToRevisionId] = useState<Record<number, string>>({});
+  appName: string;
+  deploymentTargetId: string;
+  projectId: number;
+  clusterId: number;
+}): {
+  revisionList: AppRevision[];
+  revisionIdToNumber: Record<string, number>;
+  numberToRevisionId: Record<number, string>;
+} {
+  const [revisionList, setRevisionList] = useState<AppRevision[]>([]);
+  const [revisionIdToNumber, setRevisionIdToNumber] = useState<
+    Record<string, number>
+  >({});
+  const [numberToRevisionId, setNumberToRevisionId] = useState<
+    Record<number, string>
+  >({});
   const { latestRevision } = useLatestRevision();
 
   const { data } = useQuery(
-    ["listAppRevisions", projectId, clusterId, appName, deploymentTargetId, latestRevision],
+    [
+      "listAppRevisions",
+      projectId,
+      clusterId,
+      appName,
+      deploymentTargetId,
+      latestRevision,
+    ],
     async () => {
       const res = await api.listAppRevisions(
         "<token>",
@@ -54,10 +69,14 @@ export function useRevisionList({
 
   useEffect(() => {
     if (data) {
-      const revisionList = data.app_revisions
+      const revisionList = data.app_revisions;
       setRevisionList(revisionList);
-      setRevisionIdToNumber(Object.fromEntries(revisionList.map(r => ([r.id, r.revision_number]))))
-      setNumberToRevisionId(Object.fromEntries(revisionList.map(r => ([r.revision_number, r.id]))))
+      setRevisionIdToNumber(
+        Object.fromEntries(revisionList.map((r) => [r.id, r.revision_number]))
+      );
+      setNumberToRevisionId(
+        Object.fromEntries(revisionList.map((r) => [r.revision_number, r.id]))
+      );
     }
   }, [data]);
 

+ 93 - 67
dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx

@@ -5,49 +5,51 @@ import React, {
   useMemo,
   useState,
 } from "react";
-import { FormProvider, useForm } from "react-hook-form";
-import {
-  PorterAppFormData,
-  SourceOptions,
-  clientAppFromProto,
-  porterAppFormValidator,
-} from "lib/porter-apps";
 import { zodResolver } from "@hookform/resolvers/zod";
-import { useLatestRevision } from "./LatestRevisionContext";
-import Spacer from "components/porter/Spacer";
-import TabSelector from "components/TabSelector";
-import { useHistory } from "react-router";
-import { match } from "ts-pattern";
-import Overview from "./tabs/Overview";
-import { useAppValidation } from "lib/hooks/useAppValidation";
-import api from "shared/api";
+import { PorterApp } from "@porter-dev/api-contracts";
 import { useQueryClient } from "@tanstack/react-query";
-import Settings from "./tabs/Settings";
-import BuildSettingsTab from "./tabs/BuildSettingsTab";
-import Environment from "./tabs/Environment";
+import axios from "axios";
+import _ from "lodash";
 import AnimateHeight from "react-animate-height";
+import { FormProvider, useForm } from "react-hook-form";
+import { useHistory } from "react-router";
+import { match } from "ts-pattern";
+import { z } from "zod";
+
 import Banner from "components/porter/Banner";
 import Button from "components/porter/Button";
+import { Error as ErrorComponent } from "components/porter/Error";
 import Icon from "components/porter/Icon";
+import Spacer from "components/porter/Spacer";
+import TabSelector from "components/TabSelector";
+import { useAppAnalytics } from "lib/hooks/useAppAnalytics";
+import { useAppValidation } from "lib/hooks/useAppValidation";
+import { useIntercom } from "lib/hooks/useIntercom";
+import {
+  clientAppFromProto,
+  porterAppFormValidator,
+  type PorterAppFormData,
+  type SourceOptions,
+} from "lib/porter-apps";
+
+import api from "shared/api";
+import { Context } from "shared/Context";
 import save from "assets/save-01.svg";
-import LogsTab from "./tabs/LogsTab";
-import MetricsTab from "./tabs/MetricsTab";
-import RevisionsList from "../validate-apply/revisions-list/RevisionsList";
+
+import ConfirmRedeployModal from "./ConfirmRedeployModal";
+import { useLatestRevision } from "./LatestRevisionContext";
 import Activity from "./tabs/Activity";
 import EventFocusView from "./tabs/activity-feed/events/focus-views/EventFocusView";
-import { z } from "zod";
-import { PorterApp } from "@porter-dev/api-contracts";
-import JobsTab from "./tabs/JobsTab";
-import ConfirmRedeployModal from "./ConfirmRedeployModal";
-import ImageSettingsTab from "./tabs/ImageSettingsTab";
-import { useAppAnalytics } from "lib/hooks/useAppAnalytics";
-import { Error as ErrorComponent } from "components/porter/Error";
-import _ from "lodash";
-import axios from "axios";
+import BuildSettingsTab from "./tabs/BuildSettingsTab";
+import Environment from "./tabs/Environment";
 import HelmEditorTab from "./tabs/HelmEditorTab";
 import HelmLatestValuesTab from "./tabs/HelmLatestValuesTab";
-import { Context } from "shared/Context";
-import { useIntercom } from "lib/hooks/useIntercom";
+import ImageSettingsTab from "./tabs/ImageSettingsTab";
+import JobsTab from "./tabs/JobsTab";
+import LogsTab from "./tabs/LogsTab";
+import MetricsTab from "./tabs/MetricsTab";
+import Overview from "./tabs/Overview";
+import Settings from "./tabs/Settings";
 
 // commented out tabs are not yet implemented
 // will be included as support is available based on data from app revisions rather than helm releases
@@ -67,7 +69,7 @@ const validTabs = [
   "job-history",
 ] as const;
 const DEFAULT_TAB = "activity";
-type ValidTab = typeof validTabs[number];
+type ValidTab = (typeof validTabs)[number];
 
 type AppDataContainerProps = {
   tabParam?: string;
@@ -164,12 +166,12 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
 
   // getAllDirtyFields recursively gets all dirty fields from the dirtyFields object
   // all fields in the form are set to a boolean indicating if the current value is different from the default value
-  const getAllDirtyFields = (dirtyFields: object) => {
+  const getAllDirtyFields = (dirtyFields: object): string[] => {
     const dirty: string[] = [];
 
     Object.entries(dirtyFields).forEach(([key, value]) => {
       if (value) {
-        if (typeof value === "boolean" && value === true) {
+        if (typeof value === "boolean" && value) {
           dirty.push(key);
         }
 
@@ -218,25 +220,58 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
         return;
       }
 
+      if (currentProject?.beta_features_enabled && !needsRebuild) {
+        await api.updateApp(
+          "<token>",
+          {
+            b64_app_proto: btoa(validatedAppProto.toJsonString()),
+            deployment_target_id: deploymentTarget.id,
+            variables,
+            secrets,
+            is_env_override: true,
+          },
+          {
+            project_id: projectId,
+            cluster_id: clusterId,
+          }
+        );
+      }
+
       // force_build will create a new 0 revision that will not be deployed
       // but will be used to hydrate values when the workflow is run
-      await api.applyApp(
-        "<token>",
-        {
-          b64_app_proto: btoa(validatedAppProto.toJsonString()),
-          deployment_target_id: deploymentTarget.id,
-          force_build: needsRebuild,
-          variables,
-          secrets,
-          hard_env_update: true,
-        },
-        {
-          project_id: projectId,
-          cluster_id: clusterId,
-        }
-      );
+      if (!currentProject?.beta_features_enabled) {
+        await api.applyApp(
+          "<token>",
+          {
+            b64_app_proto: btoa(validatedAppProto.toJsonString()),
+            deployment_target_id: deploymentTarget.id,
+            force_build: needsRebuild,
+            variables,
+            secrets,
+            hard_env_update: true,
+          },
+          {
+            project_id: projectId,
+            cluster_id: clusterId,
+          }
+        );
+      }
 
       if (latestSource.type === "github" && needsRebuild) {
+        if (currentProject?.beta_features_enabled && validatedAppProto.build) {
+          await api.updateBuildSettings(
+            "<token>",
+            {
+              build_settings: validatedAppProto.build,
+            },
+            {
+              project_id: projectId,
+              cluster_id: clusterId,
+              porter_app_name: porterAppRecord.name,
+            }
+          );
+        }
+
         const res = await api.reRunGHWorkflow(
           "<token>",
           {},
@@ -292,7 +327,7 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
         stack = err.stack ?? "(No error stack)";
       }
 
-      updateAppStep({
+      void updateAppStep({
         step: "porter-app-update-failure",
         errorMessage: message,
         appName: latestProto.name,
@@ -344,7 +379,7 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
 
   const finalizeDeploy = useCallback(() => {
     setConfirmDeployModalOpen(false);
-    onSubmit();
+    void onSubmit();
   }, [onSubmit, setConfirmDeployModalOpen]);
 
   const buttonStatus = useMemo(() => {
@@ -383,7 +418,7 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
       showIntercomWithMessage({
         message: "I am running into an issue updating my application.",
       });
-      updateAppStep({
+      void updateAppStep({
         step: "porter-app-update-failure",
         errorMessage: `Form validation error (visible to user): ${errorMessage}. Stringified JSON errors (invisible to user): ${stringifiedJson}`,
         appName: latestProto.name,
@@ -424,14 +459,13 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
       });
     }
 
-    {
-      (currentProject?.helm_values_enabled || user?.isPorterUser) &&
-        base.push({ label: "Helm Overrides", value: "helm-overrides" });
+    if ((currentProject?.helm_values_enabled ?? false) || user?.isPorterUser) {
+      base.push({ label: "Helm Overrides", value: "helm-overrides" });
     }
-    {
-      user?.isPorterUser &&
-        base.push({ label: "Latest Helm Values", value: "helm-values" });
+    if (user?.isPorterUser) {
+      base.push({ label: "Latest Helm Values", value: "helm-values" });
     }
+
     base.push({ label: "Settings", value: "settings" });
     return base;
   }, [deploymentTarget.preview, latestProto.build]);
@@ -483,14 +517,6 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
   return (
     <FormProvider {...porterAppFormMethods}>
       <form onSubmit={onSubmit}>
-        <RevisionsList
-          latestRevisionNumber={latestRevision.revision_number}
-          deploymentTargetId={deploymentTarget.id}
-          projectId={projectId}
-          clusterId={clusterId}
-          appName={porterAppRecord.name}
-          onSubmit={onSubmit}
-        />
         <AnimateHeight height={isDirty && !onlyExpandedChanged ? "auto" : 0}>
           <Banner
             type="warning"

+ 110 - 24
dashboard/src/main/home/app-dashboard/app-view/AppHeader.tsx

@@ -1,21 +1,29 @@
 import React, { useMemo } from "react";
+import { type PorterApp } from "@porter-dev/api-contracts";
+import styled from "styled-components";
 
 import Container from "components/porter/Container";
 import Icon from "components/porter/Icon";
+import Link from "components/porter/Link";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import { prefixSubdomain } from "lib/porter-apps/services";
 
-import web from "assets/web.png";
+import PullRequestIcon from "shared/icons/PullRequest";
+import { readableDate } from "shared/string_utils";
 import box from "assets/box.png";
 import github from "assets/github-white.png";
-import pr_icon from "assets/pull_request_icon.svg";
+import pull_request_icon from "assets/pull_request_icon.svg";
+import tag_icon from "assets/tag.png";
+import web from "assets/web.png";
 
-import { PorterApp } from "@porter-dev/api-contracts";
-import Spacer from "components/porter/Spacer";
-import Text from "components/porter/Text";
-import styled from "styled-components";
+import GHStatusBanner from "../validate-apply/revisions-list/GHStatusBanner";
 import { useLatestRevision } from "./LatestRevisionContext";
-import { prefixSubdomain } from "lib/porter-apps/services";
-import { readableDate } from "shared/string_utils";
-import PullRequestIcon from "shared/icons/PullRequest";
+import {
+  Code,
+  CommitIcon,
+  ImageTagContainer,
+} from "./tabs/activity-feed/events/cards/EventCard";
 
 // Buildpack icons
 const icons = [
@@ -27,12 +35,33 @@ const icons = [
 ];
 
 const AppHeader: React.FC = () => {
-  const {
-    latestProto,
-    porterApp,
-    latestRevision,
-    deploymentTarget,
-  } = useLatestRevision();
+  const { latestProto, porterApp, latestRevision, deploymentTarget } =
+    useLatestRevision();
+
+  const gitCommitUrl = useMemo(() => {
+    if (!porterApp.repo_name) {
+      return "";
+    }
+    if (!latestProto.build?.commitSha) {
+      return "";
+    }
+
+    return `https://www.github.com/${porterApp.repo_name}/commit/${latestProto.build.commitSha}`;
+  }, [JSON.stringify(latestProto), porterApp]);
+
+  const displayCommitSha = useMemo(() => {
+    if (!porterApp.repo_name) {
+      return "";
+    }
+    if (
+      !latestProto.build?.commitSha ||
+      latestProto.build.commitSha.length < 7
+    ) {
+      return "";
+    }
+
+    return latestProto.build.commitSha.slice(0, 7);
+  }, [JSON.stringify(latestProto), porterApp]);
 
   const gitData = useMemo(() => {
     if (
@@ -50,7 +79,7 @@ const AppHeader: React.FC = () => {
     };
   }, [porterApp]);
 
-  const getIconSvg = (build: PorterApp["build"]) => {
+  const getIconSvg = (build: PorterApp["build"]): JSX.Element => {
     if (!build) {
       return box;
     }
@@ -84,13 +113,19 @@ const AppHeader: React.FC = () => {
     );
 
     // we only show the custom domain if 1 exists; if no custom domain exists, we show the porter domain, if one exists
-    const nonPorterDomains = domains.filter((n: string) => !n.endsWith(".onporter.run") && !n.endsWith(".withporter.run"));
+    const nonPorterDomains = domains.filter(
+      (n: string) =>
+        !n.endsWith(".onporter.run") && !n.endsWith(".withporter.run")
+    );
     if (nonPorterDomains.length) {
       if (nonPorterDomains.length === 1) {
         return nonPorterDomains[0];
       }
     } else {
-      const porterDomains = domains.filter((n: string) => n.endsWith(".onporter.run") || n.endsWith(".withporter.run"));
+      const porterDomains = domains.filter(
+        (n: string) =>
+          n.endsWith(".onporter.run") || n.endsWith(".withporter.run")
+      );
       if (porterDomains.length === 1) {
         return porterDomains[0];
       }
@@ -153,7 +188,11 @@ const AppHeader: React.FC = () => {
         <>
           <Container>
             <Text>
-              <a href={prefixSubdomain(displayDomain)} target="_blank">
+              <a
+                href={prefixSubdomain(displayDomain)}
+                target="_blank"
+                rel="noreferrer"
+              >
                 {displayDomain}
               </a>
             </Text>
@@ -161,9 +200,38 @@ const AppHeader: React.FC = () => {
           <Spacer y={0.5} />
         </>
       )}
-      <Text color="#aaaabb66">
-        Last deployed {readableDate(latestRevision.created_at)}
-      </Text>
+      <LatestDeployContainer>
+        <div style={{ flexShrink: 0 }}>
+          <Text color="#aaaabb66">
+            Last deployed {readableDate(latestRevision.created_at)}
+          </Text>
+        </div>
+        <Spacer y={0.5} />
+        <NoShrink>
+          {gitCommitUrl && displayCommitSha ? (
+            <ImageTagContainer>
+              <Link
+                to={gitCommitUrl}
+                target="_blank"
+                showTargetBlankIcon={false}
+              >
+                <CommitIcon src={pull_request_icon} />
+                <Code>{displayCommitSha}</Code>
+              </Link>
+            </ImageTagContainer>
+          ) : latestProto.image?.tag ? (
+            <ImageTagContainer hoverable={false}>
+              <TagContainer>
+                <CommitIcon src={tag_icon} />
+                <Code>{latestProto.image.tag}</Code>
+              </TagContainer>
+            </ImageTagContainer>
+          ) : null}
+        </NoShrink>
+        <Spacer y={0.5} />
+      </LatestDeployContainer>
+      <Spacer y={0.5} />
+      <GHStatusBanner />
     </>
   );
 };
@@ -175,11 +243,29 @@ const A = styled.a`
   align-items: center;
 `;
 const SmallIcon = styled.img<{ opacity?: string; height?: string }>`
-  height: ${(props) => props.height || "15px"};
-  opacity: ${(props) => props.opacity || 1};
+  height: ${(props) => props.height ?? "15px"};
+  opacity: ${(props) => props.opacity ?? 1};
   margin-right: 10px;
 `;
 
+const LatestDeployContainer = styled.div`
+  display: inline-flex;
+  column-gap: 6px;
+`;
+
+const TagContainer = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  column-gap: 1px;
+  padding: 0px 2px;
+`;
+
+const NoShrink = styled.div`
+  display: inline-flex;
+  flex-shrink: 0;
+`;
+
 const TagWrapper = styled.div<{ preview?: boolean }>`
   height: 20px;
   font-size: 12px;

+ 3 - 22
dashboard/src/main/home/app-dashboard/app-view/AppView.tsx

@@ -1,13 +1,12 @@
 import React, { useMemo } from "react";
-import { RouteComponentProps, withRouter } from "react-router";
-import { z } from "zod";
+import { withRouter, type RouteComponentProps } from "react-router";
 import styled from "styled-components";
+import { z } from "zod";
 
 import Back from "components/porter/Back";
 import Spacer from "components/porter/Spacer";
 
 import AppDataContainer from "./AppDataContainer";
-
 import AppHeader from "./AppHeader";
 import { LatestRevisionProvider } from "./LatestRevisionContext";
 
@@ -30,25 +29,7 @@ export const porterAppValidator = z.object({
 });
 export type PorterAppRecord = z.infer<typeof porterAppValidator>;
 
-// commented out tabs are not yet implemented
-// will be included as support is available based on data from app revisions rather than helm releases
-const validTabs = [
-  // "activity",
-  // "events",
-  "overview",
-  // "logs",
-  // "metrics",
-  // "debug",
-  "environment",
-  "build-settings",
-  "settings",
-  // "helm-values",
-  // "job-history",
-] as const;
-const DEFAULT_TAB = "activity";
-type ValidTab = typeof validTabs[number];
-
-type Props = RouteComponentProps & {};
+type Props = RouteComponentProps;
 
 const AppView: React.FC<Props> = ({ match }) => {
   const params = useMemo(() => {

+ 11 - 5
dashboard/src/main/home/app-dashboard/app-view/ConfirmRedeployModal.tsx

@@ -1,11 +1,13 @@
+import React, { useMemo, type Dispatch, type SetStateAction } from "react";
+import { useFormContext } from "react-hook-form";
+import styled from "styled-components";
+
 import Button from "components/porter/Button";
 import Modal from "components/porter/Modal";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
-import { PorterAppFormData } from "lib/porter-apps";
-import React, { Dispatch, SetStateAction, useMemo } from "react";
-import { useFormContext } from "react-hook-form";
-import styled from "styled-components";
+import { type PorterAppFormData } from "lib/porter-apps";
+
 import { useLatestRevision } from "./LatestRevisionContext";
 
 type Props = {
@@ -36,7 +38,11 @@ const ConfirmRedeployModal: React.FC<Props> = ({
   }, [latestRevision, buildIsDirty]);
 
   return (
-    <Modal closeModal={() => setOpen(false)}>
+    <Modal
+      closeModal={() => {
+        setOpen(false);
+      }}
+    >
       <Text size={16}>Confirm deploy</Text>
       <Spacer y={0.5} />
       <Text color="helper">{message}</Text>

+ 46 - 26
dashboard/src/main/home/app-dashboard/app-view/LatestRevisionContext.tsx

@@ -1,32 +1,44 @@
-import React, { Dispatch, SetStateAction, useMemo, useState } from "react";
+import React, {
+  createContext,
+  useContext,
+  useMemo,
+  useState,
+  type Dispatch,
+  type SetStateAction,
+} from "react";
 import { PorterApp } from "@porter-dev/api-contracts";
 import { useQuery } from "@tanstack/react-query";
-import { createContext, useContext } from "react";
-import { Context } from "shared/Context";
-import api from "shared/api";
-import { PorterAppRecord, porterAppValidator } from "./AppView";
+import styled from "styled-components";
 import { z } from "zod";
-import { AppRevision, appRevisionValidator } from "lib/revisions/types";
+
 import Loading from "components/Loading";
 import Container from "components/porter/Container";
-import Text from "components/porter/Text";
-import Spacer from "components/porter/Spacer";
 import Link from "components/porter/Link";
-import notFound from "assets/not-found.png";
-import styled from "styled-components";
-import { SourceOptions, clientAppFromProto } from "lib/porter-apps";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
 import { usePorterYaml } from "lib/hooks/usePorterYaml";
-import { ClientService, DetectedServices } from "lib/porter-apps/services";
+import { clientAppFromProto, type SourceOptions } from "lib/porter-apps";
+import {
+  type ClientService,
+  type DetectedServices,
+} from "lib/porter-apps/services";
+import { appRevisionValidator, type AppRevision } from "lib/revisions/types";
+
+import api from "shared/api";
+import { Context } from "shared/Context";
 import {
-  DeploymentTarget,
   useDeploymentTarget,
+  type DeploymentTarget,
 } from "shared/DeploymentTargetContext";
+import notFound from "assets/not-found.png";
+
 import {
-  PopulatedEnvGroup,
   populatedEnvGroup,
+  type PopulatedEnvGroup,
 } from "../validate-apply/app-settings/types";
+import { porterAppValidator, type PorterAppRecord } from "./AppView";
 
-export const LatestRevisionContext = createContext<{
+type LatestRevisionContextType = {
   porterApp: PorterAppRecord;
   latestRevision: AppRevision;
   latestProto: PorterApp;
@@ -40,9 +52,12 @@ export const LatestRevisionContext = createContext<{
   appEnv?: PopulatedEnvGroup;
   setPreviewRevision: Dispatch<SetStateAction<AppRevision | null>>;
   latestClientServices: ClientService[];
-} | null>(null);
+};
+
+export const LatestRevisionContext =
+  createContext<LatestRevisionContextType | null>(null);
 
-export const useLatestRevision = () => {
+export const useLatestRevision = (): LatestRevisionContextType => {
   const context = useContext(LatestRevisionContext);
   if (context === null) {
     throw new Error(
@@ -52,12 +67,14 @@ export const useLatestRevision = () => {
   return context;
 };
 
-export const LatestRevisionProvider = ({
-  appName,
-  children,
-}: {
+type LatestRevisionProviderProps = {
   appName?: string;
   children: JSX.Element;
+};
+
+export const LatestRevisionProvider: React.FC<LatestRevisionProviderProps> = ({
+  appName,
+  children,
 }) => {
   const [previewRevision, setPreviewRevision] = useState<AppRevision | null>(
     null
@@ -157,7 +174,7 @@ export const LatestRevisionProvider = ({
         }
       );
 
-      const { deployment_target } = await z
+      const { deployment_target: deploymentTarget } = await z
         .object({
           deployment_target: z.object({
             cluster_id: z.number(),
@@ -167,7 +184,7 @@ export const LatestRevisionProvider = ({
         })
         .parseAsync(res.data);
 
-      return deployment_target;
+      return deploymentTarget;
     },
     {
       enabled: !!currentCluster && !!currentProject,
@@ -246,7 +263,7 @@ export const LatestRevisionProvider = ({
 
   const { loading: porterYamlLoading, detectedServices } = usePorterYaml({
     source: latestSource?.type === "github" ? latestSource : null,
-    appName: appName,
+    appName,
     useDefaults: false,
   });
 
@@ -265,7 +282,10 @@ export const LatestRevisionProvider = ({
       return [];
     }
 
-    const app = clientAppFromProto({proto: latestProto, overrides: detectedServices});
+    const app = clientAppFromProto({
+      proto: latestProto,
+      overrides: detectedServices,
+    });
     return app.services;
   }, [latestProto, detectedServices]);
 
@@ -292,7 +312,7 @@ export const LatestRevisionProvider = ({
         <Container row>
           <PlaceholderIcon src={notFound} />
           <Text color="helper">
-            No application matching "{appName}" was found.
+            No application matching &quot;{appName}&quot; was found.
           </Text>
         </Container>
         <Spacer y={1} />

+ 207 - 67
dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/DeployEventCard.tsx

@@ -1,21 +1,27 @@
-import React, { useState } from "react";
-import deploy from "assets/deploy.png";
-import Text from "components/porter/Text";
+import React, { useCallback, useMemo, useState } from "react";
+import AnimateHeight from "react-animate-height";
+import styled from "styled-components";
+import { match } from "ts-pattern";
+
 import Container from "components/porter/Container";
-import Spacer from "components/porter/Spacer";
 import Icon from "components/porter/Icon";
-import { getDuration, getStatusColor, getStatusIcon } from '../utils';
-import { ImageTagContainer, CommitIcon, StyledEventCard } from "./EventCard";
-import styled from "styled-components";
 import Link from "components/porter/Link";
-import { PorterAppDeployEvent } from "../types";
-import AnimateHeight from "react-animate-height";
-import ServiceStatusDetail from "./ServiceStatusDetail";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import { useLatestRevision } from "main/home/app-dashboard/app-view/LatestRevisionContext";
 import { useRevisionList } from "lib/hooks/useRevisionList";
-import RevisionDiffModal from "../modals/RevisionDiffModal";
+
+import api from "shared/api";
+import deploy from "assets/deploy.png";
 import pull_request_icon from "assets/pull_request_icon.svg";
 import run_for from "assets/run_for.png";
-import { match } from "ts-pattern";
+
+import RevisionDiffModal from "../modals/RevisionDiffModal";
+import { type PorterAppDeployEvent } from "../types";
+import { getDuration, getStatusColor, getStatusIcon } from "../utils";
+import { CommitIcon, ImageTagContainer, StyledEventCard } from "./EventCard";
+import { RevertModal } from "./RevertModal";
+import ServiceStatusDetail from "./ServiceStatusDetail";
 
 type Props = {
   event: PorterAppDeployEvent;
@@ -28,31 +34,100 @@ type Props = {
   displayCommitSha: string;
 };
 
-const DeployEventCard: React.FC<Props> = ({ 
-  event, 
-  appName, 
-  deploymentTargetId, 
-  projectId, 
-  clusterId, 
+const DeployEventCard: React.FC<Props> = ({
+  event,
+  appName,
+  deploymentTargetId,
+  projectId,
+  clusterId,
   showServiceStatusDetail = false,
   gitCommitUrl,
-  displayCommitSha, 
+  displayCommitSha,
 }) => {
   const [diffModalVisible, setDiffModalVisible] = useState(false);
-  const [revertModalVisible, setRevertModalVisible] = useState(false);
-  const [serviceStatusVisible, setServiceStatusVisible] = useState(showServiceStatusDetail);
+  const [revertData, setRevertData] = useState<{
+    revisionNumber: number;
+    id: string;
+  } | null>(null);
+  const [isReverting, setIsReverting] = useState(false);
+  const [serviceStatusVisible, setServiceStatusVisible] = useState(
+    showServiceStatusDetail
+  );
+
+  const { revisionIdToNumber, numberToRevisionId } = useRevisionList({
+    appName,
+    deploymentTargetId,
+    projectId,
+    clusterId,
+  });
+  const { latestRevision, porterApp } = useLatestRevision();
+
+  const isRevertable = useMemo(() => {
+    const latestRevisionNumber = revisionIdToNumber[latestRevision.id];
+    const prevRevisionNumber =
+      revisionIdToNumber[event.metadata.app_revision_id];
+
+    if (latestRevisionNumber == null || prevRevisionNumber == null) {
+      return false;
+    }
+    if (prevRevisionNumber === 0) {
+      return false;
+    }
+    if (prevRevisionNumber === latestRevisionNumber) {
+      return false;
+    }
+
+    const serviceDeploymentStatuses = Object.values(
+      event.metadata.service_deployment_metadata ?? {}
+    ).map((s) => s.status);
+
+    return serviceDeploymentStatuses.every(
+      (s) => s === "SUCCESS" || s === "CANCELED"
+    );
+  }, [
+    latestRevision.id,
+    event.metadata.app_revision_id,
+    event.metadata.service_deployment_metadata,
+    revisionIdToNumber,
+  ]);
+
+  const onRevert = useCallback(async (id: string) => {
+    try {
+      setIsReverting(true);
 
-  const { revisionIdToNumber, numberToRevisionId } = useRevisionList({ appName, deploymentTargetId, projectId, clusterId });
+      await api.revertApp(
+        "<token>",
+        {
+          deployment_target_id: deploymentTargetId,
+          app_revision_id: id,
+        },
+        {
+          project_id: projectId,
+          cluster_id: clusterId,
+          porter_app_name: porterApp.name,
+        }
+      );
+    } catch {
+    } finally {
+      setRevertData(null);
+      setIsReverting(false);
+    }
+  }, []);
 
-  const renderStatusText = () => {
+  const renderStatusText = (): React.ReactNode => {
     const versionNumber = revisionIdToNumber[event.metadata.app_revision_id];
     const serviceMetadata = event.metadata.service_deployment_metadata;
-  
-    const getStatusText = (status: string, text: string, numServices: number, addEllipsis?: boolean) => {
+
+    const getStatusText = (
+      status: string,
+      text: string,
+      numServices: number,
+      addEllipsis?: boolean
+    ): React.ReactNode => {
       if (versionNumber) {
         text += ` version ${versionNumber}`;
       }
-  
+
       return serviceMetadata != null ? (
         <StatusTextContainer>
           <Text color={getStatusColor(status)}>{text} to</Text>
@@ -60,15 +135,17 @@ const DeployEventCard: React.FC<Props> = ({
           {renderServiceDropdownCta(numServices, getStatusColor(status))}
         </StatusTextContainer>
       ) : (
-        <Text color={getStatusColor(status)}>{text} {addEllipsis && "..."}</Text>
+        <Text color={getStatusColor(status)}>
+          {text} {addEllipsis && "..."}
+        </Text>
       );
     };
-  
+
     let failedServices = 0;
     let canceledServices = 0;
     let successfulServices = 0;
     let progressingServices = 0;
-  
+
     if (serviceMetadata != null) {
       for (const key in serviceMetadata) {
         if (serviceMetadata[key].status === "FAILED") {
@@ -85,54 +162,92 @@ const DeployEventCard: React.FC<Props> = ({
         }
       }
     }
-  
+
     return match(event.status)
-      .with("SUCCESS", () => getStatusText(event.status, "Deployed", successfulServices))
-      .with("FAILED", () => getStatusText(event.status, "Failed to deploy", failedServices))
-      .with("CANCELED", () => getStatusText(event.status, "Canceled deployment", canceledServices))
-      .otherwise(() => getStatusText(event.status, "Deploying", progressingServices, true));
+      .with("SUCCESS", () =>
+        getStatusText(event.status, "Deployed", successfulServices)
+      )
+      .with("FAILED", () =>
+        getStatusText(event.status, "Failed to deploy", failedServices)
+      )
+      .with("CANCELED", () =>
+        getStatusText(event.status, "Canceled deployment", canceledServices)
+      )
+      .otherwise(() =>
+        getStatusText(event.status, "Deploying", progressingServices, true)
+      );
   };
-  
-  const renderRevisionDiffModal = (event: PorterAppDeployEvent) => {
+
+  const renderRevisionDiffModal = (
+    event: PorterAppDeployEvent
+  ): JSX.Element | null => {
     const changedRevisionId = event.metadata.app_revision_id;
-    const changedRevisionNumber = revisionIdToNumber[event.metadata.app_revision_id];
-    if (changedRevisionNumber == null || changedRevisionNumber == 1) {
+    const changedRevisionNumber =
+      revisionIdToNumber[event.metadata.app_revision_id];
+    if (changedRevisionNumber == null || changedRevisionNumber === 1) {
       return null;
     }
-    const baseRevisionNumber = revisionIdToNumber[event.metadata.app_revision_id] - 1;
+    const baseRevisionNumber =
+      revisionIdToNumber[event.metadata.app_revision_id] - 1;
     if (numberToRevisionId[baseRevisionNumber] == null) {
       return null;
     }
     const baseRevisionId = numberToRevisionId[baseRevisionNumber];
     return (
       <>
-        <Link hasunderline onClick={() => setDiffModalVisible(true)}>
+        <Link
+          hasunderline
+          onClick={() => {
+            setDiffModalVisible(true);
+          }}
+        >
           View changes
         </Link>
         {diffModalVisible && (
           <RevisionDiffModal
-            base={{ revisionId: baseRevisionId, revisionNumber: baseRevisionNumber }}
-            changed={{ revisionId: changedRevisionId, revisionNumber: changedRevisionNumber }}
-            close={() => setDiffModalVisible(false)}
+            base={{
+              revisionId: baseRevisionId,
+              revisionNumber: baseRevisionNumber,
+            }}
+            changed={{
+              revisionId: changedRevisionId,
+              revisionNumber: changedRevisionNumber,
+            }}
+            close={() => {
+              setDiffModalVisible(false);
+            }}
             projectId={projectId}
             clusterId={clusterId}
             appName={appName}
           />
         )}
       </>
-    )
-  }
+    );
+  };
 
-  const renderServiceDropdownCta = (numServices: number, color?: string) => {
+  const renderServiceDropdownCta = (
+    numServices: number,
+    color?: string
+  ): React.ReactNode => {
     return (
-      <ServiceStatusDropdownCtaContainer >
-        <Link color={color} onClick={() => setServiceStatusVisible(!serviceStatusVisible)}>
-          <ServiceStatusDropdownIcon className="material-icons" serviceStatusVisible={serviceStatusVisible}>arrow_drop_down</ServiceStatusDropdownIcon>
+      <ServiceStatusDropdownCtaContainer>
+        <Link
+          color={color}
+          onClick={() => {
+            setServiceStatusVisible(!serviceStatusVisible);
+          }}
+        >
+          <ServiceStatusDropdownIcon
+            className="material-icons"
+            serviceStatusVisible={serviceStatusVisible}
+          >
+            arrow_drop_down
+          </ServiceStatusDropdownIcon>
           {numServices} service{numServices === 1 ? "" : "s"}
         </Link>
       </ServiceStatusDropdownCtaContainer>
-    )
-  }
+    );
+  };
 
   return (
     <StyledEventCard>
@@ -141,24 +256,28 @@ const DeployEventCard: React.FC<Props> = ({
           <Icon height="16px" src={deploy} />
           <Spacer inline width="10px" />
           <Text>Application deploy</Text>
-          {gitCommitUrl && displayCommitSha ?
+          {gitCommitUrl && displayCommitSha ? (
             <>
               <Spacer inline x={0.5} />
               <ImageTagContainer>
-                <Link to={gitCommitUrl} target="_blank" showTargetBlankIcon={false}>
+                <Link
+                  to={gitCommitUrl}
+                  target="_blank"
+                  showTargetBlankIcon={false}
+                >
                   <CommitIcon src={pull_request_icon} />
                   <Code>{displayCommitSha}</Code>
                 </Link>
-              </ImageTagContainer> 
+              </ImageTagContainer>
             </>
-            :
+          ) : (
             <>
               <Spacer inline x={0.5} />
               <ImageTagContainer hoverable={false}>
                 <Code>{event.metadata.image_tag}</Code>
-              </ImageTagContainer> 
+              </ImageTagContainer>
             </>
-          }
+          )}
         </Container>
         <Container row>
           <Icon height="14px" src={run_for} />
@@ -172,32 +291,53 @@ const DeployEventCard: React.FC<Props> = ({
           <Icon height="12px" src={getStatusIcon(event.status)} />
           <Spacer inline width="10px" />
           {renderStatusText()}
-          {/** uncomment the below once we've implemented revert from here */}
-          {/* {revisionIdToNumber[event.metadata.app_revision_id] != null && latestRevision.revision_number !== revisionIdToNumber[event.metadata.app_revision_id] && (
+          {isRevertable && (
             <>
               <Spacer inline x={1} />
               <TempWrapper>
-                <Link hasunderline onClick={() => setRevertModalVisible(true)}>
-                  Revert to version {revisionIdToNumber[event.metadata.app_revision_id]}
+                <Link
+                  hasunderline
+                  onClick={() => {
+                    setRevertData({
+                      revisionNumber:
+                        revisionIdToNumber[event.metadata.app_revision_id],
+                      id: event.metadata.app_revision_id,
+                    });
+                  }}
+                  color="#6e9df5"
+                >
+                  Revert to version{" "}
+                  {revisionIdToNumber[event.metadata.app_revision_id]}
                 </Link>
-
               </TempWrapper>
             </>
-          )} */}
+          )}
           <Spacer inline x={0.5} />
           {renderRevisionDiffModal(event)}
         </Container>
       </Container>
-      {event.metadata.service_deployment_metadata != null &&
+      {event.metadata.service_deployment_metadata != null && (
         <AnimateHeight height={serviceStatusVisible ? "auto" : 0}>
           <Spacer y={0.5} />
           <ServiceStatusDetail
-            serviceDeploymentMetadata={event.metadata.service_deployment_metadata}
+            serviceDeploymentMetadata={
+              event.metadata.service_deployment_metadata
+            }
             appName={appName}
             revision={revisionIdToNumber[event.metadata.app_revision_id]}
           />
         </AnimateHeight>
-      }
+      )}
+      {revertData && (
+        <RevertModal
+          closeModal={() => {
+            setRevertData(null);
+          }}
+          revision={revertData}
+          revert={onRevert}
+          loading={isReverting}
+        />
+      )}
     </StyledEventCard>
   );
 };
@@ -231,7 +371,7 @@ const ServiceStatusDropdownIcon = styled.i`
   transform: ${(props: { serviceStatusVisible: boolean }) =>
     props.serviceStatusVisible ? "" : "rotate(-90deg)"};
   transition: transform 0.1s ease;
-`
+`;
 
 const StatusTextContainer = styled.div`
   display: flex;

+ 59 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/RevertModal.tsx

@@ -0,0 +1,59 @@
+import React from "react";
+import styled from "styled-components";
+
+import Button from "components/porter/Button";
+import Modal from "components/porter/Modal";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+
+type RevertModalProps = {
+  revert: (id: string) => Promise<void>;
+  closeModal: () => void;
+  revision: { revisionNumber: number; id: string };
+  loading: boolean;
+};
+
+export const RevertModal: React.FC<RevertModalProps> = ({
+  closeModal,
+  revision,
+  revert,
+  loading,
+}) => {
+  return (
+    <Modal closeModal={closeModal}>
+      <Text size={16}>Revert to version {revision.revisionNumber}</Text>
+      <Spacer y={0.5} />
+      <Text color="helper">
+        Click continue to confirm that you want to revert to version{" "}
+        {revision.revisionNumber}.
+      </Text>
+      <Spacer y={0.5} />
+      <ButtonContainer>
+        <Button
+          onClick={() => {
+            closeModal();
+          }}
+          color="#b91133"
+        >
+          Cancel
+        </Button>
+        <Button
+          onClick={() => {
+            void revert(revision.id);
+          }}
+          status={loading ? "loading" : ""}
+          loadingText="Reverting..."
+          disabled={loading}
+        >
+          Continue
+        </Button>
+      </ButtonContainer>
+    </Modal>
+  );
+};
+
+const ButtonContainer = styled.div`
+  display: flex;
+  justify-content: flex-end;
+  column-gap: 0.5rem;
+`;

+ 79 - 28
dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx

@@ -39,6 +39,7 @@ import { useClusterResources } from "shared/ClusterResourcesContext";
 import { Context } from "shared/Context";
 import { valueExists } from "shared/util";
 import web from "assets/web.png";
+
 import ImageSettings from "../image-settings/ImageSettings";
 import GithubActionModal from "../new-app-flow/GithubActionModal";
 import SourceSelector from "../new-app-flow/SourceSelector";
@@ -245,6 +246,46 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
     }
   });
 
+  const createWithValidateApply = async ({
+    app,
+    projectID,
+    clusterID,
+    deploymentTargetID,
+  }: {
+    app: PorterApp;
+    projectID: number;
+    clusterID: number;
+    deploymentTargetID: string;
+  }): Promise<void> => {
+    await api.createApp(
+      "<token>",
+      {
+        ...source,
+        name: app.name,
+        deployment_target_id: deploymentTargetID,
+      },
+      {
+        project_id: projectID,
+        cluster_id: clusterID,
+      }
+    );
+
+    await api.applyApp(
+      "<token>",
+      {
+        b64_app_proto: btoa(app.toJsonString()),
+        deployment_target_id: deploymentTargetID,
+        variables,
+        secrets,
+        hard_env_update: true,
+      },
+      {
+        project_id: projectID,
+        cluster_id: clusterID,
+      }
+    );
+  };
+
   const createAndApply = useCallback(
     async ({
       app,
@@ -273,33 +314,37 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
           return false;
         }
 
-        await api.createApp(
-          "<token>",
-          {
-            ...source,
-            name: app.name,
-            deployment_target_id: deploymentTarget.deployment_target_id,
-          },
-          {
-            project_id: currentProject.id,
-            cluster_id: currentCluster.id,
-          }
-        );
-
-        await api.applyApp(
-          "<token>",
-          {
-            b64_app_proto: btoa(app.toJsonString()),
-            deployment_target_id: deploymentTarget.deployment_target_id,
-            variables,
-            secrets,
-            hard_env_update: true,
-          },
-          {
-            project_id: currentProject.id,
-            cluster_id: currentCluster.id,
-          }
-        );
+        if (currentProject.beta_features_enabled) {
+          await api.updateApp(
+            "<token>",
+            {
+              deployment_target_id: deploymentTarget.deployment_target_id,
+              b64_app_proto: btoa(app.toJsonString()),
+              secrets,
+              variables,
+              is_env_override: true,
+              ...(source.type === "github" && {
+                git_source: {
+                  git_branch: source.git_branch,
+                  git_repo_id: source.git_repo_id,
+                  git_repo_name: source.git_repo_name,
+                },
+                porter_yaml_path: source.porter_yaml_path,
+              }),
+            },
+            {
+              project_id: currentProject.id,
+              cluster_id: currentCluster.id,
+            }
+          );
+        } else {
+          await createWithValidateApply({
+            app,
+            projectID: currentProject.id,
+            clusterID: currentCluster.id,
+            deploymentTargetID: deploymentTarget.deployment_target_id,
+          });
+        }
 
         // log analytics event that we successfully deployed
         void updateAppStep({
@@ -340,7 +385,13 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
         setIsDeploying(false);
       }
     },
-    [currentProject?.id, currentCluster?.id, deploymentTarget, name.value]
+    [
+      currentProject?.id,
+      currentCluster?.id,
+      deploymentTarget,
+      name.value,
+      createWithValidateApply,
+    ]
   );
 
   useEffect(() => {

+ 65 - 23
dashboard/src/main/home/app-dashboard/validate-apply/revisions-list/GHStatusBanner.tsx

@@ -1,48 +1,90 @@
 import React, { useContext, useMemo } from "react";
-import { useLatestRevision } from "../../app-view/LatestRevisionContext";
-import { Context } from "shared/Context";
-import { useGithubWorkflow } from "lib/hooks/useGithubWorkflow";
+import { useQuery } from "@tanstack/react-query";
+import { match } from "ts-pattern";
+import { z } from "zod";
+
 import Banner from "components/porter/Banner";
-import Spacer from "components/porter/Spacer";
 import Link from "components/porter/Link";
-import GHABanner from "../../expanded-app/GHABanner";
-import { match } from "ts-pattern";
-import { AppRevision } from "lib/revisions/types";
+import Spacer from "components/porter/Spacer";
+import { useGithubWorkflow } from "lib/hooks/useGithubWorkflow";
+import { appRevisionValidator } from "lib/revisions/types";
 
-type GHStatusBannerProps = {
-  revisions: AppRevision[];
-};
+import api from "shared/api";
+import { Context } from "shared/Context";
+
+import { useLatestRevision } from "../../app-view/LatestRevisionContext";
+import GHABanner from "../../expanded-app/GHABanner";
 
-const GHStatusBanner: React.FC<GHStatusBannerProps> = ({ revisions }) => {
+const GHStatusBanner: React.FC = () => {
   const { setCurrentModal } = useContext(Context);
-  const { porterApp } = useLatestRevision();
+  const {
+    porterApp,
+    projectId,
+    clusterId,
+    latestRevision,
+    appName,
+    deploymentTarget,
+  } = useLatestRevision();
+
+  const { data: revisions = [], status } = useQuery(
+    [
+      "listAppRevisions",
+      projectId,
+      clusterId,
+      latestRevision.revision_number,
+      appName,
+    ],
+    async () => {
+      const res = await api.listAppRevisions(
+        "<token>",
+        {
+          deployment_target_id: deploymentTarget.id,
+        },
+        {
+          project_id: projectId,
+          cluster_id: clusterId,
+          porter_app_name: appName,
+        }
+      );
+
+      const { app_revisions: appRevisions } = await z
+        .object({
+          app_revisions: z.array(appRevisionValidator),
+        })
+        .parseAsync(res.data);
+
+      return appRevisions;
+    }
+  );
 
   const previouslyBuilt = useMemo(() => {
     return revisions.some((r) =>
       match(r.status)
         .with(
           "AWAITING_PREDEPLOY",
-          "READY_TO_APPLY",
           "DEPLOYED",
           "DEPLOY_FAILED",
           "BUILD_FAILED",
+          "IMAGE_AVAILABLE",
           () => true
         )
         .otherwise(() => false)
     );
   }, [revisions]);
 
-  const {
-    githubWorkflowFilename,
-    userHasGithubAccess,
-    isLoading,
-  } = useGithubWorkflow({
-    porterApp,
-    previouslyBuilt,
-    fileNames: ["porter.yml", `porter_stack_${porterApp.name}.yml`],
-  });
+  const { githubWorkflowFilename, userHasGithubAccess, isLoading } =
+    useGithubWorkflow({
+      porterApp,
+      previouslyBuilt,
+      fileNames: ["porter.yml", `porter_stack_${porterApp.name}.yml`],
+    });
 
-  if (previouslyBuilt) {
+  if (
+    previouslyBuilt ||
+    !!porterApp.image_repo_uri ||
+    status === "loading" ||
+    status === "error"
+  ) {
     return null;
   }
 

+ 0 - 365
dashboard/src/main/home/app-dashboard/validate-apply/revisions-list/RevisionTableContents.tsx

@@ -1,365 +0,0 @@
-import React, { type Dispatch, type SetStateAction } from "react";
-import { PorterApp } from "@porter-dev/api-contracts";
-import styled from "styled-components";
-import { match } from "ts-pattern";
-
-import Text from "components/porter/Text";
-import { SourceOptions } from "lib/porter-apps";
-import { type AppRevision } from "lib/revisions/types";
-
-import { readableDate } from "shared/string_utils";
-import { useLatestRevision } from "../../app-view/LatestRevisionContext";
-
-type RevisionTableContentsProps = {
-  latestRevisionNumber: number;
-  revisions: AppRevision[];
-  expandRevisions: boolean;
-  setExpandRevisions: Dispatch<SetStateAction<boolean>>;
-  setRevertData: Dispatch<
-    SetStateAction<{
-      app: PorterApp;
-      revisionId: string;
-      number: number;
-    } | null>
-  >;
-};
-
-const RED = "#ff0000";
-const YELLOW = "#FFA500";
-
-const RevisionTableContents: React.FC<RevisionTableContentsProps> = ({
-  latestRevisionNumber,
-  revisions,
-  expandRevisions,
-  setExpandRevisions,
-  setRevertData,
-}) => {
-  const { previewRevision, setPreviewRevision } = useLatestRevision();
-
-  const revisionsWithProto = revisions.map((revision) => {
-    return {
-      ...revision,
-      app_proto: PorterApp.fromJsonString(atob(revision.b64_app_proto), {
-        ignoreUnknownFields: true,
-      }),
-    };
-  });
-
-  const deployedRevisions = revisionsWithProto.filter(
-    (r) => r.revision_number !== 0
-  );
-  const pendingRevisions = revisionsWithProto.filter(
-    (r) => r.revision_number === 0
-  );
-  const getReadableStatus = (status: AppRevision["status"]) =>
-    match(status)
-      .with("CREATED", () => "Created")
-      .with("AWAITING_BUILD_ARTIFACT", () => "Awaiting Build")
-      .with("AWAITING_PREDEPLOY", () => "Awaiting Pre-Deploy")
-      .with("BUILD_CANCELED", () => "Build Canceled")
-      .with("BUILD_FAILED", () => "Build Failed")
-      .with("DEPLOY_FAILED", () => "Deploy Failed")
-      .with("DEPLOYED", () => "Deployed")
-      .with("PREDEPLOY_FAILED", () => "Pre-Deploy Failed")
-      .otherwise(() => "Deploying"); // fine to do this for now because this component is about to be deprecated
-
-  const getDotColor = (status: AppRevision["status"]) =>
-    match(status)
-      .with(
-        "CREATED",
-        "AWAITING_BUILD_ARTIFACT",
-        "AWAITING_PREDEPLOY",
-        () => YELLOW
-      )
-      .otherwise(() => RED);
-
-  const getTableHeader = (latestRevision?: AppRevision) => {
-    if (!latestRevision) {
-      return "Versions";
-    }
-
-    if (previewRevision) {
-      return "Previewing version (not deployed) -";
-    }
-
-    return "Current version - ";
-  };
-
-  const getSelectedRevisionNumber = (args: {
-    numDeployed: number;
-    latestRevision?: AppRevision;
-  }) => {
-    const { numDeployed, latestRevision } = args;
-
-    if (previewRevision) {
-      return previewRevision.revision_number;
-    }
-
-    if (latestRevision && latestRevision.revision_number !== 0) {
-      return latestRevision.revision_number;
-    }
-
-    return numDeployed + 1;
-  };
-
-  return (
-    <div>
-      <RevisionHeader
-        showRevisions={expandRevisions}
-        isCurrent={!previewRevision}
-        onClick={() => {
-          setExpandRevisions((prev) => !prev);
-        }}
-      >
-        <RevisionPreview>
-          <i className="material-icons">arrow_drop_down</i>
-          {getTableHeader(revisions[0])}
-          {revisions[0] ? (
-            <Revision>
-              No.{" "}
-              {getSelectedRevisionNumber({
-                numDeployed: deployedRevisions[0]?.revision_number || 0,
-                latestRevision: revisions[0],
-              })}
-            </Revision>
-          ) : null}
-        </RevisionPreview>
-      </RevisionHeader>
-      <RevisionList>
-        <TableWrapper>
-          <RevisionsTable>
-            <tbody>
-              <Tr disableHover>
-                <Th>Version no.</Th>
-                <Th>
-                  {revisionsWithProto[0]?.app_proto.build
-                    ? "Commit SHA"
-                    : "Image Tag"}
-                </Th>
-                <Th>Timestamp</Th>
-                <Th>Status</Th>
-                <Th>Rollback</Th>
-              </Tr>
-              {pendingRevisions.length > 0 &&
-                pendingRevisions.map((revision) => (
-                  <Tr key={new Date(revision.updated_at).toUTCString()}>
-                    <Td>{(deployedRevisions[0]?.revision_number || 0) + 1}</Td>
-                    <Td>
-                      {revision.app_proto.build
-                        ? revision.app_proto.build.commitSha.substring(0, 7)
-                        : revision.app_proto.image?.tag}
-                    </Td>
-                    <Td>{readableDate(revision.updated_at)}</Td>
-                    <Td>
-                      <StatusContainer>
-                        <Text>{getReadableStatus(revision.status)}</Text>
-                        <StatusDot color={getDotColor(revision.status)} />
-                      </StatusContainer>
-                    </Td>
-                    <Td>-</Td>
-                  </Tr>
-                ))}
-
-              {deployedRevisions.map((revision, i) => {
-                const isLatestDeployedRevision =
-                  latestRevisionNumber !== 0
-                    ? revision.revision_number === latestRevisionNumber
-                    : i === 0;
-
-                return (
-                  <Tr
-                    key={revision.revision_number}
-                    selected={
-                      previewRevision
-                        ? revision.revision_number ===
-                          previewRevision.revision_number
-                        : isLatestDeployedRevision
-                    }
-                    onClick={() => {
-                      if (isLatestDeployedRevision) {
-                        setPreviewRevision(null);
-                      } else {
-                        setPreviewRevision(revision);
-                      }
-                    }}
-                  >
-                    <Td>{revision.revision_number}</Td>
-
-                    <Td>
-                      {revision.app_proto.build
-                        ? revision.app_proto.build.commitSha.substring(0, 7)
-                        : revision.app_proto.image?.tag}
-                    </Td>
-                    <Td>{readableDate(revision.updated_at)}</Td>
-                    <Td>
-                      {!isLatestDeployedRevision ? (
-                        getReadableStatus(revision.status)
-                      ) : (
-                        <StatusContainer>
-                          <Text>{getReadableStatus(revision.status)}</Text>
-                          <StatusDot />
-                        </StatusContainer>
-                      )}
-                    </Td>
-                    <Td>
-                      <RollbackButton
-                        disabled={isLatestDeployedRevision}
-                        onClick={() => {
-                          if (isLatestDeployedRevision) {
-                            return;
-                          }
-
-                          setRevertData({
-                            app: revision.app_proto,
-                            revisionId: revision.id,
-                            number: revision.revision_number,
-                          });
-                        }}
-                      >
-                        {isLatestDeployedRevision ? "Current" : "Revert"}
-                      </RollbackButton>
-                    </Td>
-                  </Tr>
-                );
-              })}
-            </tbody>
-          </RevisionsTable>
-        </TableWrapper>
-      </RevisionList>
-    </div>
-  );
-};
-
-export default RevisionTableContents;
-
-const RevisionHeader = styled.div`
-  color: ${(props: { showRevisions: boolean; isCurrent: boolean }) =>
-    props.isCurrent ? "#ffffff66" : "#f5cb42"};
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  height: 40px;
-  font-size: 13px;
-  width: 100%;
-  padding-left: 10px;
-  cursor: pointer;
-  background: ${({ theme }) => theme.fg};
-  :hover {
-    background: ${(props) => props.showRevisions && props.theme.fg2};
-  }
-  > div > i {
-    margin-right: 8px;
-    font-size: 20px;
-    cursor: pointer;
-    border-radius: 20px;
-    transform: ${(props: { showRevisions: boolean; isCurrent: boolean }) =>
-      props.showRevisions ? "" : "rotate(-90deg)"};
-    transition: transform 0.1s ease;
-  }
-`;
-
-const RevisionPreview = styled.div`
-  display: flex;
-  align-items: center;
-`;
-
-const Revision = styled.div`
-  color: #ffffff;
-  margin-left: 5px;
-`;
-
-const RevisionList = styled.div`
-  overflow-y: auto;
-  max-height: 215px;
-`;
-
-const TableWrapper = styled.div`
-  padding-bottom: 20px;
-`;
-
-const RevisionsTable = styled.table`
-  width: 100%;
-  margin-top: 5px;
-  padding-left: 32px;
-  padding-bottom: 20px;
-  min-width: 500px;
-  border-collapse: collapse;
-`;
-
-const Tr = styled.tr`
-  line-height: 2.2em;
-  cursor: ${(props: { disableHover?: boolean; selected?: boolean }) =>
-    props.disableHover ? "" : "pointer"};
-  background: ${(props: { disableHover?: boolean; selected?: boolean }) =>
-    props.selected ? "#ffffff11" : ""};
-  :hover {
-    background: ${(props: { disableHover?: boolean; selected?: boolean }) =>
-      props.disableHover ? "" : "#ffffff22"};
-  }
-`;
-
-const Td = styled.td`
-  font-size: 13px;
-  color: #ffffff;
-  padding-left: 32px;
-`;
-
-const Th = styled.td`
-  font-size: 13px;
-  font-weight: 500;
-  color: #aaaabb;
-  padding-left: 32px;
-`;
-
-const RollbackButton = styled.div`
-  cursor: ${(props: { disabled: boolean }) =>
-    props.disabled ? "not-allowed" : "pointer"};
-  display: flex;
-  border-radius: 3px;
-  align-items: center;
-  justify-content: center;
-  font-weight: 500;
-  height: 21px;
-  font-size: 13px;
-  width: 70px;
-  background: ${(props: { disabled: boolean }) =>
-    props.disabled ? "#aaaabbee" : "#616FEEcc"};
-  :hover {
-    background: ${(props: { disabled: boolean }) =>
-      props.disabled ? "" : "#405eddbb"};
-  }
-`;
-
-const StatusContainer = styled.div`
-  display: flex;
-  align-items: center;
-`;
-
-const StatusDot = styled.div<{ color?: string }>`
-  min-width: 7px;
-  max-width: 7px;
-  height: 7px;
-  margin-left: 10px;
-  border-radius: 50%;
-  background: ${(props) => props.color || "#38a88a"};
-
-  box-shadow: 0 0 0 0 rgba(0, 0, 0, 1);
-  transform: scale(1);
-  animation: pulse 2s infinite;
-  @keyframes pulse {
-    0% {
-      transform: scale(0.95);
-      box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.9);
-    }
-
-    70% {
-      transform: scale(1);
-      box-shadow: 0 0 0 10px rgba(0, 0, 0, 0);
-    }
-
-    100% {
-      transform: scale(0.95);
-      box-shadow: 0 0 0 0 rgba(0, 0, 0, 0);
-    }
-  }
-`;

+ 0 - 212
dashboard/src/main/home/app-dashboard/validate-apply/revisions-list/RevisionsList.tsx

@@ -1,212 +0,0 @@
-import { useQuery } from "@tanstack/react-query";
-import { appRevisionValidator } from "lib/revisions/types";
-import React, { useCallback, useState } from "react";
-import api from "shared/api";
-import styled from "styled-components";
-import { match } from "ts-pattern";
-import loading from "assets/loading.gif";
-import {
-  PorterAppFormData,
-  clientAppFromProto,
-} from "lib/porter-apps";
-import { z } from "zod";
-import { PorterApp } from "@porter-dev/api-contracts";
-import { useFormContext } from "react-hook-form";
-import ConfirmOverlay from "components/porter/ConfirmOverlay";
-import { useLatestRevision } from "../../app-view/LatestRevisionContext";
-import RevisionTableContents from "./RevisionTableContents";
-import GHStatusBanner from "./GHStatusBanner";
-import Spacer from "components/porter/Spacer";
-
-type Props = {
-  deploymentTargetId: string;
-  projectId: number;
-  clusterId: number;
-  appName: string;
-  latestRevisionNumber: number;
-  onSubmit: () => Promise<void>;
-};
-
-const RevisionsList: React.FC<Props> = ({
-  latestRevisionNumber,
-  deploymentTargetId,
-  projectId,
-  clusterId,
-  appName,
-  onSubmit,
-}) => {
-  const { servicesFromYaml } = useLatestRevision();
-  const { setValue } = useFormContext<PorterAppFormData>();
-  const [expandRevisions, setExpandRevisions] = useState(false);
-  const [revertData, setRevertData] = useState<{
-    app: PorterApp;
-    revisionId: string;
-    number: number;
-  } | null>(null);
-
-  const res = useQuery(
-    ["listAppRevisions", projectId, clusterId, latestRevisionNumber, appName],
-    async () => {
-      const res = await api.listAppRevisions(
-        "<token>",
-        {
-          deployment_target_id: deploymentTargetId,
-        },
-        {
-          project_id: projectId,
-          cluster_id: clusterId,
-          porter_app_name: appName,
-        }
-      );
-
-      const revisions = await z
-        .object({
-          app_revisions: z.array(appRevisionValidator),
-        })
-        .parseAsync(res.data);
-
-      return revisions;
-    }
-  );
-
-  const onRevert = useCallback(async () => {
-    if (!revertData) {
-      return;
-    }
-
-    const res = await api.getRevision(
-      "<token>",
-      {},
-      {
-        project_id: projectId,
-        cluster_id: clusterId,
-        porter_app_name: appName,
-        revision_id: revertData.revisionId,
-      }
-    );
-
-    // hydrate revision with env variables only on revert
-    const { app_revision } = await z
-      .object({
-        app_revision: appRevisionValidator.extend({
-          env: z.object({
-            name: z.string(),
-            latest_version: z.number(),
-            variables: z.record(z.string(), z.string()).optional(),
-            secret_variables: z.record(z.string(), z.string()).optional(),
-            created_at: z.string(),
-          }),
-        }),
-      })
-      .parseAsync(res.data);
-
-    setValue(
-      "app",
-      clientAppFromProto({
-        proto: PorterApp.fromJsonString(atob(app_revision.b64_app_proto), {
-          ignoreUnknownFields: true,
-        }),
-        overrides: servicesFromYaml,
-        variables: app_revision.env.variables,
-        secrets: app_revision.env.secret_variables,
-      })
-    );
-    setRevertData(null);
-
-    void onSubmit();
-  }, [onSubmit, setValue, revertData]);
-
-  return (
-    <div>
-      <StyledRevisionSection showRevisions={expandRevisions}>
-        {match(res)
-          .with({ status: "loading" }, () => (
-            <LoadingPlaceholder>
-              <StatusWrapper>
-                <LoadingGif src={loading} revision={false} /> Updating . . .
-              </StatusWrapper>
-            </LoadingPlaceholder>
-          ))
-          .with({ status: "success" }, ({ data }) => (
-            <RevisionTableContents
-              latestRevisionNumber={latestRevisionNumber}
-              revisions={data.app_revisions}
-              expandRevisions={expandRevisions}
-              setExpandRevisions={setExpandRevisions}
-              setRevertData={setRevertData}
-            />
-          ))
-          .otherwise(() => null)}
-        {revertData ? (
-          <ConfirmOverlay
-            message={`Are you sure you want to revert to revision ${revertData?.number}?`}
-            onYes={onRevert}
-            onNo={() => {
-              setRevertData(null);
-            }}
-          />
-        ) : null}
-      </StyledRevisionSection>
-      {res.data && (
-        <>
-          <GHStatusBanner revisions={res.data.app_revisions} />
-          <Spacer y={0.5} />
-        </>
-      )}
-    </div>
-  );
-};
-
-export default RevisionsList;
-
-const StyledRevisionSection = styled.div`
-  width: 100%;
-  max-height: ${(props: { showRevisions: boolean }) =>
-    props.showRevisions ? "255px" : "40px"};
-  margin: 20px 0px 18px;
-  overflow: hidden;
-  border-radius: 5px;
-  background: ${(props) => props.theme.fg};
-  border: 1px solid #494b4f;
-  :hover {
-    border: 1px solid #7a7b80;
-  }
-  animation: ${(props: { showRevisions: boolean }) =>
-    props.showRevisions ? "expandRevisions 0.3s" : ""};
-  animation-timing-function: ease-out;
-  @keyframes expandRevisions {
-    from {
-      max-height: 40px;
-    }
-    to {
-      max-height: 250px;
-    }
-  }
-`;
-
-const LoadingPlaceholder = styled.div`
-  height: 40px;
-  display: flex;
-  align-items: center;
-  padding-left: 20px;
-`;
-
-const LoadingGif = styled.img`
-  width: 15px;
-  height: 15px;
-  margin-right: ${(props: { revision: boolean }) =>
-    props.revision ? "0px" : "9px"};
-  margin-left: ${(props: { revision: boolean }) =>
-    props.revision ? "10px" : "0px"};
-  margin-bottom: ${(props: { revision: boolean }) =>
-    props.revision ? "-2px" : "0px"};
-`;
-
-const StatusWrapper = styled.div`
-  display: flex;
-  align-items: center;
-  font-family: "Work Sans", sans-serif;
-  font-size: 13px;
-  color: #ffffff55;
-  margin-right: 25px;
-`;

+ 124 - 65
dashboard/src/shared/api.tsx

@@ -1,21 +1,22 @@
-import { PolicyDocType } from "./auth/types";
-import { PullRequest } from "main/home/cluster-dashboard/preview-environments/types";
-import { baseApi } from "./baseApi";
-
 import {
-  BuildConfig,
-  FullActionConfigType,
-  CreateUpdatePorterAppOptions,
-} from "./types";
+  type Contract,
+  type PreflightCheckRequest,
+  type QuotaIncreaseRequest,
+} from "@porter-dev/api-contracts";
+
+import { type PullRequest } from "main/home/cluster-dashboard/preview-environments/types";
 import {
-  CreateStackBody,
-  SourceConfig,
+  type CreateStackBody,
+  type SourceConfig,
 } from "main/home/cluster-dashboard/stacks/types";
+
+import { type PolicyDocType } from "./auth/types";
+import { baseApi } from "./baseApi";
 import {
-  Contract,
-  PreflightCheckRequest,
-  QuotaIncreaseRequest,
-} from "@porter-dev/api-contracts";
+  type BuildConfig,
+  type CreateUpdatePorterAppOptions,
+  type FullActionConfigType,
+} from "./types";
 
 /**
  * Generic api call format
@@ -192,7 +193,7 @@ const getPorterApps = baseApi<
     cluster_id: number;
   }
 >("GET", (pathParams) => {
-  let { project_id, cluster_id } = pathParams;
+  const { project_id, cluster_id } = pathParams;
   return `/api/projects/${project_id}/clusters/${cluster_id}/applications`;
 });
 
@@ -204,7 +205,7 @@ const getPorterApp = baseApi<
     name: string;
   }
 >("GET", (pathParams) => {
-  let { project_id, cluster_id, name } = pathParams;
+  const { project_id, cluster_id, name } = pathParams;
   return `/api/projects/${project_id}/clusters/${cluster_id}/applications/${name}`;
 });
 
@@ -216,7 +217,7 @@ const getPorterAppEvent = baseApi<
     event_id: string;
   }
 >("GET", (pathParams) => {
-  let { project_id, cluster_id, event_id } = pathParams;
+  const { project_id, cluster_id, event_id } = pathParams;
   return `/api/projects/${project_id}/clusters/${cluster_id}/events/${event_id}`;
 });
 
@@ -228,7 +229,7 @@ const createPorterApp = baseApi<
     stack_name: string;
   }
 >("POST", (pathParams) => {
-  let { project_id, cluster_id, stack_name } = pathParams;
+  const { project_id, cluster_id, stack_name } = pathParams;
   return `/api/projects/${project_id}/clusters/${cluster_id}/applications/${stack_name}`;
 });
 
@@ -240,7 +241,7 @@ const deletePorterApp = baseApi<
     name: string;
   }
 >("DELETE", (pathParams) => {
-  let { project_id, cluster_id, name } = pathParams;
+  const { project_id, cluster_id, name } = pathParams;
   return `/api/projects/${project_id}/clusters/${cluster_id}/applications/${name}`;
 });
 
@@ -254,7 +255,7 @@ const rollbackPorterApp = baseApi<
     stack_name: string;
   }
 >("POST", (pathParams) => {
-  let { project_id, cluster_id, stack_name } = pathParams;
+  const { project_id, cluster_id, stack_name } = pathParams;
   return `/api/projects/${project_id}/clusters/${cluster_id}/applications/${stack_name}/rollback`;
 });
 
@@ -356,7 +357,7 @@ const appEvents = baseApi<
     porter_app_name: string;
   }
 >("GET", (pathParams) => {
-  let { project_id, cluster_id, porter_app_name } = pathParams;
+  const { project_id, cluster_id, porter_app_name } = pathParams;
   return `/api/projects/${project_id}/clusters/${cluster_id}/apps/${porter_app_name}/events`;
 });
 
@@ -369,7 +370,7 @@ const getFeedEvents = baseApi<
     page?: number;
   }
 >("GET", (pathParams) => {
-  let { project_id, cluster_id, stack_name, page } = pathParams;
+  const { project_id, cluster_id, stack_name, page } = pathParams;
   return `/api/projects/${project_id}/clusters/${cluster_id}/applications/${stack_name}/events?page=${
     page || 1
   }`;
@@ -392,7 +393,7 @@ const createEnvironment = baseApi<
     git_repo_name: string;
   }
 >("POST", (pathParams) => {
-  let {
+  const {
     project_id,
     cluster_id,
     git_installation_id,
@@ -433,7 +434,7 @@ const deleteEnvironment = baseApi<
     git_repo_name: string;
   }
 >("DELETE", (pathParams) => {
-  let {
+  const {
     project_id,
     cluster_id,
     git_installation_id,
@@ -472,7 +473,7 @@ const listEnvironments = baseApi<
     cluster_id: number;
   }
 >("GET", (pathParams) => {
-  let { project_id, cluster_id } = pathParams;
+  const { project_id, cluster_id } = pathParams;
   return `/api/projects/${project_id}/clusters/${cluster_id}/environments`;
 });
 
@@ -484,7 +485,7 @@ const getEnvironment = baseApi<
     environment_id: number;
   }
 >("GET", (pathParams) => {
-  let { project_id, cluster_id, environment_id } = pathParams;
+  const { project_id, cluster_id, environment_id } = pathParams;
   return `/api/projects/${project_id}/clusters/${cluster_id}/environments/${environment_id}`;
 });
 
@@ -498,7 +499,7 @@ const toggleNewCommentForEnvironment = baseApi<
     environment_id: number;
   }
 >("PATCH", (pathParams) => {
-  let { project_id, cluster_id, environment_id } = pathParams;
+  const { project_id, cluster_id, environment_id } = pathParams;
   return `/api/projects/${project_id}/clusters/${cluster_id}/environments/${environment_id}/toggle_new_comment`;
 });
 
@@ -592,7 +593,7 @@ const createSubdomain = baseApi<
     cluster_id: number;
   }
 >("POST", (pathParams) => {
-  let { cluster_id, id, namespace, release_name } = pathParams;
+  const { cluster_id, id, namespace, release_name } = pathParams;
 
   return `/api/projects/${id}/clusters/${cluster_id}/namespaces/${namespace}/releases/${release_name}/subdomain`;
 });
@@ -618,7 +619,7 @@ const deletePod = baseApi<
   {},
   { name: string; namespace: string; id: number; cluster_id: number }
 >("DELETE", (pathParams) => {
-  let { id, name, cluster_id, namespace } = pathParams;
+  const { id, name, cluster_id, namespace } = pathParams;
   return `/api/projects/${id}/clusters/${cluster_id}/namespaces/${namespace}/pods/${name}`;
 });
 
@@ -626,7 +627,7 @@ const getPodEvents = baseApi<
   {},
   { name: string; namespace: string; id: number; cluster_id: number }
 >("GET", (pathParams) => {
-  let { id, name, cluster_id, namespace } = pathParams;
+  const { id, name, cluster_id, namespace } = pathParams;
   return `/api/projects/${id}/clusters/${cluster_id}/namespaces/${namespace}/pods/${name}/events`;
 });
 
@@ -665,7 +666,7 @@ const updateNotificationConfig = baseApi<
     name: string;
   }
 >("POST", (pathParams) => {
-  let { project_id, cluster_id, namespace, name } = pathParams;
+  const { project_id, cluster_id, namespace, name } = pathParams;
 
   return `/api/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/releases/${name}/notifications`;
 });
@@ -720,7 +721,7 @@ const getNotificationConfig = baseApi<
     name: string;
   }
 >("GET", (pathParams) => {
-  let { project_id, cluster_id, namespace, name } = pathParams;
+  const { project_id, cluster_id, namespace, name } = pathParams;
 
   return `/api/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/releases/${name}/notifications`;
 });
@@ -759,7 +760,7 @@ const deployTemplate = baseApi<
     repo_url?: string;
   }
 >("POST", (pathParams) => {
-  let { cluster_id, id, namespace, repo_url } = pathParams;
+  const { cluster_id, id, namespace, repo_url } = pathParams;
 
   if (repo_url) {
     return `/api/projects/${id}/clusters/${cluster_id}/namespaces/${namespace}/releases?repo_url=${repo_url}`;
@@ -781,7 +782,7 @@ const deployAddon = baseApi<
     repo_url?: string;
   }
 >("POST", (pathParams) => {
-  let { cluster_id, id, namespace, repo_url } = pathParams;
+  const { cluster_id, id, namespace, repo_url } = pathParams;
 
   return `/api/projects/${id}/clusters/${cluster_id}/namespaces/${namespace}/addons?repo_url=${repo_url}`;
 });
@@ -1001,6 +1002,47 @@ const createAppTemplate = baseApi<
   return `/api/projects/${project_id}/clusters/${cluster_id}/apps/${porter_app_name}/templates`;
 });
 
+const updateApp = baseApi<
+  {
+    deployment_target_id: string;
+    b64_app_proto?: string;
+    git_source?: {
+      git_repo_id: number;
+      git_branch: string;
+      git_repo_name: string;
+    };
+    porter_yaml_path?: string;
+    variables?: Record<string, string>;
+    secrets?: Record<string, string>;
+    is_env_override?: boolean;
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+  }
+>("POST", (pathParams) => {
+  return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}/apps/update`;
+});
+
+const updateBuildSettings = baseApi<
+  {
+    build_settings: {
+      method: string;
+      context: string;
+      dockerfile: string;
+      builder: string;
+      buildpacks: string[];
+    };
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+    porter_app_name: string;
+  }
+>("POST", (pathParams) => {
+  return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}/apps/${pathParams.porter_app_name}/build`;
+});
+
 const applyApp = baseApi<
   {
     deployment_target_id: string;
@@ -1019,6 +1061,20 @@ const applyApp = baseApi<
   return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}/apps/apply`;
 });
 
+const revertApp = baseApi<
+  {
+    deployment_target_id: string;
+    app_revision_id: string;
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+    porter_app_name: string;
+  }
+>("POST", (pathParams) => {
+  return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}/apps/${pathParams.porter_app_name}/rollback`;
+});
+
 const getAttachedEnvGroups = baseApi<
   {},
   {
@@ -1169,7 +1225,7 @@ const getChart = baseApi<
     revision: number;
   }
 >("GET", (pathParams) => {
-  let { id, cluster_id, namespace, name, revision } = pathParams;
+  const { id, cluster_id, namespace, name, revision } = pathParams;
 
   return `/api/projects/${id}/clusters/${cluster_id}/namespaces/${namespace}/releases/${name}/${revision}`;
 });
@@ -1200,7 +1256,7 @@ const getChartComponents = baseApi<
     revision: number;
   }
 >("GET", (pathParams) => {
-  let { id, cluster_id, namespace, name, revision } = pathParams;
+  const { id, cluster_id, namespace, name, revision } = pathParams;
 
   return `/api/projects/${id}/clusters/${cluster_id}/namespaces/${namespace}/releases/${name}/${revision}/components`;
 });
@@ -1215,7 +1271,7 @@ const getChartControllers = baseApi<
     revision: number;
   }
 >("GET", (pathParams) => {
-  let { id, cluster_id, namespace, name, revision } = pathParams;
+  const { id, cluster_id, namespace, name, revision } = pathParams;
 
   return `/api/projects/${id}/clusters/${cluster_id}/namespaces/${namespace}/releases/${name}/${revision}/controllers`;
 });
@@ -1356,7 +1412,7 @@ const getInfraTemplate = baseApi<
     version: string;
   }
 >("GET", (pathParams) => {
-  let { project_id, name, version } = pathParams;
+  const { project_id, name, version } = pathParams;
 
   return `/api/projects/${project_id}/infras/templates/${name}/${version}`;
 });
@@ -1384,7 +1440,7 @@ const provisionCluster = baseApi<
           min_instances: number;
           max_instances: number;
           node_group_type: number;
-        }
+        },
       ];
     };
   },
@@ -1447,7 +1503,7 @@ const updateInfra = baseApi<
     infra_id: number;
   }
 >("POST", (pathParams) => {
-  let { project_id, infra_id } = pathParams;
+  const { project_id, infra_id } = pathParams;
   return `/api/projects/${project_id}/infras/${infra_id}/update`;
 });
 
@@ -1463,7 +1519,7 @@ const retryCreateInfra = baseApi<
     infra_id: number;
   }
 >("POST", (pathParams) => {
-  let { project_id, infra_id } = pathParams;
+  const { project_id, infra_id } = pathParams;
   return `/api/projects/${project_id}/infras/${infra_id}/retry_create`;
 });
 
@@ -1474,7 +1530,7 @@ const retryDeleteInfra = baseApi<
     infra_id: number;
   }
 >("POST", (pathParams) => {
-  let { project_id, infra_id } = pathParams;
+  const { project_id, infra_id } = pathParams;
   return `/api/projects/${project_id}/infras/${infra_id}/retry_delete`;
 });
 
@@ -1485,7 +1541,7 @@ const deleteInfra = baseApi<
     infra_id: number;
   }
 >("DELETE", (pathParams) => {
-  let { project_id, infra_id } = pathParams;
+  const { project_id, infra_id } = pathParams;
   return `/api/projects/${project_id}/infras/${infra_id}`;
 });
 
@@ -1507,7 +1563,7 @@ const getOperation = baseApi<
     operation_id: string;
   }
 >("GET", (pathParams) => {
-  let { project_id, infra_id, operation_id } = pathParams;
+  const { project_id, infra_id, operation_id } = pathParams;
   return `/api/projects/${project_id}/infras/${infra_id}/operations/${operation_id}`;
 });
 
@@ -1519,7 +1575,7 @@ const getOperationLogs = baseApi<
     operation_id: string;
   }
 >("GET", (pathParams) => {
-  let { project_id, infra_id, operation_id } = pathParams;
+  const { project_id, infra_id, operation_id } = pathParams;
   return `/api/projects/${project_id}/infras/${infra_id}/operations/${operation_id}/logs`;
 });
 
@@ -1577,7 +1633,7 @@ const getIngress = baseApi<
   {},
   { namespace: string; cluster_id: number; name: string; id: number }
 >("GET", (pathParams) => {
-  let { id, name, cluster_id, namespace } = pathParams;
+  const { id, name, cluster_id, namespace } = pathParams;
 
   return `/api/projects/${id}/clusters/${cluster_id}/namespaces/${namespace}/ingresses/${name}`;
 });
@@ -1590,7 +1646,7 @@ const getJobs = baseApi<
   {},
   { namespace: string; cluster_id: number; release_name: string; id: number }
 >("GET", (pathParams) => {
-  let { id, release_name, cluster_id, namespace } = pathParams;
+  const { id, release_name, cluster_id, namespace } = pathParams;
 
   return `/api/projects/${id}/clusters/${cluster_id}/namespaces/${namespace}/releases/${release_name}/0/jobs`;
 });
@@ -1599,7 +1655,7 @@ const getJobStatus = baseApi<
   {},
   { namespace: string; cluster_id: number; release_name: string; id: number }
 >("GET", (pathParams) => {
-  let { id, release_name, cluster_id, namespace } = pathParams;
+  const { id, release_name, cluster_id, namespace } = pathParams;
 
   return `/api/projects/${id}/clusters/${cluster_id}/namespaces/${namespace}/releases/${release_name}/0/jobs/status`;
 });
@@ -1608,7 +1664,7 @@ const getJobPods = baseApi<
   {},
   { name: string; namespace: string; id: number; cluster_id: number }
 >("GET", (pathParams) => {
-  let { id, name, cluster_id, namespace } = pathParams;
+  const { id, name, cluster_id, namespace } = pathParams;
   return `/api/projects/${id}/clusters/${cluster_id}/namespaces/${namespace}/jobs/${name}/pods`;
 });
 
@@ -1758,7 +1814,7 @@ const getReleaseToken = baseApi<
   {},
   { name: string; id: number; namespace: string; cluster_id: number }
 >("GET", (pathParams) => {
-  let { id, cluster_id, namespace, name } = pathParams;
+  const { id, cluster_id, namespace, name } = pathParams;
 
   return `/api/projects/${id}/clusters/${cluster_id}/namespaces/${namespace}/releases/${name}/webhook`;
 });
@@ -1767,7 +1823,7 @@ const getReleaseSteps = baseApi<
   {},
   { name: string; id: number; namespace: string; cluster_id: number }
 >("GET", (pathParams) => {
-  let { id, cluster_id, namespace, name } = pathParams;
+  const { id, cluster_id, namespace, name } = pathParams;
 
   return `/api/projects/${id}/clusters/${cluster_id}/namespaces/${namespace}/releases/${name}/steps`;
 });
@@ -1811,7 +1867,7 @@ const getRevisions = baseApi<
   {},
   { id: number; cluster_id: number; namespace: string; name: string }
 >("GET", (pathParams) => {
-  let { id, cluster_id, namespace, name } = pathParams;
+  const { id, cluster_id, namespace, name } = pathParams;
 
   return `/api/projects/${id}/clusters/${cluster_id}/namespaces/${namespace}/releases/${name}/history`;
 });
@@ -1926,7 +1982,7 @@ const rollbackChart = baseApi<
     cluster_id: number;
   }
 >("POST", (pathParams) => {
-  let { id, name, cluster_id, namespace } = pathParams;
+  const { id, name, cluster_id, namespace } = pathParams;
   return `/api/projects/${id}/clusters/${cluster_id}/namespaces/${namespace}/releases/${name}/0/rollback`;
 });
 
@@ -1939,7 +1995,7 @@ const uninstallTemplate = baseApi<
     namespace: string;
   }
 >("DELETE", (pathParams) => {
-  let { id, name, cluster_id, namespace } = pathParams;
+  const { id, name, cluster_id, namespace } = pathParams;
   return `/api/projects/${id}/clusters/${cluster_id}/namespaces/${namespace}/releases/${name}/0`;
 });
 
@@ -1977,7 +2033,7 @@ const upgradeChartValues = baseApi<
     cluster_id: number;
   }
 >("POST", (pathParams) => {
-  let { id, name, cluster_id, namespace } = pathParams;
+  const { id, name, cluster_id, namespace } = pathParams;
   return `/api/projects/${id}/clusters/${cluster_id}/namespaces/${namespace}/releases/${name}/0/upgrade`;
 });
 
@@ -2120,8 +2176,8 @@ const cloneEnvGroup = baseApi<
 const updateEnvGroup = baseApi<
   {
     name: string;
-    variables: { [key: string]: string };
-    secret_variables?: { [key: string]: string };
+    variables: Record<string, string>;
+    secret_variables?: Record<string, string>;
   },
   {
     project_id: number;
@@ -2137,8 +2193,8 @@ const updateEnvGroup = baseApi<
 const updateStacksEnvGroup = baseApi<
   {
     name: string;
-    variables: { [key: string]: string };
-    secret_variables?: { [key: string]: string };
+    variables: Record<string, string>;
+    secret_variables?: Record<string, string>;
     apps?: string[];
   },
   {
@@ -2179,7 +2235,7 @@ const updateConfigMap = baseApi<
     namespace: string;
   }
 >("POST", (pathParams) => {
-  let { id, cluster_id } = pathParams;
+  const { id, cluster_id } = pathParams;
   return `/api/projects/${pathParams.id}/clusters/${pathParams.cluster_id}/namespaces/${pathParams.namespace}/configmap/update`;
 });
 
@@ -2241,7 +2297,7 @@ const createNamespace = baseApi<
   },
   { id: number; cluster_id: number }
 >("POST", (pathParams) => {
-  let { id, cluster_id } = pathParams;
+  const { id, cluster_id } = pathParams;
   return `/api/projects/${id}/clusters/${cluster_id}/namespaces/create`;
 });
 
@@ -2253,7 +2309,7 @@ const deleteNamespace = baseApi<
     namespace: string;
   }
 >("DELETE", (pathParams) => {
-  let { id, cluster_id, namespace } = pathParams;
+  const { id, cluster_id, namespace } = pathParams;
   return `/api/projects/${id}/clusters/${cluster_id}/namespaces/${namespace}`;
 });
 
@@ -2261,7 +2317,7 @@ const deleteJob = baseApi<
   {},
   { name: string; namespace: string; id: number; cluster_id: number }
 >("DELETE", (pathParams) => {
-  let { id, name, cluster_id, namespace } = pathParams;
+  const { id, name, cluster_id, namespace } = pathParams;
   return `/api/projects/${id}/clusters/${cluster_id}/namespaces/${namespace}/jobs/${name}`;
 });
 
@@ -2269,7 +2325,7 @@ const stopJob = baseApi<
   {},
   { name: string; namespace: string; id: number; cluster_id: number }
 >("POST", (pathParams) => {
-  let { id, name, namespace, cluster_id } = pathParams;
+  const { id, name, namespace, cluster_id } = pathParams;
   return `/api/projects/${id}/clusters/${cluster_id}/namespaces/${namespace}/jobs/${name}/stop`;
 });
 
@@ -2937,7 +2993,7 @@ const updateStackStep = baseApi<
     cluster_id: number;
   }
 >("POST", (pathParams) => {
-  let { project_id, cluster_id } = pathParams;
+  const { project_id, cluster_id } = pathParams;
   return `/api/projects/${project_id}/clusters/${cluster_id}/applications/analytics`;
 });
 
@@ -3274,7 +3330,10 @@ export default {
   validatePorterApp,
   createApp,
   createAppTemplate,
+  updateApp,
+  updateBuildSettings,
   applyApp,
+  revertApp,
   getAttachedEnvGroups,
   getLatestRevision,
   getRevision,

+ 82 - 91
dashboard/src/shared/types.tsx

@@ -1,6 +1,4 @@
-import ValuesYaml from "main/home/cluster-dashboard/expanded-chart/ValuesYaml";
-
-export interface ClusterType {
+export type ClusterType = {
   id: number;
   name: string;
   vanity_name?: string;
@@ -15,26 +13,26 @@ export interface ClusterType {
   cloud_provider_credential_identifier?: string;
   status?: string;
   cloud_provider: string;
-}
+};
 
-export interface AddonCard {
+export type AddonCard = {
   id: string;
   icon: string;
   name: string;
   description: string;
-}
+};
 
-export interface DetailedClusterType extends ClusterType {
+export type DetailedClusterType = {
   ingress_ip?: string;
   ingress_error?: DetailedIngressError;
-}
+} & ClusterType;
 
-export interface DetailedIngressError {
+export type DetailedIngressError = {
   message: string;
   error: string;
-}
+};
 
-export interface ChartType {
+export type ChartType = {
   stack_id: string;
   image_repo_uri: string;
   git_action_config: any;
@@ -56,10 +54,10 @@ export interface ChartType {
       icon: string;
       apiVersion: string;
     };
-    files?: {
+    files?: Array<{
       data: string;
       name: string;
-    }[];
+    }>;
   };
   form?: FormYAML;
   config: any;
@@ -68,9 +66,9 @@ export interface ChartType {
   latest_version: string;
   tags: any;
   canonical_name: string;
-}
+};
 
-export interface ChartTypeWithExtendedConfig extends ChartType {
+export type ChartTypeWithExtendedConfig = {
   config: {
     auto_deploy: boolean;
     autoscaling: {
@@ -89,12 +87,8 @@ export interface ChartTypeWithExtendedConfig extends ChartType {
     container: {
       command: string;
       env: {
-        normal: {
-          [key: string]: string;
-        };
-        build: {
-          [key: string]: string;
-        };
+        normal: Record<string, string>;
+        build: Record<string, string>;
         synced: any;
       };
       lifecycle: { postStart: string; preStop: string };
@@ -137,17 +131,17 @@ export interface ChartTypeWithExtendedConfig extends ChartType {
       value: string;
     };
   };
-}
+} & ChartType;
 
-export interface ResourceType {
+export type ResourceType = {
   ID: number;
   Kind: string;
   Name: string;
   RawYAML: any;
   Relations: any;
-}
+};
 
-export interface NodeType {
+export type NodeType = {
   id: number;
   name: string;
   kind: string;
@@ -158,13 +152,13 @@ export interface NodeType {
   h: number;
   toCursorX?: number;
   toCursorY?: number;
-}
+};
 
-export interface EdgeType {
+export type EdgeType = {
   type: string;
   source: number;
   target: number;
-}
+};
 
 export enum StorageType {
   Secret = "secret",
@@ -173,64 +167,64 @@ export enum StorageType {
 }
 
 // PorterTemplate represents a bundled Porter template
-export interface PorterTemplate {
+export type PorterTemplate = {
   name: string;
   versions: string[];
   currentVersion: string;
   description: string;
   icon: string;
   repo_url?: string;
-  tags?: string[]
-}
+  tags?: string[];
+};
 
-export interface ExpandedPorterTemplate {
+export type ExpandedPorterTemplate = {
   form: FormYAML;
   markdown: string;
   metadata: ChartType["chart"]["metadata"];
   values: ChartTypeWithExtendedConfig["config"];
-}
+};
 
 // FormYAML represents a chart's values.yaml form abstraction
-export interface FormYAML {
+export type FormYAML = {
   name?: string;
   icon?: string;
   description?: string;
   hasSource?: string;
   tags?: string[];
-  tabs?: {
+  tabs?: Array<{
     name: string;
     label: string;
     sections?: Section[];
-  }[];
-}
+  }>;
+};
 
-export interface ShowIfAnd {
+export type ShowIfAnd = {
   and: ShowIf[];
-}
+};
 
-export interface ShowIfOr {
+export type ShowIfOr = {
   or: ShowIf[];
-}
+};
 
-export interface ShowIfNot {
+export type ShowIfNot = {
   not: ShowIf;
-}
+};
 
-export interface ShowIfIs {
+export type ShowIfIs = {
   variable: string;
   is: string;
-}
+};
 
 export type ShowIf = string | ShowIfIs | ShowIfAnd | ShowIfOr | ShowIfNot;
 
-export interface Section {
+export type Section = {
   name?: string;
   show_if?: ShowIf;
   contents: FormElement[];
-}
+};
 
 // FormElement represents a form element
-export interface FormElement {
+export type FormElement = {
   type: string;
   info?: string;
   label: string;
@@ -247,31 +241,31 @@ export interface FormElement {
     disableAfterLaunch?: boolean;
     unit?: string;
   };
-}
+};
 
 export type RepoType = {
   FullName: string;
 } & (
-    | {
+  | {
       Kind: "github";
       GHRepoID: number;
     }
-    | {
+  | {
       Kind: "gitlab";
       GitIntegrationId: number;
     }
-  );
+);
 
-export interface FileType {
+export type FileType = {
   path: string;
   type: string;
-}
-export interface ProjectListType {
+};
+export type ProjectListType = {
   id: number;
   name: string;
-}
+};
 
-export interface ProjectType {
+export type ProjectType = {
   id: number;
   name: string;
   preview_envs_enabled: boolean;
@@ -290,40 +284,41 @@ export interface ProjectType {
   quota_increase: boolean;
   efs_enabled: boolean;
   validate_apply_v2: boolean;
-  roles: {
+  beta_features_enabled: boolean;
+  roles: Array<{
     id: number;
     kind: string;
     user_id: number;
     project_id: number;
-  }[];
-}
+  }>;
+};
 
-export interface ChoiceType {
+export type ChoiceType = {
   value: string;
   label: string;
-}
+};
 
-export interface ImageType {
+export type ImageType = {
   kind: string;
   source: string;
   registryId: number;
   name: string;
-}
+};
 
-export interface InfraType {
+export type InfraType = {
   id: number;
   project_id: number;
   kind: string;
   status: string;
-}
+};
 
-export interface InviteType {
+export type InviteType = {
   token: string;
   expired: boolean;
   email: string;
   accepted: boolean;
   id: number;
-}
+};
 
 export type ActionConfigType = {
   git_repo: string;
@@ -331,15 +326,15 @@ export type ActionConfigType = {
   image_repo_uri: string;
   dockerfile_path?: string;
 } & (
-    | {
+  | {
       kind: "gitlab";
       gitlab_integration_id: number;
     }
-    | {
+  | {
       kind: "github";
       git_repo_id: number;
     }
-  );
+);
 
 export type GithubActionConfigType = ActionConfigType & {
   kind: "github";
@@ -359,15 +354,15 @@ export type FullGithubActionConfigType = GithubActionConfigType & {
   should_create_workflow: boolean;
 };
 
-export interface CapabilityType {
+export type CapabilityType = {
   github: boolean;
   provisioner: boolean;
   version?: string;
   default_app_helm_repo_url?: string;
   default_addon_helm_repo_url?: string;
-}
+};
 
-export interface ContextProps {
+export type ContextProps = {
   currentModal?: string;
   currentModalData: any;
   setCurrentModal: (currentModal: any, currentModalData?: any) => void;
@@ -407,7 +402,7 @@ export interface ContextProps {
   setEnableGitlab: (enableGitlab: boolean) => void;
   shouldRefreshClusters: boolean;
   setShouldRefreshClusters: (shouldRefreshClusters: boolean) => void;
-}
+};
 
 export enum JobStatusType {
   Succeeded = "succeeded",
@@ -415,24 +410,24 @@ export enum JobStatusType {
   Failed = "failed",
 }
 
-export interface JobStatusWithTimeType {
+export type JobStatusWithTimeType = {
   status: JobStatusType;
   start_time: string;
-}
+};
 
-export interface Usage {
+export type Usage = {
   resource_cpu: number;
   resource_memory: number;
   clusters: number;
   users: number;
-}
+};
 
-export interface UsageData {
-  current: Usage & { [key: string]: number };
-  limit: Usage & { [key: string]: number };
+export type UsageData = {
+  current: Usage & Record<string, number>;
+  limit: Usage & Record<string, number>;
   exceeds: boolean;
   exceeded_since?: string;
-}
+};
 
 export type KubeEvent = {
   cluster_id: number;
@@ -535,9 +530,7 @@ export type TFState = {
   last_updated: string;
   operation_id: string;
   status: TFResourceStatus;
-  resources: {
-    [key: string]: TFResourceState;
-  };
+  resources: Record<string, TFResourceState>;
 };
 
 export const KindMap: ProviderInfoMap = {
@@ -660,12 +653,10 @@ export type InfraCredentials = {
 export type BuildConfig = {
   builder: string;
   buildpacks: string[];
-  config: null | {
-    [key: string]: string;
-  };
+  config: null | Record<string, string>;
 };
 
-export interface CreateUpdatePorterAppOptions {
+export type CreateUpdatePorterAppOptions = {
   porter_yaml: string;
   porter_yaml_path?: string;
   repo_name?: string;
@@ -682,4 +673,4 @@ export interface CreateUpdatePorterAppOptions {
   };
   override_release?: boolean;
   full_helm_values?: string;
-}
+};

+ 1 - 1
go.mod

@@ -83,7 +83,7 @@ require (
 	github.com/matryer/is v1.4.0
 	github.com/nats-io/nats.go v1.24.0
 	github.com/open-policy-agent/opa v0.44.0
-	github.com/porter-dev/api-contracts v0.2.39
+	github.com/porter-dev/api-contracts v0.2.40
 	github.com/riandyrn/otelchi v0.5.1
 	github.com/santhosh-tekuri/jsonschema/v5 v5.0.1
 	github.com/stefanmcshane/helm v0.0.0-20221213002717-88a4a2c6e77d

+ 2 - 2
go.sum

@@ -1520,8 +1520,8 @@ github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/polyfloyd/go-errorlint v0.0.0-20210722154253-910bb7978349/go.mod h1:wi9BfjxjF/bwiZ701TzmfKu6UKC357IOAtNr0Td0Lvw=
-github.com/porter-dev/api-contracts v0.2.39 h1:0adXmIdyLYcJAcCXWlL5PYozoCLU70WRTX3Bl9VIXg4=
-github.com/porter-dev/api-contracts v0.2.39/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
+github.com/porter-dev/api-contracts v0.2.40 h1:K6rbdTBnh2lPM0U6GgIE2DUBOj7YyxQNqQE0DpquCFo=
+github.com/porter-dev/api-contracts v0.2.40/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
 github.com/porter-dev/switchboard v0.0.3 h1:dBuYkiVLa5Ce7059d6qTe9a1C2XEORFEanhbtV92R+M=
 github.com/porter-dev/switchboard v0.0.3/go.mod h1:xSPzqSFMQ6OSbp42fhCi4AbGbQbsm6nRvOkrblFeXU4=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=