Prechádzať zdrojové kódy

POR-1686 populate app list based on latest revision (#3560)

ianedwards 2 rokov pred
rodič
commit
2a3d4568fe

+ 2 - 1
dashboard/src/lib/hooks/useAppAnalytics.ts

@@ -3,12 +3,13 @@ import { Context } from "shared/Context";
 import api from "shared/api";
 
 type AppStep =
+  | "stack-launch-start"
   | "stack-launch-complete"
   | "stack-launch-success"
   | "stack-launch-failure"
   | "stack-deletion";
 
-export const useAppAnalytics = (appName: string) => {
+export const useAppAnalytics = (appName?: string) => {
   const { currentCluster, currentProject } = useContext(Context);
 
   const updateAppStep = async ({

+ 16 - 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 CreateApp from "./app-dashboard/create-app/CreateApp";
 import AppView from "./app-dashboard/app-view/AppView";
+import Apps from "./app-dashboard/apps/Apps";
 
 // Guarded components
 const GuardedProjectSettings = fakeGuardedRoute("settings", "", [
@@ -135,7 +136,8 @@ const Home: React.FC<Props> = (props) => {
         setProjects(projectList);
 
         if (!id) {
-          id = Number(localStorage.getItem("currentProject")) || projectList[0].id
+          id =
+            Number(localStorage.getItem("currentProject")) || projectList[0].id;
         }
 
         const project = await api
@@ -183,7 +185,7 @@ const Home: React.FC<Props> = (props) => {
       } else {
         setHasFinishedOnboarding(true);
       }
-    } catch (error) { }
+    } catch (error) {}
   };
 
   useEffect(() => {
@@ -433,7 +435,7 @@ const Home: React.FC<Props> = (props) => {
               )}
             </Route>
             <Route path="/apps">
-              <AppDashboard />
+              {currentProject?.validate_apply_v2 ? <Apps /> : <AppDashboard />}
             </Route>
             <Route path="/addons/new">
               <NewAddOnFlow />
@@ -457,17 +459,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={() => {

+ 269 - 0
dashboard/src/main/home/app-dashboard/apps/AppGrid.tsx

@@ -0,0 +1,269 @@
+import React, { useMemo } from "react";
+import { AppRevisionWithSource } from "./types";
+import { search } from "shared/search";
+import _ from "lodash";
+import { match } from "ts-pattern";
+import { Link } from "react-router-dom";
+
+import web from "assets/web.png";
+import box from "assets/box.png";
+import time from "assets/time.png";
+import healthy from "assets/status-healthy.png";
+import notFound from "assets/not-found.png";
+import github from "assets/github.png";
+
+import Fieldset from "components/porter/Fieldset";
+import Container from "components/porter/Container";
+import Text from "components/porter/Text";
+import styled from "styled-components";
+import { PorterApp } from "@porter-dev/api-contracts";
+import Icon from "components/porter/Icon";
+import Spacer from "components/porter/Spacer";
+import { readableDate } from "shared/string_utils";
+
+type AppGridProps = {
+  apps: AppRevisionWithSource[];
+  searchValue: string;
+  view: "grid" | "list";
+  sort: "letter" | "calendar";
+};
+
+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 AppGrid: React.FC<AppGridProps> = ({ apps, searchValue, view, sort }) => {
+  const appsWithProto = useMemo(() => {
+    return apps.map((app) => {
+      return {
+        ...app,
+        app_revision: {
+          ...app.app_revision,
+          proto: PorterApp.fromJsonString(atob(app.app_revision.b64_app_proto)),
+        },
+      };
+    });
+  }, [apps]);
+
+  const filteredApps = useMemo(() => {
+    const filteredBySearch = search(appsWithProto ?? [], searchValue, {
+      keys: ["name"],
+      isCaseSensitive: false,
+    });
+
+    return match(sort)
+      .with("calendar", () =>
+        _.sortBy(filteredBySearch, ["last_deployed"]).reverse()
+      )
+      .with("letter", () => _.sortBy(filteredBySearch, ["name"]))
+      .exhaustive();
+  }, [appsWithProto, searchValue, sort]);
+
+  const renderIcon = (bp: string[], size?: string) => {
+    var src = box;
+    if (bp.length) {
+      const [_, name] = bp[0].split("/");
+      switch (name) {
+        case "ruby":
+          src = icons[0];
+          break;
+        case "nodejs":
+          src = icons[1];
+          break;
+        case "python":
+          src = icons[2];
+          break;
+        case "go":
+          src = icons[3];
+          break;
+        default:
+          break;
+      }
+    }
+    return (
+      <>
+        {size === "larger" ? (
+          <Icon height="16px" src={src} />
+        ) : (
+          <Icon height="18px" src={src} />
+        )}
+      </>
+    );
+  };
+
+  const renderSource = (source: AppRevisionWithSource["source"]) => {
+    return (
+      <>
+        {source.repo_name ? (
+          <Container row>
+            <SmallIcon opacity="0.6" src={github} />
+            <Text size={13} color="#ffffff44">
+              {source.repo_name}
+            </Text>
+          </Container>
+        ) : (
+          <Container row>
+            <SmallIcon
+              opacity="0.7"
+              height="18px"
+              src="https://cdn4.iconfinder.com/data/icons/logos-and-brands/512/97_Docker_logo_logos-512.png"
+            />
+            <Text truncate={true} size={13} color="#ffffff44">
+              {source.image_repo_uri}
+            </Text>
+          </Container>
+        )}
+      </>
+    );
+  };
+
+  if (filteredApps.length === 0) {
+    return (
+      <Fieldset>
+        <Container row>
+          <PlaceholderIcon src={notFound} />
+          <Text color="helper">No matching apps were found.</Text>
+        </Container>
+      </Fieldset>
+    );
+  }
+
+  return match(view)
+    .with("grid", () => (
+      <GridList>
+        {(filteredApps ?? []).map(
+          ({ app_revision: { proto, updated_at }, source }, i) => {
+            return (
+              <Link to={`/apps/${proto.name}`} key={i}>
+                <Block>
+                  <Container row>
+                    {renderIcon(proto.build?.buildpacks ?? [])}
+                    <Spacer inline width="12px" />
+                    <Text size={14}>{proto.name}</Text>
+                    <Spacer inline x={2} />
+                  </Container>
+                  <StatusIcon src={healthy} />
+                  {renderSource(source)}
+                  <Container row>
+                    <SmallIcon opacity="0.4" src={time} />
+                    <Text size={13} color="#ffffff44">
+                      {readableDate(updated_at)}
+                    </Text>
+                  </Container>
+                </Block>
+              </Link>
+            );
+          }
+        )}
+      </GridList>
+    ))
+    .with("list", () => (
+      <List>
+        {(filteredApps ?? []).map(
+          ({ app_revision: { proto, updated_at }, source }, i) => {
+            return (
+              <Link to={`/apps/${proto.name}`} key={i}>
+                <Row>
+                  <Container row>
+                    <Spacer inline width="1px" />
+                    {renderIcon(proto.build?.buildpacks ?? [], "larger")}
+                    <Spacer inline width="12px" />
+                    <Text size={14}>{proto.name}</Text>
+                    <Spacer inline x={1} />
+                    <Icon height="16px" src={healthy} />
+                  </Container>
+                  <Spacer height="15px" />
+                  <Container row>
+                    {renderSource(source)}
+                    <Spacer inline x={1} />
+                    <SmallIcon opacity="0.4" src={time} />
+                    <Text size={13} color="#ffffff44">
+                      {readableDate(updated_at)}
+                    </Text>
+                  </Container>
+                </Row>
+              </Link>
+            );
+          }
+        )}
+      </List>
+    ))
+    .exhaustive();
+};
+
+export default AppGrid;
+
+const PlaceholderIcon = styled.img`
+  height: 13px;
+  margin-right: 12px;
+  opacity: 0.65;
+`;
+
+const GridList = styled.div`
+  display: grid;
+  grid-column-gap: 25px;
+  grid-row-gap: 25px;
+  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
+`;
+
+const Block = styled.div`
+  height: 150px;
+  flex-direction: column;
+  display: flex;
+  justify-content: space-between;
+  cursor: pointer;
+  padding: 20px;
+  color: ${(props) => props.theme.text.primary};
+  position: relative;
+  border-radius: 5px;
+  background: ${(props) => props.theme.clickable.bg};
+  border: 1px solid #494b4f;
+  :hover {
+    border: 1px solid #7a7b80;
+  }
+  animation: fadeIn 0.3s 0s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const StatusIcon = styled.img`
+  position: absolute;
+  top: 20px;
+  right: 20px;
+  height: 18px;
+`;
+
+const List = styled.div`
+  overflow: hidden;
+`;
+
+const Row = styled.div<{ isAtBottom?: boolean }>`
+  cursor: pointer;
+  padding: 15px;
+  border-bottom: ${(props) =>
+    props.isAtBottom ? "none" : "1px solid #494b4f"};
+  background: ${(props) => props.theme.clickable.bg};
+  position: relative;
+  border: 1px solid #494b4f;
+  border-radius: 5px;
+  margin-bottom: 15px;
+  animation: fadeIn 0.3s 0s;
+`;
+
+const SmallIcon = styled.img<{ opacity?: string; height?: string }>`
+  margin-left: 2px;
+  height: ${(props) => props.height || "14px"};
+  opacity: ${(props) => props.opacity || 1};
+  filter: grayscale(100%);
+  margin-right: 10px;
+`;

+ 214 - 0
dashboard/src/main/home/app-dashboard/apps/Apps.tsx

@@ -0,0 +1,214 @@
+import React, { useState, useContext } from "react";
+import styled from "styled-components";
+import _ from "lodash";
+
+import web from "assets/web.png";
+import grid from "assets/grid.png";
+import list from "assets/list.png";
+import letter from "assets/vector.svg";
+import calendar from "assets/calendar-number.svg";
+
+import { Context } from "shared/Context";
+import api from "shared/api";
+
+import Container from "components/porter/Container";
+import Button from "components/porter/Button";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import SearchBar from "components/porter/SearchBar";
+import Toggle from "components/porter/Toggle";
+import PorterLink from "components/porter/Link";
+import Loading from "components/Loading";
+import Fieldset from "components/porter/Fieldset";
+import ClusterProvisioningPlaceholder from "components/ClusterProvisioningPlaceholder";
+import { useQuery } from "@tanstack/react-query";
+import { useAppAnalytics } from "lib/hooks/useAppAnalytics";
+import { appRevisionWithSourceValidator } from "./types";
+import AppGrid from "./AppGrid";
+import DashboardHeader from "main/home/cluster-dashboard/DashboardHeader";
+import { z } from "zod";
+
+type Props = {};
+
+const Apps: React.FC<Props> = ({}) => {
+  const { currentProject, currentCluster } = useContext(Context);
+  const { updateAppStep } = useAppAnalytics();
+
+  const [searchValue, setSearchValue] = useState("");
+  const [view, setView] = useState<"grid" | "list">("grid");
+  const [sort, setSort] = useState<"calendar" | "letter">("calendar");
+
+  const { data: apps = [], status } = useQuery(
+    [
+      "getLatestAppRevisions",
+      { cluster_id: currentCluster?.id, project_id: currentProject?.id },
+    ],
+    async () => {
+      if (
+        !currentCluster ||
+        !currentProject ||
+        currentCluster.id === -1 ||
+        currentProject.id === -1
+      ) {
+        return;
+      }
+
+      const res = await api.getLatestAppRevisions(
+        "<token>",
+        {},
+        { cluster_id: currentCluster.id, project_id: currentProject.id }
+      );
+
+      const apps = await z
+        .object({
+          app_revisions: z.array(appRevisionWithSourceValidator),
+        })
+        .parseAsync(res.data);
+
+      return apps.app_revisions;
+    }
+  );
+
+  const renderContents = () => {
+    if (currentCluster?.status === "UPDATING_UNAVAILABLE") {
+      return <ClusterProvisioningPlaceholder />;
+    }
+
+    if (status === "loading") {
+      return <Loading offset="-150px" />;
+    }
+
+    if (apps.length === 0) {
+      <Fieldset>
+        <CentralContainer>
+          <Text size={16}>No apps have been deployed yet.</Text>
+          <Spacer y={1} />
+
+          <Text color={"helper"}>Get started by deploying your app.</Text>
+          <Spacer y={0.5} />
+          <PorterLink to="/apps/new/app">
+            <Button
+              onClick={async () =>
+                updateAppStep({ step: "stack-launch-start" })
+              }
+              height="35px"
+            >
+              Deploy app <Spacer inline x={1} />{" "}
+              <i className="material-icons" style={{ fontSize: "18px" }}>
+                east
+              </i>
+            </Button>
+          </PorterLink>
+        </CentralContainer>
+      </Fieldset>;
+    }
+
+    return (
+      <>
+        <Container row spaced>
+          <SearchBar
+            value={searchValue}
+            setValue={(x) => {
+              setSearchValue(x);
+            }}
+            placeholder="Search applications . . ."
+            width="100%"
+          />
+          <Spacer inline x={2} />
+          <Toggle
+            items={[
+              { label: <ToggleIcon src={calendar} />, value: "calendar" },
+              { label: <ToggleIcon src={letter} />, value: "letter" },
+            ]}
+            active={sort}
+            setActive={(x) => {
+              if (x === "calendar") {
+                setSort("calendar");
+              } else {
+                setSort("letter");
+              }
+            }}
+          />
+          <Spacer inline x={1} />
+
+          <Toggle
+            items={[
+              { label: <ToggleIcon src={grid} />, value: "grid" },
+              { label: <ToggleIcon src={list} />, value: "list" },
+            ]}
+            active={view}
+            setActive={(x) => {
+              if (x === "grid") {
+                setView("grid");
+              } else {
+                setView("list");
+              }
+            }}
+          />
+
+          <Spacer inline x={2} />
+          <PorterLink to="/apps/new/app">
+            <Button
+              onClick={async () =>
+                updateAppStep({ step: "stack-launch-start" })
+              }
+              height="30px"
+              width="160px"
+            >
+              <I className="material-icons">add</I> New application
+            </Button>
+          </PorterLink>
+        </Container>
+        <Spacer y={1} />
+        <AppGrid
+          apps={apps}
+          sort={sort}
+          view={view}
+          searchValue={searchValue}
+        />
+      </>
+    );
+  };
+
+  return (
+    <StyledAppDashboard>
+      <DashboardHeader
+        image={web}
+        title="Applications"
+        description="Web services, workers, and jobs for this project."
+        disableLineBreak
+      />
+      {renderContents()}
+      <Spacer y={5} />
+    </StyledAppDashboard>
+  );
+};
+
+export default Apps;
+
+const ToggleIcon = styled.img`
+  height: 12px;
+  margin: 0 5px;
+  min-width: 12px;
+`;
+
+const I = styled.i`
+  color: white;
+  font-size: 14px;
+  display: flex;
+  align-items: center;
+  margin-right: 5px;
+  justify-content: center;
+`;
+
+const StyledAppDashboard = styled.div`
+  width: 100%;
+  height: 100%;
+`;
+
+const CentralContainer = styled.div`
+  display: flex;
+  flex-direction: column;
+  justify-content: left;
+  align-items: left;
+`;

+ 12 - 0
dashboard/src/main/home/app-dashboard/apps/types.ts

@@ -0,0 +1,12 @@
+import { appRevisionValidator } from "lib/revisions/types";
+import { z } from "zod";
+import { porterAppValidator } from "../app-view/AppView";
+
+export const appRevisionWithSourceValidator = z.object({
+  app_revision: appRevisionValidator,
+  source: porterAppValidator,
+});
+
+export type AppRevisionWithSource = z.infer<
+  typeof appRevisionWithSourceValidator
+>;

+ 9 - 0
dashboard/src/shared/api.tsx

@@ -941,6 +941,14 @@ const listAppRevisions = baseApi<
   return `/api/projects/${project_id}/clusters/${cluster_id}/apps/${porter_app_name}/revisions`;
 });
 
+const getLatestAppRevisions = baseApi<
+{},{
+    project_id: number;
+    cluster_id: number;
+}>("GET", ({ project_id, cluster_id }) => {
+  return `/api/projects/${project_id}/clusters/${cluster_id}/apps/revisions`;
+})
+
 const getGitlabProcfileContents = baseApi<
   {
     path: string;
@@ -3043,6 +3051,7 @@ export default {
   applyApp,
   getLatestRevision,
   listAppRevisions,
+  getLatestAppRevisions,
   getGitlabProcfileContents,
   getProjectClusters,
   getProjectRegistries,