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

Add usage warning modal to dashboard

jnfrati 4 лет назад
Родитель
Сommit
55894d9959

+ 2 - 1
dashboard/src/main/Main.tsx

@@ -58,6 +58,7 @@ export default class Main extends Component<PropsType, StateType> {
     api
       .getMetadata("", {}, {})
       .then((res) => {
+        this.context.setEdition(res.data?.version);
         this.setState({ local: !res.data?.provisioner });
       })
       .catch((err) => console.log(err));
@@ -107,7 +108,7 @@ export default class Main extends Component<PropsType, StateType> {
       return <Loading />;
     }
 
-    // if logged in but not verified, block until email verification    
+    // if logged in but not verified, block until email verification
     if (
       !this.state.local &&
       this.state.isLoggedIn &&

+ 42 - 10
dashboard/src/main/home/Home.tsx

@@ -32,6 +32,7 @@ import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 import EditInviteOrCollaboratorModal from "./modals/EditInviteOrCollaboratorModal";
 import AccountSettingsModal from "./modals/AccountSettingsModal";
 import discordLogo from "../../assets/discord.svg";
+import UsageWarningModal from "./modals/UsageWarningModal";
 // Guarded components
 const GuardedProjectSettings = fakeGuardedRoute("settings", "", [
   "get",
@@ -297,6 +298,24 @@ class Home extends Component<PropsType, StateType> {
   // 2. Make sure switching projects shows appropriate initial view (dashboard || provisioner)
   // 3. Make sure initializing from URL (DO oauth) displays the appropriate initial view
   componentDidUpdate(prevProps: PropsType) {
+    if (prevProps.currentProject?.id !== this.props.currentProject?.id) {
+      api
+        .getUsage(
+          "<token>",
+          {},
+          { project_id: this.context?.currentProject?.id }
+        )
+        .then((res) => {
+          const usage = res.data;
+          if (usage.exceeds) {
+            this.context.setCurrentModal("UsageWarningModal", {
+              usage,
+            });
+          }
+        })
+        .catch(console.log);
+    }
+
     if (
       prevProps.currentProject !== this.props.currentProject ||
       (!prevProps.currentCluster && this.props.currentCluster)
@@ -445,16 +464,19 @@ class Home extends Component<PropsType, StateType> {
           if (!cluster.infra_id) continue;
 
           // Handle destroying infra we've provisioned
-          api.destroyInfra(
-            "<token>",
-                  { name: cluster.name },
-                  {
-                    project_id: currentProject.id,
-                    infra_id: cluster.infra_id,
-                  }
-          ).then(() =>
-            console.log("destroyed provisioned infra:", cluster.infra_id)
-          ).catch(console.log);
+          api
+            .destroyInfra(
+              "<token>",
+              { name: cluster.name },
+              {
+                project_id: currentProject.id,
+                infra_id: cluster.infra_id,
+              }
+            )
+            .then(() =>
+              console.log("destroyed provisioned infra:", cluster.infra_id)
+            )
+            .catch(console.log);
         }
       })
       .catch(console.log);
@@ -556,6 +578,16 @@ class Home extends Component<PropsType, StateType> {
           </Modal>
         )}
 
+        {currentModal === "UsageWarningModal" && (
+          <Modal
+            onRequestClose={() => setCurrentModal(null, null)}
+            width="760px"
+            height="440px"
+          >
+            <UsageWarningModal />
+          </Modal>
+        )}
+
         {currentOverlay && (
           <ConfirmOverlay
             show={true}

+ 185 - 0
dashboard/src/main/home/modals/UsageWarningModal.tsx

@@ -0,0 +1,185 @@
+import React, { useContext, useEffect, useState } from "react";
+import styled from "styled-components";
+import close from "assets/close.png";
+
+import { Context } from "shared/Context";
+import { UsageData } from "shared/types";
+import { Link } from "react-router-dom";
+
+const ReadableNameMap: {
+  [key: string]: string;
+} = {
+  resource_cpu: "CPU",
+  resource_memory: "Memory",
+  clusters: "Cluster number",
+  users: "Users on your team",
+};
+
+const filterExceeded = (usage: UsageData) => {
+  console.log(usage);
+  const current = usage.current;
+  const limits = usage.limit;
+  return Object.keys(usage.current).reduce((acc, key) => {
+    if (!acc.current) {
+      acc.current = {} as any;
+    }
+    if (!acc.limit) {
+      acc.limit = {} as any;
+    }
+    if (current[key] > limits[key]) {
+      acc.current[key] = current[key];
+      acc.limit[key] = limits[key];
+    }
+    return acc;
+  }, {} as Partial<UsageData>);
+};
+
+const UpgradeChartModal: React.FC<{}> = () => {
+  const { setCurrentModal, currentModalData } = useContext(Context);
+  const [usage, setUsage] = useState<UsageData>(null);
+  const [filteredUsage, setFilteredUsage] = useState<Partial<UsageData>>(null);
+  useEffect(() => {
+    if (currentModalData.usage) {
+      const currentUsage: UsageData = currentModalData.usage;
+      console.log(currentModalData);
+      setUsage(currentUsage);
+    }
+  }, [currentModalData?.usage]);
+
+  useEffect(() => {
+    if (usage) {
+      setFilteredUsage(filterExceeded(usage));
+    }
+  }, [usage]);
+
+  if (!usage || !filteredUsage) {
+    return null;
+  }
+  console.log({ usage, filteredUsage });
+  return (
+    <StyledUpgradeChartModal>
+      <CloseButton onClick={() => setCurrentModal(null, null)}>
+        <CloseButtonImg src={close} />
+      </CloseButton>
+      <ModalTitle>Usage warning</ModalTitle>
+      You're current project is currently exceeding its usage limits. Your usage
+      limits are:
+      <DescriptionSection>
+        {filteredUsage !== null &&
+          Object.entries(filteredUsage.limit).map(([key, value]) => {
+            return (
+              <div key={key}>
+                <b>{ReadableNameMap[key]}:</b> {value}
+              </div>
+            );
+          })}
+      </DescriptionSection>
+      Your project is currently using:
+      <DescriptionSection>
+        {filteredUsage !== null &&
+          Object.entries(filteredUsage.current).map(([key, value]) => {
+            return (
+              <div key={key}>
+                <b>{ReadableNameMap[key]}:</b> {value}
+              </div>
+            );
+          })}
+      </DescriptionSection>
+      You have currently <b>7 days</b> to resolve this issue before you loose
+      access to the Porter dashboard.
+      <Button
+        as={Link}
+        to={{
+          pathname: "/project-settings",
+          search: "?selected_tab=billing",
+        }}
+        onClick={() => setCurrentModal(null, null)}
+      >
+        Take me to billing
+      </Button>
+    </StyledUpgradeChartModal>
+  );
+};
+
+export default UpgradeChartModal;
+
+const Button = styled.button`
+  height: 35px;
+  background: #616feecc;
+  :hover {
+    background: #505edddd;
+  }
+  color: white;
+  font-weight: 500;
+  font-size: 13px;
+  padding: 10px 15px;
+  border-radius: 3px;
+  cursor: "pointer";
+  box-shadow: 0 5px 8px 0px #00000010;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  margin-left: 10px;
+
+  width: max-content;
+  position: absolute;
+  right: 20px;
+  bottom: 20px;
+`;
+
+const DescriptionSection = styled.div`
+  margin-top: 10px;
+  margin-bottom: 10px;
+`;
+
+const ModalTitle = styled.div`
+  margin: 0px 0px 13px;
+  display: flex;
+  flex: 1;
+  font-family: Work Sans, sans-serif;
+  font-size: 24px;
+  color: #ffffff;
+  user-select: none;
+  font-weight: 700;
+  align-items: center;
+  position: relative;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+`;
+
+const CloseButton = styled.div`
+  position: absolute;
+  display: block;
+  width: 40px;
+  height: 40px;
+  padding: 13px 0 12px 0;
+  z-index: 1;
+  text-align: center;
+  border-radius: 50%;
+  right: 15px;
+  top: 12px;
+  cursor: pointer;
+  :hover {
+    background-color: #ffffff11;
+  }
+`;
+
+const CloseButtonImg = styled.img`
+  width: 14px;
+  margin: 0 auto;
+`;
+
+const StyledUpgradeChartModal = styled.div`
+  width: 100%;
+  position: absolute;
+  left: 0;
+  top: 0;
+  height: 100%;
+  padding: 25px 30px;
+  overflow: hidden;
+  border-radius: 6px;
+  background: #202227;
+  font-size: 13px;
+  line-height: 1.8em;
+  font-family: Work Sans, sans-serif;
+`;

+ 46 - 32
dashboard/src/main/home/project-settings/InviteList.tsx

@@ -25,7 +25,13 @@ export type Collaborator = {
 };
 
 const InvitePage: React.FunctionComponent<Props> = ({}) => {
-  const { currentProject, setCurrentModal, setCurrentError, user } = useContext(Context);
+  const {
+    currentProject,
+    setCurrentModal,
+    setCurrentError,
+    user,
+    edition,
+  } = useContext(Context);
   const [isLoading, setIsLoading] = useState(true);
   const [invites, setInvites] = useState<Array<InviteType>>([]);
   const [email, setEmail] = useState("");
@@ -117,10 +123,10 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
       })
       .catch((err) => {
         if (err.response.data?.error) {
-          setCurrentError(err.response.data?.error)
+          setCurrentError(err.response.data?.error);
         }
 
-        console.log(err)
+        console.log(err);
       });
   };
 
@@ -162,10 +168,10 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
       .then(getData)
       .catch((err) => {
         if (err.response.data?.error) {
-          setCurrentError(err.response.data?.error)
+          setCurrentError(err.response.data?.error);
         }
 
-        console.log(err)
+        console.log(err);
       });
   };
 
@@ -358,35 +364,43 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
     return mappedInviteList || [];
   }, [invites, currentProject?.id, window?.location?.host, isHTTPS, user?.id]);
 
+  const isEnterpriseEdition = () => {
+    return edition === "ee";
+  };
+
   return (
     <>
-      <Heading isAtTop={true}>Share Project</Heading>
-      <Helper>Generate a project invite for another user.</Helper>
-      <InputRowWrapper>
-        <InputRow
-          value={email}
-          type="text"
-          setValue={(newEmail: string) => setEmail(newEmail)}
-          width="100%"
-          placeholder="ex: mrp@getporter.dev"
-        />
-      </InputRowWrapper>
-      <Helper>Specify a role for this user.</Helper>
-      <RoleSelectorWrapper>
-        <RadioSelector
-          selected={role}
-          setSelected={setRole}
-          options={roleList}
-        />
-      </RoleSelectorWrapper>
-      <ButtonWrapper>
-        <InviteButton disabled={false} onClick={() => validateEmail()}>
-          Create Invite
-        </InviteButton>
-        {isInvalidEmail && (
-          <Invalid>Invalid email address. Please try again.</Invalid>
-        )}
-      </ButtonWrapper>
+      {isEnterpriseEdition() && (
+        <>
+          <Heading isAtTop={true}>Share Project</Heading>
+          <Helper>Generate a project invite for another user.</Helper>
+          <InputRowWrapper>
+            <InputRow
+              value={email}
+              type="text"
+              setValue={(newEmail: string) => setEmail(newEmail)}
+              width="100%"
+              placeholder="ex: mrp@getporter.dev"
+            />
+          </InputRowWrapper>
+          <Helper>Specify a role for this user.</Helper>
+          <RoleSelectorWrapper>
+            <RadioSelector
+              selected={role}
+              setSelected={setRole}
+              options={roleList}
+            />
+          </RoleSelectorWrapper>
+          <ButtonWrapper>
+            <InviteButton disabled={false} onClick={() => validateEmail()}>
+              Create Invite
+            </InviteButton>
+            {isInvalidEmail && (
+              <Invalid>Invalid email address. Please try again.</Invalid>
+            )}
+          </ButtonWrapper>
+        </>
+      )}
 
       <Heading>Invites & Collaborators</Heading>
       <Helper>Manage pending invites and view collaborators.</Helper>

+ 35 - 2
dashboard/src/main/home/project-settings/ProjectSettings.tsx

@@ -9,8 +9,10 @@ import Heading from "components/form-components/Heading";
 import Helper from "components/form-components/Helper";
 import TitleSection from "components/TitleSection";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
+import { RouteComponentProps, withRouter, WithRouterProps } from "react-router";
+import { getQueryParam } from "shared/routing";
 
-type PropsType = WithAuthProps & {};
+type PropsType = RouteComponentProps & WithAuthProps & {};
 
 type StateType = {
   projectName: string;
@@ -25,12 +27,28 @@ class ProjectSettings extends Component<PropsType, StateType> {
     tabOptions: [] as { value: string; label: string }[],
   };
 
+  componentDidUpdate(prevProps: PropsType) {
+    const selectedTab = getQueryParam(this.props, "selected_tab");
+    if (prevProps.location.search !== this.props.location.search) {
+      if (selectedTab) {
+        this.setState({ currentTab: selectedTab });
+      } else {
+        this.setState({ currentTab: "manage-access" });
+      }
+    }
+  }
+
   componentDidMount() {
     let { currentProject } = this.context;
     this.setState({ projectName: currentProject.name });
     const tabOptions = [];
     tabOptions.push({ value: "manage-access", label: "Manage Access" });
+
     if (this.props.isAuthorized("settings", "", ["get", "delete"])) {
+      tabOptions.push({
+        value: "billing",
+        label: "Billing",
+      });
       tabOptions.push({
         value: "additional-settings",
         label: "Additional Settings",
@@ -38,6 +56,11 @@ class ProjectSettings extends Component<PropsType, StateType> {
     }
 
     this.setState({ tabOptions });
+
+    const selectedTab = getQueryParam(this.props, "selected_tab");
+    if (selectedTab) {
+      this.setState({ currentTab: selectedTab });
+    }
   }
 
   renderTabContents = () => {
@@ -45,6 +68,16 @@ class ProjectSettings extends Component<PropsType, StateType> {
       return <InvitePage />;
     }
 
+    if (this.state.currentTab === "billing") {
+      return (
+        <div>
+          <a href="https://www.youtube.com/watch?v=ETxmCCsMoD0&ab_channel=AbbaVEVO">
+            Money money money
+          </a>
+        </div>
+      );
+    }
+
     if (this.state.currentTab === "manage-access") {
       return <InvitePage />;
     } else {
@@ -107,7 +140,7 @@ class ProjectSettings extends Component<PropsType, StateType> {
 
 ProjectSettings.contextType = Context;
 
-export default withAuth(ProjectSettings);
+export default withRouter(withAuth(ProjectSettings));
 
 const Warning = styled.div`
   font-size: 13px;

+ 10 - 0
dashboard/src/shared/Context.tsx

@@ -49,6 +49,8 @@ export interface GlobalContextType {
   capabilities: CapabilityType;
   setCapabilities: (capabilities: CapabilityType) => void;
   clearContext: () => void;
+  edition: "ee" | "ce";
+  setEdition: (appVersion: string) => void;
 }
 
 /**
@@ -135,6 +137,14 @@ class ContextProvider extends Component<PropsType, StateType> {
         devOpsMode: true,
       });
     },
+    edition: "ce",
+    setEdition: (version: string) => {
+      const [edition] = version.split("-").reverse();
+      // typesafe just in case we mess up something it will default to ce
+      if (edition === "ce" || edition === "ee") {
+        this.setState({ edition });
+      }
+    },
   };
 
   render() {

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

@@ -1047,6 +1047,11 @@ const createWebhookToken = baseApi<
     `/api/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/releases/${chart_name}/0/webhook`
 );
 
+const getUsage = baseApi<{}, { project_id: number }>(
+  "GET",
+  ({ project_id }) => `/api/projects/${project_id}/usage`
+);
+
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
   checkAuth,
@@ -1152,4 +1157,5 @@ export default {
   removeCollaborator,
   getPolicyDocument,
   createWebhookToken,
+  getUsage,
 };

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

@@ -296,6 +296,8 @@ export interface ContextProps {
   capabilities: CapabilityType;
   setCapabilities: (capabilities: CapabilityType) => void;
   clearContext: () => void;
+  edition: "ee" | "ce";
+  setEdition: (appVersion: string) => void;
 }
 
 export enum JobStatusType {
@@ -308,3 +310,21 @@ export interface JobStatusWithTimeType {
   status: JobStatusType;
   start_time: string;
 }
+
+export interface UsageData {
+  current: {
+    [key: string]: number;
+    resource_cpu: number;
+    resource_memory: number;
+    clusters: number;
+    users: number;
+  };
+  limit: {
+    [key: string]: number;
+    resource_cpu: number;
+    resource_memory: number;
+    clusters: number;
+    users: number;
+  };
+  exceeds: boolean;
+}