diff --git a/App/FeatureSet/Dashboard/src/Components/Kubernetes/KubernetesLogsTab.tsx b/App/FeatureSet/Dashboard/src/Components/Kubernetes/KubernetesLogsTab.tsx index c677540c98..5d31793f6c 100644 --- a/App/FeatureSet/Dashboard/src/Components/Kubernetes/KubernetesLogsTab.tsx +++ b/App/FeatureSet/Dashboard/src/Components/Kubernetes/KubernetesLogsTab.tsx @@ -1,15 +1,7 @@ -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"; +import React, { FunctionComponent, ReactElement, useMemo } from "react"; +import DashboardLogsViewer from "../Logs/LogsViewer"; +import Query from "Common/Types/BaseDatabase/Query"; +import Log from "Common/Models/AnalyticsModels/Log"; export interface ComponentProps { clusterIdentifier: string; @@ -21,29 +13,23 @@ export interface ComponentProps { 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); + const logQuery: Query = useMemo(() => { + const attributeFilters: Record = { + "resource.k8s.cluster.name": props.clusterIdentifier, + "resource.k8s.pod.name": props.podName, }; - fetchLogs().catch(() => {}); + if (props.containerName) { + attributeFilters["resource.k8s.container.name"] = props.containerName; + } + + if (props.namespace) { + attributeFilters["resource.k8s.namespace.name"] = props.namespace; + } + + return { + attributes: attributeFilters, + } as Query; }, [ props.clusterIdentifier, props.podName, @@ -51,65 +37,13 @@ const KubernetesLogsTab: FunctionComponent = ( 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} - -
- ); - })} -
+ ); }; diff --git a/App/FeatureSet/Dashboard/src/Components/Kubernetes/KubernetesResourceTable.tsx b/App/FeatureSet/Dashboard/src/Components/Kubernetes/KubernetesResourceTable.tsx index 06f13926fd..b9419e4cb0 100644 --- a/App/FeatureSet/Dashboard/src/Components/Kubernetes/KubernetesResourceTable.tsx +++ b/App/FeatureSet/Dashboard/src/Components/Kubernetes/KubernetesResourceTable.tsx @@ -1,4 +1,9 @@ -import React, { FunctionComponent, ReactElement } from "react"; +import React, { + FunctionComponent, + ReactElement, + useMemo, + useState, +} from "react"; import KubernetesResourceUtils, { KubernetesResource, } from "../../Pages/Kubernetes/Utils/KubernetesResourceUtils"; @@ -9,6 +14,7 @@ import Link from "Common/UI/Components/Link/Link"; import SortOrder from "Common/Types/BaseDatabase/SortOrder"; import Route from "Common/Types/API/Route"; import Column from "Common/UI/Components/Table/Types/Column"; +import Input from "Common/UI/Components/Input/Input"; export interface ResourceColumn { title: string; @@ -22,21 +28,132 @@ export interface ComponentProps { description: string; columns?: Array; showNamespace?: boolean; + showStatus?: boolean; + showResourceMetrics?: boolean; getViewRoute?: (resource: KubernetesResource) => Route; emptyMessage?: string; + isLoading?: boolean; +} + +const PAGE_SIZE: number = 25; + +function getStatusBadgeClass(status: string): string { + const s: string = status.toLowerCase(); + if ( + s === "running" || + s === "ready" || + s === "active" || + s === "bound" || + s === "succeeded" || + s === "available" || + s === "true" + ) { + return "bg-green-50 text-green-700"; + } + if ( + s === "pending" || + s === "unknown" || + s === "waiting" || + s === "terminating" + ) { + return "bg-yellow-50 text-yellow-700"; + } + if ( + s === "failed" || + s === "crashloopbackoff" || + s === "error" || + s === "lost" || + s === "notready" || + s === "imagepullbackoff" || + s === "false" + ) { + return "bg-red-50 text-red-700"; + } + return "bg-gray-50 text-gray-700"; +} + +function getCpuBarColor(pct: number): string { + if (pct > 80) { + return "bg-red-500"; + } + if (pct > 60) { + return "bg-yellow-500"; + } + return "bg-green-500"; +} + +function getMemoryBarColor(pct: number): string { + if (pct > 85) { + return "bg-red-500"; + } + if (pct > 70) { + return "bg-yellow-500"; + } + return "bg-blue-500"; } const KubernetesResourceTable: FunctionComponent = ( props: ComponentProps, ): ReactElement => { const showNamespace: boolean = props.showNamespace !== false; + const showStatus: boolean = props.showStatus !== false; + const showResourceMetrics: boolean = props.showResourceMetrics !== false; + const [currentPage, setCurrentPage] = useState(1); + const [sortBy, setSortBy] = useState(null); + const [sortOrder, setSortOrder] = useState(SortOrder.Ascending); + const [filterText, setFilterText] = useState(""); + + // Filter and sort data client-side + const processedData: Array = useMemo(() => { + let data: Array = [...props.resources]; + + // Filter by search text + if (filterText.trim()) { + const search: string = filterText.toLowerCase().trim(); + data = data.filter((r: KubernetesResource) => { + return ( + r.name.toLowerCase().includes(search) || + r.namespace.toLowerCase().includes(search) || + r.status.toLowerCase().includes(search) + ); + }); + } + + // Sort + if (sortBy) { + data.sort((a: KubernetesResource, b: KubernetesResource) => { + let cmp: number = 0; + if (sortBy === "name") { + cmp = a.name.localeCompare(b.name); + } else if (sortBy === "namespace") { + cmp = a.namespace.localeCompare(b.namespace); + } else if (sortBy === "status") { + cmp = a.status.localeCompare(b.status); + } else if (sortBy === "cpuUtilization") { + cmp = (a.cpuUtilization ?? -1) - (b.cpuUtilization ?? -1); + } else if (sortBy === "memoryUsageBytes") { + cmp = (a.memoryUsageBytes ?? -1) - (b.memoryUsageBytes ?? -1); + } else if (sortBy === "age") { + cmp = a.age.localeCompare(b.age); + } + return sortOrder === SortOrder.Descending ? -cmp : cmp; + }); + } + + return data; + }, [props.resources, filterText, sortBy, sortOrder]); + + // Paginate + const paginatedData: Array = useMemo(() => { + const start: number = (currentPage - 1) * PAGE_SIZE; + return processedData.slice(start, start + PAGE_SIZE); + }, [processedData, currentPage]); const tableColumns: Array> = [ { title: "Name", type: FieldType.Element, key: "name", - disableSort: true, getElement: (resource: KubernetesResource): ReactElement => { return ( {resource.name} @@ -50,7 +167,6 @@ const KubernetesResourceTable: FunctionComponent = ( title: "Namespace", type: FieldType.Element, key: "namespace", - disableSort: true, getElement: (resource: KubernetesResource): ReactElement => { return ( @@ -61,6 +177,26 @@ const KubernetesResourceTable: FunctionComponent = ( }); } + if (showStatus) { + tableColumns.push({ + title: "Status", + type: FieldType.Element, + key: "status", + getElement: (resource: KubernetesResource): ReactElement => { + if (!resource.status) { + return -; + } + return ( + + {resource.status} + + ); + }, + }); + } + if (props.columns) { for (const col of props.columns) { tableColumns.push({ @@ -78,49 +214,113 @@ const KubernetesResourceTable: FunctionComponent = ( } } - tableColumns.push( - { - title: "CPU", - type: FieldType.Element, - key: "cpuUtilization", - disableSort: true, - getElement: (resource: KubernetesResource): ReactElement => { - return ( - 80 - ? "bg-red-50 text-red-700" - : resource.cpuUtilization !== null && - resource.cpuUtilization > 60 - ? "bg-yellow-50 text-yellow-700" - : "bg-green-50 text-green-700" - }`} - > - {KubernetesResourceUtils.formatCpuValue(resource.cpuUtilization)} - - ); + if (showResourceMetrics) { + tableColumns.push( + { + title: "CPU", + type: FieldType.Element, + key: "cpuUtilization", + getElement: (resource: KubernetesResource): ReactElement => { + if ( + resource.cpuUtilization === null || + resource.cpuUtilization === undefined + ) { + return N/A; + } + const pct: number = Math.min(resource.cpuUtilization, 100); + return ( +
+
+
+
+ + {KubernetesResourceUtils.formatCpuValue( + resource.cpuUtilization, + )} + +
+ ); + }, }, - }, - { - title: "Memory", - type: FieldType.Element, - key: "memoryUsageBytes", - disableSort: true, - getElement: (resource: KubernetesResource): ReactElement => { - return ( - - {KubernetesResourceUtils.formatMemoryValue( - resource.memoryUsageBytes, - )} - - ); + { + title: "Memory", + type: FieldType.Element, + key: "memoryUsageBytes", + getElement: (resource: KubernetesResource): ReactElement => { + if ( + resource.memoryUsageBytes === null || + resource.memoryUsageBytes === undefined + ) { + return N/A; + } + + if ( + resource.memoryLimitBytes !== null && + resource.memoryLimitBytes !== undefined && + resource.memoryLimitBytes > 0 + ) { + const pct: number = Math.min( + (resource.memoryUsageBytes / resource.memoryLimitBytes) * 100, + 100, + ); + return ( +
+
+
+
+
+ + {Math.round(pct)}% + +
+
+ {KubernetesResourceUtils.formatMemoryValue( + resource.memoryUsageBytes, + )}{" "} + /{" "} + {KubernetesResourceUtils.formatMemoryValue( + resource.memoryLimitBytes, + )} +
+
+ ); + } + + return ( + + {KubernetesResourceUtils.formatMemoryValue( + resource.memoryUsageBytes, + )} + + ); + }, }, - }, - ); + ); + } + + if (showStatus) { + tableColumns.push({ + title: "Age", + type: FieldType.Element, + key: "age", + getElement: (resource: KubernetesResource): ReactElement => { + if (!resource.age) { + return -; + } + return {resource.age}; + }, + }); + } if (props.getViewRoute) { tableColumns.push({ - title: "Actions", + title: "", type: FieldType.Element, key: "name", disableSort: true, @@ -139,25 +339,44 @@ const KubernetesResourceTable: FunctionComponent = ( return ( +
+ { + setFilterText(value); + setCurrentPage(1); + }} + value={filterText} + /> +
id={`kubernetes-${props.title.toLowerCase().replace(/\s+/g, "-")}-table`} columns={tableColumns} - data={props.resources} + data={paginatedData} singularLabel={props.title} pluralLabel={props.title} - isLoading={false} + isLoading={props.isLoading || false} error="" - disablePagination={true} - currentPageNumber={1} - totalItemsCount={props.resources.length} - itemsOnPage={props.resources.length} - onNavigateToPage={() => {}} - sortBy={null} - sortOrder={SortOrder.Ascending} - onSortChanged={() => {}} + currentPageNumber={currentPage} + totalItemsCount={processedData.length} + itemsOnPage={paginatedData.length} + onNavigateToPage={(page: number) => { + setCurrentPage(page); + }} + sortBy={sortBy as keyof KubernetesResource | null} + sortOrder={sortOrder} + onSortChanged={( + newSortBy: keyof KubernetesResource | null, + newSortOrder: SortOrder, + ) => { + setSortBy(newSortBy as string | null); + setSortOrder(newSortOrder); + }} noItemsMessage={ - props.emptyMessage || - "No resources found. Resources will appear here once the kubernetes-agent is sending data." + filterText + ? `No resources match "${filterText}".` + : props.emptyMessage || + "No resources found. Resources will appear here once the kubernetes-agent is sending data." } />
diff --git a/App/FeatureSet/Dashboard/src/Components/Metrics/MetricCharts.tsx b/App/FeatureSet/Dashboard/src/Components/Metrics/MetricCharts.tsx index 121c7ee2c7..cb631434be 100644 --- a/App/FeatureSet/Dashboard/src/Components/Metrics/MetricCharts.tsx +++ b/App/FeatureSet/Dashboard/src/Components/Metrics/MetricCharts.tsx @@ -197,6 +197,10 @@ const MetricCharts: FunctionComponent = ( options: { type: YAxisType.Number, formatter: (value: number) => { + if (queryConfig.yAxisValueFormatter) { + return queryConfig.yAxisValueFormatter(value); + } + const metricType: MetricType | undefined = props.metricTypes.find((m: MetricType) => { return ( diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/Utils/KubernetesObjectFetcher.ts b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/Utils/KubernetesObjectFetcher.ts index fb6fb4d870..b9adc3749c 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/Utils/KubernetesObjectFetcher.ts +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/Utils/KubernetesObjectFetcher.ts @@ -19,6 +19,8 @@ import { KubernetesJobObject, KubernetesCronJobObject, KubernetesNamespaceObject, + KubernetesPVCObject, + KubernetesPVObject, parsePodObject, parseNodeObject, parseDeploymentObject, @@ -27,6 +29,8 @@ import { parseJobObject, parseCronJobObject, parseNamespaceObject, + parsePVCObject, + parsePVObject, } from "./KubernetesObjectParser"; export type KubernetesObjectType = @@ -37,7 +41,9 @@ export type KubernetesObjectType = | KubernetesDaemonSetObject | KubernetesJobObject | KubernetesCronJobObject - | KubernetesNamespaceObject; + | KubernetesNamespaceObject + | KubernetesPVCObject + | KubernetesPVObject; export interface FetchK8sObjectOptions { clusterIdentifier: string; @@ -58,6 +64,8 @@ function getParser(resourceType: string): ParserFunction | null { jobs: parseJobObject, cronjobs: parseCronJobObject, namespaces: parseNamespaceObject, + persistentvolumeclaims: parsePVCObject, + persistentvolumes: parsePVObject, }; return parsers[resourceType] || null; } @@ -165,6 +173,106 @@ export async function fetchLatestK8sObject( } } +/** + * Batch fetch all K8s objects of a given type for a cluster. + * Returns a Map keyed by "namespace/name" (or just "name" for cluster-scoped resources). + */ +export async function fetchK8sObjectsBatch(options: { + clusterIdentifier: string; + resourceType: string; +}): Promise> { + const parser: ParserFunction | null = getParser(options.resourceType); + if (!parser) { + return new Map(); + } + + const projectId: string | undefined = + ProjectUtil.getCurrentProjectId()?.toString(); + if (!projectId) { + return new Map(); + } + + 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.k8s.resource.name": options.resourceType, + }, + }, + limit: 2000, + skip: 0, + select: { + time: true, + body: true, + attributes: true, + }, + sort: { + time: SortOrder.Descending, + }, + requestOptions: {}, + }; + const listResult: ListResult = + await AnalyticsModelAPI.getList(queryOptions); + + const resultMap: Map = new Map(); + + for (const log of listResult.data) { + const attrs: JSONObject = log.attributes || {}; + + 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; + } + + 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"); + const key: string = namespace ? `${namespace}/${name}` : name; + + // Only keep the latest (first encountered since sorted desc) + if (resultMap.has(key)) { + continue; + } + + const parsed: KubernetesObjectType | null = parser(objectKvList); + if (parsed) { + resultMap.set(key, parsed); + } + } + + return resultMap; + } catch { + return new Map(); + } +} + /** * Fetch K8s events related to a specific resource. */ diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/Utils/KubernetesObjectParser.ts b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/Utils/KubernetesObjectParser.ts index 42d8e52b17..99962bf156 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/Utils/KubernetesObjectParser.ts +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/Utils/KubernetesObjectParser.ts @@ -340,6 +340,45 @@ export interface KubernetesNamespaceObject { }; } +export interface KubernetesPVCObject { + metadata: KubernetesObjectMetadata; + spec: { + accessModes: Array; + storageClassName: string; + volumeName: string; + resources: { + requests: { + storage: string; + }; + }; + }; + status: { + phase: string; // Bound, Pending, Lost + capacity: { + storage: string; + }; + }; +} + +export interface KubernetesPVObject { + metadata: KubernetesObjectMetadata; + spec: { + capacity: { + storage: string; + }; + accessModes: Array; + storageClassName: string; + persistentVolumeReclaimPolicy: string; + claimRef: { + name: string; + namespace: string; + }; + }; + status: { + phase: string; // Available, Bound, Released, Failed + }; +} + /* * ============================================================ * Parsers @@ -1261,6 +1300,187 @@ export function parseNamespaceObject( } } +export function parsePVCObject( + objectKvList: JSONObject, +): KubernetesPVCObject | null { + try { + const metadataKv: string | JSONObject | null = getKvValue( + objectKvList, + "metadata", + ); + if (!metadataKv || typeof metadataKv === "string") { + return null; + } + + const metadata: KubernetesObjectMetadata = parseMetadata(metadataKv); + if (!metadata.name) { + return null; + } + + const specKv: string | JSONObject | null = getKvValue(objectKvList, "spec"); + const statusKv: string | JSONObject | null = getKvValue( + objectKvList, + "status", + ); + + // Parse spec + let accessModes: Array = []; + let storageClassName: string = ""; + let volumeName: string = ""; + let requestsStorage: string = ""; + + if (specKv && typeof specKv !== "string") { + storageClassName = getKvStringValue(specKv, "storageClassName"); + volumeName = getKvStringValue(specKv, "volumeName"); + + const accessModesArray: string | JSONObject | null = getKvValue( + specKv, + "accessModes", + ); + if (accessModesArray && typeof accessModesArray !== "string") { + const modeValues: Array = + (accessModesArray["values"] as Array) || []; + for (const v of modeValues) { + if (v["stringValue"]) { + accessModes.push(v["stringValue"] as string); + } + } + } + + const resourcesKv: string | JSONObject | null = getKvValue( + specKv, + "resources", + ); + if (resourcesKv && typeof resourcesKv !== "string") { + requestsStorage = getNestedKvValue(resourcesKv, "requests", "storage"); + } + } + + // Parse status + let phase: string = ""; + let capacityStorage: string = ""; + + if (statusKv && typeof statusKv !== "string") { + phase = getKvStringValue(statusKv, "phase"); + capacityStorage = getNestedKvValue(statusKv, "capacity", "storage"); + } + + return { + metadata, + spec: { + accessModes, + storageClassName, + volumeName, + resources: { + requests: { + storage: requestsStorage, + }, + }, + }, + status: { + phase, + capacity: { + storage: capacityStorage, + }, + }, + }; + } catch { + return null; + } +} + +export function parsePVObject( + objectKvList: JSONObject, +): KubernetesPVObject | null { + try { + const metadataKv: string | JSONObject | null = getKvValue( + objectKvList, + "metadata", + ); + if (!metadataKv || typeof metadataKv === "string") { + return null; + } + + const metadata: KubernetesObjectMetadata = parseMetadata(metadataKv); + if (!metadata.name) { + return null; + } + + const specKv: string | JSONObject | null = getKvValue(objectKvList, "spec"); + const statusKv: string | JSONObject | null = getKvValue( + objectKvList, + "status", + ); + + // Parse spec + let capacityStorage: string = ""; + let accessModes: Array = []; + let storageClassName: string = ""; + let persistentVolumeReclaimPolicy: string = ""; + let claimRefName: string = ""; + let claimRefNamespace: string = ""; + + if (specKv && typeof specKv !== "string") { + capacityStorage = getNestedKvValue(specKv, "capacity", "storage"); + storageClassName = getKvStringValue(specKv, "storageClassName"); + persistentVolumeReclaimPolicy = getKvStringValue( + specKv, + "persistentVolumeReclaimPolicy", + ); + + const accessModesArray: string | JSONObject | null = getKvValue( + specKv, + "accessModes", + ); + if (accessModesArray && typeof accessModesArray !== "string") { + const modeValues: Array = + (accessModesArray["values"] as Array) || []; + for (const v of modeValues) { + if (v["stringValue"]) { + accessModes.push(v["stringValue"] as string); + } + } + } + + const claimRefKv: string | JSONObject | null = getKvValue( + specKv, + "claimRef", + ); + if (claimRefKv && typeof claimRefKv !== "string") { + claimRefName = getKvStringValue(claimRefKv, "name"); + claimRefNamespace = getKvStringValue(claimRefKv, "namespace"); + } + } + + // Parse status + let phase: string = ""; + if (statusKv && typeof statusKv !== "string") { + phase = getKvStringValue(statusKv, "phase"); + } + + return { + metadata, + spec: { + capacity: { + storage: capacityStorage, + }, + accessModes, + storageClassName, + persistentVolumeReclaimPolicy, + claimRef: { + name: claimRefName, + namespace: claimRefNamespace, + }, + }, + status: { + phase, + }, + }; + } catch { + return null; + } +} + /** * Extract the K8s object from a raw OTLP log body string. * For k8sobjects pull mode, the body is: diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/Utils/KubernetesResourceUtils.ts b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/Utils/KubernetesResourceUtils.ts index fa6f626742..56a400584b 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/Utils/KubernetesResourceUtils.ts +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/Utils/KubernetesResourceUtils.ts @@ -13,6 +13,9 @@ export interface KubernetesResource { namespace: string; cpuUtilization: number | null; memoryUsageBytes: number | null; + memoryLimitBytes: number | null; + status: string; + age: string; additionalAttributes: Record; } @@ -98,6 +101,9 @@ export default class KubernetesResourceUtils { namespace: namespace, cpuUtilization: dataPoint.value ?? null, memoryUsageBytes: null, + memoryLimitBytes: null, + status: "", + age: "", additionalAttributes: additionalAttrs, }); } @@ -184,6 +190,34 @@ export default class KubernetesResourceUtils { return resources; } + public static formatAge(creationTimestamp: string | undefined): string { + if (!creationTimestamp) { + return "N/A"; + } + const created: Date = new Date(creationTimestamp); + const now: Date = new Date(); + const diffMs: number = now.getTime() - created.getTime(); + const diffSec: number = Math.floor(diffMs / 1000); + + if (diffSec < 60) { + return `${diffSec}s`; + } + const diffMin: number = Math.floor(diffSec / 60); + if (diffMin < 60) { + return `${diffMin}m`; + } + const diffHours: number = Math.floor(diffMin / 60); + if (diffHours < 24) { + return `${diffHours}h`; + } + const diffDays: number = Math.floor(diffHours / 24); + if (diffDays < 30) { + return `${diffDays}d`; + } + const diffMonths: number = Math.floor(diffDays / 30); + return `${diffMonths}mo`; + } + public static formatCpuValue(value: number | null): string { if (value === null || value === undefined) { return "N/A"; @@ -210,4 +244,48 @@ export default class KubernetesResourceUtils { return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; } + + public static formatBytesForChart(value: number): string { + if (value === null || value === undefined) { + return "N/A"; + } + + const absValue: number = Math.abs(value); + + if (absValue < 1024) { + return `${value.toFixed(0)} B`; + } + + if (absValue < 1024 * 1024) { + return `${(value / 1024).toFixed(1)} KB`; + } + + if (absValue < 1024 * 1024 * 1024) { + return `${(value / (1024 * 1024)).toFixed(1)} MB`; + } + + return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GB`; + } + + public static formatBytesPerSecForChart(value: number): string { + if (value === null || value === undefined) { + return "N/A"; + } + + const absValue: number = Math.abs(value); + + if (absValue < 1024) { + return `${value.toFixed(0)} B/s`; + } + + if (absValue < 1024 * 1024) { + return `${(value / 1024).toFixed(1)} KB/s`; + } + + if (absValue < 1024 * 1024 * 1024) { + return `${(value / (1024 * 1024)).toFixed(1)} MB/s`; + } + + return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GB/s`; + } } diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/ContainerDetail.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/ContainerDetail.tsx index e510c4f3bf..9f031a85ee 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/ContainerDetail.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/ContainerDetail.tsx @@ -9,7 +9,6 @@ import MetricQueryConfigData, { } from "Common/Types/Metrics/MetricQueryConfigData"; import AggregationType from "Common/Types/BaseDatabase/AggregationType"; import React, { - Fragment, FunctionComponent, ReactElement, useEffect, @@ -25,6 +24,7 @@ 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"; +import KubernetesResourceUtils from "../Utils/KubernetesResourceUtils"; const KubernetesClusterContainerDetail: FunctionComponent< PageComponentProps @@ -115,7 +115,7 @@ const KubernetesClusterContainerDetail: FunctionComponent< title: "Container Memory Usage", description: `Memory usage for container ${containerName}`, legend: "Memory", - legendUnit: "bytes", + legendUnit: "", }, metricQueryData: { filterData: { @@ -132,6 +132,7 @@ const KubernetesClusterContainerDetail: FunctionComponent< }, }, getSeries: getSeries, + yAxisValueFormatter: KubernetesResourceUtils.formatBytesForChart, }; const tabs: Array = [ @@ -177,18 +178,7 @@ const KubernetesClusterContainerDetail: FunctionComponent< }, ]; - return ( - -
-
- - -
-
- - {}} /> -
- ); + return {}} />; }; export default KubernetesClusterContainerDetail; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Containers.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Containers.tsx index 9f7dec1aae..b425981e59 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Containers.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Containers.tsx @@ -6,13 +6,7 @@ import KubernetesResourceTable from "../../../Components/Kubernetes/KubernetesRe import KubernetesResourceUtils, { KubernetesResource, } from "../Utils/KubernetesResourceUtils"; -import React, { - Fragment, - FunctionComponent, - ReactElement, - useEffect, - useState, -} from "react"; +import React, { FunctionComponent, ReactElement, useEffect, useState } from "react"; import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI"; import API from "Common/UI/Utils/API/API"; import PageLoader from "Common/UI/Components/Loader/PageLoader"; @@ -21,6 +15,11 @@ import { PromiseVoidFunction } from "Common/Types/FunctionTypes"; import PageMap from "../../../Utils/PageMap"; import RouteMap, { RouteUtil } from "../../../Utils/RouteMap"; import Route from "Common/Types/API/Route"; +import { + fetchK8sObjectsBatch, + KubernetesObjectType, +} from "../Utils/KubernetesObjectFetcher"; +import { KubernetesPodObject } from "../Utils/KubernetesObjectParser"; const KubernetesClusterContainers: FunctionComponent< PageComponentProps @@ -48,14 +47,58 @@ const KubernetesClusterContainers: FunctionComponent< return; } - const containerList: Array = - await KubernetesResourceUtils.fetchResourceListWithMemory({ + const [containerList, podObjects]: [ + Array, + Map, + ] = await Promise.all([ + KubernetesResourceUtils.fetchResourceListWithMemory({ clusterIdentifier: cluster.clusterIdentifier, metricName: "container.cpu.utilization", memoryMetricName: "container.memory.usage", resourceNameAttribute: "resource.k8s.container.name", additionalAttributes: ["resource.k8s.pod.name"], - }); + }), + fetchK8sObjectsBatch({ + clusterIdentifier: cluster.clusterIdentifier, + resourceType: "pods", + }), + ]); + + for (const resource of containerList) { + const podName: string = + resource.additionalAttributes["resource.k8s.pod.name"] || ""; + const podKey: string = resource.namespace + ? `${resource.namespace}/${podName}` + : podName; + const podObj: KubernetesObjectType | undefined = podObjects.get(podKey); + if (podObj) { + const pod: KubernetesPodObject = podObj as KubernetesPodObject; + + // Find the container status matching this container name + const containerStatus = pod.status.containerStatuses.find( + (cs) => cs.name === resource.name, + ); + + if (containerStatus) { + if (containerStatus.state === "running") { + resource.status = containerStatus.ready ? "Running" : "NotReady"; + } else if (containerStatus.state === "waiting") { + resource.status = "Waiting"; + } else if (containerStatus.state === "terminated") { + resource.status = "Terminated"; + } else { + resource.status = containerStatus.state || "Unknown"; + } + + resource.additionalAttributes["restarts"] = + `${containerStatus.restartCount}`; + } + + resource.age = KubernetesResourceUtils.formatAge( + pod.metadata.creationTimestamp, + ); + } + } setResources(containerList); } catch (err) { @@ -79,28 +122,30 @@ const KubernetesClusterContainers: FunctionComponent< } return ( - - { + return RouteUtil.populateRouteParams( + RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_CONTAINER_DETAIL] as Route, { - title: "Pod", - key: "resource.k8s.pod.name", + modelId: modelId, + subModelId: new ObjectID(resource.name), }, - ]} - getViewRoute={(resource: KubernetesResource) => { - return RouteUtil.populateRouteParams( - RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_CONTAINER_DETAIL] as Route, - { - modelId: modelId, - subModelId: new ObjectID(resource.name), - }, - ); - }} - /> - + ); + }} + /> ); }; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/ControlPlane.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/ControlPlane.tsx index 5fb856a253..3726d4cf74 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/ControlPlane.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/ControlPlane.tsx @@ -21,6 +21,7 @@ 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 KubernetesResourceUtils from "../Utils/KubernetesResourceUtils"; const KubernetesClusterControlPlane: FunctionComponent< PageComponentProps @@ -78,7 +79,7 @@ const KubernetesClusterControlPlane: FunctionComponent< title: "etcd Database Size", description: "Total size of the etcd database", legend: "DB Size", - legendUnit: "bytes", + legendUnit: "", }, metricQueryData: { filterData: { @@ -93,6 +94,7 @@ const KubernetesClusterControlPlane: FunctionComponent< attributes: true, }, }, + yAxisValueFormatter: KubernetesResourceUtils.formatBytesForChart, }; const apiServerRequestRateQuery: MetricQueryConfigData = { diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/CronJobDetail.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/CronJobDetail.tsx index 31c458a1f4..a1bf7c2364 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/CronJobDetail.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/CronJobDetail.tsx @@ -3,13 +3,12 @@ import ObjectID from "Common/Types/ObjectID"; import Navigation from "Common/UI/Utils/Navigation"; import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster"; import Card from "Common/UI/Components/Card/Card"; -import InfoCard from "Common/UI/Components/InfoCard/InfoCard"; + import MetricQueryConfigData, { ChartSeries, } from "Common/Types/Metrics/MetricQueryConfigData"; import AggregationType from "Common/Types/BaseDatabase/AggregationType"; import React, { - Fragment, FunctionComponent, ReactElement, useEffect, @@ -28,6 +27,7 @@ import KubernetesEventsTab from "../../../Components/Kubernetes/KubernetesEvents import KubernetesMetricsTab from "../../../Components/Kubernetes/KubernetesMetricsTab"; import { KubernetesCronJobObject } from "../Utils/KubernetesObjectParser"; import { fetchLatestK8sObject } from "../Utils/KubernetesObjectFetcher"; +import KubernetesResourceUtils from "../Utils/KubernetesResourceUtils"; const KubernetesClusterCronJobDetail: FunctionComponent< PageComponentProps @@ -145,7 +145,7 @@ const KubernetesClusterCronJobDetail: FunctionComponent< title: "Pod Memory Usage", description: `Memory usage for pods in cronjob ${cronJobName}`, legend: "Memory", - legendUnit: "bytes", + legendUnit: "", }, metricQueryData: { filterData: { @@ -162,6 +162,7 @@ const KubernetesClusterCronJobDetail: FunctionComponent< }, }, getSeries: getSeries, + yAxisValueFormatter: KubernetesResourceUtils.formatBytesForChart, }; // Build overview summary fields from cronjob object @@ -253,18 +254,7 @@ const KubernetesClusterCronJobDetail: FunctionComponent< }, ]; - return ( - -
-
- - -
-
- - {}} /> -
- ); + return {}} />; }; export default KubernetesClusterCronJobDetail; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/CronJobs.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/CronJobs.tsx index 6aecdf59a5..109dc08d38 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/CronJobs.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/CronJobs.tsx @@ -6,13 +6,7 @@ import KubernetesResourceTable from "../../../Components/Kubernetes/KubernetesRe import KubernetesResourceUtils, { KubernetesResource, } from "../Utils/KubernetesResourceUtils"; -import React, { - Fragment, - FunctionComponent, - ReactElement, - useEffect, - useState, -} from "react"; +import React, { FunctionComponent, ReactElement, useEffect, useState } from "react"; import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI"; import API from "Common/UI/Utils/API/API"; import PageLoader from "Common/UI/Components/Loader/PageLoader"; @@ -21,6 +15,11 @@ import { PromiseVoidFunction } from "Common/Types/FunctionTypes"; import PageMap from "../../../Utils/PageMap"; import RouteMap, { RouteUtil } from "../../../Utils/RouteMap"; import Route from "Common/Types/API/Route"; +import { + fetchK8sObjectsBatch, + KubernetesObjectType, +} from "../Utils/KubernetesObjectFetcher"; +import { KubernetesCronJobObject } from "../Utils/KubernetesObjectParser"; const KubernetesClusterCronJobs: FunctionComponent< PageComponentProps @@ -48,13 +47,39 @@ const KubernetesClusterCronJobs: FunctionComponent< return; } - const cronjobList: Array = - await KubernetesResourceUtils.fetchResourceListWithMemory({ + const [cronjobList, cronjobObjects]: [ + Array, + Map, + ] = await Promise.all([ + KubernetesResourceUtils.fetchResourceListWithMemory({ clusterIdentifier: cluster.clusterIdentifier, metricName: "k8s.pod.cpu.utilization", memoryMetricName: "k8s.pod.memory.usage", resourceNameAttribute: "resource.k8s.cronjob.name", - }); + }), + fetchK8sObjectsBatch({ + clusterIdentifier: cluster.clusterIdentifier, + resourceType: "cronjobs", + }), + ]); + + for (const resource of cronjobList) { + const key: string = `${resource.namespace}/${resource.name}`; + const cjObj: KubernetesObjectType | undefined = + cronjobObjects.get(key); + if (cjObj) { + const cronJob: KubernetesCronJobObject = + cjObj as KubernetesCronJobObject; + + resource.status = cronJob.spec.suspend ? "Suspended" : "Active"; + + resource.additionalAttributes["schedule"] = cronJob.spec.schedule; + + resource.age = KubernetesResourceUtils.formatAge( + cronJob.metadata.creationTimestamp, + ); + } + } setResources(cronjobList); } catch (err) { @@ -78,22 +103,26 @@ const KubernetesClusterCronJobs: FunctionComponent< } return ( - - { - return RouteUtil.populateRouteParams( - RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_CRONJOB_DETAIL] as Route, - { - modelId: modelId, - subModelId: new ObjectID(resource.name), - }, - ); - }} - /> - + { + return RouteUtil.populateRouteParams( + RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_CRONJOB_DETAIL] as Route, + { + modelId: modelId, + subModelId: new ObjectID(resource.name), + }, + ); + }} + /> ); }; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/DaemonSetDetail.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/DaemonSetDetail.tsx index 3121929fc5..3303c18149 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/DaemonSetDetail.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/DaemonSetDetail.tsx @@ -3,13 +3,12 @@ import ObjectID from "Common/Types/ObjectID"; import Navigation from "Common/UI/Utils/Navigation"; import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster"; import Card from "Common/UI/Components/Card/Card"; -import InfoCard from "Common/UI/Components/InfoCard/InfoCard"; + import MetricQueryConfigData, { ChartSeries, } from "Common/Types/Metrics/MetricQueryConfigData"; import AggregationType from "Common/Types/BaseDatabase/AggregationType"; import React, { - Fragment, FunctionComponent, ReactElement, useEffect, @@ -28,6 +27,7 @@ import KubernetesEventsTab from "../../../Components/Kubernetes/KubernetesEvents import KubernetesMetricsTab from "../../../Components/Kubernetes/KubernetesMetricsTab"; import { KubernetesDaemonSetObject } from "../Utils/KubernetesObjectParser"; import { fetchLatestK8sObject } from "../Utils/KubernetesObjectFetcher"; +import KubernetesResourceUtils from "../Utils/KubernetesResourceUtils"; const KubernetesClusterDaemonSetDetail: FunctionComponent< PageComponentProps @@ -145,7 +145,7 @@ const KubernetesClusterDaemonSetDetail: FunctionComponent< title: "Pod Memory Usage", description: `Memory usage for pods in daemonset ${daemonSetName}`, legend: "Memory", - legendUnit: "bytes", + legendUnit: "", }, metricQueryData: { filterData: { @@ -162,6 +162,7 @@ const KubernetesClusterDaemonSetDetail: FunctionComponent< }, }, getSeries: getSeries, + yAxisValueFormatter: KubernetesResourceUtils.formatBytesForChart, }; // Build overview summary fields from daemonset object @@ -245,18 +246,7 @@ const KubernetesClusterDaemonSetDetail: FunctionComponent< }, ]; - return ( - -
-
- - -
-
- - {}} /> -
- ); + return {}} />; }; export default KubernetesClusterDaemonSetDetail; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/DaemonSets.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/DaemonSets.tsx index 2bbc94677f..cf981c5804 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/DaemonSets.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/DaemonSets.tsx @@ -6,13 +6,7 @@ import KubernetesResourceTable from "../../../Components/Kubernetes/KubernetesRe import KubernetesResourceUtils, { KubernetesResource, } from "../Utils/KubernetesResourceUtils"; -import React, { - Fragment, - FunctionComponent, - ReactElement, - useEffect, - useState, -} from "react"; +import React, { FunctionComponent, ReactElement, useEffect, useState } from "react"; import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI"; import API from "Common/UI/Utils/API/API"; import PageLoader from "Common/UI/Components/Loader/PageLoader"; @@ -21,6 +15,11 @@ import { PromiseVoidFunction } from "Common/Types/FunctionTypes"; import PageMap from "../../../Utils/PageMap"; import RouteMap, { RouteUtil } from "../../../Utils/RouteMap"; import Route from "Common/Types/API/Route"; +import { + fetchK8sObjectsBatch, + KubernetesObjectType, +} from "../Utils/KubernetesObjectFetcher"; +import { KubernetesDaemonSetObject } from "../Utils/KubernetesObjectParser"; const KubernetesClusterDaemonSets: FunctionComponent< PageComponentProps @@ -48,13 +47,44 @@ const KubernetesClusterDaemonSets: FunctionComponent< return; } - const daemonsetList: Array = - await KubernetesResourceUtils.fetchResourceListWithMemory({ + const [daemonsetList, daemonsetObjects]: [ + Array, + Map, + ] = await Promise.all([ + KubernetesResourceUtils.fetchResourceListWithMemory({ clusterIdentifier: cluster.clusterIdentifier, metricName: "k8s.pod.cpu.utilization", memoryMetricName: "k8s.pod.memory.usage", resourceNameAttribute: "resource.k8s.daemonset.name", - }); + }), + fetchK8sObjectsBatch({ + clusterIdentifier: cluster.clusterIdentifier, + resourceType: "daemonsets", + }), + ]); + + for (const resource of daemonsetList) { + const key: string = `${resource.namespace}/${resource.name}`; + const dsObj: KubernetesObjectType | undefined = + daemonsetObjects.get(key); + if (dsObj) { + const ds: KubernetesDaemonSetObject = + dsObj as KubernetesDaemonSetObject; + + const numberReady: number = ds.status.numberReady; + const desired: number = ds.status.desiredNumberScheduled; + + resource.status = + numberReady === desired && desired > 0 ? "Ready" : "Progressing"; + + resource.additionalAttributes["ready"] = + `${numberReady}/${desired}`; + + resource.age = KubernetesResourceUtils.formatAge( + ds.metadata.creationTimestamp, + ); + } + } setResources(daemonsetList); } catch (err) { @@ -78,22 +108,26 @@ const KubernetesClusterDaemonSets: FunctionComponent< } return ( - - { - return RouteUtil.populateRouteParams( - RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_DAEMONSET_DETAIL] as Route, - { - modelId: modelId, - subModelId: new ObjectID(resource.name), - }, - ); - }} - /> - + { + return RouteUtil.populateRouteParams( + RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_DAEMONSET_DETAIL] as Route, + { + modelId: modelId, + subModelId: new ObjectID(resource.name), + }, + ); + }} + /> ); }; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/DeploymentDetail.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/DeploymentDetail.tsx index 847f9c881c..886fcb1a39 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/DeploymentDetail.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/DeploymentDetail.tsx @@ -3,13 +3,12 @@ import ObjectID from "Common/Types/ObjectID"; import Navigation from "Common/UI/Utils/Navigation"; import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster"; import Card from "Common/UI/Components/Card/Card"; -import InfoCard from "Common/UI/Components/InfoCard/InfoCard"; + import MetricQueryConfigData, { ChartSeries, } from "Common/Types/Metrics/MetricQueryConfigData"; import AggregationType from "Common/Types/BaseDatabase/AggregationType"; import React, { - Fragment, FunctionComponent, ReactElement, useEffect, @@ -28,6 +27,7 @@ import KubernetesEventsTab from "../../../Components/Kubernetes/KubernetesEvents import KubernetesMetricsTab from "../../../Components/Kubernetes/KubernetesMetricsTab"; import { KubernetesDeploymentObject } from "../Utils/KubernetesObjectParser"; import { fetchLatestK8sObject } from "../Utils/KubernetesObjectFetcher"; +import KubernetesResourceUtils from "../Utils/KubernetesResourceUtils"; const KubernetesClusterDeploymentDetail: FunctionComponent< PageComponentProps @@ -145,7 +145,7 @@ const KubernetesClusterDeploymentDetail: FunctionComponent< title: "Pod Memory Usage", description: `Memory usage for pods in deployment ${deploymentName}`, legend: "Memory", - legendUnit: "bytes", + legendUnit: "", }, metricQueryData: { filterData: { @@ -162,6 +162,7 @@ const KubernetesClusterDeploymentDetail: FunctionComponent< }, }, getSeries: getSeries, + yAxisValueFormatter: KubernetesResourceUtils.formatBytesForChart, }; // Build overview summary fields from deployment object @@ -242,18 +243,7 @@ const KubernetesClusterDeploymentDetail: FunctionComponent< }, ]; - return ( - -
-
- - -
-
- - {}} /> -
- ); + return {}} />; }; export default KubernetesClusterDeploymentDetail; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Deployments.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Deployments.tsx index 235adc8dae..3d097453a7 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Deployments.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Deployments.tsx @@ -6,13 +6,7 @@ import KubernetesResourceTable from "../../../Components/Kubernetes/KubernetesRe import KubernetesResourceUtils, { KubernetesResource, } from "../Utils/KubernetesResourceUtils"; -import React, { - Fragment, - FunctionComponent, - ReactElement, - useEffect, - useState, -} from "react"; +import React, { FunctionComponent, ReactElement, useEffect, useState } from "react"; import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI"; import API from "Common/UI/Utils/API/API"; import PageLoader from "Common/UI/Components/Loader/PageLoader"; @@ -21,6 +15,11 @@ import { PromiseVoidFunction } from "Common/Types/FunctionTypes"; import PageMap from "../../../Utils/PageMap"; import RouteMap, { RouteUtil } from "../../../Utils/RouteMap"; import Route from "Common/Types/API/Route"; +import { + fetchK8sObjectsBatch, + KubernetesObjectType, +} from "../Utils/KubernetesObjectFetcher"; +import { KubernetesDeploymentObject } from "../Utils/KubernetesObjectParser"; const KubernetesClusterDeployments: FunctionComponent< PageComponentProps @@ -48,13 +47,53 @@ const KubernetesClusterDeployments: FunctionComponent< return; } - const deploymentList: Array = - await KubernetesResourceUtils.fetchResourceListWithMemory({ + const [deploymentList, deploymentObjects]: [ + Array, + Map, + ] = await Promise.all([ + KubernetesResourceUtils.fetchResourceListWithMemory({ clusterIdentifier: cluster.clusterIdentifier, metricName: "k8s.pod.cpu.utilization", memoryMetricName: "k8s.pod.memory.usage", resourceNameAttribute: "resource.k8s.deployment.name", - }); + }), + fetchK8sObjectsBatch({ + clusterIdentifier: cluster.clusterIdentifier, + resourceType: "deployments", + }), + ]); + + for (const resource of deploymentList) { + const key: string = `${resource.namespace}/${resource.name}`; + const depObj: KubernetesObjectType | undefined = + deploymentObjects.get(key); + if (depObj) { + const deployment: KubernetesDeploymentObject = + depObj as KubernetesDeploymentObject; + + const readyReplicas: number = deployment.status.readyReplicas; + const replicas: number = deployment.spec.replicas; + + if (readyReplicas === replicas && replicas > 0) { + resource.status = "Ready"; + } else if (readyReplicas < replicas) { + // Check conditions for failure + const failedCondition = deployment.status.conditions.find( + (c) => c.type === "Available" && c.status === "False", + ); + resource.status = failedCondition ? "Failed" : "Progressing"; + } else { + resource.status = "Progressing"; + } + + resource.additionalAttributes["ready"] = + `${readyReplicas}/${replicas}`; + + resource.age = KubernetesResourceUtils.formatAge( + deployment.metadata.creationTimestamp, + ); + } + } setResources(deploymentList); } catch (err) { @@ -78,24 +117,28 @@ const KubernetesClusterDeployments: FunctionComponent< } return ( - - { - return RouteUtil.populateRouteParams( - RouteMap[ - PageMap.KUBERNETES_CLUSTER_VIEW_DEPLOYMENT_DETAIL - ] as Route, - { - modelId: modelId, - subModelId: new ObjectID(resource.name), - }, - ); - }} - /> - + { + return RouteUtil.populateRouteParams( + RouteMap[ + PageMap.KUBERNETES_CLUSTER_VIEW_DEPLOYMENT_DETAIL + ] as Route, + { + modelId: modelId, + subModelId: new ObjectID(resource.name), + }, + ); + }} + /> ); }; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Index.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Index.tsx index 22364a5544..fcca850efe 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Index.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Index.tsx @@ -25,6 +25,14 @@ import { PromiseVoidFunction } from "Common/Types/FunctionTypes"; import KubernetesResourceUtils, { KubernetesResource, } from "../Utils/KubernetesResourceUtils"; +import { + fetchK8sObjectsBatch, + KubernetesObjectType, +} from "../Utils/KubernetesObjectFetcher"; +import { + KubernetesPodObject, + KubernetesNodeObject, +} from "../Utils/KubernetesObjectParser"; interface ResourceLink { title: string; @@ -43,6 +51,19 @@ const KubernetesClusterOverview: FunctionComponent< const [nodeCount, setNodeCount] = useState(0); const [podCount, setPodCount] = useState(0); const [namespaceCount, setNamespaceCount] = useState(0); + const [podHealthSummary, setPodHealthSummary] = useState<{ + running: number; + pending: number; + failed: number; + succeeded: number; + }>({ running: 0, pending: 0, failed: 0, succeeded: 0 }); + const [nodeHealthSummary, setNodeHealthSummary] = useState<{ + ready: number; + notReady: number; + }>({ ready: 0, notReady: 0 }); + const [clusterHealth, setClusterHealth] = useState< + "Healthy" | "Degraded" | "Unhealthy" + >("Healthy"); const fetchCluster: PromiseVoidFunction = async (): Promise => { setIsLoading(true); @@ -89,6 +110,75 @@ const KubernetesClusterOverview: FunctionComponent< setNodeCount(nodes.length); setPodCount(pods.length); setNamespaceCount(namespaces.length); + + // Fetch pod and node objects for health status + try { + const [podObjects, nodeObjects]: [ + Map, + Map, + ] = await Promise.all([ + fetchK8sObjectsBatch({ + clusterIdentifier: item.clusterIdentifier, + resourceType: "pods", + }), + fetchK8sObjectsBatch({ + clusterIdentifier: item.clusterIdentifier, + resourceType: "nodes", + }), + ]); + + // Calculate pod health + let running: number = 0; + let pending: number = 0; + let failed: number = 0; + let succeeded: number = 0; + + for (const podObj of podObjects.values()) { + const pod: KubernetesPodObject = + podObj as KubernetesPodObject; + const phase: string = pod.status.phase || "Unknown"; + if (phase === "Running") { + running++; + } else if (phase === "Pending") { + pending++; + } else if (phase === "Failed") { + failed++; + } else if (phase === "Succeeded") { + succeeded++; + } + } + setPodHealthSummary({ running, pending, failed, succeeded }); + + // Calculate node health + let ready: number = 0; + let notReady: number = 0; + + for (const nodeObj of nodeObjects.values()) { + const node: KubernetesNodeObject = + nodeObj as KubernetesNodeObject; + const readyCondition: boolean = node.status.conditions.some( + (c: { type: string; status: string }) => + c.type === "Ready" && c.status === "True", + ); + if (readyCondition) { + ready++; + } else { + notReady++; + } + } + setNodeHealthSummary({ ready, notReady }); + + // Determine overall health + if (failed > 0 || notReady > 0) { + setClusterHealth("Unhealthy"); + } else if (pending > 0) { + setClusterHealth("Degraded"); + } else { + setClusterHealth("Healthy"); + } + } catch { + // Health data is supplementary, don't fail + } } } catch (err) { setError(API.getFriendlyMessage(err)); @@ -168,17 +258,116 @@ const KubernetesClusterOverview: FunctionComponent< description: "View all containers", pageMap: PageMap.KUBERNETES_CLUSTER_VIEW_CONTAINERS, }, + { + title: "PVCs", + description: "View persistent volume claims", + pageMap: PageMap.KUBERNETES_CLUSTER_VIEW_PVCS, + }, + { + title: "PVs", + description: "View persistent volumes", + pageMap: PageMap.KUBERNETES_CLUSTER_VIEW_PVS, + }, ]; return ( + {/* Cluster Health Banner */} +
+
+
+ + + Cluster {clusterHealth} + +
+
+ + + {podHealthSummary.running} + {" "} + Running + + {podHealthSummary.pending > 0 && ( + + + {podHealthSummary.pending} + {" "} + Pending + + )} + {podHealthSummary.failed > 0 && ( + + + {podHealthSummary.failed} + {" "} + Failed + + )} + {nodeHealthSummary.notReady > 0 && ( + + + {nodeHealthSummary.notReady} + {" "} + Nodes Not Ready + + )} +
+
+
+ {/* Summary Cards */} -
+
+ + {clusterHealth} + + } + /> {nodeCount.toString()} + {nodeHealthSummary.notReady > 0 && ( + + ({nodeHealthSummary.notReady} not ready) + + )} } /> @@ -218,13 +407,17 @@ const KubernetesClusterOverview: FunctionComponent< ); })}
@@ -248,13 +441,17 @@ const KubernetesClusterOverview: FunctionComponent< ); })}
diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/JobDetail.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/JobDetail.tsx index 8ed7927bf6..8829997b26 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/JobDetail.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/JobDetail.tsx @@ -3,13 +3,12 @@ import ObjectID from "Common/Types/ObjectID"; import Navigation from "Common/UI/Utils/Navigation"; import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster"; import Card from "Common/UI/Components/Card/Card"; -import InfoCard from "Common/UI/Components/InfoCard/InfoCard"; + import MetricQueryConfigData, { ChartSeries, } from "Common/Types/Metrics/MetricQueryConfigData"; import AggregationType from "Common/Types/BaseDatabase/AggregationType"; import React, { - Fragment, FunctionComponent, ReactElement, useEffect, @@ -28,6 +27,7 @@ import KubernetesEventsTab from "../../../Components/Kubernetes/KubernetesEvents import KubernetesMetricsTab from "../../../Components/Kubernetes/KubernetesMetricsTab"; import { KubernetesJobObject } from "../Utils/KubernetesObjectParser"; import { fetchLatestK8sObject } from "../Utils/KubernetesObjectFetcher"; +import KubernetesResourceUtils from "../Utils/KubernetesResourceUtils"; const KubernetesClusterJobDetail: FunctionComponent< PageComponentProps @@ -144,7 +144,7 @@ const KubernetesClusterJobDetail: FunctionComponent< title: "Pod Memory Usage", description: `Memory usage for pods in job ${jobName}`, legend: "Memory", - legendUnit: "bytes", + legendUnit: "", }, metricQueryData: { filterData: { @@ -161,6 +161,7 @@ const KubernetesClusterJobDetail: FunctionComponent< }, }, getSeries: getSeries, + yAxisValueFormatter: KubernetesResourceUtils.formatBytesForChart, }; // Build overview summary fields from job object @@ -257,18 +258,7 @@ const KubernetesClusterJobDetail: FunctionComponent< }, ]; - return ( - -
-
- - -
-
- - {}} /> -
- ); + return {}} />; }; export default KubernetesClusterJobDetail; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Jobs.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Jobs.tsx index 4b35e99940..e70bc0aab0 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Jobs.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Jobs.tsx @@ -6,13 +6,7 @@ import KubernetesResourceTable from "../../../Components/Kubernetes/KubernetesRe import KubernetesResourceUtils, { KubernetesResource, } from "../Utils/KubernetesResourceUtils"; -import React, { - Fragment, - FunctionComponent, - ReactElement, - useEffect, - useState, -} from "react"; +import React, { FunctionComponent, ReactElement, useEffect, useState } from "react"; import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI"; import API from "Common/UI/Utils/API/API"; import PageLoader from "Common/UI/Components/Loader/PageLoader"; @@ -21,6 +15,11 @@ import { PromiseVoidFunction } from "Common/Types/FunctionTypes"; import PageMap from "../../../Utils/PageMap"; import RouteMap, { RouteUtil } from "../../../Utils/RouteMap"; import Route from "Common/Types/API/Route"; +import { + fetchK8sObjectsBatch, + KubernetesObjectType, +} from "../Utils/KubernetesObjectFetcher"; +import { KubernetesJobObject } from "../Utils/KubernetesObjectParser"; const KubernetesClusterJobs: FunctionComponent< PageComponentProps @@ -48,13 +47,43 @@ const KubernetesClusterJobs: FunctionComponent< return; } - const jobList: Array = - await KubernetesResourceUtils.fetchResourceListWithMemory({ + const [jobList, jobObjects]: [ + Array, + Map, + ] = await Promise.all([ + KubernetesResourceUtils.fetchResourceListWithMemory({ clusterIdentifier: cluster.clusterIdentifier, metricName: "k8s.pod.cpu.utilization", memoryMetricName: "k8s.pod.memory.usage", resourceNameAttribute: "resource.k8s.job.name", - }); + }), + fetchK8sObjectsBatch({ + clusterIdentifier: cluster.clusterIdentifier, + resourceType: "jobs", + }), + ]); + + for (const resource of jobList) { + const key: string = `${resource.namespace}/${resource.name}`; + const jobObj: KubernetesObjectType | undefined = jobObjects.get(key); + if (jobObj) { + const job: KubernetesJobObject = jobObj as KubernetesJobObject; + + if (job.status.completionTime) { + resource.status = "Complete"; + } else if (job.status.failed > 0) { + resource.status = "Failed"; + } else if (job.status.active > 0) { + resource.status = "Running"; + } else { + resource.status = "Pending"; + } + + resource.age = KubernetesResourceUtils.formatAge( + job.metadata.creationTimestamp, + ); + } + } setResources(jobList); } catch (err) { @@ -78,22 +107,20 @@ const KubernetesClusterJobs: FunctionComponent< } return ( - - { - return RouteUtil.populateRouteParams( - RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_JOB_DETAIL] as Route, - { - modelId: modelId, - subModelId: new ObjectID(resource.name), - }, - ); - }} - /> - + { + return RouteUtil.populateRouteParams( + RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_JOB_DETAIL] as Route, + { + modelId: modelId, + subModelId: new ObjectID(resource.name), + }, + ); + }} + /> ); }; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/NamespaceDetail.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/NamespaceDetail.tsx index e41802b9df..2b04608be5 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/NamespaceDetail.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/NamespaceDetail.tsx @@ -3,13 +3,12 @@ import ObjectID from "Common/Types/ObjectID"; import Navigation from "Common/UI/Utils/Navigation"; import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster"; import Card from "Common/UI/Components/Card/Card"; -import InfoCard from "Common/UI/Components/InfoCard/InfoCard"; + import MetricQueryConfigData, { ChartSeries, } from "Common/Types/Metrics/MetricQueryConfigData"; import AggregationType from "Common/Types/BaseDatabase/AggregationType"; import React, { - Fragment, FunctionComponent, ReactElement, useEffect, @@ -28,6 +27,7 @@ import KubernetesEventsTab from "../../../Components/Kubernetes/KubernetesEvents import KubernetesMetricsTab from "../../../Components/Kubernetes/KubernetesMetricsTab"; import { KubernetesNamespaceObject } from "../Utils/KubernetesObjectParser"; import { fetchLatestK8sObject } from "../Utils/KubernetesObjectFetcher"; +import KubernetesResourceUtils from "../Utils/KubernetesResourceUtils"; const KubernetesClusterNamespaceDetail: FunctionComponent< PageComponentProps @@ -146,7 +146,7 @@ const KubernetesClusterNamespaceDetail: FunctionComponent< title: "Pod Memory Usage", description: `Memory usage for pods in namespace ${namespaceName}`, legend: "Memory", - legendUnit: "bytes", + legendUnit: "", }, metricQueryData: { filterData: { @@ -163,6 +163,7 @@ const KubernetesClusterNamespaceDetail: FunctionComponent< }, }, getSeries: getSeries, + yAxisValueFormatter: KubernetesResourceUtils.formatBytesForChart, }; // Build overview summary fields from namespace object @@ -225,18 +226,7 @@ const KubernetesClusterNamespaceDetail: FunctionComponent< }, ]; - return ( - -
-
- - -
-
- - {}} /> -
- ); + return {}} />; }; export default KubernetesClusterNamespaceDetail; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Namespaces.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Namespaces.tsx index 652d0168d0..f503f7dd5b 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Namespaces.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Namespaces.tsx @@ -6,13 +6,7 @@ import KubernetesResourceTable from "../../../Components/Kubernetes/KubernetesRe import KubernetesResourceUtils, { KubernetesResource, } from "../Utils/KubernetesResourceUtils"; -import React, { - Fragment, - FunctionComponent, - ReactElement, - useEffect, - useState, -} from "react"; +import React, { FunctionComponent, ReactElement, useEffect, useState } from "react"; import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI"; import API from "Common/UI/Utils/API/API"; import PageLoader from "Common/UI/Components/Loader/PageLoader"; @@ -21,6 +15,11 @@ import { PromiseVoidFunction } from "Common/Types/FunctionTypes"; import PageMap from "../../../Utils/PageMap"; import RouteMap, { RouteUtil } from "../../../Utils/RouteMap"; import Route from "Common/Types/API/Route"; +import { + fetchK8sObjectsBatch, + KubernetesObjectType, +} from "../Utils/KubernetesObjectFetcher"; +import { KubernetesNamespaceObject } from "../Utils/KubernetesObjectParser"; const KubernetesClusterNamespaces: FunctionComponent< PageComponentProps @@ -48,14 +47,38 @@ const KubernetesClusterNamespaces: FunctionComponent< return; } - const namespaceList: Array = - await KubernetesResourceUtils.fetchResourceListWithMemory({ + const [namespaceList, namespaceObjects]: [ + Array, + Map, + ] = await Promise.all([ + KubernetesResourceUtils.fetchResourceListWithMemory({ clusterIdentifier: cluster.clusterIdentifier, metricName: "k8s.pod.cpu.utilization", memoryMetricName: "k8s.pod.memory.usage", resourceNameAttribute: "resource.k8s.namespace.name", namespaceAttribute: "resource.k8s.namespace.name", - }); + }), + fetchK8sObjectsBatch({ + clusterIdentifier: cluster.clusterIdentifier, + resourceType: "namespaces", + }), + ]); + + for (const resource of namespaceList) { + const key: string = resource.name; + const nsObj: KubernetesObjectType | undefined = + namespaceObjects.get(key); + if (nsObj) { + const ns: KubernetesNamespaceObject = + nsObj as KubernetesNamespaceObject; + + resource.status = ns.status.phase || "Unknown"; + + resource.age = KubernetesResourceUtils.formatAge( + ns.metadata.creationTimestamp, + ); + } + } setResources(namespaceList); } catch (err) { @@ -79,23 +102,21 @@ const KubernetesClusterNamespaces: FunctionComponent< } return ( - - { - return RouteUtil.populateRouteParams( - RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_NAMESPACE_DETAIL] as Route, - { - modelId: modelId, - subModelId: new ObjectID(resource.name), - }, - ); - }} - /> - + { + return RouteUtil.populateRouteParams( + RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_NAMESPACE_DETAIL] as Route, + { + modelId: modelId, + subModelId: new ObjectID(resource.name), + }, + ); + }} + /> ); }; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/NodeDetail.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/NodeDetail.tsx index 0bc447245c..8d52b25481 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/NodeDetail.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/NodeDetail.tsx @@ -3,11 +3,10 @@ import ObjectID from "Common/Types/ObjectID"; import Navigation from "Common/UI/Utils/Navigation"; import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster"; import Card from "Common/UI/Components/Card/Card"; -import InfoCard from "Common/UI/Components/InfoCard/InfoCard"; + import MetricQueryConfigData from "Common/Types/Metrics/MetricQueryConfigData"; import AggregationType from "Common/Types/BaseDatabase/AggregationType"; import React, { - Fragment, FunctionComponent, ReactElement, useEffect, @@ -28,6 +27,7 @@ import { KubernetesNodeObject, } from "../Utils/KubernetesObjectParser"; import { fetchLatestK8sObject } from "../Utils/KubernetesObjectFetcher"; +import KubernetesResourceUtils from "../Utils/KubernetesResourceUtils"; const KubernetesClusterNodeDetail: FunctionComponent< PageComponentProps @@ -135,7 +135,7 @@ const KubernetesClusterNodeDetail: FunctionComponent< title: "Memory Usage", description: `Memory usage for node ${nodeName}`, legend: "Memory", - legendUnit: "bytes", + legendUnit: "", }, metricQueryData: { filterData: { @@ -151,6 +151,7 @@ const KubernetesClusterNodeDetail: FunctionComponent< attributes: true, }, }, + yAxisValueFormatter: KubernetesResourceUtils.formatBytesForChart, }; const filesystemQuery: MetricQueryConfigData = { @@ -159,7 +160,7 @@ const KubernetesClusterNodeDetail: FunctionComponent< title: "Filesystem Usage", description: `Filesystem usage for node ${nodeName}`, legend: "Filesystem", - legendUnit: "bytes", + legendUnit: "", }, metricQueryData: { filterData: { @@ -175,6 +176,7 @@ const KubernetesClusterNodeDetail: FunctionComponent< attributes: true, }, }, + yAxisValueFormatter: KubernetesResourceUtils.formatBytesForChart, }; const networkRxQuery: MetricQueryConfigData = { @@ -183,7 +185,7 @@ const KubernetesClusterNodeDetail: FunctionComponent< title: "Network Receive", description: `Network bytes received for node ${nodeName}`, legend: "Network RX", - legendUnit: "bytes/s", + legendUnit: "", }, metricQueryData: { filterData: { @@ -199,6 +201,7 @@ const KubernetesClusterNodeDetail: FunctionComponent< attributes: true, }, }, + yAxisValueFormatter: KubernetesResourceUtils.formatBytesPerSecForChart, }; const networkTxQuery: MetricQueryConfigData = { @@ -207,7 +210,7 @@ const KubernetesClusterNodeDetail: FunctionComponent< title: "Network Transmit", description: `Network bytes transmitted for node ${nodeName}`, legend: "Network TX", - legendUnit: "bytes/s", + legendUnit: "", }, metricQueryData: { filterData: { @@ -223,6 +226,7 @@ const KubernetesClusterNodeDetail: FunctionComponent< attributes: true, }, }, + yAxisValueFormatter: KubernetesResourceUtils.formatBytesPerSecForChart, }; // Determine node status from conditions @@ -352,18 +356,7 @@ const KubernetesClusterNodeDetail: FunctionComponent< }, ]; - return ( - -
-
- - -
-
- - {}} /> -
- ); + return {}} />; }; export default KubernetesClusterNodeDetail; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Nodes.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Nodes.tsx index 92ef97b42a..afba043780 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Nodes.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Nodes.tsx @@ -6,13 +6,7 @@ import KubernetesResourceTable from "../../../Components/Kubernetes/KubernetesRe import KubernetesResourceUtils, { KubernetesResource, } from "../Utils/KubernetesResourceUtils"; -import React, { - Fragment, - FunctionComponent, - ReactElement, - useEffect, - useState, -} from "react"; +import React, { FunctionComponent, ReactElement, useEffect, useState } from "react"; import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI"; import API from "Common/UI/Utils/API/API"; import PageLoader from "Common/UI/Components/Loader/PageLoader"; @@ -21,6 +15,11 @@ import { PromiseVoidFunction } from "Common/Types/FunctionTypes"; import PageMap from "../../../Utils/PageMap"; import RouteMap, { RouteUtil } from "../../../Utils/RouteMap"; import Route from "Common/Types/API/Route"; +import { + fetchK8sObjectsBatch, + KubernetesObjectType, +} from "../Utils/KubernetesObjectFetcher"; +import { KubernetesNodeObject } from "../Utils/KubernetesObjectParser"; const KubernetesClusterNodes: FunctionComponent< PageComponentProps @@ -48,14 +47,43 @@ const KubernetesClusterNodes: FunctionComponent< return; } - const nodeList: Array = - await KubernetesResourceUtils.fetchResourceListWithMemory({ + const [nodeList, nodeObjects]: [ + Array, + Map, + ] = await Promise.all([ + KubernetesResourceUtils.fetchResourceListWithMemory({ clusterIdentifier: cluster.clusterIdentifier, metricName: "k8s.node.cpu.utilization", memoryMetricName: "k8s.node.memory.usage", resourceNameAttribute: "resource.k8s.node.name", namespaceAttribute: "resource.k8s.node.name", - }); + }), + fetchK8sObjectsBatch({ + clusterIdentifier: cluster.clusterIdentifier, + resourceType: "nodes", + }), + ]); + + for (const resource of nodeList) { + const key: string = resource.name; + const nodeObj: KubernetesObjectType | undefined = nodeObjects.get(key); + if (nodeObj) { + const node: KubernetesNodeObject = nodeObj as KubernetesNodeObject; + + // Check conditions for Ready status + const readyCondition = node.status.conditions.find( + (c) => c.type === "Ready", + ); + resource.status = + readyCondition && readyCondition.status === "True" + ? "Ready" + : "NotReady"; + + resource.age = KubernetesResourceUtils.formatAge( + node.metadata.creationTimestamp, + ); + } + } setResources(nodeList); } catch (err) { @@ -79,23 +107,21 @@ const KubernetesClusterNodes: FunctionComponent< } return ( - - { - return RouteUtil.populateRouteParams( - RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_NODE_DETAIL] as Route, - { - modelId: modelId, - subModelId: new ObjectID(resource.name), - }, - ); - }} - /> - + { + return RouteUtil.populateRouteParams( + RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_NODE_DETAIL] as Route, + { + modelId: modelId, + subModelId: new ObjectID(resource.name), + }, + ); + }} + /> ); }; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/PVCDetail.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/PVCDetail.tsx new file mode 100644 index 0000000000..a399e30f45 --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/PVCDetail.tsx @@ -0,0 +1,187 @@ +import PageComponentProps from "../../PageComponentProps"; +import ObjectID from "Common/Types/ObjectID"; +import Navigation from "Common/UI/Utils/Navigation"; +import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster"; +import Card from "Common/UI/Components/Card/Card"; +import React, { + FunctionComponent, + ReactElement, + useEffect, + useState, +} from "react"; +import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI"; +import API from "Common/UI/Utils/API/API"; +import PageLoader from "Common/UI/Components/Loader/PageLoader"; +import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage"; +import { PromiseVoidFunction } from "Common/Types/FunctionTypes"; +import 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 { KubernetesPVCObject } from "../Utils/KubernetesObjectParser"; +import { fetchLatestK8sObject } from "../Utils/KubernetesObjectFetcher"; + +const KubernetesClusterPVCDetail: FunctionComponent< + PageComponentProps +> = (): ReactElement => { + const modelId: ObjectID = Navigation.getLastParamAsObjectID(2); + const pvcName: string = Navigation.getLastParamAsString(); + + const [cluster, setCluster] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(""); + const [pvcObject, setPvcObject] = useState(null); + const [isLoadingObject, setIsLoadingObject] = useState(true); + + const fetchCluster: PromiseVoidFunction = async (): Promise => { + setIsLoading(true); + try { + const item: KubernetesCluster | null = await ModelAPI.getItem({ + modelType: KubernetesCluster, + id: modelId, + select: { + clusterIdentifier: true, + }, + }); + setCluster(item); + } catch (err) { + setError(API.getFriendlyMessage(err)); + } + setIsLoading(false); + }; + + useEffect(() => { + fetchCluster().catch((err: Error) => { + setError(API.getFriendlyMessage(err)); + }); + }, []); + + useEffect(() => { + if (!cluster?.clusterIdentifier) { + return; + } + + const fetchPvcObject: () => Promise = async (): Promise => { + setIsLoadingObject(true); + try { + const obj: KubernetesPVCObject | null = + await fetchLatestK8sObject({ + clusterIdentifier: cluster.clusterIdentifier || "", + resourceType: "persistentvolumeclaims", + resourceName: pvcName, + }); + setPvcObject(obj); + } catch { + // Graceful degradation + } + setIsLoadingObject(false); + }; + + fetchPvcObject().catch(() => {}); + }, [cluster?.clusterIdentifier, pvcName]); + + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + if (!cluster) { + return ; + } + + const clusterIdentifier: string = cluster.clusterIdentifier || ""; + + const summaryFields: Array<{ + title: string; + value: string | ReactElement; + }> = [ + { title: "PVC Name", value: pvcName }, + { title: "Cluster", value: clusterIdentifier }, + ]; + + if (pvcObject) { + summaryFields.push( + { + title: "Namespace", + value: pvcObject.metadata.namespace || "default", + }, + { + title: "Status", + value: ( + + {pvcObject.status.phase || "Unknown"} + + ), + }, + { + title: "Storage Class", + value: pvcObject.spec.storageClassName || "N/A", + }, + { + title: "Capacity", + value: pvcObject.status.capacity.storage || "N/A", + }, + { + title: "Requested Storage", + value: pvcObject.spec.resources.requests.storage || "N/A", + }, + { + title: "Volume Name", + value: pvcObject.spec.volumeName || "N/A", + }, + { + title: "Access Modes", + value: pvcObject.spec.accessModes.join(", ") || "N/A", + }, + { + title: "Created", + value: pvcObject.metadata.creationTimestamp || "N/A", + }, + ); + } + + const tabs: Array = [ + { + name: "Overview", + children: ( + + ), + }, + { + name: "Events", + children: ( + + + + ), + }, + ]; + + return {}} />; +}; + +export default KubernetesClusterPVCDetail; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/PVDetail.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/PVDetail.tsx new file mode 100644 index 0000000000..15530f3350 --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/PVDetail.tsx @@ -0,0 +1,185 @@ +import PageComponentProps from "../../PageComponentProps"; +import ObjectID from "Common/Types/ObjectID"; +import Navigation from "Common/UI/Utils/Navigation"; +import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster"; +import Card from "Common/UI/Components/Card/Card"; +import React, { + FunctionComponent, + ReactElement, + useEffect, + useState, +} from "react"; +import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI"; +import API from "Common/UI/Utils/API/API"; +import PageLoader from "Common/UI/Components/Loader/PageLoader"; +import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage"; +import { PromiseVoidFunction } from "Common/Types/FunctionTypes"; +import 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 { KubernetesPVObject } from "../Utils/KubernetesObjectParser"; +import { fetchLatestK8sObject } from "../Utils/KubernetesObjectFetcher"; + +const KubernetesClusterPVDetail: FunctionComponent< + PageComponentProps +> = (): ReactElement => { + const modelId: ObjectID = Navigation.getLastParamAsObjectID(2); + const pvName: string = Navigation.getLastParamAsString(); + + const [cluster, setCluster] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(""); + const [pvObject, setPvObject] = useState(null); + const [isLoadingObject, setIsLoadingObject] = useState(true); + + const fetchCluster: PromiseVoidFunction = async (): Promise => { + setIsLoading(true); + try { + const item: KubernetesCluster | null = await ModelAPI.getItem({ + modelType: KubernetesCluster, + id: modelId, + select: { + clusterIdentifier: true, + }, + }); + setCluster(item); + } catch (err) { + setError(API.getFriendlyMessage(err)); + } + setIsLoading(false); + }; + + useEffect(() => { + fetchCluster().catch((err: Error) => { + setError(API.getFriendlyMessage(err)); + }); + }, []); + + useEffect(() => { + if (!cluster?.clusterIdentifier) { + return; + } + + const fetchPvObject: () => Promise = async (): Promise => { + setIsLoadingObject(true); + try { + const obj: KubernetesPVObject | null = + await fetchLatestK8sObject({ + clusterIdentifier: cluster.clusterIdentifier || "", + resourceType: "persistentvolumes", + resourceName: pvName, + }); + setPvObject(obj); + } catch { + // Graceful degradation + } + setIsLoadingObject(false); + }; + + fetchPvObject().catch(() => {}); + }, [cluster?.clusterIdentifier, pvName]); + + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + if (!cluster) { + return ; + } + + const clusterIdentifier: string = cluster.clusterIdentifier || ""; + + const summaryFields: Array<{ + title: string; + value: string | ReactElement; + }> = [ + { title: "PV Name", value: pvName }, + { title: "Cluster", value: clusterIdentifier }, + ]; + + if (pvObject) { + summaryFields.push( + { + title: "Status", + value: ( + + {pvObject.status.phase || "Unknown"} + + ), + }, + { + title: "Capacity", + value: pvObject.spec.capacity.storage || "N/A", + }, + { + title: "Storage Class", + value: pvObject.spec.storageClassName || "N/A", + }, + { + title: "Reclaim Policy", + value: pvObject.spec.persistentVolumeReclaimPolicy || "N/A", + }, + { + title: "Access Modes", + value: pvObject.spec.accessModes.join(", ") || "N/A", + }, + { + title: "Claim", + value: pvObject.spec.claimRef.name + ? `${pvObject.spec.claimRef.namespace}/${pvObject.spec.claimRef.name}` + : "N/A", + }, + { + title: "Created", + value: pvObject.metadata.creationTimestamp || "N/A", + }, + ); + } + + const tabs: Array = [ + { + name: "Overview", + children: ( + + ), + }, + { + name: "Events", + children: ( + + + + ), + }, + ]; + + return {}} />; +}; + +export default KubernetesClusterPVDetail; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/PersistentVolumeClaims.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/PersistentVolumeClaims.tsx new file mode 100644 index 0000000000..98518ad645 --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/PersistentVolumeClaims.tsx @@ -0,0 +1,143 @@ +import PageComponentProps from "../../PageComponentProps"; +import ObjectID from "Common/Types/ObjectID"; +import Navigation from "Common/UI/Utils/Navigation"; +import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster"; +import KubernetesResourceTable from "../../../Components/Kubernetes/KubernetesResourceTable"; +import KubernetesResourceUtils, { + KubernetesResource, +} from "../Utils/KubernetesResourceUtils"; +import React, { + FunctionComponent, + ReactElement, + useEffect, + useState, +} from "react"; +import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI"; +import API from "Common/UI/Utils/API/API"; +import PageLoader from "Common/UI/Components/Loader/PageLoader"; +import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage"; +import { PromiseVoidFunction } from "Common/Types/FunctionTypes"; +import PageMap from "../../../Utils/PageMap"; +import RouteMap, { RouteUtil } from "../../../Utils/RouteMap"; +import Route from "Common/Types/API/Route"; +import { + fetchK8sObjectsBatch, + KubernetesObjectType, +} from "../Utils/KubernetesObjectFetcher"; +import { KubernetesPVCObject } from "../Utils/KubernetesObjectParser"; + +const KubernetesClusterPVCs: FunctionComponent< + PageComponentProps +> = (): ReactElement => { + const modelId: ObjectID = Navigation.getLastParamAsObjectID(1); + + const [resources, setResources] = useState>([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(""); + + const fetchData: PromiseVoidFunction = async (): Promise => { + setIsLoading(true); + try { + const cluster: KubernetesCluster | null = await ModelAPI.getItem({ + modelType: KubernetesCluster, + id: modelId, + select: { + clusterIdentifier: true, + }, + }); + + if (!cluster?.clusterIdentifier) { + setError("Cluster not found."); + setIsLoading(false); + return; + } + + const pvcObjects: Map = + await fetchK8sObjectsBatch({ + clusterIdentifier: cluster.clusterIdentifier, + resourceType: "persistentvolumeclaims", + }); + + const pvcResources: Array = []; + + for (const pvcObj of pvcObjects.values()) { + const pvc: KubernetesPVCObject = pvcObj as KubernetesPVCObject; + pvcResources.push({ + name: pvc.metadata.name, + namespace: pvc.metadata.namespace || "default", + cpuUtilization: null, + memoryUsageBytes: null, + memoryLimitBytes: null, + status: pvc.status.phase || "Unknown", + age: KubernetesResourceUtils.formatAge( + pvc.metadata.creationTimestamp, + ), + additionalAttributes: { + storageClass: pvc.spec.storageClassName || "N/A", + capacity: pvc.status.capacity.storage || "N/A", + volumeName: pvc.spec.volumeName || "N/A", + accessModes: pvc.spec.accessModes.join(", ") || "N/A", + }, + }); + } + + setResources(pvcResources); + } catch (err) { + setError(API.getFriendlyMessage(err)); + } + setIsLoading(false); + }; + + useEffect(() => { + fetchData().catch((err: Error) => { + setError(API.getFriendlyMessage(err)); + }); + }, []); + + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + return ( + { + return RouteUtil.populateRouteParams( + RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_PVC_DETAIL] as Route, + { + modelId: modelId, + subModelId: new ObjectID(resource.name), + }, + ); + }} + emptyMessage="No PVCs found. PVC data will appear here once the kubernetes-agent Helm chart has resourceSpecs.enabled set to true and includes persistentvolumeclaims." + /> + ); +}; + +export default KubernetesClusterPVCs; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/PersistentVolumes.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/PersistentVolumes.tsx new file mode 100644 index 0000000000..a4b6b250d3 --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/PersistentVolumes.tsx @@ -0,0 +1,134 @@ +import PageComponentProps from "../../PageComponentProps"; +import ObjectID from "Common/Types/ObjectID"; +import Navigation from "Common/UI/Utils/Navigation"; +import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster"; +import KubernetesResourceTable from "../../../Components/Kubernetes/KubernetesResourceTable"; +import KubernetesResourceUtils, { + KubernetesResource, +} from "../Utils/KubernetesResourceUtils"; +import React, { + FunctionComponent, + ReactElement, + useEffect, + useState, +} from "react"; +import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI"; +import API from "Common/UI/Utils/API/API"; +import PageLoader from "Common/UI/Components/Loader/PageLoader"; +import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage"; +import { PromiseVoidFunction } from "Common/Types/FunctionTypes"; +import { + fetchK8sObjectsBatch, + KubernetesObjectType, +} from "../Utils/KubernetesObjectFetcher"; +import { KubernetesPVObject } from "../Utils/KubernetesObjectParser"; + +const KubernetesClusterPVs: FunctionComponent< + PageComponentProps +> = (): ReactElement => { + const modelId: ObjectID = Navigation.getLastParamAsObjectID(1); + + const [resources, setResources] = useState>([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(""); + + const fetchData: PromiseVoidFunction = async (): Promise => { + setIsLoading(true); + try { + const cluster: KubernetesCluster | null = await ModelAPI.getItem({ + modelType: KubernetesCluster, + id: modelId, + select: { + clusterIdentifier: true, + }, + }); + + if (!cluster?.clusterIdentifier) { + setError("Cluster not found."); + setIsLoading(false); + return; + } + + const pvObjects: Map = + await fetchK8sObjectsBatch({ + clusterIdentifier: cluster.clusterIdentifier, + resourceType: "persistentvolumes", + }); + + const pvResources: Array = []; + + for (const pvObj of pvObjects.values()) { + const pv: KubernetesPVObject = pvObj as KubernetesPVObject; + pvResources.push({ + name: pv.metadata.name, + namespace: "", + cpuUtilization: null, + memoryUsageBytes: null, + memoryLimitBytes: null, + status: pv.status.phase || "Unknown", + age: KubernetesResourceUtils.formatAge( + pv.metadata.creationTimestamp, + ), + additionalAttributes: { + capacity: pv.spec.capacity.storage || "N/A", + storageClass: pv.spec.storageClassName || "N/A", + reclaimPolicy: pv.spec.persistentVolumeReclaimPolicy || "N/A", + claimRef: pv.spec.claimRef.name + ? `${pv.spec.claimRef.namespace}/${pv.spec.claimRef.name}` + : "N/A", + }, + }); + } + + setResources(pvResources); + } catch (err) { + setError(API.getFriendlyMessage(err)); + } + setIsLoading(false); + }; + + useEffect(() => { + fetchData().catch((err: Error) => { + setError(API.getFriendlyMessage(err)); + }); + }, []); + + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + return ( + + ); +}; + +export default KubernetesClusterPVs; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/PodDetail.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/PodDetail.tsx index 79492d4309..e4fd3f83bb 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/PodDetail.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/PodDetail.tsx @@ -3,13 +3,11 @@ import ObjectID from "Common/Types/ObjectID"; import Navigation from "Common/UI/Utils/Navigation"; import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster"; import Card from "Common/UI/Components/Card/Card"; -import InfoCard from "Common/UI/Components/InfoCard/InfoCard"; import MetricQueryConfigData, { ChartSeries, } from "Common/Types/Metrics/MetricQueryConfigData"; import AggregationType from "Common/Types/BaseDatabase/AggregationType"; import React, { - Fragment, FunctionComponent, ReactElement, useEffect, @@ -30,6 +28,7 @@ import KubernetesLogsTab from "../../../Components/Kubernetes/KubernetesLogsTab" import KubernetesMetricsTab from "../../../Components/Kubernetes/KubernetesMetricsTab"; import { KubernetesPodObject } from "../Utils/KubernetesObjectParser"; import { fetchLatestK8sObject } from "../Utils/KubernetesObjectFetcher"; +import KubernetesResourceUtils from "../Utils/KubernetesResourceUtils"; const KubernetesClusterPodDetail: FunctionComponent< PageComponentProps @@ -147,7 +146,7 @@ const KubernetesClusterPodDetail: FunctionComponent< title: "Container Memory Usage", description: `Memory usage for containers in pod ${podName}`, legend: "Memory", - legendUnit: "bytes", + legendUnit: "", }, metricQueryData: { filterData: { @@ -164,6 +163,7 @@ const KubernetesClusterPodDetail: FunctionComponent< }, }, getSeries: getContainerSeries, + yAxisValueFormatter: KubernetesResourceUtils.formatBytesForChart, }; const podCpuQuery: MetricQueryConfigData = { @@ -196,7 +196,7 @@ const KubernetesClusterPodDetail: FunctionComponent< title: "Pod Memory Usage", description: `Memory usage for pod ${podName}`, legend: "Memory", - legendUnit: "bytes", + legendUnit: "", }, metricQueryData: { filterData: { @@ -212,6 +212,7 @@ const KubernetesClusterPodDetail: FunctionComponent< attributes: true, }, }, + yAxisValueFormatter: KubernetesResourceUtils.formatBytesForChart, }; // Build overview summary fields from pod object @@ -310,16 +311,11 @@ const KubernetesClusterPodDetail: FunctionComponent< { name: "Logs", children: ( - - - + ), }, { @@ -337,18 +333,7 @@ const KubernetesClusterPodDetail: FunctionComponent< }, ]; - return ( - -
-
- - -
-
- - {}} /> -
- ); + return {}} />; }; export default KubernetesClusterPodDetail; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Pods.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Pods.tsx index cd90eacda8..d2125320c8 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Pods.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Pods.tsx @@ -6,13 +6,7 @@ import KubernetesResourceTable from "../../../Components/Kubernetes/KubernetesRe import KubernetesResourceUtils, { KubernetesResource, } from "../Utils/KubernetesResourceUtils"; -import React, { - Fragment, - FunctionComponent, - ReactElement, - useEffect, - useState, -} from "react"; +import React, { FunctionComponent, ReactElement, useEffect, useState } from "react"; import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI"; import API from "Common/UI/Utils/API/API"; import PageLoader from "Common/UI/Components/Loader/PageLoader"; @@ -21,6 +15,37 @@ import { PromiseVoidFunction } from "Common/Types/FunctionTypes"; import PageMap from "../../../Utils/PageMap"; import RouteMap, { RouteUtil } from "../../../Utils/RouteMap"; import Route from "Common/Types/API/Route"; +import { + fetchK8sObjectsBatch, + KubernetesObjectType, +} from "../Utils/KubernetesObjectFetcher"; +import { KubernetesPodObject } from "../Utils/KubernetesObjectParser"; + +function parseMemoryString(memory: string): number { + if (!memory) { + return 0; + } + const value: number = parseFloat(memory); + if (memory.endsWith("Gi")) { + return value * 1024 * 1024 * 1024; + } + if (memory.endsWith("Mi")) { + return value * 1024 * 1024; + } + if (memory.endsWith("Ki")) { + return value * 1024; + } + if (memory.endsWith("G")) { + return value * 1000 * 1000 * 1000; + } + if (memory.endsWith("M")) { + return value * 1000 * 1000; + } + if (memory.endsWith("K")) { + return value * 1000; + } + return value; +} const KubernetesClusterPods: FunctionComponent< PageComponentProps @@ -48,8 +73,11 @@ const KubernetesClusterPods: FunctionComponent< return; } - const podList: Array = - await KubernetesResourceUtils.fetchResourceListWithMemory({ + const [podList, podObjects]: [ + Array, + Map, + ] = await Promise.all([ + KubernetesResourceUtils.fetchResourceListWithMemory({ clusterIdentifier: cluster.clusterIdentifier, metricName: "k8s.pod.cpu.utilization", memoryMetricName: "k8s.pod.memory.usage", @@ -58,7 +86,46 @@ const KubernetesClusterPods: FunctionComponent< "resource.k8s.node.name", "resource.k8s.deployment.name", ], - }); + }), + fetchK8sObjectsBatch({ + clusterIdentifier: cluster.clusterIdentifier, + resourceType: "pods", + }), + ]); + + for (const resource of podList) { + const key: string = `${resource.namespace}/${resource.name}`; + const podObj: KubernetesObjectType | undefined = podObjects.get(key); + if (podObj) { + const pod: KubernetesPodObject = podObj as KubernetesPodObject; + resource.status = pod.status.phase || "Unknown"; + + for (const cs of pod.status.containerStatuses) { + if (cs.state === "waiting" && cs.reason) { + resource.status = cs.reason; + break; + } + } + + resource.age = KubernetesResourceUtils.formatAge( + pod.metadata.creationTimestamp, + ); + resource.additionalAttributes["containers"] = + `${pod.spec.containers.length}`; + + let totalMemoryLimit: number = 0; + for (const container of pod.spec.containers) { + if (container.resources.limits.memory) { + totalMemoryLimit += parseMemoryString( + container.resources.limits.memory, + ); + } + } + if (totalMemoryLimit > 0) { + resource.memoryLimitBytes = totalMemoryLimit; + } + } + } setResources(podList); } catch (err) { @@ -82,28 +149,30 @@ const KubernetesClusterPods: FunctionComponent< } return ( - - { + return RouteUtil.populateRouteParams( + RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_POD_DETAIL] as Route, { - title: "Node", - key: "resource.k8s.node.name", + modelId: modelId, + subModelId: new ObjectID(resource.name), }, - ]} - getViewRoute={(resource: KubernetesResource) => { - return RouteUtil.populateRouteParams( - RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_POD_DETAIL] as Route, - { - modelId: modelId, - subModelId: new ObjectID(resource.name), - }, - ); - }} - /> - + ); + }} + /> ); }; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/SideMenu.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/SideMenu.tsx index 67031555a9..9c1c940277 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/SideMenu.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/SideMenu.tsx @@ -134,6 +134,30 @@ const KubernetesClusterSideMenu: FunctionComponent = ( }} icon={IconProp.Cube} /> + + diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/StatefulSetDetail.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/StatefulSetDetail.tsx index 10e638c077..77fd8dc33f 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/StatefulSetDetail.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/StatefulSetDetail.tsx @@ -3,13 +3,12 @@ import ObjectID from "Common/Types/ObjectID"; import Navigation from "Common/UI/Utils/Navigation"; import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster"; import Card from "Common/UI/Components/Card/Card"; -import InfoCard from "Common/UI/Components/InfoCard/InfoCard"; + import MetricQueryConfigData, { ChartSeries, } from "Common/Types/Metrics/MetricQueryConfigData"; import AggregationType from "Common/Types/BaseDatabase/AggregationType"; import React, { - Fragment, FunctionComponent, ReactElement, useEffect, @@ -28,6 +27,7 @@ import KubernetesEventsTab from "../../../Components/Kubernetes/KubernetesEvents import KubernetesMetricsTab from "../../../Components/Kubernetes/KubernetesMetricsTab"; import { KubernetesStatefulSetObject } from "../Utils/KubernetesObjectParser"; import { fetchLatestK8sObject } from "../Utils/KubernetesObjectFetcher"; +import KubernetesResourceUtils from "../Utils/KubernetesResourceUtils"; const KubernetesClusterStatefulSetDetail: FunctionComponent< PageComponentProps @@ -145,7 +145,7 @@ const KubernetesClusterStatefulSetDetail: FunctionComponent< title: "Pod Memory Usage", description: `Memory usage for pods in statefulset ${statefulSetName}`, legend: "Memory", - legendUnit: "bytes", + legendUnit: "", }, metricQueryData: { filterData: { @@ -162,6 +162,7 @@ const KubernetesClusterStatefulSetDetail: FunctionComponent< }, }, getSeries: getSeries, + yAxisValueFormatter: KubernetesResourceUtils.formatBytesForChart, }; // Build overview summary fields from statefulset object @@ -245,18 +246,7 @@ const KubernetesClusterStatefulSetDetail: FunctionComponent< }, ]; - return ( - -
-
- - -
-
- - {}} /> -
- ); + return {}} />; }; export default KubernetesClusterStatefulSetDetail; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/StatefulSets.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/StatefulSets.tsx index 54cd58b1fd..a88d0d5fb1 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/StatefulSets.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/StatefulSets.tsx @@ -6,13 +6,7 @@ import KubernetesResourceTable from "../../../Components/Kubernetes/KubernetesRe import KubernetesResourceUtils, { KubernetesResource, } from "../Utils/KubernetesResourceUtils"; -import React, { - Fragment, - FunctionComponent, - ReactElement, - useEffect, - useState, -} from "react"; +import React, { FunctionComponent, ReactElement, useEffect, useState } from "react"; import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI"; import API from "Common/UI/Utils/API/API"; import PageLoader from "Common/UI/Components/Loader/PageLoader"; @@ -21,6 +15,11 @@ import { PromiseVoidFunction } from "Common/Types/FunctionTypes"; import PageMap from "../../../Utils/PageMap"; import RouteMap, { RouteUtil } from "../../../Utils/RouteMap"; import Route from "Common/Types/API/Route"; +import { + fetchK8sObjectsBatch, + KubernetesObjectType, +} from "../Utils/KubernetesObjectFetcher"; +import { KubernetesStatefulSetObject } from "../Utils/KubernetesObjectParser"; const KubernetesClusterStatefulSets: FunctionComponent< PageComponentProps @@ -48,13 +47,46 @@ const KubernetesClusterStatefulSets: FunctionComponent< return; } - const statefulsetList: Array = - await KubernetesResourceUtils.fetchResourceListWithMemory({ + const [statefulsetList, statefulsetObjects]: [ + Array, + Map, + ] = await Promise.all([ + KubernetesResourceUtils.fetchResourceListWithMemory({ clusterIdentifier: cluster.clusterIdentifier, metricName: "k8s.pod.cpu.utilization", memoryMetricName: "k8s.pod.memory.usage", resourceNameAttribute: "resource.k8s.statefulset.name", - }); + }), + fetchK8sObjectsBatch({ + clusterIdentifier: cluster.clusterIdentifier, + resourceType: "statefulsets", + }), + ]); + + for (const resource of statefulsetList) { + const key: string = `${resource.namespace}/${resource.name}`; + const stsObj: KubernetesObjectType | undefined = + statefulsetObjects.get(key); + if (stsObj) { + const sts: KubernetesStatefulSetObject = + stsObj as KubernetesStatefulSetObject; + + const readyReplicas: number = sts.status.readyReplicas; + const replicas: number = sts.spec.replicas; + + resource.status = + readyReplicas === replicas && replicas > 0 + ? "Ready" + : "Progressing"; + + resource.additionalAttributes["ready"] = + `${readyReplicas}/${replicas}`; + + resource.age = KubernetesResourceUtils.formatAge( + sts.metadata.creationTimestamp, + ); + } + } setResources(statefulsetList); } catch (err) { @@ -78,24 +110,28 @@ const KubernetesClusterStatefulSets: FunctionComponent< } return ( - - { - return RouteUtil.populateRouteParams( - RouteMap[ - PageMap.KUBERNETES_CLUSTER_VIEW_STATEFULSET_DETAIL - ] as Route, - { - modelId: modelId, - subModelId: new ObjectID(resource.name), - }, - ); - }} - /> - + { + return RouteUtil.populateRouteParams( + RouteMap[ + PageMap.KUBERNETES_CLUSTER_VIEW_STATEFULSET_DETAIL + ] as Route, + { + modelId: modelId, + subModelId: new ObjectID(resource.name), + }, + ); + }} + /> ); }; diff --git a/App/FeatureSet/Dashboard/src/Utils/PageMap.ts b/App/FeatureSet/Dashboard/src/Utils/PageMap.ts index ac1ca4cf6d..9da1af3e65 100644 --- a/App/FeatureSet/Dashboard/src/Utils/PageMap.ts +++ b/App/FeatureSet/Dashboard/src/Utils/PageMap.ts @@ -238,6 +238,10 @@ enum PageMap { KUBERNETES_CLUSTER_VIEW_NODE_DETAIL = "KUBERNETES_CLUSTER_VIEW_NODE_DETAIL", KUBERNETES_CLUSTER_VIEW_CONTAINERS = "KUBERNETES_CLUSTER_VIEW_CONTAINERS", KUBERNETES_CLUSTER_VIEW_CONTAINER_DETAIL = "KUBERNETES_CLUSTER_VIEW_CONTAINER_DETAIL", + KUBERNETES_CLUSTER_VIEW_PVCS = "KUBERNETES_CLUSTER_VIEW_PVCS", + KUBERNETES_CLUSTER_VIEW_PVC_DETAIL = "KUBERNETES_CLUSTER_VIEW_PVC_DETAIL", + KUBERNETES_CLUSTER_VIEW_PVS = "KUBERNETES_CLUSTER_VIEW_PVS", + KUBERNETES_CLUSTER_VIEW_PV_DETAIL = "KUBERNETES_CLUSTER_VIEW_PV_DETAIL", KUBERNETES_CLUSTER_VIEW_EVENTS = "KUBERNETES_CLUSTER_VIEW_EVENTS", KUBERNETES_CLUSTER_VIEW_CONTROL_PLANE = "KUBERNETES_CLUSTER_VIEW_CONTROL_PLANE", KUBERNETES_CLUSTER_VIEW_DELETE = "KUBERNETES_CLUSTER_VIEW_DELETE", diff --git a/App/FeatureSet/Dashboard/src/Utils/RouteMap.ts b/App/FeatureSet/Dashboard/src/Utils/RouteMap.ts index 72a16f5bda..e2be4229bf 100644 --- a/App/FeatureSet/Dashboard/src/Utils/RouteMap.ts +++ b/App/FeatureSet/Dashboard/src/Utils/RouteMap.ts @@ -79,6 +79,10 @@ export const KubernetesRoutePath: Dictionary = { [PageMap.KUBERNETES_CLUSTER_VIEW_NODE_DETAIL]: `${RouteParams.ModelID}/nodes/${RouteParams.SubModelID}`, [PageMap.KUBERNETES_CLUSTER_VIEW_CONTAINERS]: `${RouteParams.ModelID}/containers`, [PageMap.KUBERNETES_CLUSTER_VIEW_CONTAINER_DETAIL]: `${RouteParams.ModelID}/containers/${RouteParams.SubModelID}`, + [PageMap.KUBERNETES_CLUSTER_VIEW_PVCS]: `${RouteParams.ModelID}/pvcs`, + [PageMap.KUBERNETES_CLUSTER_VIEW_PVC_DETAIL]: `${RouteParams.ModelID}/pvcs/${RouteParams.SubModelID}`, + [PageMap.KUBERNETES_CLUSTER_VIEW_PVS]: `${RouteParams.ModelID}/pvs`, + [PageMap.KUBERNETES_CLUSTER_VIEW_PV_DETAIL]: `${RouteParams.ModelID}/pvs/${RouteParams.SubModelID}`, [PageMap.KUBERNETES_CLUSTER_VIEW_EVENTS]: `${RouteParams.ModelID}/events`, [PageMap.KUBERNETES_CLUSTER_VIEW_CONTROL_PLANE]: `${RouteParams.ModelID}/control-plane`, [PageMap.KUBERNETES_CLUSTER_VIEW_DELETE]: `${RouteParams.ModelID}/delete`, @@ -1621,6 +1625,30 @@ const RouteMap: Dictionary = { }`, ), + [PageMap.KUBERNETES_CLUSTER_VIEW_PVCS]: new Route( + `/dashboard/${RouteParams.ProjectID}/kubernetes/${ + KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_PVCS] + }`, + ), + + [PageMap.KUBERNETES_CLUSTER_VIEW_PVC_DETAIL]: new Route( + `/dashboard/${RouteParams.ProjectID}/kubernetes/${ + KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_PVC_DETAIL] + }`, + ), + + [PageMap.KUBERNETES_CLUSTER_VIEW_PVS]: new Route( + `/dashboard/${RouteParams.ProjectID}/kubernetes/${ + KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_PVS] + }`, + ), + + [PageMap.KUBERNETES_CLUSTER_VIEW_PV_DETAIL]: new Route( + `/dashboard/${RouteParams.ProjectID}/kubernetes/${ + KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_PV_DETAIL] + }`, + ), + [PageMap.KUBERNETES_CLUSTER_VIEW_EVENTS]: new Route( `/dashboard/${RouteParams.ProjectID}/kubernetes/${ KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_EVENTS] diff --git a/Common/Types/Metrics/MetricQueryConfigData.ts b/Common/Types/Metrics/MetricQueryConfigData.ts index 1696b5c2b0..c9e6fc24ee 100644 --- a/Common/Types/Metrics/MetricQueryConfigData.ts +++ b/Common/Types/Metrics/MetricQueryConfigData.ts @@ -16,4 +16,5 @@ export default interface MetricQueryConfigData { metricQueryData: MetricQueryData; getSeries?: ((data: AggregatedModel) => ChartSeries) | undefined; chartType?: MetricChartType | undefined; + yAxisValueFormatter?: ((value: number) => string) | undefined; } diff --git a/HelmChart/Public/kubernetes-agent/templates/configmap-deployment.yaml b/HelmChart/Public/kubernetes-agent/templates/configmap-deployment.yaml index a4ab08a5a1..446abd7126 100644 --- a/HelmChart/Public/kubernetes-agent/templates/configmap-deployment.yaml +++ b/HelmChart/Public/kubernetes-agent/templates/configmap-deployment.yaml @@ -63,6 +63,12 @@ data: mode: pull interval: {{ .Values.resourceSpecs.interval }} group: batch + - name: persistentvolumeclaims + mode: pull + interval: {{ .Values.resourceSpecs.interval }} + - name: persistentvolumes + mode: pull + interval: {{ .Values.resourceSpecs.interval }} {{- end }} {{- if .Values.controlPlane.enabled }}