From bc9949abe40a82a046251ad4feacaba113d7d67a Mon Sep 17 00:00:00 2001 From: Nawaz Dhandala Date: Tue, 17 Mar 2026 15:29:52 +0000 Subject: [PATCH] feat: Add Kubernetes Cluster Management and Monitoring Agent - Implemented a new migration for the KubernetesCluster and KubernetesClusterLabel tables in the database. - Created a KubernetesClusterService for managing cluster instances, including methods for finding or creating clusters, updating their status, and marking disconnected clusters. - Introduced a Helm chart for the OneUptime Kubernetes Monitoring Agent, including configuration files, deployment templates, and RBAC settings. - Added support for collecting metrics and logs from Kubernetes clusters using OpenTelemetry. - Configured service accounts, secrets, and resource limits for the agent's deployment and daemonset. - Provided detailed notes and helper templates for the Helm chart to facilitate installation and configuration. --- App/FeatureSet/Dashboard/src/App.tsx | 15 + .../src/Components/NavBar/NavBar.tsx | 11 + .../src/Pages/Kubernetes/Clusters.tsx | 125 ++++ .../Dashboard/src/Pages/Kubernetes/Layout.tsx | 25 + .../src/Pages/Kubernetes/SideMenu.tsx | 31 + .../Pages/Kubernetes/View/ControlPlane.tsx | 301 ++++++++ .../src/Pages/Kubernetes/View/Delete.tsx | 34 + .../src/Pages/Kubernetes/View/Events.tsx | 253 +++++++ .../src/Pages/Kubernetes/View/Index.tsx | 329 +++++++++ .../src/Pages/Kubernetes/View/Layout.tsx | 32 + .../src/Pages/Kubernetes/View/NodeDetail.tsx | 242 +++++++ .../src/Pages/Kubernetes/View/Nodes.tsx | 213 ++++++ .../src/Pages/Kubernetes/View/PodDetail.tsx | 218 ++++++ .../src/Pages/Kubernetes/View/Pods.tsx | 215 ++++++ .../src/Pages/Kubernetes/View/Settings.tsx | 64 ++ .../src/Pages/Kubernetes/View/SideMenu.tsx | 103 +++ .../Dashboard/src/Routes/AllRoutes.tsx | 1 + .../Dashboard/src/Routes/KubernetesRoutes.tsx | 137 ++++ .../Breadcrumbs/KubernetesBreadcrumbs.ts | 64 ++ .../Dashboard/src/Utils/Breadcrumbs/index.ts | 1 + App/FeatureSet/Dashboard/src/Utils/PageMap.ts | 13 + .../Dashboard/src/Utils/RouteMap.ts | 76 +++ Common/Models/DatabaseModels/Index.ts | 3 + .../DatabaseModels/KubernetesCluster.ts | 640 ++++++++++++++++++ .../1774000000000-MigrationName.ts | 80 +++ .../Postgres/SchemaMigrations/Index.ts | 2 + .../Services/KubernetesClusterService.ts | 109 +++ Common/Types/Permission.ts | 42 ++ HelmChart/Public/kubernetes-agent/.helmignore | 18 + HelmChart/Public/kubernetes-agent/Chart.yaml | 15 + .../kubernetes-agent/templates/NOTES.txt | 24 + .../kubernetes-agent/templates/_helpers.tpl | 59 ++ .../templates/configmap-daemonset.yaml | 134 ++++ .../templates/configmap-deployment.yaml | 176 +++++ .../kubernetes-agent/templates/daemonset.yaml | 56 ++ .../templates/deployment.yaml | 67 ++ .../kubernetes-agent/templates/rbac.yaml | 88 +++ .../kubernetes-agent/templates/secret.yaml | 10 + HelmChart/Public/kubernetes-agent/values.yaml | 80 +++ 39 files changed, 4106 insertions(+) create mode 100644 App/FeatureSet/Dashboard/src/Pages/Kubernetes/Clusters.tsx create mode 100644 App/FeatureSet/Dashboard/src/Pages/Kubernetes/Layout.tsx create mode 100644 App/FeatureSet/Dashboard/src/Pages/Kubernetes/SideMenu.tsx create mode 100644 App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/ControlPlane.tsx create mode 100644 App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Delete.tsx create mode 100644 App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Events.tsx create mode 100644 App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Index.tsx create mode 100644 App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Layout.tsx create mode 100644 App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/NodeDetail.tsx create mode 100644 App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Nodes.tsx create mode 100644 App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/PodDetail.tsx create mode 100644 App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Pods.tsx create mode 100644 App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Settings.tsx create mode 100644 App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/SideMenu.tsx create mode 100644 App/FeatureSet/Dashboard/src/Routes/KubernetesRoutes.tsx create mode 100644 App/FeatureSet/Dashboard/src/Utils/Breadcrumbs/KubernetesBreadcrumbs.ts create mode 100644 Common/Models/DatabaseModels/KubernetesCluster.ts create mode 100644 Common/Server/Infrastructure/Postgres/SchemaMigrations/1774000000000-MigrationName.ts create mode 100644 Common/Server/Services/KubernetesClusterService.ts create mode 100644 HelmChart/Public/kubernetes-agent/.helmignore create mode 100644 HelmChart/Public/kubernetes-agent/Chart.yaml create mode 100644 HelmChart/Public/kubernetes-agent/templates/NOTES.txt create mode 100644 HelmChart/Public/kubernetes-agent/templates/_helpers.tpl create mode 100644 HelmChart/Public/kubernetes-agent/templates/configmap-daemonset.yaml create mode 100644 HelmChart/Public/kubernetes-agent/templates/configmap-deployment.yaml create mode 100644 HelmChart/Public/kubernetes-agent/templates/daemonset.yaml create mode 100644 HelmChart/Public/kubernetes-agent/templates/deployment.yaml create mode 100644 HelmChart/Public/kubernetes-agent/templates/rbac.yaml create mode 100644 HelmChart/Public/kubernetes-agent/templates/secret.yaml create mode 100644 HelmChart/Public/kubernetes-agent/values.yaml diff --git a/App/FeatureSet/Dashboard/src/App.tsx b/App/FeatureSet/Dashboard/src/App.tsx index 14f6d057c6..67a3dd5a3a 100644 --- a/App/FeatureSet/Dashboard/src/App.tsx +++ b/App/FeatureSet/Dashboard/src/App.tsx @@ -181,6 +181,15 @@ const ServiceRoutes: React.LazyExoticComponent< }; }); }); +const KubernetesRoutes: React.LazyExoticComponent< + AllRoutesModule["KubernetesRoutes"] +> = lazy(() => { + return import("./Routes/AllRoutes").then((m: AllRoutesModule) => { + return { + default: m.KubernetesRoutes, + }; + }); +}); const CodeRepositoryRoutes: React.LazyExoticComponent< AllRoutesModule["CodeRepositoryRoutes"] > = lazy(() => { @@ -528,6 +537,12 @@ const App: () => JSX.Element = () => { element={} /> + {/* Kubernetes */} + } + /> + {/* Code Repository */} = ( iconColor: "indigo", category: "Observability", }, + { + title: "Kubernetes", + description: "Monitor Kubernetes clusters.", + route: RouteUtil.populateRouteParams( + RouteMap[PageMap.KUBERNETES_CLUSTERS] as Route, + ), + activeRoute: RouteMap[PageMap.KUBERNETES_CLUSTERS], + icon: IconProp.Cube, + iconColor: "teal", + category: "Observability", + }, // Automation & Analytics { title: "Dashboards", diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/Clusters.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/Clusters.tsx new file mode 100644 index 0000000000..cab8811a1a --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/Clusters.tsx @@ -0,0 +1,125 @@ +import PageMap from "../../Utils/PageMap"; +import RouteMap, { RouteUtil } from "../../Utils/RouteMap"; +import PageComponentProps from "../PageComponentProps"; +import Route from "Common/Types/API/Route"; +import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster"; +import React, { Fragment, FunctionComponent, ReactElement } from "react"; +import ModelTable from "Common/UI/Components/ModelTable/ModelTable"; +import FieldType from "Common/UI/Components/Types/FieldType"; +import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType"; + +const KubernetesClusters: FunctionComponent< + PageComponentProps +> = (): ReactElement => { + return ( + + + modelType={KubernetesCluster} + id="kubernetes-clusters-table" + userPreferencesKey="kubernetes-clusters-table" + isDeleteable={false} + isEditable={false} + isCreateable={true} + name="Kubernetes Clusters" + isViewable={true} + filters={[]} + cardProps={{ + title: "Kubernetes Clusters", + description: + "Clusters being monitored in this project. Install the OneUptime kubernetes-agent Helm chart to connect a cluster.", + }} + noItemsMessage="No Kubernetes clusters connected yet." + showViewIdButton={true} + formFields={[ + { + field: { + name: true, + }, + title: "Name", + fieldType: FormFieldSchemaType.Text, + required: true, + placeholder: "production-us-east", + }, + { + field: { + clusterIdentifier: true, + }, + title: "Cluster Identifier", + fieldType: FormFieldSchemaType.Text, + required: true, + placeholder: "production-us-east-1", + description: + "This should match the clusterName value in your kubernetes-agent Helm chart.", + }, + { + field: { + description: true, + }, + title: "Description", + fieldType: FormFieldSchemaType.LongText, + required: false, + placeholder: "Production cluster running in US East", + }, + ]} + columns={[ + { + field: { + name: true, + }, + title: "Name", + type: FieldType.Text, + }, + { + field: { + clusterIdentifier: true, + }, + title: "Cluster Identifier", + type: FieldType.Text, + }, + { + field: { + otelCollectorStatus: true, + }, + title: "Status", + type: FieldType.Text, + }, + { + field: { + nodeCount: true, + }, + title: "Nodes", + type: FieldType.Number, + }, + { + field: { + podCount: true, + }, + title: "Pods", + type: FieldType.Number, + }, + { + field: { + provider: true, + }, + title: "Provider", + type: FieldType.Text, + }, + ]} + onViewPage={(item: KubernetesCluster): Promise => { + return Promise.resolve( + new Route( + RouteUtil.populateRouteParams( + RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW] as Route, + { + modelId: item._id, + }, + ).toString(), + ), + ); + }} + /> + + ); +}; + +export default KubernetesClusters; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/Layout.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/Layout.tsx new file mode 100644 index 0000000000..d770766081 --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/Layout.tsx @@ -0,0 +1,25 @@ +import { getKubernetesBreadcrumbs } from "../../Utils/Breadcrumbs"; +import { RouteUtil } from "../../Utils/RouteMap"; +import LayoutPageComponentProps from "../LayoutPageComponentProps"; +import SideMenu from "./SideMenu"; +import Page from "Common/UI/Components/Page/Page"; +import Navigation from "Common/UI/Utils/Navigation"; +import React, { FunctionComponent, ReactElement } from "react"; +import { Outlet } from "react-router-dom"; + +const KubernetesLayout: FunctionComponent = ( + _props: LayoutPageComponentProps, +): ReactElement => { + const path: string = Navigation.getRoutePath(RouteUtil.getRoutes()); + return ( + } + breadcrumbLinks={getKubernetesBreadcrumbs(path)} + > + + + ); +}; + +export default KubernetesLayout; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/SideMenu.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/SideMenu.tsx new file mode 100644 index 0000000000..f49c778db3 --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/SideMenu.tsx @@ -0,0 +1,31 @@ +import PageMap from "../../Utils/PageMap"; +import RouteMap, { RouteUtil } from "../../Utils/RouteMap"; +import Route from "Common/Types/API/Route"; +import IconProp from "Common/Types/Icon/IconProp"; +import SideMenu, { + SideMenuSectionProps, +} from "Common/UI/Components/SideMenu/SideMenu"; +import React, { FunctionComponent, ReactElement } from "react"; + +const KubernetesSideMenu: FunctionComponent = (): ReactElement => { + const sections: SideMenuSectionProps[] = [ + { + title: "Kubernetes", + items: [ + { + link: { + title: "All Clusters", + to: RouteUtil.populateRouteParams( + RouteMap[PageMap.KUBERNETES_CLUSTERS] as Route, + ), + }, + icon: IconProp.List, + }, + ], + }, + ]; + + return ; +}; + +export default KubernetesSideMenu; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/ControlPlane.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/ControlPlane.tsx new file mode 100644 index 0000000000..cc64fcb7ef --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/ControlPlane.tsx @@ -0,0 +1,301 @@ +import PageComponentProps from "../../PageComponentProps"; +import ObjectID from "Common/Types/ObjectID"; +import Navigation from "Common/UI/Utils/Navigation"; +import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster"; +import Card from "Common/UI/Components/Card/Card"; +import MetricView from "../../../Components/Metrics/MetricView"; +import MetricViewData from "Common/Types/Metrics/MetricViewData"; +import MetricQueryConfigData from "Common/Types/Metrics/MetricQueryConfigData"; +import AggregationType from "Common/Types/BaseDatabase/AggregationType"; +import OneUptimeDate from "Common/Types/Date"; +import InBetween from "Common/Types/BaseDatabase/InBetween"; +import React, { + Fragment, + FunctionComponent, + ReactElement, + useEffect, + useState, +} from "react"; +import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI"; +import API from "Common/UI/Utils/API/API"; +import PageLoader from "Common/UI/Components/Loader/PageLoader"; +import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage"; +import { PromiseVoidFunction } from "Common/Types/FunctionTypes"; + +const KubernetesClusterControlPlane: FunctionComponent< + PageComponentProps +> = (): ReactElement => { + const modelId: ObjectID = Navigation.getLastParamAsObjectID(1); + + const [cluster, setCluster] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(""); + + const fetchCluster: PromiseVoidFunction = async (): Promise => { + setIsLoading(true); + try { + const item: KubernetesCluster | null = await ModelAPI.getItem({ + modelType: KubernetesCluster, + id: modelId, + select: { + clusterIdentifier: true, + }, + }); + setCluster(item); + } catch (err) { + setError(API.getFriendlyMessage(err)); + } + setIsLoading(false); + }; + + useEffect(() => { + fetchCluster().catch((err: Error) => { + setError(API.getFriendlyMessage(err)); + }); + }, []); + + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + if (!cluster) { + return ; + } + + const clusterIdentifier: string = cluster.clusterIdentifier || ""; + + const endDate: Date = OneUptimeDate.getCurrentDate(); + const startDate: Date = OneUptimeDate.addRemoveHours(endDate, -6); + const startAndEndDate: InBetween = new InBetween(startDate, endDate); + + // etcd metrics (scraped via prometheus receiver) + const etcdDbSizeQuery: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "etcd_db_size", + title: "etcd Database Size", + description: "Total size of the etcd database", + legend: "DB Size", + legendUnit: "bytes", + }, + metricQueryData: { + filterData: { + metricName: "etcd_mvcc_db_total_size_in_bytes", + attributes: { + "k8s.cluster.name": clusterIdentifier, + }, + aggegationType: AggregationType.Avg, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + }; + + // API Server request rate + const apiServerRequestRateQuery: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "apiserver_requests", + title: "API Server Request Rate", + description: "Total API server requests by verb", + legend: "Requests", + legendUnit: "req/s", + }, + metricQueryData: { + filterData: { + metricName: "apiserver_request_total", + attributes: { + "k8s.cluster.name": clusterIdentifier, + }, + aggegationType: AggregationType.Sum, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + }; + + // API Server request latency + const apiServerLatencyQuery: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "apiserver_latency", + title: "API Server Request Latency", + description: "API server request duration", + legend: "Latency", + legendUnit: "seconds", + }, + metricQueryData: { + filterData: { + metricName: "apiserver_request_duration_seconds", + attributes: { + "k8s.cluster.name": clusterIdentifier, + }, + aggegationType: AggregationType.Avg, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + }; + + // Scheduler pending pods + const schedulerPendingQuery: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "scheduler_pending", + title: "Scheduler Pending Pods", + description: "Number of pods pending scheduling", + legend: "Pending Pods", + legendUnit: "", + }, + metricQueryData: { + filterData: { + metricName: "scheduler_pending_pods", + attributes: { + "k8s.cluster.name": clusterIdentifier, + }, + aggegationType: AggregationType.Avg, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + }; + + // Scheduler latency + const schedulerLatencyQuery: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "scheduler_latency", + title: "Scheduler Latency", + description: "End-to-end scheduling latency", + legend: "Latency", + legendUnit: "seconds", + }, + metricQueryData: { + filterData: { + metricName: "scheduler_e2e_scheduling_duration_seconds", + attributes: { + "k8s.cluster.name": clusterIdentifier, + }, + aggegationType: AggregationType.Avg, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + }; + + // Controller Manager work queue depth + const controllerQueueDepthQuery: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "controller_queue", + title: "Controller Manager Queue Depth", + description: "Work queue depth for controller manager", + legend: "Queue Depth", + legendUnit: "", + }, + metricQueryData: { + filterData: { + metricName: "workqueue_depth", + attributes: { + "k8s.cluster.name": clusterIdentifier, + }, + aggegationType: AggregationType.Avg, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + }; + + const [etcdMetricViewData] = useState({ + startAndEndDate: startAndEndDate, + queryConfigs: [etcdDbSizeQuery], + formulaConfigs: [], + }); + + const [apiServerMetricViewData] = useState({ + startAndEndDate: startAndEndDate, + queryConfigs: [apiServerRequestRateQuery, apiServerLatencyQuery], + formulaConfigs: [], + }); + + const [schedulerMetricViewData] = useState({ + startAndEndDate: startAndEndDate, + queryConfigs: [schedulerPendingQuery, schedulerLatencyQuery], + formulaConfigs: [], + }); + + const [controllerMetricViewData] = useState({ + startAndEndDate: startAndEndDate, + queryConfigs: [controllerQueueDepthQuery], + formulaConfigs: [], + }); + + return ( + +
+

