Ver código fonte

[POR-2200] Beginning work of making db dashboard look like app dashboard (#4164)

Co-authored-by: Stefan McShane <stefanmcshane@users.noreply.github.com>
Feroze Mohideen 2 anos atrás
pai
commit
4b9d46ee97

+ 1 - 0
api/server/handlers/datastore/get.go

@@ -119,6 +119,7 @@ func (c *GetDatastoreHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 	datastore := datastores[0]
 	datastore.Type = datastoreRecord.Type
 	datastore.Engine = datastoreRecord.Engine
+	datastore.CreatedAtUTC = datastoreRecord.CreatedAt
 
 	resp.Datastore = datastore
 

+ 8 - 3
api/server/handlers/datastore/list.go

@@ -3,6 +3,7 @@ package datastore
 import (
 	"context"
 	"net/http"
+	"time"
 
 	"connectrpc.com/connect"
 	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
@@ -59,6 +60,9 @@ type Datastore struct {
 
 	// Status is the status of the datastore
 	Status string `json:"status,omitempty"`
+
+	// CreatedAtUTC is the time the datastore was created in UTC
+	CreatedAtUTC time.Time `json:"created_at"`
 }
 
 // ListDatastoresHandler is a struct for listing all datastores for a given project
@@ -98,9 +102,10 @@ func (h *ListDatastoresHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 
 	for _, datastore := range datastores {
 		datastoreList = append(datastoreList, Datastore{
-			Name:   datastore.Name,
-			Type:   datastore.Type,
-			Engine: datastore.Engine,
+			Name:         datastore.Name,
+			Type:         datastore.Type,
+			Engine:       datastore.Engine,
+			CreatedAtUTC: datastore.CreatedAt,
 		})
 	}
 

+ 50 - 0
dashboard/src/components/porter/StatusDot.tsx

@@ -0,0 +1,50 @@
+import React, { useMemo } from "react";
+import styled from "styled-components";
+import { match } from "ts-pattern";
+
+type StatusDotProps = {
+  status: "available" | "pending" | "failing";
+  heightPixels?: number;
+};
+
+const StatusDot: React.FC<StatusDotProps> = ({ status, heightPixels = 7 }) => {
+  const color = useMemo(() => {
+    return match(status)
+      .with("available", () => "#38a88a")
+      .with("pending", () => "#FFA500")
+      .with("failing", () => "#ff0000")
+      .exhaustive();
+  }, [status]);
+  return <StyledStatusDot color={color} height={heightPixels} />;
+};
+
+export default StatusDot;
+
+const StyledStatusDot = styled.div<{ color: string; height: number }>`
+  min-width: ${(props) => props.height}px;
+  max-width: ${(props) => props.height}px;
+  height: ${(props) => props.height}px;
+  border-radius: 50%;
+  margin-right: 10px;
+  background: ${(props) => props.color};
+
+  box-shadow: 0 0 0 0 rgba(0, 0, 0, 1);
+  transform: scale(1);
+  animation: pulse 2s infinite;
+  @keyframes pulse {
+    0% {
+      transform: scale(0.95);
+      box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.7);
+    }
+
+    70% {
+      transform: scale(1);
+      box-shadow: 0 0 0 10px rgba(0, 0, 0, 0);
+    }
+
+    100% {
+      transform: scale(0.95);
+      box-shadow: 0 0 0 0 rgba(0, 0, 0, 0);
+    }
+  }
+`;

+ 6 - 1
dashboard/src/lib/databases/types.ts

@@ -23,13 +23,18 @@ export const datastoreValidator = z.object({
   name: z.string(),
   type: z.string(),
   engine: z.string(),
+  created_at: z.string().default(""),
   status: z.string().default(""),
   metadata: datastoreMetadataValidator.array().default([]),
   env: datastoreEnvValidator.optional(),
   connection_string: z.string().default(""),
 });
 
-export type ClientDatastore = z.infer<typeof datastoreValidator>;
+export type SerializedDatastore = z.infer<typeof datastoreValidator>;
+
+export type ClientDatastore = SerializedDatastore & {
+  template: DatabaseTemplate;
+};
 
 export const datastoreListResponseValidator = z.object({
   datastores: datastoreValidator.array(),

+ 15 - 3
dashboard/src/lib/hooks/useDatabaseList.ts

@@ -1,16 +1,19 @@
 import { useContext } from "react";
 import { useQuery } from "@tanstack/react-query";
 
+import { SUPPORTED_DATABASE_TEMPLATES } from "main/home/database-dashboard/constants";
 import {
   datastoreListResponseValidator,
-  type ClientDatastore,
+  type DatabaseTemplate,
+  type SerializedDatastore,
 } from "lib/databases/types";
 
 import api from "shared/api";
 import { Context } from "shared/Context";
+import { valueExists } from "shared/util";
 
 type DatabaseListType = {
-  datastores: ClientDatastore[];
+  datastores: Array<SerializedDatastore & { template: DatabaseTemplate }>;
   isLoading: boolean;
 };
 export const useDatabaseList = (): DatabaseListType => {
@@ -34,7 +37,16 @@ export const useDatabaseList = (): DatabaseListType => {
       const parsed = await datastoreListResponseValidator.parseAsync(
         response.data
       );
-      return parsed.datastores;
+      return parsed.datastores
+        .map((d) => {
+          const template = SUPPORTED_DATABASE_TEMPLATES.find(
+            (t) => t.type === d.type && t.engine.name === d.engine
+          );
+
+          // filter out this datastore if it is a type we do not recognize
+          return template ? { ...d, template } : null;
+        })
+        .filter(valueExists);
     },
     {
       enabled: !!currentProject?.id && currentProject.id !== -1,

+ 17 - 1
dashboard/src/main/home/database-dashboard/DatabaseContextProvider.tsx

@@ -14,6 +14,8 @@ import api from "shared/api";
 import { Context } from "shared/Context";
 import notFound from "assets/not-found.png";
 
+import { SUPPORTED_DATABASE_TEMPLATES } from "./constants";
+
 type DatabaseContextType = {
   datastore: ClientDatastore;
   projectId: number;
@@ -59,7 +61,21 @@ export const DatabaseContextProvider: React.FC<
       const results = await z
         .object({ datastore: datastoreValidator })
         .parseAsync(response.data);
-      return results.datastore;
+
+      const datastore = results.datastore;
+      const matchingTemplate = SUPPORTED_DATABASE_TEMPLATES.find(
+        (t) => t.type === datastore.type && t.engine.name === datastore.engine
+      );
+
+      // this datastore is a type we do not recognize
+      if (!matchingTemplate) {
+        return;
+      }
+
+      return {
+        ...results.datastore,
+        template: matchingTemplate,
+      };
     },
     {
       enabled: paramsExist,

+ 34 - 110
dashboard/src/main/home/database-dashboard/DatabaseDashboard.tsx

@@ -21,14 +21,15 @@ import { useDatabaseList } from "lib/hooks/useDatabaseList";
 
 import { Context } from "shared/Context";
 import { search } from "shared/search";
+import { readableDate } from "shared/string_utils";
 import database from "assets/database.svg";
 import grid from "assets/grid.png";
 import list from "assets/list.png";
-import loading from "assets/loading.gif";
 import notFound from "assets/not-found.png";
 import healthy from "assets/status-healthy.png";
+import time from "assets/time.png";
 
-import { getTemplateEngineDisplayName, getTemplateIcon } from "./constants";
+import EngineTag from "./tags/EngineTag";
 
 const DatabaseDashboard: React.FC = () => {
   const { currentCluster } = useContext(Context);
@@ -47,35 +48,6 @@ const DatabaseDashboard: React.FC = () => {
     return _.sortBy(filteredBySearch, ["name"]);
   }, [datastores, searchValue]);
 
-  const renderStatusIcon = (status: string): JSX.Element => {
-    switch (status) {
-      case "available":
-        return <StatusIcon src={healthy} />;
-      case "":
-        return <></>;
-      case "error":
-        return (
-          <StatusText>
-            <StatusWrapper success={false}>
-              <Status src={loading} />
-              {"Creating database"}
-            </StatusWrapper>
-          </StatusText>
-        );
-      case "updating":
-        return (
-          <StatusText>
-            <StatusWrapper success={false}>
-              <Status src={loading} />
-              {"Creating database"}
-            </StatusWrapper>
-          </StatusText>
-        );
-      default:
-        return <></>;
-    }
-  };
-
   const renderContents = (): JSX.Element => {
     if (currentCluster?.status === "UPDATING_UNAVAILABLE") {
       return <ClusterProvisioningPlaceholder />;
@@ -169,33 +141,25 @@ const DatabaseDashboard: React.FC = () => {
           <GridList>
             {(filteredDatabases ?? []).map(
               (datastore: ClientDatastore, i: number) => {
-                const templateIcon = getTemplateIcon(
-                  datastore.type,
-                  datastore.engine
-                );
-                const templateDisplayName = getTemplateEngineDisplayName(
-                  datastore.engine
-                );
                 return (
                   <Link to={`/databases/${datastore.name}`} key={i}>
                     <Block>
                       <Container row spaced>
                         <Container row>
-                          <Icon src={templateIcon} />
+                          <Icon src={datastore.template.icon} />
                           <Text size={14}>{datastore.name}</Text>
                         </Container>
                         <MidIcon src={healthy} height="16px" />
                       </Container>
-                      {templateDisplayName && (
-                        <>
-                          <Spacer y={1} />
-                          <Container row>
-                            <Tag hoverable={false}>
-                              <Text size={13}>{templateDisplayName}</Text>
-                            </Tag>
-                          </Container>
-                        </>
-                      )}
+                      <Container row>
+                        <EngineTag engine={datastore.template.engine} />
+                      </Container>
+                      <Container row>
+                        <SmallIcon opacity="0.4" src={time} />
+                        <Text size={13} color="#ffffff44">
+                          {readableDate(datastore.created_at)}
+                        </Text>
+                      </Container>
                     </Block>
                   </Link>
                 );
@@ -206,31 +170,25 @@ const DatabaseDashboard: React.FC = () => {
           <List>
             {(filteredDatabases ?? []).map(
               (datastore: ClientDatastore, i: number) => {
-                const templateIcon = getTemplateIcon(
-                  datastore.type,
-                  datastore.engine
-                );
-                const templateDisplayName = getTemplateEngineDisplayName(
-                  datastore.engine
-                );
                 return (
                   <Row to={`/databases/${datastore.name}`} key={i}>
-                    <Container row>
-                      <MidIcon src={templateIcon} />
-                      <Text size={14}>{datastore.name}</Text>
-                      <Spacer inline x={1} />
+                    <Container row spaced>
+                      <Container row>
+                        <MidIcon src={datastore.template.icon} />
+                        <Text size={14}>{datastore.name}</Text>
+                      </Container>
                       <MidIcon src={healthy} height="16px" />
                     </Container>
-                    <Spacer height="15px" />
+                    <Spacer y={0.5} />
                     <Container row>
-                      {templateDisplayName && (
-                        <>
-                          <Spacer inline x={0.5} />
-                          <Tag hoverable={false}>
-                            <Text size={13}>{templateDisplayName}</Text>
-                          </Tag>
-                        </>
-                      )}
+                      <EngineTag engine={datastore.template.engine} />
+                      <Spacer inline x={1} />
+                      <Container>
+                        <SmallIcon opacity="0.4" src={time} />
+                        <Text size={13} color="#ffffff44">
+                          {readableDate(datastore.created_at)}
+                        </Text>
+                      </Container>
                     </Container>
                   </Row>
                 );
@@ -281,20 +239,13 @@ const List = styled.div`
   overflow: hidden;
 `;
 
-const StatusIcon = styled.img`
-  position: absolute;
-  top: 20px;
-  right: 20px;
-  height: 18px;
-`;
-
 const Icon = styled.img`
   height: 20px;
   margin-right: 13px;
 `;
 
 const Block = styled.div`
-  height: 120px;
+  height: 150px;
   flex-direction: column;
   display: flex;
   justify-content: space-between;
@@ -353,37 +304,10 @@ const StyledAppDashboard = styled.div`
   height: 100%;
 `;
 
-const StatusText = styled.div`
-  position: absolute;
-  top: 20px;
-  right: 20px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-`;
-
-const StatusWrapper = styled.div<{
-  success?: boolean;
-}>`
-  display: flex;
-  line-height: 1.5;
-  align-items: center;
-  font-family: "Work Sans", sans-serif;
-  font-size: 13px;
-  color: #ffffff55;
-  margin-left: 15px;
-  text-overflow: ellipsis;
-  animation-fill-mode: forwards;
-  > i {
-    font-size: 18px;
-    margin-right: 10px;
-    float: left;
-    color: ${(props) => (props.success ? "#4797ff" : "#fcba03")};
-  }
-`;
-const Status = styled.img`
-  width: 15px;
-  height: 15px;
-  margin-right: 9px;
-  margin-bottom: 0px;
+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;
 `;

+ 47 - 29
dashboard/src/main/home/database-dashboard/DatabaseHeader.tsx

@@ -1,65 +1,83 @@
 import React from "react";
 import styled from "styled-components";
+import { match } from "ts-pattern";
 
 import Banner from "components/porter/Banner";
-import Fieldset from "components/porter/Fieldset";
+import Container from "components/porter/Container";
+import Icon from "components/porter/Icon";
 import Spacer from "components/porter/Spacer";
+import StatusDot from "components/porter/StatusDot";
 import Text from "components/porter/Text";
-import TitleSection from "components/TitleSection";
+
+import { readableDate } from "shared/string_utils";
 
 import { useDatabaseContext } from "./DatabaseContextProvider";
-import DatabaseHeaderItem from "./DatabaseHeaderItem";
 import { getDatastoreIcon } from "./icons";
+import EngineTag from "./tags/EngineTag";
 import { datastoreField } from "./utils";
 
 const DatabaseHeader: React.FC = () => {
   const { datastore } = useDatabaseContext();
+
   return (
     <>
-      <TitleSection icon={getDatastoreIcon(datastore.type)} iconWidth="33px">
-        {datastore.name}
-      </TitleSection>
-      <Spacer y={1} />
-
+      <Container row style={{ width: "100%" }}>
+        <Container row spaced style={{ width: "100%" }}>
+          <Container row>
+            <Icon src={getDatastoreIcon(datastore.type)} height={"25px"} />
+            <Spacer inline x={1} />
+            <Text size={21}>{datastore.name}</Text>
+            <Spacer inline x={1} />
+            <Container row>
+              <EngineTag engine={datastore.template.engine} heightPixels={15} />
+            </Container>
+          </Container>
+          {match(datastoreField(datastore, "status"))
+            .with("available", () => (
+              <Container row>
+                <StatusDot status={"available"} heightPixels={11} />
+              </Container>
+            ))
+            .otherwise(() => (
+              <Container row>
+                <StatusDot status={"pending"} heightPixels={11} />
+              </Container>
+            ))}
+        </Container>
+      </Container>
+      <Spacer y={0.5} />
+      <CreatedAtContainer>
+        <div style={{ flexShrink: 0 }}>
+          <Text color="#aaaabb66">
+            Created {readableDate(datastore.created_at)}
+          </Text>
+        </div>
+        <Spacer y={0.5} />
+      </CreatedAtContainer>
       {datastoreField(datastore, "status") !== "available" && (
         <>
+          <Spacer y={1} />
           <Banner>
             <BannerContents>
               <b>Database is being created</b>
             </BannerContents>
             <Spacer inline width="5px" />
           </Banner>
-          <Spacer y={1} />
         </>
       )}
-
-      <Fieldset>
-        <Text size={12}>Database details: </Text>
-        <Spacer y={0.5} />
-
-        {datastore.metadata !== undefined && datastore.metadata?.length > 0 && (
-          <GridList>
-            {datastore.metadata?.map((item, index) => (
-              <DatabaseHeaderItem item={item} key={index}></DatabaseHeaderItem>
-            ))}
-          </GridList>
-        )}
-      </Fieldset>
     </>
   );
 };
 
 export default DatabaseHeader;
 
-const GridList = styled.div`
-  display: grid;
-  grid-column-gap: 25px;
-  grid-row-gap: 25px;
-  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
-`;
-
 const BannerContents = styled.div`
   display: flex;
   flex-direction: column;
   row-gap: 0.5rem;
 `;
+
+const CreatedAtContainer = styled.div`
+  display: inline-flex;
+  column-gap: 6px;
+`;

+ 8 - 4
dashboard/src/main/home/database-dashboard/DatabaseTabs.tsx

@@ -6,6 +6,7 @@ import Spacer from "components/porter/Spacer";
 import TabSelector from "components/TabSelector";
 
 import { useDatabaseContext } from "./DatabaseContextProvider";
+import ConfigurationTab from "./tabs/ConfigurationTab";
 import DatabaseEnvTab from "./tabs/DatabaseEnvTab";
 import MetricsTab from "./tabs/MetricsTab";
 import SettingsTab from "./tabs/SettingsTab";
@@ -16,6 +17,7 @@ const validTabs = [
   "metrics",
   // "debug",
   "environment",
+  "configuration",
   "settings",
 ] as const;
 const DEFAULT_TAB = "environment";
@@ -41,9 +43,11 @@ const DatabaseTabs: React.FC<DbTabProps> = ({ tabParam }) => {
   }, [tabParam]);
 
   const tabs = useMemo(() => {
-    const base = [{ label: "Connection Info", value: "environment" }];
-    base.push({ label: "Settings", value: "settings" });
-    return base;
+    return [
+      { label: "Connection Info", value: "environment" },
+      { label: "Configuration", value: "configuration" },
+      { label: "Settings", value: "settings" },
+    ];
   }, []);
 
   return (
@@ -61,7 +65,7 @@ const DatabaseTabs: React.FC<DbTabProps> = ({ tabParam }) => {
         .with("environment", () => <DatabaseEnvTab envData={datastore.env} />)
         .with("settings", () => <SettingsTab />)
         .with("metrics", () => <MetricsTab />)
-
+        .with("configuration", () => <ConfigurationTab />)
         .otherwise(() => null)}
       <Spacer y={2} />
     </>

+ 0 - 16
dashboard/src/main/home/database-dashboard/constants.ts

@@ -11,22 +11,6 @@ import {
 import awsRDS from "assets/amazon-rds.png";
 import awsElastiCache from "assets/aws-elasticache.png";
 
-export const getTemplateIcon = (type: string, engine: string): string => {
-  const template = SUPPORTED_DATABASE_TEMPLATES.find(
-    (t) => t.type === type && t.engine.name === engine
-  );
-
-  return template ? template.icon : awsRDS;
-};
-
-export const getTemplateEngineDisplayName = (engine: string): string => {
-  const template = SUPPORTED_DATABASE_TEMPLATES.find(
-    (t) => t.engine.name === engine
-  );
-
-  return template ? template.engine.displayName : "";
-};
-
 export const SUPPORTED_DATABASE_TEMPLATES: DatabaseTemplate[] = [
   Object.freeze({
     name: "Amazon RDS",

+ 36 - 0
dashboard/src/main/home/database-dashboard/tabs/ConfigurationTab.tsx

@@ -0,0 +1,36 @@
+import React from "react";
+import styled from "styled-components";
+
+import Fieldset from "components/porter/Fieldset";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+
+import { useDatabaseContext } from "../DatabaseContextProvider";
+import DatabaseHeaderItem from "../DatabaseHeaderItem";
+
+const ConfigurationTab: React.FC = () => {
+  const { datastore } = useDatabaseContext();
+  return (
+    <Fieldset>
+      <Text size={12}>Database details: </Text>
+      <Spacer y={0.5} />
+
+      {datastore.metadata !== undefined && datastore.metadata?.length > 0 && (
+        <GridList>
+          {datastore.metadata?.map((item, index) => (
+            <DatabaseHeaderItem item={item} key={index}></DatabaseHeaderItem>
+          ))}
+        </GridList>
+      )}
+    </Fieldset>
+  );
+};
+
+export default ConfigurationTab;
+
+const GridList = styled.div`
+  display: grid;
+  grid-column-gap: 25px;
+  grid-row-gap: 25px;
+  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
+`;

+ 46 - 0
dashboard/src/main/home/database-dashboard/tags/EngineTag.tsx

@@ -0,0 +1,46 @@
+import React from "react";
+import styled from "styled-components";
+import { match } from "ts-pattern";
+
+import Icon from "components/porter/Icon";
+import Spacer from "components/porter/Spacer";
+import Tag from "components/porter/Tag";
+import Text from "components/porter/Text";
+import { type DatabaseEngine } from "lib/databases/types";
+
+import postgresql from "assets/postgresql.svg";
+import redis from "assets/redis.svg";
+
+type Props = {
+  engine: DatabaseEngine;
+  heightPixels?: number;
+};
+
+const EngineTag: React.FC<Props> = ({ engine, heightPixels = 13 }) => {
+  return (
+    <Tag hoverable={false}>
+      <IconContainer>
+        {match(engine)
+          .with({ name: "POSTGRES" }, () => (
+            <Icon src={postgresql} height={`${heightPixels}px`} />
+          ))
+          .with({ name: "REDIS" }, () => (
+            <Icon src={redis} height={`${heightPixels}px`} />
+          ))
+          .otherwise(() => null)}
+      </IconContainer>
+      <Spacer inline x={0.5} />
+      <Text size={heightPixels}>{engine.displayName}</Text>
+    </Tag>
+  );
+};
+
+export default EngineTag;
+
+const IconContainer = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-top: 1px;
+  margin-bottom: 1px;
+`;

+ 2 - 2
dashboard/src/main/home/database-dashboard/utils.tsx

@@ -1,7 +1,7 @@
-import { type ClientDatastore } from "lib/databases/types";
+import { type SerializedDatastore } from "lib/databases/types";
 
 export const datastoreField = (
-  datastore: ClientDatastore,
+  datastore: SerializedDatastore,
   field: string
 ): string => {
   if (datastore.metadata?.length === 0) {