diff --git a/.claude/launch.json b/.claude/launch.json deleted file mode 100644 index e49adee269..0000000000 --- a/.claude/launch.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "version": "0.0.1", - "configurations": [ - { - "name": "mobile-app", - "runtimeExecutable": "bash", - "runtimeArgs": ["-c", "cd MobileApp && npx expo start --port 8081"], - "port": 8081 - }, - { - "name": "dashboard", - "runtimeExecutable": "bash", - "runtimeArgs": ["-c", "cd App/FeatureSet/Dashboard && npm run dev"], - "port": 3002, - "autoPort": false - } - ] -} diff --git a/App/FeatureSet/Dashboard/src/Components/Kubernetes/KubernetesContainersTab.tsx b/App/FeatureSet/Dashboard/src/Components/Kubernetes/KubernetesContainersTab.tsx new file mode 100644 index 0000000000..cea19f3ea8 --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Components/Kubernetes/KubernetesContainersTab.tsx @@ -0,0 +1,276 @@ +import React, { FunctionComponent, ReactElement, useState } from "react"; +import Card from "Common/UI/Components/Card/Card"; +import DictionaryOfStringsViewer from "Common/UI/Components/Dictionary/DictionaryOfStingsViewer"; +import { + KubernetesContainerPort, + KubernetesContainerSpec, + KubernetesContainerStatus, +} from "../../Pages/Kubernetes/Utils/KubernetesObjectParser"; + +export interface ComponentProps { + containers: Array; + initContainers: Array; + containerStatuses?: Array | undefined; + initContainerStatuses?: Array | undefined; +} + +interface ContainerCardProps { + container: KubernetesContainerSpec; + status?: KubernetesContainerStatus | undefined; + isInit: boolean; +} + +const ContainerCard: FunctionComponent = ( + props: ContainerCardProps, +): ReactElement => { + const [showEnv, setShowEnv] = useState(false); + const [showMounts, setShowMounts] = useState(false); + + const envRecord: Record = {}; + for (const env of props.container.env) { + envRecord[env.name] = env.value; + } + + const hasResources: boolean = + Object.keys(props.container.resources.requests).length > 0 || + Object.keys(props.container.resources.limits).length > 0; + + return ( + +
+ {/* Status */} + {props.status && ( +
+
+ State:{" "} + + {props.status.state} + +
+
+ Ready:{" "} + + {props.status.ready ? "Yes" : "No"} + +
+
+ Restarts:{" "} + 0 + ? "text-yellow-700" + : "text-gray-700" + } + > + {props.status.restartCount} + +
+
+ )} + + {/* Command & Args */} + {props.container.command.length > 0 && ( +
+ Command:{" "} + + {props.container.command.join(" ")} + +
+ )} + {props.container.args.length > 0 && ( +
+ Args:{" "} + + {props.container.args.join(" ")} + +
+ )} + + {/* Ports */} + {props.container.ports.length > 0 && ( +
+ Ports:{" "} + {props.container.ports.map( + (port: KubernetesContainerPort, idx: number) => { + return ( + + {port.name ? `${port.name}: ` : ""} + {port.containerPort}/{port.protocol} + + ); + }, + )} +
+ )} + + {/* Resources */} + {hasResources && ( +
+ {Object.keys(props.container.resources.requests).length > 0 && ( +
+ + Requests: + + +
+ )} + {Object.keys(props.container.resources.limits).length > 0 && ( +
+ + Limits: + + +
+ )} +
+ )} + + {/* Environment Variables (expandable) */} + {props.container.env.length > 0 && ( +
+ + {showEnv && ( +
+ +
+ )} +
+ )} + + {/* Volume Mounts (expandable) */} + {props.container.volumeMounts.length > 0 && ( +
+ + {showMounts && ( +
+ {props.container.volumeMounts.map( + ( + mount: { + name: string; + mountPath: string; + readOnly: boolean; + }, + idx: number, + ) => { + return ( +
+ + {mount.name} + + + + {mount.mountPath} + + {mount.readOnly && ( + + (read-only) + + )} +
+ ); + }, + )} +
+ )} +
+ )} +
+
+ ); +}; + +const KubernetesContainersTab: FunctionComponent = ( + props: ComponentProps, +): ReactElement => { + if (props.containers.length === 0 && props.initContainers.length === 0) { + return ( +
+ No container information available. +
+ ); + } + + const getStatus: ( + name: string, + isInit: boolean, + ) => KubernetesContainerStatus | undefined = ( + name: string, + isInit: boolean, + ): KubernetesContainerStatus | undefined => { + const statuses: Array | undefined = isInit + ? props.initContainerStatuses + : props.containerStatuses; + return statuses?.find((s: KubernetesContainerStatus) => { + return s.name === name; + }); + }; + + return ( +
+ {props.initContainers.map( + (container: KubernetesContainerSpec, index: number) => { + return ( + + ); + }, + )} + {props.containers.map( + (container: KubernetesContainerSpec, index: number) => { + return ( + + ); + }, + )} +
+ ); +}; + +export default KubernetesContainersTab; diff --git a/App/FeatureSet/Dashboard/src/Components/Kubernetes/KubernetesEventsTab.tsx b/App/FeatureSet/Dashboard/src/Components/Kubernetes/KubernetesEventsTab.tsx new file mode 100644 index 0000000000..680220ed7d --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Components/Kubernetes/KubernetesEventsTab.tsx @@ -0,0 +1,123 @@ +import React, { + FunctionComponent, + ReactElement, + useEffect, + useState, +} from "react"; +import { + fetchK8sEventsForResource, + KubernetesEvent, +} from "../../Pages/Kubernetes/Utils/KubernetesObjectFetcher"; +import PageLoader from "Common/UI/Components/Loader/PageLoader"; +import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage"; + +export interface ComponentProps { + clusterIdentifier: string; + resourceKind: string; // "Pod", "Node", "Deployment", etc. + resourceName: string; + namespace?: string | undefined; +} + +const KubernetesEventsTab: FunctionComponent = ( + props: ComponentProps, +): ReactElement => { + const [events, setEvents] = useState>([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(""); + + useEffect(() => { + const fetchEvents: () => Promise = async (): Promise => { + setIsLoading(true); + try { + const result: Array = await fetchK8sEventsForResource({ + clusterIdentifier: props.clusterIdentifier, + resourceKind: props.resourceKind, + resourceName: props.resourceName, + namespace: props.namespace, + }); + setEvents(result); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to fetch events"); + } + setIsLoading(false); + }; + + fetchEvents().catch(() => {}); + }, [ + props.clusterIdentifier, + props.resourceKind, + props.resourceName, + props.namespace, + ]); + + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + if (events.length === 0) { + return ( +
+ No events found for this {props.resourceKind.toLowerCase()} in the last + 24 hours. +
+ ); + } + + return ( +
+ + + + + + + + + + + {events.map((event: KubernetesEvent, index: number) => { + const isWarning: boolean = event.type.toLowerCase() === "warning"; + return ( + + + + + + + ); + })} + +
+ Time + + Type + + Reason + + Message +
+ {event.timestamp} + + + {event.type} + + + {event.reason} + + {event.message} +
+
+ ); +}; + +export default KubernetesEventsTab; diff --git a/App/FeatureSet/Dashboard/src/Components/Kubernetes/KubernetesLogsTab.tsx b/App/FeatureSet/Dashboard/src/Components/Kubernetes/KubernetesLogsTab.tsx new file mode 100644 index 0000000000..c677540c98 --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Components/Kubernetes/KubernetesLogsTab.tsx @@ -0,0 +1,116 @@ +import React, { + FunctionComponent, + ReactElement, + useEffect, + useState, +} from "react"; +import { + fetchPodLogs, + KubernetesLogEntry, +} from "../../Pages/Kubernetes/Utils/KubernetesObjectFetcher"; +import PageLoader from "Common/UI/Components/Loader/PageLoader"; +import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage"; + +export interface ComponentProps { + clusterIdentifier: string; + podName: string; + containerName?: string | undefined; + namespace?: string | undefined; +} + +const KubernetesLogsTab: FunctionComponent = ( + props: ComponentProps, +): ReactElement => { + const [logs, setLogs] = useState>([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(""); + + useEffect(() => { + const fetchLogs: () => Promise = async (): Promise => { + setIsLoading(true); + try { + const result: Array = await fetchPodLogs({ + clusterIdentifier: props.clusterIdentifier, + podName: props.podName, + containerName: props.containerName, + namespace: props.namespace, + limit: 500, + }); + setLogs(result); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to fetch logs"); + } + setIsLoading(false); + }; + + fetchLogs().catch(() => {}); + }, [ + props.clusterIdentifier, + props.podName, + props.containerName, + props.namespace, + ]); + + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + if (logs.length === 0) { + return ( +
+ No application logs found for this pod in the last 6 hours. Logs will + appear here once the kubernetes-agent's filelog receiver is + collecting data. +
+ ); + } + + const getSeverityColor: (severity: string) => string = ( + severity: string, + ): string => { + const s: string = severity.toUpperCase(); + if (s === "ERROR" || s === "FATAL" || s === "CRITICAL") { + return "text-red-600"; + } + if (s === "WARN" || s === "WARNING") { + return "text-yellow-600"; + } + if (s === "DEBUG" || s === "TRACE") { + return "text-gray-400"; + } + return "text-gray-700"; + }; + + return ( +
+ {logs.map((log: KubernetesLogEntry, index: number) => { + return ( +
+ + {log.timestamp} + + {log.containerName && ( + + [{log.containerName}] + + )} + + {log.severity} + + + {log.body} + +
+ ); + })} +
+ ); +}; + +export default KubernetesLogsTab; diff --git a/App/FeatureSet/Dashboard/src/Components/Kubernetes/KubernetesMetricsTab.tsx b/App/FeatureSet/Dashboard/src/Components/Kubernetes/KubernetesMetricsTab.tsx new file mode 100644 index 0000000000..d1ef5073d4 --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Components/Kubernetes/KubernetesMetricsTab.tsx @@ -0,0 +1,43 @@ +import React, { FunctionComponent, ReactElement, useState } from "react"; +import MetricView from "../../Components/Metrics/MetricView"; +import MetricViewData from "Common/Types/Metrics/MetricViewData"; +import MetricQueryConfigData from "Common/Types/Metrics/MetricQueryConfigData"; +import OneUptimeDate from "Common/Types/Date"; +import InBetween from "Common/Types/BaseDatabase/InBetween"; + +export interface ComponentProps { + queryConfigs: Array; +} + +const KubernetesMetricsTab: FunctionComponent = ( + props: ComponentProps, +): ReactElement => { + const endDate: Date = OneUptimeDate.getCurrentDate(); + const startDate: Date = OneUptimeDate.addRemoveHours(endDate, -6); + const startAndEndDate: InBetween = new InBetween(startDate, endDate); + + const [metricViewData, setMetricViewData] = useState({ + startAndEndDate: startAndEndDate, + queryConfigs: [], + formulaConfigs: [], + }); + + return ( + { + setMetricViewData({ + ...data, + queryConfigs: props.queryConfigs, + formulaConfigs: [], + }); + }} + /> + ); +}; + +export default KubernetesMetricsTab; diff --git a/App/FeatureSet/Dashboard/src/Components/Kubernetes/KubernetesOverviewTab.tsx b/App/FeatureSet/Dashboard/src/Components/Kubernetes/KubernetesOverviewTab.tsx new file mode 100644 index 0000000000..3dc3eff1ce --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Components/Kubernetes/KubernetesOverviewTab.tsx @@ -0,0 +1,168 @@ +import React, { FunctionComponent, ReactElement } from "react"; +import Card from "Common/UI/Components/Card/Card"; +import InfoCard from "Common/UI/Components/InfoCard/InfoCard"; +import DictionaryOfStringsViewer from "Common/UI/Components/Dictionary/DictionaryOfStingsViewer"; +import { KubernetesCondition } from "../../Pages/Kubernetes/Utils/KubernetesObjectParser"; +import PageLoader from "Common/UI/Components/Loader/PageLoader"; + +export interface SummaryField { + title: string; + value: string | ReactElement; +} + +export interface ComponentProps { + summaryFields: Array; + labels: Record; + annotations: Record; + conditions?: Array | undefined; + ownerReferences?: Array<{ kind: string; name: string }> | undefined; + isLoading: boolean; + emptyMessage?: string | undefined; +} + +const KubernetesOverviewTab: FunctionComponent = ( + props: ComponentProps, +): ReactElement => { + if (props.isLoading) { + return ; + } + + if ( + props.summaryFields.length === 0 && + Object.keys(props.labels).length === 0 + ) { + return ( +
+ {props.emptyMessage || + "Resource details not yet available. Ensure the kubernetes-agent Helm chart has resourceSpecs.enabled set to true and wait for the next data pull (up to 5 minutes)."} +
+ ); + } + + return ( +
+ {/* Summary Info Cards */} + {props.summaryFields.length > 0 && ( +
+ {props.summaryFields.map((field: SummaryField, index: number) => { + return ( + + ); + })} +
+ )} + + {/* Owner References */} + {props.ownerReferences && props.ownerReferences.length > 0 && ( + +
+ {props.ownerReferences.map( + (ref: { kind: string; name: string }, index: number) => { + return ( +
+ + {ref.kind}: + {" "} + {ref.name} +
+ ); + }, + )} +
+
+ )} + + {/* Conditions */} + {props.conditions && props.conditions.length > 0 && ( + +
+ + + + + + + + + + + + {props.conditions.map( + (condition: KubernetesCondition, index: number) => { + return ( + + + + + + + + ); + }, + )} + +
+ Type + + Status + + Reason + + Message + + Last Transition +
+ {condition.type} + + + {condition.status} + + + {condition.reason || "-"} + + {condition.message || "-"} + + {condition.lastTransitionTime || "-"} +
+
+
+ )} + + {/* Labels */} + {Object.keys(props.labels).length > 0 && ( + + + + )} + + {/* Annotations */} + {Object.keys(props.annotations).length > 0 && ( + + + + )} +
+ ); +}; + +export default KubernetesOverviewTab; diff --git a/App/FeatureSet/Dashboard/src/Components/Kubernetes/KubernetesResourceTable.tsx b/App/FeatureSet/Dashboard/src/Components/Kubernetes/KubernetesResourceTable.tsx new file mode 100644 index 0000000000..06f13926fd --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Components/Kubernetes/KubernetesResourceTable.tsx @@ -0,0 +1,167 @@ +import React, { FunctionComponent, ReactElement } from "react"; +import KubernetesResourceUtils, { + KubernetesResource, +} from "../../Pages/Kubernetes/Utils/KubernetesResourceUtils"; +import Card from "Common/UI/Components/Card/Card"; +import Table from "Common/UI/Components/Table/Table"; +import FieldType from "Common/UI/Components/Types/FieldType"; +import Link from "Common/UI/Components/Link/Link"; +import SortOrder from "Common/Types/BaseDatabase/SortOrder"; +import Route from "Common/Types/API/Route"; +import Column from "Common/UI/Components/Table/Types/Column"; + +export interface ResourceColumn { + title: string; + key: string; + getValue?: (resource: KubernetesResource) => string; +} + +export interface ComponentProps { + resources: Array; + title: string; + description: string; + columns?: Array; + showNamespace?: boolean; + getViewRoute?: (resource: KubernetesResource) => Route; + emptyMessage?: string; +} + +const KubernetesResourceTable: FunctionComponent = ( + props: ComponentProps, +): ReactElement => { + const showNamespace: boolean = props.showNamespace !== false; + + const tableColumns: Array> = [ + { + title: "Name", + type: FieldType.Element, + key: "name", + disableSort: true, + getElement: (resource: KubernetesResource): ReactElement => { + return ( + {resource.name} + ); + }, + }, + ]; + + if (showNamespace) { + tableColumns.push({ + title: "Namespace", + type: FieldType.Element, + key: "namespace", + disableSort: true, + getElement: (resource: KubernetesResource): ReactElement => { + return ( + + {resource.namespace || "default"} + + ); + }, + }); + } + + if (props.columns) { + for (const col of props.columns) { + tableColumns.push({ + title: col.title, + type: FieldType.Element, + key: col.key as keyof KubernetesResource, + disableSort: true, + getElement: (resource: KubernetesResource): ReactElement => { + const value: string = col.getValue + ? col.getValue(resource) + : resource.additionalAttributes[col.key] || ""; + return {value}; + }, + }); + } + } + + tableColumns.push( + { + title: "CPU", + type: FieldType.Element, + key: "cpuUtilization", + disableSort: true, + getElement: (resource: KubernetesResource): ReactElement => { + return ( + 80 + ? "bg-red-50 text-red-700" + : resource.cpuUtilization !== null && + resource.cpuUtilization > 60 + ? "bg-yellow-50 text-yellow-700" + : "bg-green-50 text-green-700" + }`} + > + {KubernetesResourceUtils.formatCpuValue(resource.cpuUtilization)} + + ); + }, + }, + { + title: "Memory", + type: FieldType.Element, + key: "memoryUsageBytes", + disableSort: true, + getElement: (resource: KubernetesResource): ReactElement => { + return ( + + {KubernetesResourceUtils.formatMemoryValue( + resource.memoryUsageBytes, + )} + + ); + }, + }, + ); + + if (props.getViewRoute) { + tableColumns.push({ + title: "Actions", + type: FieldType.Element, + key: "name", + disableSort: true, + getElement: (resource: KubernetesResource): ReactElement => { + return ( + + View + + ); + }, + }); + } + + return ( + + + id={`kubernetes-${props.title.toLowerCase().replace(/\s+/g, "-")}-table`} + columns={tableColumns} + data={props.resources} + singularLabel={props.title} + pluralLabel={props.title} + isLoading={false} + error="" + disablePagination={true} + currentPageNumber={1} + totalItemsCount={props.resources.length} + itemsOnPage={props.resources.length} + onNavigateToPage={() => {}} + sortBy={null} + sortOrder={SortOrder.Ascending} + onSortChanged={() => {}} + noItemsMessage={ + props.emptyMessage || + "No resources found. Resources will appear here once the kubernetes-agent is sending data." + } + /> + + ); +}; + +export default KubernetesResourceTable; diff --git a/App/FeatureSet/Dashboard/src/Components/Logs/LogsViewer.tsx b/App/FeatureSet/Dashboard/src/Components/Logs/LogsViewer.tsx index 7a1b7a1438..a5ae442ff4 100644 --- a/App/FeatureSet/Dashboard/src/Components/Logs/LogsViewer.tsx +++ b/App/FeatureSet/Dashboard/src/Components/Logs/LogsViewer.tsx @@ -413,10 +413,32 @@ const DashboardLogsViewer: FunctionComponent = ( } try { + /* + * When live polling, recompute the time range so the query window + * slides forward to "now" and new logs become visible. + */ + let query: Query = filterOptions; + + if ( + skipLoadingState && + isLiveEnabled && + timeRange.range !== TimeRange.CUSTOM + ) { + const freshRange: InBetween = + RangeStartAndEndDateTimeUtil.getStartAndEndDate(timeRange); + query = { + ...filterOptions, + time: new InBetween( + freshRange.startValue, + freshRange.endValue, + ), + }; + } + const listResult: ListResult = await AnalyticsModelAPI.getList({ modelType: Log, - query: filterOptions, + query: query, limit: pageSize, skip: (page - 1) * pageSize, select: select, @@ -452,7 +474,16 @@ const DashboardLogsViewer: FunctionComponent = ( } } }, - [filterOptions, page, pageSize, select, sortField, sortOrder], + [ + filterOptions, + isLiveEnabled, + page, + pageSize, + select, + sortField, + sortOrder, + timeRange, + ], ); // --- Fetch histogram --- diff --git a/App/FeatureSet/Dashboard/src/Components/NavBar/NavBar.tsx b/App/FeatureSet/Dashboard/src/Components/NavBar/NavBar.tsx index 0079edc4ed..666a1b07d4 100644 --- a/App/FeatureSet/Dashboard/src/Components/NavBar/NavBar.tsx +++ b/App/FeatureSet/Dashboard/src/Components/NavBar/NavBar.tsx @@ -144,17 +144,17 @@ const DashboardNavbar: FunctionComponent = ( 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.Kubernetes, - // iconColor: "blue", - // category: "Observability", - // }, + { + title: "Kubernetes", + description: "Monitor Kubernetes clusters.", + route: RouteUtil.populateRouteParams( + RouteMap[PageMap.KUBERNETES_CLUSTERS] as Route, + ), + activeRoute: RouteMap[PageMap.KUBERNETES_CLUSTERS], + icon: IconProp.Kubernetes, + iconColor: "blue", + category: "Observability", + }, // Automation & Analytics { title: "Dashboards", diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/Utils/KubernetesObjectFetcher.ts b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/Utils/KubernetesObjectFetcher.ts new file mode 100644 index 0000000000..d4665cb566 --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/Utils/KubernetesObjectFetcher.ts @@ -0,0 +1,408 @@ +import Log from "Common/Models/AnalyticsModels/Log"; +import AnalyticsModelAPI, { + ListResult, +} from "Common/UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI"; +import ProjectUtil from "Common/UI/Utils/Project"; +import OneUptimeDate from "Common/Types/Date"; +import SortOrder from "Common/Types/BaseDatabase/SortOrder"; +import InBetween from "Common/Types/BaseDatabase/InBetween"; +import { JSONObject } from "Common/Types/JSON"; +import { + extractObjectFromLogBody, + getKvStringValue, + getKvValue, + KubernetesPodObject, + KubernetesNodeObject, + KubernetesDeploymentObject, + KubernetesStatefulSetObject, + KubernetesDaemonSetObject, + KubernetesJobObject, + KubernetesCronJobObject, + KubernetesNamespaceObject, + parsePodObject, + parseNodeObject, + parseDeploymentObject, + parseStatefulSetObject, + parseDaemonSetObject, + parseJobObject, + parseCronJobObject, + parseNamespaceObject, +} from "./KubernetesObjectParser"; + +export type KubernetesObjectType = + | KubernetesPodObject + | KubernetesNodeObject + | KubernetesDeploymentObject + | KubernetesStatefulSetObject + | KubernetesDaemonSetObject + | KubernetesJobObject + | KubernetesCronJobObject + | KubernetesNamespaceObject; + +export interface FetchK8sObjectOptions { + clusterIdentifier: string; + resourceType: string; // "pods", "nodes", "deployments", etc. + resourceName: string; + namespace?: string | undefined; // Not needed for cluster-scoped resources (nodes, namespaces) +} + +type ParserFunction = (kvList: JSONObject) => KubernetesObjectType | null; + +function getParser(resourceType: string): ParserFunction | null { + const parsers: Record = { + pods: parsePodObject, + nodes: parseNodeObject, + deployments: parseDeploymentObject, + statefulsets: parseStatefulSetObject, + daemonsets: parseDaemonSetObject, + jobs: parseJobObject, + cronjobs: parseCronJobObject, + namespaces: parseNamespaceObject, + }; + return parsers[resourceType] || null; +} + +/** + * Fetch the latest K8s resource object from the Log table. + * The k8sobjects pull mode stores full K8s API objects as log entries. + */ +export async function fetchLatestK8sObject( + options: FetchK8sObjectOptions, +): Promise { + const parser: ParserFunction | null = getParser(options.resourceType); + if (!parser) { + return null; + } + + const projectId: string | undefined = + ProjectUtil.getCurrentProjectId()?.toString(); + if (!projectId) { + return null; + } + + const endDate: Date = OneUptimeDate.getCurrentDate(); + const startDate: Date = OneUptimeDate.addRemoveHours(endDate, -24); + + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const queryOptions: any = { + modelType: Log, + query: { + projectId: projectId, + time: new InBetween(startDate, endDate), + attributes: { + "logAttributes.event.domain": "k8s", + "logAttributes.k8s.resource.name": options.resourceType, + }, + }, + limit: 500, // Get enough logs to find the resource + skip: 0, + select: { + time: true, + body: true, + attributes: true, + }, + sort: { + time: SortOrder.Descending, + }, + requestOptions: {}, + }; + const listResult: ListResult = + await AnalyticsModelAPI.getList(queryOptions); + + // Parse each log body and find the matching resource + for (const log of listResult.data) { + const attrs: JSONObject = log.attributes || {}; + + // Filter to this cluster + if ( + attrs["resource.k8s.cluster.name"] !== options.clusterIdentifier && + attrs["k8s.cluster.name"] !== options.clusterIdentifier + ) { + continue; + } + + if (typeof log.body !== "string") { + continue; + } + + const objectKvList: JSONObject | null = extractObjectFromLogBody( + log.body, + ); + if (!objectKvList) { + continue; + } + + // Check if this is the resource we're looking for + const metadataKv: string | JSONObject | null = getKvValue( + objectKvList, + "metadata", + ); + if (!metadataKv || typeof metadataKv === "string") { + continue; + } + + const name: string = getKvStringValue(metadataKv, "name"); + const namespace: string = getKvStringValue(metadataKv, "namespace"); + + if (name !== options.resourceName) { + continue; + } + + // For namespaced resources, also match namespace + if (options.namespace && namespace && namespace !== options.namespace) { + continue; + } + + const parsed: KubernetesObjectType | null = parser(objectKvList); + if (parsed) { + return parsed as T; + } + } + + return null; + } catch { + return null; + } +} + +/** + * Fetch K8s events related to a specific resource. + */ +export interface KubernetesEvent { + timestamp: string; + type: string; + reason: string; + objectKind: string; + objectName: string; + namespace: string; + message: string; +} + +export async function fetchK8sEventsForResource(options: { + clusterIdentifier: string; + resourceKind: string; // "Pod", "Node", "Deployment", etc. + resourceName: string; + namespace?: string | undefined; +}): Promise> { + const projectId: string | undefined = + ProjectUtil.getCurrentProjectId()?.toString(); + if (!projectId) { + return []; + } + + const endDate: Date = OneUptimeDate.getCurrentDate(); + const startDate: Date = OneUptimeDate.addRemoveHours(endDate, -24); + + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const eventsQueryOptions: any = { + modelType: Log, + query: { + projectId: projectId, + time: new InBetween(startDate, endDate), + attributes: { + "logAttributes.event.domain": "k8s", + "logAttributes.k8s.resource.name": "events", + }, + }, + limit: 500, + skip: 0, + select: { + time: true, + body: true, + attributes: true, + }, + sort: { + time: SortOrder.Descending, + }, + requestOptions: {}, + }; + const listResult: ListResult = + await AnalyticsModelAPI.getList(eventsQueryOptions); + + const events: Array = []; + + for (const log of listResult.data) { + const attrs: JSONObject = log.attributes || {}; + + // Filter to this cluster + if ( + attrs["resource.k8s.cluster.name"] !== options.clusterIdentifier && + attrs["k8s.cluster.name"] !== options.clusterIdentifier + ) { + continue; + } + + if (typeof log.body !== "string") { + continue; + } + + let bodyObj: JSONObject | null = null; + try { + bodyObj = JSON.parse(log.body) as JSONObject; + } catch { + continue; + } + + const topKvList: JSONObject | undefined = bodyObj["kvlistValue"] as + | JSONObject + | undefined; + if (!topKvList) { + continue; + } + + // Get the "object" which is the actual k8s Event + const objectVal: string | JSONObject | null = getKvValue( + topKvList, + "object", + ); + if (!objectVal || typeof objectVal === "string") { + continue; + } + const objectKvList: JSONObject = objectVal; + + // Get event details + const eventType: string = getKvStringValue(objectKvList, "type") || ""; + const reason: string = getKvStringValue(objectKvList, "reason") || ""; + const note: string = getKvStringValue(objectKvList, "note") || ""; + + // Get regarding object + const regardingKind: string = + getKvStringValue( + getKvValue(objectKvList, "regarding") as JSONObject | undefined, + "kind", + ) || ""; + const regardingName: string = + getKvStringValue( + getKvValue(objectKvList, "regarding") as JSONObject | undefined, + "name", + ) || ""; + const regardingNamespace: string = + getKvStringValue( + getKvValue(objectKvList, "regarding") as JSONObject | undefined, + "namespace", + ) || ""; + + // Filter to events for this specific resource + if ( + regardingKind.toLowerCase() !== options.resourceKind.toLowerCase() || + regardingName !== options.resourceName + ) { + continue; + } + + if ( + options.namespace && + regardingNamespace && + regardingNamespace !== options.namespace + ) { + continue; + } + + events.push({ + timestamp: log.time + ? OneUptimeDate.getDateAsLocalFormattedString(log.time) + : "", + type: eventType || "Unknown", + reason: reason || "Unknown", + objectKind: regardingKind || "Unknown", + objectName: regardingName || "Unknown", + namespace: regardingNamespace || "default", + message: note || "", + }); + } + + return events; + } catch { + return []; + } +} + +/** + * Fetch application logs for a pod/container from the Log table. + * These come from the filelog receiver (not k8sobjects). + */ +export interface KubernetesLogEntry { + timestamp: string; + body: string; + severity: string; + containerName: string; +} + +export async function fetchPodLogs(options: { + clusterIdentifier: string; + podName: string; + containerName?: string | undefined; + namespace?: string | undefined; + limit?: number | undefined; +}): Promise> { + const projectId: string | undefined = + ProjectUtil.getCurrentProjectId()?.toString(); + if (!projectId) { + return []; + } + + const endDate: Date = OneUptimeDate.getCurrentDate(); + const startDate: Date = OneUptimeDate.addRemoveHours(endDate, -6); + + // Build attribute filters for filelog data + const attributeFilters: Record = { + "resource.k8s.cluster.name": options.clusterIdentifier, + "resource.k8s.pod.name": options.podName, + }; + + if (options.containerName) { + attributeFilters["resource.k8s.container.name"] = options.containerName; + } + + if (options.namespace) { + attributeFilters["resource.k8s.namespace.name"] = options.namespace; + } + + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const logsQueryOptions: any = { + modelType: Log, + query: { + projectId: projectId, + time: new InBetween(startDate, endDate), + attributes: attributeFilters, + }, + limit: options.limit || 200, + skip: 0, + select: { + time: true, + body: true, + severityText: true, + attributes: true, + }, + sort: { + time: SortOrder.Descending, + }, + requestOptions: {}, + }; + const listResult: ListResult = + await AnalyticsModelAPI.getList(logsQueryOptions); + + return listResult.data + .filter((log: Log) => { + // Exclude k8s event logs — only application logs + const attrs: JSONObject = log.attributes || {}; + return attrs["logAttributes.event.domain"] !== "k8s"; + }) + .map((log: Log) => { + const attrs: JSONObject = log.attributes || {}; + return { + timestamp: log.time + ? OneUptimeDate.getDateAsLocalFormattedString(log.time) + : "", + body: typeof log.body === "string" ? log.body : "", + severity: log.severityText || "INFO", + containerName: (attrs["resource.k8s.container.name"] as string) || "", + }; + }); + } catch { + return []; + } +} diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/Utils/KubernetesObjectParser.ts b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/Utils/KubernetesObjectParser.ts new file mode 100644 index 0000000000..42d8e52b17 --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/Utils/KubernetesObjectParser.ts @@ -0,0 +1,1304 @@ +import { JSONObject } from "Common/Types/JSON"; + +/* + * ============================================================ + * OTLP kvlistValue parsing helpers + * ============================================================ + */ + +/** + * Extract a value from an OTLP kvlistValue by key. + * Returns the string/int value or nested kvlistValue as JSONObject. + */ +export function getKvValue( + kvList: JSONObject | undefined, + key: string, +): string | JSONObject | null { + if (!kvList) { + return null; + } + const values: Array | undefined = kvList["values"] as + | Array + | undefined; + if (!values) { + return null; + } + for (const entry of values) { + if (entry["key"] === key) { + const val: JSONObject | undefined = entry["value"] as + | JSONObject + | undefined; + if (!val) { + return null; + } + if (val["stringValue"] !== undefined) { + return val["stringValue"] as string; + } + if (val["intValue"] !== undefined) { + return String(val["intValue"]); + } + if (val["boolValue"] !== undefined) { + return String(val["boolValue"]); + } + if (val["kvlistValue"]) { + return val["kvlistValue"] as JSONObject; + } + if (val["arrayValue"]) { + return val["arrayValue"] as JSONObject; + } + return null; + } + } + return null; +} + +/** + * Extract a string value from an OTLP kvlistValue by key. + */ +export function getKvStringValue( + kvList: JSONObject | undefined, + key: string, +): string { + const val: string | JSONObject | null = getKvValue(kvList, key); + if (typeof val === "string") { + return val; + } + return ""; +} + +/** + * Extract a nested kvlist value (parent → child). + */ +export function getNestedKvValue( + kvList: JSONObject | undefined, + parentKey: string, + childKey: string, +): string { + const parent: string | JSONObject | null = getKvValue(kvList, parentKey); + if (!parent || typeof parent === "string") { + return ""; + } + return getKvStringValue(parent, childKey); +} + +/** + * Convert a kvlistValue to a flat Record (for labels, annotations, env). + */ +export function getKvListAsRecord( + kvList: JSONObject | undefined, +): Record { + const result: Record = {}; + if (!kvList) { + return result; + } + const values: Array | undefined = kvList["values"] as + | Array + | undefined; + if (!values) { + return result; + } + for (const entry of values) { + const key: string = (entry["key"] as string) || ""; + const val: JSONObject | undefined = entry["value"] as + | JSONObject + | undefined; + if (key && val) { + if (val["stringValue"] !== undefined) { + result[key] = val["stringValue"] as string; + } else if (val["intValue"] !== undefined) { + result[key] = String(val["intValue"]); + } else if (val["boolValue"] !== undefined) { + result[key] = String(val["boolValue"]); + } + } + } + return result; +} + +/** + * Convert an OTLP arrayValue to an array of JSONObjects (kvlistValues). + */ +export function getArrayValues( + arrayValue: JSONObject | undefined, +): Array { + if (!arrayValue) { + return []; + } + const values: Array | undefined = arrayValue["values"] as + | Array + | undefined; + if (!values) { + return []; + } + return values + .map((item: JSONObject) => { + if (item["kvlistValue"]) { + return item["kvlistValue"] as JSONObject; + } + if (item["stringValue"]) { + return item as JSONObject; + } + return null; + }) + .filter(Boolean) as Array; +} + +/* + * ============================================================ + * TypeScript interfaces for parsed K8s objects + * ============================================================ + */ + +export interface KubernetesObjectMetadata { + name: string; + namespace: string; + uid: string; + creationTimestamp: string; + labels: Record; + annotations: Record; + ownerReferences: Array<{ + kind: string; + name: string; + uid: string; + }>; +} + +export interface KubernetesContainerEnvVar { + name: string; + value: string; // Direct value, or description like "" +} + +export interface KubernetesContainerPort { + name: string; + containerPort: number; + protocol: string; +} + +export interface KubernetesContainerSpec { + name: string; + image: string; + command: Array; + args: Array; + env: Array; + ports: Array; + resources: { + requests: Record; + limits: Record; + }; + volumeMounts: Array<{ + name: string; + mountPath: string; + readOnly: boolean; + }>; +} + +export interface KubernetesCondition { + type: string; + status: string; + reason: string; + message: string; + lastTransitionTime: string; +} + +export interface KubernetesContainerStatus { + name: string; + ready: boolean; + restartCount: number; + state: string; + image: string; +} + +export interface KubernetesPodObject { + metadata: KubernetesObjectMetadata; + spec: { + containers: Array; + initContainers: Array; + serviceAccountName: string; + nodeName: string; + nodeSelector: Record; + tolerations: Array<{ + key: string; + operator: string; + value: string; + effect: string; + }>; + volumes: Array<{ + name: string; + type: string; + source: string; + }>; + }; + status: { + phase: string; + podIP: string; + hostIP: string; + conditions: Array; + containerStatuses: Array; + initContainerStatuses: Array; + }; +} + +export interface KubernetesNodeObject { + metadata: KubernetesObjectMetadata; + status: { + conditions: Array; + nodeInfo: { + osImage: string; + kernelVersion: string; + containerRuntimeVersion: string; + kubeletVersion: string; + architecture: string; + operatingSystem: string; + }; + allocatable: Record; + capacity: Record; + addresses: Array<{ type: string; address: string }>; + }; +} + +export interface KubernetesDeploymentObject { + metadata: KubernetesObjectMetadata; + spec: { + replicas: number; + strategy: string; + selector: Record; + }; + status: { + replicas: number; + readyReplicas: number; + availableReplicas: number; + unavailableReplicas: number; + conditions: Array; + }; +} + +export interface KubernetesStatefulSetObject { + metadata: KubernetesObjectMetadata; + spec: { + replicas: number; + serviceName: string; + podManagementPolicy: string; + updateStrategy: string; + }; + status: { + replicas: number; + readyReplicas: number; + currentReplicas: number; + }; +} + +export interface KubernetesDaemonSetObject { + metadata: KubernetesObjectMetadata; + spec: { + updateStrategy: string; + }; + status: { + desiredNumberScheduled: number; + currentNumberScheduled: number; + numberReady: number; + numberMisscheduled: number; + numberAvailable: number; + }; +} + +export interface KubernetesJobObject { + metadata: KubernetesObjectMetadata; + spec: { + completions: number; + parallelism: number; + backoffLimit: number; + }; + status: { + active: number; + succeeded: number; + failed: number; + startTime: string; + completionTime: string; + conditions: Array; + }; +} + +export interface KubernetesCronJobObject { + metadata: KubernetesObjectMetadata; + spec: { + schedule: string; + suspend: boolean; + concurrencyPolicy: string; + successfulJobsHistoryLimit: number; + failedJobsHistoryLimit: number; + }; + status: { + lastScheduleTime: string; + activeCount: number; + }; +} + +export interface KubernetesNamespaceObject { + metadata: KubernetesObjectMetadata; + status: { + phase: string; + }; +} + +/* + * ============================================================ + * Parsers + * ============================================================ + */ + +function parseMetadata(kvList: JSONObject): KubernetesObjectMetadata { + const labelsKvList: string | JSONObject | null = getKvValue(kvList, "labels"); + const annotationsKvList: string | JSONObject | null = getKvValue( + kvList, + "annotations", + ); + const ownerRefsArrayValue: string | JSONObject | null = getKvValue( + kvList, + "ownerReferences", + ); + + const ownerReferences: Array<{ + kind: string; + name: string; + uid: string; + }> = []; + if (ownerRefsArrayValue && typeof ownerRefsArrayValue !== "string") { + const refs: Array = getArrayValues(ownerRefsArrayValue); + for (const ref of refs) { + ownerReferences.push({ + kind: getKvStringValue(ref, "kind"), + name: getKvStringValue(ref, "name"), + uid: getKvStringValue(ref, "uid"), + }); + } + } + + return { + name: getKvStringValue(kvList, "name"), + namespace: getKvStringValue(kvList, "namespace"), + uid: getKvStringValue(kvList, "uid"), + creationTimestamp: getKvStringValue(kvList, "creationTimestamp"), + labels: + labelsKvList && typeof labelsKvList !== "string" + ? getKvListAsRecord(labelsKvList) + : {}, + annotations: + annotationsKvList && typeof annotationsKvList !== "string" + ? getKvListAsRecord(annotationsKvList) + : {}, + ownerReferences, + }; +} + +function parseContainerEnv( + envArrayValue: JSONObject | null, +): Array { + if (!envArrayValue || typeof envArrayValue === "string") { + return []; + } + const envItems: Array = getArrayValues(envArrayValue); + const result: Array = []; + + for (const envKvList of envItems) { + const name: string = getKvStringValue(envKvList, "name"); + const directValue: string = getKvStringValue(envKvList, "value"); + + if (directValue) { + result.push({ name, value: directValue }); + } else { + // Check for valueFrom (secretKeyRef, configMapKeyRef, fieldRef) + const valueFrom: string | JSONObject | null = getKvValue( + envKvList, + "valueFrom", + ); + if (valueFrom && typeof valueFrom !== "string") { + const secretRef: string | JSONObject | null = getKvValue( + valueFrom, + "secretKeyRef", + ); + const configMapRef: string | JSONObject | null = getKvValue( + valueFrom, + "configMapKeyRef", + ); + const fieldRef: string | JSONObject | null = getKvValue( + valueFrom, + "fieldRef", + ); + + if (secretRef && typeof secretRef !== "string") { + const secretName: string = getKvStringValue(secretRef, "name"); + const secretKey: string = getKvStringValue(secretRef, "key"); + result.push({ + name, + value: ``, + }); + } else if (configMapRef && typeof configMapRef !== "string") { + const cmName: string = getKvStringValue(configMapRef, "name"); + const cmKey: string = getKvStringValue(configMapRef, "key"); + result.push({ + name, + value: ``, + }); + } else if (fieldRef && typeof fieldRef !== "string") { + const fieldPath: string = getKvStringValue(fieldRef, "fieldPath"); + result.push({ name, value: `` }); + } else { + result.push({ name, value: "" }); + } + } else { + result.push({ name, value: directValue || "" }); + } + } + } + return result; +} + +function parseContainerSpec(kvList: JSONObject): KubernetesContainerSpec { + const portsArrayValue: string | JSONObject | null = getKvValue( + kvList, + "ports", + ); + const ports: Array = []; + if (portsArrayValue && typeof portsArrayValue !== "string") { + const portItems: Array = getArrayValues(portsArrayValue); + for (const portKv of portItems) { + ports.push({ + name: getKvStringValue(portKv, "name"), + containerPort: parseInt(getKvStringValue(portKv, "containerPort")) || 0, + protocol: getKvStringValue(portKv, "protocol") || "TCP", + }); + } + } + + const envArrayValue: string | JSONObject | null = getKvValue(kvList, "env"); + const env: Array = parseContainerEnv( + envArrayValue as JSONObject | null, + ); + + const volumeMountsArray: string | JSONObject | null = getKvValue( + kvList, + "volumeMounts", + ); + const volumeMounts: Array<{ + name: string; + mountPath: string; + readOnly: boolean; + }> = []; + if (volumeMountsArray && typeof volumeMountsArray !== "string") { + const mountItems: Array = getArrayValues(volumeMountsArray); + for (const mountKv of mountItems) { + volumeMounts.push({ + name: getKvStringValue(mountKv, "name"), + mountPath: getKvStringValue(mountKv, "mountPath"), + readOnly: getKvStringValue(mountKv, "readOnly") === "true", + }); + } + } + + const resourcesKv: string | JSONObject | null = getKvValue( + kvList, + "resources", + ); + let requests: Record = {}; + let limits: Record = {}; + if (resourcesKv && typeof resourcesKv !== "string") { + const reqKv: string | JSONObject | null = getKvValue( + resourcesKv, + "requests", + ); + const limKv: string | JSONObject | null = getKvValue(resourcesKv, "limits"); + if (reqKv && typeof reqKv !== "string") { + requests = getKvListAsRecord(reqKv); + } + if (limKv && typeof limKv !== "string") { + limits = getKvListAsRecord(limKv); + } + } + + const commandArray: string | JSONObject | null = getKvValue( + kvList, + "command", + ); + const command: Array = []; + if (commandArray && typeof commandArray !== "string") { + const cmdValues: Array = + (commandArray["values"] as Array) || []; + for (const v of cmdValues) { + if (v["stringValue"]) { + command.push(v["stringValue"] as string); + } + } + } + + const argsArray: string | JSONObject | null = getKvValue(kvList, "args"); + const args: Array = []; + if (argsArray && typeof argsArray !== "string") { + const argValues: Array = + (argsArray["values"] as Array) || []; + for (const v of argValues) { + if (v["stringValue"]) { + args.push(v["stringValue"] as string); + } + } + } + + return { + name: getKvStringValue(kvList, "name"), + image: getKvStringValue(kvList, "image"), + command, + args, + env, + ports, + resources: { requests, limits }, + volumeMounts, + }; +} + +function parseConditions( + conditionsArrayValue: JSONObject | null, +): Array { + if (!conditionsArrayValue) { + return []; + } + const items: Array = getArrayValues(conditionsArrayValue); + return items.map((kvList: JSONObject) => { + return { + type: getKvStringValue(kvList, "type"), + status: getKvStringValue(kvList, "status"), + reason: getKvStringValue(kvList, "reason"), + message: getKvStringValue(kvList, "message"), + lastTransitionTime: getKvStringValue(kvList, "lastTransitionTime"), + }; + }); +} + +function parseContainerStatuses( + statusesArrayValue: JSONObject | null, +): Array { + if (!statusesArrayValue) { + return []; + } + const items: Array = getArrayValues(statusesArrayValue); + return items.map((kvList: JSONObject) => { + // state is a kvlist with one key (running/waiting/terminated) + const stateKv: string | JSONObject | null = getKvValue(kvList, "state"); + let state: string = "Unknown"; + if (stateKv && typeof stateKv !== "string") { + const stateValues: Array | undefined = stateKv["values"] as + | Array + | undefined; + if (stateValues && stateValues.length > 0 && stateValues[0]) { + state = (stateValues[0]["key"] as string) || "Unknown"; + } + } + + return { + name: getKvStringValue(kvList, "name"), + ready: getKvStringValue(kvList, "ready") === "true", + restartCount: parseInt(getKvStringValue(kvList, "restartCount")) || 0, + state, + image: getKvStringValue(kvList, "image"), + }; + }); +} + +/** + * Parse a raw OTLP log body into a Pod object. + * The body format: { kvlistValue: { values: [{ key: "type", value: ... }, { key: "object", value: { kvlistValue: ... } }] } } + */ +export function parsePodObject( + objectKvList: JSONObject, +): KubernetesPodObject | null { + try { + const metadataKv: string | JSONObject | null = getKvValue( + objectKvList, + "metadata", + ); + if (!metadataKv || typeof metadataKv === "string") { + return null; + } + const metadata: KubernetesObjectMetadata = parseMetadata(metadataKv); + + const specKv: string | JSONObject | null = getKvValue(objectKvList, "spec"); + const statusKv: string | JSONObject | null = getKvValue( + objectKvList, + "status", + ); + + // Parse containers + const containers: Array = []; + const initContainers: Array = []; + + if (specKv && typeof specKv !== "string") { + const containersArray: string | JSONObject | null = getKvValue( + specKv, + "containers", + ); + if (containersArray && typeof containersArray !== "string") { + const containerItems: Array = + getArrayValues(containersArray); + for (const cKv of containerItems) { + containers.push(parseContainerSpec(cKv)); + } + } + + const initContainersArray: string | JSONObject | null = getKvValue( + specKv, + "initContainers", + ); + if (initContainersArray && typeof initContainersArray !== "string") { + const initItems: Array = + getArrayValues(initContainersArray); + for (const cKv of initItems) { + initContainers.push(parseContainerSpec(cKv)); + } + } + } + + // Parse volumes + const volumes: Array<{ name: string; type: string; source: string }> = []; + if (specKv && typeof specKv !== "string") { + const volumesArray: string | JSONObject | null = getKvValue( + specKv, + "volumes", + ); + if (volumesArray && typeof volumesArray !== "string") { + const volItems: Array = getArrayValues(volumesArray); + for (const volKv of volItems) { + const name: string = getKvStringValue(volKv, "name"); + // Volume type is one of: configMap, secret, emptyDir, hostPath, persistentVolumeClaim, etc. + const volValues: Array | undefined = volKv["values"] as + | Array + | undefined; + let volType: string = "unknown"; + let volSource: string = ""; + if (volValues) { + for (const v of volValues) { + const k: string = (v["key"] as string) || ""; + if (k !== "name") { + volType = k; + const innerVal: JSONObject | undefined = v["value"] as + | JSONObject + | undefined; + if (innerVal && innerVal["kvlistValue"]) { + const innerKv: JSONObject = innerVal[ + "kvlistValue" + ] as JSONObject; + volSource = + getKvStringValue(innerKv, "name") || + getKvStringValue(innerKv, "path") || + getKvStringValue(innerKv, "claimName") || + volType; + } + break; + } + } + } + volumes.push({ name, type: volType, source: volSource }); + } + } + } + + // Parse tolerations + const tolerations: Array<{ + key: string; + operator: string; + value: string; + effect: string; + }> = []; + if (specKv && typeof specKv !== "string") { + const tolArray: string | JSONObject | null = getKvValue( + specKv, + "tolerations", + ); + if (tolArray && typeof tolArray !== "string") { + const tolItems: Array = getArrayValues(tolArray); + for (const tolKv of tolItems) { + tolerations.push({ + key: getKvStringValue(tolKv, "key"), + operator: getKvStringValue(tolKv, "operator"), + value: getKvStringValue(tolKv, "value"), + effect: getKvStringValue(tolKv, "effect"), + }); + } + } + } + + // Parse nodeSelector + let nodeSelector: Record = {}; + if (specKv && typeof specKv !== "string") { + const nsKv: string | JSONObject | null = getKvValue( + specKv, + "nodeSelector", + ); + if (nsKv && typeof nsKv !== "string") { + nodeSelector = getKvListAsRecord(nsKv); + } + } + + // Parse status + let phase: string = ""; + let podIP: string = ""; + let hostIP: string = ""; + let conditions: Array = []; + let containerStatuses: Array = []; + let initContainerStatuses: Array = []; + + if (statusKv && typeof statusKv !== "string") { + phase = getKvStringValue(statusKv, "phase"); + podIP = getKvStringValue(statusKv, "podIP"); + hostIP = getKvStringValue(statusKv, "hostIP"); + + const condArray: string | JSONObject | null = getKvValue( + statusKv, + "conditions", + ); + conditions = parseConditions(condArray as JSONObject | null); + + const csArray: string | JSONObject | null = getKvValue( + statusKv, + "containerStatuses", + ); + containerStatuses = parseContainerStatuses(csArray as JSONObject | null); + + const icsArray: string | JSONObject | null = getKvValue( + statusKv, + "initContainerStatuses", + ); + initContainerStatuses = parseContainerStatuses( + icsArray as JSONObject | null, + ); + } + + return { + metadata, + spec: { + containers, + initContainers, + serviceAccountName: specKv + ? getKvStringValue(specKv as JSONObject, "serviceAccountName") + : "", + nodeName: specKv + ? getKvStringValue(specKv as JSONObject, "nodeName") + : "", + nodeSelector, + tolerations, + volumes, + }, + status: { + phase, + podIP, + hostIP, + conditions, + containerStatuses, + initContainerStatuses, + }, + }; + } catch { + return null; + } +} + +export function parseNodeObject( + objectKvList: JSONObject, +): KubernetesNodeObject | null { + try { + const metadataKv: string | JSONObject | null = getKvValue( + objectKvList, + "metadata", + ); + if (!metadataKv || typeof metadataKv === "string") { + return null; + } + + const statusKv: string | JSONObject | null = getKvValue( + objectKvList, + "status", + ); + + let conditions: Array = []; + let nodeInfo: KubernetesNodeObject["status"]["nodeInfo"] = { + osImage: "", + kernelVersion: "", + containerRuntimeVersion: "", + kubeletVersion: "", + architecture: "", + operatingSystem: "", + }; + let allocatable: Record = {}; + let capacity: Record = {}; + let addresses: Array<{ type: string; address: string }> = []; + + if (statusKv && typeof statusKv !== "string") { + const condArray: string | JSONObject | null = getKvValue( + statusKv, + "conditions", + ); + conditions = parseConditions(condArray as JSONObject | null); + + const nodeInfoKv: string | JSONObject | null = getKvValue( + statusKv, + "nodeInfo", + ); + if (nodeInfoKv && typeof nodeInfoKv !== "string") { + nodeInfo = { + osImage: getKvStringValue(nodeInfoKv, "osImage"), + kernelVersion: getKvStringValue(nodeInfoKv, "kernelVersion"), + containerRuntimeVersion: getKvStringValue( + nodeInfoKv, + "containerRuntimeVersion", + ), + kubeletVersion: getKvStringValue(nodeInfoKv, "kubeletVersion"), + architecture: getKvStringValue(nodeInfoKv, "architecture"), + operatingSystem: getKvStringValue(nodeInfoKv, "operatingSystem"), + }; + } + + const allocKv: string | JSONObject | null = getKvValue( + statusKv, + "allocatable", + ); + if (allocKv && typeof allocKv !== "string") { + allocatable = getKvListAsRecord(allocKv); + } + + const capKv: string | JSONObject | null = getKvValue( + statusKv, + "capacity", + ); + if (capKv && typeof capKv !== "string") { + capacity = getKvListAsRecord(capKv); + } + + const addrArray: string | JSONObject | null = getKvValue( + statusKv, + "addresses", + ); + if (addrArray && typeof addrArray !== "string") { + const addrItems: Array = getArrayValues(addrArray); + addresses = addrItems.map((a: JSONObject) => { + return { + type: getKvStringValue(a, "type"), + address: getKvStringValue(a, "address"), + }; + }); + } + } + + return { + metadata: parseMetadata(metadataKv), + status: { + conditions, + nodeInfo, + allocatable, + capacity, + addresses, + }, + }; + } catch { + return null; + } +} + +export function parseDeploymentObject( + objectKvList: JSONObject, +): KubernetesDeploymentObject | null { + try { + const metadataKv: string | JSONObject | null = getKvValue( + objectKvList, + "metadata", + ); + if (!metadataKv || typeof metadataKv === "string") { + return null; + } + + const specKv: string | JSONObject | null = getKvValue(objectKvList, "spec"); + const statusKv: string | JSONObject | null = getKvValue( + objectKvList, + "status", + ); + + let replicas: number = 0; + let strategy: string = ""; + let selector: Record = {}; + if (specKv && typeof specKv !== "string") { + replicas = parseInt(getKvStringValue(specKv, "replicas")) || 0; + const strategyKv: string | JSONObject | null = getKvValue( + specKv, + "strategy", + ); + if (strategyKv && typeof strategyKv !== "string") { + strategy = getKvStringValue(strategyKv, "type"); + } + const selectorKv: string | JSONObject | null = getKvValue( + specKv, + "selector", + ); + if (selectorKv && typeof selectorKv !== "string") { + const matchLabelsKv: string | JSONObject | null = getKvValue( + selectorKv, + "matchLabels", + ); + if (matchLabelsKv && typeof matchLabelsKv !== "string") { + selector = getKvListAsRecord(matchLabelsKv); + } + } + } + + let statusReplicas: number = 0; + let readyReplicas: number = 0; + let availableReplicas: number = 0; + let unavailableReplicas: number = 0; + let conditions: Array = []; + if (statusKv && typeof statusKv !== "string") { + statusReplicas = parseInt(getKvStringValue(statusKv, "replicas")) || 0; + readyReplicas = + parseInt(getKvStringValue(statusKv, "readyReplicas")) || 0; + availableReplicas = + parseInt(getKvStringValue(statusKv, "availableReplicas")) || 0; + unavailableReplicas = + parseInt(getKvStringValue(statusKv, "unavailableReplicas")) || 0; + const condArray: string | JSONObject | null = getKvValue( + statusKv, + "conditions", + ); + conditions = parseConditions(condArray as JSONObject | null); + } + + return { + metadata: parseMetadata(metadataKv), + spec: { replicas, strategy, selector }, + status: { + replicas: statusReplicas, + readyReplicas, + availableReplicas, + unavailableReplicas, + conditions, + }, + }; + } catch { + return null; + } +} + +export function parseStatefulSetObject( + objectKvList: JSONObject, +): KubernetesStatefulSetObject | null { + try { + const metadataKv: string | JSONObject | null = getKvValue( + objectKvList, + "metadata", + ); + if (!metadataKv || typeof metadataKv === "string") { + return null; + } + + const specKv: string | JSONObject | null = getKvValue(objectKvList, "spec"); + const statusKv: string | JSONObject | null = getKvValue( + objectKvList, + "status", + ); + + let replicas: number = 0; + let serviceName: string = ""; + let podManagementPolicy: string = ""; + let updateStrategy: string = ""; + if (specKv && typeof specKv !== "string") { + replicas = parseInt(getKvStringValue(specKv, "replicas")) || 0; + serviceName = getKvStringValue(specKv, "serviceName"); + podManagementPolicy = getKvStringValue(specKv, "podManagementPolicy"); + const usKv: string | JSONObject | null = getKvValue( + specKv, + "updateStrategy", + ); + if (usKv && typeof usKv !== "string") { + updateStrategy = getKvStringValue(usKv, "type"); + } + } + + return { + metadata: parseMetadata(metadataKv), + spec: { replicas, serviceName, podManagementPolicy, updateStrategy }, + status: { + replicas: statusKv + ? parseInt(getKvStringValue(statusKv as JSONObject, "replicas")) || 0 + : 0, + readyReplicas: statusKv + ? parseInt( + getKvStringValue(statusKv as JSONObject, "readyReplicas"), + ) || 0 + : 0, + currentReplicas: statusKv + ? parseInt( + getKvStringValue(statusKv as JSONObject, "currentReplicas"), + ) || 0 + : 0, + }, + }; + } catch { + return null; + } +} + +export function parseDaemonSetObject( + objectKvList: JSONObject, +): KubernetesDaemonSetObject | null { + try { + const metadataKv: string | JSONObject | null = getKvValue( + objectKvList, + "metadata", + ); + if (!metadataKv || typeof metadataKv === "string") { + return null; + } + + const specKv: string | JSONObject | null = getKvValue(objectKvList, "spec"); + const statusKv: string | JSONObject | null = getKvValue( + objectKvList, + "status", + ); + + let updateStrategy: string = ""; + if (specKv && typeof specKv !== "string") { + const usKv: string | JSONObject | null = getKvValue( + specKv, + "updateStrategy", + ); + if (usKv && typeof usKv !== "string") { + updateStrategy = getKvStringValue(usKv, "type"); + } + } + + return { + metadata: parseMetadata(metadataKv), + spec: { updateStrategy }, + status: { + desiredNumberScheduled: statusKv + ? parseInt( + getKvStringValue( + statusKv as JSONObject, + "desiredNumberScheduled", + ), + ) || 0 + : 0, + currentNumberScheduled: statusKv + ? parseInt( + getKvStringValue( + statusKv as JSONObject, + "currentNumberScheduled", + ), + ) || 0 + : 0, + numberReady: statusKv + ? parseInt(getKvStringValue(statusKv as JSONObject, "numberReady")) || + 0 + : 0, + numberMisscheduled: statusKv + ? parseInt( + getKvStringValue(statusKv as JSONObject, "numberMisscheduled"), + ) || 0 + : 0, + numberAvailable: statusKv + ? parseInt( + getKvStringValue(statusKv as JSONObject, "numberAvailable"), + ) || 0 + : 0, + }, + }; + } catch { + return null; + } +} + +export function parseJobObject( + objectKvList: JSONObject, +): KubernetesJobObject | null { + try { + const metadataKv: string | JSONObject | null = getKvValue( + objectKvList, + "metadata", + ); + if (!metadataKv || typeof metadataKv === "string") { + return null; + } + + const specKv: string | JSONObject | null = getKvValue(objectKvList, "spec"); + const statusKv: string | JSONObject | null = getKvValue( + objectKvList, + "status", + ); + + return { + metadata: parseMetadata(metadataKv), + spec: { + completions: specKv + ? parseInt(getKvStringValue(specKv as JSONObject, "completions")) || 0 + : 0, + parallelism: specKv + ? parseInt(getKvStringValue(specKv as JSONObject, "parallelism")) || 0 + : 0, + backoffLimit: specKv + ? parseInt(getKvStringValue(specKv as JSONObject, "backoffLimit")) || + 0 + : 0, + }, + status: { + active: statusKv + ? parseInt(getKvStringValue(statusKv as JSONObject, "active")) || 0 + : 0, + succeeded: statusKv + ? parseInt(getKvStringValue(statusKv as JSONObject, "succeeded")) || 0 + : 0, + failed: statusKv + ? parseInt(getKvStringValue(statusKv as JSONObject, "failed")) || 0 + : 0, + startTime: statusKv + ? getKvStringValue(statusKv as JSONObject, "startTime") + : "", + completionTime: statusKv + ? getKvStringValue(statusKv as JSONObject, "completionTime") + : "", + conditions: statusKv + ? parseConditions( + getKvValue( + statusKv as JSONObject, + "conditions", + ) as JSONObject | null, + ) + : [], + }, + }; + } catch { + return null; + } +} + +export function parseCronJobObject( + objectKvList: JSONObject, +): KubernetesCronJobObject | null { + try { + const metadataKv: string | JSONObject | null = getKvValue( + objectKvList, + "metadata", + ); + if (!metadataKv || typeof metadataKv === "string") { + return null; + } + + const specKv: string | JSONObject | null = getKvValue(objectKvList, "spec"); + const statusKv: string | JSONObject | null = getKvValue( + objectKvList, + "status", + ); + + return { + metadata: parseMetadata(metadataKv), + spec: { + schedule: specKv + ? getKvStringValue(specKv as JSONObject, "schedule") + : "", + suspend: specKv + ? getKvStringValue(specKv as JSONObject, "suspend") === "true" + : false, + concurrencyPolicy: specKv + ? getKvStringValue(specKv as JSONObject, "concurrencyPolicy") + : "", + successfulJobsHistoryLimit: specKv + ? parseInt( + getKvStringValue( + specKv as JSONObject, + "successfulJobsHistoryLimit", + ), + ) || 0 + : 0, + failedJobsHistoryLimit: specKv + ? parseInt( + getKvStringValue(specKv as JSONObject, "failedJobsHistoryLimit"), + ) || 0 + : 0, + }, + status: { + lastScheduleTime: statusKv + ? getKvStringValue(statusKv as JSONObject, "lastScheduleTime") + : "", + activeCount: statusKv + ? parseInt(getKvStringValue(statusKv as JSONObject, "active")) || 0 + : 0, + }, + }; + } catch { + return null; + } +} + +export function parseNamespaceObject( + objectKvList: JSONObject, +): KubernetesNamespaceObject | null { + try { + const metadataKv: string | JSONObject | null = getKvValue( + objectKvList, + "metadata", + ); + if (!metadataKv || typeof metadataKv === "string") { + return null; + } + + const statusKv: string | JSONObject | null = getKvValue( + objectKvList, + "status", + ); + + return { + metadata: parseMetadata(metadataKv), + status: { + phase: statusKv + ? getKvStringValue(statusKv as JSONObject, "phase") + : "", + }, + }; + } catch { + return null; + } +} + +/** + * Extract the K8s object from a raw OTLP log body string. + * For k8sobjects pull mode, the body is: + * { kvlistValue: { values: [{ key: "type", value: ... }, { key: "object", value: { kvlistValue: ... } }] } } + * OR for some modes, the object may be at the top level. + */ +export function extractObjectFromLogBody( + bodyString: string, +): JSONObject | null { + try { + const bodyObj: JSONObject = JSON.parse(bodyString) as JSONObject; + const topKvList: JSONObject | undefined = bodyObj["kvlistValue"] as + | JSONObject + | undefined; + if (!topKvList) { + return null; + } + + // Try to get the "object" key (used in watch mode) + const objectVal: string | JSONObject | null = getKvValue( + topKvList, + "object", + ); + if (objectVal && typeof objectVal !== "string") { + return objectVal; + } + + /* + * If no "object" key, the kvlist might BE the object (pull mode) + * Check if it has typical K8s fields + */ + const kind: string | JSONObject | null = getKvValue(topKvList, "kind"); + if (kind) { + return topKvList; + } + + return null; + } catch { + return null; + } +} diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/Utils/KubernetesResourceUtils.ts b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/Utils/KubernetesResourceUtils.ts new file mode 100644 index 0000000000..fa6f626742 --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/Utils/KubernetesResourceUtils.ts @@ -0,0 +1,213 @@ +import Metric from "Common/Models/AnalyticsModels/Metric"; +import AnalyticsModelAPI from "Common/UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI"; +import ProjectUtil from "Common/UI/Utils/Project"; +import OneUptimeDate from "Common/Types/Date"; +import InBetween from "Common/Types/BaseDatabase/InBetween"; +import MetricsAggregationType from "Common/Types/Metrics/MetricsAggregationType"; +import AggregatedResult from "Common/Types/BaseDatabase/AggregatedResult"; +import { LIMIT_PER_PROJECT } from "Common/Types/Database/LimitMax"; +import Dictionary from "Common/Types/Dictionary"; + +export interface KubernetesResource { + name: string; + namespace: string; + cpuUtilization: number | null; + memoryUsageBytes: number | null; + additionalAttributes: Record; +} + +export interface FetchResourceListOptions { + clusterIdentifier: string; + metricName: string; + resourceNameAttribute: string; + namespaceAttribute?: string; + additionalAttributes?: Array; + filterAttributes?: Dictionary; + hoursBack?: number; +} + +export default class KubernetesResourceUtils { + public static async fetchResourceList( + options: FetchResourceListOptions, + ): Promise> { + const { + clusterIdentifier, + metricName, + resourceNameAttribute, + namespaceAttribute = "resource.k8s.namespace.name", + additionalAttributes = [], + filterAttributes = {}, + hoursBack = 24, + } = options; + + const endDate: Date = OneUptimeDate.getCurrentDate(); + const startDate: Date = OneUptimeDate.addRemoveHours(endDate, -hoursBack); + + const cpuResult: AggregatedResult = await AnalyticsModelAPI.aggregate({ + modelType: Metric, + aggregateBy: { + query: { + projectId: ProjectUtil.getCurrentProjectId()!, + time: new InBetween(startDate, endDate), + name: metricName, + attributes: { + "resource.k8s.cluster.name": clusterIdentifier, + ...filterAttributes, + } as Dictionary, + }, + aggregationType: MetricsAggregationType.Avg, + aggregateColumnName: "value", + aggregationTimestampColumnName: "time", + startTimestamp: startDate, + endTimestamp: endDate, + limit: LIMIT_PER_PROJECT, + skip: 0, + groupBy: { + attributes: true, + }, + }, + }); + + const resourceMap: Map = new Map(); + + for (const dataPoint of cpuResult.data) { + const attributes: Record = + (dataPoint["attributes"] as Record) || {}; + + const resourceName: string = + (attributes[resourceNameAttribute] as string) || ""; + + if (!resourceName) { + continue; + } + + const namespace: string = + (attributes[namespaceAttribute] as string) || ""; + + const key: string = `${namespace}/${resourceName}`; + + if (!resourceMap.has(key)) { + const additionalAttrs: Record = {}; + + for (const attr of additionalAttributes) { + additionalAttrs[attr] = (attributes[attr] as string) || ""; + } + + resourceMap.set(key, { + name: resourceName, + namespace: namespace, + cpuUtilization: dataPoint.value ?? null, + memoryUsageBytes: null, + additionalAttributes: additionalAttrs, + }); + } + } + + return Array.from(resourceMap.values()).sort( + (a: KubernetesResource, b: KubernetesResource) => { + const nsCompare: number = a.namespace.localeCompare(b.namespace); + if (nsCompare !== 0) { + return nsCompare; + } + return a.name.localeCompare(b.name); + }, + ); + } + + public static async fetchResourceListWithMemory( + options: FetchResourceListOptions & { memoryMetricName: string }, + ): Promise> { + const resources: Array = + await KubernetesResourceUtils.fetchResourceList(options); + + const endDate: Date = OneUptimeDate.getCurrentDate(); + const startDate: Date = OneUptimeDate.addRemoveHours( + endDate, + -(options.hoursBack || 1), + ); + + try { + const memoryResult: AggregatedResult = await AnalyticsModelAPI.aggregate({ + modelType: Metric, + aggregateBy: { + query: { + projectId: ProjectUtil.getCurrentProjectId()!, + time: new InBetween(startDate, endDate), + name: options.memoryMetricName, + attributes: { + "resource.k8s.cluster.name": options.clusterIdentifier, + ...(options.filterAttributes || {}), + } as Dictionary, + }, + aggregationType: MetricsAggregationType.Avg, + aggregateColumnName: "value", + aggregationTimestampColumnName: "time", + startTimestamp: startDate, + endTimestamp: endDate, + limit: LIMIT_PER_PROJECT, + skip: 0, + groupBy: { + attributes: true, + }, + }, + }); + + const memoryMap: Map = new Map(); + + for (const dataPoint of memoryResult.data) { + const attributes: Record = + (dataPoint["attributes"] as Record) || {}; + const resourceName: string = + (attributes[options.resourceNameAttribute] as string) || ""; + const namespace: string = + (attributes[ + options.namespaceAttribute || "resource.k8s.namespace.name" + ] as string) || ""; + const key: string = `${namespace}/${resourceName}`; + + if (resourceName && !memoryMap.has(key)) { + memoryMap.set(key, dataPoint.value ?? 0); + } + } + + for (const resource of resources) { + const key: string = `${resource.namespace}/${resource.name}`; + const memValue: number | undefined = memoryMap.get(key); + if (memValue !== undefined) { + resource.memoryUsageBytes = memValue; + } + } + } catch { + // Memory data is optional, don't fail if not available + } + + return resources; + } + + public static formatCpuValue(value: number | null): string { + if (value === null || value === undefined) { + return "N/A"; + } + return `${value.toFixed(1)}%`; + } + + public static formatMemoryValue(bytes: number | null): string { + if (bytes === null || bytes === undefined) { + return "N/A"; + } + + if (bytes < 1024) { + return `${bytes} B`; + } + + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} KB`; + } + + if (bytes < 1024 * 1024 * 1024) { + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + } + + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; + } +} diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/ContainerDetail.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/ContainerDetail.tsx new file mode 100644 index 0000000000..e510c4f3bf --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/ContainerDetail.tsx @@ -0,0 +1,194 @@ +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 MetricQueryConfigData, { + ChartSeries, +} from "Common/Types/Metrics/MetricQueryConfigData"; +import AggregationType from "Common/Types/BaseDatabase/AggregationType"; +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 Tabs from "Common/UI/Components/Tabs/Tabs"; +import { Tab } from "Common/UI/Components/Tabs/Tab"; +import KubernetesMetricsTab from "../../../Components/Kubernetes/KubernetesMetricsTab"; +import KubernetesLogsTab from "../../../Components/Kubernetes/KubernetesLogsTab"; + +const KubernetesClusterContainerDetail: FunctionComponent< + PageComponentProps +> = (): ReactElement => { + const modelId: ObjectID = Navigation.getLastParamAsObjectID(2); + const containerName: string = Navigation.getLastParamAsString(); + + 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 getSeries: (data: AggregateModel) => ChartSeries = ( + data: AggregateModel, + ): ChartSeries => { + const attributes: Record = + (data["attributes"] as Record) || {}; + const name: string = + (attributes["resource.k8s.container.name"] as string) || + "Unknown Container"; + return { title: name }; + }; + + const cpuQuery: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "container_cpu", + title: "Container CPU Utilization", + description: `CPU utilization for container ${containerName}`, + legend: "CPU", + legendUnit: "%", + }, + metricQueryData: { + filterData: { + metricName: "container.cpu.utilization", + attributes: { + "resource.k8s.cluster.name": clusterIdentifier, + "resource.k8s.container.name": containerName, + }, + aggegationType: AggregationType.Avg, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + getSeries: getSeries, + }; + + const memoryQuery: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "container_memory", + title: "Container Memory Usage", + description: `Memory usage for container ${containerName}`, + legend: "Memory", + legendUnit: "bytes", + }, + metricQueryData: { + filterData: { + metricName: "container.memory.usage", + attributes: { + "resource.k8s.cluster.name": clusterIdentifier, + "resource.k8s.container.name": containerName, + }, + aggegationType: AggregationType.Avg, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + getSeries: getSeries, + }; + + const tabs: Array = [ + { + name: "Overview", + children: ( +
+
+ + +
+
+ ), + }, + { + name: "Logs", + children: ( + + + + ), + }, + { + name: "Metrics", + children: ( + + + + ), + }, + ]; + + return ( + +
+
+ + +
+
+ + {}} /> +
+ ); +}; + +export default KubernetesClusterContainerDetail; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Containers.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Containers.tsx new file mode 100644 index 0000000000..9f7dec1aae --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Containers.tsx @@ -0,0 +1,107 @@ +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 KubernetesResourceTable from "../../../Components/Kubernetes/KubernetesResourceTable"; +import KubernetesResourceUtils, { + KubernetesResource, +} from "../Utils/KubernetesResourceUtils"; +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 PageMap from "../../../Utils/PageMap"; +import RouteMap, { RouteUtil } from "../../../Utils/RouteMap"; +import Route from "Common/Types/API/Route"; + +const KubernetesClusterContainers: FunctionComponent< + PageComponentProps +> = (): ReactElement => { + const modelId: ObjectID = Navigation.getLastParamAsObjectID(1); + + const [resources, setResources] = useState>([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(""); + + const fetchData: PromiseVoidFunction = async (): Promise => { + setIsLoading(true); + try { + const cluster: KubernetesCluster | null = await ModelAPI.getItem({ + modelType: KubernetesCluster, + id: modelId, + select: { + clusterIdentifier: true, + }, + }); + + if (!cluster?.clusterIdentifier) { + setError("Cluster not found."); + setIsLoading(false); + return; + } + + const containerList: Array = + await KubernetesResourceUtils.fetchResourceListWithMemory({ + clusterIdentifier: cluster.clusterIdentifier, + metricName: "container.cpu.utilization", + memoryMetricName: "container.memory.usage", + resourceNameAttribute: "resource.k8s.container.name", + additionalAttributes: ["resource.k8s.pod.name"], + }); + + setResources(containerList); + } catch (err) { + setError(API.getFriendlyMessage(err)); + } + setIsLoading(false); + }; + + useEffect(() => { + fetchData().catch((err: Error) => { + setError(API.getFriendlyMessage(err)); + }); + }, []); + + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + return ( + + { + return RouteUtil.populateRouteParams( + RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_CONTAINER_DETAIL] as Route, + { + modelId: modelId, + subModelId: new ObjectID(resource.name), + }, + ); + }} + /> + + ); +}; + +export default KubernetesClusterContainers; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/CronJobDetail.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/CronJobDetail.tsx new file mode 100644 index 0000000000..31c458a1f4 --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/CronJobDetail.tsx @@ -0,0 +1,270 @@ +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 MetricQueryConfigData, { + ChartSeries, +} from "Common/Types/Metrics/MetricQueryConfigData"; +import AggregationType from "Common/Types/BaseDatabase/AggregationType"; +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 Tabs from "Common/UI/Components/Tabs/Tabs"; +import { Tab } from "Common/UI/Components/Tabs/Tab"; +import KubernetesOverviewTab from "../../../Components/Kubernetes/KubernetesOverviewTab"; +import KubernetesEventsTab from "../../../Components/Kubernetes/KubernetesEventsTab"; +import KubernetesMetricsTab from "../../../Components/Kubernetes/KubernetesMetricsTab"; +import { KubernetesCronJobObject } from "../Utils/KubernetesObjectParser"; +import { fetchLatestK8sObject } from "../Utils/KubernetesObjectFetcher"; + +const KubernetesClusterCronJobDetail: FunctionComponent< + PageComponentProps +> = (): ReactElement => { + const modelId: ObjectID = Navigation.getLastParamAsObjectID(2); + const cronJobName: string = Navigation.getLastParamAsString(); + + const [cluster, setCluster] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(""); + const [cronJobObject, setCronJobObject] = + useState(null); + const [isLoadingObject, setIsLoadingObject] = useState(true); + + 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)); + }); + }, []); + + // Fetch the K8s cronjob object for overview tab + useEffect(() => { + if (!cluster?.clusterIdentifier) { + return; + } + + const fetchCronJobObject: () => Promise = async (): Promise => { + setIsLoadingObject(true); + try { + const obj: KubernetesCronJobObject | null = + await fetchLatestK8sObject({ + clusterIdentifier: cluster.clusterIdentifier || "", + resourceType: "cronjobs", + resourceName: cronJobName, + }); + setCronJobObject(obj); + } catch { + // Graceful degradation — overview tab shows empty state + } + setIsLoadingObject(false); + }; + + fetchCronJobObject().catch(() => {}); + }, [cluster?.clusterIdentifier, cronJobName]); + + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + if (!cluster) { + return ; + } + + const clusterIdentifier: string = cluster.clusterIdentifier || ""; + + const getSeries: (data: AggregateModel) => ChartSeries = ( + data: AggregateModel, + ): ChartSeries => { + const attributes: Record = + (data["attributes"] as Record) || {}; + const podName: string = + (attributes["resource.k8s.pod.name"] as string) || "Unknown Pod"; + return { title: podName }; + }; + + const cpuQuery: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "cronjob_cpu", + title: "Pod CPU Utilization", + description: `CPU utilization for pods in cronjob ${cronJobName}`, + legend: "CPU", + legendUnit: "%", + }, + metricQueryData: { + filterData: { + metricName: "k8s.pod.cpu.utilization", + attributes: { + "resource.k8s.cluster.name": clusterIdentifier, + "resource.k8s.cronjob.name": cronJobName, + }, + aggegationType: AggregationType.Avg, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + getSeries: getSeries, + }; + + const memoryQuery: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "cronjob_memory", + title: "Pod Memory Usage", + description: `Memory usage for pods in cronjob ${cronJobName}`, + legend: "Memory", + legendUnit: "bytes", + }, + metricQueryData: { + filterData: { + metricName: "k8s.pod.memory.usage", + attributes: { + "resource.k8s.cluster.name": clusterIdentifier, + "resource.k8s.cronjob.name": cronJobName, + }, + aggegationType: AggregationType.Avg, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + getSeries: getSeries, + }; + + // Build overview summary fields from cronjob object + const summaryFields: Array<{ title: string; value: string | ReactElement }> = + [ + { title: "CronJob Name", value: cronJobName }, + { title: "Cluster", value: clusterIdentifier }, + ]; + + if (cronJobObject) { + summaryFields.push( + { + title: "Namespace", + value: cronJobObject.metadata.namespace || "default", + }, + { + title: "Schedule", + value: cronJobObject.spec.schedule || "N/A", + }, + { + title: "Suspend", + value: cronJobObject.spec.suspend ? "Yes" : "No", + }, + { + title: "Concurrency Policy", + value: cronJobObject.spec.concurrencyPolicy || "N/A", + }, + { + title: "Successful Jobs History Limit", + value: String(cronJobObject.spec.successfulJobsHistoryLimit ?? "N/A"), + }, + { + title: "Failed Jobs History Limit", + value: String(cronJobObject.spec.failedJobsHistoryLimit ?? "N/A"), + }, + { + title: "Last Schedule Time", + value: cronJobObject.status.lastScheduleTime || "N/A", + }, + { + title: "Active Jobs", + value: String(cronJobObject.status.activeCount ?? 0), + }, + { + title: "Created", + value: cronJobObject.metadata.creationTimestamp || "N/A", + }, + ); + } + + const tabs: Array = [ + { + name: "Overview", + children: ( + + ), + }, + { + name: "Events", + children: ( + + + + ), + }, + { + name: "Metrics", + children: ( + + + + ), + }, + ]; + + return ( + +
+
+ + +
+
+ + {}} /> +
+ ); +}; + +export default KubernetesClusterCronJobDetail; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/CronJobs.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/CronJobs.tsx new file mode 100644 index 0000000000..6aecdf59a5 --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/CronJobs.tsx @@ -0,0 +1,100 @@ +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 KubernetesResourceTable from "../../../Components/Kubernetes/KubernetesResourceTable"; +import KubernetesResourceUtils, { + KubernetesResource, +} from "../Utils/KubernetesResourceUtils"; +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 PageMap from "../../../Utils/PageMap"; +import RouteMap, { RouteUtil } from "../../../Utils/RouteMap"; +import Route from "Common/Types/API/Route"; + +const KubernetesClusterCronJobs: FunctionComponent< + PageComponentProps +> = (): ReactElement => { + const modelId: ObjectID = Navigation.getLastParamAsObjectID(1); + + const [resources, setResources] = useState>([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(""); + + const fetchData: PromiseVoidFunction = async (): Promise => { + setIsLoading(true); + try { + const cluster: KubernetesCluster | null = await ModelAPI.getItem({ + modelType: KubernetesCluster, + id: modelId, + select: { + clusterIdentifier: true, + }, + }); + + if (!cluster?.clusterIdentifier) { + setError("Cluster not found."); + setIsLoading(false); + return; + } + + const cronjobList: Array = + await KubernetesResourceUtils.fetchResourceListWithMemory({ + clusterIdentifier: cluster.clusterIdentifier, + metricName: "k8s.pod.cpu.utilization", + memoryMetricName: "k8s.pod.memory.usage", + resourceNameAttribute: "resource.k8s.cronjob.name", + }); + + setResources(cronjobList); + } catch (err) { + setError(API.getFriendlyMessage(err)); + } + setIsLoading(false); + }; + + useEffect(() => { + fetchData().catch((err: Error) => { + setError(API.getFriendlyMessage(err)); + }); + }, []); + + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + return ( + + { + return RouteUtil.populateRouteParams( + RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_CRONJOB_DETAIL] as Route, + { + modelId: modelId, + subModelId: new ObjectID(resource.name), + }, + ); + }} + /> + + ); +}; + +export default KubernetesClusterCronJobs; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/DaemonSetDetail.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/DaemonSetDetail.tsx new file mode 100644 index 0000000000..3121929fc5 --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/DaemonSetDetail.tsx @@ -0,0 +1,262 @@ +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 MetricQueryConfigData, { + ChartSeries, +} from "Common/Types/Metrics/MetricQueryConfigData"; +import AggregationType from "Common/Types/BaseDatabase/AggregationType"; +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 Tabs from "Common/UI/Components/Tabs/Tabs"; +import { Tab } from "Common/UI/Components/Tabs/Tab"; +import KubernetesOverviewTab from "../../../Components/Kubernetes/KubernetesOverviewTab"; +import KubernetesEventsTab from "../../../Components/Kubernetes/KubernetesEventsTab"; +import KubernetesMetricsTab from "../../../Components/Kubernetes/KubernetesMetricsTab"; +import { KubernetesDaemonSetObject } from "../Utils/KubernetesObjectParser"; +import { fetchLatestK8sObject } from "../Utils/KubernetesObjectFetcher"; + +const KubernetesClusterDaemonSetDetail: FunctionComponent< + PageComponentProps +> = (): ReactElement => { + const modelId: ObjectID = Navigation.getLastParamAsObjectID(2); + const daemonSetName: string = Navigation.getLastParamAsString(); + + const [cluster, setCluster] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(""); + const [objectData, setObjectData] = + useState(null); + const [isLoadingObject, setIsLoadingObject] = useState(true); + + 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)); + }); + }, []); + + // Fetch the K8s daemonset object for overview tab + useEffect(() => { + if (!cluster?.clusterIdentifier) { + return; + } + + const fetchObject: () => Promise = async (): Promise => { + setIsLoadingObject(true); + try { + const obj: KubernetesDaemonSetObject | null = + await fetchLatestK8sObject({ + clusterIdentifier: cluster.clusterIdentifier || "", + resourceType: "daemonsets", + resourceName: daemonSetName, + }); + setObjectData(obj); + } catch { + // Graceful degradation — overview tab shows empty state + } + setIsLoadingObject(false); + }; + + fetchObject().catch(() => {}); + }, [cluster?.clusterIdentifier, daemonSetName]); + + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + if (!cluster) { + return ; + } + + const clusterIdentifier: string = cluster.clusterIdentifier || ""; + + const getSeries: (data: AggregateModel) => ChartSeries = ( + data: AggregateModel, + ): ChartSeries => { + const attributes: Record = + (data["attributes"] as Record) || {}; + const podName: string = + (attributes["resource.k8s.pod.name"] as string) || "Unknown Pod"; + return { title: podName }; + }; + + const cpuQuery: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "daemonset_cpu", + title: "Pod CPU Utilization", + description: `CPU utilization for pods in daemonset ${daemonSetName}`, + legend: "CPU", + legendUnit: "%", + }, + metricQueryData: { + filterData: { + metricName: "k8s.pod.cpu.utilization", + attributes: { + "resource.k8s.cluster.name": clusterIdentifier, + "resource.k8s.daemonset.name": daemonSetName, + }, + aggegationType: AggregationType.Avg, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + getSeries: getSeries, + }; + + const memoryQuery: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "daemonset_memory", + title: "Pod Memory Usage", + description: `Memory usage for pods in daemonset ${daemonSetName}`, + legend: "Memory", + legendUnit: "bytes", + }, + metricQueryData: { + filterData: { + metricName: "k8s.pod.memory.usage", + attributes: { + "resource.k8s.cluster.name": clusterIdentifier, + "resource.k8s.daemonset.name": daemonSetName, + }, + aggegationType: AggregationType.Avg, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + getSeries: getSeries, + }; + + // Build overview summary fields from daemonset object + const summaryFields: Array<{ title: string; value: string | ReactElement }> = + [ + { title: "Name", value: daemonSetName }, + { title: "Cluster", value: clusterIdentifier }, + ]; + + if (objectData) { + summaryFields.push( + { + title: "Namespace", + value: objectData.metadata.namespace || "default", + }, + { + title: "Desired Scheduled", + value: String(objectData.status.desiredNumberScheduled ?? "N/A"), + }, + { + title: "Current Scheduled", + value: String(objectData.status.currentNumberScheduled ?? "N/A"), + }, + { + title: "Number Ready", + value: String(objectData.status.numberReady ?? "N/A"), + }, + { + title: "Number Available", + value: String(objectData.status.numberAvailable ?? "N/A"), + }, + { + title: "Update Strategy", + value: objectData.spec.updateStrategy || "N/A", + }, + { + title: "Created", + value: objectData.metadata.creationTimestamp || "N/A", + }, + ); + } + + const tabs: Array = [ + { + name: "Overview", + children: ( + + ), + }, + { + name: "Events", + children: ( + + + + ), + }, + { + name: "Metrics", + children: ( + + + + ), + }, + ]; + + return ( + +
+
+ + +
+
+ + {}} /> +
+ ); +}; + +export default KubernetesClusterDaemonSetDetail; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/DaemonSets.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/DaemonSets.tsx new file mode 100644 index 0000000000..2bbc94677f --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/DaemonSets.tsx @@ -0,0 +1,100 @@ +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 KubernetesResourceTable from "../../../Components/Kubernetes/KubernetesResourceTable"; +import KubernetesResourceUtils, { + KubernetesResource, +} from "../Utils/KubernetesResourceUtils"; +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 PageMap from "../../../Utils/PageMap"; +import RouteMap, { RouteUtil } from "../../../Utils/RouteMap"; +import Route from "Common/Types/API/Route"; + +const KubernetesClusterDaemonSets: FunctionComponent< + PageComponentProps +> = (): ReactElement => { + const modelId: ObjectID = Navigation.getLastParamAsObjectID(1); + + const [resources, setResources] = useState>([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(""); + + const fetchData: PromiseVoidFunction = async (): Promise => { + setIsLoading(true); + try { + const cluster: KubernetesCluster | null = await ModelAPI.getItem({ + modelType: KubernetesCluster, + id: modelId, + select: { + clusterIdentifier: true, + }, + }); + + if (!cluster?.clusterIdentifier) { + setError("Cluster not found."); + setIsLoading(false); + return; + } + + const daemonsetList: Array = + await KubernetesResourceUtils.fetchResourceListWithMemory({ + clusterIdentifier: cluster.clusterIdentifier, + metricName: "k8s.pod.cpu.utilization", + memoryMetricName: "k8s.pod.memory.usage", + resourceNameAttribute: "resource.k8s.daemonset.name", + }); + + setResources(daemonsetList); + } catch (err) { + setError(API.getFriendlyMessage(err)); + } + setIsLoading(false); + }; + + useEffect(() => { + fetchData().catch((err: Error) => { + setError(API.getFriendlyMessage(err)); + }); + }, []); + + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + return ( + + { + return RouteUtil.populateRouteParams( + RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_DAEMONSET_DETAIL] as Route, + { + modelId: modelId, + subModelId: new ObjectID(resource.name), + }, + ); + }} + /> + + ); +}; + +export default KubernetesClusterDaemonSets; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/DeploymentDetail.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/DeploymentDetail.tsx new file mode 100644 index 0000000000..847f9c881c --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/DeploymentDetail.tsx @@ -0,0 +1,259 @@ +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 MetricQueryConfigData, { + ChartSeries, +} from "Common/Types/Metrics/MetricQueryConfigData"; +import AggregationType from "Common/Types/BaseDatabase/AggregationType"; +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 Tabs from "Common/UI/Components/Tabs/Tabs"; +import { Tab } from "Common/UI/Components/Tabs/Tab"; +import KubernetesOverviewTab from "../../../Components/Kubernetes/KubernetesOverviewTab"; +import KubernetesEventsTab from "../../../Components/Kubernetes/KubernetesEventsTab"; +import KubernetesMetricsTab from "../../../Components/Kubernetes/KubernetesMetricsTab"; +import { KubernetesDeploymentObject } from "../Utils/KubernetesObjectParser"; +import { fetchLatestK8sObject } from "../Utils/KubernetesObjectFetcher"; + +const KubernetesClusterDeploymentDetail: FunctionComponent< + PageComponentProps +> = (): ReactElement => { + const modelId: ObjectID = Navigation.getLastParamAsObjectID(2); + const deploymentName: string = Navigation.getLastParamAsString(); + + const [cluster, setCluster] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(""); + const [objectData, setObjectData] = + useState(null); + const [isLoadingObject, setIsLoadingObject] = useState(true); + + 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)); + }); + }, []); + + // Fetch the K8s deployment object for overview tab + useEffect(() => { + if (!cluster?.clusterIdentifier) { + return; + } + + const fetchObject: () => Promise = async (): Promise => { + setIsLoadingObject(true); + try { + const obj: KubernetesDeploymentObject | null = + await fetchLatestK8sObject({ + clusterIdentifier: cluster.clusterIdentifier || "", + resourceType: "deployments", + resourceName: deploymentName, + }); + setObjectData(obj); + } catch { + // Graceful degradation — overview tab shows empty state + } + setIsLoadingObject(false); + }; + + fetchObject().catch(() => {}); + }, [cluster?.clusterIdentifier, deploymentName]); + + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + if (!cluster) { + return ; + } + + const clusterIdentifier: string = cluster.clusterIdentifier || ""; + + const getSeries: (data: AggregateModel) => ChartSeries = ( + data: AggregateModel, + ): ChartSeries => { + const attributes: Record = + (data["attributes"] as Record) || {}; + const podName: string = + (attributes["resource.k8s.pod.name"] as string) || "Unknown Pod"; + return { title: podName }; + }; + + const cpuQuery: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "deployment_cpu", + title: "Pod CPU Utilization", + description: `CPU utilization for pods in deployment ${deploymentName}`, + legend: "CPU", + legendUnit: "%", + }, + metricQueryData: { + filterData: { + metricName: "k8s.pod.cpu.utilization", + attributes: { + "resource.k8s.cluster.name": clusterIdentifier, + "resource.k8s.deployment.name": deploymentName, + }, + aggegationType: AggregationType.Avg, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + getSeries: getSeries, + }; + + const memoryQuery: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "deployment_memory", + title: "Pod Memory Usage", + description: `Memory usage for pods in deployment ${deploymentName}`, + legend: "Memory", + legendUnit: "bytes", + }, + metricQueryData: { + filterData: { + metricName: "k8s.pod.memory.usage", + attributes: { + "resource.k8s.cluster.name": clusterIdentifier, + "resource.k8s.deployment.name": deploymentName, + }, + aggegationType: AggregationType.Avg, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + getSeries: getSeries, + }; + + // Build overview summary fields from deployment object + const summaryFields: Array<{ title: string; value: string | ReactElement }> = + [ + { title: "Name", value: deploymentName }, + { title: "Cluster", value: clusterIdentifier }, + ]; + + if (objectData) { + summaryFields.push( + { + title: "Namespace", + value: objectData.metadata.namespace || "default", + }, + { + title: "Replicas", + value: String(objectData.spec.replicas ?? "N/A"), + }, + { + title: "Ready Replicas", + value: String(objectData.status.readyReplicas ?? "N/A"), + }, + { + title: "Available Replicas", + value: String(objectData.status.availableReplicas ?? "N/A"), + }, + { + title: "Strategy", + value: objectData.spec.strategy || "N/A", + }, + { + title: "Created", + value: objectData.metadata.creationTimestamp || "N/A", + }, + ); + } + + const tabs: Array = [ + { + name: "Overview", + children: ( + + ), + }, + { + name: "Events", + children: ( + + + + ), + }, + { + name: "Metrics", + children: ( + + + + ), + }, + ]; + + return ( + +
+
+ + +
+
+ + {}} /> +
+ ); +}; + +export default KubernetesClusterDeploymentDetail; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Deployments.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Deployments.tsx new file mode 100644 index 0000000000..235adc8dae --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Deployments.tsx @@ -0,0 +1,102 @@ +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 KubernetesResourceTable from "../../../Components/Kubernetes/KubernetesResourceTable"; +import KubernetesResourceUtils, { + KubernetesResource, +} from "../Utils/KubernetesResourceUtils"; +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 PageMap from "../../../Utils/PageMap"; +import RouteMap, { RouteUtil } from "../../../Utils/RouteMap"; +import Route from "Common/Types/API/Route"; + +const KubernetesClusterDeployments: FunctionComponent< + PageComponentProps +> = (): ReactElement => { + const modelId: ObjectID = Navigation.getLastParamAsObjectID(1); + + const [resources, setResources] = useState>([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(""); + + const fetchData: PromiseVoidFunction = async (): Promise => { + setIsLoading(true); + try { + const cluster: KubernetesCluster | null = await ModelAPI.getItem({ + modelType: KubernetesCluster, + id: modelId, + select: { + clusterIdentifier: true, + }, + }); + + if (!cluster?.clusterIdentifier) { + setError("Cluster not found."); + setIsLoading(false); + return; + } + + const deploymentList: Array = + await KubernetesResourceUtils.fetchResourceListWithMemory({ + clusterIdentifier: cluster.clusterIdentifier, + metricName: "k8s.pod.cpu.utilization", + memoryMetricName: "k8s.pod.memory.usage", + resourceNameAttribute: "resource.k8s.deployment.name", + }); + + setResources(deploymentList); + } catch (err) { + setError(API.getFriendlyMessage(err)); + } + setIsLoading(false); + }; + + useEffect(() => { + fetchData().catch((err: Error) => { + setError(API.getFriendlyMessage(err)); + }); + }, []); + + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + return ( + + { + return RouteUtil.populateRouteParams( + RouteMap[ + PageMap.KUBERNETES_CLUSTER_VIEW_DEPLOYMENT_DETAIL + ] as Route, + { + modelId: modelId, + subModelId: new ObjectID(resource.name), + }, + ); + }} + /> + + ); +}; + +export default KubernetesClusterDeployments; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Events.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Events.tsx index ba6e56a86b..27807636e7 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Events.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Events.tsx @@ -24,16 +24,8 @@ import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage"; import { PromiseVoidFunction } from "Common/Types/FunctionTypes"; import { JSONObject } from "Common/Types/JSON"; import InBetween from "Common/Types/BaseDatabase/InBetween"; - -interface KubernetesEvent { - timestamp: string; - type: string; - reason: string; - objectKind: string; - objectName: string; - namespace: string; - message: string; -} +import { getKvValue, getKvStringValue } from "../Utils/KubernetesObjectParser"; +import { KubernetesEvent } from "../Utils/KubernetesObjectFetcher"; const KubernetesClusterEvents: FunctionComponent< PageComponentProps @@ -65,13 +57,18 @@ const KubernetesClusterEvents: FunctionComponent< const endDate: Date = OneUptimeDate.getCurrentDate(); const startDate: Date = OneUptimeDate.addRemoveHours(endDate, -24); - const listResult: ListResult = await AnalyticsModelAPI.getList({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const eventsQueryOptions: any = { modelType: Log, query: { projectId: ProjectUtil.getCurrentProjectId()!.toString(), time: new InBetween(startDate, endDate), + attributes: { + "logAttributes.event.domain": "k8s", + "logAttributes.k8s.resource.name": "events", + }, }, - limit: 200, + limit: 500, skip: 0, select: { time: true, @@ -83,76 +80,9 @@ const KubernetesClusterEvents: FunctionComponent< time: SortOrder.Descending, }, requestOptions: {}, - }); - - // Helper to extract a string value from OTLP kvlistValue - const getKvValue: ( - kvList: JSONObject | undefined, - key: string, - ) => string = (kvList: JSONObject | undefined, key: string): string => { - if (!kvList) { - return ""; - } - const values: Array | undefined = (kvList as JSONObject)[ - "values" - ] as Array | undefined; - if (!values) { - return ""; - } - for (const entry of values) { - if (entry["key"] === key) { - const val: JSONObject | undefined = entry["value"] as - | JSONObject - | undefined; - if (!val) { - return ""; - } - if (val["stringValue"]) { - return val["stringValue"] as string; - } - if (val["intValue"]) { - return String(val["intValue"]); - } - // Nested kvlist (e.g., regarding, metadata) - if (val["kvlistValue"]) { - return val["kvlistValue"] as unknown as string; - } - } - } - return ""; - }; - - // Helper to get nested kvlist value - const getNestedKvValue: ( - kvList: JSONObject | undefined, - parentKey: string, - childKey: string, - ) => string = ( - kvList: JSONObject | undefined, - parentKey: string, - childKey: string, - ): string => { - if (!kvList) { - return ""; - } - const values: Array | undefined = (kvList as JSONObject)[ - "values" - ] as Array | undefined; - if (!values) { - return ""; - } - for (const entry of values) { - if (entry["key"] === parentKey) { - const val: JSONObject | undefined = entry["value"] as - | JSONObject - | undefined; - if (val && val["kvlistValue"]) { - return getKvValue(val["kvlistValue"] as JSONObject, childKey); - } - } - } - return ""; }; + const listResult: ListResult = + await AnalyticsModelAPI.getList(eventsQueryOptions); const k8sEvents: Array = []; @@ -167,11 +97,6 @@ const KubernetesClusterEvents: FunctionComponent< continue; } - // Only process k8s event logs (from k8sobjects receiver) - if (attrs["logAttributes.event.domain"] !== "k8s") { - continue; - } - // Parse the body which is OTLP kvlistValue JSON let bodyObj: JSONObject | null = null; try { @@ -186,7 +111,6 @@ const KubernetesClusterEvents: FunctionComponent< continue; } - // The body has a top-level kvlistValue with "type" (ADDED/MODIFIED) and "object" keys const topKvList: JSONObject | undefined = bodyObj["kvlistValue"] as | JSONObject | undefined; @@ -195,25 +119,46 @@ const KubernetesClusterEvents: FunctionComponent< } // Get the "object" which is the actual k8s Event - const objectKvListRaw: string = getKvValue(topKvList, "object"); - if (!objectKvListRaw || typeof objectKvListRaw === "string") { + const objectVal: string | JSONObject | null = getKvValue( + topKvList, + "object", + ); + if (!objectVal || typeof objectVal === "string") { continue; } - const objectKvList: JSONObject = - objectKvListRaw as unknown as JSONObject; + const objectKvList: JSONObject = objectVal; - const eventType: string = getKvValue(objectKvList, "type") || ""; - const reason: string = getKvValue(objectKvList, "reason") || ""; - const note: string = getKvValue(objectKvList, "note") || ""; + const eventType: string = getKvStringValue(objectKvList, "type") || ""; + const reason: string = getKvStringValue(objectKvList, "reason") || ""; + const note: string = getKvStringValue(objectKvList, "note") || ""; + + // Get regarding object details using shared parser + const regardingKv: string | JSONObject | null = getKvValue( + objectKvList, + "regarding", + ); + const regardingObj: JSONObject | undefined = + regardingKv && typeof regardingKv !== "string" + ? regardingKv + : undefined; + + const objectKind: string = regardingObj + ? getKvStringValue(regardingObj, "kind") + : ""; + const objectName: string = regardingObj + ? getKvStringValue(regardingObj, "name") + : ""; + + const metadataKv: string | JSONObject | null = getKvValue( + objectKvList, + "metadata", + ); + const metadataObj: JSONObject | undefined = + metadataKv && typeof metadataKv !== "string" ? metadataKv : undefined; - // Get object details from "regarding" sub-object - const objectKind: string = - getNestedKvValue(objectKvList, "regarding", "kind") || ""; - const objectName: string = - getNestedKvValue(objectKvList, "regarding", "name") || ""; const namespace: string = - getNestedKvValue(objectKvList, "regarding", "namespace") || - getNestedKvValue(objectKvList, "metadata", "namespace") || + (regardingObj ? getKvStringValue(regardingObj, "namespace") : "") || + (metadataObj ? getKvStringValue(metadataObj, "namespace") : "") || ""; if (eventType || reason) { diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Index.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Index.tsx index fb10436918..ad81b8f750 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Index.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Index.tsx @@ -6,6 +6,10 @@ import CardModelDetail from "Common/UI/Components/ModelDetail/CardModelDetail"; import FieldType from "Common/UI/Components/Types/FieldType"; import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType"; import InfoCard from "Common/UI/Components/InfoCard/InfoCard"; +import Card from "Common/UI/Components/Card/Card"; +import PageMap from "../../../Utils/PageMap"; +import RouteMap, { RouteUtil } from "../../../Utils/RouteMap"; +import Route from "Common/Types/API/Route"; import React, { Fragment, FunctionComponent, @@ -19,6 +23,12 @@ import PageLoader from "Common/UI/Components/Loader/PageLoader"; import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage"; import { PromiseVoidFunction } from "Common/Types/FunctionTypes"; +interface ResourceLink { + title: string; + description: string; + pageMap: PageMap; +} + const KubernetesClusterOverview: FunctionComponent< PageComponentProps > = (): ReactElement => { @@ -75,6 +85,57 @@ const KubernetesClusterOverview: FunctionComponent< ? "text-green-600" : "text-red-600"; + const workloadLinks: Array = [ + { + title: "Namespaces", + description: "View all namespaces", + pageMap: PageMap.KUBERNETES_CLUSTER_VIEW_NAMESPACES, + }, + { + title: "Pods", + description: "View all pods", + pageMap: PageMap.KUBERNETES_CLUSTER_VIEW_PODS, + }, + { + title: "Deployments", + description: "View all deployments", + pageMap: PageMap.KUBERNETES_CLUSTER_VIEW_DEPLOYMENTS, + }, + { + title: "StatefulSets", + description: "View all statefulsets", + pageMap: PageMap.KUBERNETES_CLUSTER_VIEW_STATEFULSETS, + }, + { + title: "DaemonSets", + description: "View all daemonsets", + pageMap: PageMap.KUBERNETES_CLUSTER_VIEW_DAEMONSETS, + }, + { + title: "Jobs", + description: "View all jobs", + pageMap: PageMap.KUBERNETES_CLUSTER_VIEW_JOBS, + }, + { + title: "CronJobs", + description: "View all cron jobs", + pageMap: PageMap.KUBERNETES_CLUSTER_VIEW_CRONJOBS, + }, + ]; + + const infraLinks: Array = [ + { + title: "Nodes", + description: "View all nodes", + pageMap: PageMap.KUBERNETES_CLUSTER_VIEW_NODES, + }, + { + title: "Containers", + description: "View all containers", + pageMap: PageMap.KUBERNETES_CLUSTER_VIEW_CONTAINERS, + }, + ]; + return ( {/* Summary Cards */} @@ -115,6 +176,66 @@ const KubernetesClusterOverview: FunctionComponent< /> + {/* Quick Navigation - Workloads */} + +
+ {workloadLinks.map((link: ResourceLink) => { + return ( + +
+
+ {link.title} +
+
+ {link.description} +
+
+
+ ); + })} +
+
+ + {/* Quick Navigation - Infrastructure */} + +
+ {infraLinks.map((link: ResourceLink) => { + return ( + +
+
+ {link.title} +
+
+ {link.description} +
+
+
+ ); + })} +
+
+ {/* Cluster Details */} name="Cluster Details" diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/JobDetail.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/JobDetail.tsx new file mode 100644 index 0000000000..8ed7927bf6 --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/JobDetail.tsx @@ -0,0 +1,274 @@ +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 MetricQueryConfigData, { + ChartSeries, +} from "Common/Types/Metrics/MetricQueryConfigData"; +import AggregationType from "Common/Types/BaseDatabase/AggregationType"; +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 Tabs from "Common/UI/Components/Tabs/Tabs"; +import { Tab } from "Common/UI/Components/Tabs/Tab"; +import KubernetesOverviewTab from "../../../Components/Kubernetes/KubernetesOverviewTab"; +import KubernetesEventsTab from "../../../Components/Kubernetes/KubernetesEventsTab"; +import KubernetesMetricsTab from "../../../Components/Kubernetes/KubernetesMetricsTab"; +import { KubernetesJobObject } from "../Utils/KubernetesObjectParser"; +import { fetchLatestK8sObject } from "../Utils/KubernetesObjectFetcher"; + +const KubernetesClusterJobDetail: FunctionComponent< + PageComponentProps +> = (): ReactElement => { + const modelId: ObjectID = Navigation.getLastParamAsObjectID(2); + const jobName: string = Navigation.getLastParamAsString(); + + const [cluster, setCluster] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(""); + const [jobObject, setJobObject] = useState(null); + const [isLoadingObject, setIsLoadingObject] = useState(true); + + 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)); + }); + }, []); + + // Fetch the K8s job object for overview tab + useEffect(() => { + if (!cluster?.clusterIdentifier) { + return; + } + + const fetchJobObject: () => Promise = async (): Promise => { + setIsLoadingObject(true); + try { + const obj: KubernetesJobObject | null = + await fetchLatestK8sObject({ + clusterIdentifier: cluster.clusterIdentifier || "", + resourceType: "jobs", + resourceName: jobName, + }); + setJobObject(obj); + } catch { + // Graceful degradation — overview tab shows empty state + } + setIsLoadingObject(false); + }; + + fetchJobObject().catch(() => {}); + }, [cluster?.clusterIdentifier, jobName]); + + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + if (!cluster) { + return ; + } + + const clusterIdentifier: string = cluster.clusterIdentifier || ""; + + const getSeries: (data: AggregateModel) => ChartSeries = ( + data: AggregateModel, + ): ChartSeries => { + const attributes: Record = + (data["attributes"] as Record) || {}; + const podName: string = + (attributes["resource.k8s.pod.name"] as string) || "Unknown Pod"; + return { title: podName }; + }; + + const cpuQuery: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "job_cpu", + title: "Pod CPU Utilization", + description: `CPU utilization for pods in job ${jobName}`, + legend: "CPU", + legendUnit: "%", + }, + metricQueryData: { + filterData: { + metricName: "k8s.pod.cpu.utilization", + attributes: { + "resource.k8s.cluster.name": clusterIdentifier, + "resource.k8s.job.name": jobName, + }, + aggegationType: AggregationType.Avg, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + getSeries: getSeries, + }; + + const memoryQuery: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "job_memory", + title: "Pod Memory Usage", + description: `Memory usage for pods in job ${jobName}`, + legend: "Memory", + legendUnit: "bytes", + }, + metricQueryData: { + filterData: { + metricName: "k8s.pod.memory.usage", + attributes: { + "resource.k8s.cluster.name": clusterIdentifier, + "resource.k8s.job.name": jobName, + }, + aggegationType: AggregationType.Avg, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + getSeries: getSeries, + }; + + // Build overview summary fields from job object + const summaryFields: Array<{ title: string; value: string | ReactElement }> = + [ + { title: "Job Name", value: jobName }, + { title: "Cluster", value: clusterIdentifier }, + ]; + + if (jobObject) { + summaryFields.push( + { + title: "Namespace", + value: jobObject.metadata.namespace || "default", + }, + { + title: "Completions", + value: String(jobObject.spec.completions ?? "N/A"), + }, + { + title: "Parallelism", + value: String(jobObject.spec.parallelism ?? "N/A"), + }, + { + title: "Backoff Limit", + value: String(jobObject.spec.backoffLimit ?? "N/A"), + }, + { + title: "Active", + value: String(jobObject.status.active ?? 0), + }, + { + title: "Succeeded", + value: String(jobObject.status.succeeded ?? 0), + }, + { + title: "Failed", + value: String(jobObject.status.failed ?? 0), + }, + { + title: "Start Time", + value: jobObject.status.startTime || "N/A", + }, + { + title: "Completion Time", + value: jobObject.status.completionTime || "N/A", + }, + { + title: "Created", + value: jobObject.metadata.creationTimestamp || "N/A", + }, + ); + } + + const tabs: Array = [ + { + name: "Overview", + children: ( + + ), + }, + { + name: "Events", + children: ( + + + + ), + }, + { + name: "Metrics", + children: ( + + + + ), + }, + ]; + + return ( + +
+
+ + +
+
+ + {}} /> +
+ ); +}; + +export default KubernetesClusterJobDetail; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Jobs.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Jobs.tsx new file mode 100644 index 0000000000..4b35e99940 --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Jobs.tsx @@ -0,0 +1,100 @@ +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 KubernetesResourceTable from "../../../Components/Kubernetes/KubernetesResourceTable"; +import KubernetesResourceUtils, { + KubernetesResource, +} from "../Utils/KubernetesResourceUtils"; +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 PageMap from "../../../Utils/PageMap"; +import RouteMap, { RouteUtil } from "../../../Utils/RouteMap"; +import Route from "Common/Types/API/Route"; + +const KubernetesClusterJobs: FunctionComponent< + PageComponentProps +> = (): ReactElement => { + const modelId: ObjectID = Navigation.getLastParamAsObjectID(1); + + const [resources, setResources] = useState>([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(""); + + const fetchData: PromiseVoidFunction = async (): Promise => { + setIsLoading(true); + try { + const cluster: KubernetesCluster | null = await ModelAPI.getItem({ + modelType: KubernetesCluster, + id: modelId, + select: { + clusterIdentifier: true, + }, + }); + + if (!cluster?.clusterIdentifier) { + setError("Cluster not found."); + setIsLoading(false); + return; + } + + const jobList: Array = + await KubernetesResourceUtils.fetchResourceListWithMemory({ + clusterIdentifier: cluster.clusterIdentifier, + metricName: "k8s.pod.cpu.utilization", + memoryMetricName: "k8s.pod.memory.usage", + resourceNameAttribute: "resource.k8s.job.name", + }); + + setResources(jobList); + } catch (err) { + setError(API.getFriendlyMessage(err)); + } + setIsLoading(false); + }; + + useEffect(() => { + fetchData().catch((err: Error) => { + setError(API.getFriendlyMessage(err)); + }); + }, []); + + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + return ( + + { + return RouteUtil.populateRouteParams( + RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_JOB_DETAIL] as Route, + { + modelId: modelId, + subModelId: new ObjectID(resource.name), + }, + ); + }} + /> + + ); +}; + +export default KubernetesClusterJobs; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/NamespaceDetail.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/NamespaceDetail.tsx new file mode 100644 index 0000000000..e41802b9df --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/NamespaceDetail.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 MetricQueryConfigData, { + ChartSeries, +} from "Common/Types/Metrics/MetricQueryConfigData"; +import AggregationType from "Common/Types/BaseDatabase/AggregationType"; +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 Tabs from "Common/UI/Components/Tabs/Tabs"; +import { Tab } from "Common/UI/Components/Tabs/Tab"; +import KubernetesOverviewTab from "../../../Components/Kubernetes/KubernetesOverviewTab"; +import KubernetesEventsTab from "../../../Components/Kubernetes/KubernetesEventsTab"; +import KubernetesMetricsTab from "../../../Components/Kubernetes/KubernetesMetricsTab"; +import { KubernetesNamespaceObject } from "../Utils/KubernetesObjectParser"; +import { fetchLatestK8sObject } from "../Utils/KubernetesObjectFetcher"; + +const KubernetesClusterNamespaceDetail: FunctionComponent< + PageComponentProps +> = (): ReactElement => { + const modelId: ObjectID = Navigation.getLastParamAsObjectID(2); + const namespaceName: string = Navigation.getLastParamAsString(); + + const [cluster, setCluster] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(""); + const [namespaceObject, setNamespaceObject] = + useState(null); + const [isLoadingObject, setIsLoadingObject] = useState(true); + + 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)); + }); + }, []); + + // Fetch the K8s namespace object for overview tab + useEffect(() => { + if (!cluster?.clusterIdentifier) { + return; + } + + const fetchNamespaceObject: () => Promise = + async (): Promise => { + setIsLoadingObject(true); + try { + const obj: KubernetesNamespaceObject | null = + await fetchLatestK8sObject({ + clusterIdentifier: cluster.clusterIdentifier || "", + resourceType: "namespaces", + resourceName: namespaceName, + }); + setNamespaceObject(obj); + } catch { + // Graceful degradation — overview tab shows empty state + } + setIsLoadingObject(false); + }; + + fetchNamespaceObject().catch(() => {}); + }, [cluster?.clusterIdentifier, namespaceName]); + + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + if (!cluster) { + return ; + } + + const clusterIdentifier: string = cluster.clusterIdentifier || ""; + + const getSeries: (data: AggregateModel) => ChartSeries = ( + data: AggregateModel, + ): ChartSeries => { + const attributes: Record = + (data["attributes"] as Record) || {}; + const podName: string = + (attributes["resource.k8s.pod.name"] as string) || "Unknown Pod"; + return { title: podName }; + }; + + const cpuQuery: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "namespace_cpu", + title: "Pod CPU Utilization", + description: `CPU utilization for pods in namespace ${namespaceName}`, + legend: "CPU", + legendUnit: "%", + }, + metricQueryData: { + filterData: { + metricName: "k8s.pod.cpu.utilization", + attributes: { + "resource.k8s.cluster.name": clusterIdentifier, + "resource.k8s.namespace.name": namespaceName, + }, + aggegationType: AggregationType.Avg, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + getSeries: getSeries, + }; + + const memoryQuery: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "namespace_memory", + title: "Pod Memory Usage", + description: `Memory usage for pods in namespace ${namespaceName}`, + legend: "Memory", + legendUnit: "bytes", + }, + metricQueryData: { + filterData: { + metricName: "k8s.pod.memory.usage", + attributes: { + "resource.k8s.cluster.name": clusterIdentifier, + "resource.k8s.namespace.name": namespaceName, + }, + aggegationType: AggregationType.Avg, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + getSeries: getSeries, + }; + + // Build overview summary fields from namespace object + const summaryFields: Array<{ title: string; value: string | ReactElement }> = + [ + { title: "Namespace Name", value: namespaceName }, + { title: "Cluster", value: clusterIdentifier }, + ]; + + if (namespaceObject) { + summaryFields.push( + { + title: "Status Phase", + value: namespaceObject.status.phase || "N/A", + }, + { + title: "Created", + value: namespaceObject.metadata.creationTimestamp || "N/A", + }, + ); + } + + const tabs: Array = [ + { + name: "Overview", + children: ( + + ), + }, + { + name: "Events", + children: ( + + + + ), + }, + { + name: "Metrics", + children: ( + + + + ), + }, + ]; + + return ( + +
+
+ + +
+
+ + {}} /> +
+ ); +}; + +export default KubernetesClusterNamespaceDetail; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Namespaces.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Namespaces.tsx new file mode 100644 index 0000000000..652d0168d0 --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Namespaces.tsx @@ -0,0 +1,102 @@ +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 KubernetesResourceTable from "../../../Components/Kubernetes/KubernetesResourceTable"; +import KubernetesResourceUtils, { + KubernetesResource, +} from "../Utils/KubernetesResourceUtils"; +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 PageMap from "../../../Utils/PageMap"; +import RouteMap, { RouteUtil } from "../../../Utils/RouteMap"; +import Route from "Common/Types/API/Route"; + +const KubernetesClusterNamespaces: FunctionComponent< + PageComponentProps +> = (): ReactElement => { + const modelId: ObjectID = Navigation.getLastParamAsObjectID(1); + + const [resources, setResources] = useState>([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(""); + + const fetchData: PromiseVoidFunction = async (): Promise => { + setIsLoading(true); + try { + const cluster: KubernetesCluster | null = await ModelAPI.getItem({ + modelType: KubernetesCluster, + id: modelId, + select: { + clusterIdentifier: true, + }, + }); + + if (!cluster?.clusterIdentifier) { + setError("Cluster not found."); + setIsLoading(false); + return; + } + + const namespaceList: Array = + await KubernetesResourceUtils.fetchResourceListWithMemory({ + clusterIdentifier: cluster.clusterIdentifier, + metricName: "k8s.pod.cpu.utilization", + memoryMetricName: "k8s.pod.memory.usage", + resourceNameAttribute: "resource.k8s.namespace.name", + namespaceAttribute: "resource.k8s.namespace.name", + }); + + setResources(namespaceList); + } catch (err) { + setError(API.getFriendlyMessage(err)); + } + setIsLoading(false); + }; + + useEffect(() => { + fetchData().catch((err: Error) => { + setError(API.getFriendlyMessage(err)); + }); + }, []); + + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + return ( + + { + return RouteUtil.populateRouteParams( + RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_NAMESPACE_DETAIL] as Route, + { + modelId: modelId, + subModelId: new ObjectID(resource.name), + }, + ); + }} + /> + + ); +}; + +export default KubernetesClusterNamespaces; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/NodeDetail.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/NodeDetail.tsx index 1618746cc8..0bc447245c 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/NodeDetail.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/NodeDetail.tsx @@ -4,12 +4,8 @@ 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, @@ -22,16 +18,30 @@ 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 Tabs from "Common/UI/Components/Tabs/Tabs"; +import { Tab } from "Common/UI/Components/Tabs/Tab"; +import KubernetesOverviewTab from "../../../Components/Kubernetes/KubernetesOverviewTab"; +import KubernetesEventsTab from "../../../Components/Kubernetes/KubernetesEventsTab"; +import KubernetesMetricsTab from "../../../Components/Kubernetes/KubernetesMetricsTab"; +import { + KubernetesCondition, + KubernetesNodeObject, +} from "../Utils/KubernetesObjectParser"; +import { fetchLatestK8sObject } from "../Utils/KubernetesObjectFetcher"; const KubernetesClusterNodeDetail: FunctionComponent< PageComponentProps > = (): ReactElement => { const modelId: ObjectID = Navigation.getLastParamAsObjectID(2); - const nodeName: string = Navigation.getLastParam()?.toString() || ""; + const nodeName: string = Navigation.getLastParamAsString(); const [cluster, setCluster] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(""); + const [nodeObject, setNodeObject] = useState( + null, + ); + const [isLoadingObject, setIsLoadingObject] = useState(true); const fetchCluster: PromiseVoidFunction = async (): Promise => { setIsLoading(true); @@ -56,6 +66,31 @@ const KubernetesClusterNodeDetail: FunctionComponent< }); }, []); + // Fetch the K8s node object for overview tab + useEffect(() => { + if (!cluster?.clusterIdentifier) { + return; + } + + const fetchNodeObject: () => Promise = async (): Promise => { + setIsLoadingObject(true); + try { + const obj: KubernetesNodeObject | null = + await fetchLatestK8sObject({ + clusterIdentifier: cluster.clusterIdentifier || "", + resourceType: "nodes", + resourceName: nodeName, + }); + setNodeObject(obj); + } catch { + // Graceful degradation — overview tab shows empty state + } + setIsLoadingObject(false); + }; + + fetchNodeObject().catch(() => {}); + }, [cluster?.clusterIdentifier, nodeName]); + if (isLoading) { return ; } @@ -70,10 +105,6 @@ const KubernetesClusterNodeDetail: FunctionComponent< 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", @@ -86,8 +117,8 @@ const KubernetesClusterNodeDetail: FunctionComponent< filterData: { metricName: "k8s.node.cpu.utilization", attributes: { - "k8s.cluster.name": clusterIdentifier, - "k8s.node.name": nodeName, + "resource.k8s.cluster.name": clusterIdentifier, + "resource.k8s.node.name": nodeName, }, aggegationType: AggregationType.Avg, aggregateBy: {}, @@ -110,8 +141,8 @@ const KubernetesClusterNodeDetail: FunctionComponent< filterData: { metricName: "k8s.node.memory.usage", attributes: { - "k8s.cluster.name": clusterIdentifier, - "k8s.node.name": nodeName, + "resource.k8s.cluster.name": clusterIdentifier, + "resource.k8s.node.name": nodeName, }, aggegationType: AggregationType.Avg, aggregateBy: {}, @@ -134,8 +165,8 @@ const KubernetesClusterNodeDetail: FunctionComponent< filterData: { metricName: "k8s.node.filesystem.usage", attributes: { - "k8s.cluster.name": clusterIdentifier, - "k8s.node.name": nodeName, + "resource.k8s.cluster.name": clusterIdentifier, + "resource.k8s.node.name": nodeName, }, aggegationType: AggregationType.Avg, aggregateBy: {}, @@ -158,8 +189,8 @@ const KubernetesClusterNodeDetail: FunctionComponent< filterData: { metricName: "k8s.node.network.io.receive", attributes: { - "k8s.cluster.name": clusterIdentifier, - "k8s.node.name": nodeName, + "resource.k8s.cluster.name": clusterIdentifier, + "resource.k8s.node.name": nodeName, }, aggegationType: AggregationType.Avg, aggregateBy: {}, @@ -182,8 +213,8 @@ const KubernetesClusterNodeDetail: FunctionComponent< filterData: { metricName: "k8s.node.network.io.transmit", attributes: { - "k8s.cluster.name": clusterIdentifier, - "k8s.node.name": nodeName, + "resource.k8s.cluster.name": clusterIdentifier, + "resource.k8s.node.name": nodeName, }, aggegationType: AggregationType.Avg, aggregateBy: {}, @@ -194,47 +225,143 @@ const KubernetesClusterNodeDetail: FunctionComponent< }, }; - const [metricViewData, setMetricViewData] = useState({ - startAndEndDate: startAndEndDate, - queryConfigs: [ - cpuQuery, - memoryQuery, - filesystemQuery, - networkRxQuery, - networkTxQuery, - ], - formulaConfigs: [], - }); + // Determine node status from conditions + const getNodeStatus: () => { label: string; isReady: boolean } = (): { + label: string; + isReady: boolean; + } => { + if (!nodeObject) { + return { label: "Unknown", isReady: false }; + } + const readyCondition: KubernetesCondition | undefined = + nodeObject.status.conditions.find((c: KubernetesCondition) => { + return c.type === "Ready"; + }); + if (readyCondition && readyCondition.status === "True") { + return { label: "Ready", isReady: true }; + } + return { label: "NotReady", isReady: false }; + }; + + // Build overview summary fields from node object + const summaryFields: Array<{ title: string; value: string | ReactElement }> = + [ + { title: "Node Name", value: nodeName }, + { title: "Cluster", value: clusterIdentifier }, + ]; + + if (nodeObject) { + const nodeStatus: { label: string; isReady: boolean } = getNodeStatus(); + + summaryFields.push( + { + title: "Status", + value: ( + + {nodeStatus.label} + + ), + }, + { + title: "OS Image", + value: nodeObject.status.nodeInfo.osImage || "N/A", + }, + { + title: "Kernel", + value: nodeObject.status.nodeInfo.kernelVersion || "N/A", + }, + { + title: "Container Runtime", + value: nodeObject.status.nodeInfo.containerRuntimeVersion || "N/A", + }, + { + title: "Kubelet Version", + value: nodeObject.status.nodeInfo.kubeletVersion || "N/A", + }, + { + title: "Architecture", + value: nodeObject.status.nodeInfo.architecture || "N/A", + }, + { + title: "CPU Allocatable", + value: nodeObject.status.allocatable["cpu"] || "N/A", + }, + { + title: "Memory Allocatable", + value: nodeObject.status.allocatable["memory"] || "N/A", + }, + { + title: "Created", + value: nodeObject.metadata.creationTimestamp || "N/A", + }, + ); + } + + const tabs: Array = [ + { + name: "Overview", + children: ( + + ), + }, + { + name: "Events", + children: ( + + + + ), + }, + { + name: "Metrics", + children: ( + + + + ), + }, + ]; return ( -
- - +
+
+ + +
- - { - setMetricViewData({ - ...data, - queryConfigs: [ - cpuQuery, - memoryQuery, - filesystemQuery, - networkRxQuery, - networkTxQuery, - ], - formulaConfigs: [], - }); - }} - /> - + {}} /> ); }; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Nodes.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Nodes.tsx index ae95b70e2e..92ef97b42a 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Nodes.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Nodes.tsx @@ -2,14 +2,10 @@ 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, { - ChartSeries, -} 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 KubernetesResourceTable from "../../../Components/Kubernetes/KubernetesResourceTable"; +import KubernetesResourceUtils, { + KubernetesResource, +} from "../Utils/KubernetesResourceUtils"; import React, { Fragment, FunctionComponent, @@ -22,31 +18,46 @@ 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 PageMap from "../../../Utils/PageMap"; +import RouteMap, { RouteUtil } from "../../../Utils/RouteMap"; +import Route from "Common/Types/API/Route"; const KubernetesClusterNodes: FunctionComponent< PageComponentProps > = (): ReactElement => { const modelId: ObjectID = Navigation.getLastParamAsObjectID(1); - const [cluster, setCluster] = useState(null); + const [resources, setResources] = useState>([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(""); - const [metricViewData, setMetricViewData] = useState( - null, - ); - const fetchCluster: PromiseVoidFunction = async (): Promise => { + const fetchData: PromiseVoidFunction = async (): Promise => { setIsLoading(true); try { - const item: KubernetesCluster | null = await ModelAPI.getItem({ + const cluster: KubernetesCluster | null = await ModelAPI.getItem({ modelType: KubernetesCluster, id: modelId, select: { clusterIdentifier: true, }, }); - setCluster(item); + + if (!cluster?.clusterIdentifier) { + setError("Cluster not found."); + setIsLoading(false); + return; + } + + const nodeList: Array = + await KubernetesResourceUtils.fetchResourceListWithMemory({ + clusterIdentifier: cluster.clusterIdentifier, + metricName: "k8s.node.cpu.utilization", + memoryMetricName: "k8s.node.memory.usage", + resourceNameAttribute: "resource.k8s.node.name", + namespaceAttribute: "resource.k8s.node.name", + }); + + setResources(nodeList); } catch (err) { setError(API.getFriendlyMessage(err)); } @@ -54,140 +65,11 @@ const KubernetesClusterNodes: FunctionComponent< }; useEffect(() => { - fetchCluster().catch((err: Error) => { + fetchData().catch((err: Error) => { setError(API.getFriendlyMessage(err)); }); }, []); - useEffect(() => { - 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 = ( - data: AggregateModel, - ): ChartSeries => { - const attributes: Record = - (data["attributes"] as Record) || {}; - const nodeName: string = - (attributes["resource.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: { - "resource.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: { - "resource.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: { - "resource.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", - attributes: { - "resource.k8s.cluster.name": clusterIdentifier, - "metricAttributes.direction": "receive", - }, - aggegationType: AggregationType.Avg, - aggregateBy: {}, - }, - groupBy: { - attributes: true, - }, - }, - getSeries: getNodeSeries, - }; - - setMetricViewData({ - startAndEndDate: startAndEndDate, - queryConfigs: [ - nodeCpuQuery, - nodeMemoryQuery, - nodeFilesystemQuery, - nodeNetworkRxQuery, - ], - formulaConfigs: [], - }); - }, [cluster]); - if (isLoading) { return ; } @@ -196,21 +78,21 @@ const KubernetesClusterNodes: FunctionComponent< return ; } - if (!cluster || !metricViewData) { - return ; - } - return ( - { - setMetricViewData({ - ...data, - queryConfigs: metricViewData.queryConfigs, - formulaConfigs: [], - }); + { + return RouteUtil.populateRouteParams( + RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_NODE_DETAIL] as Route, + { + modelId: modelId, + subModelId: new ObjectID(resource.name), + }, + ); }} /> diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/PodDetail.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/PodDetail.tsx index 21711434f2..79492d4309 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/PodDetail.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/PodDetail.tsx @@ -4,14 +4,10 @@ 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, { ChartSeries, } 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, @@ -25,16 +21,27 @@ 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 Tabs from "Common/UI/Components/Tabs/Tabs"; +import { Tab } from "Common/UI/Components/Tabs/Tab"; +import KubernetesOverviewTab from "../../../Components/Kubernetes/KubernetesOverviewTab"; +import KubernetesContainersTab from "../../../Components/Kubernetes/KubernetesContainersTab"; +import KubernetesEventsTab from "../../../Components/Kubernetes/KubernetesEventsTab"; +import KubernetesLogsTab from "../../../Components/Kubernetes/KubernetesLogsTab"; +import KubernetesMetricsTab from "../../../Components/Kubernetes/KubernetesMetricsTab"; +import { KubernetesPodObject } from "../Utils/KubernetesObjectParser"; +import { fetchLatestK8sObject } from "../Utils/KubernetesObjectFetcher"; const KubernetesClusterPodDetail: FunctionComponent< PageComponentProps > = (): ReactElement => { const modelId: ObjectID = Navigation.getLastParamAsObjectID(2); - const podName: string = Navigation.getLastParam()?.toString() || ""; + const podName: string = Navigation.getLastParamAsString(); const [cluster, setCluster] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(""); + const [podObject, setPodObject] = useState(null); + const [isLoadingObject, setIsLoadingObject] = useState(true); const fetchCluster: PromiseVoidFunction = async (): Promise => { setIsLoading(true); @@ -59,6 +66,31 @@ const KubernetesClusterPodDetail: FunctionComponent< }); }, []); + // Fetch the K8s pod object for overview/containers tabs + useEffect(() => { + if (!cluster?.clusterIdentifier) { + return; + } + + const fetchPodObject: () => Promise = async (): Promise => { + setIsLoadingObject(true); + try { + const obj: KubernetesPodObject | null = + await fetchLatestK8sObject({ + clusterIdentifier: cluster.clusterIdentifier || "", + resourceType: "pods", + resourceName: podName, + }); + setPodObject(obj); + } catch { + // Graceful degradation — overview tab shows empty state + } + setIsLoadingObject(false); + }; + + fetchPodObject().catch(() => {}); + }, [cluster?.clusterIdentifier, podName]); + if (isLoading) { return ; } @@ -73,17 +105,14 @@ const KubernetesClusterPodDetail: FunctionComponent< 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 = ( data: AggregateModel, ): ChartSeries => { const attributes: Record = (data["attributes"] as Record) || {}; const containerName: string = - (attributes["k8s.container.name"] as string) || "Unknown Container"; + (attributes["resource.k8s.container.name"] as string) || + "Unknown Container"; return { title: containerName }; }; @@ -99,8 +128,8 @@ const KubernetesClusterPodDetail: FunctionComponent< filterData: { metricName: "container.cpu.utilization", attributes: { - "k8s.cluster.name": clusterIdentifier, - "k8s.pod.name": podName, + "resource.k8s.cluster.name": clusterIdentifier, + "resource.k8s.pod.name": podName, }, aggegationType: AggregationType.Avg, aggregateBy: {}, @@ -124,8 +153,8 @@ const KubernetesClusterPodDetail: FunctionComponent< filterData: { metricName: "container.memory.usage", attributes: { - "k8s.cluster.name": clusterIdentifier, - "k8s.pod.name": podName, + "resource.k8s.cluster.name": clusterIdentifier, + "resource.k8s.pod.name": podName, }, aggegationType: AggregationType.Avg, aggregateBy: {}, @@ -149,8 +178,8 @@ const KubernetesClusterPodDetail: FunctionComponent< filterData: { metricName: "k8s.pod.cpu.utilization", attributes: { - "k8s.cluster.name": clusterIdentifier, - "k8s.pod.name": podName, + "resource.k8s.cluster.name": clusterIdentifier, + "resource.k8s.pod.name": podName, }, aggegationType: AggregationType.Avg, aggregateBy: {}, @@ -173,8 +202,8 @@ const KubernetesClusterPodDetail: FunctionComponent< filterData: { metricName: "k8s.pod.memory.usage", attributes: { - "k8s.cluster.name": clusterIdentifier, - "k8s.pod.name": podName, + "resource.k8s.cluster.name": clusterIdentifier, + "resource.k8s.pod.name": podName, }, aggegationType: AggregationType.Avg, aggregateBy: {}, @@ -185,40 +214,139 @@ const KubernetesClusterPodDetail: FunctionComponent< }, }; - const [metricViewData, setMetricViewData] = useState({ - startAndEndDate: startAndEndDate, - queryConfigs: [podCpuQuery, podMemoryQuery, cpuQuery, memoryQuery], - formulaConfigs: [], - }); + // Build overview summary fields from pod object + const summaryFields: Array<{ title: string; value: string | ReactElement }> = + [ + { title: "Pod Name", value: podName }, + { title: "Cluster", value: clusterIdentifier }, + ]; + + if (podObject) { + summaryFields.push( + { + title: "Namespace", + value: podObject.metadata.namespace || "default", + }, + { + title: "Status", + value: ( + + {podObject.status.phase || "Unknown"} + + ), + }, + { title: "Node", value: podObject.spec.nodeName || "N/A" }, + { title: "Pod IP", value: podObject.status.podIP || "N/A" }, + { title: "Host IP", value: podObject.status.hostIP || "N/A" }, + { + title: "Service Account", + value: podObject.spec.serviceAccountName || "default", + }, + { + title: "Created", + value: podObject.metadata.creationTimestamp || "N/A", + }, + ); + } + + const tabs: Array = [ + { + name: "Overview", + children: ( + + ), + }, + { + name: "Containers", + children: podObject ? ( + + ) : isLoadingObject ? ( + + ) : ( +
+ Container details not yet available. Ensure the kubernetes-agent Helm + chart has resourceSpecs.enabled set to true. +
+ ), + }, + { + name: "Events", + children: ( + + + + ), + }, + { + name: "Logs", + children: ( + + + + ), + }, + { + name: "Metrics", + children: ( + + + + ), + }, + ]; return ( -
- - +
+
+ + +
- - { - setMetricViewData({ - ...data, - queryConfigs: [ - podCpuQuery, - podMemoryQuery, - cpuQuery, - memoryQuery, - ], - formulaConfigs: [], - }); - }} - /> - + {}} /> ); }; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Pods.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Pods.tsx index f8191c2541..cd90eacda8 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Pods.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Pods.tsx @@ -2,14 +2,10 @@ 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, { - ChartSeries, -} 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 KubernetesResourceTable from "../../../Components/Kubernetes/KubernetesResourceTable"; +import KubernetesResourceUtils, { + KubernetesResource, +} from "../Utils/KubernetesResourceUtils"; import React, { Fragment, FunctionComponent, @@ -22,31 +18,49 @@ 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 PageMap from "../../../Utils/PageMap"; +import RouteMap, { RouteUtil } from "../../../Utils/RouteMap"; +import Route from "Common/Types/API/Route"; const KubernetesClusterPods: FunctionComponent< PageComponentProps > = (): ReactElement => { const modelId: ObjectID = Navigation.getLastParamAsObjectID(1); - const [cluster, setCluster] = useState(null); + const [resources, setResources] = useState>([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(""); - const [metricViewData, setMetricViewData] = useState( - null, - ); - const fetchCluster: PromiseVoidFunction = async (): Promise => { + const fetchData: PromiseVoidFunction = async (): Promise => { setIsLoading(true); try { - const item: KubernetesCluster | null = await ModelAPI.getItem({ + const cluster: KubernetesCluster | null = await ModelAPI.getItem({ modelType: KubernetesCluster, id: modelId, select: { clusterIdentifier: true, }, }); - setCluster(item); + + if (!cluster?.clusterIdentifier) { + setError("Cluster not found."); + setIsLoading(false); + return; + } + + const podList: Array = + await KubernetesResourceUtils.fetchResourceListWithMemory({ + clusterIdentifier: cluster.clusterIdentifier, + metricName: "k8s.pod.cpu.utilization", + memoryMetricName: "k8s.pod.memory.usage", + resourceNameAttribute: "resource.k8s.pod.name", + additionalAttributes: [ + "resource.k8s.node.name", + "resource.k8s.deployment.name", + ], + }); + + setResources(podList); } catch (err) { setError(API.getFriendlyMessage(err)); } @@ -54,143 +68,11 @@ const KubernetesClusterPods: FunctionComponent< }; useEffect(() => { - fetchCluster().catch((err: Error) => { + fetchData().catch((err: Error) => { setError(API.getFriendlyMessage(err)); }); }, []); - useEffect(() => { - 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 = ( - data: AggregateModel, - ): ChartSeries => { - const attributes: Record = - (data["attributes"] as Record) || {}; - const podName: string = - (attributes["resource.k8s.pod.name"] as string) || "Unknown Pod"; - const namespace: string = - (attributes["resource.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: { - "resource.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: { - "resource.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", - attributes: { - "resource.k8s.cluster.name": clusterIdentifier, - "metricAttributes.direction": "receive", - }, - 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", - attributes: { - "resource.k8s.cluster.name": clusterIdentifier, - "metricAttributes.direction": "transmit", - }, - aggegationType: AggregationType.Avg, - aggregateBy: {}, - }, - groupBy: { - attributes: true, - }, - }, - getSeries: getPodSeries, - }; - - setMetricViewData({ - startAndEndDate: startAndEndDate, - queryConfigs: [ - podCpuQuery, - podMemoryQuery, - podNetworkRxQuery, - podNetworkTxQuery, - ], - formulaConfigs: [], - }); - }, [cluster]); - if (isLoading) { return ; } @@ -199,21 +81,26 @@ const KubernetesClusterPods: FunctionComponent< return ; } - if (!cluster || !metricViewData) { - return ; - } - return ( - { - setMetricViewData({ - ...data, - queryConfigs: metricViewData.queryConfigs, - formulaConfigs: [], - }); + { + return RouteUtil.populateRouteParams( + RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_POD_DETAIL] as Route, + { + modelId: modelId, + subModelId: new ObjectID(resource.name), + }, + ); }} /> diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/SideMenu.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/SideMenu.tsx index ff62834736..67031555a9 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/SideMenu.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/SideMenu.tsx @@ -40,7 +40,17 @@ const KubernetesClusterSideMenu: FunctionComponent = ( /> - + + = ( }} icon={IconProp.Circle} /> + + + + + + + + = ( }} icon={IconProp.Server} /> + @@ -87,6 +160,16 @@ const KubernetesClusterSideMenu: FunctionComponent = ( + = (): ReactElement => { + const modelId: ObjectID = Navigation.getLastParamAsObjectID(2); + const statefulSetName: string = Navigation.getLastParamAsString(); + + const [cluster, setCluster] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(""); + const [objectData, setObjectData] = + useState(null); + const [isLoadingObject, setIsLoadingObject] = useState(true); + + 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)); + }); + }, []); + + // Fetch the K8s statefulset object for overview tab + useEffect(() => { + if (!cluster?.clusterIdentifier) { + return; + } + + const fetchObject: () => Promise = async (): Promise => { + setIsLoadingObject(true); + try { + const obj: KubernetesStatefulSetObject | null = + await fetchLatestK8sObject({ + clusterIdentifier: cluster.clusterIdentifier || "", + resourceType: "statefulsets", + resourceName: statefulSetName, + }); + setObjectData(obj); + } catch { + // Graceful degradation — overview tab shows empty state + } + setIsLoadingObject(false); + }; + + fetchObject().catch(() => {}); + }, [cluster?.clusterIdentifier, statefulSetName]); + + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + if (!cluster) { + return ; + } + + const clusterIdentifier: string = cluster.clusterIdentifier || ""; + + const getSeries: (data: AggregateModel) => ChartSeries = ( + data: AggregateModel, + ): ChartSeries => { + const attributes: Record = + (data["attributes"] as Record) || {}; + const podName: string = + (attributes["resource.k8s.pod.name"] as string) || "Unknown Pod"; + return { title: podName }; + }; + + const cpuQuery: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "statefulset_cpu", + title: "Pod CPU Utilization", + description: `CPU utilization for pods in statefulset ${statefulSetName}`, + legend: "CPU", + legendUnit: "%", + }, + metricQueryData: { + filterData: { + metricName: "k8s.pod.cpu.utilization", + attributes: { + "resource.k8s.cluster.name": clusterIdentifier, + "resource.k8s.statefulset.name": statefulSetName, + }, + aggegationType: AggregationType.Avg, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + getSeries: getSeries, + }; + + const memoryQuery: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "statefulset_memory", + title: "Pod Memory Usage", + description: `Memory usage for pods in statefulset ${statefulSetName}`, + legend: "Memory", + legendUnit: "bytes", + }, + metricQueryData: { + filterData: { + metricName: "k8s.pod.memory.usage", + attributes: { + "resource.k8s.cluster.name": clusterIdentifier, + "resource.k8s.statefulset.name": statefulSetName, + }, + aggegationType: AggregationType.Avg, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + getSeries: getSeries, + }; + + // Build overview summary fields from statefulset object + const summaryFields: Array<{ title: string; value: string | ReactElement }> = + [ + { title: "Name", value: statefulSetName }, + { title: "Cluster", value: clusterIdentifier }, + ]; + + if (objectData) { + summaryFields.push( + { + title: "Namespace", + value: objectData.metadata.namespace || "default", + }, + { + title: "Replicas", + value: String(objectData.spec.replicas ?? "N/A"), + }, + { + title: "Ready Replicas", + value: String(objectData.status.readyReplicas ?? "N/A"), + }, + { + title: "Service Name", + value: objectData.spec.serviceName || "N/A", + }, + { + title: "Pod Management Policy", + value: objectData.spec.podManagementPolicy || "N/A", + }, + { + title: "Update Strategy", + value: objectData.spec.updateStrategy || "N/A", + }, + { + title: "Created", + value: objectData.metadata.creationTimestamp || "N/A", + }, + ); + } + + const tabs: Array = [ + { + name: "Overview", + children: ( + + ), + }, + { + name: "Events", + children: ( + + + + ), + }, + { + name: "Metrics", + children: ( + + + + ), + }, + ]; + + return ( + +
+
+ + +
+
+ + {}} /> +
+ ); +}; + +export default KubernetesClusterStatefulSetDetail; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/StatefulSets.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/StatefulSets.tsx new file mode 100644 index 0000000000..54cd58b1fd --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/StatefulSets.tsx @@ -0,0 +1,102 @@ +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 KubernetesResourceTable from "../../../Components/Kubernetes/KubernetesResourceTable"; +import KubernetesResourceUtils, { + KubernetesResource, +} from "../Utils/KubernetesResourceUtils"; +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 PageMap from "../../../Utils/PageMap"; +import RouteMap, { RouteUtil } from "../../../Utils/RouteMap"; +import Route from "Common/Types/API/Route"; + +const KubernetesClusterStatefulSets: FunctionComponent< + PageComponentProps +> = (): ReactElement => { + const modelId: ObjectID = Navigation.getLastParamAsObjectID(1); + + const [resources, setResources] = useState>([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(""); + + const fetchData: PromiseVoidFunction = async (): Promise => { + setIsLoading(true); + try { + const cluster: KubernetesCluster | null = await ModelAPI.getItem({ + modelType: KubernetesCluster, + id: modelId, + select: { + clusterIdentifier: true, + }, + }); + + if (!cluster?.clusterIdentifier) { + setError("Cluster not found."); + setIsLoading(false); + return; + } + + const statefulsetList: Array = + await KubernetesResourceUtils.fetchResourceListWithMemory({ + clusterIdentifier: cluster.clusterIdentifier, + metricName: "k8s.pod.cpu.utilization", + memoryMetricName: "k8s.pod.memory.usage", + resourceNameAttribute: "resource.k8s.statefulset.name", + }); + + setResources(statefulsetList); + } catch (err) { + setError(API.getFriendlyMessage(err)); + } + setIsLoading(false); + }; + + useEffect(() => { + fetchData().catch((err: Error) => { + setError(API.getFriendlyMessage(err)); + }); + }, []); + + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + return ( + + { + return RouteUtil.populateRouteParams( + RouteMap[ + PageMap.KUBERNETES_CLUSTER_VIEW_STATEFULSET_DETAIL + ] as Route, + { + modelId: modelId, + subModelId: new ObjectID(resource.name), + }, + ); + }} + /> + + ); +}; + +export default KubernetesClusterStatefulSets; diff --git a/App/FeatureSet/Dashboard/src/Routes/KubernetesRoutes.tsx b/App/FeatureSet/Dashboard/src/Routes/KubernetesRoutes.tsx index ae8920621d..f2cb6ac6a9 100644 --- a/App/FeatureSet/Dashboard/src/Routes/KubernetesRoutes.tsx +++ b/App/FeatureSet/Dashboard/src/Routes/KubernetesRoutes.tsx @@ -10,13 +10,28 @@ import { Route as PageRoute, Routes } from "react-router-dom"; // Pages import KubernetesClusters from "../Pages/Kubernetes/Clusters"; import KubernetesClusterView from "../Pages/Kubernetes/View/Index"; +import KubernetesClusterViewNamespaces from "../Pages/Kubernetes/View/Namespaces"; +import KubernetesClusterViewNamespaceDetail from "../Pages/Kubernetes/View/NamespaceDetail"; import KubernetesClusterViewPods from "../Pages/Kubernetes/View/Pods"; import KubernetesClusterViewPodDetail from "../Pages/Kubernetes/View/PodDetail"; +import KubernetesClusterViewDeployments from "../Pages/Kubernetes/View/Deployments"; +import KubernetesClusterViewDeploymentDetail from "../Pages/Kubernetes/View/DeploymentDetail"; +import KubernetesClusterViewStatefulSets from "../Pages/Kubernetes/View/StatefulSets"; +import KubernetesClusterViewStatefulSetDetail from "../Pages/Kubernetes/View/StatefulSetDetail"; +import KubernetesClusterViewDaemonSets from "../Pages/Kubernetes/View/DaemonSets"; +import KubernetesClusterViewDaemonSetDetail from "../Pages/Kubernetes/View/DaemonSetDetail"; +import KubernetesClusterViewJobs from "../Pages/Kubernetes/View/Jobs"; +import KubernetesClusterViewJobDetail from "../Pages/Kubernetes/View/JobDetail"; +import KubernetesClusterViewCronJobs from "../Pages/Kubernetes/View/CronJobs"; +import KubernetesClusterViewCronJobDetail from "../Pages/Kubernetes/View/CronJobDetail"; import KubernetesClusterViewNodes from "../Pages/Kubernetes/View/Nodes"; import KubernetesClusterViewNodeDetail from "../Pages/Kubernetes/View/NodeDetail"; +import KubernetesClusterViewContainers from "../Pages/Kubernetes/View/Containers"; +import KubernetesClusterViewContainerDetail from "../Pages/Kubernetes/View/ContainerDetail"; 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"; import KubernetesClusterViewDocumentation from "../Pages/Kubernetes/View/Documentation"; import KubernetesDocumentation from "../Pages/Kubernetes/Documentation"; @@ -60,6 +75,39 @@ const KubernetesRoutes: FunctionComponent = ( } /> + {/* Namespaces */} + + } + /> + + + } + /> + + {/* Pods */} = ( = ( } /> + {/* Deployments */} + + } + /> + + + } + /> + + {/* StatefulSets */} + + } + /> + + + } + /> + + {/* DaemonSets */} + + } + /> + + + } + /> + + {/* Jobs */} + + } + /> + + + } + /> + + {/* CronJobs */} + + } + /> + + + } + /> + + {/* Nodes */} = ( = ( } /> + {/* Containers */} + + } + /> + + + } + /> + + {/* Events */} = ( } /> + {/* Control Plane */} = ( } /> + {/* Settings */} + + } + /> + + {/* Delete */} = ( } /> + {/* Documentation */} = { export const KubernetesRoutePath: Dictionary = { [PageMap.KUBERNETES_CLUSTER_VIEW]: `${RouteParams.ModelID}`, + [PageMap.KUBERNETES_CLUSTER_VIEW_NAMESPACES]: `${RouteParams.ModelID}/namespaces`, + [PageMap.KUBERNETES_CLUSTER_VIEW_NAMESPACE_DETAIL]: `${RouteParams.ModelID}/namespaces/${RouteParams.SubModelID}`, [PageMap.KUBERNETES_CLUSTER_VIEW_PODS]: `${RouteParams.ModelID}/pods`, [PageMap.KUBERNETES_CLUSTER_VIEW_POD_DETAIL]: `${RouteParams.ModelID}/pods/${RouteParams.SubModelID}`, + [PageMap.KUBERNETES_CLUSTER_VIEW_DEPLOYMENTS]: `${RouteParams.ModelID}/deployments`, + [PageMap.KUBERNETES_CLUSTER_VIEW_DEPLOYMENT_DETAIL]: `${RouteParams.ModelID}/deployments/${RouteParams.SubModelID}`, + [PageMap.KUBERNETES_CLUSTER_VIEW_STATEFULSETS]: `${RouteParams.ModelID}/statefulsets`, + [PageMap.KUBERNETES_CLUSTER_VIEW_STATEFULSET_DETAIL]: `${RouteParams.ModelID}/statefulsets/${RouteParams.SubModelID}`, + [PageMap.KUBERNETES_CLUSTER_VIEW_DAEMONSETS]: `${RouteParams.ModelID}/daemonsets`, + [PageMap.KUBERNETES_CLUSTER_VIEW_DAEMONSET_DETAIL]: `${RouteParams.ModelID}/daemonsets/${RouteParams.SubModelID}`, + [PageMap.KUBERNETES_CLUSTER_VIEW_JOBS]: `${RouteParams.ModelID}/jobs`, + [PageMap.KUBERNETES_CLUSTER_VIEW_JOB_DETAIL]: `${RouteParams.ModelID}/jobs/${RouteParams.SubModelID}`, + [PageMap.KUBERNETES_CLUSTER_VIEW_CRONJOBS]: `${RouteParams.ModelID}/cronjobs`, + [PageMap.KUBERNETES_CLUSTER_VIEW_CRONJOB_DETAIL]: `${RouteParams.ModelID}/cronjobs/${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_CONTAINERS]: `${RouteParams.ModelID}/containers`, + [PageMap.KUBERNETES_CLUSTER_VIEW_CONTAINER_DETAIL]: `${RouteParams.ModelID}/containers/${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`, @@ -1499,6 +1513,18 @@ const RouteMap: Dictionary = { }`, ), + [PageMap.KUBERNETES_CLUSTER_VIEW_NAMESPACES]: new Route( + `/dashboard/${RouteParams.ProjectID}/kubernetes/${ + KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_NAMESPACES] + }`, + ), + + [PageMap.KUBERNETES_CLUSTER_VIEW_NAMESPACE_DETAIL]: new Route( + `/dashboard/${RouteParams.ProjectID}/kubernetes/${ + KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_NAMESPACE_DETAIL] + }`, + ), + [PageMap.KUBERNETES_CLUSTER_VIEW_PODS]: new Route( `/dashboard/${RouteParams.ProjectID}/kubernetes/${ KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_PODS] @@ -1511,6 +1537,66 @@ const RouteMap: Dictionary = { }`, ), + [PageMap.KUBERNETES_CLUSTER_VIEW_DEPLOYMENTS]: new Route( + `/dashboard/${RouteParams.ProjectID}/kubernetes/${ + KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_DEPLOYMENTS] + }`, + ), + + [PageMap.KUBERNETES_CLUSTER_VIEW_DEPLOYMENT_DETAIL]: new Route( + `/dashboard/${RouteParams.ProjectID}/kubernetes/${ + KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_DEPLOYMENT_DETAIL] + }`, + ), + + [PageMap.KUBERNETES_CLUSTER_VIEW_STATEFULSETS]: new Route( + `/dashboard/${RouteParams.ProjectID}/kubernetes/${ + KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_STATEFULSETS] + }`, + ), + + [PageMap.KUBERNETES_CLUSTER_VIEW_STATEFULSET_DETAIL]: new Route( + `/dashboard/${RouteParams.ProjectID}/kubernetes/${ + KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_STATEFULSET_DETAIL] + }`, + ), + + [PageMap.KUBERNETES_CLUSTER_VIEW_DAEMONSETS]: new Route( + `/dashboard/${RouteParams.ProjectID}/kubernetes/${ + KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_DAEMONSETS] + }`, + ), + + [PageMap.KUBERNETES_CLUSTER_VIEW_DAEMONSET_DETAIL]: new Route( + `/dashboard/${RouteParams.ProjectID}/kubernetes/${ + KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_DAEMONSET_DETAIL] + }`, + ), + + [PageMap.KUBERNETES_CLUSTER_VIEW_JOBS]: new Route( + `/dashboard/${RouteParams.ProjectID}/kubernetes/${ + KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_JOBS] + }`, + ), + + [PageMap.KUBERNETES_CLUSTER_VIEW_JOB_DETAIL]: new Route( + `/dashboard/${RouteParams.ProjectID}/kubernetes/${ + KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_JOB_DETAIL] + }`, + ), + + [PageMap.KUBERNETES_CLUSTER_VIEW_CRONJOBS]: new Route( + `/dashboard/${RouteParams.ProjectID}/kubernetes/${ + KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_CRONJOBS] + }`, + ), + + [PageMap.KUBERNETES_CLUSTER_VIEW_CRONJOB_DETAIL]: new Route( + `/dashboard/${RouteParams.ProjectID}/kubernetes/${ + KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_CRONJOB_DETAIL] + }`, + ), + [PageMap.KUBERNETES_CLUSTER_VIEW_NODES]: new Route( `/dashboard/${RouteParams.ProjectID}/kubernetes/${ KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_NODES] @@ -1523,6 +1609,18 @@ const RouteMap: Dictionary = { }`, ), + [PageMap.KUBERNETES_CLUSTER_VIEW_CONTAINERS]: new Route( + `/dashboard/${RouteParams.ProjectID}/kubernetes/${ + KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_CONTAINERS] + }`, + ), + + [PageMap.KUBERNETES_CLUSTER_VIEW_CONTAINER_DETAIL]: new Route( + `/dashboard/${RouteParams.ProjectID}/kubernetes/${ + KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_CONTAINER_DETAIL] + }`, + ), + [PageMap.KUBERNETES_CLUSTER_VIEW_EVENTS]: new Route( `/dashboard/${RouteParams.ProjectID}/kubernetes/${ KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_EVENTS] diff --git a/App/FeatureSet/Docs/Content/configuration/ip-addresses.md b/App/FeatureSet/Docs/Content/configuration/ip-addresses.md index 779a3d4f5e..d5dc83b6db 100644 --- a/App/FeatureSet/Docs/Content/configuration/ip-addresses.md +++ b/App/FeatureSet/Docs/Content/configuration/ip-addresses.md @@ -4,7 +4,24 @@ If you are using OneUptime.com and want to whitelist our IP's for security reaso Please whitelist the following IP's in your firewall to allow oneuptime.com to reach your resources. -- 172.174.206.132 -- 57.151.99.117 +{{IP_WHITELIST}} -These IP's can change, we will let you know in advance if this happens. +These IP's can change, we will let you know in advance if this happens. + +## Fetch IP Addresses Programmatically + +You can also fetch the list of probe egress IP addresses programmatically via the following API endpoint: + +``` +GET https://oneuptime.com/ip-whitelist +``` + +This returns a JSON response: + +```json +{ + "ipWhitelist": [""] +} +``` + +You can use this endpoint to keep your firewall whitelist updated automatically. diff --git a/App/FeatureSet/Docs/Index.ts b/App/FeatureSet/Docs/Index.ts index ed559783bf..7c97deaccc 100755 --- a/App/FeatureSet/Docs/Index.ts +++ b/App/FeatureSet/Docs/Index.ts @@ -13,7 +13,7 @@ import Response from "Common/Server/Utils/Response"; import LocalFile from "Common/Server/Utils/LocalFile"; import logger from "Common/Server/Utils/Logger"; import "ejs"; -import { IsBillingEnabled } from "Common/Server/EnvironmentConfig"; +import { IsBillingEnabled, IpWhitelist } from "Common/Server/EnvironmentConfig"; const DocsFeatureSet: FeatureSet = { init: async (): Promise => { @@ -78,6 +78,24 @@ const DocsFeatureSet: FeatureSet = { // Remove first line (title) from content as it is already present in the navigation contentInMarkdown = contentInMarkdown.split("\n").slice(1).join("\n"); + // Replace dynamic placeholders in markdown content + if (contentInMarkdown.includes("{{IP_WHITELIST}}")) { + const ipList: string = IpWhitelist + ? IpWhitelist.split(",") + .map((ip: string) => { + return `- ${ip.trim()}`; + }) + .filter((line: string) => { + return line.length > 2; + }) + .join("\n") + : "- No IP addresses configured."; + contentInMarkdown = contentInMarkdown.replace( + "{{IP_WHITELIST}}", + ipList, + ); + } + // Render Markdown content to HTML const renderedContent: string = await DocsRender.render(contentInMarkdown); diff --git a/App/FeatureSet/Identity/API/Authentication.ts b/App/FeatureSet/Identity/API/Authentication.ts index c098739032..6c61a49249 100644 --- a/App/FeatureSet/Identity/API/Authentication.ts +++ b/App/FeatureSet/Identity/API/Authentication.ts @@ -575,6 +575,11 @@ router.post( }, }); + // Revoke all active sessions for this user on password reset + await UserSessionService.revokeAllSessionsByUserId(alreadySavedUser.id!, { + reason: "Password reset", + }); + const host: Hostname = await DatabaseConfig.getHost(); const httpProtocol: Protocol = await DatabaseConfig.getHttpProtocol(); diff --git a/Common/Server/API/IPWhitelistAPI.ts b/Common/Server/API/IPWhitelistAPI.ts new file mode 100644 index 0000000000..4e9c150fee --- /dev/null +++ b/Common/Server/API/IPWhitelistAPI.ts @@ -0,0 +1,31 @@ +import Express, { + ExpressRequest, + ExpressResponse, + ExpressRouter, +} from "../Utils/Express"; +import Response from "../Utils/Response"; +import { IpWhitelist } from "../EnvironmentConfig"; + +export default class IPWhitelistAPI { + public static init(): ExpressRouter { + const router: ExpressRouter = Express.getRouter(); + + router.get("/ip-whitelist", (req: ExpressRequest, res: ExpressResponse) => { + const ipList: Array = IpWhitelist + ? IpWhitelist.split(",") + .map((ip: string) => { + return ip.trim(); + }) + .filter((ip: string) => { + return ip.length > 0; + }) + : []; + + Response.sendJsonObjectResponse(req, res, { + ipWhitelist: ipList, + }); + }); + + return router; + } +} diff --git a/Common/Server/API/Index.ts b/Common/Server/API/Index.ts index a4aba7cf9c..c135fd7fb9 100644 --- a/Common/Server/API/Index.ts +++ b/Common/Server/API/Index.ts @@ -1,5 +1,6 @@ import Express, { ExpressApplication } from "../Utils/Express"; import StatusAPI, { StatusAPIOptions } from "./StatusAPI"; +import IPWhitelistAPI from "./IPWhitelistAPI"; import version from "./VersionAPI"; const app: ExpressApplication = Express.getExpressApp(); @@ -14,6 +15,7 @@ type InitFunction = (data: InitOptions) => void; const init: InitFunction = (data: InitOptions): void => { app.use([`/${data.appName}`, "/"], version); app.use([`/${data.appName}`, "/"], StatusAPI.init(data.statusOptions)); + app.use([`/${data.appName}`, "/"], IPWhitelistAPI.init()); }; export default init; diff --git a/Common/Server/EnvironmentConfig.ts b/Common/Server/EnvironmentConfig.ts index c095ad9073..76c4947eb2 100644 --- a/Common/Server/EnvironmentConfig.ts +++ b/Common/Server/EnvironmentConfig.ts @@ -397,6 +397,8 @@ export const DocsClientUrl: URL = new URL( new Route(DocsRoute.toString()), ); +export const IpWhitelist: string = process.env["IP_WHITELIST"] || ""; + export const DisableTelemetry: boolean = process.env["DISABLE_TELEMETRY"] === "true"; diff --git a/Common/Server/Services/UserService.ts b/Common/Server/Services/UserService.ts index d62afad5d4..8b744f0540 100755 --- a/Common/Server/Services/UserService.ts +++ b/Common/Server/Services/UserService.ts @@ -13,6 +13,7 @@ import MailService from "./MailService"; import TeamMemberService from "./TeamMemberService"; import UserNotificationRuleService from "./UserNotificationRuleService"; import UserNotificationSettingService from "./UserNotificationSettingService"; +import UserSessionService from "./UserSessionService"; import { AccountsRoute } from "../../ServiceRoute"; import Hostname from "../../Types/API/Hostname"; import Protocol from "../../Types/API/Protocol"; @@ -252,6 +253,11 @@ export class Service extends DatabaseService { const httpProtocol: Protocol = await DatabaseConfig.getHttpProtocol(); for (const user of onUpdate.carryForward) { + // Revoke all active sessions for this user on password change + await UserSessionService.revokeAllSessionsByUserId(user.id!, { + reason: "Password changed", + }); + // password changed, send password changed mail MailService.sendMail({ toEmail: user.email!, diff --git a/Common/Server/Services/UserSessionService.ts b/Common/Server/Services/UserSessionService.ts index d670bd37f4..8e4b473554 100644 --- a/Common/Server/Services/UserSessionService.ts +++ b/Common/Server/Services/UserSessionService.ts @@ -1,6 +1,7 @@ import DatabaseService from "./DatabaseService"; import Model from "../../Models/DatabaseModels/UserSession"; import ObjectID from "../../Types/ObjectID"; +import LIMIT_MAX from "../../Types/Database/LimitMax"; import { JSONObject } from "../../Types/JSON"; import HashedString from "../../Types/HashedString"; import { EncryptionSecret } from "../EnvironmentConfig"; @@ -275,6 +276,28 @@ export class Service extends DatabaseService { await this.revokeSessionById(session.id, options); } + public async revokeAllSessionsByUserId( + userId: ObjectID, + options?: RevokeSessionOptions, + ): Promise { + await this.updateBy({ + query: { + userId: userId, + isRevoked: false, + }, + data: { + isRevoked: true, + revokedAt: OneUptimeDate.getCurrentDate(), + revokedReason: options?.reason ?? null, + }, + limit: LIMIT_MAX, + skip: 0, + props: { + isRoot: true, + }, + }); + } + private buildSessionModel( options: CreateSessionOptions, tokenMeta: { refreshToken: string; refreshTokenExpiresAt: Date }, diff --git a/HelmChart/Public/kubernetes-agent/templates/configmap-daemonset.yaml b/HelmChart/Public/kubernetes-agent/templates/configmap-daemonset.yaml index d00626b001..a87cff9018 100644 --- a/HelmChart/Public/kubernetes-agent/templates/configmap-daemonset.yaml +++ b/HelmChart/Public/kubernetes-agent/templates/configmap-daemonset.yaml @@ -13,6 +13,21 @@ data: endpoint: "0.0.0.0:13133" receivers: + # Collect node, pod, and container resource metrics from kubelet + kubeletstats: + collection_interval: {{ .Values.collectionInterval }} + auth_type: serviceAccount + endpoint: "https://${env:NODE_NAME}:10250" + insecure_skip_verify: true + metric_groups: + - node + - pod + - container + extra_metadata_labels: + - container.id + k8s_api_config: + auth_type: serviceAccount + # Collect pod logs from /var/log/pods filelog: include: @@ -95,11 +110,25 @@ data: - k8s.replicaset.name - k8s.statefulset.name - k8s.daemonset.name + - k8s.job.name + - k8s.cronjob.name - k8s.container.name + labels: + - tag_name: k8s.pod.label.app + key: app + from: pod + - tag_name: k8s.pod.label.app.kubernetes.io/name + key: app.kubernetes.io/name + from: pod pod_association: + - sources: + - from: resource_attribute + name: k8s.pod.ip - sources: - from: resource_attribute name: k8s.pod.uid + - sources: + - from: connection # Stamp with cluster name resource: @@ -114,8 +143,8 @@ data: memory_limiter: check_interval: 5s - limit_mib: 200 - spike_limit_mib: 50 + limit_mib: 400 + spike_limit_mib: 100 exporters: otlphttp: @@ -127,6 +156,16 @@ data: extensions: - health_check pipelines: + metrics: + receivers: + - kubeletstats + processors: + - memory_limiter + - k8sattributes + - resource + - batch + exporters: + - otlphttp logs: receivers: - filelog diff --git a/HelmChart/Public/kubernetes-agent/templates/configmap-deployment.yaml b/HelmChart/Public/kubernetes-agent/templates/configmap-deployment.yaml index a28e092cb4..a4ab08a5a1 100644 --- a/HelmChart/Public/kubernetes-agent/templates/configmap-deployment.yaml +++ b/HelmChart/Public/kubernetes-agent/templates/configmap-deployment.yaml @@ -12,21 +12,6 @@ data: endpoint: "0.0.0.0:13133" receivers: - # Collect node, pod, and container resource metrics from kubelet - kubeletstats: - collection_interval: {{ .Values.collectionInterval }} - auth_type: serviceAccount - endpoint: "https://${env:NODE_NAME}:10250" - insecure_skip_verify: true - metric_groups: - - node - - pod - - container - extra_metadata_labels: - - container.id - k8s_api_config: - auth_type: serviceAccount - # Collect cluster-level metrics from the Kubernetes API k8s_cluster: collection_interval: {{ .Values.collectionInterval }} @@ -47,6 +32,38 @@ data: - name: events mode: watch group: events.k8s.io + {{- if .Values.resourceSpecs.enabled }} + # Pull full resource specs for dashboard detail views + - name: pods + mode: pull + interval: {{ .Values.resourceSpecs.interval }} + - name: nodes + mode: pull + interval: {{ .Values.resourceSpecs.interval }} + - name: namespaces + mode: pull + interval: {{ .Values.resourceSpecs.interval }} + - name: deployments + mode: pull + interval: {{ .Values.resourceSpecs.interval }} + group: apps + - name: statefulsets + mode: pull + interval: {{ .Values.resourceSpecs.interval }} + group: apps + - name: daemonsets + mode: pull + interval: {{ .Values.resourceSpecs.interval }} + group: apps + - name: jobs + mode: pull + interval: {{ .Values.resourceSpecs.interval }} + group: batch + - name: cronjobs + mode: pull + interval: {{ .Values.resourceSpecs.interval }} + group: batch + {{- end }} {{- if .Values.controlPlane.enabled }} # Scrape control plane metrics via Prometheus endpoints @@ -137,21 +154,31 @@ data: # Batch telemetry for efficient export batch: - send_batch_size: 200 - send_batch_max_size: 500 + send_batch_size: 100 + send_batch_max_size: 200 timeout: 10s # Limit memory usage memory_limiter: check_interval: 5s - limit_mib: 1500 - spike_limit_mib: 300 + limit_mib: 3000 + spike_limit_mib: 600 exporters: otlphttp: endpoint: "{{ .Values.oneuptime.url }}/otlp" + encoding: json headers: x-oneuptime-token: "${env:ONEUPTIME_API_KEY}" + sending_queue: + enabled: true + num_consumers: 10 + queue_size: 5000 + retry_on_failure: + enabled: true + initial_interval: 5s + max_interval: 60s + max_elapsed_time: 300s service: extensions: @@ -159,7 +186,6 @@ data: pipelines: metrics: receivers: - - kubeletstats - k8s_cluster {{- if .Values.controlPlane.enabled }} - prometheus diff --git a/HelmChart/Public/kubernetes-agent/templates/daemonset.yaml b/HelmChart/Public/kubernetes-agent/templates/daemonset.yaml index c2cfcb8036..e6f6d8bcb6 100644 --- a/HelmChart/Public/kubernetes-agent/templates/daemonset.yaml +++ b/HelmChart/Public/kubernetes-agent/templates/daemonset.yaml @@ -21,6 +21,8 @@ spec: checksum/config: {{ include (print $.Template.BasePath "/configmap-daemonset.yaml") . | sha256sum }} spec: serviceAccountName: {{ include "kubernetes-agent.serviceAccountName" . }} + tolerations: + - operator: Exists containers: - name: otel-collector image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" diff --git a/HelmChart/Public/kubernetes-agent/values.schema.json b/HelmChart/Public/kubernetes-agent/values.schema.json index c602921306..d334964c72 100644 --- a/HelmChart/Public/kubernetes-agent/values.schema.json +++ b/HelmChart/Public/kubernetes-agent/values.schema.json @@ -184,6 +184,21 @@ }, "additionalProperties": false }, + "resourceSpecs": { + "type": "object", + "description": "Pull full K8s resource specs for dashboard detail views (labels, annotations, env vars, status, etc.)", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable pulling full resource specs via k8sobjects receiver" + }, + "interval": { + "type": "string", + "description": "How often to pull resource specs (e.g., 300s, 5m)" + } + }, + "additionalProperties": false + }, "collectionInterval": { "type": "string", "description": "Collection interval for metrics (e.g., 30s, 1m)" diff --git a/HelmChart/Public/kubernetes-agent/values.yaml b/HelmChart/Public/kubernetes-agent/values.yaml index 1dff9d71b2..691915d72c 100644 --- a/HelmChart/Public/kubernetes-agent/values.yaml +++ b/HelmChart/Public/kubernetes-agent/values.yaml @@ -30,10 +30,10 @@ deployment: resources: requests: cpu: 200m - memory: 512Mi + memory: 1Gi limits: cpu: 1000m - memory: 2Gi + memory: 4Gi # Control plane monitoring (etcd, API server, scheduler, controller manager) # Disabled by default — enable for self-managed clusters. @@ -70,6 +70,13 @@ logs: cpu: 200m memory: 256Mi +# Resource spec collection — pulls full K8s resource objects (pods, nodes, +# deployments, etc.) for displaying labels, annotations, env vars, status, +# and other details in the dashboard. +resourceSpecs: + enabled: true + interval: 300s # How often to pull full resource specs (default: 5 minutes) + # Collection intervals collectionInterval: 30s diff --git a/HelmChart/Public/oneuptime/templates/_helpers.tpl b/HelmChart/Public/oneuptime/templates/_helpers.tpl index 2f7d151272..19a488fe5a 100644 --- a/HelmChart/Public/oneuptime/templates/_helpers.tpl +++ b/HelmChart/Public/oneuptime/templates/_helpers.tpl @@ -128,6 +128,8 @@ Usage: value: {{ $.Values.home.ports.http | squote }} - name: WORKER_PORT value: {{ $.Values.worker.ports.http | squote }} +- name: IP_WHITELIST + value: {{ default "" $.Values.ipWhitelist | quote }} {{- end }} diff --git a/HelmChart/Public/oneuptime/values.schema.json b/HelmChart/Public/oneuptime/values.schema.json index 80ab761178..bae8b8e5f2 100644 --- a/HelmChart/Public/oneuptime/values.schema.json +++ b/HelmChart/Public/oneuptime/values.schema.json @@ -41,6 +41,10 @@ "encryptionSecret": { "type": ["string", "null"] }, + "ipWhitelist": { + "type": ["string", "null"], + "description": "Comma-separated list of probe egress IP addresses for firewall whitelisting. Returned via the /ip-whitelist API endpoint." + }, "externalSecrets": { "type": "object", "properties": { diff --git a/HelmChart/Public/oneuptime/values.yaml b/HelmChart/Public/oneuptime/values.yaml index 1f25e79c5e..3ff4e282eb 100644 --- a/HelmChart/Public/oneuptime/values.yaml +++ b/HelmChart/Public/oneuptime/values.yaml @@ -35,6 +35,12 @@ oneuptimeSecret: registerProbeKey: encryptionSecret: +# Comma-separated list of egress IP addresses that probes use for monitoring checks. +# Customers can use this to whitelist probe traffic in their firewalls. +# This is returned as a JSON array via the /ip-whitelist API endpoint. +# Example: "203.0.113.1,203.0.113.2,198.51.100.10" +ipWhitelist: + # External Secrets # You need to leave blank oneuptimeSecret and encryptionSecret to use this section externalSecrets: @@ -128,6 +134,8 @@ postgresql: # pg_hba.conf rules. These enable password auth (md5) from any host/IP. # Tighten these for production to your pod/service/network CIDRs. hbaConfiguration: |- + # Local connections (needed for initdb/entrypoint to create databases) + local all all trust # Allow all IPv4 and IPv6 clients with md5 password auth host all all 0.0.0.0/0 md5 host all all ::/0 md5 diff --git a/Home/Routes.ts b/Home/Routes.ts index cff91f0543..5d3f845059 100755 --- a/Home/Routes.ts +++ b/Home/Routes.ts @@ -1208,6 +1208,41 @@ const HomeFeatureSet: FeatureSet = { }, ); + app.get( + "/product/kubernetes", + (_req: ExpressRequest, res: ExpressResponse) => { + const seo: PageSEOData & { fullCanonicalUrl: string } = getSEOForPath( + "/product/kubernetes", + res.locals["homeUrl"] as string, + ); + res.render(`${ViewsPath}/kubernetes`, { + enableGoogleTagManager: IsBillingEnabled, + seo, + }); + }, + ); + + app.get( + "/product/scheduled-maintenance", + (_req: ExpressRequest, res: ExpressResponse) => { + const seo: PageSEOData & { fullCanonicalUrl: string } = getSEOForPath( + "/product/scheduled-maintenance", + res.locals["homeUrl"] as string, + ); + res.render(`${ViewsPath}/scheduled-maintenance`, { + enableGoogleTagManager: IsBillingEnabled, + seo, + }); + }, + ); + + app.get( + "/scheduled-maintenance", + (_req: ExpressRequest, res: ExpressResponse) => { + res.redirect("/product/scheduled-maintenance"); + }, + ); + app.get("/product/traces", (_req: ExpressRequest, res: ExpressResponse) => { const seo: PageSEOData & { fullCanonicalUrl: string } = getSEOForPath( "/product/traces", diff --git a/Home/Utils/PageSEO.ts b/Home/Utils/PageSEO.ts index 53766621a6..8ea5e37d28 100644 --- a/Home/Utils/PageSEO.ts +++ b/Home/Utils/PageSEO.ts @@ -403,6 +403,77 @@ export const PageSEOConfig: Record = { }, }, + "/product/kubernetes": { + title: + "Kubernetes Observability | Monitor Clusters, Pods & Nodes | OneUptime", + description: + "Complete Kubernetes observability with real-time cluster monitoring, pod health tracking, node metrics, and automated alerting. OpenTelemetry native. Open source.", + canonicalPath: "/product/kubernetes", + twitterCard: "summary_large_image", + pageType: "product", + breadcrumbs: [ + { name: "Home", url: "/" }, + { name: "Products", url: "/#products" }, + { name: "Kubernetes", url: "/product/kubernetes" }, + ], + softwareApplication: { + name: "OneUptime Kubernetes Observability", + applicationCategory: "DeveloperApplication", + operatingSystem: "Web, Cloud", + description: + "Monitor Kubernetes clusters, nodes, pods, and containers with real-time metrics, intelligent alerting, and pre-built dashboards.", + features: [ + "Multi-cluster monitoring", + "Node health and metrics", + "Pod and container monitoring", + "CrashLoopBackOff detection", + "OOMKill alerting", + "Resource utilization tracking", + "Namespace-level breakdowns", + "OpenTelemetry native", + "DaemonSet deployment", + "Kubelet stats receiver", + "Logs and traces correlation", + ], + }, + }, + + "/product/scheduled-maintenance": { + title: "Scheduled Maintenance | Plan & Communicate Downtime | OneUptime", + description: + "Plan, schedule, and communicate maintenance windows to your users. Notify subscribers automatically, update status pages in real-time. Open source maintenance management.", + canonicalPath: "/product/scheduled-maintenance", + twitterCard: "summary_large_image", + pageType: "product", + breadcrumbs: [ + { name: "Home", url: "/" }, + { name: "Products", url: "/#products" }, + { + name: "Scheduled Maintenance", + url: "/product/scheduled-maintenance", + }, + ], + softwareApplication: { + name: "OneUptime Scheduled Maintenance", + applicationCategory: "DeveloperApplication", + operatingSystem: "Web, Cloud", + description: + "Schedule maintenance windows, notify subscribers via email, SMS, and webhooks, and keep your status page updated in real-time.", + features: [ + "Maintenance scheduling", + "Subscriber notifications", + "Status page integration", + "Custom maintenance states", + "Email and SMS alerts", + "Webhook integrations", + "Slack and Teams notifications", + "Maintenance timeline", + "Affected monitors tracking", + "Automatic state transitions", + ], + }, + }, + "/product/traces": { title: "Distributed Tracing | End-to-End Request Tracing | OneUptime", description: diff --git a/Home/Utils/Sitemap.ts b/Home/Utils/Sitemap.ts index 0044b97e42..561ec7ad95 100644 --- a/Home/Utils/Sitemap.ts +++ b/Home/Utils/Sitemap.ts @@ -37,6 +37,7 @@ const PAGE_CONFIG: Record = { "/product/workflows": { priority: 0.9, changefreq: "weekly" }, "/product/dashboards": { priority: 0.9, changefreq: "weekly" }, "/product/ai-agent": { priority: 0.9, changefreq: "weekly" }, + "/product/scheduled-maintenance": { priority: 0.9, changefreq: "weekly" }, // Teams (Solutions) pages "/solutions/devops": { priority: 0.8, changefreq: "weekly" }, diff --git a/Home/Views/Partials/hero-cards/scheduled-maintenance.ejs b/Home/Views/Partials/hero-cards/scheduled-maintenance.ejs new file mode 100644 index 0000000000..4730898014 --- /dev/null +++ b/Home/Views/Partials/hero-cards/scheduled-maintenance.ejs @@ -0,0 +1,10 @@ + + diff --git a/Home/Views/Partials/icons/kubernetes.ejs b/Home/Views/Partials/icons/kubernetes.ejs new file mode 100644 index 0000000000..f16d9ef555 --- /dev/null +++ b/Home/Views/Partials/icons/kubernetes.ejs @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/Home/Views/Partials/icons/scheduled-maintenance.ejs b/Home/Views/Partials/icons/scheduled-maintenance.ejs new file mode 100644 index 0000000000..580475a444 --- /dev/null +++ b/Home/Views/Partials/icons/scheduled-maintenance.ejs @@ -0,0 +1,4 @@ + + + + diff --git a/Home/Views/kubernetes.ejs b/Home/Views/kubernetes.ejs new file mode 100644 index 0000000000..11ef99a596 --- /dev/null +++ b/Home/Views/kubernetes.ejs @@ -0,0 +1,1297 @@ + + + + + + + OneUptime | Kubernetes Observability - Monitor Clusters, Pods & Nodes + + <%- include('head', { + enableGoogleTagManager: typeof enableGoogleTagManager !== 'undefined' ? enableGoogleTagManager : false +}) -%> + + + + <%- include('nav') -%> + +
+ +
+ +
+ + +
+ +
+
+
+ +

OpenTelemetry Native Kubernetes Monitoring

+ +

+ Full visibility into your Kubernetes clusters +

+

+ Monitor clusters, nodes, pods, and containers in real-time. Detect issues before they impact your workloads with intelligent alerting and dashboards. +

+ + + + +
+ Cluster health + + Pod monitoring + + Node metrics + + Container insights +
+
+ + +
+
+
+ +
+ +
+
+
+
+
+
+
+
+ + + + Kubernetes Cluster Overview +
+
+
+
+ + + + Last 1 hour +
+ + + prod-us-east-1 + +
+
+ + +
+ +
+
+
+
Nodes
+
+ + + +
+
+
12
+
+ + All healthy +
+
+
+
+
Pods
+
+ + + +
+
+
147
+
+ 143 running + 3 pending + 1 failed +
+
+
+
+
Deployments
+
+ + + +
+
+
23
+
+ + All available +
+
+
+
+
CPU
+
+ + + +
+
+
62%
+
+
+
+
+
+
+
Memory
+
+ + + +
+
+
71%
+
+
+
+
+
+ + +
+ +
+
+
+
Cluster Resource Usage
+
CPU and Memory over time
+
+
+
+ + CPU +
+
+ + Memory +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + 75% + 50% + 25% + +
+ + +
+
Pod Status
+
Across all namespaces
+ +
+
+ + + + + + +
+ 97% + healthy +
+
+
+
+
+
+ + Running +
+ 143 +
+
+
+ + Pending +
+ 3 +
+
+
+ + Failed +
+ 1 +
+
+
+
+ + +
+ +
+
+ Namespaces + 6 total +
+
+
+
+ + production +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 48 pods +
+
+
+
+ + staging +
+
+
+
+
+
+
+
+
+
+
+
+ 32 pods +
+
+
+
+ + monitoring +
+
+
+
+
+
+
+
+
+ 18 pods +
+
+
+
+ + kube-system +
+
+
+
+
+
+
+
+ 14 pods +
+
+
+
+ + +
+
+ Node Health + 12 nodes +
+
+
+
+ + node-01 +
+
+
+ CPU +
+
+
+ MEM +
+
+
+
+
+
+ + node-02 +
+
+
+ CPU +
+
+
+ MEM +
+
+
+
+
+
+ + node-03 +
+
+
+ CPU +
+
+
+ MEM +
+
+
+
+
+
+ + node-04 +
+
+
+ CPU +
+
+
+ MEM +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + <%- include('logo-roll') -%> + + +
+
+
+

How It Works

+

+ Start monitoring Kubernetes in minutes +

+

+ Deploy our lightweight collector and get full visibility into your clusters instantly. +

+
+ +
+ + + +
+ +
+
+ + + + 1 +
+

Deploy Collector

+

Install the OpenTelemetry Collector as a DaemonSet in your cluster with a single Helm command.

+
+ + +
+
+ + + + + 2 +
+

Auto-discover

+

Automatically discover all nodes, pods, containers, and services in your cluster.

+
+ + +
+
+ + + + 3 +
+

View Dashboards

+

Get pre-built dashboards for cluster, node, pod, and container-level metrics.

+
+ + +
+
+ + + + 4 +
+

Set Alerts

+

Configure alerts for pod crashes, node pressure, resource limits, and more.

+
+
+
+
+
+ + +
+ +
+ +
+
+ + + + Why OneUptime for Kubernetes +
+

+ Kubernetes observability built for production +

+

+ From cluster health to individual container metrics, OneUptime provides deep visibility into every layer of your Kubernetes infrastructure. +

+
+ + +
+ + +
+
+
+
+ + + +
+ Cluster Health +
+

Complete cluster visibility at a glance

+

See the health of every cluster, namespace, and workload in a single pane of glass. Instantly know when nodes are under pressure or pods are failing.

+
    +
  • + + + + Multi-cluster monitoring +
  • +
  • + + + + Namespace-level breakdowns +
  • +
  • + + + + Real-time resource utilization +
  • +
+ + Get started + + + + +
+
+
+
+ + +
+
+
+
+
+
+
+
+
+ Cluster Health +
+
+
+ +
+
+
+ + + +
+
+
prod-us-east
+
12 nodes, 147 pods
+
+
+ Healthy +
+ +
+
+
+ + + +
+
+
prod-eu-west
+
8 nodes, 89 pods
+
+
+ Warning +
+ +
+
+
+ + + +
+
+
staging-us
+
4 nodes, 42 pods
+
+
+ Healthy +
+
+
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+
+
+
+ Pod Details +
+
+
+ +
+
+
api-server-7d4f8b6c5-x2k9p
+
namespace: production
+
+ + + Running + +
+ + +
+
+
+ CPU Usage + 245m / 500m +
+
+
+
+
+
+
+ Memory Usage + 312Mi / 512Mi +
+
+
+
+
+
+
+ Network I/O + 2.4 MB/s +
+
+ RX: 1.8 MB/s + TX: 0.6 MB/s +
+
+
+ + +
+ Restarts + 0 +
+
+
+
+
+
+
+
+ + + +
+ Pod & Container Monitoring +
+

Deep visibility into every pod and container

+

Track CPU, memory, network, and disk usage for every container. See resource requests vs limits, restart counts, and OOMKill events in real-time.

+
    +
  • + + + + Container-level CPU & memory metrics +
  • +
  • + + + + OOMKill and CrashLoopBackOff detection +
  • +
  • + + + + Resource requests vs limits tracking +
  • +
+ + Get started + + + + +
+
+ + +
+
+
+
+ + + +
+ Node Monitoring +
+

Monitor every node in your cluster

+

Track node-level CPU, memory, disk, and network metrics. Get alerted on node pressure conditions, disk space issues, and scheduling problems.

+
    +
  • + + + + CPU, memory, disk & network metrics +
  • +
  • + + + + Node condition alerts (MemoryPressure, DiskPressure) +
  • +
  • + + + + Kubelet and system component health +
  • +
+ + Get started + + + + +
+
+
+
+ + +
+
+
+
+
+
+
+
+ Node Metrics +
+
+
+ +
+
+ + node-01 +
+
+ CPU 45% + MEM 62% + DISK 38% +
+
+
+
+ + node-02 +
+
+ CPU 67% + MEM 71% + DISK 42% +
+
+
+
+ + node-03 +
+
+ CPU 89% + MEM 78% + DISK 55% +
+
+
+
+ + node-04 +
+
+ CPU 32% + MEM 48% + DISK 29% +
+
+
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+
+
+
+ Active Alerts +
+
+
+
+
+ CRITICAL + 2 min ago +
+
Pod CrashLoopBackOff
+
payment-service-6d8f9 in production
+
+
+
+ WARNING + 15 min ago +
+
Node Memory Pressure
+
node-03 memory at 89%
+
+
+
+ INFO + 1 hr ago +
+
Deployment Scaled Up
+
api-server scaled from 3 to 5 replicas
+
+
+
+
+
+
+
+
+ + + +
+ Smart Alerting +
+

Kubernetes-aware alerting

+

Get alerts that understand Kubernetes semantics. Detect CrashLoopBackOff, OOMKill, pending pods, failed deployments, and node pressure conditions automatically.

+
    +
  • + + + + Pod crash and restart alerts +
  • +
  • + + + + Resource quota and limit alerts +
  • +
  • + + + + Multi-channel notifications (Slack, PagerDuty, Email) +
  • +
+ + Get started + + + + +
+
+ +
+
+
+ + +
+ +
+ +
+
+ + + + Advanced capabilities +
+

+ Everything you need for Kubernetes observability +

+

+ Built for production Kubernetes environments with enterprise-grade features. +

+
+ + +
+
+ + +
+
+
+ + + +
+

OpenTelemetry Native

+

Built on OpenTelemetry from the ground up. Use the standard K8s receivers and the kubeletstats receiver for comprehensive metrics.

+
+
+ + +
+
+
+ + + +
+

Multi-Cluster Support

+

Monitor all your clusters from a single dashboard. Compare resource usage across environments and regions.

+
+
+ + +
+
+
+ + + +
+

Logs & Traces Correlation

+

Jump from a pod metric spike to its container logs and distributed traces. Full correlation across all telemetry signals.

+
+
+ + +
+
+
+ + + +
+

DaemonSet Deployment

+

Deploy as a DaemonSet with tolerations for all nodes including control plane. One Helm command to get started.

+
+
+ + +
+
+
+ + + +
+

Resource Right-sizing

+

See actual resource usage vs requests and limits. Identify over-provisioned and under-provisioned workloads to optimize costs.

+
+
+ + +
+
+
+ + + +
+

Enterprise Compliance

+

SOC 2/3, ISO, GDPR, HIPAA compliant. Data residency options. Self-hosted or cloud deployment.

+
+
+ +
+
+
+
+ + +
+
+
+ +
+

Team Notifications

+

+ Kubernetes alerts where your team already works +

+

+ Get instant notifications for pod crashes, node issues, and deployment failures in Slack and Microsoft Teams. Acknowledge and investigate without leaving your chat app. +

+ + +
+ +
+
+ + + + + + +
+
+

Slack

+

Interactive Kubernetes alert actions

+
+
+ + +
+
+ + + + + + + +
+
+

Microsoft Teams

+

Native adaptive cards integration

+
+
+
+ + +
+
+ + + + Real-time delivery +
+
+ + + + Pod crash alerts +
+
+ + + + Rich formatting +
+
+ + + + Action buttons +
+
+
+ + +
+ +
+ +
+ +
+
+
+
+
+
+
+
+
+ + + + #kubernetes-alerts +
+
+
+ + +
+ +
+
+ + + +
+
+
+ OneUptime Kubernetes + 2:47 PM +
+ +
+
+
+ CRITICAL + Pod CrashLoopBackOff +
+

payment-service-6d8f9 restarting

+
+ + + + + ns: production + + + + + + Restarts: 5 + +
+ +
+ + + +
+
+
+ +
+ + 👀 + 3 + + + 🚨 + 2 + +
+
+
+ + + +
+ + +
+
+ + + + Message #kubernetes-alerts +
+
+
+
+
+
+
+ + <%- include('./Partials/enterprise-ready') -%> + <%- include('features-table') -%> + <%- include('cta') -%> +
+ + <%- include('footer') -%> + <%- include('./Partials/video-script') -%> + + + + + + + + diff --git a/Home/Views/nav.ejs b/Home/Views/nav.ejs index 39a660ad49..c076cf311e 100644 --- a/Home/Views/nav.ejs +++ b/Home/Views/nav.ejs @@ -109,6 +109,17 @@

Smart routing & escalations

+ + + +
+ <%- include('./Partials/icons/scheduled-maintenance', {iconClass: 'h-4 w-4'}) %> +
+
+

Scheduled Maintenance

+

Plan & communicate downtime

+
+
@@ -161,6 +172,17 @@

Error tracking & debugging

+ + + +
+ <%- include('./Partials/icons/kubernetes', {iconClass: 'h-4 w-4'}) %> +
+
+

Kubernetes

+

Cluster & pod observability

+
+
@@ -764,6 +786,14 @@ On-Call + + +
+ <%- include('./Partials/icons/scheduled-maintenance', {iconClass: 'h-4 w-4'}) %> +
+ Maintenance +
+
@@ -796,6 +826,14 @@ Exceptions + + +
+ <%- include('./Partials/icons/kubernetes', {iconClass: 'h-4 w-4'}) %> +
+ Kubernetes +
+
diff --git a/Home/Views/scheduled-maintenance.ejs b/Home/Views/scheduled-maintenance.ejs new file mode 100644 index 0000000000..c228e6dd0d --- /dev/null +++ b/Home/Views/scheduled-maintenance.ejs @@ -0,0 +1,708 @@ + + + + + + + OneUptime | Scheduled Maintenance - Plan & Communicate Downtime + + <%- include('head', { + enableGoogleTagManager: typeof enableGoogleTagManager !== 'undefined' ? enableGoogleTagManager : false +}) -%> + + + + <%- include('nav') -%> + +
+ +
+ +
+ + +
+ +
+
+ + + +
+
+
+ +
+ +
+
+
+
+
+
+
+
+ + + + Scheduled Maintenance +
+
+
+ +
+
+ + +
+ +
+
+
+
Scheduled
+
+ + + +
+
+
3
+
+ Upcoming this week +
+
+
+
+
Ongoing
+
+ + + +
+
+
1
+
+ + In progress +
+
+
+
+
Completed
+
+ + + +
+
+
12
+
+ This month +
+
+
+
+
Subscribers
+
+ + + +
+
+
847
+
+ Auto-notified +
+
+
+ + +
+
+ Upcoming & Ongoing Events + 4 events +
+
+ +
+
+
+
+
Database Migration - Production
+
Started 45 min ago · Est. 2 hours remaining
+
+
+
+ + + Ongoing + +
+
+ +
+
+
+
+
SSL Certificate Renewal
+
Tomorrow, 2:00 AM – 2:30 AM UTC
+
+
+
+ Scheduled +
+
+ +
+
+
+
+
Infrastructure Upgrade - Load Balancers
+
Sat, Mar 22 · 1:00 AM – 5:00 AM UTC
+
+
+
+ Scheduled +
+
+ +
+
+
+
+
API Gateway Version Upgrade
+
Mon, Mar 24 · 3:00 AM – 4:00 AM UTC
+
+
+
+ Scheduled +
+
+
+
+
+
+
+
+
+
+
+
+ + <%- include('logo-roll') -%> + + +
+
+
+

How It Works

+

+ From planning to completion in four steps +

+

+ Schedule maintenance windows, keep users informed, and complete your work without surprises. +

+
+ +
+ + + +
+ +
+
+ + + + 1 +
+

Schedule Event

+

Create a maintenance event with start time, duration, and affected services.

+
+ + +
+
+ + + + 2 +
+

Notify Subscribers

+

Automatically notify subscribers via email, SMS, and webhooks before maintenance begins.

+
+ + +
+
+ + + + 3 +
+

Post Updates

+

Share real-time progress updates on your status page as work proceeds.

+
+ + +
+
+ + + + 4 +
+

Complete & Resolve

+

Mark maintenance complete and automatically notify subscribers that services are restored.

+
+
+
+
+
+ + +
+ +
+ +
+
+ + + + Why OneUptime for Scheduled Maintenance +
+

+ Maintenance communication your users will appreciate +

+

+ Proactive communication builds trust. Keep your users informed about planned downtime so they can plan accordingly. +

+
+ + +
+ + +
+
+
+
+ + + +
+ Planning +
+

Schedule maintenance with precision

+

Define start and end times, select affected monitors and status pages, and set custom maintenance states to communicate exactly what is happening.

+
    +
  • + + + + Set precise start and end times +
  • +
  • + + + + Assign affected monitors and services +
  • +
  • + + + + Custom maintenance states and labels +
  • +
+ + Get started + + + + +
+
+
+
+ + +
+
+
+
+
+
+
+
+
+ Create Maintenance Event +
+
+
+
+ +
Database Migration - Production
+
+
+
+ +
Mar 20, 2:00 AM
+
+
+ +
Mar 20, 6:00 AM
+
+
+
+ +
+ API Server + Database + Web App +
+
+
+ +
+ Public Status Page + Internal Status +
+
+ +
+
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+
+
+
+ Subscriber Notifications +
+
+
+ +
+
+
+ + + +
+
Email
+ 847 sent +
+
Scheduled maintenance notification sent to all subscribers
+
+ +
+
+
+ + + +
+
SMS
+ 123 sent +
+
SMS alerts sent to phone subscribers
+
+ +
+
+
+ + + +
+
Webhooks
+ 5 triggered +
+
Webhook payloads sent to integrations
+
+
+
+
+
+
+
+
+ + + +
+ Notifications +
+

Automatic subscriber notifications

+

Notify your users before maintenance begins via email, SMS, and webhooks. Send updates during maintenance and a final notification when services are restored.

+
    +
  • + + + + Email, SMS, and webhook notifications +
  • +
  • + + + + Pre-maintenance advance notices +
  • +
  • + + + + Completion notifications when resolved +
  • +
+ + Get started + + + + +
+
+ + +
+
+
+
+ + + +
+ Status Page +
+

Seamless status page integration

+

Scheduled maintenance events automatically appear on your status page, keeping your users informed with a timeline of upcoming and ongoing maintenance.

+
    +
  • + + + + Automatic status page updates +
  • +
  • + + + + Public maintenance timeline +
  • +
  • + + + + Real-time progress notes +
  • +
+ + Get started + + + + +
+
+
+
+ + +
+
+
+
+
+
+
+
+
+ Status Page - Maintenance +
+
+
+
+ + + + Scheduled Maintenance +
+ +
+
+
+
In Progress
+
Database Migration - Production
+
Migration is 75% complete. No user impact detected.
+
Updated 15 minutes ago
+
+
+
+
Started
+
Maintenance window opened
+
Beginning database migration. Expected duration: 4 hours.
+
2:00 AM UTC
+
+
+
+
Scheduled
+
Maintenance announced
+
Subscribers notified about upcoming maintenance.
+
Mar 18, 10:00 AM UTC
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+
+

Integrations

+

+ Works with your existing tools +

+

+ Connect scheduled maintenance events to Slack, Microsoft Teams, and more. Keep your entire team in the loop. +

+
+ +
+ +
+
+ + + + + + +
+

Slack

+

Post maintenance updates directly to Slack channels

+
+ + +
+
+ + + + + + + +
+

Microsoft Teams

+

Send maintenance alerts to Teams channels

+
+ + +
+
+ + + +
+

Webhooks

+

Trigger custom workflows with webhook events

+
+
+
+
+ + <%- include('./Partials/enterprise-ready') -%> + <%- include('features-table') -%> + <%- include('cta') -%> +
+ + <%- include('footer') -%> + + + + +