소스 검색

scaffold and ff expanded app using revisions (#3432)

ianedwards 2 년 전
부모
커밋
5b666dbd6e

+ 9 - 0
api/server/handlers/porter_app/get.go

@@ -36,6 +36,8 @@ func (c *GetPorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 	defer span.End()
 
 	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+
 	appName, reqErr := requestutils.GetURLParamString(r, types.URLParamPorterAppName)
 	if reqErr != nil {
 		err := telemetry.Error(ctx, span, nil, "error parsing porter app name")
@@ -56,6 +58,13 @@ func (c *GetPorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
+	// this is a temporary fix until we figure out how to reconcile the new revisions table
+	// with dependencies on helm releases throuhg the api
+	if project.ValidateApplyV2 {
+		c.WriteResult(w, r, app.ToPorterAppType())
+		return
+	}
+
 	namespace := utils.NamespaceFromPorterAppName(appName)
 	helmAgent, err := c.GetHelmAgent(ctx, r, cluster, namespace)
 	if err != nil {

+ 4 - 4
dashboard/src/lib/porter-apps/index.ts

@@ -52,18 +52,18 @@ export const sourceValidator = z.discriminatedUnion("type", [
 ]);
 export type SourceOptions = z.infer<typeof sourceValidator>;
 
-// porterAppValidator is the representation of a Porter app on the client, and is used to validate inputs for app setting fields
-export const porterAppValidator = z.object({
+// clientAppValidator is the representation of a Porter app on the client, and is used to validate inputs for app setting fields
+export const clientAppValidator = z.object({
   name: z.string().min(1),
   services: serviceValidator.array(),
   env: z.record(z.string(), z.string()).default({}),
   build: buildValidator,
 });
-export type ClientPorterApp = z.infer<typeof porterAppValidator>;
+export type ClientPorterApp = z.infer<typeof clientAppValidator>;
 
 // porterAppFormValidator is used to validate inputs when creating + updating an app
 export const porterAppFormValidator = z.object({
-  app: porterAppValidator,
+  app: clientAppValidator,
   source: sourceValidator,
 });
 export type PorterAppFormData = z.infer<typeof porterAppFormValidator>;

+ 27 - 14
dashboard/src/main/home/Home.tsx

@@ -41,6 +41,7 @@ import NewAppFlow from "./app-dashboard/new-app-flow/NewAppFlow";
 import ExpandedApp from "./app-dashboard/expanded-app/ExpandedApp";
 import ExpandedJob from "./app-dashboard/expanded-app/expanded-job/ExpandedJob";
 import CreateApp from "./app-dashboard/create-app/CreateApp";
+import AppView from "./app-dashboard/app-view/AppView";
 
 // Guarded components
 const GuardedProjectSettings = fakeGuardedRoute("settings", "", [
@@ -335,7 +336,11 @@ const Home: React.FC<Props> = (props) => {
 
     localStorage.removeItem(currentProject.id + "-cluster");
     try {
-      await api.updateOnboardingStep("<token>", { step: "project-delete" }, { project_id: currentProject.id });
+      await api.updateOnboardingStep(
+        "<token>",
+        { step: "project-delete" },
+        { project_id: currentProject.id }
+      );
       await api.deleteProject("<token>", {}, { id: currentProject.id });
       projectOverlayCall();
     } catch (error) {
@@ -411,13 +416,21 @@ const Home: React.FC<Props> = (props) => {
 
           <Switch>
             <Route path="/apps/new/app">
-              {currentProject?.validate_apply_v2 ? <CreateApp /> : <NewAppFlow />}
+              {currentProject?.validate_apply_v2 ? (
+                <CreateApp />
+              ) : (
+                <NewAppFlow />
+              )}
             </Route>
             <Route path="/apps/:appName/:tab">
               <ExpandedApp />
             </Route>
             <Route path="/apps/:appName">
-              <ExpandedApp />
+              {currentProject?.validate_apply_v2 ? (
+                <AppView />
+              ) : (
+                <ExpandedApp />
+              )}
             </Route>
             <Route path="/apps">
               <AppDashboard />
@@ -444,17 +457,17 @@ const Home: React.FC<Props> = (props) => {
               overrideInfraTabEnabled({
                 projectID: currentProject?.id,
               })) && (
-                <Route
-                  path="/infrastructure"
-                  render={() => {
-                    return (
-                      <DashboardWrapper>
-                        <InfrastructureRouter />
-                      </DashboardWrapper>
-                    );
-                  }}
-                />
-              )}
+              <Route
+                path="/infrastructure"
+                render={() => {
+                  return (
+                    <DashboardWrapper>
+                      <InfrastructureRouter />
+                    </DashboardWrapper>
+                  );
+                }}
+              />
+            )}
             <Route
               path="/dashboard"
               render={() => {

+ 286 - 0
dashboard/src/main/home/app-dashboard/app-view/AppView.tsx

@@ -0,0 +1,286 @@
+import React, { useContext } from "react";
+import { useMemo } from "react";
+import { RouteComponentProps, withRouter } from "react-router";
+import { useQuery } from "@tanstack/react-query";
+import { z } from "zod";
+import Loading from "components/Loading";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { useDefaultDeploymentTarget } from "lib/hooks/useDeploymentTarget";
+import { PorterApp } from "@porter-dev/api-contracts";
+import styled from "styled-components";
+import Back from "components/porter/Back";
+import Container from "components/porter/Container";
+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 Icon from "components/porter/Icon";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+
+export const porterAppValidator = z.object({
+  name: z.string(),
+  git_branch: z.string().optional(),
+  git_repo_id: z.number().optional(),
+  repo_name: z.string().optional(),
+  build_context: z.string().optional(),
+  builder: z.string().optional(),
+  buildpacks: z.array(z.string()).optional(),
+  dockerfile: z.string().optional(),
+  image_repo_uri: z.string().optional(),
+  porter_yaml_path: z.string().optional(),
+});
+
+// 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,
+];
+
+// 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 & {};
+
+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(),
+      })
+      .safeParse(params);
+
+    if (!validParams.success) {
+      return {
+        appName: null,
+      };
+    }
+
+    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(
+    ["getAppRevision", 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 rawAppData = await z
+        .object({
+          b64_app_proto: z.string(),
+        })
+        .parseAsync(res.data);
+
+      const porterApp = PorterApp.fromJsonString(
+        atob(rawAppData.b64_app_proto)
+      );
+
+      return porterApp;
+    },
+    {
+      enabled: appParamsExist,
+    }
+  );
+
+  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 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) {
+    return <div>error</div>;
+  }
+
+  return (
+    <StyledExpandedApp>
+      <Back to="/apps" />
+      <Container row>
+        <Icon src={getIconSvg(revision.build)} height={"24px"} />
+        <Spacer inline x={1} />
+        <Text size={21}>{revision.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>
+          </>
+        )}
+      </Container>
+    </StyledExpandedApp>
+  );
+};
+
+export default withRouter(AppView);
+
+const StyledExpandedApp = styled.div`
+  width: 100%;
+  height: 100%;
+
+  animation: fadeIn 0.5s 0s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+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 - 0
dashboard/src/shared/api.tsx

@@ -856,6 +856,19 @@ const applyApp = baseApi<
   return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}/apps/apply`;
 });
 
+const getLatestRevision = baseApi<
+  {
+    deployment_target_id: string;
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+    porter_app_name: string;
+  }
+>("GET", ({ project_id, cluster_id, porter_app_name }) => {
+  return `/api/projects/${project_id}/clusters/${cluster_id}/apps/${porter_app_name}/latest`;
+});
+
 const getGitlabProcfileContents = baseApi<
   {
     path: string;
@@ -2927,6 +2940,7 @@ export default {
   validatePorterApp,
   createApp,
   applyApp,
+  getLatestRevision,
   getGitlabProcfileContents,
   getProjectClusters,
   getProjectRegistries,