Selaa lähdekoodia

Merge branch 'nico/frontend-implementation-for-porter-v2' of github.com:porter-dev/porter into nafees/porter-agent-v2-endpoints

jnfrati 4 vuotta sitten
vanhempi
sitoutus
ac2dd60504

+ 24 - 2
dashboard/src/components/Table.tsx

@@ -39,6 +39,8 @@ export type TableProps = {
   disableGlobalFilter?: boolean;
   disableHover?: boolean;
   enablePagination?: boolean;
+  hasError?: boolean;
+  errorMessage?: string;
 };
 
 const MIN_PAGE_SIZE = 1;
@@ -51,6 +53,8 @@ const Table: React.FC<TableProps> = ({
   disableGlobalFilter = false,
   disableHover,
   enablePagination,
+  hasError,
+  errorMessage = "An unexpected error occurred, please try again.",
 }) => {
   const {
     getTableProps,
@@ -87,10 +91,20 @@ const Table: React.FC<TableProps> = ({
   }, [data, enablePagination]);
 
   const renderRows = () => {
+    if (hasError) {
+      return (
+        <StyledTr disableHover={true} selected={false}>
+          <StyledTd colSpan={visibleColumns.length} align="center">
+            {errorMessage}
+          </StyledTd>
+        </StyledTr>
+      );
+    }
+
     if (isLoading) {
       return (
         <StyledTr disableHover={true} selected={false}>
-          <StyledTd colSpan={visibleColumns.length}>
+          <StyledTd colSpan={visibleColumns.length} height="150px">
             <Loading />
           </StyledTd>
         </StyledTr>
@@ -100,7 +114,9 @@ const Table: React.FC<TableProps> = ({
     if (!page.length) {
       return (
         <StyledTr disableHover={true} selected={false}>
-          <StyledTd colSpan={visibleColumns.length}>No data available</StyledTd>
+          <StyledTd colSpan={visibleColumns.length} align="center">
+            No data available
+          </StyledTd>
         </StyledTr>
       );
     }
@@ -281,6 +297,12 @@ export const StyledTd = styled.td`
     padding-right: 10px;
   }
   user-select: text;
+
+  ${(props: { align?: "center" | "left" }) => {
+    if (props.align) {
+      return `text-align:${props.align};`;
+    }
+  }}
 `;
 
 export const StyledTHead = styled.thead`

+ 5 - 4
dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx

@@ -15,6 +15,7 @@ import EventsTab from "./events/EventsTab";
 import EnvironmentList from "./preview-environments/EnvironmentList";
 import { useLocation } from "react-router";
 import { getQueryParam } from "shared/routing";
+import IncidentsTable from "./incidents/IncidentsTable";
 
 type TabEnum =
   | "preview_environments"
@@ -22,7 +23,7 @@ type TabEnum =
   | "settings"
   | "namespaces"
   | "metrics"
-  | "events";
+  | "incidents";
 
 const tabOptions: {
   label: string;
@@ -30,7 +31,7 @@ const tabOptions: {
 }[] = [
   { label: "Preview Environments", value: "preview_environments" },
   { label: "Nodes", value: "nodes" },
-  { label: "Events", value: "events" },
+  { label: "Incidents", value: "incidents" },
   { label: "Metrics", value: "metrics" },
   { label: "Namespaces", value: "namespaces" },
   { label: "Settings", value: "settings" },
@@ -53,8 +54,8 @@ export const Dashboard: React.FunctionComponent = () => {
           return <EnvironmentList />;
         }
         return <NodeList />;
-      case "events":
-        return <EventsTab />;
+      case "incidents":
+        return <IncidentsTable />;
       case "settings":
         return <ClusterSettings />;
       case "metrics":

+ 4 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/Routes.tsx

@@ -2,6 +2,7 @@ import React, { useContext } from "react";
 import { Redirect, Route, Switch, useRouteMatch } from "react-router";
 import { Context } from "shared/Context";
 import { Dashboard } from "./Dashboard";
+import IncidentPage from "./incidents/IncidentPage";
 import ExpandedNodeView from "./node-view/ExpandedNodeView";
 import EnvironmentDetail from "./preview-environments/EnvironmentDetail";
 
@@ -11,6 +12,9 @@ export const Routes = () => {
   return (
     <>
       <Switch>
+        <Route path={`${url}/incidents/:incident_id`}>
+          <IncidentPage />
+        </Route>
         <Route path={`${url}/node-view/:nodeId`}>
           <ExpandedNodeView />
         </Route>

+ 115 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/incidents/EventDrawer.tsx

@@ -0,0 +1,115 @@
+import Loading from "components/Loading";
+import React, { useContext, useEffect, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import styled from "styled-components";
+import { IncidentEvent } from "./IncidentPage";
+
+const EventDrawer: React.FC<{ event: IncidentEvent }> = ({ event }) => {
+  const { currentProject, currentCluster } = useContext(Context);
+
+  const [containerLogs, setContainerLogs] = useState<{ [key: string]: string }>(
+    null
+  );
+
+  useEffect(() => {
+    if (!event) {
+      return () => {};
+    }
+
+    let isSubscribed = true;
+    const promises = event.container_events.map((container) => {
+      return api
+        .getIncidentLogsByLogId<{ contents: string }>(
+          "<token>",
+          {},
+          {
+            project_id: currentProject.id,
+            cluster_id: currentCluster.id,
+            namespace: event.namespace,
+            release_name: event.release_name,
+            log_id: container.log_id,
+          }
+        )
+        .then((res) => ({
+          contents: res.data?.contents,
+          container_name: container.container_name,
+        }));
+    });
+
+    Promise.all(promises)
+      .then((data) => {
+        if (!isSubscribed) {
+          return;
+        }
+
+        const tmpContainerLogs = data.reduce<{ [key: string]: string }>(
+          (acc, c) => {
+            acc[c.container_name] = c.contents;
+            return acc;
+          },
+          {}
+        );
+
+        setContainerLogs(tmpContainerLogs);
+      })
+      .catch(() => console.log("nope"));
+
+    return () => {
+      isSubscribed = false;
+    };
+  }, [event]);
+
+  if (!event) {
+    return null;
+  }
+
+  if (!containerLogs) {
+    return <Loading />;
+  }
+
+  return (
+    <EventDrawerContainer>
+      <EventDrawerTitle>{event?.pod_name}</EventDrawerTitle>
+      <span>{event?.message}</span>
+
+      <div>
+        <span>Pod Phase: {event?.pod_phase}</span>
+        <span>Pod Status: {event?.pod_status}</span>
+      </div>
+      {event.container_events.map((container) => {
+        return (
+          <>
+            <h3>{container.container_name}</h3>
+            <span>
+              {container.message} - Exit Code: {container.exit_code}
+            </span>
+            <div>{containerLogs[container.container_name]}</div>
+          </>
+        );
+      })}
+      {Object.entries(containerLogs || {}).map(([key, value]) => {
+        return (
+          <>
+            <h3>{key}</h3>
+            <div>{value}</div>
+          </>
+        );
+      })}
+    </EventDrawerContainer>
+  );
+};
+
+export default EventDrawer;
+
+const EventDrawerContainer = styled.div`
+  color: #ffffff;
+  padding: 25px 30px;
+`;
+
+const EventDrawerTitle = styled.span`
+  display: block;
+  font-size: 24px;
+  font-weight: bold;
+  color: #ffffff90;
+`;

+ 476 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/incidents/IncidentPage.tsx

@@ -0,0 +1,476 @@
+import Loading from "components/Loading";
+import React, { useEffect, useMemo, useState } from "react";
+import { useHistory, useParams } from "react-router";
+import styled from "styled-components";
+import TitleSection from "components/TitleSection";
+
+import backArrow from "assets/back_arrow.png";
+import nodePng from "assets/node.png";
+import { Drawer, withStyles } from "@material-ui/core";
+import EventDrawer from "./EventDrawer";
+import { useRouting } from "shared/routing";
+
+type IncidentPageParams = {
+  incident_id: string;
+};
+
+const IncidentPage = () => {
+  const { incident_id } = useParams<IncidentPageParams>();
+
+  const [incident, setIncident] = useState<Incident>(null);
+
+  const [selectedEvent, setSelectedEvent] = useState<IncidentEvent>(null);
+
+  const { getQueryParam, pushFiltered } = useRouting();
+
+  const history = useHistory();
+
+  useEffect(() => {
+    let isSubscribed = true;
+
+    setIncident(null);
+
+    mockApi().then((res) => {
+      if (isSubscribed) {
+        setIncident(res.data);
+      }
+    });
+
+    return () => {
+      isSubscribed = false;
+    };
+  }, [incident_id]);
+
+  const events = useMemo(() => {
+    return groupEventsByDate(incident?.events);
+  }, [incident]);
+
+  if (incident === null) {
+    return <Loading />;
+  }
+
+  const handleClose = () => {
+    const redirect_url = getQueryParam("redirect_url");
+    if (!redirect_url) {
+      pushFiltered("/cluster-dashboard", []);
+      return;
+    }
+
+    pushFiltered(redirect_url, []);
+  };
+
+  return (
+    <StyledExpandedNodeView>
+      <HeaderWrapper>
+        <BackButton onClick={handleClose}>
+          <BackButtonImg src={backArrow} />
+        </BackButton>
+        <TitleSection icon={nodePng}>{incident.incident_id}</TitleSection>
+        <IncidentMessage>{incident.latest_message}</IncidentMessage>
+        <IncidentStatus status={incident.latest_state}>
+          Status: <i>{incident.latest_state}</i>
+        </IncidentStatus>
+      </HeaderWrapper>
+      <LineBreak />
+      <BodyWrapper>
+        {Object.entries(events).map(([date, events_list]) => {
+          return (
+            <>
+              <StyledDate>{date}</StyledDate>
+
+              {events_list.map((event) => {
+                return (
+                  <StyledCard
+                    onClick={() => setSelectedEvent(event)}
+                    active={selectedEvent?.event_id === event.event_id}
+                  >
+                    <ContentContainer>
+                      <Icon
+                        status={"normal"}
+                        className="material-icons-outlined"
+                      >
+                        info
+                      </Icon>
+                      <EventInformation>
+                        <EventName>
+                          <Helper>Pod:</Helper>
+                          {event.pod_name}
+                        </EventName>
+                        <EventReason>{event.message}</EventReason>
+                      </EventInformation>
+                    </ContentContainer>
+                    <ActionContainer>
+                      <TimestampContainer>
+                        <TimestampIcon className="material-icons-outlined">
+                          access_time
+                        </TimestampIcon>
+                        <span>
+                          {Intl.DateTimeFormat([], {
+                            // @ts-ignore
+                            dateStyle: "full",
+                            timeStyle: "long",
+                          }).format(new Date(event.timestamp))}
+                        </span>
+                      </TimestampContainer>
+                    </ActionContainer>
+                  </StyledCard>
+                );
+              })}
+            </>
+          );
+        })}
+      </BodyWrapper>
+      <StyledDrawer
+        anchor="right"
+        open={!!selectedEvent}
+        onClose={() => setSelectedEvent(null)}
+      >
+        <EventDrawer event={selectedEvent} />
+      </StyledDrawer>
+    </StyledExpandedNodeView>
+  );
+};
+
+export default IncidentPage;
+
+const groupEventsByDate = (
+  events: IncidentEvent[]
+): { [key: string]: IncidentEvent[] } => {
+  if (!events?.length) {
+    return {};
+  }
+
+  return events.reduce<{ [key: string]: IncidentEvent[] }>(
+    (accumulator, current) => {
+      // @ts-ignore
+      const date = Intl.DateTimeFormat([], { dateStyle: "full" }).format(
+        new Date(current.timestamp)
+      );
+
+      if (accumulator[date]?.length) {
+        accumulator[date].push(current);
+      } else {
+        accumulator[date] = [current];
+      }
+
+      return accumulator;
+    },
+    {}
+  );
+};
+
+const mockApi = () =>
+  new Promise<{ data: Incident }>((resolve) => {
+    setTimeout(() => {
+      resolve({ data: incident_mock });
+    }, 1000);
+  });
+
+const incident_mock = {
+  incident_id: "incident:sample-web:default", // eg: "incident:sample-web:default",
+  release_name: "sample-web", // eg: "sample-web"
+  latest_state: "ONGOING", // "ONGOING" or "RESOLVED"
+  latest_reason: "Out of memory", // eg: "Out of memory",
+  latest_message: "Application crash due to out of memory issue", // eg: "Application crash due to out of memory issue"
+  events: [
+    {
+      event_id: "incident:sample-web:default:1647267140", // eg: "incident:sample-web:default:1647267140"
+      pod_name: "sample-web-9x8dsa", // eg: "sample-web-9x8dsa"
+      cluster: "crowdcow-production", // eg: "crowdcow-production"
+      namespace: "production", // eg: "production"
+      release_name: "sample-web", // eg: "sample-web" (release name)
+      release_type: "Deployment", // "Deployment" or "Job"
+      timestamp: 1549312452, // UNIX timestamp of event occurrence
+      pod_phase: "Terminated", // eg: "Terminated"
+      pod_status: "CrashLoopBackOff", // eg: "CrashLoopBackOff"
+      reason: "Out of memory", // eg: "Out of memory"
+      message: "Application crash due to out of memory issue", // eg: "Application crash due to out of memory issue",
+      container_events: [
+        {
+          container_name: "web",
+          reason: "Something",
+          message: "Something",
+          exit_code: 3,
+          log_id: "Something", // eg: "log:<UUID>"
+        },
+      ],
+    },
+    {
+      event_id: "Something", // eg: "incident:sample-web:default:1647267140"
+      pod_name: "Something", // eg: "sample-web-9x8dsa"
+      cluster: "Something", // eg: "crowdcow-production"
+      namespace: "Something", // eg: "production"
+      release_name: "Something", // eg: "sample-web" (release name)
+      release_type: "Something", // "Deployment" or "Job"
+      timestamp: 1549312452, // UNIX timestamp of event occurrence
+      pod_phase: "Something", // eg: "Terminated"
+      pod_status: "Something", // eg: "CrashLoopBackOff"
+      reason: "Something", // eg: "Out of memory"
+      message: "Something", // eg: "Application crash due to out of memory issue",
+      container_events: [
+        {
+          container_name: "Something",
+          reason: "Something",
+          message: "Something",
+          exit_code: 3,
+          log_id: "Something", // eg: "log:<UUID>"
+        },
+      ],
+    },
+    {
+      event_id: "Something", // eg: "incident:sample-web:default:1647267140"
+      pod_name: "Something", // eg: "sample-web-9x8dsa"
+      cluster: "Something", // eg: "crowdcow-production"
+      namespace: "Something", // eg: "production"
+      release_name: "Something", // eg: "sample-web" (release name)
+      release_type: "Something", // "Deployment" or "Job"
+      timestamp: 1647358791310, // UNIX timestamp of event occurrence
+      pod_phase: "Something", // eg: "Terminated"
+      pod_status: "Something", // eg: "CrashLoopBackOff"
+      reason: "Something", // eg: "Out of memory"
+      message: "Something", // eg: "Application crash due to out of memory issue",
+      container_events: [
+        {
+          container_name: "Something",
+          reason: "Something",
+          message: "Something",
+          exit_code: 3,
+          log_id: "Something", // eg: "log:<UUID>"
+        },
+      ],
+    },
+  ],
+};
+
+export type IncidentContainerEvent = {
+  container_name: string;
+  reason: string;
+  message: string;
+  exit_code: number;
+  log_id: string;
+};
+
+export type IncidentEvent = {
+  event_id: string;
+  pod_name: string;
+  cluster: string;
+  namespace: string;
+  release_name: string;
+  release_type: string;
+  timestamp: number;
+  pod_phase: string;
+  pod_status: string;
+  reason: string;
+  message: string;
+  container_events: IncidentContainerEvent[];
+};
+
+export type Incident = {
+  incident_id: string;
+  release_name: string; // eg: "sample-web"
+  latest_state: string; // "ONGOING" or "RESOLVED"
+  latest_reason: string; // eg: "Out of memory",
+  latest_message: string; // eg: "Application crash due to out of memory issue"
+  events: IncidentEvent[];
+};
+
+const LineBreak = styled.div`
+  width: calc(100% - 0px);
+  height: 2px;
+  background: #ffffff20;
+  margin: 10px 0px 35px;
+`;
+
+const IncidentMessage = styled.span`
+  display: block;
+  font-size: 16px;
+  color: #ffffff88;
+  margin-top: 10px;
+`;
+
+const IncidentStatus = styled.span`
+  display: block;
+  font-size: 16px;
+  color: #ffffff88;
+  margin-top: 10px;
+  > i {
+    margin-left: 5px;
+    color: ${(props: { status: string }) => {
+      if (props.status === "ONGOING") {
+        return "#f5cb42";
+      }
+      return "#00d12a";
+    }};
+  }
+`;
+
+const BackButton = styled.div`
+  position: absolute;
+  top: 0px;
+  right: 0px;
+  display: flex;
+  width: 36px;
+  cursor: pointer;
+  height: 36px;
+  align-items: center;
+  justify-content: center;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+    > img {
+      opacity: 1;
+    }
+  }
+`;
+
+const BackButtonImg = styled.img`
+  width: 16px;
+  opacity: 0.75;
+`;
+
+const BodyWrapper = styled.div`
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+`;
+
+const HeaderWrapper = styled.div`
+  position: relative;
+`;
+
+const StyledExpandedNodeView = styled.div`
+  width: 100%;
+  z-index: 0;
+  animation: fadeIn 0.3s;
+  animation-timing-function: ease-out;
+  animation-fill-mode: forwards;
+  display: flex;
+  overflow-y: auto;
+  padding-bottom: 120px;
+  flex-direction: column;
+  overflow: visible;
+
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const StyledDate = styled.div`
+  font-size: 18px;
+  font-weight: bold;
+  color: #ffffff;
+  margin-bottom: 20px;
+  margin-top: 20px;
+  :first-child {
+    margin-top: 0px;
+  }
+`;
+
+const StyledCard = styled.div<{ active: boolean }>`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  border: 1px solid ${({ active }) => (active ? "#819bfd" : "#ffffff44")};
+  background: #ffffff08;
+  margin-bottom: 5px;
+  border-radius: 10px;
+  padding: 14px;
+  overflow: hidden;
+  height: 80px;
+  font-size: 13px;
+  cursor: pointer;
+  :hover {
+    background: #ffffff11;
+    border: 1px solid ${({ active }) => (active ? "#819bfd" : "#ffffff66")};
+  }
+  animation: fadeIn 0.5s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+  :not(:last-child) {
+    margin-bottom: 15px;
+  }
+`;
+
+const ContentContainer = styled.div`
+  display: flex;
+  height: 100%;
+  width: 100%;
+  align-items: center;
+`;
+
+const Icon = styled.span<{ status: "critical" | "normal" }>`
+  font-size: 20px;
+  margin-left: 10px;
+  margin-right: 20px;
+  color: ${({ status }) => (status === "critical" ? "#ff385d" : "#aaaabb")};
+`;
+
+const EventInformation = styled.div`
+  display: flex;
+  flex-direction: column;
+  justify-content: space-around;
+  height: 100%;
+`;
+
+const EventName = styled.div`
+  font-family: "Work Sans", sans-serif;
+  font-weight: 500;
+  color: #ffffff;
+`;
+
+const Helper = styled.span`
+  text-transform: capitalize;
+  color: #ffffff44;
+  margin-right: 5px;
+`;
+
+const EventReason = styled.div`
+  font-family: "Work Sans", sans-serif;
+  color: #aaaabb;
+  margin-top: 5px;
+`;
+
+const ActionContainer = styled.div`
+  display: flex;
+  align-items: center;
+  white-space: nowrap;
+  height: 100%;
+`;
+
+const TimestampContainer = styled.div`
+  display: flex;
+  white-space: nowrap;
+  align-items: center;
+  justify-self: flex-end;
+  color: #ffffff55;
+  margin-right: 10px;
+  font-size: 13px;
+  min-width: 130px;
+  justify-content: space-between;
+`;
+
+const TimestampIcon = styled.span`
+  margin-right: 7px;
+  font-size: 18px;
+`;
+
+const StyledDrawer = withStyles({
+  paperAnchorRight: {
+    background: "#202227",
+    minWidth: "700px",
+  },
+})(Drawer);

+ 119 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/incidents/IncidentsTable.tsx

@@ -0,0 +1,119 @@
+import Table from "components/Table";
+import React, { useContext, useEffect, useMemo, useState } from "react";
+import { Column } from "react-table";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { useRouting } from "shared/routing";
+import styled from "styled-components";
+import { Incident } from "./IncidentPage";
+
+export type IncidentsWithoutEvents = Omit<
+  Incident,
+  "events" | "incident_id"
+> & {
+  id: string;
+};
+
+const IncidentsTable = () => {
+  const { currentCluster, currentProject, setCurrentError } = useContext(
+    Context
+  );
+  const { pushFiltered } = useRouting();
+
+  const [incidents, setIncidents] = useState<IncidentsWithoutEvents[]>(null);
+  const [hasError, setHasError] = useState(false);
+
+  useEffect(() => {
+    let isSubscribed = true;
+    setIncidents(null);
+    setHasError(false);
+
+    api
+      .getIncidents<IncidentsWithoutEvents[]>(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      )
+      .then((res) => {
+        if (!isSubscribed) {
+          return;
+        }
+
+        setIncidents(res.data);
+      })
+      .catch((err) => {
+        setHasError(true);
+        setCurrentError(err);
+      });
+
+    return () => {
+      isSubscribed = false;
+    };
+  }, [currentCluster, currentProject]);
+
+  const columns = useMemo(() => {
+    return [
+      {
+        Header: "Release name",
+        accessor: "release_name",
+      },
+      {
+        Header: "Latest state",
+        accessor: "latest_state",
+      },
+      {
+        Header: "Message",
+        accessor: "latest_message",
+      },
+    ] as Column<IncidentsWithoutEvents>[];
+  }, []);
+
+  const data = useMemo(() => {
+    if (!incidents) {
+      return [];
+    }
+    return incidents;
+  }, [incidents]);
+
+  return (
+    <TableWrapper>
+      <StyledCard>
+        <Table
+          columns={columns}
+          data={data}
+          isLoading={incidents === null}
+          onRowClick={(row: any) => {
+            pushFiltered(
+              `/cluster-dashboard/incidents/${row?.original?.id}`,
+              []
+            );
+          }}
+          hasError={hasError}
+        />
+      </StyledCard>
+    </TableWrapper>
+  );
+};
+
+export default IncidentsTable;
+
+const TableWrapper = styled.div`
+  margin-top: 35px;
+`;
+
+const StyledCard = styled.div`
+  background: #26282f;
+  padding: 14px;
+  border-radius: 8px;
+  box-shadow: 0 4px 15px 0px #00000055;
+  position: relative;
+  border: 2px solid #9eb4ff00;
+  width: 100%;
+  height: 100%;
+  :not(:last-child) {
+    margin-bottom: 25px;
+  }
+`;

+ 9 - 5
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -28,9 +28,8 @@ import { useWebsockets } from "shared/hooks/useWebsockets";
 import useAuth from "shared/auth/useAuth";
 import TitleSection from "components/TitleSection";
 import DeploymentType from "./DeploymentType";
-import EventsTab from "./events/EventsTab";
-import { PopulatedEnvGroup } from "components/porter-form/types";
 import { onlyInLeft } from "shared/array_utils";
+import IncidentsTable from "./incidents/IncidentsTable";
 
 type Props = {
   namespace: string;
@@ -426,8 +425,13 @@ const ExpandedChart: React.FC<Props> = (props) => {
     switch (currentTab) {
       case "metrics":
         return <MetricsSection currentChart={chart} />;
-      case "events":
-        return <EventsTab controllers={controllers} />;
+      case "incidents":
+        return (
+          <IncidentsTable
+            releaseName={chart?.name}
+            namespace={chart?.namespace}
+          />
+        );
       case "status":
         if (isLoadingChartData) {
           return (
@@ -520,7 +524,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
     let rightTabOptions = [] as any[];
     let leftTabOptions = [] as any[];
     leftTabOptions.push({ label: "Status", value: "status" });
-    leftTabOptions.push({ label: "Events", value: "events" });
+    leftTabOptions.push({ label: "Incidents", value: "incidents" });
 
     if (props.isMetricsInstalled) {
       leftTabOptions.push({ label: "Metrics", value: "metrics" });

+ 120 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/incidents/IncidentsTable.tsx

@@ -0,0 +1,120 @@
+import Table from "components/Table";
+import React, { useContext, useEffect, useMemo, useState } from "react";
+import { Column } from "react-table";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { useRouting } from "shared/routing";
+import styled from "styled-components";
+import { IncidentsWithoutEvents } from "../../dashboard/incidents/IncidentsTable";
+
+const IncidentsTable = ({
+  releaseName,
+  namespace,
+}: {
+  releaseName: string;
+  namespace: string;
+}) => {
+  const { currentCluster, currentProject, setCurrentError } = useContext(
+    Context
+  );
+  const { pushFiltered } = useRouting();
+
+  const [incidents, setIncidents] = useState<IncidentsWithoutEvents[]>(null);
+  const [hasError, setHasError] = useState(false);
+
+  useEffect(() => {
+    let isSubscribed = true;
+    setIncidents(null);
+    setHasError(false);
+
+    api
+      .getIncidentsByReleaseName<IncidentsWithoutEvents[]>(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          namespace: namespace,
+          release_name: releaseName,
+        }
+      )
+      .then((res) => {
+        if (!isSubscribed) {
+          return;
+        }
+
+        setIncidents(res.data);
+      })
+      .catch((err) => {
+        setHasError(true);
+        setCurrentError(err);
+      });
+
+    return () => {
+      isSubscribed = false;
+    };
+  }, [currentCluster, currentProject]);
+
+  const columns = useMemo(() => {
+    return [
+      {
+        Header: "Incident ID",
+        accessor: "id",
+      },
+      {
+        Header: "Latest state",
+        accessor: "latest_state",
+      },
+      {
+        Header: "Message",
+        accessor: "latest_message",
+      },
+    ] as Column<IncidentsWithoutEvents>[];
+  }, []);
+
+  const data = useMemo(() => {
+    if (!incidents) {
+      return [];
+    }
+    return incidents;
+  }, [incidents]);
+
+  return (
+    <TableWrapper>
+      <StyledCard>
+        <Table
+          columns={columns}
+          data={data}
+          isLoading={incidents === null}
+          onRowClick={(row: any) => {
+            pushFiltered(
+              `/cluster-dashboard/incidents/${row?.original?.id}`,
+              []
+            );
+          }}
+          hasError={hasError}
+        />
+      </StyledCard>
+    </TableWrapper>
+  );
+};
+
+export default IncidentsTable;
+
+const TableWrapper = styled.div`
+  margin-top: 35px;
+`;
+
+const StyledCard = styled.div`
+  background: #26282f;
+  padding: 14px;
+  border-radius: 8px;
+  box-shadow: 0 4px 15px 0px #00000055;
+  position: relative;
+  border: 2px solid #9eb4ff00;
+  width: 100%;
+  height: 100%;
+  :not(:last-child) {
+    margin-bottom: 25px;
+  }
+`;

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

@@ -1588,6 +1588,63 @@ const getPreviousLogsForContainer = baseApi<
     `/api/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/pod/${name}/previous_logs`
 );
 
+const getIncidents = baseApi<
+  {},
+  {
+    project_id: number;
+    cluster_id: number;
+  }
+>(
+  "GET",
+  ({ project_id, cluster_id }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/incidents`
+);
+
+const getIncidentsByReleaseName = baseApi<
+  {},
+  {
+    project_id: number;
+    cluster_id: number;
+    namespace: string;
+    release_name: string;
+  }
+>(
+  "GET",
+  ({ project_id, cluster_id, namespace, release_name: name }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/releases/${name}/incidents`
+);
+
+const getIncidentById = baseApi<
+  {
+    incident_id: string;
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+    namespace: string;
+    release_name: string;
+  }
+>(
+  "GET",
+  ({ project_id, cluster_id, namespace, release_name: name }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/releases/${name}/incidents`
+);
+
+const getIncidentLogsByLogId = baseApi<
+  {},
+  {
+    project_id: number;
+    cluster_id: number;
+    namespace: string;
+    release_name: string;
+    log_id: string;
+  }
+>(
+  "GET",
+  ({ project_id, cluster_id, namespace, release_name: name, log_id }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/releases/${name}/incidents/logs/${log_id}`
+);
+
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
   checkAuth,
@@ -1741,4 +1798,8 @@ export default {
   provisionDatabase,
   getDatabases,
   getPreviousLogsForContainer,
+  getIncidents,
+  getIncidentsByReleaseName,
+  getIncidentById,
+  getIncidentLogsByLogId,
 };