diff --git a/api/src/main/java/com/github/streamshub/console/api/KafkaClustersResource.java b/api/src/main/java/com/github/streamshub/console/api/KafkaClustersResource.java index 90f4df331..b81d8a581 100644 --- a/api/src/main/java/com/github/streamshub/console/api/KafkaClustersResource.java +++ b/api/src/main/java/com/github/streamshub/console/api/KafkaClustersResource.java @@ -184,11 +184,15 @@ public CompletionStage describeCluster( KafkaCluster.Fields.NODE_POOLS, KafkaCluster.Fields.CRUISE_CONTROL_ENABLED, })) - List fields) { + List fields, + + @Parameter(description = "Time range for metrics in minutes") + @QueryParam("duration") + Integer durationMinutes) { requestedFields.accept(fields); - return clusterService.describeCluster(fields) + return clusterService.describeCluster(fields, durationMinutes) .thenApply(KafkaCluster.KafkaClusterData::new) .thenApply(Response::ok) .thenApply(Response.ResponseBuilder::build); diff --git a/api/src/main/java/com/github/streamshub/console/api/NodesResource.java b/api/src/main/java/com/github/streamshub/console/api/NodesResource.java index f1e783f88..84ce13162 100644 --- a/api/src/main/java/com/github/streamshub/console/api/NodesResource.java +++ b/api/src/main/java/com/github/streamshub/console/api/NodesResource.java @@ -169,9 +169,10 @@ public CompletionStage describeConfigs( @ResourcePrivilege(Privilege.GET) public CompletionStage getNodeMetrics( @PathParam("clusterId") String clusterId, - @PathParam("nodeId") String nodeId) { + @PathParam("nodeId") String nodeId, + @QueryParam("duration") @DefaultValue("60") int durationMinutes) { - return nodeService.getNodeMetrics(nodeId) + return nodeService.getNodeMetrics(nodeId, durationMinutes) .thenApply(metrics -> new NodeMetrics.MetricsResponse(nodeId, metrics)) .thenApply(Response::ok) .thenApply(Response.ResponseBuilder::build); diff --git a/api/src/main/java/com/github/streamshub/console/api/service/KafkaClusterService.java b/api/src/main/java/com/github/streamshub/console/api/service/KafkaClusterService.java index 859042aa1..0ebff4a5f 100644 --- a/api/src/main/java/com/github/streamshub/console/api/service/KafkaClusterService.java +++ b/api/src/main/java/com/github/streamshub/console/api/service/KafkaClusterService.java @@ -153,7 +153,7 @@ public List listClusters(ListRequestContext listSupp .toList(); } - public CompletionStage describeCluster(List fields) { + public CompletionStage describeCluster(List fields, Integer durationMinutes) { Admin adminClient = kafkaContext.admin(); DescribeClusterOptions options = new DescribeClusterOptions() .includeAuthorizedOperations(fields.contains(KafkaCluster.Fields.AUTHORIZED_OPERATIONS)); @@ -172,7 +172,7 @@ public CompletionStage describeCluster(List fields) { threadContext.currentContextExecutor()) .thenApplyAsync(this::addKafkaContextData, threadContext.currentContextExecutor()) .thenApply(this::addKafkaResourceData) - .thenCompose(cluster -> addMetrics(cluster, fields)) + .thenCompose(cluster -> addMetrics(cluster, fields, durationMinutes)) .thenApply(this::setManaged) .thenApplyAsync( permissionService.addPrivileges(ResourceTypes.Global.KAFKAS, KafkaCluster::getId), @@ -354,11 +354,13 @@ KafkaCluster setManaged(KafkaCluster cluster) { return cluster; } - CompletionStage addMetrics(KafkaCluster cluster, List fields) { + CompletionStage addMetrics(KafkaCluster cluster, List fields, Integer durationMinutes) { if (!fields.contains(KafkaCluster.Fields.METRICS)) { return CompletableFuture.completedStage(cluster); } + int finalDuration = (durationMinutes != null) ? durationMinutes : 5; + if (kafkaContext.prometheus() == null) { logger.warnf("Kafka cluster metrics were requested, but Prometheus URL is not configured"); cluster.metrics(null); @@ -380,7 +382,12 @@ CompletionStage addMetrics(KafkaCluster cluster, List fiel throw new UncheckedIOException(e); } - var rangeResults = metricsService.queryRanges(rangeQuery).toCompletableFuture(); + String promInterval = "5m"; + if (finalDuration >= 1440) promInterval = "30m"; + + final String finalizedQuery = rangeQuery.replace("[5m]", "[" + promInterval + "]"); + + var rangeResults = metricsService.queryRanges(finalizedQuery, finalDuration).toCompletableFuture(); var valueResults = metricsService.queryValues(valueQuery).toCompletableFuture(); return CompletableFuture.allOf( diff --git a/api/src/main/java/com/github/streamshub/console/api/service/MetricsService.java b/api/src/main/java/com/github/streamshub/console/api/service/MetricsService.java index 70c60bfbe..89c5bdee6 100644 --- a/api/src/main/java/com/github/streamshub/console/api/service/MetricsService.java +++ b/api/src/main/java/com/github/streamshub/console/api/service/MetricsService.java @@ -124,15 +124,18 @@ CompletionStage>> queryValues(String query }); } - CompletionStage>> queryRanges(String query) { + public CompletionStage>> queryRanges(String query, int durationMinutes) { PrometheusAPI prometheusAPI = kafkaContext.prometheus(); return fetchMetrics( () -> { Instant now = Instant.now().truncatedTo(ChronoUnit.MILLIS); - Instant start = now.minus(30, ChronoUnit.MINUTES); + Instant start = now.minus(durationMinutes, ChronoUnit.MINUTES); Instant end = now; - return prometheusAPI.queryRange(query, start, end, "25"); + + String step = calculateStep(durationMinutes); + + return prometheusAPI.queryRange(query, start, end, step); }, (metric, attributes) -> { List values = metric.getJsonArray("values") @@ -148,6 +151,16 @@ CompletionStage>> queryRanges(String query }); } + + private String calculateStep(int durationMinutes) { + if (durationMinutes <= 15) return "15s"; + if (durationMinutes <= 60) return "1m"; + if (durationMinutes <= 360) return "5m"; + if (durationMinutes <= 1440) return "15m"; + if (durationMinutes <= 2880) return "30m"; + return "2h"; + } + CompletionStage>> fetchMetrics( Supplier operation, BiFunction, M> builder) { diff --git a/api/src/main/java/com/github/streamshub/console/api/service/NodeService.java b/api/src/main/java/com/github/streamshub/console/api/service/NodeService.java index e898481f7..c843ffb62 100644 --- a/api/src/main/java/com/github/streamshub/console/api/service/NodeService.java +++ b/api/src/main/java/com/github/streamshub/console/api/service/NodeService.java @@ -524,7 +524,7 @@ private void extractNodeMetrics( }); } - public CompletionStage getNodeMetrics(String nodeId) { + public CompletionStage getNodeMetrics(String nodeId, int durationMinutes) { if (kafkaContext.prometheus() == null) { logger.warnf("Metrics requested for node %s, but Prometheus is not configured", nodeId); return CompletableFuture.completedStage(new Metrics()); @@ -534,14 +534,15 @@ public CompletionStage getNodeMetrics(String nodeId) { String namespace = clusterConfig.getNamespace(); String name = clusterConfig.getName(); - String rangeQuery; + String rawRangeQuery; String valueQuery; + try ( var rangesStream = getClass().getResourceAsStream("/metrics/queries/kafkaCluster_ranges.promql"); var valuesStream = getClass().getResourceAsStream("/metrics/queries/kafkaCluster_values.promql") ) { - rangeQuery = new String(rangesStream.readAllBytes(), StandardCharsets.UTF_8) + rawRangeQuery = new String(rangesStream.readAllBytes(), StandardCharsets.UTF_8) .formatted(namespace, name); valueQuery = new String(valuesStream.readAllBytes(), StandardCharsets.UTF_8) .formatted(namespace, name); @@ -549,23 +550,23 @@ public CompletionStage getNodeMetrics(String nodeId) { throw new UncheckedIOException(e); } - Metrics nodeMetrics = new Metrics(); + + String promInterval = "5m"; + if (durationMinutes >= 1440) promInterval = "30m"; + if (durationMinutes >= 10080) promInterval = "2h"; - var rangeFuture = metricsService.queryRanges(rangeQuery).toCompletableFuture(); + final String finalizedQuery = rawRangeQuery.replace("[5m]", "[" + promInterval + "]"); + + logger.debugf("Executing PromQL: %s", finalizedQuery); + + Metrics nodeMetrics = new Metrics(); + var rangeFuture = metricsService.queryRanges(finalizedQuery, durationMinutes).toCompletableFuture(); var valueFuture = metricsService.queryValues(valueQuery).toCompletableFuture(); return CompletableFuture.allOf(rangeFuture, valueFuture) .thenApply(nothing -> { - extractNodeMetrics( - nodeId, - rangeFuture.join(), - nodeMetrics.ranges()); - - extractNodeMetrics( - nodeId, - valueFuture.join(), - nodeMetrics.values()); - + extractNodeMetrics(nodeId, rangeFuture.join(), nodeMetrics.ranges()); + extractNodeMetrics(nodeId, valueFuture.join(), nodeMetrics.values()); return nodeMetrics; }); } diff --git a/ui/api/kafka/actions.ts b/ui/api/kafka/actions.ts index 1fe44fa0b..59de5d230 100644 --- a/ui/api/kafka/actions.ts +++ b/ui/api/kafka/actions.ts @@ -58,15 +58,22 @@ export async function getKafkaCluster( clusterId: string, params?: { fields?: string; + duration?: number; // Optional duration }, ): Promise> { + const queryParams = new URLSearchParams({ + "fields[kafkas]": + params?.fields ?? + "name,namespace,creationTimestamp,status,kafkaVersion,nodes,listeners,metrics,conditions,nodePools,cruiseControlEnabled", + }); + + if (params?.duration) { + queryParams.append("duration", params.duration.toString()); + } + return fetchData( `/api/kafkas/${clusterId}`, - new URLSearchParams({ - "fields[kafkas]": - params?.fields ?? - "name,namespace,creationTimestamp,status,kafkaVersion,nodes,listeners,conditions,nodePools,cruiseControlEnabled", - }), + queryParams, (rawData: any) => ClusterResponse.parse(rawData).data, undefined, { diff --git a/ui/api/nodes/actions.ts b/ui/api/nodes/actions.ts index d8b39a1b3..8ae17ac88 100644 --- a/ui/api/nodes/actions.ts +++ b/ui/api/nodes/actions.ts @@ -16,6 +16,8 @@ import { NodeRoles, BrokerStatus, ControllerStatus, + NodeMetricsResponseSchema, + NodeMetrics, } from "@/api/nodes/schema"; import { filterUndefinedFromObj } from "@/utils/filterUndefinedFromObj"; @@ -67,3 +69,15 @@ export async function getNodeConfiguration( (rawData) => ConfigResponseSchema.parse(rawData).data, ); } + +export async function getNodeMetrics( + kafkaId: string, + nodeId: number | string, + duration: number, +): Promise> { + return fetchData( + `/api/kafkas/${kafkaId}/nodes/${nodeId}/metrics?duration=${duration}`, + "", + (rawData) => NodeMetricsResponseSchema.parse(rawData), + ); +} diff --git a/ui/api/nodes/schema.ts b/ui/api/nodes/schema.ts index 007c402cd..4457a4420 100644 --- a/ui/api/nodes/schema.ts +++ b/ui/api/nodes/schema.ts @@ -19,10 +19,13 @@ const ControllerStatusSchema = z.union([ z.literal("Unknown"), ]); -const NodePoolsSchema = z.record(z.string(), z.object({ - roles: z.array(z.string()), - count: z.number(), -})); +const NodePoolsSchema = z.record( + z.string(), + z.object({ + roles: z.array(z.string()), + count: z.number(), + }), +); export const NodeSchema = z.object({ id: z.string(), @@ -116,6 +119,35 @@ const ConfigSchema = z.object({ ), }); +export const MetricsSchema = z.object({ + values: z.record( + z.string(), + z.array( + z.object({ + value: z.string(), + nodeId: z.string(), + }), + ), + ), + ranges: z.record( + z.string(), + z.array( + z.object({ + range: z.array(z.tuple([z.string(), z.string()])), + nodeId: z.string().optional(), + }), + ), + ), +}); + +export const NodeMetricsResponseSchema = z.object({ + data: z.object({ + attributes: z.object({ + metrics: MetricsSchema.optional().nullable(), + }), + }), +}); + export type NodeConfig = z.infer; export type BrokerStatus = z.infer; @@ -129,3 +161,5 @@ export type Statuses = z.infer; export const ConfigResponseSchema = z.object({ data: ConfigSchema, }); + +export type NodeMetrics = z.infer; diff --git a/ui/api/topics/actions.ts b/ui/api/topics/actions.ts index 44c41bf93..bcea8c80b 100644 --- a/ui/api/topics/actions.ts +++ b/ui/api/topics/actions.ts @@ -17,6 +17,8 @@ import { Topic, TopicCreateResponse, TopicCreateResponseSchema, + TopicMetrics, + TopicMetricsResponseSchema, TopicResponse, TopicsResponse, TopicsResponseSchema, @@ -206,3 +208,14 @@ export async function setTopicAsViewed(kafkaId: string, topicId: string) { return viewedTopics; } } + +export async function gettopicMetrics( + kafkaId: string, + topicId: number | string, +): Promise> { + return fetchData( + `/api/kafkas/${kafkaId}/topics/${topicId}/metrics`, + "", + (rawData) => TopicMetricsResponseSchema.parse(rawData), + ); +} diff --git a/ui/api/topics/schema.ts b/ui/api/topics/schema.ts index ee6f3d2c5..7ba2a51d0 100644 --- a/ui/api/topics/schema.ts +++ b/ui/api/topics/schema.ts @@ -71,10 +71,12 @@ export type TopicStatus = z.infer; const TopicSchema = z.object({ id: z.string(), type: z.literal("topics"), - meta: z.object({ - managed: z.boolean().optional(), - privileges: z.array(z.string()).optional(), - }).optional(), + meta: z + .object({ + managed: z.boolean().optional(), + privileges: z.array(z.string()).optional(), + }) + .optional(), attributes: z.object({ name: z.string().optional(), status: TopicStatusSchema.optional(), @@ -86,10 +88,13 @@ const TopicSchema = z.object({ totalLeaderLogBytes: z.number().optional().nullable(), }), relationships: z.object({ - consumerGroups: z.object({ - meta: z.record(z.string(), z.any()).optional(), - data: z.array(z.any()), - }).optional().nullable(), + consumerGroups: z + .object({ + meta: z.record(z.string(), z.any()).optional(), + data: z.array(z.any()), + }) + .optional() + .nullable(), }), }); export const TopicResponse = z.object({ @@ -115,7 +120,7 @@ const TopicListItemSchema = z.object({ totalLeaderLogBytes: true, }), relationships: TopicSchema.shape.relationships.pick({ - consumerGroups: true + consumerGroups: true, }), }); export type TopicListItem = z.infer; @@ -145,6 +150,36 @@ export const TopicsResponseSchema = z.object({ }), data: z.array(TopicListItemSchema), }); + +export const MetricsSchema = z.object({ + values: z.record( + z.string(), + z.array( + z.object({ + value: z.string(), + }), + ), + ), + ranges: z.record( + z.string(), + z.array( + z.object({ + range: z.array(z.tuple([z.string(), z.string()])), + }), + ), + ), +}); + +export const TopicMetricsResponseSchema = z.object({ + data: z.object({ + id: z.string(), + type: z.literal("topicMetrics"), + attributes: z.object({ + metrics: MetricsSchema.optional().nullable(), + }), + }), +}); + export type TopicsResponse = z.infer; export const TopicCreateResponseSchema = z.object({ @@ -154,3 +189,5 @@ export const TopicCreateResponseSchema = z.object({ }); export type TopicCreateResponse = z.infer; + +export type TopicMetrics = z.infer; diff --git a/ui/app/[locale]/(authorized)/kafka/[kafkaId]/overview/ConnectedClusterChartsCard.tsx b/ui/app/[locale]/(authorized)/kafka/[kafkaId]/overview/ConnectedClusterChartsCard.tsx index 7e8c1df54..a39dd3aad 100644 --- a/ui/app/[locale]/(authorized)/kafka/[kafkaId]/overview/ConnectedClusterChartsCard.tsx +++ b/ui/app/[locale]/(authorized)/kafka/[kafkaId]/overview/ConnectedClusterChartsCard.tsx @@ -8,28 +8,10 @@ import { CardTitle, Title, } from "@/libs/patternfly/react-core"; - import { useTranslations } from "next-intl"; import { ClusterDetail } from "@/api/kafka/schema"; import { ClusterChartsCard } from "@/components/ClusterOverview/ClusterChartsCard"; - -function timeSeriesMetrics( - ranges: Record | undefined, - rangeName: string, -): Record { - const series: Record = {}; - - if (ranges) { - Object.values(ranges[rangeName] ?? {}).forEach((r) => { - series[r.nodeId!] = r.range.reduce( - (a, v) => ({ ...a, [v[0]]: parseFloat(v[1]) }), - {} as TimeSeriesMetrics, - ); - }); - } - - return series; -} +import { timeSeriesMetrics } from "@/components/ClusterOverview/components/timeSeriesMetrics"; export async function ConnectedClusterChartsCard({ cluster, @@ -42,6 +24,9 @@ export async function ConnectedClusterChartsCard({ const isVirtualKafkaCluster = res?.meta?.kind === "virtualkafkaclusters.kroxylicious.io"; + const brokerList = + res?.relationships?.nodes?.data?.map((n) => `Node ${n.id}`) ?? []; + const metricsUnavailable = res?.attributes.metrics === null; console.log("is virtual kafka cluster", isVirtualKafkaCluster); @@ -79,7 +64,9 @@ export async function ConnectedClusterChartsCard({ return ( ; + topics: Promise>; }) { const res = await cluster; + const topicResponse = await topics; + + const topicList = + topicResponse.payload?.data + ?.map((topic) => ({ + id: topic.id, + name: topic.attributes.name, + })) + .filter( + (topic): topic is { id: string; name: string } => + !!topic.id && !!topic.name, + ) ?? []; + return ( r.payload ?? null); - const topics = getTopics(params.kafkaId, { fields: "status", pageSize: 1 }); + const topicsSummary = getTopics(params.kafkaId, { + fields: "status", + pageSize: 1, + }); + + const topicsForCharts = getTopics(params.kafkaId, { + fields: "name", + pageSize: 100, + }); const consumerGroups = getConsumerGroups(params.kafkaId, { fields: "groupId,state", }); @@ -45,9 +53,14 @@ export default async function OverviewPage({ consumerGroups={consumerGroups} /> } - topicsPartitions={} + topicsPartitions={} clusterCharts={} - topicCharts={} + topicCharts={ + + } recentTopics={} /> ); diff --git a/ui/components/ClusterOverview/ClusterChartsCard.stories.tsx b/ui/components/ClusterOverview/ClusterChartsCard.stories.tsx index 9b09e385e..07881f13c 100644 --- a/ui/components/ClusterOverview/ClusterChartsCard.stories.tsx +++ b/ui/components/ClusterOverview/ClusterChartsCard.stories.tsx @@ -1,6 +1,8 @@ import type { Meta, StoryObj } from "@storybook/nextjs"; import { ClusterChartsCard } from "./ClusterChartsCard"; +const brokers = ["broker1", "broker2"]; + // Mock data for charts const mockTimestamps = { "2024-01-01T00:00:00Z": 40, @@ -37,6 +39,7 @@ export const Default: Story = { availableDiskSpace: sampleAvailable, memoryUsage: sampleUsages, cpuUsage: sampleUsages, + brokerList: brokers, }, }; diff --git a/ui/components/ClusterOverview/ClusterChartsCard.tsx b/ui/components/ClusterOverview/ClusterChartsCard.tsx index aa4a799d5..f06b4dd9d 100644 --- a/ui/components/ClusterOverview/ClusterChartsCard.tsx +++ b/ui/components/ClusterOverview/ClusterChartsCard.tsx @@ -11,16 +11,29 @@ import { Divider, Flex, Title, + Toolbar, + ToolbarContent, + ToolbarGroup, Tooltip, } from "@/libs/patternfly/react-core"; import { HelpIcon } from "@/libs/patternfly/react-icons"; import { useTranslations } from "next-intl"; +import { FilterByBroker } from "./components/FilterByBroker"; +import { useState } from "react"; +import { useNodeMetrics } from "./components/useNodeMetric"; +import { DurationOptions, FilterByTime } from "./components/FilterByTime"; + +function hasMetrics(metrics?: Record) { + return metrics && Object.keys(metrics).length > 0; +} type ClusterChartsCardProps = { + brokerList: string[]; usedDiskSpace: Record; availableDiskSpace: Record; memoryUsage: Record; cpuUsage: Record; + kafkaId: string | undefined; }; export function ClusterChartsCard({ @@ -29,12 +42,47 @@ export function ClusterChartsCard({ availableDiskSpace, memoryUsage, cpuUsage, + brokerList, + kafkaId, }: | ({ isLoading: false } & ClusterChartsCardProps) | ({ isLoading: true; } & Partial<{ [key in keyof ClusterChartsCardProps]?: undefined }>)) { const t = useTranslations(); + + const [diskBroker, setDiskBroker] = useState(); + const [cpuBroker, setCpuBroker] = useState(); + const [memBroker, setMemBroker] = useState(); + + const [diskDuration, setDiskDuration] = useState( + DurationOptions.Last5minutes, + ); + const [cpuDuration, setCpuDuration] = useState( + DurationOptions.Last5minutes, + ); + const [memDuration, setMemDuration] = useState( + DurationOptions.Last5minutes, + ); + + const diskMetrics = useNodeMetrics(kafkaId, diskBroker, diskDuration); + const cpuMetrics = useNodeMetrics(kafkaId, cpuBroker, cpuDuration); + const memMetrics = useNodeMetrics(kafkaId, memBroker, memDuration); + + const diskHasMetrics = hasMetrics( + diskMetrics.data?.volume_stats_used_bytes ?? usedDiskSpace, + ); + + const cpuHasMetrics = hasMetrics( + cpuMetrics.data?.cpu_usage_seconds ?? cpuUsage, + ); + + const memHasMetrics = hasMetrics( + memMetrics.data?.memory_usage_bytes ?? memoryUsage, + ); + + const disableFilter = isLoading || !kafkaId || brokerList.length <= 1; + return ( @@ -55,11 +103,40 @@ export function ClusterChartsCard({ {isLoading ? ( ) : ( - + <> + {!isLoading && diskHasMetrics && ( + + + + + + + + + )} + + )} + {t("ClusterChartsCard.cpu_usage")}{" "} @@ -70,8 +147,35 @@ export function ClusterChartsCard({ {isLoading ? ( ) : ( - + <> + {!isLoading && cpuHasMetrics && ( + + + + + + + + + )} + + + )} + {t("ClusterChartsCard.memory_usage")}{" "} @@ -82,7 +186,32 @@ export function ClusterChartsCard({ {isLoading ? ( ) : ( - + <> + {!isLoading && memHasMetrics && ( + + + + + + + + + )} + + )} diff --git a/ui/components/ClusterOverview/TopicChartsCard.tsx b/ui/components/ClusterOverview/TopicChartsCard.tsx index 23e1daa91..4115fb62f 100644 --- a/ui/components/ClusterOverview/TopicChartsCard.tsx +++ b/ui/components/ClusterOverview/TopicChartsCard.tsx @@ -12,10 +12,35 @@ import { HelpIcon } from "@/libs/patternfly/react-icons"; import { ChartIncomingOutgoing } from "./components/ChartIncomingOutgoing"; import { ChartSkeletonLoader } from "./components/ChartSkeletonLoader"; import { useTranslations } from "next-intl"; +import { FilterByTopic } from "./components/FilterByTopic"; +import { useEffect, useState } from "react"; +import { gettopicMetrics } from "@/api/topics/actions"; + +function timeSeriesMetrics( + ranges: Record | undefined, + rangeName: string, +): TimeSeriesMetrics { + let series: TimeSeriesMetrics = {}; + + if (ranges) { + Object.values(ranges[rangeName] ?? {}).forEach((r) => { + series = r.range.reduce( + (a, v) => ({ ...a, [v[0]]: parseFloat(v[1]) }), + series, + ); + }); + } + + return series; +} + +type TopicOption = { id: string; name: string }; type TopicChartsCardProps = { incoming: TimeSeriesMetrics; outgoing: TimeSeriesMetrics; + topicList: TopicOption[]; + kafkaId: string | undefined; }; export function TopicChartsCard({ @@ -23,6 +48,8 @@ export function TopicChartsCard({ incoming, outgoing, isVirtualKafkaCluster, + topicList, + kafkaId, }: | ({ isLoading: false; @@ -34,6 +61,55 @@ export function TopicChartsCard({ } & Partial<{ [key in keyof TopicChartsCardProps]?: undefined }>)) { const t = useTranslations(); + const [selectedTopic, setSelectedTopic] = useState(); + + const selectedTopicName = topicList?.find( + (t) => t.id === selectedTopic, + )?.name; + + const [topicSpecificMetrics, setTopicSpecificMetrics] = useState<{ + incoming: TimeSeriesMetrics; + outgoing: TimeSeriesMetrics; + } | null>(null); + + const [isFetching, setIsFetching] = useState(false); + + useEffect(() => { + async function fetchSpecificMetrics() { + if (selectedTopic && kafkaId) { + setIsFetching(true); + try { + const response = await gettopicMetrics(kafkaId, selectedTopic); + const ranges = response.payload?.data?.attributes?.metrics?.ranges; + + if (ranges) { + setTopicSpecificMetrics({ + incoming: timeSeriesMetrics(ranges, "incoming_byte_rate"), + outgoing: timeSeriesMetrics(ranges, "outgoing_byte_rate"), + }); + } + } catch (error) { + console.error("Failed to fetch topic metrics", error); + } finally { + setIsFetching(false); + } + } else { + setTopicSpecificMetrics(null); + } + } + + fetchSpecificMetrics(); + }, [selectedTopic, kafkaId]); + + const displayIncoming = topicSpecificMetrics?.incoming ?? incoming; + + const displayOutgoing = topicSpecificMetrics?.outgoing ?? outgoing; + + const hasBaselineMetrics = + !isVirtualKafkaCluster && + Object.keys(incoming ?? {}).length > 0 && + Object.keys(outgoing ?? {}).length > 0; + return ( @@ -58,11 +134,23 @@ export function TopicChartsCard({ {isLoading ? ( ) : ( - + <> + {hasBaselineMetrics && ( + + )} + + + )} diff --git a/ui/components/ClusterOverview/components/ChartCpuUsage.tsx b/ui/components/ClusterOverview/components/ChartCpuUsage.tsx index c35866cb2..06911dc5f 100644 --- a/ui/components/ClusterOverview/components/ChartCpuUsage.tsx +++ b/ui/components/ClusterOverview/components/ChartCpuUsage.tsx @@ -14,9 +14,11 @@ import { useFormatter, useTranslations } from "next-intl"; import { getHeight, getPadding } from "./chartConsts"; import { useChartWidth } from "./useChartWidth"; import { formatDateTime } from "@/utils/dateTime"; +import { DurationOptions } from "./FilterByTime"; type ChartCpuUsageProps = { usages: Record; + duration: DurationOptions; }; type Datum = { @@ -25,11 +27,15 @@ type Datum = { name: string; }; -export function ChartCpuUsage({ usages }: ChartCpuUsageProps) { +export function ChartCpuUsage({ usages, duration }: ChartCpuUsageProps) { const t = useTranslations(); const format = useFormatter(); const [containerRef, width] = useChartWidth(); + const showDate = duration >= DurationOptions.Last24hours; + const axisFormat = showDate ? "HH:mm'\n'MMM dd" : "HH:mm"; + const tooltipFormat = showDate ? "MMM dd, HH:mm" : "HH:mm"; + let itemsPerRow; if (width > 650) { @@ -71,7 +77,12 @@ export function ChartCpuUsage({ usages }: ChartCpuUsageProps) { formatDateTime({ value: args?.x ?? 0})} + title={(args) => + formatDateTime({ + value: args?.x ?? 0, + format: tooltipFormat, + }) + } /> } labels={({ datum }: { datum: Datum }) => @@ -96,7 +107,12 @@ export function ChartCpuUsage({ usages }: ChartCpuUsageProps) { > formatDateTime({ value: d, format: "HH:mm" })} + tickFormat={(d) => + formatDateTime({ + value: d, + format: axisFormat, + }) + } tickCount={5} /> { return ( { - return ({ - name: `Node ${nodeId}`, - x: Date.parse(k), - y: v, - }) + key={`cpu-usage-${nodeId}`} + data={Object.entries(series).map(([k, v]) => { + return { + name: `Node ${nodeId}`, + x: Date.parse(k), + y: v, + }; })} - name={ `node ${nodeId}` } + name={`node ${nodeId}`} /> ); })} diff --git a/ui/components/ClusterOverview/components/ChartDiskUsage.tsx b/ui/components/ClusterOverview/components/ChartDiskUsage.tsx index f1e3494ea..5befdd644 100644 --- a/ui/components/ClusterOverview/components/ChartDiskUsage.tsx +++ b/ui/components/ClusterOverview/components/ChartDiskUsage.tsx @@ -16,10 +16,12 @@ import { useFormatter, useTranslations } from "next-intl"; import { getHeight, getPadding } from "./chartConsts"; import { useChartWidth } from "./useChartWidth"; import { formatDateTime } from "@/utils/dateTime"; +import { DurationOptions } from "./FilterByTime"; type ChartDiskUsageProps = { usages: Record; available: Record; + duration: DurationOptions; }; type Datum = { x: number; @@ -27,7 +29,11 @@ type Datum = { name: string; }; -export function ChartDiskUsage({ usages, available }: ChartDiskUsageProps) { +export function ChartDiskUsage({ + usages, + available, + duration, +}: ChartDiskUsageProps) { const t = useTranslations(); const format = useFormatter(); const formatBytes = useFormatBytes(); @@ -35,6 +41,10 @@ export function ChartDiskUsage({ usages, available }: ChartDiskUsageProps) { const itemsPerRow = width > 650 ? 2 : 1; + const showDate = duration >= DurationOptions.Last24hours; + const axisFormat = showDate ? "HH:mm'\n'MMM dd" : "HH:mm"; + const tooltipFormat = showDate ? "MMM dd, HH:mm" : "HH:mm"; + const hasMetrics = Object.keys(usages).length > 0; if (!hasMetrics) { return ( @@ -47,7 +57,11 @@ export function ChartDiskUsage({ usages, available }: ChartDiskUsageProps) { ); } const CursorVoronoiContainer = createContainer("voronoi", "cursor"); - const legendData: { name: string, childName: string, symbol?: { type: string } }[] = []; + const legendData: { + name: string; + childName: string; + symbol?: { type: string }; + }[] = []; Object.entries(usages).forEach(([nodeId, _]) => { legendData.push({ @@ -77,7 +91,9 @@ export function ChartDiskUsage({ usages, available }: ChartDiskUsageProps) { labelComponent={ formatDateTime({ value: args?.x ?? 0 })} + title={(args) => + formatDateTime({ value: args?.x ?? 0, format: tooltipFormat }) + } /> } labels={({ datum }: { datum: Datum }) => @@ -102,7 +118,7 @@ export function ChartDiskUsage({ usages, available }: ChartDiskUsageProps) { > formatDateTime({ value: d, format: "HH:mm" })} + tickFormat={(d) => formatDateTime({ value: d, format: axisFormat })} tickCount={5} /> { return ( { - return ({ - name: `Node ${nodeId}`, - x: Date.parse(k), - y: v, - }) + key={`usage-area-${nodeId}`} + data={Object.entries(series).map(([k, v]) => { + return { + name: `Node ${nodeId}`, + x: Date.parse(k), + y: v, + }; })} - name={ `node ${nodeId}` } + name={`node ${nodeId}`} /> ); })} @@ -134,8 +150,8 @@ export function ChartDiskUsage({ usages, available }: ChartDiskUsageProps) { return ( ({ + key={`chart-softlimit-${nodeId}`} + data={Object.entries(availableSeries).map(([k, v]) => ({ name: `Available storage threshold (node ${nodeId})`, x: Date.parse(k), y: v, diff --git a/ui/components/ClusterOverview/components/ChartIncomingOutgoing.tsx b/ui/components/ClusterOverview/components/ChartIncomingOutgoing.tsx index bdc420eb8..5e8cc9325 100644 --- a/ui/components/ClusterOverview/components/ChartIncomingOutgoing.tsx +++ b/ui/components/ClusterOverview/components/ChartIncomingOutgoing.tsx @@ -20,6 +20,7 @@ type ChartIncomingOutgoingProps = { incoming: TimeSeriesMetrics; outgoing: TimeSeriesMetrics; isVirtualKafkaCluster: boolean; + selectedTopicName?: string; }; type Datum = { @@ -33,6 +34,7 @@ export function ChartIncomingOutgoing({ incoming, outgoing, isVirtualKafkaCluster, + selectedTopicName, }: ChartIncomingOutgoingProps) { const t = useTranslations(); const formatBytes = useFormatBytes(); @@ -59,13 +61,22 @@ export function ChartIncomingOutgoing({ } // const showDate = shouldShowDate(duration); const CursorVoronoiContainer = createContainer("voronoi", "cursor"); + + const incomingLabel = selectedTopicName + ? `Incoming bytes (${selectedTopicName})` + : "Incoming bytes (all topics)"; + + const outgoingLabel = selectedTopicName + ? `Outgoing bytes (${selectedTopicName})` + : "Outgoing bytes (all topics)"; + const legendData = [ { - name: "Incoming bytes (all topics)", + name: incomingLabel, childName: "incoming", }, { - name: "Outgoing bytes (all topics)", + name: outgoingLabel, childName: "outgoing", }, ]; diff --git a/ui/components/ClusterOverview/components/ChartMemoryUsage.tsx b/ui/components/ClusterOverview/components/ChartMemoryUsage.tsx index eb4599c6c..604df3696 100644 --- a/ui/components/ClusterOverview/components/ChartMemoryUsage.tsx +++ b/ui/components/ClusterOverview/components/ChartMemoryUsage.tsx @@ -15,9 +15,11 @@ import { useFormatter, useTranslations } from "next-intl"; import { getHeight, getPadding } from "./chartConsts"; import { useChartWidth } from "./useChartWidth"; import { formatDateTime } from "@/utils/dateTime"; +import { DurationOptions } from "./FilterByTime"; -type ChartDiskUsageProps = { +type ChartMemoryUsageProps = { usages: Record; + duration: DurationOptions; }; type Datum = { @@ -26,12 +28,16 @@ type Datum = { name: string; }; -export function ChartMemoryUsage({ usages }: ChartDiskUsageProps) { +export function ChartMemoryUsage({ usages, duration }: ChartMemoryUsageProps) { const t = useTranslations(); const format = useFormatter(); const formatBytes = useFormatBytes(); const [containerRef, width] = useChartWidth(); + const showDate = duration >= DurationOptions.Last24hours; + const axisFormat = showDate ? "HH:mm'\n'MMM dd" : "HH:mm"; + const tooltipFormat = showDate ? "MMM dd, HH:mm" : "HH:mm"; + const itemsPerRow = width > 650 ? 6 : width > 300 ? 3 : 1; const hasMetrics = Object.keys(usages).length > 0; @@ -66,7 +72,9 @@ export function ChartMemoryUsage({ usages }: ChartDiskUsageProps) { formatDateTime({ value: args?.x ?? 0 })} + title={(args) => + formatDateTime({ value: args?.x ?? 0, format: tooltipFormat }) + } /> } labels={({ datum }: { datum: Datum }) => @@ -91,7 +99,7 @@ export function ChartMemoryUsage({ usages }: ChartDiskUsageProps) { > formatDateTime({ value: d, format: "HH:mm" })} + tickFormat={(d) => formatDateTime({ value: d, format: axisFormat })} tickCount={5} /> { return ( { - return ({ - name: `Node ${nodeId}`, - x: Date.parse(k), - y: v, - }) + key={`memory-usage-${nodeId}`} + data={Object.entries(series).map(([k, v]) => { + return { + name: `Node ${nodeId}`, + x: Date.parse(k), + y: v, + }; })} - name={ `node ${nodeId}` } + name={`node ${nodeId}`} /> ); })} diff --git a/ui/components/ClusterOverview/components/FilterByBroker.stories.tsx b/ui/components/ClusterOverview/components/FilterByBroker.stories.tsx new file mode 100644 index 000000000..e849f9291 --- /dev/null +++ b/ui/components/ClusterOverview/components/FilterByBroker.stories.tsx @@ -0,0 +1,15 @@ +import type { Meta, StoryObj } from "@storybook/nextjs"; +import { FilterByBroker } from "./FilterByBroker"; + +const meta: Meta = { + component: FilterByBroker, + args: { + selectedBroker: "Broker1", + brokerList: ["Broker1", "Broker2", "Broker3"], + }, +} as Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/ui/components/ClusterOverview/components/FilterByBroker.tsx b/ui/components/ClusterOverview/components/FilterByBroker.tsx new file mode 100644 index 000000000..2e7371f98 --- /dev/null +++ b/ui/components/ClusterOverview/components/FilterByBroker.tsx @@ -0,0 +1,76 @@ +import { + MenuToggle, + MenuToggleElement, + Select, + SelectList, + SelectOption, + ToolbarItem, +} from "@/libs/patternfly/react-core"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; + +export function FilterByBroker({ + selectedBroker, + brokerList, + onSetSelectedBroker, + disableToolbar, +}: { + selectedBroker: string | undefined; + brokerList: string[]; + onSetSelectedBroker: (value: string | undefined) => void; + disableToolbar: boolean; +}) { + const t = useTranslations("metrics"); + const [isBrokerSelectOpen, setIsBrokerSelectOpen] = useState(false); + + const onToggleClick = () => setIsBrokerSelectOpen((prev) => !prev); + + const onBrokerSelect = ( + _event: React.MouseEvent | undefined, + value: string | number | undefined, + ) => { + if (value === t("all_brokers")) { + onSetSelectedBroker(undefined); + } else { + onSetSelectedBroker(value as string); + } + setIsBrokerSelectOpen(false); + }; + + // Define the toggle (new API pattern) + const toggle = (toggleRef: React.Ref) => ( + + {selectedBroker || t("all_brokers")} + + ); + + return ( + + + + ); +} diff --git a/ui/components/ClusterOverview/components/FilterByTime.stories.tsx b/ui/components/ClusterOverview/components/FilterByTime.stories.tsx new file mode 100644 index 000000000..de15a308f --- /dev/null +++ b/ui/components/ClusterOverview/components/FilterByTime.stories.tsx @@ -0,0 +1,17 @@ +import type { Meta, StoryObj } from "@storybook/nextjs"; +import { FilterByTime } from "./FilterByTime"; + +const meta: Meta = { + component: FilterByTime, + args: { + keyText: "string", + disableToolbar: false, + ariaLabel: "the aria label", + duration: 60, + }, +} as Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/ui/components/ClusterOverview/components/FilterByTime.tsx b/ui/components/ClusterOverview/components/FilterByTime.tsx new file mode 100644 index 000000000..104e6ea38 --- /dev/null +++ b/ui/components/ClusterOverview/components/FilterByTime.tsx @@ -0,0 +1,96 @@ +import { + MenuToggle, + MenuToggleElement, + Select, + SelectList, + SelectOption, + ToolbarItem, +} from "@/libs/patternfly/react-core"; +import { useState } from "react"; + +export enum DurationOptions { + Last5minutes = 5, + Last15minutes = 15, + Last30minutes = 30, + Last1hour = 60, + Last3hours = 3 * 60, + Last6hours = 6 * 60, + Last12hours = 12 * 60, + Last24hours = 24 * 60, + Last2days = 2 * 24 * 60, + Last7days = 7 * 24 * 60, +} + +export const DurationOptionsMap = { + [DurationOptions.Last5minutes]: "Last 5 minutes", + [DurationOptions.Last15minutes]: "Last 15 minutes", + [DurationOptions.Last30minutes]: "Last 30 minutes", + [DurationOptions.Last1hour]: "Last 1 hour", + [DurationOptions.Last3hours]: "Last 3 hours", + [DurationOptions.Last6hours]: "Last 6 hours", + [DurationOptions.Last12hours]: "Last 12 hours", + [DurationOptions.Last24hours]: "Last 24 hours", + [DurationOptions.Last2days]: "Last 2 days", + [DurationOptions.Last7days]: "Last 7 days", +} as const; + +export function FilterByTime({ + duration, + ariaLabel, + disableToolbar, + onDurationChange, +}: { + duration: DurationOptions; + onDurationChange: (value: DurationOptions) => void; + ariaLabel: string; + disableToolbar: boolean; +}) { + const [isTimeSelectOpen, setIsTimeSelectOpen] = useState(false); + + const onToggleClick = () => setIsTimeSelectOpen((prev) => !prev); + + const onTimeSelect = ( + _event: React.MouseEvent | undefined, + value: string | number | undefined, + ) => { + const mapping = Object.entries(DurationOptionsMap).find( + ([, label]) => label === value, + ); + if (mapping) { + onDurationChange(parseInt(mapping[0], 10) as DurationOptions); + } + setIsTimeSelectOpen(false); + }; + + const toggle = (toggleRef: React.Ref) => ( + + {DurationOptionsMap[duration]} + + ); + + return ( + + + + + ); +} diff --git a/ui/components/ClusterOverview/components/FilterByTopic.stories.tsx b/ui/components/ClusterOverview/components/FilterByTopic.stories.tsx new file mode 100644 index 000000000..a6166e002 --- /dev/null +++ b/ui/components/ClusterOverview/components/FilterByTopic.stories.tsx @@ -0,0 +1,52 @@ +import type { Meta, StoryObj } from "@storybook/nextjs"; +import { FilterByTopic } from "./FilterByTopic"; + +const topics = (names: string[]) => names.map((name) => ({ id: name, name })); + +const meta: Meta = { + component: FilterByTopic, + args: { + selectedTopic: undefined, + topicList: topics(["lorem", "dolor", "ipsum"]), + disableToolbar: false, + }, +} as Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; +Default.args = {}; + +export const Disabled: Story = {}; +Disabled.args = { + disableToolbar: true, + selectedTopic: "lorem", +}; + +export const NoTopics: Story = {}; +NoTopics.args = { + topicList: [], +}; + +export const MultipleTopicsWithCommonWords: Story = {}; +MultipleTopicsWithCommonWords.args = { + topicList: topics([ + "lorem dolor", + "lorem ipsum", + "lorem foo", + "dolor", + "ipsum", + ]), +}; + +export const DoesNotBreakWithLongWords: Story = {}; +DoesNotBreakWithLongWords.args = { + topicList: topics([ + "lorem dolor lorem dolor lorem dolor lorem dolor lorem dolor lorem dolor", + "lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum", + "lorem foo", + "dolor", + "ipsum", + ]), +}; diff --git a/ui/components/ClusterOverview/components/FilterByTopic.tsx b/ui/components/ClusterOverview/components/FilterByTopic.tsx new file mode 100644 index 000000000..7ba5a0eff --- /dev/null +++ b/ui/components/ClusterOverview/components/FilterByTopic.tsx @@ -0,0 +1,139 @@ +"use client"; +import { + Menu, + MenuList, + MenuContent, + MenuSearch, + MenuSearchInput, + SearchInput, + SelectOption, + MenuToggle, + MenuContainer, + ToolbarItem, + SelectGroup, +} from "@/libs/patternfly/react-core"; +import { useTranslations } from "next-intl"; +import { useRef, useState, useMemo } from "react"; + +type TopicOption = { id: string; name: string }; + +export function FilterByTopic({ + selectedTopic, + topicList = [], + disableToolbar, + onSetSelectedTopic, +}: { + selectedTopic: string | undefined; + topicList: TopicOption[]; + disableToolbar: boolean; + onSetSelectedTopic: (value: string | undefined) => void; +}) { + const t = useTranslations("metrics"); + const allTopicsLabel = t("all_topics"); + + const [isOpen, setIsOpen] = useState(false); + const [filter, setFilter] = useState(""); + + const toggleRef = useRef(); + const menuRef = useRef(); + + const filteredTopics = useMemo(() => { + return topicList.filter((topic) => + topic.name.toLowerCase().includes(filter.toLowerCase()), + ); + }, [topicList, filter]); + + const selectedTopicName = useMemo(() => { + return topicList.find((t) => t.id === selectedTopic)?.name; + }, [topicList, selectedTopic]); + + const onSelect = (_event: any, itemId: string | number | undefined) => { + if (itemId === "all-topics") { + onSetSelectedTopic(undefined); + } else { + onSetSelectedTopic(itemId as string); + } + setIsOpen(false); + }; + + const menuItems = [ + + {allTopicsLabel} + , + + {filteredTopics.map((topic) => ( + + {topic.name} + + ))} + , + ]; + + if (filter && filteredTopics.length === 0) { + menuItems.push( + + {t("common:no_results_found")} + , + ); + } + + const toggle = ( + setIsOpen(!isOpen)} + isExpanded={isOpen} + isDisabled={disableToolbar || topicList.length === 0} + className="appserv-metrics-filterbytopic" + > + {selectedTopicName || allTopicsLabel} + + ); + + const menu = ( + + + + setFilter(val)} + onClear={(evt) => { + evt.stopPropagation(); + setFilter(""); + }} + /> + + + + + {menuItems} + + + ); + + return ( + + + + ); +} diff --git a/ui/components/ClusterOverview/components/timeSeriesMetrics.ts b/ui/components/ClusterOverview/components/timeSeriesMetrics.ts new file mode 100644 index 000000000..daad77b4d --- /dev/null +++ b/ui/components/ClusterOverview/components/timeSeriesMetrics.ts @@ -0,0 +1,17 @@ +export function timeSeriesMetrics( + ranges: Record | undefined, + rangeName: string, +): Record { + const series: Record = {}; + + if (ranges) { + Object.values(ranges[rangeName] ?? {}).forEach((r) => { + series[r.nodeId!] = r.range.reduce( + (a, v) => ({ ...a, [v[0]]: parseFloat(v[1]) }), + {} as TimeSeriesMetrics, + ); + }); + } + + return series; +} diff --git a/ui/components/ClusterOverview/components/useNodeMetric.ts b/ui/components/ClusterOverview/components/useNodeMetric.ts new file mode 100644 index 000000000..28130bd2f --- /dev/null +++ b/ui/components/ClusterOverview/components/useNodeMetric.ts @@ -0,0 +1,68 @@ +import { useEffect, useState } from "react"; +import { getNodeMetrics } from "@/api/nodes/actions"; +import { timeSeriesMetrics } from "@/components/ClusterOverview/components/timeSeriesMetrics"; +import { getKafkaCluster } from "@/api/kafka/actions"; + +type MetricKey = + | "volume_stats_used_bytes" + | "volume_stats_capacity_bytes" + | "cpu_usage_seconds" + | "memory_usage_bytes"; + +export function useNodeMetrics( + kafkaId: string | undefined, + selectedBroker: string | undefined, + duration: number, +) { + const [data, setData] = useState + > | null>(null); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + async function fetchMetrics() { + if (!kafkaId) return; + setIsLoading(true); + + try { + let ranges: any = null; + + if (selectedBroker) { + const nodeId = selectedBroker.replace("Node ", ""); + const res = await getNodeMetrics(kafkaId, nodeId, duration); + ranges = res.payload?.data?.attributes?.metrics?.ranges; + } else { + const res = await getKafkaCluster(kafkaId, { duration }); + ranges = res.payload?.attributes?.metrics?.ranges; + } + + if (ranges) { + setData({ + volume_stats_used_bytes: timeSeriesMetrics( + ranges, + "volume_stats_used_bytes", + ), + volume_stats_capacity_bytes: timeSeriesMetrics( + ranges, + "volume_stats_capacity_bytes", + ), + cpu_usage_seconds: timeSeriesMetrics(ranges, "cpu_usage_seconds"), + memory_usage_bytes: timeSeriesMetrics(ranges, "memory_usage_bytes"), + }); + } else { + setData(null); + } + } catch (e) { + console.error("Failed to fetch metrics", e); + setData(null); + } finally { + setIsLoading(false); + } + } + + fetchMetrics(); + }, [kafkaId, selectedBroker, duration]); + + return { data, isLoading }; +} diff --git a/ui/messages/en.json b/ui/messages/en.json index 780e45e38..ee60d41c1 100644 --- a/ui/messages/en.json +++ b/ui/messages/en.json @@ -416,6 +416,10 @@ "no_data": "No clusters available.", "not_available": "n/a" }, + "metrics": { + "all_brokers": "All Nodes", + "all_topics": "All topics" + }, "ColumnsModal": { "title": "Manage columns", "description": "Chosen fields will be displayed in the table.",