Selaa lähdekoodia

status page boilerplate fe

jusrhee 2 vuotta sitten
vanhempi
sitoutus
c95a47a07c

+ 44 - 22
dashboard/src/main/Main.tsx

@@ -1,18 +1,21 @@
 import React, { Component } from "react";
-import { Route, Redirect, Switch } from "react-router-dom";
+import { Redirect, Route, Switch } from "react-router-dom";
+
+import Loading from "components/Loading";
 
 import api from "shared/api";
 import { Context } from "shared/Context";
-import ResetPasswordInit from "./auth/ResetPasswordInit";
-import ResetPasswordFinalize from "./auth/ResetPasswordFinalize";
+import { PorterUrls, type PorterUrl } from "shared/routing";
+
 import Login from "./auth/Login";
 import Register from "./auth/Register";
-import VerifyEmail from "./auth/VerifyEmail";
+import ResetPasswordFinalize from "./auth/ResetPasswordFinalize";
+import ResetPasswordInit from "./auth/ResetPasswordInit";
 import SetInfo from "./auth/SetInfo";
+import VerifyEmail from "./auth/VerifyEmail";
 import CurrentError from "./CurrentError";
 import Home from "./home/Home";
-import Loading from "components/Loading";
-import { PorterUrl, PorterUrls } from "shared/routing";
+import StatusPage from "./status/StatusPage";
 
 type PropsType = {};
 
@@ -40,24 +43,26 @@ export default class Main extends Component<PropsType, StateType> {
   };
 
   componentDidMount() {
-
     // Get capabilities to case on user info requirements
-    api.getMetadata("", {}, {})
+    api
+      .getMetadata("", {}, {})
       .then((res) => {
         this.setState({
           version: res.data?.version,
-        })
+        });
       })
-      .catch((err) => console.log(err));
+      .catch((err) => {
+        console.log(err);
+      });
 
