ソースを参照

app view services and overrides (#3446)

ianedwards 2 年 前
コミット
8372577354

+ 7 - 7
dashboard/package-lock.json

@@ -13,7 +13,7 @@
         "@loadable/component": "^5.15.2",
         "@material-ui/core": "^4.11.3",
         "@material-ui/lab": "^4.0.0-alpha.61",
-        "@porter-dev/api-contracts": "^0.0.86",
+        "@porter-dev/api-contracts": "^0.0.93",
         "@react-spring/web": "^9.6.1",
         "@sentry/react": "^6.13.2",
         "@sentry/tracing": "^6.13.2",
@@ -2454,9 +2454,9 @@
       }
     },
     "node_modules/@porter-dev/api-contracts": {
-      "version": "0.0.86",
-      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.0.86.tgz",
-      "integrity": "sha512-nihcZuR+FsbbBBr+7gIsvpxSJvRS+eGurSAElytN5LIimL8TbYN4T+7EDAA0sDvRp95qF7B+vdezPbHTsWRkcA==",
+      "version": "0.0.93",
+      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.0.93.tgz",
+      "integrity": "sha512-BPJKvCNUXsVGw2rp3SC04fp6lYRTliEdxxORs/SxbxkQOysZxs21K/lAHmti7LIlqyFStil+g+gKyTCQSyWagg==",
       "dependencies": {
         "@bufbuild/protobuf": "^1.1.0"
       }
@@ -16943,9 +16943,9 @@
       "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="
     },
     "@porter-dev/api-contracts": {
-      "version": "0.0.86",
-      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.0.86.tgz",
-      "integrity": "sha512-nihcZuR+FsbbBBr+7gIsvpxSJvRS+eGurSAElytN5LIimL8TbYN4T+7EDAA0sDvRp95qF7B+vdezPbHTsWRkcA==",
+      "version": "0.0.93",
+      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.0.93.tgz",
+      "integrity": "sha512-BPJKvCNUXsVGw2rp3SC04fp6lYRTliEdxxORs/SxbxkQOysZxs21K/lAHmti7LIlqyFStil+g+gKyTCQSyWagg==",
       "requires": {
         "@bufbuild/protobuf": "^1.1.0"
       }

+ 1 - 1
dashboard/package.json

@@ -8,7 +8,7 @@
     "@loadable/component": "^5.15.2",
     "@material-ui/core": "^4.11.3",
     "@material-ui/lab": "^4.0.0-alpha.61",
-    "@porter-dev/api-contracts": "^0.0.86",
+    "@porter-dev/api-contracts": "^0.0.93",
     "@react-spring/web": "^9.6.1",
     "@sentry/react": "^6.13.2",
     "@sentry/tracing": "^6.13.2",

+ 44 - 44
dashboard/src/lib/hooks/useAppValidation.ts

@@ -54,56 +54,56 @@ export const useAppValidation = ({
 
   const validateApp = useCallback(
     async (data: PorterAppFormData) => {
-      try {
-        if (!currentProject || !currentCluster) {
-          throw new Error("No project or cluster selected");
-        }
+      if (!currentProject || !currentCluster) {
+        throw new Error("No project or cluster selected");
+      }
 
-        if (!deploymentTargetID) {
-          throw new Error("No deployment target selected");
-        }
+      if (!deploymentTargetID) {
+        throw new Error("No deployment target selected");
+      }
 
-        const proto = clientAppToProto(data);
-        const commit_sha = await match(data.source)
-          .with({ type: "github" }, async (src) => {
-            const { commit_sha } = await getBranchHead({
-              projectID: currentProject.id,
-              source: src,
-            });
-            return commit_sha;
-          })
-          .with({ type: "docker-registry" }, () => {
-            return "";
-          })
-          .exhaustive();
+      const proto = clientAppToProto(data);
+      const commit_sha = await match(data.source)
+        .with({ type: "github" }, async (src) => {
+          const { commit_sha } = await getBranchHead({
+            projectID: currentProject.id,
+            source: src,
+          });
+          return commit_sha;
+        })
+        .with({ type: "docker-registry" }, () => {
+          return "";
+        })
+        .exhaustive();
 
-        const res = await api.validatePorterApp(
-          "<token>",
-          {
-            b64_app_proto: btoa(proto.toJsonString()),
-            deployment_target_id: deploymentTargetID,
-            commit_sha,
-          },
-          {
-            project_id: currentProject.id,
-            cluster_id: currentCluster.id,
-          }
-        );
+      const res = await api.validatePorterApp(
+        "<token>",
+        {
+          b64_app_proto: btoa(
+            proto.toJsonString({
+              emitDefaultValues: true,
+            })
+          ),
+          deployment_target_id: deploymentTargetID,
+          commit_sha,
+        },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      );
 
-        const validAppData = await z
-          .object({
-            validate_b64_app_proto: z.string(),
-          })
-          .parseAsync(res.data);
+      const validAppData = await z
+        .object({
+          validate_b64_app_proto: z.string(),
+        })
+        .parseAsync(res.data);
 
-        const validatedAppProto = PorterApp.fromJsonString(
-          atob(validAppData.validate_b64_app_proto)
-        );
+      const validatedAppProto = PorterApp.fromJsonString(
+        atob(validAppData.validate_b64_app_proto)
+      );
 
-        return validatedAppProto;
-      } catch (err) {
-        return null;
-      }
+      return validatedAppProto;
     },
     [deploymentTargetID, currentProject, currentCluster]
   );

+ 36 - 14
dashboard/src/lib/hooks/usePorterYaml.ts

@@ -1,16 +1,21 @@
 import { PorterApp } from "@porter-dev/api-contracts";
 import { useQuery } from "@tanstack/react-query";
-import { SourceOptions, defaultServicesWithOverrides } from "lib/porter-apps";
-import { ClientService } from "lib/porter-apps/services";
+import { SourceOptions, serviceOverrides } from "lib/porter-apps";
+import { ClientService, DetectedServices } from "lib/porter-apps/services";
 import { useCallback, useContext, useEffect, useState } from "react";
 import { Context } from "shared/Context";
 import api from "shared/api";
 import { z } from "zod";
 
-type DetectedServices = {
-  services: ClientService[];
-  predeploy?: ClientService;
-};
+type PorterYamlStatus =
+  | {
+      loading: true;
+      detectedServices: null;
+    }
+  | {
+      detectedServices: DetectedServices | null;
+      loading: false;
+    };
 
 /*
  *
@@ -19,25 +24,31 @@ type DetectedServices = {
  * added to an app by default with read-only values.
  *
  */
-export const usePorterYaml = (source: SourceOptions) => {
+export const usePorterYaml = ({
+  source,
+  useDefaults = true,
+}: {
+  source: SourceOptions | null;
+  useDefaults?: boolean;
+}): PorterYamlStatus => {
   const { currentProject, currentCluster } = useContext(Context);
   const [
     detectedServices,
     setDetectedServices,
   ] = useState<DetectedServices | null>(null);
 
-  const { data } = useQuery(
+  const { data, status } = useQuery(
     [
       "getPorterYamlContents",
       currentProject?.id,
-      source.git_branch,
-      source.git_repo_name,
+      source?.git_branch,
+      source?.git_repo_name,
     ],
     async () => {
       if (!currentProject) {
         return;
       }
-      if (source.type !== "github") {
+      if (source?.type !== "github") {
         return;
       }
       const res = await api.getPorterYamlContents(
@@ -59,7 +70,7 @@ export const usePorterYaml = (source: SourceOptions) => {
     },
     {
       enabled:
-        source.type === "github" &&
+        source?.type === "github" &&
         Boolean(source.git_repo_name) &&
         Boolean(source.git_branch),
     }
@@ -92,8 +103,9 @@ export const usePorterYaml = (source: SourceOptions) => {
           .parseAsync(res.data);
         const proto = PorterApp.fromJsonString(atob(data.b64_app_proto));
 
-        const { services, predeploy } = defaultServicesWithOverrides({
+        const { services, predeploy } = serviceOverrides({
           overrides: proto,
+          useDefaults,
         });
 
         if (services.length || predeploy) {
@@ -127,5 +139,15 @@ export const usePorterYaml = (source: SourceOptions) => {
     }
   }, [data]);
 
-  return detectedServices;
+  if (status === "loading") {
+    return {
+      loading: true,
+      detectedServices: null,
+    };
+  }
+
+  return {
+    detectedServices,
+    loading: false,
+  };
 };

+ 80 - 30
dashboard/src/lib/porter-apps/index.ts

@@ -1,7 +1,7 @@
 import { buildpackSchema } from "main/home/app-dashboard/types/buildpack";
 import { z } from "zod";
 import {
-  ClientService,
+  DetectedServices,
   defaultSerialized,
   deserializeService,
   isPredeployService,
@@ -69,45 +69,62 @@ export const porterAppFormValidator = z.object({
 });
 export type PorterAppFormData = z.infer<typeof porterAppFormValidator>;
 
-// defaultServicesWithOverrides is used to generate the default services for an app from porter.yaml
+// serviceOverrides is used to generate the services overrides for an app from porter.yaml
 // this method is only called when a porter.yaml is present and has services defined
-export function defaultServicesWithOverrides({
+export function serviceOverrides({
   overrides,
+  useDefaults = true,
 }: {
   overrides: PorterApp;
-}): {
-  services: ClientService[];
-  predeploy?: ClientService;
-} {
+  useDefaults?: boolean;
+}): DetectedServices {
   const services = Object.entries(overrides.services)
     .map(([name, service]) => serializedServiceFromProto({ name, service }))
-    .map((svc) =>
-      deserializeService(
-        defaultSerialized({
-          name: svc.name,
-          type: svc.config.type,
-        }),
-        svc
-      )
-    );
+    .map((svc) => {
+      if (useDefaults) {
+        return deserializeService({
+          service: defaultSerialized({ name: svc.name, type: svc.config.type }),
+          override: svc,
+          expanded: true,
+        });
+      }
+
+      return deserializeService({ service: svc });
+    });
 
-  const predeploy = overrides.predeploy
-    ? deserializeService(
-        defaultSerialized({
+  if (!overrides.predeploy) {
+    return {
+      services,
+    };
+  }
+
+  if (useDefaults) {
+    return {
+      services,
+      predeploy: deserializeService({
+        service: defaultSerialized({
           name: "pre-deploy",
           type: "predeploy",
         }),
-        serializedServiceFromProto({
+        override: serializedServiceFromProto({
           name: "pre-deploy",
           service: overrides.predeploy,
           isPredeploy: true,
-        })
-      )
-    : undefined;
+        }),
+        expanded: true,
+      }),
+    };
+  }
 
   return {
     services,
-    predeploy,
+    predeploy: deserializeService({
+      service: serializedServiceFromProto({
+        name: "pre-deploy",
+        service: overrides.predeploy,
+        isPredeploy: true,
+      }),
+    }),
   };
 }
 
@@ -115,6 +132,7 @@ const clientBuildToProto = (build: BuildOptions) => {
   return match(build)
     .with({ method: "pack" }, (b) =>
       Object.freeze({
+        method: "pack",
         context: b.context,
         buildpacks: b.buildpacks.map((b) => b.buildpack),
         builder: b.builder,
@@ -122,6 +140,7 @@ const clientBuildToProto = (build: BuildOptions) => {
     )
     .with({ method: "docker" }, (b) =>
       Object.freeze({
+        method: "docker",
         context: b.context,
         dockerfile: b.dockerfile,
       })
@@ -219,19 +238,50 @@ const clientBuildFromProto = (proto?: Build): BuildOptions | undefined => {
     .exhaustive();
 };
 
-export function clientAppFromProto(proto: PorterApp): ClientPorterApp {
+export function clientAppFromProto(
+  proto: PorterApp,
+  overrides: DetectedServices | null
+): ClientPorterApp {
   const services = Object.entries(proto.services)
     .map(([name, service]) => serializedServiceFromProto({ name, service }))
-    .map((svc) => deserializeService(svc));
+    .map((svc) => {
+      const override = overrides?.services.find(
+        (s) => s.name.value === svc.name
+      );
+
+      if (override) {
+        return deserializeService({
+          service: svc,
+          override: serializeService(override),
+        });
+      }
+      return deserializeService({ service: svc });
+    });
+
+  if (!overrides?.predeploy) {
+    return {
+      name: proto.name,
+      services,
+      env: proto.env,
+      build: clientBuildFromProto(proto.build) ?? {
+        method: "pack",
+        context: "./",
+        buildpacks: [],
+        builder: "",
+      },
+    };
+  }
 
+  const predeployOverrides = serializeService(overrides.predeploy);
   const predeploy = proto.predeploy
-    ? deserializeService(
-        serializedServiceFromProto({
+    ? deserializeService({
+        service: serializedServiceFromProto({
           name: "pre-deploy",
           service: proto.predeploy,
           isPredeploy: true,
-        })
-      )
+        }),
+        override: predeployOverrides,
+      })
     : undefined;
 
   return {

+ 14 - 5
dashboard/src/lib/porter-apps/services.ts

@@ -17,6 +17,10 @@ import {
 } from "./values";
 import { Service, ServiceType } from "@porter-dev/api-contracts";
 
+export type DetectedServices = {
+  services: ClientService[];
+  predeploy?: ClientService;
+};
 type ClientServiceType = "web" | "worker" | "job" | "predeploy";
 
 // serviceValidator is the validator for a ClientService
@@ -228,12 +232,17 @@ export function serializeService(service: ClientService): SerializedService {
 
 // deserializeService converts a SerializedService to a ClientService
 // A deserialized ClientService represents the state of a service in the UI and which fields are editable
-export function deserializeService(
-  service: SerializedService,
-  override?: SerializedService
-): ClientService {
+export function deserializeService({
+  service,
+  override,
+  expanded,
+}: {
+  service: SerializedService;
+  override?: SerializedService;
+  expanded?: boolean;
+}): ClientService {
   const baseService = {
-    expanded: true,
+    expanded,
     canDelete: !override,
     name: ServiceField.string(service.name, override?.name),
     run: ServiceField.string(service.run, override?.run),

+ 1 - 1
dashboard/src/lib/porter-apps/values.ts

@@ -10,7 +10,7 @@ export type ServiceString = z.infer<typeof serviceStringValidator>;
 // ServiceNumber is a number value in a service that can be read-only or editable
 export const serviceNumberValidator = z.object({
   readOnly: z.boolean(),
-  value: z.number(),
+  value: z.coerce.number(),
 });
 export type ServiceNumber = z.infer<typeof serviceNumberValidator>;
 

+ 5 - 1
dashboard/src/main/home/Home.tsx

@@ -423,7 +423,11 @@ const Home: React.FC<Props> = (props) => {
               )}
             </Route>
             <Route path="/apps/:appName/:tab">
-              <ExpandedApp />
+              {currentProject?.validate_apply_v2 ? (
+                <AppView />
+              ) : (
+                <ExpandedApp />
+              )}
             </Route>
             <Route path="/apps/:appName">
               {currentProject?.validate_apply_v2 ? (

+ 80 - 35
dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx

@@ -1,7 +1,5 @@
-import React, { useContext, useMemo } from "react";
-import { AppRevision } from "lib/revisions/types";
-import { PorterApp } from "@porter-dev/api-contracts";
-import { useForm } from "react-hook-form";
+import React, { useEffect, useMemo } from "react";
+import { FormProvider, useForm } from "react-hook-form";
 import {
   PorterAppFormData,
   SourceOptions,
@@ -9,27 +7,56 @@ import {
   porterAppFormValidator,
 } from "lib/porter-apps";
 import { zodResolver } from "@hookform/resolvers/zod";
-import { PorterAppRecord } from "./AppView";
 import RevisionsList from "./RevisionsList";
-import { Context } from "shared/Context";
-import { useDefaultDeploymentTarget } from "lib/hooks/useDeploymentTarget";
+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";
+
+// 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 = "overview";
+type ValidTab = typeof validTabs[number];
 
 type AppDataContainerProps = {
-  latestRevision: AppRevision;
-  porterApp: PorterAppRecord;
+  tabParam?: string;
 };
 
-const AppDataContainer: React.FC<AppDataContainerProps> = ({
-  latestRevision,
-  porterApp,
-}) => {
-  const { currentProject, currentCluster } = useContext(Context);
-  const deploymentTarget = useDefaultDeploymentTarget();
+const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
+  const history = useHistory();
+  const {
+    porterApp,
+    latestProto,
+    latestRevision,
+    projectId,
+    clusterId,
+    deploymentTargetId,
+    servicesFromYaml,
+  } = useLatestRevision();
+
+  const currentTab = useMemo(() => {
+    if (tabParam && validTabs.includes(tabParam as ValidTab)) {
+      return tabParam as ValidTab;
+    }
+
+    return DEFAULT_TAB;
+  }, [tabParam]);
 
-  const latestProto = useMemo(
-    () => PorterApp.fromJsonString(atob(latestRevision.b64_app_proto)),
-    [latestRevision]
-  );
   const latestSource: SourceOptions = useMemo(() => {
     if (porterApp.image_repo_uri) {
       const [repository, tag] = porterApp.image_repo_uri.split(":");
@@ -55,28 +82,46 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({
     reValidateMode: "onSubmit",
     resolver: zodResolver(porterAppFormValidator),
     defaultValues: {
-      app: clientAppFromProto(latestProto),
+      app: clientAppFromProto(latestProto, servicesFromYaml),
       source: latestSource,
     },
   });
+  const { reset } = porterAppFormMethods;
 
-  if (!currentProject || !currentCluster) {
-    return null;
-  }
-
-  if (!deploymentTarget) {
-    return null;
-  }
+  useEffect(() => {
+    if (servicesFromYaml) {
+      reset({
+        app: clientAppFromProto(latestProto, servicesFromYaml),
+        source: latestSource,
+      });
+    }
+  }, [servicesFromYaml]);
 
   return (
-    <RevisionsList
-      latestRevisionNumber={latestRevision.revision_number}
-      deploymentTargetId={deploymentTarget?.deployment_target_id}
-      projectId={currentProject.id}
-      clusterId={currentCluster.id}
-      appName={porterApp.name}
-      sourceType={latestSource.type}
-    />
+    <FormProvider {...porterAppFormMethods}>
+      <RevisionsList
+        latestRevisionNumber={latestRevision.revision_number}
+        deploymentTargetId={deploymentTargetId}
+        projectId={projectId}
+        clusterId={clusterId}
+        appName={porterApp.name}
+        sourceType={latestSource.type}
+      />
+      <Spacer y={1} />
+      <TabSelector
+        noBuffer
+        options={[{ label: "Overview", value: "overview" }]}
+        currentTab={currentTab}
+        setCurrentTab={() => {
+          history.push(`/apps/${porterApp.name}/${currentTab}`);
+        }}
+      />
+      <Spacer y={1} />
+      {match(currentTab)
+        .with("overview", () => <Overview />)
+        .otherwise(() => null)}
+      <Spacer y={2} />
+    </FormProvider>
   );
 };
 

+ 151 - 0
dashboard/src/main/home/app-dashboard/app-view/AppHeader.tsx

@@ -0,0 +1,151 @@
+import React, { useMemo } from "react";
+
+import Container from "components/porter/Container";
+import Icon from "components/porter/Icon";
+
+import web from "assets/web.png";
+import box from "assets/box.png";
+import github from "assets/github-white.png";
+import pr_icon from "assets/pull_request_icon.svg";
+
+import { PorterApp } from "@porter-dev/api-contracts";
+import { useLatestRevision } from "./LatestRevisionContext";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import styled from "styled-components";
+
+// Buildpack icons
+const icons = [
+  "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/ruby/ruby-plain.svg",
+  "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/nodejs/nodejs-plain.svg",
+  "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/python/python-plain.svg",
+  "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/go/go-original-wordmark.svg",
+  web,
+];
+
+const AppHeader: React.FC = () => {
+  const { latestProto, porterApp } = useLatestRevision();
+
+  const gitData = useMemo(() => {
+    if (
+      !porterApp.git_branch ||
+      !porterApp.repo_name ||
+      !porterApp.git_repo_id
+    ) {
+      return null;
+    }
+
+    return {
+      id: porterApp.git_repo_id,
+      branch: porterApp.git_branch,
+      repo: porterApp.repo_name,
+    };
+  }, [porterApp]);
+
+  const getIconSvg = (build: PorterApp["build"]) => {
+    if (!build) {
+      return box;
+    }
+
+    const bp = build.buildpacks[0]?.split("/")[1];
+    switch (bp) {
+      case "ruby":
+        return icons[0];
+      case "nodejs":
+        return icons[1];
+      case "python":
+        return icons[2];
+      case "go":
+        return icons[3];
+      default:
+        return box;
+    }
+  };
+
+  return (
+    <Container row>
+      <Icon src={getIconSvg(latestProto.build)} height={"24px"} />
+      <Spacer inline x={1} />
+      <Text size={21}>{latestProto.name}</Text>
+      {gitData && (
+        <>
+          <Spacer inline x={1} />
+          <Container row>
+            <A target="_blank" href={`https://github.com/${gitData.repo}`}>
+              <SmallIcon src={github} />
+              <Text size={13}>{gitData.repo}</Text>
+            </A>
+          </Container>
+          <Spacer inline x={1} />
+          <TagWrapper>
+            Branch
+            <BranchTag>
+              <BranchIcon src={pr_icon} />
+              {gitData.branch}
+            </BranchTag>
+          </TagWrapper>
+        </>
+      )}
+      {!gitData && porterApp.image_repo_uri && (
+        <>
+          <Spacer inline x={1} />
+          <Container row>
+            <SmallIcon
+              height="19px"
+              src="https://cdn4.iconfinder.com/data/icons/logos-and-brands/512/97_Docker_logo_logos-512.png"
+            />
+            <Text size={13} color="helper">
+              {porterApp.image_repo_uri}
+            </Text>
+          </Container>
+        </>
+      )}
+    </Container>
+  );
+};
+
+export default AppHeader;
+
+const A = styled.a`
+  display: flex;
+  align-items: center;
+`;
+const SmallIcon = styled.img<{ opacity?: string; height?: string }>`
+  height: ${(props) => props.height || "15px"};
+  opacity: ${(props) => props.opacity || 1};
+  margin-right: 10px;
+`;
+const BranchIcon = styled.img`
+  height: 14px;
+  opacity: 0.65;
+  margin-right: 5px;
+`;
+const TagWrapper = styled.div`
+  height: 20px;
+  font-size: 12px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #ffffff44;
+  border: 1px solid #ffffff44;
+  border-radius: 3px;
+  padding-left: 6px;
+`;
+const BranchTag = styled.div`
+  height: 20px;
+  margin-left: 6px;
+  color: #aaaabb;
+  background: #ffffff22;
+  border-radius: 3px;
+  font-size: 12px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 0px 6px;
+  padding-left: 7px;
+  border-top-left-radius: 0px;
+  border-bottom-left-radius: 0px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+`;

+ 14 - 212
dashboard/src/main/home/app-dashboard/app-view/AppView.tsx

@@ -1,30 +1,16 @@
-import React, { useContext, useMemo } from "react";
+import React, { useMemo } from "react";
 import { RouteComponentProps, withRouter } from "react-router";
-import { useQuery } from "@tanstack/react-query";
 import { z } from "zod";
-import { PorterApp } from "@porter-dev/api-contracts";
 import styled from "styled-components";
 
-import { Context } from "shared/Context";
-import api from "shared/api";
-import { useDefaultDeploymentTarget } from "lib/hooks/useDeploymentTarget";
-import { appRevisionValidator } from "lib/revisions/types";
-
-import Loading from "components/Loading";
 import Back from "components/porter/Back";
-import Container from "components/porter/Container";
 import Spacer from "components/porter/Spacer";
-import Text from "components/porter/Text";
-import Link from "components/porter/Link";
-import Icon from "components/porter/Icon";
 
 import AppDataContainer from "./AppDataContainer";
 
 import web from "assets/web.png";
-import box from "assets/box.png";
-import github from "assets/github-white.png";
-import pr_icon from "assets/pull_request_icon.svg";
-import notFound from "assets/not-found.png";
+import AppHeader from "./AppHeader";
+import { LatestRevisionProvider } from "./LatestRevisionContext";
 
 export const porterAppValidator = z.object({
   name: z.string(),
@@ -70,204 +56,34 @@ type ValidTab = typeof validTabs[number];
 type Props = RouteComponentProps & {};
 
 const AppView: React.FC<Props> = ({ match }) => {
-  const { currentCluster, currentProject } = useContext(Context);
-  const deploymentTarget = useDefaultDeploymentTarget();
-
   const params = useMemo(() => {
     const { params } = match;
     const validParams = z
       .object({
         appName: z.string(),
+        tab: z.string().optional(),
       })
       .safeParse(params);
 
     if (!validParams.success) {
       return {
-        appName: null,
+        appName: undefined,
+        tab: undefined,
       };
     }
 
     return validParams.data;
   }, [match]);
 
-  const appParamsExist =
-    !!params.appName &&
-    !!currentCluster &&
-    !!currentProject &&
-    !!deploymentTarget;
-
-  const { data: appData, status: porterAppStatus } = useQuery(
-    ["getPorterApp", currentCluster?.id, currentProject?.id, params.appName],
-    async () => {
-      if (!appParamsExist) {
-        return;
-      }
-
-      const res = await api.getPorterApp(
-        "<token>",
-        {},
-        {
-          cluster_id: currentCluster.id,
-          project_id: currentProject.id,
-          name: params.appName,
-        }
-      );
-
-      const porterApp = await porterAppValidator.parseAsync(res.data);
-      return porterApp;
-    },
-    {
-      enabled: appParamsExist,
-    }
-  );
-
-  const { data: revision, status } = useQuery(
-    ["getLatestRevision", params.appName, "latest"],
-    async () => {
-      if (!appParamsExist) {
-        return null;
-      }
-
-      const res = await api.getLatestRevision(
-        "<token>",
-        {
-          deployment_target_id: deploymentTarget.deployment_target_id,
-        },
-        {
-          cluster_id: currentCluster.id,
-          project_id: currentProject.id,
-          porter_app_name: params.appName,
-        }
-      );
-
-      const revisionData = await z
-        .object({
-          app_revision: appRevisionValidator,
-        })
-        .parseAsync(res.data);
-
-      return revisionData.app_revision;
-    },
-    {
-      enabled: appParamsExist,
-      refetchInterval: 5000,
-    }
-  );
-
-  const gitData = useMemo(() => {
-    if (!appData?.git_branch || !appData?.repo_name || !appData?.git_repo_id) {
-      return null;
-    }
-
-    return {
-      id: appData.git_repo_id,
-      branch: appData.git_branch,
-      repo: appData.repo_name,
-    };
-  }, [appData]);
-
-  const appProto = useMemo(
-    () =>
-      revision?.b64_app_proto
-        ? PorterApp.fromJsonString(atob(revision?.b64_app_proto))
-        : null,
-    [revision]
-  );
-
-  const getIconSvg = (build: PorterApp["build"]) => {
-    if (!build) {
-      return box;
-    }
-
-    const bp = build.buildpacks[0].split("/")[1];
-    switch (bp) {
-      case "ruby":
-        return icons[0];
-      case "nodejs":
-        return icons[1];
-      case "python":
-        return icons[2];
-      case "go":
-        return icons[3];
-      default:
-        return box;
-    }
-  };
-
-  if (
-    status === "loading" ||
-    porterAppStatus === "loading" ||
-    !appParamsExist
-  ) {
-    return <Loading />;
-  }
-
-  if (
-    status === "error" ||
-    porterAppStatus === "error" ||
-    !revision ||
-    !appData
-  ) {
-    return (
-      <Placeholder>
-        <Container row>
-          <PlaceholderIcon src={notFound} />
-          <Text color="helper">
-            No application matching "{params.appName}" was found.
-          </Text>
-        </Container>
-        <Spacer y={1} />
-        <Link to="/apps">Return to dashboard</Link>
-      </Placeholder>
-    );
-  }
-
   return (
-    <StyledExpandedApp>
-      <Back to="/apps" />
-      <Container row>
-        <Icon src={getIconSvg(appProto?.build)} height={"24px"} />
-        <Spacer inline x={1} />
-        <Text size={21}>{appProto?.name}</Text>
-        {gitData && (
-          <>
-            <Spacer inline x={1} />
-            <Container row>
-              <A target="_blank" href={`https://github.com/${gitData.repo}`}>
-                <SmallIcon src={github} />
-                <Text size={13}>{gitData.repo}</Text>
-              </A>
-            </Container>
-            <Spacer inline x={1} />
-            <TagWrapper>
-              Branch
-              <BranchTag>
-                <BranchIcon src={pr_icon} />
-                {gitData.branch}
-              </BranchTag>
-            </TagWrapper>
-          </>
-        )}
-        {!gitData && appData?.image_repo_uri && (
-          <>
-            <Spacer inline x={1} />
-            <Container row>
-              <SmallIcon
-                height="19px"
-                src="https://cdn4.iconfinder.com/data/icons/logos-and-brands/512/97_Docker_logo_logos-512.png"
-              />
-              <Text size={13} color="helper">
-                {appData.image_repo_uri}
-              </Text>
-            </Container>
-          </>
-        )}
-      </Container>
-      <Spacer y={0.5} />
-      {appData && revision && (
-        <AppDataContainer porterApp={appData} latestRevision={revision} />
-      )}
-    </StyledExpandedApp>
+    <LatestRevisionProvider appName={params.appName}>
+      <StyledExpandedApp>
+        <Back to="/apps" />
+        <AppHeader />
+        <Spacer y={0.5} />
+        <AppDataContainer tabParam={params.tab} />
+      </StyledExpandedApp>
+    </LatestRevisionProvider>
   );
 };
 
@@ -287,20 +103,6 @@ const StyledExpandedApp = styled.div`
     }
   }
 `;
-const PlaceholderIcon = styled.img`
-  height: 13px;
-  margin-right: 12px;
-  opacity: 0.65;
-`;
-const Placeholder = styled.div`
-  width: 100%;
-  height: 100%;
-  display: flex;
-  flex-direction: column;
-  justify-content: center;
-  align-items: center;
-  font-size: 13px;
-`;
 const A = styled.a`
   display: flex;
   align-items: center;

+ 216 - 0
dashboard/src/main/home/app-dashboard/app-view/LatestRevisionContext.tsx

@@ -0,0 +1,216 @@
+import React, { useMemo } from "react";
+import { PorterApp } from "@porter-dev/api-contracts";
+import { useQuery } from "@tanstack/react-query";
+import { useDefaultDeploymentTarget } from "lib/hooks/useDeploymentTarget";
+import { createContext, useContext } from "react";
+import { Context } from "shared/Context";
+import api from "shared/api";
+import { PorterAppRecord, porterAppValidator } from "./AppView";
+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 } from "lib/porter-apps";
+import { usePorterYaml } from "lib/hooks/usePorterYaml";
+import { DetectedServices } from "lib/porter-apps/services";
+
+export const LatestRevisionContext = createContext<{
+  porterApp: PorterAppRecord;
+  latestRevision: AppRevision;
+  latestProto: PorterApp;
+  servicesFromYaml: DetectedServices | null;
+  clusterId: number;
+  projectId: number;
+  deploymentTargetId: string;
+} | null>(null);
+
+export const useLatestRevision = () => {
+  const context = useContext(LatestRevisionContext);
+  if (context === null) {
+    throw new Error(
+      "useLatestRevision must be used within a LatestRevisionContext"
+    );
+  }
+  return context;
+};
+
+export const LatestRevisionProvider = ({
+  appName,
+  children,
+}: {
+  appName?: string;
+  children: JSX.Element;
+}) => {
+  const { currentCluster, currentProject } = useContext(Context);
+  const deploymentTarget = useDefaultDeploymentTarget();
+
+  const appParamsExist =
+    !!appName && !!currentCluster && !!currentProject && !!deploymentTarget;
+
+  const { data: porterApp, status: porterAppStatus } = useQuery(
+    ["getPorterApp", currentCluster?.id, currentProject?.id, appName],
+    async () => {
+      if (!appParamsExist) {
+        return;
+      }
+
+      const res = await api.getPorterApp(
+        "<token>",
+        {},
+        {
+          cluster_id: currentCluster.id,
+          project_id: currentProject.id,
+          name: appName,
+        }
+      );
+
+      const porterApp = await porterAppValidator.parseAsync(res.data);
+      return porterApp;
+    },
+    {
+      enabled: appParamsExist,
+    }
+  );
+
+  const { data: latestRevision, status } = useQuery(
+    [
+      "getLatestRevision",
+      currentProject?.id,
+      currentCluster?.id,
+      deploymentTarget?.deployment_target_id,
+      appName,
+    ],
+    async () => {
+      if (!appParamsExist) {
+        return;
+      }
+      const res = await api.getLatestRevision(
+        "<token>",
+        {
+          deployment_target_id: deploymentTarget.deployment_target_id,
+        },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          porter_app_name: appName,
+        }
+      );
+
+      const revisionData = await z
+        .object({
+          app_revision: appRevisionValidator,
+        })
+        .parseAsync(res.data);
+
+      return revisionData.app_revision;
+    },
+    {
+      enabled: appParamsExist,
+      refetchInterval: 5000,
+    }
+  );
+
+  const latestSource: SourceOptions | null = useMemo(() => {
+    if (!porterApp) {
+      return null;
+    }
+
+    if (porterApp.image_repo_uri) {
+      const [repository, tag] = porterApp.image_repo_uri.split(":");
+      return {
+        type: "docker-registry",
+        image: {
+          repository,
+          tag,
+        },
+      };
+    }
+
+    return {
+      type: "github",
+      git_repo_id: porterApp.git_repo_id ?? 0,
+      git_repo_name: porterApp.repo_name ?? "",
+      git_branch: porterApp.git_branch ?? "",
+      porter_yaml_path: porterApp.porter_yaml_path ?? "./porter.yaml",
+    };
+  }, [porterApp]);
+
+  const { loading: porterYamlLoading, detectedServices } = usePorterYaml({
+    source: latestSource,
+    useDefaults: false,
+  });
+
+  const latestProto = useMemo(() => {
+    if (!latestRevision) {
+      return;
+    }
+
+    return PorterApp.fromJsonString(atob(latestRevision.b64_app_proto));
+  }, [latestRevision]);
+
+  if (
+    status === "loading" ||
+    porterAppStatus === "loading" ||
+    !appParamsExist ||
+    porterYamlLoading
+  ) {
+    return <Loading />;
+  }
+
+  if (
+    status === "error" ||
+    porterAppStatus === "error" ||
+    !latestRevision ||
+    !latestProto ||
+    !porterApp
+  ) {
+    return (
+      <Placeholder>
+        <Container row>
+          <PlaceholderIcon src={notFound} />
+          <Text color="helper">
+            No application matching "{appName}" was found.
+          </Text>
+        </Container>
+        <Spacer y={1} />
+        <Link to="/apps">Return to dashboard</Link>
+      </Placeholder>
+    );
+  }
+
+  return (
+    <LatestRevisionContext.Provider
+      value={{
+        latestRevision,
+        latestProto,
+        porterApp,
+        clusterId: currentCluster.id,
+        projectId: currentProject.id,
+        deploymentTargetId: deploymentTarget.deployment_target_id,
+        servicesFromYaml: detectedServices,
+      }}
+    >
+      {children}
+    </LatestRevisionContext.Provider>
+  );
+};
+
+const PlaceholderIcon = styled.img`
+  height: 13px;
+  margin-right: 12px;
+  opacity: 0.65;
+`;
+const Placeholder = styled.div`
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  font-size: 13px;
+`;

+ 10 - 2
dashboard/src/main/home/app-dashboard/app-view/RevisionsList.tsx

@@ -84,14 +84,22 @@ const RevisionsList: React.FC<Props> = ({
                 <Tr disableHover>
                   <Th>Revision no.</Th>
                   <Th>Timestamp</Th>
-                  <Th>Image Tag</Th>
+                  <Th>
+                    {revisionsWithProto[0]?.app_proto.build
+                      ? "Commit SHA"
+                      : "Image Tag"}
+                  </Th>
                   <Th>Rollback</Th>
                 </Tr>
                 {revisionsWithProto.map((revision) => (
                   <Tr key={revision.revision_number}>
                     <Td>{revision.revision_number}</Td>
                     <Td>{readableDate(revision.updated_at)}</Td>
-                    <Td>{revision.app_proto.image?.tag}</Td>
+                    <Td>
+                      {revision.app_proto.build
+                        ? revision.app_proto.build.commitSha.substring(0, 7)
+                        : revision.app_proto.image?.tag}
+                    </Td>
                     <Td>
                       <RollbackButton
                         disabled={

+ 62 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/Overview.tsx

@@ -0,0 +1,62 @@
+import { PorterApp } from "@porter-dev/api-contracts";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import { PorterAppFormData } from "lib/porter-apps";
+import React, { useMemo } from "react";
+import { useFormContext, useFormState } from "react-hook-form";
+import ServiceList from "../../validate-apply/services-settings/ServiceList";
+import {
+  defaultSerialized,
+  deserializeService,
+} from "lib/porter-apps/services";
+import Error from "components/porter/Error";
+import Button from "components/porter/Button";
+
+const Overview: React.FC = () => {
+  const { formState } = useFormContext<PorterAppFormData>();
+
+  const buttonStatus = useMemo(() => {
+    if (formState.isSubmitting) {
+      return "loading";
+    }
+
+    if (Object.keys(formState.errors).length > 0) {
+      return <Error message="Unable to update app" />;
+    }
+
+    return "";
+  }, [formState.isSubmitting, formState.errors]);
+
+  return (
+    <>
+      <Text size={16}>Pre-deploy job</Text>
+      <Spacer y={0.5} />
+      <ServiceList
+        limitOne={true}
+        addNewText={"Add a new pre-deploy job"}
+        prePopulateService={deserializeService({
+          service: defaultSerialized({
+            name: "pre-deploy",
+            type: "predeploy",
+          }),
+        })}
+        isPredeploy
+      />
+      <Spacer y={0.5} />
+      <Text size={16}>Application services</Text>
+      <Spacer y={0.5} />
+      <ServiceList addNewText={"Add a new service"} />
+      <Spacer y={0.75} />
+      <Button
+        type="submit"
+        status={buttonStatus}
+        loadingText={"Updating..."}
+        disabled={formState.isSubmitting || !formState.isDirty}
+      >
+        Update app
+      </Button>
+    </>
+  );
+};
+
+export default Overview;

+ 13 - 11
dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx

@@ -15,7 +15,11 @@ import { ControlledInput } from "components/porter/ControlledInput";
 import Link from "components/porter/Link";
 
 import { Context } from "shared/Context";
-import { PorterAppFormData, SourceOptions, porterAppFormValidator } from "lib/porter-apps";
+import {
+  PorterAppFormData,
+  SourceOptions,
+  porterAppFormValidator,
+} from "lib/porter-apps";
 import DashboardHeader from "main/home/cluster-dashboard/DashboardHeader";
 import SourceSelector from "../new-app-flow/SourceSelector";
 import Button from "components/porter/Button";
@@ -122,7 +126,7 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
   const build = watch("app.build");
   const image = watch("source.image");
   const services = watch("app.services");
-  const servicesFromYaml = usePorterYaml(source);
+  const { detectedServices: servicesFromYaml } = usePorterYaml({ source });
   const deploymentTarget = useDefaultDeploymentTarget();
   const { updateAppStep } = useAppAnalytics(name);
   const { validateApp } = useAppValidation({
@@ -131,6 +135,7 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
 
   const onSubmit = handleSubmit(async (data) => {
     try {
+      setDeployError("");
       const validatedAppProto = await validateApp(data);
       setValidatedAppProto(validatedAppProto);
 
@@ -334,7 +339,7 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
       setError("app.name", {
         message: "An app with this name already exists",
       });
-      return
+      return;
     }
   }, [porterApps, name]);
 
@@ -444,10 +449,7 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
                       )}
                     </Container>
                     <Spacer y={0.5} />
-                    <ServiceList
-                      defaultExpanded={true}
-                      addNewText={"Add a new service"}
-                    />
+                    <ServiceList addNewText={"Add a new service"} />
                   </>,
                   <>
                     <Text size={16}>Environment variables (optional)</Text>
@@ -470,12 +472,12 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
                       <ServiceList
                         limitOne={true}
                         addNewText={"Add a new pre-deploy job"}
-                        prePopulateService={deserializeService(
-                          defaultSerialized({
+                        prePopulateService={deserializeService({
+                          service: defaultSerialized({
                             name: "pre-deploy",
                             type: "predeploy",
-                          })
-                        )}
+                          }),
+                        })}
                         isPredeploy
                       />
                     </>

+ 10 - 28
dashboard/src/main/home/app-dashboard/validate-apply/services-settings/ServiceContainer.tsx

@@ -39,7 +39,7 @@ const ServiceContainer: React.FC<ServiceProps> = ({
   update,
   remove,
 }) => {
-  const [height, setHeight] = useState<Height>("auto");
+  const [height, setHeight] = useState<Height>(service.expanded ? "auto" : 0);
 
   const UPPER_BOUND = 0.75;
 
@@ -233,13 +233,15 @@ const ServiceContainer: React.FC<ServiceProps> = ({
         contentClassName="auto-content"
         duration={300}
       >
-        <StyledSourceBox
-          showExpanded={service.expanded}
-          chart={chart}
-          hasFooter={chart && service && getHasBuiltImage()}
-        >
-          {renderTabs(service)}
-        </StyledSourceBox>
+        {height !== 0 && (
+          <StyledSourceBox
+            showExpanded={service.expanded}
+            chart={chart}
+            hasFooter={chart && service && getHasBuiltImage()}
+          >
+            {renderTabs(service)}
+          </StyledSourceBox>
+        )}
       </AnimateHeight>
       {chart &&
         service &&
@@ -333,29 +335,9 @@ const ServiceHeader = styled.div<{
     transform: ${(props: { showExpanded?: boolean; chart: any }) =>
       props.showExpanded ? "" : "rotate(-90deg)"};
   }
-
-  animation: fadeIn 0.3s 0s;
-  @keyframes fadeIn {
-    from {
-      opacity: 0;
-    }
-    to {
-      opacity: 1;
-    }
-  }
 `;
 
 const Icon = styled.img`
   height: 18px;
   margin-right: 15px;
-
-  animation: fadeIn 0.3s 0s;
-  @keyframes fadeIn {
-    from {
-      opacity: 0;
-    }
-    to {
-      opacity: 1;
-    }
-  }
 `;

+ 14 - 3
dashboard/src/main/home/app-dashboard/validate-apply/services-settings/ServiceList.tsx

@@ -41,7 +41,6 @@ type AddServiceFormValues = z.infer<typeof addServiceFormValidator>;
 
 type ServiceListProps = {
   addNewText: string;
-  defaultExpanded?: boolean;
   limitOne?: boolean;
   prePopulateService?: ClientService;
   isPredeploy?: boolean;
@@ -125,7 +124,9 @@ const ServiceList: React.FC<ServiceListProps> = ({
   };
 
   const onSubmit = handleSubmit(async (data) => {
-    append(deserializeService(defaultSerialized(data)));
+    append(
+      deserializeService({ service: defaultSerialized(data), expanded: true })
+    );
     reset();
     setShowAddServiceModal(false);
   });
@@ -241,7 +242,17 @@ const I = styled.i`
   justify-content: center;
 `;
 
-const ServicesContainer = styled.div``;
+const ServicesContainer = styled.div`
+  animation: fadeIn 0.3s 0s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
 
 const AddServiceButton = styled.div`
   color: #aaaabb;