From a8988346f7fc2592ea98a7439e13ca5c3df4a4a9 Mon Sep 17 00:00:00 2001 From: Nawaz Dhandala Date: Tue, 24 Mar 2026 20:21:26 +0000 Subject: [PATCH] feat: refactor KubernetesClusterEvents to use new table component and enhance filtering capabilities --- .../src/Pages/Kubernetes/View/Events.tsx | 520 +++++++++++------- 1 file changed, 308 insertions(+), 212 deletions(-) diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Events.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Events.tsx index 8ae9a3ac63..1b211f1400 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Events.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Events.tsx @@ -2,7 +2,6 @@ import PageComponentProps from "../../PageComponentProps"; import ObjectID from "Common/Types/ObjectID"; import Navigation from "Common/UI/Utils/Navigation"; import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster"; -import Card from "Common/UI/Components/Card/Card"; import AnalyticsModelAPI, { ListResult, } from "Common/UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI"; @@ -11,10 +10,10 @@ import ProjectUtil from "Common/UI/Utils/Project"; import OneUptimeDate from "Common/Types/Date"; import SortOrder from "Common/Types/BaseDatabase/SortOrder"; import React, { - Fragment, FunctionComponent, ReactElement, useEffect, + useMemo, useState, } from "react"; import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI"; @@ -25,26 +24,45 @@ import { PromiseVoidFunction } from "Common/Types/FunctionTypes"; import { JSONObject } from "Common/Types/JSON"; import InBetween from "Common/Types/BaseDatabase/InBetween"; import { getKvValue, getKvStringValue } from "../Utils/KubernetesObjectParser"; -import { KubernetesEvent } from "../Utils/KubernetesObjectFetcher"; -import FilterButtons, { - type FilterButtonOption, -} from "Common/UI/Components/FilterButtons/FilterButtons"; -import StatusBadge, { - StatusBadgeType, -} from "Common/UI/Components/StatusBadge/StatusBadge"; +import Card, { CardButtonSchema } from "Common/UI/Components/Card/Card"; +import Table from "Common/UI/Components/Table/Table"; +import FieldType from "Common/UI/Components/Types/FieldType"; +import Column from "Common/UI/Components/Table/Types/Column"; +import Filter from "Common/UI/Components/Filters/Types/Filter"; +import FilterData from "Common/UI/Components/Filters/Types/FilterData"; +import Search from "Common/Types/BaseDatabase/Search"; +import Includes from "Common/Types/BaseDatabase/Includes"; +import { ButtonStyleType } from "Common/UI/Components/Button/Button"; +import IconProp from "Common/Types/Icon/IconProp"; + +interface KubernetesEventRow { + timestamp: string; + type: string; + reason: string; + objectKind: string; + objectName: string; + object: string; + namespace: string; + message: string; +} + +const PAGE_SIZE: number = 25; const KubernetesClusterEvents: FunctionComponent< PageComponentProps > = (): ReactElement => { const modelId: ObjectID = Navigation.getLastParamAsObjectID(1); - const [cluster, setCluster] = useState(null); - const [events, setEvents] = useState>([]); + const [events, setEvents] = useState>([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(""); - const [typeFilter, setTypeFilter] = useState("all"); - const [namespaceFilter, setNamespaceFilter] = useState("all"); - const [searchText, setSearchText] = useState(""); + const [currentPage, setCurrentPage] = useState(1); + const [sortBy, setSortBy] = useState(null); + const [sortOrder, setSortOrder] = useState(SortOrder.Descending); + const [showFilterModal, setShowFilterModal] = useState(false); + const [filterData, setFilterData] = useState>( + {}, + ); const fetchData: PromiseVoidFunction = async (): Promise => { setIsLoading(true); @@ -56,9 +74,9 @@ const KubernetesClusterEvents: FunctionComponent< clusterIdentifier: true, }, }); - setCluster(item); if (!item?.clusterIdentifier) { + setError("Cluster not found."); setIsLoading(false); return; } @@ -93,12 +111,11 @@ const KubernetesClusterEvents: FunctionComponent< const listResult: ListResult = await AnalyticsModelAPI.getList(eventsQueryOptions); - const k8sEvents: Array = []; + const k8sEvents: Array = []; for (const log of listResult.data) { const attrs: JSONObject = log.attributes || {}; - // Filter to only k8s events from this cluster if ( attrs["resource.k8s.cluster.name"] !== item.clusterIdentifier && attrs["k8s.cluster.name"] !== item.clusterIdentifier @@ -106,7 +123,6 @@ const KubernetesClusterEvents: FunctionComponent< continue; } - // Parse the body which is OTLP kvlistValue JSON let bodyObj: JSONObject | null = null; try { if (typeof log.body === "string") { @@ -120,14 +136,12 @@ const KubernetesClusterEvents: FunctionComponent< continue; } - const topKvList: JSONObject | undefined = bodyObj["kvlistValue"] as - | JSONObject - | undefined; + const topKvList: JSONObject | undefined = (bodyObj["kvlistValue"] || + bodyObj["kvlist_value"]) as JSONObject | undefined; if (!topKvList) { continue; } - // Get the "object" which is the actual k8s Event const objectVal: string | JSONObject | null = getKvValue( topKvList, "object", @@ -141,7 +155,6 @@ const KubernetesClusterEvents: FunctionComponent< const reason: string = getKvStringValue(objectKvList, "reason") || ""; const note: string = getKvStringValue(objectKvList, "note") || ""; - // Get regarding object details using shared parser const regardingKv: string | JSONObject | null = getKvValue( objectKvList, "regarding", @@ -179,6 +192,7 @@ const KubernetesClusterEvents: FunctionComponent< reason: reason || "Unknown", objectKind: objectKind || "Unknown", objectName: objectName || "Unknown", + object: `${objectKind || "Unknown"}/${objectName || "Unknown"}`, namespace: namespace || "default", message: note || "", }); @@ -198,6 +212,145 @@ const KubernetesClusterEvents: FunctionComponent< }); }, []); + // Build filters from data + const filters: Array> = useMemo(() => { + const types: Array = Array.from( + new Set( + events.map((e: KubernetesEventRow) => { + return e.type; + }), + ), + ).sort(); + + const namespaces: Array = Array.from( + new Set( + events + .map((e: KubernetesEventRow) => { + return e.namespace; + }) + .filter(Boolean), + ), + ).sort(); + + const reasons: Array = Array.from( + new Set( + events + .map((e: KubernetesEventRow) => { + return e.reason; + }) + .filter(Boolean), + ), + ).sort(); + + const objectKinds: Array = Array.from( + new Set( + events + .map((e: KubernetesEventRow) => { + return e.objectKind; + }) + .filter(Boolean), + ), + ).sort(); + + return [ + { + title: "Type", + key: "type", + type: FieldType.Dropdown, + filterDropdownOptions: types.map((t: string) => { + return { label: t, value: t }; + }), + }, + { + title: "Reason", + key: "reason", + type: FieldType.Dropdown, + filterDropdownOptions: reasons.map((r: string) => { + return { label: r, value: r }; + }), + }, + { + title: "Object Kind", + key: "objectKind", + type: FieldType.Dropdown, + filterDropdownOptions: objectKinds.map((k: string) => { + return { label: k, value: k }; + }), + }, + { + title: "Namespace", + key: "namespace", + type: FieldType.Dropdown, + filterDropdownOptions: namespaces.map((ns: string) => { + return { label: ns, value: ns }; + }), + }, + { + title: "Message", + key: "message", + type: FieldType.Text, + }, + ]; + }, [events]); + + // Filter and sort data client-side + const processedData: Array = useMemo(() => { + let data: Array = [...events]; + + for (const key of Object.keys(filterData) as Array< + keyof KubernetesEventRow + >) { + const value: unknown = filterData[key]; + if (!value) { + continue; + } + + if (value instanceof Search) { + const searchText: string = value.toString().toLowerCase(); + data = data.filter((r: KubernetesEventRow) => { + const fieldValue: string = (r[key] as string) || ""; + return fieldValue.toLowerCase().includes(searchText); + }); + } else if (value instanceof Includes) { + const includeValues: Array = value.values as Array; + data = data.filter((r: KubernetesEventRow) => { + const fieldValue: string = (r[key] as string) || ""; + return includeValues.includes(fieldValue); + }); + } else if (typeof value === "string") { + data = data.filter((r: KubernetesEventRow) => { + const fieldValue: string = (r[key] as string) || ""; + return fieldValue === value; + }); + } else if (Array.isArray(value)) { + const includeValues: Array = value.map((v: unknown) => { + return String(v); + }); + data = data.filter((r: KubernetesEventRow) => { + const fieldValue: string = (r[key] as string) || ""; + return includeValues.includes(fieldValue); + }); + } + } + + if (sortBy) { + data.sort((a: KubernetesEventRow, b: KubernetesEventRow) => { + const aVal: string = (a[sortBy as keyof KubernetesEventRow] as string) || ""; + const bVal: string = (b[sortBy as keyof KubernetesEventRow] as string) || ""; + const cmp: number = aVal.localeCompare(bVal); + return sortOrder === SortOrder.Descending ? -cmp : cmp; + }); + } + + return data; + }, [events, filterData, sortBy, sortOrder]); + + // Paginate + const paginatedData: Array = useMemo(() => { + const start: number = (currentPage - 1) * PAGE_SIZE; + return processedData.slice(start, start + PAGE_SIZE); + }, [processedData, currentPage]); + if (isLoading) { return ; } @@ -206,202 +359,145 @@ const KubernetesClusterEvents: FunctionComponent< return ; } - if (!cluster) { - return ; - } - - // Compute filter options - const namespaces: Array = Array.from( - new Set( - events.map((e: KubernetesEvent) => { - return e.namespace; - }), - ), - ).sort(); - - const warningCount: number = events.filter((e: KubernetesEvent) => { - return e.type.toLowerCase() === "warning"; - }).length; - const normalCount: number = events.length - warningCount; - - // Apply filters - const filteredEvents: Array = events.filter( - (e: KubernetesEvent) => { - if (typeFilter === "warning" && e.type.toLowerCase() !== "warning") { - return false; - } - if (typeFilter === "normal" && e.type.toLowerCase() === "warning") { - return false; - } - if (namespaceFilter !== "all" && e.namespace !== namespaceFilter) { - return false; - } - if (searchText.trim()) { - const search: string = searchText.toLowerCase(); + const tableColumns: Array> = [ + { + title: "Time", + type: FieldType.Element, + key: "timestamp", + getElement: (event: KubernetesEventRow): ReactElement => { return ( - e.message.toLowerCase().includes(search) || - e.reason.toLowerCase().includes(search) || - e.objectName.toLowerCase().includes(search) || - e.objectKind.toLowerCase().includes(search) + + {event.timestamp} + ); - } - return true; + }, }, - ); + { + title: "Type", + type: FieldType.Element, + key: "type", + getElement: (event: KubernetesEventRow): ReactElement => { + const isWarning: boolean = event.type.toLowerCase() === "warning"; + return ( + + {event.type} + + ); + }, + }, + { + title: "Reason", + type: FieldType.Element, + key: "reason", + getElement: (event: KubernetesEventRow): ReactElement => { + return ( + {event.reason} + ); + }, + }, + { + title: "Object", + type: FieldType.Element, + key: "object", + disableSort: true, + getElement: (event: KubernetesEventRow): ReactElement => { + return {event.object}; + }, + }, + { + title: "Namespace", + type: FieldType.Element, + key: "namespace", + getElement: (event: KubernetesEventRow): ReactElement => { + return ( + + {event.namespace} + + ); + }, + }, + { + title: "Message", + type: FieldType.Element, + key: "message", + disableSort: true, + getElement: (event: KubernetesEventRow): ReactElement => { + return ( + {event.message} + ); + }, + }, + ]; - const filterOptions: Array = [ - { label: "All Types", value: "all" }, - { label: "Warnings", value: "warning", badge: warningCount }, - { label: "Normal", value: "normal", badge: normalCount }, + const hasActiveFilters: boolean = Object.keys(filterData).length > 0; + + const cardButtons: Array = [ + { + title: "", + buttonStyle: ButtonStyleType.ICON, + className: "py-0 pr-0 pl-1 mt-1", + onClick: () => { + setShowFilterModal(true); + }, + icon: IconProp.Filter, + }, ]; return ( - - - {/* Event Summary Banner */} -
-
- {events.length}{" "} - total events -
- {warningCount > 0 && ( - - )} - -
- - {/* Filters Row */} -
- - - {/* Namespace Filter */} - - - {/* Text Search */} - ) => { - setSearchText(e.target.value); - }} - className="px-3 py-1.5 text-xs rounded-md border border-gray-200 bg-white text-gray-700 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-indigo-500 w-64" - /> - - {/* Results Count */} - - Showing {filteredEvents.length} of {events.length} - -
- - {events.length === 0 ? ( -

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

- ) : filteredEvents.length === 0 ? ( -

- No events match the current filters. -

- ) : ( -
- - - - - - - - - - - - - {filteredEvents.map((event: KubernetesEvent, index: number) => { - const isWarning: boolean = - event.type.toLowerCase() === "warning"; - return ( - - - - - - - - - ); - })} - -
- Time - - Type - - Reason - - Object - - Namespace - - Message -
- {event.timestamp} - - - - {event.reason} - - {event.objectKind}/{event.objectName} - - - - {event.message} -
-
- )} -
-
+ + + id="kubernetes-events-table" + columns={tableColumns} + data={paginatedData} + singularLabel="Kubernetes Event" + pluralLabel="Kubernetes Events" + isLoading={false} + error="" + currentPageNumber={currentPage} + totalItemsCount={processedData.length} + itemsOnPage={paginatedData.length} + onNavigateToPage={(page: number) => { + setCurrentPage(page); + }} + sortBy={sortBy as keyof KubernetesEventRow | null} + sortOrder={sortOrder} + onSortChanged={( + newSortBy: keyof KubernetesEventRow | null, + newSortOrder: SortOrder, + ) => { + setSortBy(newSortBy as string | null); + setSortOrder(newSortOrder); + }} + filters={filters} + showFilterModal={showFilterModal} + filterData={filterData} + onFilterChanged={(newFilterData: FilterData) => { + setFilterData(newFilterData); + setCurrentPage(1); + }} + onFilterModalOpen={() => { + setShowFilterModal(true); + }} + onFilterModalClose={() => { + setShowFilterModal(false); + }} + noItemsMessage={ + hasActiveFilters + ? "No events match the current filters." + : "No Kubernetes events found in the last 24 hours. Events will appear here once the kubernetes-agent is sending data." + } + /> + ); };