-    let { setUser, setCurrentError } = this.context;
-    let urlParams = new URLSearchParams(window.location.search);
-    let error = urlParams.get("error");
+    const { setUser, setCurrentError } = this.context;
+    const urlParams = new URLSearchParams(window.location.search);
+    const error = urlParams.get("error");
     error && setCurrentError(error);
     api
       .checkAuth("", {}, {})
       .then((res) => {
-        if (res && res?.data) {
+        if (res?.data) {
           setUser(res.data.id, res.data.email);
           this.setState({
             isLoggedIn: true,
@@ -71,16 +76,20 @@ export default class Main extends Component<PropsType, StateType> {
           this.setState({ isLoggedIn: false, loading: false });
         }
       })
-      .catch((err) => this.setState({ isLoggedIn: false, loading: false }));
+      .catch((err) => {
+        this.setState({ isLoggedIn: false, loading: false });
+      });
 
     api
       .getMetadata("", {}, {})
       .then((res) => {
         this.context.setEdition(res.data?.version);
         this.setState({ local: !res.data?.provisioner });
-        this.context.setEnableGitlab(res.data?.gitlab ? true : false);
+        this.context.setEnableGitlab(!!res.data?.gitlab);
       })
-      .catch((err) => console.log(err));
+      .catch((err) => {
+        console.log(err);
+      });
   }
 
   initialize = () => {
@@ -92,7 +101,7 @@ export default class Main extends Component<PropsType, StateType> {
     api
       .checkAuth("", {}, {})
       .then((res) => {
-        if (res && res?.data) {
+        if (res?.data) {
           this.context.setUser(res?.data?.id, res?.data?.email);
           this.setState({
             isLoggedIn: true,
@@ -106,7 +115,9 @@ export default class Main extends Component<PropsType, StateType> {
           this.setState({ isLoggedIn: false, loading: false });
         }
       })
-      .catch((err) => this.setState({ isLoggedIn: false, loading: false }));
+      .catch((err) => {
+        this.setState({ isLoggedIn: false, loading: false });
+      });
   };
 
   handleLogOut = () => {
@@ -150,7 +161,7 @@ export default class Main extends Component<PropsType, StateType> {
     // Handle case where new user signs up via OAuth and has not set name and company
     if (
       this.state.version === "production" &&
-      !this.state.hasInfo && 
+      !this.state.hasInfo &&
       this.state.userId > 9312 &&
       this.state.isLoggedIn
     ) {
@@ -160,7 +171,7 @@ export default class Main extends Component<PropsType, StateType> {
             path="/"
             render={() => {
               return (
-                <SetInfo 
+                <SetInfo
                   handleLogOut={this.handleLogOut}
                   authenticate={this.authenticate}
                 />
@@ -168,7 +179,7 @@ export default class Main extends Component<PropsType, StateType> {
             }}
           />
         </Switch>
-      )
+      );
     }
 
     return (
@@ -224,6 +235,17 @@ export default class Main extends Component<PropsType, StateType> {
             }
           }}
         />
+        <Route
+          path={`/:status/:projectId?/:clusterId?`}
+          render={() => {
+            if (!this.state.isLoggedIn) {
+              return <Redirect to="/login" />;
+            } else if (!this.context.user?.email?.includes("@porter.run")) {
+              return <Redirect to="/dashboard" />;
+            }
+            return <StatusPage />;
+          }}
+        />
         <Route
           path={`/:baseRoute/:cluster?/:namespace?`}
           render={(routeProps) => {

+ 437 - 0
dashboard/src/main/status/ClusterStatus.tsx

@@ -0,0 +1,437 @@
+import React, { useEffect, useState } from "react";
+import styled from "styled-components";
+
+import Container from "components/porter/Container";
+import Expandable from "components/porter/Expandable";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+
+type Props = {
+  projectId: number;
+  clusterId: number;
+};
+
+type StatusData = {
+  cluster_unresponsive: Array<{
+    timestamp: string;
+    status: string;
+  }>;
+  services: Array<{
+    system_service: {
+      name: string;
+      namespace: string;
+      involved_object_type: string;
+    };
+    system_statuses: Array<{
+      timestamp: string;
+      status: string;
+    }>;
+  }>;
+};
+
+type SystemService = {
+  name: string;
+  namespace: string;
+  involved_object_type: string;
+};
+
+type SystemStatus = {
+  timestamp: string;
+  status: "failure" | "healthy" | "partial_failure";
+};
+
+type Service = {
+  system_service: SystemService;
+  system_statuses: SystemStatus[];
+};
+
+// If you're also grouping services by namespace and want a type for the grouped structure:
+type GroupedService = {
+  system_service: Pick<SystemService, "name" | "involved_object_type">;
+  system_statuses: SystemStatus[];
+};
+
+type GroupedServices = Record<string, GroupedService[]>;
+
+const groupServicesByNamespace = (services: Service[]): GroupedServices => {
+  return services.reduce<GroupedServices>((acc, service) => {
+    const { namespace } = service.system_service;
+    if (!acc[namespace]) {
+      acc[namespace] = [];
+    }
+    acc[namespace].push({
+      system_service: {
+        name: service.system_service.name,
+        involved_object_type: service.system_service.involved_object_type,
+      },
+      system_statuses: service.system_statuses,
+    });
+    return acc;
+  }, {});
+};
+
+const ClusterStatus: React.FC<Props> = ({ projectId, clusterId }) => {
+  // TODO: make API call to get cluster status
+  const [statusData, setStatusData] = useState<any | null>(null);
+
+  useEffect(() => {
+    const groupedServices = groupServicesByNamespace(dummyResponse.services);
+    setStatusData({
+      cluster_unresponsive: dummyResponse.cluster_unresponsive,
+      services: groupedServices,
+    });
+  }, []);
+
+  return (
+    <>
+      <Expandable
+        alt
+        preExpanded
+        header={
+          <Container row>
+            <Text size={16}>Cluster reachable</Text>
+            <Spacer x={1} inline />
+            <Text color="#01a05d">Operational</Text>
+          </Container>
+        }
+      >
+        <StatusBars>
+          {Array.from({ length: 90 }).map((_, i) => {
+            const statusIndex =
+              dummyResponse.cluster_unresponsive.length - (90 - i);
+            return (
+              <Bar
+                key={i}
+                isFirst={i === 0}
+                isLast={i === 89}
+                status={
+                  dummyResponse.cluster_unresponsive[statusIndex]?.status ||
+                  "unknown"
+                }
+              />
+            );
+          })}
+        </StatusBars>
+        <Spacer y={0.5} />
+        <Container row spaced>
+          <Text color="helper">90 days ago</Text>
+          <Text color="helper">Today</Text>
+        </Container>
+      </Expandable>
+      {statusData?.services &&
+        Object.keys(statusData?.services).map((key) => {
+          return (
+            <>
+              <Spacer y={1} />
+              <Expandable
+                alt
+                preExpanded
+                header={
+                  <Container row>
+                    <Text size={16}>{key}</Text>
+                    <Spacer x={1} inline />
+                    <Text color="#01a05d">Operational</Text>
+                  </Container>
+                }
+              >
+                {statusData.services[key].map((service, i) => {
+                  return (
+                    <>
+                      <Text color="helper">{service.system_service.name}</Text>
+                      <Spacer y={0.25} />
+                      <StatusBars>
+                        {Array.from({ length: 90 }).map((_, i) => {
+                          const statusIndex =
+                            service.system_statuses.length - (90 - i);
+
+                          return (
+                            <Bar
+                              key={i}
+                              isFirst={i === 0}
+                              isLast={i === 89}
+                              status={
+                                statusIndex >= 0
+                                  ? service.system_statuses[statusIndex].status
+                                  : "unknown"
+                              }
+                            />
+                          );
+                        })}
+                      </StatusBars>
+                      <Spacer y={0.25} />
+                    </>
+                  );
+                })}
+                <Spacer y={0.25} />
+                <Container row spaced>
+                  <Text color="helper">90 days ago</Text>
+                  <Text color="helper">Today</Text>
+                </Container>
+              </Expandable>
+            </>
+          );
+        })}
+    </>
+  );
+};
+
+export default ClusterStatus;
+
+const getBackgroundGradient = (status: string): string => {
+  switch (status) {
+    case "healthy":
+      return "linear-gradient(#01a05d, #0f2527)";
+    case "failure":
+      return "linear-gradient(#E1322E, #25100f)";
+    case "partial_failure":
+      return "linear-gradient(#E49621, #25270f)";
+    default:
+      return "linear-gradient(#76767644, #76767622)"; // Default or unknown status
+  }
+};
+const Bar = styled.div<{ isFirst: boolean; isLast: boolean; status: string }>`
+  height: 20px;
+  display: flex;
+  flex: 1;
+  border-top-left-radius: ${(props) => (props.isFirst ? "5px" : "0")};
+  border-bottom-left-radius: ${(props) => (props.isFirst ? "5px" : "0")};
+  border-top-right-radius: ${(props) => (props.isLast ? "5px" : "0")};
+  border-bottom-right-radius: ${(props) => (props.isLast ? "5px" : "0")};
+  background: ${(props) => getBackgroundGradient(props.status)};
+`;
+
+const StatusBars = styled.div`
+  width: 100%;
+  display: flex;
+  justify-content: space-between;
+  gap: 2px;
+`;
+
+const dummyResponse = {
+  cluster_unresponsive: [
+    {
+      timestamp: "4/1/24",
+      status: "failure",
+    },
+    {
+      timestamp: "4/2/24",
+      status: "healthy",
+    },
+    {
+      timestamp: "4/3/24",
+      status: "healthy",
+    },
+    {
+      timestamp: "4/4/24",
+      status: "partial_failure",
+    },
+    {
+      timestamp: "4/5/24",
+      status: "healthy",
+    },
+    {
+      timestamp: "4/6/24",
+      status: "healthy",
+    },
+  ],
+  services: [
+    {
+      system_service: {
+        name: "cert-manager",
+        namespace: "cert-manager",
+        involved_object_type: "deployment",
+      },
+      system_statuses: [
+        {
+          timestamp: "4/1/24",
+          status: "healthy",
+        },
+        {
+          timestamp: "4/2/24",
+          status: "healthy",
+        },
+        {
+          timestamp: "4/3/24",
+          status: "healthy",
+        },
+        {
+          timestamp: "4/4/24",
+          status: "healthy",
+        },
+        {
+          timestamp: "4/5/24",
+          status: "healthy",
+        },
+        {
+          timestamp: "4/6/24",
+          status: "healthy",
+        },
+      ],
+    },
+    {
+      system_service: {
+        name: "ca-manager",
+        namespace: "cert-manager",
+        involved_object_type: "statefulset",
+      },
+      system_statuses: [
+        {
+          timestamp: "4/1/24",
+          status: "healthy",
+        },
+        {
+          timestamp: "4/2/24",
+          status: "healthy",
+        },
+        {
+          timestamp: "4/3/24",
+          status: "healthy",
+        },
+        {
+          timestamp: "4/4/24",
+          status: "partial_failure",
+        },
+        {
+          timestamp: "4/5/24",
+          status: "healthy",
+        },
+        {
+          timestamp: "4/6/24",
+          status: "healthy",
+        },
+      ],
+    },
+    {
+      system_service: {
+        name: "ca-injector",
+        namespace: "cert-manager",
+        involved_object_type: "statefulset",
+      },
+      system_statuses: [
+        {
+          timestamp: "4/1/24",
+          status: "partial_failure",
+        },
+        {
+          timestamp: "4/2/24",
+          status: "partial_failure",
+        },
+        {
+          timestamp: "4/3/24",
+          status: "healthy",
+        },
+        {
+          timestamp: "4/4/24",
+          status: "healthy",
+        },
+        {
+          timestamp: "4/5/24",
+          status: "healthy",
+        },
+        {
+          timestamp: "4/6/24",
+          status: "healthy",
+        },
+      ],
+    },
+    {
+      system_service: {
+        name: "bar",
+        namespace: "foo",
+        involved_object_type: "statefulset",
+      },
+      system_statuses: [
+        {
+          timestamp: "4/1/24",
+          status: "healthy",
+        },
+        {
+          timestamp: "4/2/24",
+          status: "healthy",
+        },
+        {
+          timestamp: "4/3/24",
+          status: "healthy",
+        },
+        {
+          timestamp: "4/4/24",
+          status: "failure",
+        },
+        {
+          timestamp: "4/5/24",
+          status: "healthy",
+        },
+        {
+          timestamp: "4/6/24",
+          status: "healthy",
+        },
+      ],
+    },
+    {
+      system_service: {
+        name: "alice",
+        namespace: "foo",
+        involved_object_type: "statefulset",
+      },
+      system_statuses: [
+        {
+          timestamp: "4/1/24",
+          status: "healthy",
+        },
+        {
+          timestamp: "4/2/24",
+          status: "healthy",
+        },
+        {
+          timestamp: "4/3/24",
+          status: "healthy",
+        },
+        {
+          timestamp: "4/4/24",
+          status: "healthy",
+        },
+        {
+          timestamp: "4/5/24",
+          status: "healthy",
+        },
+        {
+          timestamp: "4/6/24",
+          status: "healthy",
+        },
+      ],
+    },
+    {
+      system_service: {
+        name: "bob",
+        namespace: "foo",
+        involved_object_type: "statefulset",
+      },
+      system_statuses: [
+        {
+          timestamp: "4/1/24",
+          status: "healthy",
+        },
+        {
+          timestamp: "4/2/24",
+          status: "healthy",
+        },
+        {
+          timestamp: "4/3/24",
+          status: "healthy",
+        },
+        {
+          timestamp: "4/4/24",
+          status: "partial_failure",
+        },
+        {
+          timestamp: "4/5/24",
+          status: "healthy",
+        },
+        {
+          timestamp: "4/6/24",
+          status: "healthy",
+        },
+      ],
+    },
+  ],
+};

+ 211 - 0
dashboard/src/main/status/StatusPage.tsx

@@ -0,0 +1,211 @@
+import React, { useMemo } from "react";
+import { withRouter, type RouteComponentProps } from "react-router";
+import styled, { ThemeProvider } from "styled-components";
+import { z } from "zod";
+
+import Back from "components/porter/Back";
+import Container from "components/porter/Container";
+import Expandable from "components/porter/Expandable";
+import Image from "components/porter/Image";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+
+import midnight from "shared/themes/midnight";
+import gradient from "assets/gradient.png";
+import logo from "assets/logo.png";
+
+import ClusterStatus from "./ClusterStatus";
+
+type Props = RouteComponentProps;
+
+const StatusPage: React.FC<Props> = ({ match }) => {
+  // TODO: retrieve project and cluster names
+  const projectName = "some-project";
+  const clusterName = "some-cluster";
+
+  const params = useMemo(() => {
+    const { params } = match;
+    const validParams = z
+      .object({
+        projectId: z.string().optional(),
+        clusterId: z.string().optional(),
+      })
+      .safeParse(params);
+
+    if (
+      !validParams.success ||
+      !validParams.data.clusterId ||
+      !validParams.data.projectId
+    ) {
+      return {};
+    }
+    const projectId = parseInt(validParams.data.projectId);
+    const clusterId = parseInt(validParams.data.clusterId);
+    return {
+      projectId,
+      clusterId,
+    };
+  }, [match]);
+
+  return (
+    <ThemeProvider theme={midnight}>
+      <StyledStatusPage>
+        <StatusSection>
+          <Image src={logo} size={30} />
+          <Spacer y={1.5} />
+          {params.projectId && params.clusterId ? (
+            <>
+              <Back to={`/status`} />
+              <Container row>
+                <ProjectIcon>
+                  <ProjectImage src={gradient} />
+                  <Letter>{projectName[0].toUpperCase()}</Letter>
+                </ProjectIcon>
+                <Text size={16}>{projectName}</Text>
+                <Spacer x={1} inline />
+                <Badge>{clusterName}</Badge>
+              </Container>
+              <Spacer y={1} />
+              <ClusterStatus
+                projectId={params.projectId}
+                clusterId={params.clusterId}
+              />
+            </>
+          ) : (
+            <>
+              {Array.from({ length: 100 }).map((_, j) => (
+                <>
+                  <Expandable
+                    key={j}
+                    alt
+                    header={
+                      <Container row>
+                        <Text size={16}>project-{j}</Text>
+                        <Spacer x={1} inline />
+                        <Text color="#01a05d">Operational</Text>
+                      </Container>
+                    }
+                  >
+                    <Spacer y={0.25} />
+                    <Container row spaced>
+                      <Text color="helper">cluster-1</Text>
+                      <Text color="#01a05d">Operational</Text>
+                    </Container>
+                    <Spacer y={0.25} />
+                    <StatusBars>
+                      {Array.from({ length: 90 }).map((_, i) => (
+                        <Bar key={i} isFirst={i === 0} isLast={i === 89} />
+                      ))}
+                    </StatusBars>
+                    <Spacer y={0.5} />
+                    <Container row spaced>
+                      <Text color="helper">cluster-2</Text>
+                      <Text color="#01a05d">Operational</Text>
+                    </Container>
+                    <Spacer y={0.25} />
+                    <StatusBars>
+                      {Array.from({ length: 90 }).map((_, i) => (
+                        <Bar key={i} isFirst={i === 0} isLast={i === 89} />
+                      ))}
+                    </StatusBars>
+                    <Spacer y={0.5} />
+                    <Container row spaced>
+                      <Text color="helper">cluster-3</Text>
+                      <Text color="#01a05d">Operational</Text>
+                    </Container>
+                    <Spacer y={0.25} />
+                    <StatusBars>
+                      {Array.from({ length: 90 }).map((_, i) => (
+                        <Bar key={i} isFirst={i === 0} isLast={i === 89} />
+                      ))}
+                    </StatusBars>
+                    <Spacer y={0.5} />
+                    <Container row spaced>
+                      <Text color="helper">90 days ago</Text>
+                      <Text color="helper">Today</Text>
+                    </Container>
+                    <Spacer y={0.5} />
+                  </Expandable>
+                  <Spacer y={1} />
+                </>
+              ))}
+            </>
+          )}
+        </StatusSection>
+      </StyledStatusPage>
+    </ThemeProvider>
+  );
+};
+
+export default withRouter(StatusPage);
+
+const Badge = styled.div`
+  background: ${(props) => props.theme.clickable.bg};
+  padding: 5px 10px;
+  border: 1px solid ${(props) => props.theme.border};
+  border-radius: 5px;
+  font-size: 13px;
+`;
+
+const Letter = styled.div`
+  height: 100%;
+  width: 100%;
+  position: absolute;
+  padding-bottom: 2px;
+  font-weight: 500;
+  top: 0;
+  left: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`;
+
+const ProjectImage = styled.img`
+  width: 100%;
+  height: 100%;
+`;
+
+const ProjectIcon = styled.div`
+  width: 26px;
+  min-width: 26px;
+  height: 26px;
+  border-radius: 3px;
+  overflow: hidden;
+  position: relative;
+  margin-right: 10px;
+  font-weight: 400;
+`;
+
+const Bar = styled.div<{ isFirst: boolean; isLast: boolean }>`
+  height: 20px;
+  display: flex;
+  flex: 1;
+  border-top-left-radius: ${(props) => (props.isFirst ? "5px" : "0")};
+  border-bottom-left-radius: ${(props) => (props.isFirst ? "5px" : "0")};
+  border-top-right-radius: ${(props) => (props.isLast ? "5px" : "0")};
+  border-bottom-right-radius: ${(props) => (props.isLast ? "5px" : "0")};
+  background: linear-gradient(#01a05d, #0f2527);
+`;
+
+const StatusBars = styled.div`
+  width: 100%;
+  display: flex;
+  justify-content: space-between;
+  gap: 2px;
+`;
+
+const StyledStatusPage = styled.div`
+  width: 100vw;
+  height: 100vh;
+  overflow: auto;
+  padding-top: 50px;
+  display: flex;
+  align-items: center;
+  flex-direction: column;
+`;
+
+const StatusSection = styled.div`
+  width: calc(100% - 40px);
+  padding-bottom: 50px;
+  max-width: 1000px;
+`;