Преглед изворни кода

status page for system services (#4514)

Co-authored-by: jusrhee <justin@porter.run>
Co-authored-by: d-g-town <66391417+d-g-town@users.noreply.github.com>
Co-authored-by: ianedwards <ianedwards559@gmail.com>
Co-authored-by: sunguroku <65516095+sunguroku@users.noreply.github.com>
Co-authored-by: sunguroku <trevor@porter.run>
Co-authored-by: Porter Support <93286801+portersupport@users.noreply.github.com>
Yosef Mihretie пре 2 година
родитељ
комит
4e840ee0e4

+ 79 - 0
api/server/handlers/system_status/history.go

@@ -0,0 +1,79 @@
+package systemstatus
+
+import (
+	"net/http"
+
+	"connectrpc.com/connect"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// HistoryHandler handles requests to fetch history of system status
+type HistoryHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+// NewHistoryHandler returns a HistoryHandler
+func NewHistoryHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *HistoryHandler {
+	return &HistoryHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (p *HistoryHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-system-service-status-handler")
+	defer span.End()
+
+	// read the project and cluster from context
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+	telemetry.WithAttributes(
+		span,
+		telemetry.AttributeKV{Key: "project_id", Value: project.ID},
+		telemetry.AttributeKV{Key: "cluster_id", Value: cluster.ID},
+	)
+	cloudProvider := p.getCloudProviderEnum(cluster.CloudProvider)
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "cloud_provider", Value: cloudProvider.String()})
+
+	request := connect.NewRequest(&porterv1.SystemStatusHistoryRequest{
+		ProjectId:     int64(project.ID),
+		ClusterId:     int64(cluster.ID),
+		CloudProvider: cloudProvider,
+	})
+	resp, err := p.Config().ClusterControlPlaneClient.SystemStatusHistory(ctx, request)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error getting system status history from ccp")
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+	systemStatusHistory, err := types.ToSystemStatusHistory(resp.Msg)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error converting to system status history type")
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+	p.WriteResult(w, r, systemStatusHistory)
+}
+
+func (p *HistoryHandler) getCloudProviderEnum(cloudProvider string) porterv1.EnumCloudProvider {
+	switch cloudProvider {
+	case "AWS":
+		return porterv1.EnumCloudProvider_ENUM_CLOUD_PROVIDER_AWS
+	case "AZURE":
+		return porterv1.EnumCloudProvider_ENUM_CLOUD_PROVIDER_AZURE
+	case "GCP":
+		return porterv1.EnumCloudProvider_ENUM_CLOUD_PROVIDER_GCP
+	default:
+		// We use unspecified to mean local kind cluster which is used in testing
+		return porterv1.EnumCloudProvider_ENUM_CLOUD_PROVIDER_UNSPECIFIED
+	}
+}

+ 29 - 0
api/server/router/cluster.go

@@ -9,6 +9,7 @@ import (
 	"github.com/porter-dev/porter/api/server/handlers/datastore"
 	"github.com/porter-dev/porter/api/server/handlers/environment"
 	"github.com/porter-dev/porter/api/server/handlers/environment_groups"
+	systemstatus "github.com/porter-dev/porter/api/server/handlers/system_status"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/server/shared/router"
@@ -1867,5 +1868,33 @@ func getClusterRoutes(
 		Router:   r,
 	})
 
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/system-status-history -> systemstatus.NewSystemStatusHistoryHandler
+	systemStatusHistoryEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/system-status-history", relPath),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	systemStatusHistoryHandler := systemstatus.NewHistoryHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: systemStatusHistoryEndpoint,
+		Handler:  systemStatusHistoryHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

+ 172 - 0
api/types/system_service_status.go

@@ -0,0 +1,172 @@
+package types
+
+import (
+	"errors"
+	"fmt"
+	"time"
+
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+)
+
+// InvolvedObjectType is the k8s object the service runs
+// it has to be one of deplouyment, statefulset or daemonset
+type InvolvedObjectType string
+
+const (
+	//  ServiceDeployment is a service that runs as a deployment
+	ServiceDeployment InvolvedObjectType = "deployment"
+	//  ServiceStatefulSet is a service that runs as a statefulset
+	ServiceStatefulSet InvolvedObjectType = "statefulset"
+	// ServiceDaemonSet is a service that runs as a daemonset
+	ServiceDaemonSet InvolvedObjectType = "daemonset"
+)
+
+// Status is the status of a service
+// it has to be one of healthy, partial_failure or failure
+type Status string
+
+const (
+	// StatusHealthy is when a service is fully healthy
+	StatusHealthy Status = "healthy"
+	// StatusPartialFailure is when a service is partially failing
+	StatusPartialFailure Status = "partial_failure"
+	// StatusFailure is when a service is critically in failure mode
+	StatusFailure Status = "failure"
+)
+
+// SystemStatusHistory contains the system infrastructure status for a cluster
+type SystemStatusHistory struct {
+	// ClusterStatusHistory is a time series of the cluster's health
+	ClusterStatusHistory []ClusterHealthStatus `json:"cluster_status_history"`
+	// SystemServiceStatusHistories is a list of SystemServiceStatusHistory for each service
+	// there should be only one entry for a service
+	SystemServiceStatusHistories []SystemServiceStatusHistory `json:"system_service_status_histories"`
+}
+
+// ClusterHealthStatus is the status of a cluster at a certain timestamp
+type ClusterHealthStatus struct {
+	Timestamp time.Time `json:"timestamp"`
+	// Responsive is set to true if the cluster sent all heartbeats in the time period represented by the Timestamp
+	Responsive bool `json:"responsive"`
+}
+
+// SystemServiceStatusHistory contains the status of a system service
+type SystemServiceStatusHistory struct {
+	SystemService SystemService   `json:"system_service"`
+	StatusHistory []ServiceStatus `json:"status_history"`
+}
+
+// SystemService identifies a system service
+type SystemService struct {
+	Name               string             `json:"name"`
+	Namespace          string             `json:"namespace"`
+	InvolvedObjectType InvolvedObjectType `json:"involved_object_type"`
+}
+
+// ServiceStatus is the status of a system service at a certain timestamp
+type ServiceStatus struct {
+	Timestamp time.Time `json:"timestamp"`
+	Status    Status    `json:"status"`
+}
+
+// ToSystemStatusHistory converts the CCP resposne to the internal SystemStatusHistory
+func ToSystemStatusHistory(apiResp *porterv1.SystemStatusHistoryResponse) (SystemStatusHistory, error) {
+	if apiResp == nil {
+		return SystemStatusHistory{}, fmt.Errorf("nil system service status response")
+	}
+	resp := SystemStatusHistory{
+		ClusterStatusHistory:         []ClusterHealthStatus{},
+		SystemServiceStatusHistories: []SystemServiceStatusHistory{},
+	}
+	for _, apiClusterStatus := range apiResp.ClusterStatusHistory {
+		clusterHealthStatus, err := toClusterHealthStatus(apiClusterStatus)
+		if err != nil {
+			return resp, err
+		}
+		resp.ClusterStatusHistory = append(resp.ClusterStatusHistory, clusterHealthStatus)
+	}
+	for _, apiServiceStatusHistory := range apiResp.SystemServiceStatusHistories {
+		statusHistory, err := toSystemServiceStatusHistory(apiServiceStatusHistory)
+		if err != nil {
+			return resp, err
+		}
+		resp.SystemServiceStatusHistories = append(resp.SystemServiceStatusHistories, statusHistory)
+	}
+	return resp, nil
+}
+
+func toClusterHealthStatus(apiClusterStatus *porterv1.ClusterStatus) (ClusterHealthStatus, error) {
+	if apiClusterStatus == nil {
+		return ClusterHealthStatus{}, errors.New("unexpected nil: ClusterStatus")
+	}
+	return ClusterHealthStatus{
+		Timestamp:  apiClusterStatus.TimestampField.AsTime(),
+		Responsive: apiClusterStatus.Responsive,
+	}, nil
+}
+
+func toSystemServiceStatusHistory(apiServiceStatusHistory *porterv1.SystemServiceStatusHistory) (SystemServiceStatusHistory, error) {
+	if apiServiceStatusHistory == nil {
+		return SystemServiceStatusHistory{}, errors.New("unexpected nil: SystemServiceStatusHistory")
+	}
+	systemService, err := toSystemService(apiServiceStatusHistory.SystemService)
+	if err != nil {
+		return SystemServiceStatusHistory{}, err
+	}
+	resp := SystemServiceStatusHistory{
+		SystemService: systemService,
+		StatusHistory: []ServiceStatus{},
+	}
+	for _, apiStatus := range apiServiceStatusHistory.StatusHistory {
+		status, err := toStatus(apiStatus.Status)
+		if err != nil {
+			return resp, err
+		}
+		resp.StatusHistory = append(resp.StatusHistory, ServiceStatus{
+			Timestamp: apiStatus.TimestampField.AsTime(),
+			Status:    status,
+		})
+	}
+	return resp, nil
+}
+
+func toSystemService(apiSystemService *porterv1.SystemService) (SystemService, error) {
+	if apiSystemService == nil {
+		return SystemService{}, errors.New("unexpected nil: SystemService")
+	}
+	involvedObjectType, err := toInternalInvolvedObjectType(apiSystemService.InvolvedObjectType)
+	if err != nil {
+		return SystemService{}, err
+	}
+	return SystemService{
+		Name:               apiSystemService.Name,
+		Namespace:          apiSystemService.Namespace,
+		InvolvedObjectType: involvedObjectType,
+	}, nil
+}
+
+func toInternalInvolvedObjectType(apiType porterv1.InvolvedObjectType) (InvolvedObjectType, error) {
+	switch apiType {
+	case porterv1.InvolvedObjectType_INVOLVED_OBJECT_TYPE_DEPLOYMENT:
+		return ServiceDeployment, nil
+	case porterv1.InvolvedObjectType_INVOLVED_OBJECT_TYPE_STATEFULSET:
+		return ServiceStatefulSet, nil
+	case porterv1.InvolvedObjectType_INVOLVED_OBJECT_TYPE_DAEMONSET:
+		return ServiceDaemonSet, nil
+	default:
+		return "", fmt.Errorf("unknown involved object type")
+	}
+}
+
+func toStatus(apiStatus porterv1.Status) (Status, error) {
+	switch apiStatus {
+	case porterv1.Status_STATUS_HEALTHY:
+		return StatusHealthy, nil
+	case porterv1.Status_STATUS_PARTIAL_FAILURE:
+		return StatusPartialFailure, nil
+	case porterv1.Status_STATUS_FAILURE:
+		return StatusFailure, nil
+	default:
+		return "", errors.New("unknown service status")
+	}
+}

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

@@ -1,18 +1,20 @@
 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";
 
 type PropsType = {};
 
@@ -40,24 +42,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 +75,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 +100,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 +114,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 +160,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 +170,7 @@ export default class Main extends Component<PropsType, StateType> {
             path="/"
             render={() => {
               return (
-                <SetInfo 
+                <SetInfo
                   handleLogOut={this.handleLogOut}
                   authenticate={this.authenticate}
                 />
@@ -168,7 +178,7 @@ export default class Main extends Component<PropsType, StateType> {
             }}
           />
         </Switch>
-      )
+      );
     }
 
     return (

+ 13 - 2
dashboard/src/main/home/infrastructure-dashboard/ClusterTabs.tsx

@@ -1,4 +1,4 @@
-import React, { useEffect, useMemo } from "react";
+import React, { useContext, useEffect, useMemo } from "react";
 import { Contract } from "@porter-dev/api-contracts";
 import AnimateHeight from "react-animate-height";
 import { useFormContext } from "react-hook-form";
@@ -11,6 +11,7 @@ import Spacer from "components/porter/Spacer";
 import TabSelector from "components/TabSelector";
 import { type ClientClusterContract } from "lib/clusters/types";
 
+import { Context } from "shared/Context";
 import { valueExists } from "shared/util";
 
 import { useClusterContext } from "./ClusterContextProvider";
@@ -20,8 +21,9 @@ import ClusterSaveButton from "./ClusterSaveButton";
 import AdvancedSettingsTab from "./tabs/AdvancedSettingsTab";
 import ClusterOverview from "./tabs/overview/ClusterOverview";
 import Settings from "./tabs/Settings";
+import SystemStatus from "./tabs/SystemStatus";
 
-const validTabs = ["overview", "settings", "advanced"] as const;
+const validTabs = ["overview", "settings", "advanced", "systemStatus"] as const;
 const DEFAULT_TAB = "overview" as const;
 type ValidTab = (typeof validTabs)[number];
 
@@ -29,6 +31,7 @@ type Props = {
   tabParam?: string;
 };
 const ClusterTabs: React.FC<Props> = ({ tabParam }) => {
+  const { user } = useContext(Context);
   const history = useHistory();
   const { cluster, isClusterUpdating } = useClusterContext();
 
@@ -69,6 +72,13 @@ const ClusterTabs: React.FC<Props> = ({ tabParam }) => {
       },
     ].filter(valueExists);
 
+    if (user?.email?.endsWith("@porter.run")) {
+      tabs.push({
+        label: "System status",
+        value: "systemStatus" as ValidTab,
+      });
+    }
+
     return tabs;
   }, [isAdvancedSettingsEnabled]);
 
