From dc3db1ec4721518879e6ded4987a657db2cf119b Mon Sep 17 00:00:00 2001 From: Nawaz Dhandala Date: Thu, 19 Mar 2026 08:15:35 +0000 Subject: [PATCH] Refactor code structure for improved readability and maintainability --- .../Kubernetes/KubernetesContainersTab.tsx | 257 ++++ .../Kubernetes/KubernetesEventsTab.tsx | 127 ++ .../Kubernetes/KubernetesLogsTab.tsx | 118 ++ .../Kubernetes/KubernetesMetricsTab.tsx | 47 + .../Kubernetes/KubernetesOverviewTab.tsx | 168 +++ .../Utils/KubernetesObjectFetcher.ts | 410 +++++ .../Utils/KubernetesObjectParser.ts | 1329 +++++++++++++++++ .../Pages/Kubernetes/View/ContainerDetail.tsx | 88 +- .../Pages/Kubernetes/View/CronJobDetail.tsx | 166 +- .../Pages/Kubernetes/View/DaemonSetDetail.tsx | 160 +- .../Kubernetes/View/DeploymentDetail.tsx | 157 +- .../src/Pages/Kubernetes/View/Events.tsx | 164 +- .../src/Pages/Kubernetes/View/JobDetail.tsx | 169 ++- .../Pages/Kubernetes/View/NamespaceDetail.tsx | 137 +- .../src/Pages/Kubernetes/View/NodeDetail.tsx | 214 ++- .../src/Pages/Kubernetes/View/PodDetail.tsx | 208 ++- .../Kubernetes/View/StatefulSetDetail.tsx | 163 +- .../templates/configmap-deployment.yaml | 32 + HelmChart/Public/kubernetes-agent/values.yaml | 7 + Home/Routes.ts | 21 + Home/Utils/PageSEO.ts | 37 + Home/Utils/Sitemap.ts | 1 + .../hero-cards/scheduled-maintenance.ejs | 10 + Home/Views/Partials/icons/kubernetes.ejs | 7 +- .../Partials/icons/scheduled-maintenance.ejs | 4 + Home/Views/kubernetes.ejs | 200 +++ Home/Views/nav.ejs | 19 + Home/Views/scheduled-maintenance.ejs | 704 +++++++++ 28 files changed, 4671 insertions(+), 453 deletions(-) create mode 100644 App/FeatureSet/Dashboard/src/Components/Kubernetes/KubernetesContainersTab.tsx create mode 100644 App/FeatureSet/Dashboard/src/Components/Kubernetes/KubernetesEventsTab.tsx create mode 100644 App/FeatureSet/Dashboard/src/Components/Kubernetes/KubernetesLogsTab.tsx create mode 100644 App/FeatureSet/Dashboard/src/Components/Kubernetes/KubernetesMetricsTab.tsx create mode 100644 App/FeatureSet/Dashboard/src/Components/Kubernetes/KubernetesOverviewTab.tsx create mode 100644 App/FeatureSet/Dashboard/src/Pages/Kubernetes/Utils/KubernetesObjectFetcher.ts create mode 100644 App/FeatureSet/Dashboard/src/Pages/Kubernetes/Utils/KubernetesObjectParser.ts create mode 100644 Home/Views/Partials/hero-cards/scheduled-maintenance.ejs create mode 100644 Home/Views/Partials/icons/scheduled-maintenance.ejs create mode 100644 Home/Views/scheduled-maintenance.ejs 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..222da2021f --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Components/Kubernetes/KubernetesContainersTab.tsx @@ -0,0 +1,257 @@ +import React, { FunctionComponent, ReactElement, useState } from "react"; +import Card from "Common/UI/Components/Card/Card"; +import DictionaryOfStringsViewer from "Common/UI/Components/Dictionary/DictionaryOfStingsViewer"; +import { + 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, idx) => ( + + {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, idx) => ( +
+ + {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) => s.name === name, + ); + }; + + return ( +
+ {props.initContainers.map( + (container: KubernetesContainerSpec, index: number) => ( + + ), + )} + {props.containers.map( + (container: KubernetesContainerSpec, index: number) => ( + + ), + )} +
+ ); +}; + +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..22d6c3f139 --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Components/Kubernetes/KubernetesEventsTab.tsx @@ -0,0 +1,127 @@ +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..965790884e --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Components/Kubernetes/KubernetesLogsTab.tsx @@ -0,0 +1,118 @@ +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..5e84c12272 --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Components/Kubernetes/KubernetesMetricsTab.tsx @@ -0,0 +1,47 @@ +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..a0f310b20c --- /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/Pages/Kubernetes/Utils/KubernetesObjectFetcher.ts b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/Utils/KubernetesObjectFetcher.ts new file mode 100644 index 0000000000..e76d0494cc --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/Utils/KubernetesObjectFetcher.ts @@ -0,0 +1,410 @@ +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..30e885237e --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/Utils/KubernetesObjectParser.ts @@ -0,0 +1,1329 @@ +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) => ({ + 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/View/ContainerDetail.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/ContainerDetail.tsx index 865a91f496..79357d0b5e 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/ContainerDetail.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/ContainerDetail.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,6 +21,10 @@ 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 @@ -36,16 +36,6 @@ const KubernetesClusterContainerDetail: FunctionComponent< const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(""); - 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: [], - }); - const fetchCluster: PromiseVoidFunction = async (): Promise => { setIsLoading(true); try { @@ -89,7 +79,8 @@ const KubernetesClusterContainerDetail: FunctionComponent< const attributes: Record = (data["attributes"] as Record) || {}; const name: string = - (attributes["resource.k8s.container.name"] as string) || "Unknown Container"; + (attributes["resource.k8s.container.name"] as string) || + "Unknown Container"; return { title: name }; }; @@ -143,32 +134,55 @@ const KubernetesClusterContainerDetail: FunctionComponent< getSeries: getSeries, }; + const tabs: Array = [ + { + name: "Overview", + children: ( +
+
+ + +
+
+ ), + }, + { + name: "Logs", + children: ( + + + + ), + }, + { + name: "Metrics", + children: ( + + + + ), + }, + ]; + return ( -
- - +
+
+ + +
- - { - setMetricViewData({ - ...data, - queryConfigs: [cpuQuery, memoryQuery], - formulaConfigs: [], - }); - }} - /> - + {}} /> ); }; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/CronJobDetail.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/CronJobDetail.tsx index 623a3ba5d9..93d48f4fa4 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/CronJobDetail.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/CronJobDetail.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,6 +21,13 @@ 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 @@ -35,16 +38,9 @@ const KubernetesClusterCronJobDetail: FunctionComponent< const [cluster, setCluster] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(""); - - 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: [], - }); + const [cronJobObject, setCronJobObject] = + useState(null); + const [isLoadingObject, setIsLoadingObject] = useState(true); const fetchCluster: PromiseVoidFunction = async (): Promise => { setIsLoading(true); @@ -69,6 +65,32 @@ const KubernetesClusterCronJobDetail: FunctionComponent< }); }, []); + // 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 ; } @@ -143,32 +165,104 @@ const KubernetesClusterCronJobDetail: FunctionComponent< 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 ( -
- - +
+
+ + +
- - { - setMetricViewData({ - ...data, - queryConfigs: [cpuQuery, memoryQuery], - formulaConfigs: [], - }); - }} - /> - + {}} /> ); }; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/DaemonSetDetail.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/DaemonSetDetail.tsx index 7c2a344d70..acfcd83f24 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/DaemonSetDetail.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/DaemonSetDetail.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,6 +21,13 @@ 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 @@ -35,16 +38,9 @@ const KubernetesClusterDaemonSetDetail: FunctionComponent< const [cluster, setCluster] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(""); - - 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: [], - }); + const [objectData, setObjectData] = + useState(null); + const [isLoadingObject, setIsLoadingObject] = useState(true); const fetchCluster: PromiseVoidFunction = async (): Promise => { setIsLoading(true); @@ -69,6 +65,31 @@ const KubernetesClusterDaemonSetDetail: FunctionComponent< }); }, []); + // 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 ; } @@ -143,32 +164,99 @@ const KubernetesClusterDaemonSetDetail: FunctionComponent< 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 ( -
- - +
+
+ + +
- - { - setMetricViewData({ - ...data, - queryConfigs: [cpuQuery, memoryQuery], - formulaConfigs: [], - }); - }} - /> - + {}} /> ); }; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/DeploymentDetail.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/DeploymentDetail.tsx index 54c5c6d6ab..ba877cafd1 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/DeploymentDetail.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/DeploymentDetail.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,6 +21,13 @@ 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 @@ -35,16 +38,9 @@ const KubernetesClusterDeploymentDetail: FunctionComponent< const [cluster, setCluster] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(""); - - 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: [], - }); + const [objectData, setObjectData] = + useState(null); + const [isLoadingObject, setIsLoadingObject] = useState(true); const fetchCluster: PromiseVoidFunction = async (): Promise => { setIsLoading(true); @@ -69,6 +65,31 @@ const KubernetesClusterDeploymentDetail: FunctionComponent< }); }, []); + // 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 ; } @@ -143,32 +164,96 @@ const KubernetesClusterDeploymentDetail: FunctionComponent< 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 ( -
- - +
+
+ + +
- - { - setMetricViewData({ - ...data, - queryConfigs: [cpuQuery, memoryQuery], - formulaConfigs: [], - }); - }} - /> - + {}} /> ); }; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Events.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Events.tsx index ba6e56a86b..0302bc902a 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Events.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Events.tsx @@ -24,16 +24,11 @@ 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 +60,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 +83,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 +100,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 +114,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 +122,55 @@ 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) { @@ -297,7 +254,10 @@ const KubernetesClusterEvents: FunctionComponent< const isWarning: boolean = event.type.toLowerCase() === "warning"; return ( - + {event.timestamp} diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/JobDetail.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/JobDetail.tsx index 2ce5d73fe5..8b32227a89 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/JobDetail.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/JobDetail.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,6 +21,13 @@ 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 @@ -35,16 +38,8 @@ const KubernetesClusterJobDetail: FunctionComponent< const [cluster, setCluster] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(""); - - 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: [], - }); + const [jobObject, setJobObject] = useState(null); + const [isLoadingObject, setIsLoadingObject] = useState(true); const fetchCluster: PromiseVoidFunction = async (): Promise => { setIsLoading(true); @@ -69,6 +64,31 @@ const KubernetesClusterJobDetail: FunctionComponent< }); }, []); + // 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 ; } @@ -143,32 +163,109 @@ const KubernetesClusterJobDetail: FunctionComponent< 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 ( -
- - +
+
+ + +
- - { - setMetricViewData({ - ...data, - queryConfigs: [cpuQuery, memoryQuery], - formulaConfigs: [], - }); - }} - /> - + {}} /> ); }; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/NamespaceDetail.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/NamespaceDetail.tsx index d6f4043bd0..fd37e12f59 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/NamespaceDetail.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/NamespaceDetail.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,6 +21,13 @@ 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 @@ -35,16 +38,9 @@ const KubernetesClusterNamespaceDetail: FunctionComponent< const [cluster, setCluster] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(""); - - 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: [], - }); + const [namespaceObject, setNamespaceObject] = + useState(null); + const [isLoadingObject, setIsLoadingObject] = useState(true); const fetchCluster: PromiseVoidFunction = async (): Promise => { setIsLoading(true); @@ -69,6 +65,32 @@ const KubernetesClusterNamespaceDetail: FunctionComponent< }); }, []); + // 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 ; } @@ -143,32 +165,75 @@ const KubernetesClusterNamespaceDetail: FunctionComponent< 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 ( -
- - +
+
+ + +
- - { - setMetricViewData({ - ...data, - queryConfigs: [cpuQuery, memoryQuery], - formulaConfigs: [], - }); - }} - /> - + {}} /> ); }; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/NodeDetail.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/NodeDetail.tsx index 0234597b0f..3382ce8f30 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,6 +18,13 @@ 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 { KubernetesNodeObject } from "../Utils/KubernetesObjectParser"; +import { fetchLatestK8sObject } from "../Utils/KubernetesObjectFetcher"; const KubernetesClusterNodeDetail: FunctionComponent< PageComponentProps @@ -32,16 +35,10 @@ const KubernetesClusterNodeDetail: FunctionComponent< const [cluster, setCluster] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(""); - - 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: [], - }); + const [nodeObject, setNodeObject] = useState( + null, + ); + const [isLoadingObject, setIsLoadingObject] = useState(true); const fetchCluster: PromiseVoidFunction = async (): Promise => { setIsLoading(true); @@ -66,6 +63,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 ; } @@ -200,44 +222,144 @@ const KubernetesClusterNodeDetail: FunctionComponent< }, }; - return ( - -
- - -
+ // Determine node status from conditions + const getNodeStatus: () => { label: string; isReady: boolean } = (): { + label: string; + isReady: boolean; + } => { + if (!nodeObject) { + return { label: "Unknown", isReady: false }; + } + const readyCondition = nodeObject.status.conditions.find( + (c) => { + return c.type === "Ready"; + }, + ); + if (readyCondition && readyCondition.status === "True") { + return { label: "Ready", isReady: true }; + } + return { label: "NotReady", isReady: false }; + }; - - = + [ + { title: "Node Name", value: nodeName }, + { title: "Cluster", value: clusterIdentifier }, + ]; + + if (nodeObject) { + const nodeStatus = 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: ( + + { - setMetricViewData({ - ...data, - queryConfigs: [ - cpuQuery, - memoryQuery, - filesystemQuery, - networkRxQuery, - networkTxQuery, - ], - formulaConfigs: [], - }); - }} - /> - + ]} + /> + + ), + }, + ]; + + return ( + +
+
+ + +
+
+ + {}} />
); }; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/PodDetail.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/PodDetail.tsx index a1b32ef459..9ecd356348 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,6 +21,15 @@ 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 @@ -35,16 +40,8 @@ const KubernetesClusterPodDetail: FunctionComponent< const [cluster, setCluster] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(""); - - 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: [], - }); + const [podObject, setPodObject] = useState(null); + const [isLoadingObject, setIsLoadingObject] = useState(true); const fetchCluster: PromiseVoidFunction = async (): Promise => { setIsLoading(true); @@ -69,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 ; } @@ -89,7 +111,8 @@ const KubernetesClusterPodDetail: FunctionComponent< const attributes: Record = (data["attributes"] as Record) || {}; const containerName: string = - (attributes["resource.k8s.container.name"] as string) || "Unknown Container"; + (attributes["resource.k8s.container.name"] as string) || + "Unknown Container"; return { title: containerName }; }; @@ -191,42 +214,133 @@ const KubernetesClusterPodDetail: FunctionComponent< }, }; + // 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/StatefulSetDetail.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/StatefulSetDetail.tsx index 39a780ca68..1e55e3643f 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/StatefulSetDetail.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/StatefulSetDetail.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,6 +21,13 @@ 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 { KubernetesStatefulSetObject } from "../Utils/KubernetesObjectParser"; +import { fetchLatestK8sObject } from "../Utils/KubernetesObjectFetcher"; const KubernetesClusterStatefulSetDetail: FunctionComponent< PageComponentProps @@ -35,16 +38,9 @@ const KubernetesClusterStatefulSetDetail: FunctionComponent< const [cluster, setCluster] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(""); - - 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: [], - }); + const [objectData, setObjectData] = + useState(null); + const [isLoadingObject, setIsLoadingObject] = useState(true); const fetchCluster: PromiseVoidFunction = async (): Promise => { setIsLoading(true); @@ -69,6 +65,31 @@ const KubernetesClusterStatefulSetDetail: FunctionComponent< }); }, []); + // 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 ; } @@ -143,32 +164,102 @@ const KubernetesClusterStatefulSetDetail: FunctionComponent< 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 ( -
- - +
+
+ + +
- - { - setMetricViewData({ - ...data, - queryConfigs: [cpuQuery, memoryQuery], - formulaConfigs: [], - }); - }} - /> - + {}} /> ); }; diff --git a/HelmChart/Public/kubernetes-agent/templates/configmap-deployment.yaml b/HelmChart/Public/kubernetes-agent/templates/configmap-deployment.yaml index 54beb0f2e7..95bd5937d7 100644 --- a/HelmChart/Public/kubernetes-agent/templates/configmap-deployment.yaml +++ b/HelmChart/Public/kubernetes-agent/templates/configmap-deployment.yaml @@ -32,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 diff --git a/HelmChart/Public/kubernetes-agent/values.yaml b/HelmChart/Public/kubernetes-agent/values.yaml index 1dff9d71b2..ec8a5e6159 100644 --- a/HelmChart/Public/kubernetes-agent/values.yaml +++ b/HelmChart/Public/kubernetes-agent/values.yaml @@ -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/Home/Routes.ts b/Home/Routes.ts index 7b62a4df69..5d3f845059 100755 --- a/Home/Routes.ts +++ b/Home/Routes.ts @@ -1222,6 +1222,27 @@ const HomeFeatureSet: FeatureSet = { }, ); + 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 d303f864f7..f42e417373 100644 --- a/Home/Utils/PageSEO.ts +++ b/Home/Utils/PageSEO.ts @@ -438,6 +438,43 @@ export const PageSEOConfig: Record = { }, }, + "/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 index ea51114870..e1c9f1b4ff 100644 --- a/Home/Views/Partials/icons/kubernetes.ejs +++ b/Home/Views/Partials/icons/kubernetes.ejs @@ -1,5 +1,2 @@ - - - - - + +Kubernetes 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 index d2616e4899..11ef99a596 100644 --- a/Home/Views/kubernetes.ejs +++ b/Home/Views/kubernetes.ejs @@ -1062,6 +1062,193 @@
+ +
+
+
+ +
+

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') -%> @@ -1070,6 +1257,19 @@ <%- include('footer') -%> <%- include('./Partials/video-script') -%> + + + +