|
|
@@ -0,0 +1,524 @@
|
|
|
+import Loading from "components/Loading";
|
|
|
+import React, { useContext, useEffect, useMemo, useState } from "react";
|
|
|
+import { useParams } from "react-router";
|
|
|
+import styled from "styled-components";
|
|
|
+
|
|
|
+import loading from "assets/loading.gif";
|
|
|
+import { Drawer, withStyles } from "@material-ui/core";
|
|
|
+import EventDrawer from "./EventDrawer";
|
|
|
+import { useRouting } from "shared/routing";
|
|
|
+import api from "shared/api";
|
|
|
+import { Context } from "shared/Context";
|
|
|
+import DynamicLink from "components/DynamicLink";
|
|
|
+import Header from "components/expanded-object/Header";
|
|
|
+import { capitalize } from "shared/string_utils";
|
|
|
+import Description from "components/Description";
|
|
|
+import { dateFormatter } from "../../chart/JobRunTable";
|
|
|
+
|
|
|
+type IncidentPageParams = {
|
|
|
+ incident_id: string;
|
|
|
+};
|
|
|
+
|
|
|
+const IncidentPage = () => {
|
|
|
+ const { incident_id } = useParams<IncidentPageParams>();
|
|
|
+
|
|
|
+ const { currentProject, currentCluster } = useContext(Context);
|
|
|
+
|
|
|
+ const [incident, setIncident] = useState<Incident>(null);
|
|
|
+
|
|
|
+ const [isRefreshing, setIsRefreshing] = useState(false);
|
|
|
+ const [selectedEvent, setSelectedEvent] = useState<IncidentEvent>(null);
|
|
|
+
|
|
|
+ const { getQueryParam, pushFiltered } = useRouting();
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ let isSubscribed = true;
|
|
|
+
|
|
|
+ setIncident(null);
|
|
|
+
|
|
|
+ api
|
|
|
+ .getIncidentById<Incident>(
|
|
|
+ "<token>",
|
|
|
+ { incident_id },
|
|
|
+ {
|
|
|
+ cluster_id: currentCluster.id,
|
|
|
+ project_id: currentProject.id,
|
|
|
+ }
|
|
|
+ )
|
|
|
+ .then((res) => {
|
|
|
+ if (!isSubscribed) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ let incident = res.data;
|
|
|
+
|
|
|
+ incident.events = convertEventsTimestampsToMilliseconds(
|
|
|
+ incident.events
|
|
|
+ );
|
|
|
+
|
|
|
+ setIncident(incident);
|
|
|
+ });
|
|
|
+
|
|
|
+ return () => {
|
|
|
+ isSubscribed = false;
|
|
|
+ };
|
|
|
+ }, [incident_id]);
|
|
|
+
|
|
|
+ const refreshIncident = async () => {
|
|
|
+ setIsRefreshing(true);
|
|
|
+ try {
|
|
|
+ let incident = await api
|
|
|
+ .getIncidentById<Incident>(
|
|
|
+ "<token>",
|
|
|
+ { incident_id },
|
|
|
+ {
|
|
|
+ cluster_id: currentCluster.id,
|
|
|
+ project_id: currentProject.id,
|
|
|
+ }
|
|
|
+ )
|
|
|
+ .then((res) => res.data);
|
|
|
+
|
|
|
+ incident.events = convertEventsTimestampsToMilliseconds(incident.events);
|
|
|
+
|
|
|
+ setIncident(incident);
|
|
|
+ } catch (error) {
|
|
|
+ } finally {
|
|
|
+ setIsRefreshing(false);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const events = useMemo(() => {
|
|
|
+ return groupEventsByDate(incident?.events);
|
|
|
+ }, [incident]);
|
|
|
+
|
|
|
+ if (incident === null) {
|
|
|
+ return <Loading />;
|
|
|
+ }
|
|
|
+
|
|
|
+ const getBackLink = () => {
|
|
|
+ return (
|
|
|
+ getQueryParam("redirect_url") ||
|
|
|
+ "/cluster-dashboard?selected_tab=incidents"
|
|
|
+ );
|
|
|
+ };
|
|
|
+
|
|
|
+ const getResourceLink = () => {
|
|
|
+ let chartName = incident?.chart_name.split("-")[0] || "web";
|
|
|
+ let namespace = incident?.incident_id.split(":")[2] || "default";
|
|
|
+
|
|
|
+ if (chartName == "job") {
|
|
|
+ return `/jobs/${currentCluster.name}/${namespace}/${incident?.release_name}`;
|
|
|
+ }
|
|
|
+
|
|
|
+ return `/applications/${currentCluster.name}/${namespace}/${incident?.release_name}`;
|
|
|
+ };
|
|
|
+
|
|
|
+ return (
|
|
|
+ <StyledExpandedNodeView>
|
|
|
+ <HeaderWrapper>
|
|
|
+ <Header
|
|
|
+ last_updated={dateFormatter(incident.updated_at * 1000)}
|
|
|
+ back_link={getBackLink()}
|
|
|
+ name={"Incident for " + incident.release_name}
|
|
|
+ icon={"error"}
|
|
|
+ materialIconClass="material-icons"
|
|
|
+ inline_title_items={[
|
|
|
+ <ResourceLink
|
|
|
+ key="resource_link"
|
|
|
+ to={getResourceLink()}
|
|
|
+ target="_blank"
|
|
|
+ onClick={(e) => e.stopPropagation()}
|
|
|
+ >
|
|
|
+ {incident.release_name}
|
|
|
+ <i className="material-icons">open_in_new</i>
|
|
|
+ </ResourceLink>,
|
|
|
+ ]}
|
|
|
+ sub_title_items={[
|
|
|
+ <StatusContainer>
|
|
|
+ <Status>
|
|
|
+ <StatusDot status={incident.latest_state} />
|
|
|
+ {capitalize(incident.latest_state)}
|
|
|
+ </Status>
|
|
|
+ <StatusText>
|
|
|
+ - started {dateFormatter(incident.created_at * 1000)}, last
|
|
|
+ updated {dateFormatter(incident.updated_at * 1000)}
|
|
|
+ </StatusText>
|
|
|
+ <Description></Description>
|
|
|
+ </StatusContainer>,
|
|
|
+ ]}
|
|
|
+ />
|
|
|
+ </HeaderWrapper>
|
|
|
+ <LineBreak />
|
|
|
+ <BodyWrapper>
|
|
|
+ <RefreshButton onClick={refreshIncident} disabled={isRefreshing}>
|
|
|
+ {isRefreshing ? (
|
|
|
+ <>
|
|
|
+ <img src={loading} alt="loading icon" />
|
|
|
+ </>
|
|
|
+ ) : (
|
|
|
+ <i className="material-icons">refresh</i>
|
|
|
+ )}
|
|
|
+ </RefreshButton>
|
|
|
+ {Object.entries(events).map(([date, events_list]) => (
|
|
|
+ <React.Fragment key={date}>
|
|
|
+ <StyledDate>{date}</StyledDate>
|
|
|
+
|
|
|
+ {events_list.map((event) => {
|
|
|
+ return (
|
|
|
+ <StyledCard
|
|
|
+ key={event.event_id}
|
|
|
+ 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>
|
|
|
+ );
|
|
|
+ })}
|
|
|
+ </React.Fragment>
|
|
|
+ ))}
|
|
|
+ </BodyWrapper>
|
|
|
+ <StyledDrawer
|
|
|
+ anchor="right"
|
|
|
+ open={!!selectedEvent}
|
|
|
+ onClose={() => setSelectedEvent(null)}
|
|
|
+ >
|
|
|
+ <EventDrawer
|
|
|
+ event={selectedEvent}
|
|
|
+ closeDrawer={() => setSelectedEvent(null)}
|
|
|
+ />
|
|
|
+ </StyledDrawer>
|
|
|
+ </StyledExpandedNodeView>
|
|
|
+ );
|
|
|
+};
|
|
|
+
|
|
|
+export default IncidentPage;
|
|
|
+
|
|
|
+const convertEventsTimestampsToMilliseconds = (events: IncidentEvent[]) => {
|
|
|
+ return events.map((e) => {
|
|
|
+ let newEvent = e;
|
|
|
+
|
|
|
+ newEvent.timestamp = newEvent.timestamp * 1000;
|
|
|
+
|
|
|
+ return newEvent;
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+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;
|
|
|
+ },
|
|
|
+ {}
|
|
|
+ );
|
|
|
+};
|
|
|
+
|
|
|
+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: {
|
|
|
+ [key: string]: 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[];
|
|
|
+ created_at: number;
|
|
|
+ updated_at: number;
|
|
|
+ chart_name: string;
|
|
|
+};
|
|
|
+
|
|
|
+const RefreshButton = styled.button`
|
|
|
+ position: absolute;
|
|
|
+ right: 0px;
|
|
|
+ top: 20px;
|
|
|
+ border: 1px solid #ffffff00;
|
|
|
+ border-radius: 50%;
|
|
|
+ background: inherit;
|
|
|
+ color: #ffffff;
|
|
|
+ cursor: pointer;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ width: 35px;
|
|
|
+ height: 35px;
|
|
|
+
|
|
|
+ > i {
|
|
|
+ font-size: 20px;
|
|
|
+ }
|
|
|
+ > img {
|
|
|
+ width: 20px;
|
|
|
+ height: 20px;
|
|
|
+ }
|
|
|
+
|
|
|
+ :hover {
|
|
|
+ color: #ffffff88;
|
|
|
+ border-color: #ffffff88;
|
|
|
+ }
|
|
|
+`;
|
|
|
+
|
|
|
+const LineBreak = styled.div`
|
|
|
+ width: calc(100% - 0px);
|
|
|
+ height: 2px;
|
|
|
+ background: #ffffff20;
|
|
|
+ margin: 10px 0px 35px;
|
|
|
+`;
|
|
|
+
|
|
|
+const BodyWrapper = styled.div`
|
|
|
+ position: relative;
|
|
|
+ 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);
|
|
|
+
|
|
|
+const ResourceLink = styled(DynamicLink)`
|
|
|
+ font-size: 13px;
|
|
|
+ font-weight: 400;
|
|
|
+ margin-left: 20px;
|
|
|
+ color: #aaaabb;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+
|
|
|
+ :hover {
|
|
|
+ text-decoration: underline;
|
|
|
+ color: white;
|
|
|
+ }
|
|
|
+
|
|
|
+ > i {
|
|
|
+ margin-left: 7px;
|
|
|
+ font-size: 17px;
|
|
|
+ }
|
|
|
+`;
|
|
|
+
|
|
|
+const Status = styled.span`
|
|
|
+ font-size: 13px;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ margin-left: 1px;
|
|
|
+ min-height: 17px;
|
|
|
+ color: #a7a6bb;
|
|
|
+ margin-right: 6px;
|
|
|
+`;
|
|
|
+
|
|
|
+const StatusDot = styled.div`
|
|
|
+ width: 8px;
|
|
|
+ height: 8px;
|
|
|
+ background: ${(props: { status: string }) =>
|
|
|
+ props.status === "ONGOING" ? "#ed5f85" : "#4797ff"};
|
|
|
+ border-radius: 20px;
|
|
|
+ margin-left: 3px;
|
|
|
+ margin-right: 15px;
|
|
|
+`;
|
|
|
+
|
|
|
+const StatusContainer = styled.div`
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ font-size: 13px;
|
|
|
+ color: #aaaabb;
|
|
|
+ width: 100%;
|
|
|
+`;
|
|
|
+
|
|
|
+const StatusText = styled.div`
|
|
|
+ width: 100%;
|
|
|
+`;
|