@@ -143,6 +153,7 @@ const ClusterTabs: React.FC<Props> = ({ tabParam }) => {
         .with("overview", () => <ClusterOverview />)
         .with("settings", () => <Settings />)
         .with("advanced", () => <AdvancedSettingsTab />)
+        .with("systemStatus", () => <SystemStatus />)
         .otherwise(() => null)}
     </DashboardWrapper>
   );

+ 222 - 0
dashboard/src/main/home/infrastructure-dashboard/tabs/SystemStatus.tsx

@@ -0,0 +1,222 @@
+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";
+
+import api from "shared/api";
+
+import { useClusterContext } from "../ClusterContextProvider";
+
+type Props = {};
+
+type StatusData = {
+  cluster_responsive: Array<{
+    timestamp: string;
+    responsive: boolean;
+  }>;
+  services: Record<string, GroupedService[]>;
+};
+
+type SystemService = {
+  name: string;
+  namespace: string;
+  involved_object_type: string;
+};
+
+type SystemStatus = {
+  timestamp: string;
+  status: "failure" | "healthy" | "partial_failure";
+};
+
+type Service = {
+  system_service: SystemService;
+  status_history: SystemStatus[];
+};
+
+// If you're also grouping services by namespace and want a type for the grouped structure:
+type GroupedService = {
+  system_service: SystemService;
+  status_history: SystemStatus[];
+};
+
+type GroupedServices = Record<string, GroupedService[]>;
+
+// Initialize statusData with empty arrays
+const initialState: StatusData = {
+  cluster_responsive: [],
+  services: {},
+};
+
+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,
+        namespace: service.system_service.namespace,
+        involved_object_type: service.system_service.involved_object_type,
+      },
+      status_history: service.status_history,
+    });
+    return acc;
+  }, {});
+};
+
+const SystemStatus: React.FC<Props> = () => {
+  const { projectId, cluster } = useClusterContext();
+
+  const [statusData, setStatusData] = useState<StatusData>(initialState);
+
+  useEffect(() => {
+    if (!projectId || !cluster) {
+      return;
+    }
+
+    api
+      .systemStatusHistory(
+        "<token>",
+        {},
+        {
+          projectId,
+          clusterId: cluster.id,
+        }
+      )
+      .then(({ data }) => {
+        const groupedServices = groupServicesByNamespace(
+          data.system_service_status_histories
+        );
+        setStatusData({
+          cluster_responsive: data.cluster_status_history,
+          services: groupedServices,
+        });
+      })
+      .catch((err) => {
+        console.error(err);
+      });
+  }, [projectId, cluster]);
+
+  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 = 89 - i;
+            const responsive =
+              statusData?.cluster_responsive[statusIndex]?.responsive || true; // Provide "true" as the default value
+            return (
+              <Bar
+                key={i}
+                isFirst={i === 0}
+                isLast={i === 89}
+                status={responsive ? "healthy" : "failure"} // Use "responsive" if the value is true, otherwise "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 (
+            <React.Fragment key={key}>
+              <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: Service) => {
+                  return (
+                    <React.Fragment key={service.system_service.name}>
+                      <Text color="helper">{service.system_service.name}</Text>
+                      <Spacer y={0.25} />
+                      <StatusBars>
+                        {Array.from({ length: 90 }).map((_, i) => {
+                          const statusIndex = 89 - i;
+                          return (
+                            <Bar
+                              key={i}
+                              isFirst={i === 0}
+                              isLast={i === 89}
+                              status={
+                                service.status_history[statusIndex]?.status ||
+                                "healthy"
+                              }
+                            />
+                          );
+                        })}
+                      </StatusBars>
+                      <Spacer y={0.25} />
+                    </React.Fragment>
+                  );
+                })}
+                <Spacer y={0.25} />
+                <Container row spaced>
+                  <Text color="helper">90 days ago</Text>
+                  <Text color="helper">Today</Text>
+                </Container>
+              </Expandable>
+            </React.Fragment>
+          );
+        })}
+    </>
+  );
+};
+
+export default SystemStatus;
+
+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;
+`;

+ 11 - 1
dashboard/src/shared/api.tsx

@@ -3627,6 +3627,13 @@ const updateAppEventWebhooks = baseApi<
   return `/api/projects/${pathParams.projectId}/targets/${pathParams.deploymentTargetId}/apps/${pathParams.appName}/update-app-event-webhooks`;
 });
 
+const systemStatusHistory = baseApi<
+{},
+{
+  projectId: number; clusterId: number;
+}>("GET", (pathParams) => {
+  return `/api/projects/${pathParams.projectId}/clusters/${pathParams.clusterId}/system-status-history`;
+});
 
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
@@ -3939,5 +3946,8 @@ export default {
 
   // Webhooks
   appEventWebhooks,
-  updateAppEventWebhooks
+  updateAppEventWebhooks,
+
+  // system status
+  systemStatusHistory
 };