+ Control plane metrics require the controlPlane.enabled{" "} + flag to be set to true in the kubernetes-agent Helm chart + values. This is typically only available for self-managed Kubernetes + clusters, not managed services like EKS, GKE, or AKS. +

+
+ + + {}} + /> + + + + {}} + /> + + + + {}} + /> + + + + {}} + /> + +
+ ); +}; + +export default KubernetesClusterControlPlane; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Delete.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Delete.tsx new file mode 100644 index 0000000000..6c8e190e8e --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Delete.tsx @@ -0,0 +1,34 @@ +import PageMap from "../../../Utils/PageMap"; +import RouteMap, { RouteUtil } from "../../../Utils/RouteMap"; +import PageComponentProps from "../../PageComponentProps"; +import Route from "Common/Types/API/Route"; +import ObjectID from "Common/Types/ObjectID"; +import ModelDelete from "Common/UI/Components/ModelDelete/ModelDelete"; +import Navigation from "Common/UI/Utils/Navigation"; +import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster"; +import React, { Fragment, FunctionComponent, ReactElement } from "react"; + +const KubernetesClusterDelete: FunctionComponent< + PageComponentProps +> = (): ReactElement => { + const modelId: ObjectID = Navigation.getLastParamAsObjectID(1); + + return ( + + { + Navigation.navigate( + RouteUtil.populateRouteParams( + RouteMap[PageMap.KUBERNETES_CLUSTERS] as Route, + { modelId }, + ), + ); + }} + /> + + ); +}; + +export default KubernetesClusterDelete; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Events.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Events.tsx new file mode 100644 index 0000000000..9210c4de45 --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Events.tsx @@ -0,0 +1,253 @@ +import PageComponentProps from "../../PageComponentProps"; +import ObjectID from "Common/Types/ObjectID"; +import Navigation from "Common/UI/Utils/Navigation"; +import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster"; +import Card from "Common/UI/Components/Card/Card"; +import AnalyticsModelAPI, { + ListResult, +} from "Common/UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI"; +import Log from "Common/Models/AnalyticsModels/Log"; +import ProjectUtil from "Common/UI/Utils/Project"; +import OneUptimeDate from "Common/Types/Date"; +import SortOrder from "Common/Types/BaseDatabase/SortOrder"; +import React, { + Fragment, + FunctionComponent, + ReactElement, + useEffect, + useState, +} from "react"; +import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI"; +import API from "Common/UI/Utils/API/API"; +import PageLoader from "Common/UI/Components/Loader/PageLoader"; +import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage"; +import { PromiseVoidFunction } from "Common/Types/FunctionTypes"; +import { JSONObject } from "Common/Types/JSON"; + +interface KubernetesEvent { + timestamp: string; + type: string; + reason: string; + objectKind: string; + objectName: string; + namespace: string; + message: string; +} + +const KubernetesClusterEvents: FunctionComponent< + PageComponentProps +> = (): ReactElement => { + const modelId: ObjectID = Navigation.getLastParamAsObjectID(1); + + const [cluster, setCluster] = useState(null); + const [events, setEvents] = useState>([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(""); + + const fetchData: PromiseVoidFunction = async (): Promise => { + setIsLoading(true); + try { + const item: KubernetesCluster | null = await ModelAPI.getItem({ + modelType: KubernetesCluster, + id: modelId, + select: { + clusterIdentifier: true, + }, + }); + setCluster(item); + + if (!item?.clusterIdentifier) { + setIsLoading(false); + return; + } + + const endDate: Date = OneUptimeDate.getCurrentDate(); + const startDate: Date = OneUptimeDate.addRemoveHours(endDate, -24); + + const listResult: ListResult = + await AnalyticsModelAPI.getList({ + modelType: Log, + query: { + projectId: ProjectUtil.getCurrentProjectId()!.toString(), + time: { + startValue: startDate, + endValue: endDate, + } as any, + }, + limit: 200, + skip: 0, + select: { + time: true, + body: true, + severityText: true, + attributes: true, + }, + sort: { + time: SortOrder.Descending, + }, + requestOptions: {}, + }); + + const k8sEvents: Array = []; + + for (const log of listResult.data) { + const attrs: JSONObject = log.attributes || {}; + + // Filter to only k8s events from this cluster + if ( + attrs["k8s.cluster.name"] !== item.clusterIdentifier && + attrs["k8s_cluster_name"] !== item.clusterIdentifier + ) { + continue; + } + + // k8sobjects receiver events have k8s event attributes + const eventType: string = + (attrs["k8s.event.type"] as string) || + (attrs["type"] as string) || + ""; + const reason: string = + (attrs["k8s.event.reason"] as string) || + (attrs["reason"] as string) || + ""; + const objectKind: string = + (attrs["k8s.object.kind"] as string) || + (attrs["involvedObject.kind"] as string) || + ""; + const objectName: string = + (attrs["k8s.object.name"] as string) || + (attrs["involvedObject.name"] as string) || + ""; + const namespace: string = + (attrs["k8s.namespace.name"] as string) || + (attrs["namespace"] as string) || + ""; + + if (eventType || reason || objectKind) { + k8sEvents.push({ + timestamp: log.time + ? OneUptimeDate.getDateAsLocalFormattedString(log.time) + : "", + type: eventType || "Unknown", + reason: reason || "Unknown", + objectKind: objectKind || "Unknown", + objectName: objectName || "Unknown", + namespace: namespace || "default", + message: log.body || "", + }); + } + } + + setEvents(k8sEvents); + } catch (err) { + setError(API.getFriendlyMessage(err)); + } + setIsLoading(false); + }; + + useEffect(() => { + fetchData().catch((err: Error) => { + setError(API.getFriendlyMessage(err)); + }); + }, []); + + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + if (!cluster) { + return ; + } + + return ( + + + {events.length === 0 ? ( +

+ No Kubernetes events found in the last 24 hours. Events will appear + here once the kubernetes-agent is sending data. +

+ ) : ( +
+ + + + + + + + + + + + + {events.map( + (event: KubernetesEvent, index: number) => { + const isWarning: boolean = + event.type.toLowerCase() === "warning"; + return ( + + + + + + + + + ); + }, + )} + +
+ Time + + Type + + Reason + + Object + + Namespace + + Message +
+ {event.timestamp} + + + {event.type} + + + {event.reason} + + {event.objectKind}/{event.objectName} + + {event.namespace} + + {event.message} +
+
+ )} +
+
+ ); +}; + +export default KubernetesClusterEvents; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Index.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Index.tsx new file mode 100644 index 0000000000..9a6eb95f63 --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Index.tsx @@ -0,0 +1,329 @@ +import PageComponentProps from "../../PageComponentProps"; +import ObjectID from "Common/Types/ObjectID"; +import Navigation from "Common/UI/Utils/Navigation"; +import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster"; +import CardModelDetail from "Common/UI/Components/ModelDetail/CardModelDetail"; +import FieldType from "Common/UI/Components/Types/FieldType"; +import Card from "Common/UI/Components/Card/Card"; +import InfoCard from "Common/UI/Components/InfoCard/InfoCard"; +import MetricView from "../../../Components/Metrics/MetricView"; +import MetricViewData from "Common/Types/Metrics/MetricViewData"; +import MetricQueryConfigData from "Common/Types/Metrics/MetricQueryConfigData"; +import AggregationType from "Common/Types/BaseDatabase/AggregationType"; +import OneUptimeDate from "Common/Types/Date"; +import InBetween from "Common/Types/BaseDatabase/InBetween"; +import React, { + Fragment, + FunctionComponent, + ReactElement, + useEffect, + useState, +} from "react"; +import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI"; +import API from "Common/UI/Utils/API/API"; +import PageLoader from "Common/UI/Components/Loader/PageLoader"; +import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage"; +import { PromiseVoidFunction } from "Common/Types/FunctionTypes"; +import AggregateModel from "Common/Types/BaseDatabase/AggregatedModel"; +import { ChartSeries } from "Common/Types/Metrics/MetricQueryConfigData"; + +const KubernetesClusterOverview: FunctionComponent< + PageComponentProps +> = (): ReactElement => { + const modelId: ObjectID = Navigation.getLastParamAsObjectID(1); + + const [cluster, setCluster] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(""); + + const fetchCluster: PromiseVoidFunction = async (): Promise => { + setIsLoading(true); + try { + const item: KubernetesCluster | null = await ModelAPI.getItem({ + modelType: KubernetesCluster, + id: modelId, + select: { + name: true, + clusterIdentifier: true, + provider: true, + otelCollectorStatus: true, + lastSeenAt: true, + nodeCount: true, + podCount: true, + namespaceCount: true, + }, + }); + setCluster(item); + } catch (err) { + setError(API.getFriendlyMessage(err)); + } + setIsLoading(false); + }; + + useEffect(() => { + fetchCluster().catch((err: Error) => { + setError(API.getFriendlyMessage(err)); + }); + }, []); + + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + if (!cluster) { + return ; + } + + const clusterIdentifier: string = cluster.clusterIdentifier || ""; + + // Time range: past 6 hours + const endDate: Date = OneUptimeDate.getCurrentDate(); + const startDate: Date = OneUptimeDate.addRemoveHours(endDate, -6); + const startAndEndDate: InBetween = new InBetween(startDate, endDate); + + const getNodeSeries = (data: AggregateModel): ChartSeries => { + const attributes: Record = + (data["attributes"] as Record) || {}; + const nodeName: string = + (attributes["k8s.node.name"] as string) || "Unknown Node"; + return { title: nodeName }; + }; + + // CPU utilization metric query + const cpuQueryConfig: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "node_cpu", + title: "Node CPU Utilization", + description: "CPU utilization across cluster nodes", + legend: "CPU", + legendUnit: "%", + }, + metricQueryData: { + filterData: { + metricName: "k8s.node.cpu.utilization", + attributes: { + "k8s.cluster.name": clusterIdentifier, + }, + aggegationType: AggregationType.Avg, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + getSeries: getNodeSeries, + }; + + // Memory utilization metric query + const memoryQueryConfig: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "node_memory", + title: "Node Memory Usage", + description: "Memory usage across cluster nodes", + legend: "Memory", + legendUnit: "bytes", + }, + metricQueryData: { + filterData: { + metricName: "k8s.node.memory.usage", + attributes: { + "k8s.cluster.name": clusterIdentifier, + }, + aggegationType: AggregationType.Avg, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + getSeries: getNodeSeries, + }; + + // Pod CPU usage + const podCpuQueryConfig: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "pod_cpu", + title: "Pod CPU Usage (Top Consumers)", + description: "CPU usage by pod across the cluster", + legend: "CPU", + legendUnit: "%", + }, + metricQueryData: { + filterData: { + metricName: "k8s.pod.cpu.utilization", + attributes: { + "k8s.cluster.name": clusterIdentifier, + }, + aggegationType: AggregationType.Avg, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + getSeries: (data: AggregateModel): ChartSeries => { + const attributes: Record = + (data["attributes"] as Record) || {}; + const podName: string = + (attributes["k8s.pod.name"] as string) || "Unknown Pod"; + const namespace: string = + (attributes["k8s.namespace.name"] as string) || ""; + return { title: namespace ? `${namespace}/${podName}` : podName }; + }, + }; + + // Pod Memory usage + const podMemoryQueryConfig: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "pod_memory", + title: "Pod Memory Usage (Top Consumers)", + description: "Memory usage by pod across the cluster", + legend: "Memory", + legendUnit: "bytes", + }, + metricQueryData: { + filterData: { + metricName: "k8s.pod.memory.usage", + attributes: { + "k8s.cluster.name": clusterIdentifier, + }, + aggegationType: AggregationType.Avg, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + getSeries: (data: AggregateModel): ChartSeries => { + const attributes: Record = + (data["attributes"] as Record) || {}; + const podName: string = + (attributes["k8s.pod.name"] as string) || "Unknown Pod"; + const namespace: string = + (attributes["k8s.namespace.name"] as string) || ""; + return { title: namespace ? `${namespace}/${podName}` : podName }; + }, + }; + + const [metricViewData] = useState({ + startAndEndDate: startAndEndDate, + queryConfigs: [ + cpuQueryConfig, + memoryQueryConfig, + podCpuQueryConfig, + podMemoryQueryConfig, + ], + formulaConfigs: [], + }); + + const statusColor: string = + cluster.otelCollectorStatus === "connected" + ? "text-green-600" + : "text-red-600"; + + return ( + + {/* Summary Cards */} +
+ + {cluster.nodeCount?.toString() || "0"} + + } + /> + + {cluster.podCount?.toString() || "0"} + + } + /> + + {cluster.namespaceCount?.toString() || "0"} + + } + /> + + {cluster.otelCollectorStatus === "connected" + ? "Connected" + : "Disconnected"} + + } + /> +
+ + {/* Cluster Details */} + + name="Cluster Overview" + cardProps={{ + title: "Cluster Details", + description: "Basic information about this Kubernetes cluster.", + }} + isEditable={false} + modelDetailProps={{ + showDetailsInNumberOfColumns: 2, + modelType: KubernetesCluster, + id: "kubernetes-cluster-overview", + modelId: modelId, + fields: [ + { + field: { + name: true, + }, + title: "Cluster Name", + fieldType: FieldType.Text, + }, + { + field: { + clusterIdentifier: true, + }, + title: "Cluster Identifier", + fieldType: FieldType.Text, + }, + { + field: { + provider: true, + }, + title: "Provider", + fieldType: FieldType.Text, + }, + { + field: { + lastSeenAt: true, + }, + title: "Last Seen", + fieldType: FieldType.DateTime, + }, + ], + }} + /> + + {/* Resource Utilization Charts */} + + {}} + /> + +
+ ); +}; + +export default KubernetesClusterOverview; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Layout.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Layout.tsx new file mode 100644 index 0000000000..cb6ee5e0c6 --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Layout.tsx @@ -0,0 +1,32 @@ +import { getKubernetesBreadcrumbs } from "../../../Utils/Breadcrumbs"; +import { RouteUtil } from "../../../Utils/RouteMap"; +import PageComponentProps from "../../PageComponentProps"; +import SideMenu from "./SideMenu"; +import ObjectID from "Common/Types/ObjectID"; +import ModelPage from "Common/UI/Components/Page/ModelPage"; +import Navigation from "Common/UI/Utils/Navigation"; +import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster"; +import React, { FunctionComponent, ReactElement } from "react"; +import { Outlet, useParams } from "react-router-dom"; + +const KubernetesClusterViewLayout: FunctionComponent< + PageComponentProps +> = (): ReactElement => { + const { id } = useParams(); + const modelId: ObjectID = new ObjectID(id || ""); + const path: string = Navigation.getRoutePath(RouteUtil.getRoutes()); + return ( + } + > + + + ); +}; + +export default KubernetesClusterViewLayout; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/NodeDetail.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/NodeDetail.tsx new file mode 100644 index 0000000000..1618746cc8 --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/NodeDetail.tsx @@ -0,0 +1,242 @@ +import PageComponentProps from "../../PageComponentProps"; +import ObjectID from "Common/Types/ObjectID"; +import Navigation from "Common/UI/Utils/Navigation"; +import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster"; +import Card from "Common/UI/Components/Card/Card"; +import InfoCard from "Common/UI/Components/InfoCard/InfoCard"; +import MetricView from "../../../Components/Metrics/MetricView"; +import MetricViewData from "Common/Types/Metrics/MetricViewData"; +import MetricQueryConfigData from "Common/Types/Metrics/MetricQueryConfigData"; +import AggregationType from "Common/Types/BaseDatabase/AggregationType"; +import OneUptimeDate from "Common/Types/Date"; +import InBetween from "Common/Types/BaseDatabase/InBetween"; +import React, { + Fragment, + FunctionComponent, + ReactElement, + useEffect, + useState, +} from "react"; +import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI"; +import API from "Common/UI/Utils/API/API"; +import PageLoader from "Common/UI/Components/Loader/PageLoader"; +import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage"; +import { PromiseVoidFunction } from "Common/Types/FunctionTypes"; + +const KubernetesClusterNodeDetail: FunctionComponent< + PageComponentProps +> = (): ReactElement => { + const modelId: ObjectID = Navigation.getLastParamAsObjectID(2); + const nodeName: string = Navigation.getLastParam()?.toString() || ""; + + const [cluster, setCluster] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(""); + + const fetchCluster: PromiseVoidFunction = async (): Promise => { + setIsLoading(true); + try { + const item: KubernetesCluster | null = await ModelAPI.getItem({ + modelType: KubernetesCluster, + id: modelId, + select: { + clusterIdentifier: true, + }, + }); + setCluster(item); + } catch (err) { + setError(API.getFriendlyMessage(err)); + } + setIsLoading(false); + }; + + useEffect(() => { + fetchCluster().catch((err: Error) => { + setError(API.getFriendlyMessage(err)); + }); + }, []); + + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + if (!cluster) { + return ; + } + + const clusterIdentifier: string = cluster.clusterIdentifier || ""; + + const endDate: Date = OneUptimeDate.getCurrentDate(); + const startDate: Date = OneUptimeDate.addRemoveHours(endDate, -6); + const startAndEndDate: InBetween = new InBetween(startDate, endDate); + + const cpuQuery: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "node_cpu", + title: "CPU Utilization", + description: `CPU utilization for node ${nodeName}`, + legend: "CPU", + legendUnit: "%", + }, + metricQueryData: { + filterData: { + metricName: "k8s.node.cpu.utilization", + attributes: { + "k8s.cluster.name": clusterIdentifier, + "k8s.node.name": nodeName, + }, + aggegationType: AggregationType.Avg, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + }; + + const memoryQuery: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "node_memory", + title: "Memory Usage", + description: `Memory usage for node ${nodeName}`, + legend: "Memory", + legendUnit: "bytes", + }, + metricQueryData: { + filterData: { + metricName: "k8s.node.memory.usage", + attributes: { + "k8s.cluster.name": clusterIdentifier, + "k8s.node.name": nodeName, + }, + aggegationType: AggregationType.Avg, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + }; + + const filesystemQuery: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "node_filesystem", + title: "Filesystem Usage", + description: `Filesystem usage for node ${nodeName}`, + legend: "Filesystem", + legendUnit: "bytes", + }, + metricQueryData: { + filterData: { + metricName: "k8s.node.filesystem.usage", + attributes: { + "k8s.cluster.name": clusterIdentifier, + "k8s.node.name": nodeName, + }, + aggegationType: AggregationType.Avg, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + }; + + const networkRxQuery: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "node_network_rx", + title: "Network Receive", + description: `Network bytes received for node ${nodeName}`, + legend: "Network RX", + legendUnit: "bytes/s", + }, + metricQueryData: { + filterData: { + metricName: "k8s.node.network.io.receive", + attributes: { + "k8s.cluster.name": clusterIdentifier, + "k8s.node.name": nodeName, + }, + aggegationType: AggregationType.Avg, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + }; + + const networkTxQuery: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "node_network_tx", + title: "Network Transmit", + description: `Network bytes transmitted for node ${nodeName}`, + legend: "Network TX", + legendUnit: "bytes/s", + }, + metricQueryData: { + filterData: { + metricName: "k8s.node.network.io.transmit", + attributes: { + "k8s.cluster.name": clusterIdentifier, + "k8s.node.name": nodeName, + }, + aggegationType: AggregationType.Avg, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + }; + + const [metricViewData, setMetricViewData] = useState({ + startAndEndDate: startAndEndDate, + queryConfigs: [ + cpuQuery, + memoryQuery, + filesystemQuery, + networkRxQuery, + networkTxQuery, + ], + formulaConfigs: [], + }); + + return ( + +
+ + +
+ + + { + setMetricViewData({ + ...data, + queryConfigs: [ + cpuQuery, + memoryQuery, + filesystemQuery, + networkRxQuery, + networkTxQuery, + ], + formulaConfigs: [], + }); + }} + /> + +
+ ); +}; + +export default KubernetesClusterNodeDetail; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Nodes.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Nodes.tsx new file mode 100644 index 0000000000..dcfead29d5 --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Nodes.tsx @@ -0,0 +1,213 @@ +import PageComponentProps from "../../PageComponentProps"; +import ObjectID from "Common/Types/ObjectID"; +import Navigation from "Common/UI/Utils/Navigation"; +import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster"; +import MetricView from "../../../Components/Metrics/MetricView"; +import MetricViewData from "Common/Types/Metrics/MetricViewData"; +import MetricQueryConfigData from "Common/Types/Metrics/MetricQueryConfigData"; +import AggregationType from "Common/Types/BaseDatabase/AggregationType"; +import OneUptimeDate from "Common/Types/Date"; +import InBetween from "Common/Types/BaseDatabase/InBetween"; +import React, { + Fragment, + FunctionComponent, + ReactElement, + useEffect, + useState, +} from "react"; +import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI"; +import API from "Common/UI/Utils/API/API"; +import PageLoader from "Common/UI/Components/Loader/PageLoader"; +import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage"; +import { PromiseVoidFunction } from "Common/Types/FunctionTypes"; +import AggregateModel from "Common/Types/BaseDatabase/AggregatedModel"; +import { ChartSeries } from "Common/Types/Metrics/MetricQueryConfigData"; + +const KubernetesClusterNodes: FunctionComponent< + PageComponentProps +> = (): ReactElement => { + const modelId: ObjectID = Navigation.getLastParamAsObjectID(1); + + const [cluster, setCluster] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(""); + + const fetchCluster: PromiseVoidFunction = async (): Promise => { + setIsLoading(true); + try { + const item: KubernetesCluster | null = await ModelAPI.getItem({ + modelType: KubernetesCluster, + id: modelId, + select: { + clusterIdentifier: true, + }, + }); + setCluster(item); + } catch (err) { + setError(API.getFriendlyMessage(err)); + } + setIsLoading(false); + }; + + useEffect(() => { + fetchCluster().catch((err: Error) => { + setError(API.getFriendlyMessage(err)); + }); + }, []); + + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + if (!cluster) { + return ; + } + + const clusterIdentifier: string = cluster.clusterIdentifier || ""; + + const endDate: Date = OneUptimeDate.getCurrentDate(); + const startDate: Date = OneUptimeDate.addRemoveHours(endDate, -6); + const startAndEndDate: InBetween = new InBetween(startDate, endDate); + + const getNodeSeries = (data: AggregateModel): ChartSeries => { + const attributes: Record = + (data["attributes"] as Record) || {}; + const nodeName: string = + (attributes["k8s.node.name"] as string) || "Unknown Node"; + return { title: nodeName }; + }; + + const nodeCpuQuery: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "node_cpu", + title: "Node CPU Utilization", + description: "CPU utilization by node", + legend: "CPU", + legendUnit: "%", + }, + metricQueryData: { + filterData: { + metricName: "k8s.node.cpu.utilization", + attributes: { + "k8s.cluster.name": clusterIdentifier, + }, + aggegationType: AggregationType.Avg, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + getSeries: getNodeSeries, + }; + + const nodeMemoryQuery: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "node_memory", + title: "Node Memory Usage", + description: "Memory usage by node", + legend: "Memory", + legendUnit: "bytes", + }, + metricQueryData: { + filterData: { + metricName: "k8s.node.memory.usage", + attributes: { + "k8s.cluster.name": clusterIdentifier, + }, + aggegationType: AggregationType.Avg, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + getSeries: getNodeSeries, + }; + + const nodeFilesystemQuery: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "node_filesystem", + title: "Node Filesystem Usage", + description: "Filesystem usage by node", + legend: "Filesystem", + legendUnit: "bytes", + }, + metricQueryData: { + filterData: { + metricName: "k8s.node.filesystem.usage", + attributes: { + "k8s.cluster.name": clusterIdentifier, + }, + aggegationType: AggregationType.Avg, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + getSeries: getNodeSeries, + }; + + const nodeNetworkRxQuery: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "node_network_rx", + title: "Node Network Receive", + description: "Network bytes received by node", + legend: "Network RX", + legendUnit: "bytes/s", + }, + metricQueryData: { + filterData: { + metricName: "k8s.node.network.io.receive", + attributes: { + "k8s.cluster.name": clusterIdentifier, + }, + aggegationType: AggregationType.Avg, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + getSeries: getNodeSeries, + }; + + const [metricViewData, setMetricViewData] = useState({ + startAndEndDate: startAndEndDate, + queryConfigs: [ + nodeCpuQuery, + nodeMemoryQuery, + nodeFilesystemQuery, + nodeNetworkRxQuery, + ], + formulaConfigs: [], + }); + + return ( + + { + setMetricViewData({ + ...data, + queryConfigs: [ + nodeCpuQuery, + nodeMemoryQuery, + nodeFilesystemQuery, + nodeNetworkRxQuery, + ], + formulaConfigs: [], + }); + }} + /> + + ); +}; + +export default KubernetesClusterNodes; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/PodDetail.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/PodDetail.tsx new file mode 100644 index 0000000000..b52f2b9a09 --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/PodDetail.tsx @@ -0,0 +1,218 @@ +import PageComponentProps from "../../PageComponentProps"; +import ObjectID from "Common/Types/ObjectID"; +import Navigation from "Common/UI/Utils/Navigation"; +import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster"; +import Card from "Common/UI/Components/Card/Card"; +import InfoCard from "Common/UI/Components/InfoCard/InfoCard"; +import MetricView from "../../../Components/Metrics/MetricView"; +import MetricViewData from "Common/Types/Metrics/MetricViewData"; +import MetricQueryConfigData from "Common/Types/Metrics/MetricQueryConfigData"; +import AggregationType from "Common/Types/BaseDatabase/AggregationType"; +import OneUptimeDate from "Common/Types/Date"; +import InBetween from "Common/Types/BaseDatabase/InBetween"; +import React, { + Fragment, + FunctionComponent, + ReactElement, + useEffect, + useState, +} from "react"; +import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI"; +import API from "Common/UI/Utils/API/API"; +import PageLoader from "Common/UI/Components/Loader/PageLoader"; +import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage"; +import { PromiseVoidFunction } from "Common/Types/FunctionTypes"; +import AggregateModel from "Common/Types/BaseDatabase/AggregatedModel"; +import { ChartSeries } from "Common/Types/Metrics/MetricQueryConfigData"; + +const KubernetesClusterPodDetail: FunctionComponent< + PageComponentProps +> = (): ReactElement => { + const modelId: ObjectID = Navigation.getLastParamAsObjectID(2); + const podName: string = Navigation.getLastParam()?.toString() || ""; + + const [cluster, setCluster] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(""); + + const fetchCluster: PromiseVoidFunction = async (): Promise => { + setIsLoading(true); + try { + const item: KubernetesCluster | null = await ModelAPI.getItem({ + modelType: KubernetesCluster, + id: modelId, + select: { + clusterIdentifier: true, + }, + }); + setCluster(item); + } catch (err) { + setError(API.getFriendlyMessage(err)); + } + setIsLoading(false); + }; + + useEffect(() => { + fetchCluster().catch((err: Error) => { + setError(API.getFriendlyMessage(err)); + }); + }, []); + + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + if (!cluster) { + return ; + } + + const clusterIdentifier: string = cluster.clusterIdentifier || ""; + + const endDate: Date = OneUptimeDate.getCurrentDate(); + const startDate: Date = OneUptimeDate.addRemoveHours(endDate, -6); + const startAndEndDate: InBetween = new InBetween(startDate, endDate); + + const getContainerSeries = (data: AggregateModel): ChartSeries => { + const attributes: Record = + (data["attributes"] as Record) || {}; + const containerName: string = + (attributes["k8s.container.name"] as string) || "Unknown Container"; + return { title: containerName }; + }; + + const cpuQuery: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "container_cpu", + title: "Container CPU Utilization", + description: `CPU utilization for containers in pod ${podName}`, + legend: "CPU", + legendUnit: "%", + }, + metricQueryData: { + filterData: { + metricName: "container.cpu.utilization", + attributes: { + "k8s.cluster.name": clusterIdentifier, + "k8s.pod.name": podName, + }, + aggegationType: AggregationType.Avg, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + getSeries: getContainerSeries, + }; + + const memoryQuery: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "container_memory", + title: "Container Memory Usage", + description: `Memory usage for containers in pod ${podName}`, + legend: "Memory", + legendUnit: "bytes", + }, + metricQueryData: { + filterData: { + metricName: "container.memory.usage", + attributes: { + "k8s.cluster.name": clusterIdentifier, + "k8s.pod.name": podName, + }, + aggegationType: AggregationType.Avg, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + getSeries: getContainerSeries, + }; + + const podCpuQuery: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "pod_cpu", + title: "Pod CPU Utilization", + description: `CPU utilization for pod ${podName}`, + legend: "CPU", + legendUnit: "%", + }, + metricQueryData: { + filterData: { + metricName: "k8s.pod.cpu.utilization", + attributes: { + "k8s.cluster.name": clusterIdentifier, + "k8s.pod.name": podName, + }, + aggegationType: AggregationType.Avg, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + }; + + const podMemoryQuery: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "pod_memory", + title: "Pod Memory Usage", + description: `Memory usage for pod ${podName}`, + legend: "Memory", + legendUnit: "bytes", + }, + metricQueryData: { + filterData: { + metricName: "k8s.pod.memory.usage", + attributes: { + "k8s.cluster.name": clusterIdentifier, + "k8s.pod.name": podName, + }, + aggegationType: AggregationType.Avg, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + }; + + const [metricViewData, setMetricViewData] = useState({ + startAndEndDate: startAndEndDate, + queryConfigs: [podCpuQuery, podMemoryQuery, cpuQuery, memoryQuery], + formulaConfigs: [], + }); + + return ( + +
+ + +
+ + + { + setMetricViewData({ + ...data, + queryConfigs: [podCpuQuery, podMemoryQuery, cpuQuery, memoryQuery], + formulaConfigs: [], + }); + }} + /> + +
+ ); +}; + +export default KubernetesClusterPodDetail; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Pods.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Pods.tsx new file mode 100644 index 0000000000..f9895e4c7e --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Pods.tsx @@ -0,0 +1,215 @@ +import PageComponentProps from "../../PageComponentProps"; +import ObjectID from "Common/Types/ObjectID"; +import Navigation from "Common/UI/Utils/Navigation"; +import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster"; +import MetricView from "../../../Components/Metrics/MetricView"; +import MetricViewData from "Common/Types/Metrics/MetricViewData"; +import MetricQueryConfigData from "Common/Types/Metrics/MetricQueryConfigData"; +import AggregationType from "Common/Types/BaseDatabase/AggregationType"; +import OneUptimeDate from "Common/Types/Date"; +import InBetween from "Common/Types/BaseDatabase/InBetween"; +import React, { + Fragment, + FunctionComponent, + ReactElement, + useEffect, + useState, +} from "react"; +import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI"; +import API from "Common/UI/Utils/API/API"; +import PageLoader from "Common/UI/Components/Loader/PageLoader"; +import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage"; +import { PromiseVoidFunction } from "Common/Types/FunctionTypes"; +import AggregateModel from "Common/Types/BaseDatabase/AggregatedModel"; +import { ChartSeries } from "Common/Types/Metrics/MetricQueryConfigData"; + +const KubernetesClusterPods: FunctionComponent< + PageComponentProps +> = (): ReactElement => { + const modelId: ObjectID = Navigation.getLastParamAsObjectID(1); + + const [cluster, setCluster] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(""); + + const fetchCluster: PromiseVoidFunction = async (): Promise => { + setIsLoading(true); + try { + const item: KubernetesCluster | null = await ModelAPI.getItem({ + modelType: KubernetesCluster, + id: modelId, + select: { + clusterIdentifier: true, + }, + }); + setCluster(item); + } catch (err) { + setError(API.getFriendlyMessage(err)); + } + setIsLoading(false); + }; + + useEffect(() => { + fetchCluster().catch((err: Error) => { + setError(API.getFriendlyMessage(err)); + }); + }, []); + + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + if (!cluster) { + return ; + } + + const clusterIdentifier: string = cluster.clusterIdentifier || ""; + + const endDate: Date = OneUptimeDate.getCurrentDate(); + const startDate: Date = OneUptimeDate.addRemoveHours(endDate, -6); + const startAndEndDate: InBetween = new InBetween(startDate, endDate); + + const getPodSeries = (data: AggregateModel): ChartSeries => { + const attributes: Record = + (data["attributes"] as Record) || {}; + const podName: string = + (attributes["k8s.pod.name"] as string) || "Unknown Pod"; + const namespace: string = + (attributes["k8s.namespace.name"] as string) || ""; + return { title: namespace ? `${namespace}/${podName}` : podName }; + }; + + const podCpuQuery: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "pod_cpu", + title: "Pod CPU Utilization", + description: "CPU utilization by pod", + legend: "CPU", + legendUnit: "%", + }, + metricQueryData: { + filterData: { + metricName: "k8s.pod.cpu.utilization", + attributes: { + "k8s.cluster.name": clusterIdentifier, + }, + aggegationType: AggregationType.Avg, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + getSeries: getPodSeries, + }; + + const podMemoryQuery: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "pod_memory", + title: "Pod Memory Usage", + description: "Memory usage by pod", + legend: "Memory", + legendUnit: "bytes", + }, + metricQueryData: { + filterData: { + metricName: "k8s.pod.memory.usage", + attributes: { + "k8s.cluster.name": clusterIdentifier, + }, + aggegationType: AggregationType.Avg, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + getSeries: getPodSeries, + }; + + const podNetworkRxQuery: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "pod_network_rx", + title: "Pod Network Receive", + description: "Network bytes received by pod", + legend: "Network RX", + legendUnit: "bytes/s", + }, + metricQueryData: { + filterData: { + metricName: "k8s.pod.network.io.receive", + attributes: { + "k8s.cluster.name": clusterIdentifier, + }, + aggegationType: AggregationType.Avg, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + getSeries: getPodSeries, + }; + + const podNetworkTxQuery: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "pod_network_tx", + title: "Pod Network Transmit", + description: "Network bytes transmitted by pod", + legend: "Network TX", + legendUnit: "bytes/s", + }, + metricQueryData: { + filterData: { + metricName: "k8s.pod.network.io.transmit", + attributes: { + "k8s.cluster.name": clusterIdentifier, + }, + aggegationType: AggregationType.Avg, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + getSeries: getPodSeries, + }; + + const [metricViewData, setMetricViewData] = useState({ + startAndEndDate: startAndEndDate, + queryConfigs: [ + podCpuQuery, + podMemoryQuery, + podNetworkRxQuery, + podNetworkTxQuery, + ], + formulaConfigs: [], + }); + + return ( + + { + setMetricViewData({ + ...data, + queryConfigs: [ + podCpuQuery, + podMemoryQuery, + podNetworkRxQuery, + podNetworkTxQuery, + ], + formulaConfigs: [], + }); + }} + /> + + ); +}; + +export default KubernetesClusterPods; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Settings.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Settings.tsx new file mode 100644 index 0000000000..700df5de1a --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Settings.tsx @@ -0,0 +1,64 @@ +import PageComponentProps from "../../PageComponentProps"; +import ObjectID from "Common/Types/ObjectID"; +import Navigation from "Common/UI/Utils/Navigation"; +import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster"; +import CardModelDetail from "Common/UI/Components/ModelDetail/CardModelDetail"; +import FieldType from "Common/UI/Components/Types/FieldType"; +import React, { Fragment, FunctionComponent, ReactElement } from "react"; + +const KubernetesClusterSettings: FunctionComponent< + PageComponentProps +> = (): ReactElement => { + const modelId: ObjectID = Navigation.getLastParamAsObjectID(1); + + return ( + + + name="Cluster Settings" + cardProps={{ + title: "Cluster Settings", + description: "Manage settings for this Kubernetes cluster.", + }} + isEditable={true} + editButtonText="Edit Settings" + modelDetailProps={{ + modelType: KubernetesCluster, + id: "kubernetes-cluster-settings", + modelId: modelId, + fields: [ + { + field: { + name: true, + }, + title: "Name", + fieldType: FieldType.Text, + }, + { + field: { + description: true, + }, + title: "Description", + fieldType: FieldType.Text, + }, + { + field: { + clusterIdentifier: true, + }, + title: "Cluster Identifier", + fieldType: FieldType.Text, + }, + { + field: { + provider: true, + }, + title: "Provider", + fieldType: FieldType.Text, + }, + ], + }} + /> + + ); +}; + +export default KubernetesClusterSettings; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/SideMenu.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/SideMenu.tsx new file mode 100644 index 0000000000..4fc0dc7052 --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/SideMenu.tsx @@ -0,0 +1,103 @@ +import PageMap from "../../../Utils/PageMap"; +import RouteMap, { RouteUtil } from "../../../Utils/RouteMap"; +import Route from "Common/Types/API/Route"; +import IconProp from "Common/Types/Icon/IconProp"; +import ObjectID from "Common/Types/ObjectID"; +import SideMenu from "Common/UI/Components/SideMenu/SideMenu"; +import SideMenuItem from "Common/UI/Components/SideMenu/SideMenuItem"; +import SideMenuSection from "Common/UI/Components/SideMenu/SideMenuSection"; +import React, { FunctionComponent, ReactElement } from "react"; + +export interface ComponentProps { + modelId: ObjectID; +} + +const KubernetesClusterSideMenu: FunctionComponent = ( + props: ComponentProps, +): ReactElement => { + return ( + + + + + + + + + + + + + + + + + + ); +}; + +export default KubernetesClusterSideMenu; diff --git a/App/FeatureSet/Dashboard/src/Routes/AllRoutes.tsx b/App/FeatureSet/Dashboard/src/Routes/AllRoutes.tsx index bd7c4fad97..18063248e3 100644 --- a/App/FeatureSet/Dashboard/src/Routes/AllRoutes.tsx +++ b/App/FeatureSet/Dashboard/src/Routes/AllRoutes.tsx @@ -20,6 +20,7 @@ export { default as StatusPagesRoutes } from "./StatusPagesRoutes"; export { default as DashboardRoutes } from "./DashboardRoutes"; export { default as ServiceRoutes } from "./ServiceRoutes"; export { default as CodeRepositoryRoutes } from "./CodeRepositoryRoutes"; +export { default as KubernetesRoutes } from "./KubernetesRoutes"; export { default as AIAgentTasksRoutes } from "./AIAgentTasksRoutes"; // Settings diff --git a/App/FeatureSet/Dashboard/src/Routes/KubernetesRoutes.tsx b/App/FeatureSet/Dashboard/src/Routes/KubernetesRoutes.tsx new file mode 100644 index 0000000000..9d36a9973d --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Routes/KubernetesRoutes.tsx @@ -0,0 +1,137 @@ +import ComponentProps from "../Pages/PageComponentProps"; +import KubernetesLayout from "../Pages/Kubernetes/Layout"; +import KubernetesClusterViewLayout from "../Pages/Kubernetes/View/Layout"; +import PageMap from "../Utils/PageMap"; +import RouteMap, { RouteUtil, KubernetesRoutePath } from "../Utils/RouteMap"; +import Route from "Common/Types/API/Route"; +import React, { FunctionComponent, ReactElement } from "react"; +import { Route as PageRoute, Routes } from "react-router-dom"; + +// Pages +import KubernetesClusters from "../Pages/Kubernetes/Clusters"; +import KubernetesClusterView from "../Pages/Kubernetes/View/Index"; +import KubernetesClusterViewPods from "../Pages/Kubernetes/View/Pods"; +import KubernetesClusterViewPodDetail from "../Pages/Kubernetes/View/PodDetail"; +import KubernetesClusterViewNodes from "../Pages/Kubernetes/View/Nodes"; +import KubernetesClusterViewNodeDetail from "../Pages/Kubernetes/View/NodeDetail"; +import KubernetesClusterViewEvents from "../Pages/Kubernetes/View/Events"; +import KubernetesClusterViewControlPlane from "../Pages/Kubernetes/View/ControlPlane"; +import KubernetesClusterViewDelete from "../Pages/Kubernetes/View/Delete"; +import KubernetesClusterViewSettings from "../Pages/Kubernetes/View/Settings"; + +const KubernetesRoutes: FunctionComponent = ( + props: ComponentProps, +): ReactElement => { + return ( + + }> + + } + /> + + + } + > + + } + /> + + + } + /> + + + } + /> + + + } + /> + + + } + /> + + + } + /> + + + } + /> + + + } + /> + + + } + /> + + + ); +}; + +export default KubernetesRoutes; diff --git a/App/FeatureSet/Dashboard/src/Utils/Breadcrumbs/KubernetesBreadcrumbs.ts b/App/FeatureSet/Dashboard/src/Utils/Breadcrumbs/KubernetesBreadcrumbs.ts new file mode 100644 index 0000000000..645767f7f4 --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Utils/Breadcrumbs/KubernetesBreadcrumbs.ts @@ -0,0 +1,64 @@ +import PageMap from "../PageMap"; +import { BuildBreadcrumbLinksByTitles } from "./Helper"; +import Dictionary from "Common/Types/Dictionary"; +import Link from "Common/Types/Link"; + +export function getKubernetesBreadcrumbs( + path: string, +): Array | undefined { + const breadcrumpLinksMap: Dictionary = { + ...BuildBreadcrumbLinksByTitles(PageMap.KUBERNETES_CLUSTERS, [ + "Project", + "Kubernetes", + "Clusters", + ]), + ...BuildBreadcrumbLinksByTitles(PageMap.KUBERNETES_CLUSTER_VIEW, [ + "Project", + "Kubernetes", + "View Cluster", + ]), + ...BuildBreadcrumbLinksByTitles(PageMap.KUBERNETES_CLUSTER_VIEW_PODS, [ + "Project", + "Kubernetes", + "View Cluster", + "Pods", + ]), + ...BuildBreadcrumbLinksByTitles( + PageMap.KUBERNETES_CLUSTER_VIEW_POD_DETAIL, + ["Project", "Kubernetes", "View Cluster", "Pods", "Pod Detail"], + ), + ...BuildBreadcrumbLinksByTitles(PageMap.KUBERNETES_CLUSTER_VIEW_NODES, [ + "Project", + "Kubernetes", + "View Cluster", + "Nodes", + ]), + ...BuildBreadcrumbLinksByTitles( + PageMap.KUBERNETES_CLUSTER_VIEW_NODE_DETAIL, + ["Project", "Kubernetes", "View Cluster", "Nodes", "Node Detail"], + ), + ...BuildBreadcrumbLinksByTitles(PageMap.KUBERNETES_CLUSTER_VIEW_EVENTS, [ + "Project", + "Kubernetes", + "View Cluster", + "Events", + ]), + ...BuildBreadcrumbLinksByTitles( + PageMap.KUBERNETES_CLUSTER_VIEW_CONTROL_PLANE, + ["Project", "Kubernetes", "View Cluster", "Control Plane"], + ), + ...BuildBreadcrumbLinksByTitles(PageMap.KUBERNETES_CLUSTER_VIEW_DELETE, [ + "Project", + "Kubernetes", + "View Cluster", + "Delete Cluster", + ]), + ...BuildBreadcrumbLinksByTitles(PageMap.KUBERNETES_CLUSTER_VIEW_SETTINGS, [ + "Project", + "Kubernetes", + "View Cluster", + "Settings", + ]), + }; + return breadcrumpLinksMap[path]; +} diff --git a/App/FeatureSet/Dashboard/src/Utils/Breadcrumbs/index.ts b/App/FeatureSet/Dashboard/src/Utils/Breadcrumbs/index.ts index 4d3b9a1074..0be2579617 100644 --- a/App/FeatureSet/Dashboard/src/Utils/Breadcrumbs/index.ts +++ b/App/FeatureSet/Dashboard/src/Utils/Breadcrumbs/index.ts @@ -11,6 +11,7 @@ export * from "./SettingsBreadcrumbs"; export * from "./MonitorGroupBreadcrumbs"; export * from "./ServiceBreadcrumbs"; export * from "./CodeRepositoryBreadcrumbs"; +export * from "./KubernetesBreadcrumbs"; export * from "./DashboardBreadCrumbs"; export * from "./AIAgentTasksBreadcrumbs"; export * from "./ExceptionsBreadcrumbs"; diff --git a/App/FeatureSet/Dashboard/src/Utils/PageMap.ts b/App/FeatureSet/Dashboard/src/Utils/PageMap.ts index 3afefba04b..2acd00ba36 100644 --- a/App/FeatureSet/Dashboard/src/Utils/PageMap.ts +++ b/App/FeatureSet/Dashboard/src/Utils/PageMap.ts @@ -214,6 +214,19 @@ enum PageMap { SERVICE_VIEW_CODE_REPOSITORIES = "SERVICE_VIEW_CODE_REPOSITORIES", SERVICE_DEPENDENCY_GRAPH = "SERVICE_DEPENDENCY_GRAPH", + // Kubernetes (standalone product) + KUBERNETES_ROOT = "KUBERNETES_ROOT", + KUBERNETES_CLUSTERS = "KUBERNETES_CLUSTERS", + KUBERNETES_CLUSTER_VIEW = "KUBERNETES_CLUSTER_VIEW", + KUBERNETES_CLUSTER_VIEW_PODS = "KUBERNETES_CLUSTER_VIEW_PODS", + KUBERNETES_CLUSTER_VIEW_POD_DETAIL = "KUBERNETES_CLUSTER_VIEW_POD_DETAIL", + KUBERNETES_CLUSTER_VIEW_NODES = "KUBERNETES_CLUSTER_VIEW_NODES", + KUBERNETES_CLUSTER_VIEW_NODE_DETAIL = "KUBERNETES_CLUSTER_VIEW_NODE_DETAIL", + KUBERNETES_CLUSTER_VIEW_EVENTS = "KUBERNETES_CLUSTER_VIEW_EVENTS", + KUBERNETES_CLUSTER_VIEW_CONTROL_PLANE = "KUBERNETES_CLUSTER_VIEW_CONTROL_PLANE", + KUBERNETES_CLUSTER_VIEW_DELETE = "KUBERNETES_CLUSTER_VIEW_DELETE", + KUBERNETES_CLUSTER_VIEW_SETTINGS = "KUBERNETES_CLUSTER_VIEW_SETTINGS", + // Code Repository CODE_REPOSITORY_ROOT = "CODE_REPOSITORY_ROOT", CODE_REPOSITORY = "CODE_REPOSITORY", diff --git a/App/FeatureSet/Dashboard/src/Utils/RouteMap.ts b/App/FeatureSet/Dashboard/src/Utils/RouteMap.ts index 17330cf2f9..bf12e8579a 100644 --- a/App/FeatureSet/Dashboard/src/Utils/RouteMap.ts +++ b/App/FeatureSet/Dashboard/src/Utils/RouteMap.ts @@ -59,6 +59,18 @@ export const CodeRepositoryRoutePath: Dictionary = { [PageMap.CODE_REPOSITORY_VIEW_SERVICES]: `${RouteParams.ModelID}/services`, }; +export const KubernetesRoutePath: Dictionary = { + [PageMap.KUBERNETES_CLUSTER_VIEW]: `${RouteParams.ModelID}`, + [PageMap.KUBERNETES_CLUSTER_VIEW_PODS]: `${RouteParams.ModelID}/pods`, + [PageMap.KUBERNETES_CLUSTER_VIEW_POD_DETAIL]: `${RouteParams.ModelID}/pods/${RouteParams.SubModelID}`, + [PageMap.KUBERNETES_CLUSTER_VIEW_NODES]: `${RouteParams.ModelID}/nodes`, + [PageMap.KUBERNETES_CLUSTER_VIEW_NODE_DETAIL]: `${RouteParams.ModelID}/nodes/${RouteParams.SubModelID}`, + [PageMap.KUBERNETES_CLUSTER_VIEW_EVENTS]: `${RouteParams.ModelID}/events`, + [PageMap.KUBERNETES_CLUSTER_VIEW_CONTROL_PLANE]: `${RouteParams.ModelID}/control-plane`, + [PageMap.KUBERNETES_CLUSTER_VIEW_DELETE]: `${RouteParams.ModelID}/delete`, + [PageMap.KUBERNETES_CLUSTER_VIEW_SETTINGS]: `${RouteParams.ModelID}/settings`, +}; + export const WorkflowRoutePath: Dictionary = { [PageMap.WORKFLOWS_LOGS]: "logs", [PageMap.WORKFLOWS_VARIABLES]: "variables", @@ -1465,6 +1477,70 @@ const RouteMap: Dictionary = { }`, ), + // Kubernetes + + [PageMap.KUBERNETES_ROOT]: new Route( + `/dashboard/${RouteParams.ProjectID}/kubernetes/*`, + ), + + [PageMap.KUBERNETES_CLUSTERS]: new Route( + `/dashboard/${RouteParams.ProjectID}/kubernetes`, + ), + + [PageMap.KUBERNETES_CLUSTER_VIEW]: new Route( + `/dashboard/${RouteParams.ProjectID}/kubernetes/${ + KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW] + }`, + ), + + [PageMap.KUBERNETES_CLUSTER_VIEW_PODS]: new Route( + `/dashboard/${RouteParams.ProjectID}/kubernetes/${ + KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_PODS] + }`, + ), + + [PageMap.KUBERNETES_CLUSTER_VIEW_POD_DETAIL]: new Route( + `/dashboard/${RouteParams.ProjectID}/kubernetes/${ + KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_POD_DETAIL] + }`, + ), + + [PageMap.KUBERNETES_CLUSTER_VIEW_NODES]: new Route( + `/dashboard/${RouteParams.ProjectID}/kubernetes/${ + KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_NODES] + }`, + ), + + [PageMap.KUBERNETES_CLUSTER_VIEW_NODE_DETAIL]: new Route( + `/dashboard/${RouteParams.ProjectID}/kubernetes/${ + KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_NODE_DETAIL] + }`, + ), + + [PageMap.KUBERNETES_CLUSTER_VIEW_EVENTS]: new Route( + `/dashboard/${RouteParams.ProjectID}/kubernetes/${ + KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_EVENTS] + }`, + ), + + [PageMap.KUBERNETES_CLUSTER_VIEW_CONTROL_PLANE]: new Route( + `/dashboard/${RouteParams.ProjectID}/kubernetes/${ + KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_CONTROL_PLANE] + }`, + ), + + [PageMap.KUBERNETES_CLUSTER_VIEW_DELETE]: new Route( + `/dashboard/${RouteParams.ProjectID}/kubernetes/${ + KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_DELETE] + }`, + ), + + [PageMap.KUBERNETES_CLUSTER_VIEW_SETTINGS]: new Route( + `/dashboard/${RouteParams.ProjectID}/kubernetes/${ + KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_SETTINGS] + }`, + ), + // Dashboards [PageMap.DASHBOARDS_ROOT]: new Route( diff --git a/Common/Models/DatabaseModels/Index.ts b/Common/Models/DatabaseModels/Index.ts index 53ed9ba87b..11bfd86ec8 100644 --- a/Common/Models/DatabaseModels/Index.ts +++ b/Common/Models/DatabaseModels/Index.ts @@ -1,5 +1,6 @@ import AcmeCertificate from "./AcmeCertificate"; import AcmeChallenge from "./AcmeChallenge"; +import KubernetesCluster from "./KubernetesCluster"; // API Keys import ApiKey from "./ApiKey"; import ApiKeyPermission from "./ApiKeyPermission"; @@ -499,6 +500,8 @@ const AllModelTypes: Array<{ ProjectSCIM, ProjectSCIMLog, StatusPageSCIMLog, + + KubernetesCluster, ]; const modelTypeMap: { [key: string]: { new (): BaseModel } } = {}; diff --git a/Common/Models/DatabaseModels/KubernetesCluster.ts b/Common/Models/DatabaseModels/KubernetesCluster.ts new file mode 100644 index 0000000000..6201ff5e07 --- /dev/null +++ b/Common/Models/DatabaseModels/KubernetesCluster.ts @@ -0,0 +1,640 @@ +import Label from "./Label"; +import Project from "./Project"; +import User from "./User"; +import BaseModel from "./DatabaseBaseModel/DatabaseBaseModel"; +import Route from "../../Types/API/Route"; +import ColumnAccessControl from "../../Types/Database/AccessControl/ColumnAccessControl"; +import TableAccessControl from "../../Types/Database/AccessControl/TableAccessControl"; +import AccessControlColumn from "../../Types/Database/AccessControlColumn"; +import ColumnLength from "../../Types/Database/ColumnLength"; +import ColumnType from "../../Types/Database/ColumnType"; +import CrudApiEndpoint from "../../Types/Database/CrudApiEndpoint"; +import EnableDocumentation from "../../Types/Database/EnableDocumentation"; +import EnableWorkflow from "../../Types/Database/EnableWorkflow"; +import SlugifyColumn from "../../Types/Database/SlugifyColumn"; +import TableColumn from "../../Types/Database/TableColumn"; +import TableColumnType from "../../Types/Database/TableColumnType"; +import TableMetadata from "../../Types/Database/TableMetadata"; +import TenantColumn from "../../Types/Database/TenantColumn"; +import UniqueColumnBy from "../../Types/Database/UniqueColumnBy"; +import IconProp from "../../Types/Icon/IconProp"; +import ObjectID from "../../Types/ObjectID"; +import Permission from "../../Types/Permission"; +import { + Column, + Entity, + Index, + JoinColumn, + JoinTable, + ManyToMany, + ManyToOne, +} from "typeorm"; + +@AccessControlColumn("labels") +@EnableDocumentation() +@TenantColumn("projectId") +@TableAccessControl({ + create: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.CreateKubernetesCluster, + ], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadKubernetesCluster, + Permission.ReadAllProjectResources, + ], + delete: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.DeleteKubernetesCluster, + ], + update: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.EditKubernetesCluster, + ], +}) +@EnableWorkflow({ + create: true, + delete: true, + update: true, + read: true, +}) +@CrudApiEndpoint(new Route("/kubernetes-cluster")) +@SlugifyColumn("name", "slug") +@TableMetadata({ + tableName: "KubernetesCluster", + singularName: "Kubernetes Cluster", + pluralName: "Kubernetes Clusters", + icon: IconProp.Cube, + tableDescription: + "Kubernetes Clusters that are being monitored in this project. Each cluster is auto-discovered when the OneUptime kubernetes-agent sends metrics, or can be manually registered.", +}) +@Entity({ + name: "KubernetesCluster", +}) +export default class KubernetesCluster extends BaseModel { + @ColumnAccessControl({ + create: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.CreateKubernetesCluster, + ], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadKubernetesCluster, + Permission.ReadAllProjectResources, + ], + update: [], + }) + @TableColumn({ + manyToOneRelationColumn: "projectId", + type: TableColumnType.Entity, + modelType: Project, + title: "Project", + description: "Relation to Project Resource in which this object belongs", + }) + @ManyToOne( + () => { + return Project; + }, + { + eager: false, + nullable: true, + onDelete: "CASCADE", + orphanedRowAction: "nullify", + }, + ) + @JoinColumn({ name: "projectId" }) + public project?: Project = undefined; + + @ColumnAccessControl({ + create: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.CreateKubernetesCluster, + ], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadKubernetesCluster, + Permission.ReadAllProjectResources, + ], + update: [], + }) + @Index() + @TableColumn({ + type: TableColumnType.ObjectID, + required: true, + canReadOnRelationQuery: true, + title: "Project ID", + description: "ID of your OneUptime Project in which this object belongs", + }) + @Column({ + type: ColumnType.ObjectID, + nullable: false, + transformer: ObjectID.getDatabaseTransformer(), + }) + public projectId?: ObjectID = undefined; + + @ColumnAccessControl({ + create: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.CreateKubernetesCluster, + ], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadKubernetesCluster, + Permission.ReadAllProjectResources, + ], + update: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.EditKubernetesCluster, + ], + }) + @TableColumn({ + required: true, + type: TableColumnType.ShortText, + canReadOnRelationQuery: true, + title: "Name", + description: "Friendly name for this Kubernetes cluster", + example: "production-us-east", + }) + @Column({ + nullable: false, + type: ColumnType.ShortText, + length: ColumnLength.ShortText, + }) + @UniqueColumnBy("projectId") + public name?: string = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadKubernetesCluster, + Permission.ReadAllProjectResources, + ], + update: [], + }) + @TableColumn({ + required: true, + unique: true, + type: TableColumnType.Slug, + computed: true, + title: "Slug", + description: "Friendly globally unique name for your object", + }) + @Column({ + nullable: false, + type: ColumnType.Slug, + length: ColumnLength.Slug, + }) + public slug?: string = undefined; + + @ColumnAccessControl({ + create: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.CreateKubernetesCluster, + ], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadKubernetesCluster, + Permission.ReadAllProjectResources, + ], + update: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.EditKubernetesCluster, + ], + }) + @TableColumn({ + required: false, + type: TableColumnType.LongText, + canReadOnRelationQuery: true, + title: "Description", + description: "Friendly description for this Kubernetes cluster", + example: "Production cluster running in US East region on EKS", + }) + @Column({ + nullable: true, + type: ColumnType.LongText, + length: ColumnLength.LongText, + }) + public description?: string = undefined; + + @ColumnAccessControl({ + create: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.CreateKubernetesCluster, + ], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadKubernetesCluster, + Permission.ReadAllProjectResources, + ], + update: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.EditKubernetesCluster, + ], + }) + @Index() + @TableColumn({ + required: true, + type: TableColumnType.ShortText, + canReadOnRelationQuery: true, + title: "Cluster Identifier", + description: + "Unique identifier for this cluster, sourced from the k8s.cluster.name OTel resource attribute", + example: "production-us-east-1", + }) + @Column({ + nullable: false, + type: ColumnType.ShortText, + length: ColumnLength.ShortText, + }) + @UniqueColumnBy("projectId") + public clusterIdentifier?: string = undefined; + + @ColumnAccessControl({ + create: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.CreateKubernetesCluster, + ], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadKubernetesCluster, + Permission.ReadAllProjectResources, + ], + update: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.EditKubernetesCluster, + ], + }) + @TableColumn({ + required: false, + type: TableColumnType.ShortText, + canReadOnRelationQuery: true, + title: "Provider", + description: + "Cloud provider or platform running this cluster (EKS, GKE, AKS, self-managed, unknown)", + example: "EKS", + }) + @Column({ + nullable: true, + type: ColumnType.ShortText, + length: ColumnLength.ShortText, + default: "unknown", + }) + public provider?: string = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadKubernetesCluster, + Permission.ReadAllProjectResources, + ], + update: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.EditKubernetesCluster, + ], + }) + @TableColumn({ + required: false, + type: TableColumnType.ShortText, + canReadOnRelationQuery: true, + title: "OTel Collector Status", + description: + "Connection status of the OTel Collector agent (connected or disconnected)", + example: "connected", + }) + @Column({ + nullable: true, + type: ColumnType.ShortText, + length: ColumnLength.ShortText, + default: "disconnected", + }) + public otelCollectorStatus?: string = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadKubernetesCluster, + Permission.ReadAllProjectResources, + ], + update: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.EditKubernetesCluster, + ], + }) + @TableColumn({ + required: false, + type: TableColumnType.Date, + canReadOnRelationQuery: true, + title: "Last Seen At", + description: "When metrics were last received from this cluster", + }) + @Column({ + nullable: true, + type: ColumnType.Date, + }) + public lastSeenAt?: Date = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadKubernetesCluster, + Permission.ReadAllProjectResources, + ], + update: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.EditKubernetesCluster, + ], + }) + @TableColumn({ + type: TableColumnType.Number, + title: "Node Count", + description: "Cached count of nodes in this cluster", + }) + @Column({ + type: ColumnType.Number, + nullable: true, + default: 0, + }) + public nodeCount?: number = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadKubernetesCluster, + Permission.ReadAllProjectResources, + ], + update: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.EditKubernetesCluster, + ], + }) + @TableColumn({ + type: TableColumnType.Number, + title: "Pod Count", + description: "Cached count of pods in this cluster", + }) + @Column({ + type: ColumnType.Number, + nullable: true, + default: 0, + }) + public podCount?: number = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadKubernetesCluster, + Permission.ReadAllProjectResources, + ], + update: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.EditKubernetesCluster, + ], + }) + @TableColumn({ + type: TableColumnType.Number, + title: "Namespace Count", + description: "Cached count of namespaces in this cluster", + }) + @Column({ + type: ColumnType.Number, + nullable: true, + default: 0, + }) + public namespaceCount?: number = undefined; + + @ColumnAccessControl({ + create: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.CreateKubernetesCluster, + ], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadKubernetesCluster, + Permission.ReadAllProjectResources, + ], + update: [], + }) + @TableColumn({ + manyToOneRelationColumn: "createdByUserId", + type: TableColumnType.Entity, + modelType: User, + title: "Created by User", + description: + "Relation to User who created this object (if this object was created by a User)", + }) + @ManyToOne( + () => { + return User; + }, + { + eager: false, + nullable: true, + onDelete: "SET NULL", + orphanedRowAction: "nullify", + }, + ) + @JoinColumn({ name: "createdByUserId" }) + public createdByUser?: User = undefined; + + @ColumnAccessControl({ + create: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.CreateKubernetesCluster, + ], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadKubernetesCluster, + Permission.ReadAllProjectResources, + ], + update: [], + }) + @TableColumn({ + type: TableColumnType.ObjectID, + title: "Created by User ID", + description: + "User ID who created this object (if this object was created by a User)", + }) + @Column({ + type: ColumnType.ObjectID, + nullable: true, + transformer: ObjectID.getDatabaseTransformer(), + }) + public createdByUserId?: ObjectID = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadKubernetesCluster, + Permission.ReadAllProjectResources, + ], + update: [], + }) + @TableColumn({ + manyToOneRelationColumn: "deletedByUserId", + type: TableColumnType.Entity, + title: "Deleted by User", + modelType: User, + description: + "Relation to User who deleted this object (if this object was deleted by a User)", + }) + @ManyToOne( + () => { + return User; + }, + { + cascade: false, + eager: false, + nullable: true, + onDelete: "SET NULL", + orphanedRowAction: "nullify", + }, + ) + @JoinColumn({ name: "deletedByUserId" }) + public deletedByUser?: User = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadKubernetesCluster, + Permission.ReadAllProjectResources, + ], + update: [], + }) + @TableColumn({ + type: TableColumnType.ObjectID, + title: "Deleted by User ID", + description: + "User ID who deleted this object (if this object was deleted by a User)", + }) + @Column({ + type: ColumnType.ObjectID, + nullable: true, + transformer: ObjectID.getDatabaseTransformer(), + }) + public deletedByUserId?: ObjectID = undefined; + + @ColumnAccessControl({ + create: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.CreateKubernetesCluster, + ], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadKubernetesCluster, + Permission.ReadAllProjectResources, + ], + update: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.EditKubernetesCluster, + ], + }) + @TableColumn({ + required: false, + type: TableColumnType.EntityArray, + modelType: Label, + title: "Labels", + description: + "Relation to Labels Array where this object is categorized in.", + }) + @ManyToMany( + () => { + return Label; + }, + { eager: false }, + ) + @JoinTable({ + name: "KubernetesClusterLabel", + inverseJoinColumn: { + name: "labelId", + referencedColumnName: "_id", + }, + joinColumn: { + name: "kubernetesClusterId", + referencedColumnName: "_id", + }, + }) + public labels?: Array