From c0edff5b7f06e2e910b6529f6b5e2800c588391f Mon Sep 17 00:00:00 2001 From: Lokananda Prabhu Date: Wed, 10 Jul 2024 16:39:27 +0530 Subject: [PATCH 1/3] Adding overview page for k8s data --- console-extensions.json | 55 +- .../en/plugin__pipelines-console-plugin.json | 3 +- src/components/hooks/flagHookProvider.ts | 8 +- .../PipelinesAverageDurationK8s.tsx | 318 ++++++++++ .../PipelinesMetricsPageK8s.tsx | 120 ++++ src/components/pipelines-metrics/const.ts | 7 + src/components/pipelines-metrics/helpers.ts | 118 ++++ src/components/pipelines-metrics/hooks.ts | 155 +++++ src/components/pipelines-metrics/index.ts | 1 + src/components/pipelines-metrics/poll-hook.ts | 25 + .../pipelines-metrics/safe-fetch-hook.ts | 15 + .../pipelines-metrics/url-poll-hook.ts | 41 ++ src/components/pipelines-metrics/utils.ts | 78 +++ .../PipelineRunsDurationCard.tsx | 2 +- .../PipelineRunsDurationCardK8s.tsx | 184 ++++++ .../PipelineRunsNumbersChartK8s.tsx | 272 ++++++++ .../PipelineRunsStatusCardK8s.tsx | 597 ++++++++++++++++++ .../PipelineRunsTotalCardK8s.tsx | 135 ++++ .../PipelinesOverviewPageK8s.tsx | 126 ++++ .../pipelines-overview/TimeRangeDropdown.tsx | 10 +- .../__tests__/PipelinesOverview.spec.tsx | 4 + src/components/pipelines-overview/dateTime.ts | 17 +- src/components/pipelines-overview/index.ts | 1 + .../PipelineRunsForPipelinesList.tsx | 21 +- .../PipelineRunsForPipelinesRow.tsx | 19 +- .../list-pages/PipelineRunsListPageK8s.tsx | 263 ++++++++ src/components/pipelines-overview/utils.ts | 163 +++++ .../utils/__tests__/pipeline-utils.spec.ts | 4 +- 28 files changed, 2736 insertions(+), 26 deletions(-) create mode 100644 src/components/pipelines-metrics/PipelinesAverageDurationK8s.tsx create mode 100644 src/components/pipelines-metrics/PipelinesMetricsPageK8s.tsx create mode 100644 src/components/pipelines-metrics/const.ts create mode 100644 src/components/pipelines-metrics/helpers.ts create mode 100644 src/components/pipelines-metrics/hooks.ts create mode 100644 src/components/pipelines-metrics/poll-hook.ts create mode 100644 src/components/pipelines-metrics/safe-fetch-hook.ts create mode 100644 src/components/pipelines-metrics/url-poll-hook.ts create mode 100644 src/components/pipelines-metrics/utils.ts create mode 100644 src/components/pipelines-overview/PipelineRunsDurationCardK8s.tsx create mode 100644 src/components/pipelines-overview/PipelineRunsNumbersChartK8s.tsx create mode 100644 src/components/pipelines-overview/PipelineRunsStatusCardK8s.tsx create mode 100644 src/components/pipelines-overview/PipelineRunsTotalCardK8s.tsx create mode 100644 src/components/pipelines-overview/PipelinesOverviewPageK8s.tsx create mode 100644 src/components/pipelines-overview/list-pages/PipelineRunsListPageK8s.tsx diff --git a/console-extensions.json b/console-extensions.json index 7a6b06d6..90d190a3 100644 --- a/console-extensions.json +++ b/console-extensions.json @@ -156,6 +156,23 @@ "required": ["PIPELINE_TEKTON_RESULT_INSTALLED"] } }, + { + "type": "console.page/route", + "properties": { + "exact": true, + "path": [ + "/pipelines-overview/ns/:ns", + "/pipelines-overview/all-namespaces" + ], + "component": { + "$codeRef": "pipelinesComponent.PipelinesOverviewPageK8s" + } + }, + "flags": { + "required": ["OPENSHIFT_PIPELINE"], + "disallowed": ["PIPELINE_TEKTON_RESULT_INSTALLED"] + } + }, { "type": "console.navigation/href", "properties": { @@ -168,7 +185,7 @@ "namespaced": true }, "flags": { - "required": ["OPENSHIFT_PIPELINE", "PIPELINE_TEKTON_RESULT_INSTALLED"] + "required": ["OPENSHIFT_PIPELINE"] } }, { @@ -189,6 +206,42 @@ "required": ["PIPELINE_TEKTON_RESULT_INSTALLED"] } }, + { + "type": "console.tab/horizontalNav", + "properties": { + "model": { + "group": "tekton.dev", + "version": "v1beta1", + "kind": "Pipeline" + }, + "page": { + "name": "%plugin__pipelines-console-plugin~Metrics%", + "href": "metrics" + }, + "component": { "$codeRef": "metricsComponent.PipelinesMetricsPageK8s" } + }, + "flags": { + "disallowed": ["PIPELINE_TEKTON_RESULT_INSTALLED"] + } + }, + { + "type": "console.tab/horizontalNav", + "properties": { + "model": { + "group": "tekton.dev", + "version": "v1", + "kind": "Pipeline" + }, + "page": { + "name": "%plugin__pipelines-console-plugin~Metrics%", + "href": "metrics" + }, + "component": { "$codeRef": "metricsComponent.PipelinesMetricsPageK8s" } + }, + "flags": { + "disallowed": ["PIPELINE_TEKTON_RESULT_INSTALLED"] + } + }, { "type": "console.tab/horizontalNav", "properties": { diff --git a/locales/en/plugin__pipelines-console-plugin.json b/locales/en/plugin__pipelines-console-plugin.json index e2676953..9da3944f 100644 --- a/locales/en/plugin__pipelines-console-plugin.json +++ b/locales/en/plugin__pipelines-console-plugin.json @@ -195,8 +195,8 @@ "Log snippet": "Log snippet", "Logs": "Logs", "Low": "Low", + "Maximum": "Maximum", "Maximum file size exceeded. File limit is 4MB.": "Maximum file size exceeded. File limit is 4MB.", - "Maximun": "Maximun", "Medium": "Medium", "Message": "Message", "Metrics": "Metrics", @@ -272,6 +272,7 @@ "PipelineRun not started yet": "PipelineRun not started yet", "PipelineRun status": "PipelineRun status", "PipelineRun Status shows the % of PipelineRuns for various status like \"Succeeded\", \"Failed\", \"Running\", \"Cancelled\" and \"Others\". Here, Others includes statuses like \"Started\", \"CreateRunFailed\", \"PipelineRunTimeout\"": "PipelineRun Status shows the % of PipelineRuns for various status like \"Succeeded\", \"Failed\", \"Running\", \"Cancelled\" and \"Others\". Here, Others includes statuses like \"Started\", \"CreateRunFailed\", \"PipelineRunTimeout\"", + "PipelineRun status shows the % of PipelineRuns for various statuses like \"Succeeded\", \"Failed\" and \"Cancelled\".": "PipelineRun status shows the % of PipelineRuns for various statuses like \"Succeeded\", \"Failed\" and \"Cancelled\".", "PipelineRuns": "PipelineRuns", "Pipelines": "Pipelines", "Please <2>try again.": "Please <2>try again.", diff --git a/src/components/hooks/flagHookProvider.ts b/src/components/hooks/flagHookProvider.ts index 828c37ac..2929b264 100644 --- a/src/components/hooks/flagHookProvider.ts +++ b/src/components/hooks/flagHookProvider.ts @@ -85,6 +85,10 @@ export const useFlagHookProvider = (setFeatureFlag: SetFeatureFlag) => { FLAG_HIDE_STATIC_PIPELINE_PLUGIN_PIPELINERUN_DETAIL_APPROVALS_TAB, true, ); + setFeatureFlag( + FLAG_HIDE_STATIC_PIPELINE_PLUGIN_PIPELINE_DETAIL_METRICS_TAB, + true, + ); }; export const useTektonResultInstallProvider = ( @@ -106,8 +110,4 @@ export const useTektonResultInstallProvider = ( fetch(); }, []); setFeatureFlag(FLAG_PIPELINE_TEKTON_RESULT_INSTALLED, data ? true : false); - setFeatureFlag( - FLAG_HIDE_STATIC_PIPELINE_PLUGIN_PIPELINE_DETAIL_METRICS_TAB, - data ? true : false, - ); }; diff --git a/src/components/pipelines-metrics/PipelinesAverageDurationK8s.tsx b/src/components/pipelines-metrics/PipelinesAverageDurationK8s.tsx new file mode 100644 index 00000000..eb747853 --- /dev/null +++ b/src/components/pipelines-metrics/PipelinesAverageDurationK8s.tsx @@ -0,0 +1,318 @@ +import * as React from 'react'; +import _ from 'lodash'; +import * as classNames from 'classnames'; +import { useTranslation } from 'react-i18next'; +import { DomainPropType, DomainTuple } from 'victory-core'; +import { + Chart, + ChartAxis, + ChartAxisProps, + ChartBar, + ChartGroup, + ChartThemeColor, + ChartVoronoiContainer, +} from '@patternfly/react-charts'; +import { Card, CardBody, CardTitle } from '@patternfly/react-core'; +import { + formatDate, + getXaxisValues, + hourformat, + monthYear, + parsePrometheusDuration, +} from '../pipelines-overview/dateTime'; +import { ALL_NAMESPACES_KEY } from '../../consts'; +import { + usePipelineMetricsForAllNamespacePoll, + usePipelineMetricsForNamespaceForPipelinePoll, + usePipelineMetricsForNamespacePoll, +} from './hooks'; +import { + MetricsQueryPrefix, + PipelineQuery, + secondsToMinutesK8s, +} from './utils'; +import { roundToNearestSecond } from '../pipelines-overview/utils'; + +interface PipelinesAverageDurationProps { + timespan?: number; + domain?: DomainPropType; + bordered?: boolean; + interval?: number; + parentName?: string; + namespace?: string; +} +type DomainType = { x?: DomainTuple; y?: DomainTuple }; + +const getChartData = ( + tickValues: number[] | Date[], + data: any, + type: string, +) => { + const chartData = tickValues?.map((value, index) => { + if (index === 0) { + // Ensure the first value is always 0 + return { + x: value, + y: 0, + }; + } + const s = data?.find((d) => { + const group_date = new Date(Number(d.group_value) * 1000); + if (type == 'hour') { + return group_date.getHours() === value; + } + if (type == 'day' || type == 'week') { + return group_date.toDateString() === new Date(value).toDateString(); + } + if (type == 'month') { + return group_date.getMonth() === value.getMonth(); + } + }); + return { + x: value, + y: s?.avg_duration || 0, + }; + }); + return chartData; +}; + +const PipelinesAverageDurationK8s: React.FC = ({ + timespan, + domain, + bordered, + interval, + parentName, + namespace, +}) => { + const { t } = useTranslation('plugin__pipelines-console-plugin'); + const startTimespan = timespan - parsePrometheusDuration('1d'); + const endDate = new Date(Date.now()).setHours(0, 0, 0, 0); + const startDate = new Date(Date.now() - startTimespan).setHours(0, 0, 0, 0); + const { x: domainX, y: domainY } = (domain as DomainType) || {}; + const domainValue: DomainPropType = { + x: domainX || [startDate, endDate], + y: domainY || undefined, + }; + const [tickValues, type] = getXaxisValues(timespan); + + const [totalPipelineRunsCountData] = + parentName && namespace + ? usePipelineMetricsForNamespaceForPipelinePoll({ + namespace, + timespan, + delay: interval, + queryPrefix: MetricsQueryPrefix.TEKTON_PIPELINES_CONTROLLER, + name: parentName, + metricsQuery: + PipelineQuery.PIPELINERUN_COUNT_FOR_NAMESPACE_FOR_PIPELINE, + }) + : namespace == ALL_NAMESPACES_KEY + ? usePipelineMetricsForAllNamespacePoll({ + timespan, + delay: interval, + queryPrefix: MetricsQueryPrefix.TEKTON_PIPELINES_CONTROLLER, + metricsQuery: PipelineQuery.PIPELINERUN_COUNT_FOR_ALL_NAMESPACE, + }) + : usePipelineMetricsForNamespacePoll({ + namespace, + timespan, + delay: interval, + queryPrefix: MetricsQueryPrefix.TEKTON_PIPELINES_CONTROLLER, + metricsQuery: PipelineQuery.PIPELINERUN_COUNT_FOR_NAMESPACE, + }); + + const [totalPipelineRunsDurationData] = + parentName && namespace + ? usePipelineMetricsForNamespaceForPipelinePoll({ + namespace, + timespan, + delay: interval, + queryPrefix: MetricsQueryPrefix.TEKTON_PIPELINES_CONTROLLER, + name: parentName, + metricsQuery: + PipelineQuery.PIPELINERUN_DURATION_FOR_NAMESPACE_FOR_PIPELINE, + }) + : namespace == ALL_NAMESPACES_KEY + ? usePipelineMetricsForAllNamespacePoll({ + timespan, + delay: interval, + queryPrefix: MetricsQueryPrefix.TEKTON_PIPELINES_CONTROLLER, + metricsQuery: PipelineQuery.PIPELINERUN_DURATION_FOR_ALL_NAMESPACE, + }) + : usePipelineMetricsForNamespacePoll({ + namespace, + timespan, + delay: interval, + queryPrefix: MetricsQueryPrefix.TEKTON_PIPELINES_CONTROLLER, + metricsQuery: PipelineQuery.PIPELINERUN_DURATION_FOR_NAMESPACE, + }); + + const combinedData = totalPipelineRunsCountData?.data?.result[0]?.values?.map( + (value1) => { + const group_value = value1[0]; + const runs = parseInt(value1[1]); + const value2 = + totalPipelineRunsDurationData?.data?.result[0]?.values?.find( + (val) => + roundToNearestSecond(val[0]) === roundToNearestSecond(group_value), + ); + const duration = value2 ? parseFloat(value2[1]) : 0; + + return { group_value, runs, duration }; + }, + ); + + const result = combinedData?.map(({ group_value, runs, duration }, index) => { + if (index === 0) { + return { + group_value, + avg_duration: secondsToMinutesK8s(duration / runs), + }; + } else { + const prevDuration = combinedData[index - 1].duration; + const prevRuns = combinedData[index - 1].runs; + if (duration === prevDuration && runs === prevRuns) { + // If both runs and duration are the same as the previous, set avg_duration to 0 + return { group_value, avg_duration: 0 }; + } else if (duration < prevDuration || runs < prevRuns) { + // Reset condition + return { + group_value, + avg_duration: secondsToMinutesK8s(duration / runs), + }; + } else { + // Increment condition + const incrementalDuration = duration - prevDuration; + const incrementalRuns = runs - prevRuns; + const avg_duration = incrementalDuration / incrementalRuns; + return { group_value, avg_duration: secondsToMinutesK8s(avg_duration) }; + } + } + }); + + let xTickFormat; + let dayLabel; + let showLabel = false; + let chartData = []; + switch (type) { + case 'hour': + xTickFormat = (d) => hourformat(d); + showLabel = true; + domainValue.x = [0, 23]; + dayLabel = formatDate(new Date()); + chartData = getChartData(tickValues, result, 'hour'); + break; + case 'day': + xTickFormat = (d) => formatDate(d); + domainValue.x = [startDate, endDate]; + chartData = getChartData(tickValues, result, 'day'); + break; + case 'week': + xTickFormat = (d) => formatDate(d); + domainValue.x = [new Date(tickValues[0]), new Date(tickValues[11])]; + chartData = getChartData(tickValues, result, 'week'); + break; + case 'month': + xTickFormat = (d) => monthYear(d); + domainValue.x = [new Date(tickValues[0]), new Date(tickValues[11])]; + chartData = getChartData(tickValues, result, 'month'); + break; + default: + console.log('Received wrong data'); + break; + } + const max = Math.max(...chartData.map((yVal) => yVal.y)); + const roundUp = (value, nearest) => { + return Math.ceil(value / nearest) * nearest; + }; + const nearest = max > 10 ? 10 : 5; + const roundedMax = roundUp(max, nearest); + domainValue.y = + !isNaN(roundedMax) && roundedMax > 5 ? [0, roundedMax] : [0, 5]; + + if (!domainY) { + let minY: number = _.minBy(chartData, 'y')?.y ?? 0; + let maxY: number = _.maxBy(chartData, 'y')?.y ?? 0; + if (minY === 0 && maxY === 0) { + minY = -1; + maxY = 1; + } else if (minY > 0 && maxY > 0) { + minY = 0; + } else if (minY < 0 && maxY < 0) { + maxY = 0; + } + domainValue.y = [minY, maxY]; + } + + let xAxisStyle: ChartAxisProps['style'] = { + tickLabels: { fill: 'var(--pf-v5-global--Color--100)', fontSize: 12 }, + }; + const yAxisStyle: ChartAxisProps['style'] = { + tickLabels: { fill: 'var(--pf-v5-global--Color--100)', fontSize: 12 }, + }; + if (tickValues.length > 7) { + xAxisStyle = { + tickLabels: { + fill: 'var(--pf-v5-global--Color--100)', + angle: 320, + fontSize: 10, + textAnchor: 'end', + verticalAnchor: 'end', + }, + }; + } + + return ( + <> + + + {t('Average duration')} + + +
+ `${datum.y}m`} + constrainToVisibleArea + /> + } + scale={{ x: 'time', y: 'linear' }} + domain={domainValue} + domainPadding={{ x: [30, 25] }} + height={145} + width={400} + padding={{ + top: 10, + bottom: 55, + left: 50, + }} + themeColor={ChartThemeColor.blue} + > + + `${v}m`} + /> + + + + +
+
+
+ + ); +}; + +export default PipelinesAverageDurationK8s; diff --git a/src/components/pipelines-metrics/PipelinesMetricsPageK8s.tsx b/src/components/pipelines-metrics/PipelinesMetricsPageK8s.tsx new file mode 100644 index 00000000..dbbd9cb0 --- /dev/null +++ b/src/components/pipelines-metrics/PipelinesMetricsPageK8s.tsx @@ -0,0 +1,120 @@ +import * as React from 'react'; +import { Flex, FlexItem } from '@patternfly/react-core'; +import { + formatPrometheusDuration, + parsePrometheusDuration, +} from '../pipelines-overview/dateTime'; +import TimeRangeDropdown from '../pipelines-overview/TimeRangeDropdown'; +import RefreshDropdown from '../pipelines-overview/RefreshDropdown'; +import { + IntervalOptions, + TimeRangeOptionsK8s, + useQueryParams, +} from '../pipelines-overview/utils'; +import { PipelineKind } from '../../types'; +import './PipelinesMetrics.scss'; +import PipelineRunsStatusCardK8s from '../pipelines-overview/PipelineRunsStatusCardK8s'; +import PipelineRunsNumbersChartK8s from '../pipelines-overview/PipelineRunsNumbersChartK8s'; +import PipelineRunsDurationCardK8s from '../pipelines-overview/PipelineRunsDurationCardK8s'; +import PipelinesAverageDurationK8s from './PipelinesAverageDurationK8s'; + +type PipelinesMetricsPageProps = { + obj: PipelineKind; +}; + +const PipelinesMetricsPageK8s: React.FC = ({ + obj, +}) => { + const { + metadata: { namespace, name: parentName }, + } = obj; + const [timespan, setTimespan] = React.useState(parsePrometheusDuration('1d')); + const [interval, setInterval] = React.useState( + parsePrometheusDuration('30s'), + ); + + useQueryParams({ + key: 'refreshinterval', + value: interval, + setValue: setInterval, + defaultValue: parsePrometheusDuration('30s'), + options: { ...IntervalOptions(), off: 'OFF_KEY' }, + displayFormat: (v) => (v ? formatPrometheusDuration(v) : 'off'), + loadFormat: (v) => (v == 'off' ? null : parsePrometheusDuration(v)), + }); + + useQueryParams({ + key: 'timerange', + value: timespan, + setValue: setTimespan, + defaultValue: parsePrometheusDuration('1w'), + options: TimeRangeOptionsK8s(), + displayFormat: formatPrometheusDuration, + loadFormat: parsePrometheusDuration, + }); + + return ( + <> + + + + + + + + + +
+ + + + + + + + + + + + + +
+ + ); +}; + +export default PipelinesMetricsPageK8s; diff --git a/src/components/pipelines-metrics/const.ts b/src/components/pipelines-metrics/const.ts new file mode 100644 index 00000000..b77142e6 --- /dev/null +++ b/src/components/pipelines-metrics/const.ts @@ -0,0 +1,7 @@ +export const ONE_SECOND = 1000; +export const ONE_MINUTE = 60 * ONE_SECOND; +export const ONE_HOUR = 60 * ONE_MINUTE; +export const DEFAULT_SAMPLES = 60; +export const PROMETHEUS_TENANCY_BASE_PATH = '/api/prometheus-tenancy'; +export const DEFAULT_PROMETHEUS_SAMPLES = 60; +export const DEFAULT_PROMETHEUS_TIMESPAN = ONE_HOUR; diff --git a/src/components/pipelines-metrics/helpers.ts b/src/components/pipelines-metrics/helpers.ts new file mode 100644 index 00000000..9cd021e0 --- /dev/null +++ b/src/components/pipelines-metrics/helpers.ts @@ -0,0 +1,118 @@ +import { PrometheusEndpoint } from '@openshift-console/dynamic-plugin-sdk'; +import * as _ from 'lodash-es'; +import { + DEFAULT_PROMETHEUS_SAMPLES, + DEFAULT_PROMETHEUS_TIMESPAN, + PROMETHEUS_TENANCY_BASE_PATH, +} from './const'; + +export { PrometheusEndpoint }; + +const getRangeVectorSearchParams = ( + endTime: number = Date.now(), + samples: number = DEFAULT_PROMETHEUS_SAMPLES, + timespan: number = DEFAULT_PROMETHEUS_TIMESPAN, +): URLSearchParams => { + const params = new URLSearchParams(); + const now = new Date(); + if (timespan === 86400000) { + // Set start to 12 AM today + const startOfDay = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate(), + 0, + 0, + 0, + ); + params.append('start', `${startOfDay.getTime() / 1000}`); + + // Set end to 11 PM today + const endOfDay = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate(), + 23, + 0, + 0, + ); + params.append('end', `${endOfDay.getTime() / 1000}`); + + // Set step to 3600 seconds (1 hour) + params.append('step', '3600'); + } else if (timespan === 1209600000) { + // 14 days in milliseconds + // Set start to 14 days ago + const startOfFourteenDaysAgo = new Date( + now.getTime() - 14 * 24 * 60 * 60 * 1000, + ); + params.append('start', `${startOfFourteenDaysAgo.getTime() / 1000}`); + + // Set end to now + params.append('end', `${endTime / 1000}`); + params.append('step', '86400'); + } else if (timespan === 2592000000) { + // 30 days in milliseconds + // Set start to 30 days ago + const startOfThirtyDaysAgo = new Date( + now.getTime() - 30 * 24 * 60 * 60 * 1000, + ); + params.append('start', `${startOfThirtyDaysAgo.getTime() / 1000}`); + + // Set end to now + params.append('end', `${endTime / 1000}`); + params.append('step', '86400'); + } else if (timespan === 7257600000) { + // 3 months in milliseconds + // Set start to 3 months ago + const startOfThreeMonthsAgo = new Date(now.setMonth(now.getMonth() - 3)); + params.append('start', `${startOfThreeMonthsAgo.getTime() / 1000}`); + + // Set end to now + params.append('end', `${endTime / 1000}`); + params.append('step', '604800'); + } else { + params.append('start', `${(endTime - timespan) / 1000}`); + params.append('end', `${endTime / 1000}`); + params.append('step', `${timespan / samples / 1000}`); + } + return params; +}; + +const getSearchParams = ({ + endpoint, + endTime, + timespan, + samples, + ...params +}: PrometheusURLProps): URLSearchParams => { + const searchParams = + endpoint === PrometheusEndpoint.QUERY_RANGE + ? getRangeVectorSearchParams(endTime, samples, timespan) + : new URLSearchParams(); + _.each( + params, + (value, key) => value && searchParams.append(key, value.toString()), + ); + return searchParams; +}; + +export const getPrometheusURL = (props: PrometheusURLProps): string => { + if (props.endpoint !== PrometheusEndpoint.RULES && !props.query) { + return ''; + } + const params = getSearchParams(props); + return `${PROMETHEUS_TENANCY_BASE_PATH}/${ + props.endpoint + }?${params.toString()}`; +}; + +type PrometheusURLProps = { + endpoint: PrometheusEndpoint; + endTime?: number; + namespace?: string; + query?: string; + samples?: number; + timeout?: string; + timespan?: number; +}; diff --git a/src/components/pipelines-metrics/hooks.ts b/src/components/pipelines-metrics/hooks.ts new file mode 100644 index 00000000..2b454cf6 --- /dev/null +++ b/src/components/pipelines-metrics/hooks.ts @@ -0,0 +1,155 @@ +import { + PrometheusEndpoint, + PrometheusResponse, +} from '@openshift-console/dynamic-plugin-sdk'; +import _ from 'lodash'; +import { getPrometheusURL } from './helpers'; +import { useURLPoll } from './url-poll-hook'; +import { metricsQueries } from './utils'; + +export const calculateTotalValues = ( + prometheusResponse: PrometheusResponse, + timerangeSeconds: number, +): number => { + const currentTime = Date.now() / 1000; + + const totalValues = prometheusResponse?.data?.result?.reduce( + (total, current) => { + const valuesInRange = current.values.filter( + (value) => value[0] >= currentTime - timerangeSeconds, + ); + const sumValues = _.sumBy(valuesInRange, (value) => + parseInt(value[1], 10), + ); + return total + sumValues; + }, + 0, + ); + + return totalValues; +}; + +export const usePipelineMetricsForAllNamespacePoll = ({ + delay, + timespan, + queryPrefix, + metricsQuery, +}) => { + const queries = metricsQueries(queryPrefix); + return useURLPoll( + getPrometheusURL({ + endpoint: PrometheusEndpoint.QUERY_RANGE, + query: queries[metricsQuery](), + samples: 1, + endTime: Date.now(), + timespan, + }), + delay, + timespan, + ); +}; + +export const usePipelineMetricsForNamespaceForPipelinePoll = ({ + delay, + namespace, + timespan, + queryPrefix, + name, + metricsQuery, +}) => { + const queries = metricsQueries(queryPrefix); + return useURLPoll( + getPrometheusURL({ + endpoint: PrometheusEndpoint.QUERY_RANGE, + query: queries[metricsQuery]({ + name, + namespace, + }), + samples: 1, + endTime: Date.now(), + timespan, + namespace, + }), + delay, + namespace, + timespan, + ); +}; + +export const usePipelineMetricsForNamespacePoll = ({ + delay, + namespace, + timespan, + queryPrefix, + metricsQuery, +}) => { + const queries = metricsQueries(queryPrefix); + return useURLPoll( + getPrometheusURL({ + endpoint: PrometheusEndpoint.QUERY_RANGE, + query: queries[metricsQuery]({ + namespace, + }), + samples: 1, + endTime: Date.now(), + timespan, + namespace, + }), + delay, + namespace, + timespan, + ); +}; + +export const getStatusCounts = (prometheusResponse: PrometheusResponse) => { + const result = prometheusResponse?.data?.result; + const counts = { + success: 0, + failed: 0, + cancelled: 0, + }; + if (!result) { + return counts; + } + result.forEach((item) => { + const status = item.metric.status; + const value = parseInt(item.values[0][1], 10); // parse the count value from the string + + if (status === 'success') { + counts.success += value; + } else if (status === 'failed') { + counts.failed += value; + } else if (status === 'cancelled') { + counts.cancelled += value; + } + }); + + return counts; +}; + +export const calculateTotalDuration = ( + prometheusResponse: PrometheusResponse, +) => { + const results = prometheusResponse?.data?.result; + + if (!results) { + return 0; + } + + const totalDuration = results.reduce((total, result) => { + const values = result.values; + return ( + total + + values.reduce((resultTotal, value, index, array) => { + if (index < array.length - 1) { + const currentTimestamp = value[0]; + const nextTimestamp = array[index + 1][0]; + return resultTotal + (nextTimestamp - currentTimestamp); + } + return resultTotal; + }, 0) + ); + }, 0); + + return totalDuration; +}; diff --git a/src/components/pipelines-metrics/index.ts b/src/components/pipelines-metrics/index.ts index 6a191aae..db8b1fb1 100644 --- a/src/components/pipelines-metrics/index.ts +++ b/src/components/pipelines-metrics/index.ts @@ -1 +1,2 @@ export { default as PipelinesMetricsPage } from './PipelinesMetricsPage'; +export { default as PipelinesMetricsPageK8s } from './PipelinesMetricsPageK8s'; diff --git a/src/components/pipelines-metrics/poll-hook.ts b/src/components/pipelines-metrics/poll-hook.ts new file mode 100644 index 00000000..4771c9ec --- /dev/null +++ b/src/components/pipelines-metrics/poll-hook.ts @@ -0,0 +1,25 @@ +import { useEffect, useRef } from 'react'; + +// Slightly modified from Dan Abramov's blog post about using React hooks for polling +// https://overreacted.io/making-setinterval-declarative-with-react-hooks/ +export const usePoll = (callback, delay, ...dependencies) => { + const savedCallback = useRef(null); + + // Remember the latest callback. + useEffect(() => { + savedCallback.current = callback; + }, [callback]); + + // Set up the interval. + useEffect(() => { + const tick = () => savedCallback.current(); + + tick(); // Run first tick immediately. + + if (delay) { + // Only start interval if a delay is provided. + const id = setInterval(tick, delay); + return () => clearInterval(id); + } + }, [delay, ...dependencies]); +}; diff --git a/src/components/pipelines-metrics/safe-fetch-hook.ts b/src/components/pipelines-metrics/safe-fetch-hook.ts new file mode 100644 index 00000000..95ac809b --- /dev/null +++ b/src/components/pipelines-metrics/safe-fetch-hook.ts @@ -0,0 +1,15 @@ +import { consoleFetchJSON } from '@openshift-console/dynamic-plugin-sdk'; +import { useEffect, useRef } from 'react'; + +export const useSafeFetch = () => { + const controller = useRef(); + useEffect(() => { + controller.current = new AbortController(); + return () => controller.current.abort(); + }, []); + + return (url) => + consoleFetchJSON(url, 'get', { + signal: controller.current.signal as AbortSignal, + }); +}; diff --git a/src/components/pipelines-metrics/url-poll-hook.ts b/src/components/pipelines-metrics/url-poll-hook.ts new file mode 100644 index 00000000..2ce841b7 --- /dev/null +++ b/src/components/pipelines-metrics/url-poll-hook.ts @@ -0,0 +1,41 @@ +import { UseURLPoll } from '@openshift-console/dynamic-plugin-sdk/lib/api/internal-types'; +import { useCallback, useState } from 'react'; +import { usePoll } from './poll-hook'; +import { useSafeFetch } from './safe-fetch-hook'; + +export const URL_POLL_DEFAULT_DELAY = 15000; // 15 seconds + +export const useURLPoll: UseURLPoll = ( + url: string, + delay = URL_POLL_DEFAULT_DELAY, + ...dependencies: any[] +) => { + const [error, setError] = useState(); + const [response, setResponse] = useState(); + const [loading, setLoading] = useState(true); + const safeFetch = useSafeFetch(); + const tick = useCallback(() => { + if (url) { + safeFetch(url) + .then((data) => { + setResponse(data); + setError(null); + }) + .catch((err) => { + if (err.name !== 'AbortError') { + setResponse(null); + setError(err); + // eslint-disable-next-line no-console + console.error(`Error polling URL: ${err}`); + } + }) + .finally(() => setLoading(false)); + } else { + setLoading(false); + } + }, [url]); + + usePoll(tick, delay, ...dependencies); + + return [response, error, loading]; +}; diff --git a/src/components/pipelines-metrics/utils.ts b/src/components/pipelines-metrics/utils.ts new file mode 100644 index 00000000..96f169ec --- /dev/null +++ b/src/components/pipelines-metrics/utils.ts @@ -0,0 +1,78 @@ +import _ from 'lodash'; + +export enum PipelineQuery { + PIPELINERUN_COUNT_FOR_STATUS_FOR_NAMESPACE = 'PIPELINERUN_COUNT_FOR_STATUS_FOR_NAMESPACE', + PIPELINERUN_COUNT_FOR_STATUS_FOR_ALL_NAMESPACE = 'PIPELINERUN_COUNT_FOR_STATUS_FOR_ALL_NAMESPACE', + PIPELINERUN_COUNT_FOR_STATUS_FOR_NAMESPACE_FOR_PIPELINE = 'PIPELINERUN_COUNT_FOR_STATUS_FOR_NAMESPACE_FOR_PIPELINE', + PIPELINERUN_COUNT_FOR_NAMESPACE = 'PIPELINERUN_COUNT_FOR_NAMESPACE', + PIPELINERUN_COUNT_FOR_ALL_NAMESPACE = 'PIPELINERUN_COUNT_FOR_ALL_NAMESPACE', + PIPELINERUN_COUNT_FOR_NAMESPACE_FOR_PIPELINE = 'PIPELINERUN_COUNT_FOR_NAMESPACE_FOR_PIPELINE', + PIPELINERUN_DURATION_FOR_NAMESPACE = 'PIPELINERUN_DURATION_FOR_NAMESPACE', + PIPELINERUN_DURATION_FOR_ALL_NAMESPACE = 'PIPELINERUN_DURATION_FOR_ALL_NAMESPACE', + PIPELINERUN_DURATION_FOR_NAMESPACE_FOR_PIPELINE = 'PIPELINERUN_DURATION_FOR_NAMESPACE_FOR_PIPELINE', + PIPELINERUN_COUNT_WITH_METRIC_FOR_ALL_NAMESPACE = 'PIPELINERUN_COUNT_WITH_METRIC_FOR_ALL_NAMESPACE', + PIPELINERUN_COUNT_WITH_METRIC_FOR_NAMESPACE = 'PIPELINERUN_COUNT_WITH_METRIC_FOR_NAMESPACE', + PIPELINERUN_SUM_WITH_METRIC_FOR_ALL_NAMESPACE = 'PIPELINERUN_SUM_WITH_METRIC_FOR_ALL_NAMESPACE', + PIPELINERUN_SUM_WITH_METRIC_FOR_NAMESPACE = 'PIPELINERUN_SUM_WITH_METRIC_FOR_NAMESPACE', +} + +export enum MetricsQueryPrefix { + TEKTON = 'tekton', + TEKTON_PIPELINES_CONTROLLER = 'tekton_pipelines_controller', +} + +export const metricsQueries = ( + prefix: string = MetricsQueryPrefix.TEKTON_PIPELINES_CONTROLLER, +) => ({ + [PipelineQuery.PIPELINERUN_COUNT_FOR_STATUS_FOR_NAMESPACE]: _.template( + `sum by (status) (${prefix}_pipelinerun_duration_seconds_count{namespace="<%= namespace %>"})`, + ), + [PipelineQuery.PIPELINERUN_COUNT_FOR_STATUS_FOR_NAMESPACE_FOR_PIPELINE]: + _.template( + `sum by (status) (${prefix}_pipelinerun_duration_seconds_count{pipeline="<%= name %>",namespace="<%= namespace %>"})`, + ), + [PipelineQuery.PIPELINERUN_COUNT_FOR_STATUS_FOR_ALL_NAMESPACE]: _.template( + `sum by (status) (${prefix}_pipelinerun_duration_seconds_count)`, + ), + [PipelineQuery.PIPELINERUN_COUNT_FOR_NAMESPACE]: _.template( + `sum(${prefix}_pipelinerun_duration_seconds_count{namespace="<%= namespace %>"})`, + ), + [PipelineQuery.PIPELINERUN_COUNT_FOR_NAMESPACE_FOR_PIPELINE]: _.template( + `sum(${prefix}_pipelinerun_duration_seconds_count{pipeline="<%= name %>",namespace="<%= namespace %>"})`, + ), + [PipelineQuery.PIPELINERUN_COUNT_FOR_ALL_NAMESPACE]: _.template( + `sum(${prefix}_pipelinerun_duration_seconds_count)`, + ), + [PipelineQuery.PIPELINERUN_DURATION_FOR_NAMESPACE]: _.template( + `sum(${prefix}_pipelinerun_duration_seconds_sum{namespace="<%= namespace %>"})`, + ), + [PipelineQuery.PIPELINERUN_DURATION_FOR_NAMESPACE_FOR_PIPELINE]: _.template( + `sum(${prefix}_pipelinerun_duration_seconds_sum{pipeline="<%= name %>",namespace="<%= namespace %>"})`, + ), + [PipelineQuery.PIPELINERUN_DURATION_FOR_ALL_NAMESPACE]: _.template( + `sum(${prefix}_pipelinerun_duration_seconds_sum)`, + ), + [PipelineQuery.PIPELINERUN_COUNT_WITH_METRIC_FOR_ALL_NAMESPACE]: _.template( + `${prefix}_pipelinerun_duration_seconds_count`, + ), + [PipelineQuery.PIPELINERUN_COUNT_WITH_METRIC_FOR_NAMESPACE]: _.template( + `${prefix}_pipelinerun_duration_seconds_count{namespace="<%= namespace %>"}`, + ), + [PipelineQuery.PIPELINERUN_SUM_WITH_METRIC_FOR_ALL_NAMESPACE]: _.template( + `${prefix}_pipelinerun_duration_seconds_sum`, + ), + [PipelineQuery.PIPELINERUN_SUM_WITH_METRIC_FOR_NAMESPACE]: _.template( + `${prefix}_pipelinerun_duration_seconds_sum{namespace="<%= namespace %>"}`, + ), +}); + +export const adjustToStartOfWeek = (date: Date): Date => { + const day = date.getDay(); + const diff = date.getDate() - day + (day === 0 ? -6 : 1); // adjust when day is Sunday + return new Date(date.setDate(diff)); +}; + +export const secondsToMinutesK8s = (seconds: number): number => { + const minutes = seconds / 60; + return parseFloat(minutes.toFixed(2)); +}; diff --git a/src/components/pipelines-overview/PipelineRunsDurationCard.tsx b/src/components/pipelines-overview/PipelineRunsDurationCard.tsx index 72b308f0..147c11e2 100644 --- a/src/components/pipelines-overview/PipelineRunsDurationCard.tsx +++ b/src/components/pipelines-overview/PipelineRunsDurationCard.tsx @@ -109,7 +109,7 @@ const PipelinesRunsDurationCard: React.FC = ({ - {t('Maximun')} + {t('Maximum')} = ({ + namespace, + timespan, + parentName, + interval, + bordered, +}) => { + const { t } = useTranslation('plugin__pipelines-console-plugin'); + + const [totalPipelineRunsCountData] = + parentName && namespace + ? usePipelineMetricsForNamespaceForPipelinePoll({ + namespace, + timespan, + delay: interval, + queryPrefix: MetricsQueryPrefix.TEKTON_PIPELINES_CONTROLLER, + name: parentName, + metricsQuery: + PipelineQuery.PIPELINERUN_COUNT_FOR_NAMESPACE_FOR_PIPELINE, + }) + : namespace == ALL_NAMESPACES_KEY + ? usePipelineMetricsForAllNamespacePoll({ + timespan, + delay: interval, + queryPrefix: MetricsQueryPrefix.TEKTON_PIPELINES_CONTROLLER, + metricsQuery: PipelineQuery.PIPELINERUN_COUNT_FOR_ALL_NAMESPACE, + }) + : usePipelineMetricsForNamespacePoll({ + namespace, + timespan, + delay: interval, + queryPrefix: MetricsQueryPrefix.TEKTON_PIPELINES_CONTROLLER, + metricsQuery: PipelineQuery.PIPELINERUN_COUNT_FOR_NAMESPACE, + }); + const [tickValues, type] = getXaxisValues(timespan); + + const totalPipelineRuns = getTotalPipelineRuns( + totalPipelineRunsCountData, + tickValues, + type, + ); + + const [totalPipelineRunsDurationData] = + parentName && namespace + ? usePipelineMetricsForNamespaceForPipelinePoll({ + namespace, + timespan, + delay: interval, + queryPrefix: MetricsQueryPrefix.TEKTON_PIPELINES_CONTROLLER, + name: parentName, + metricsQuery: + PipelineQuery.PIPELINERUN_DURATION_FOR_NAMESPACE_FOR_PIPELINE, + }) + : namespace == ALL_NAMESPACES_KEY + ? usePipelineMetricsForAllNamespacePoll({ + timespan, + delay: interval, + queryPrefix: MetricsQueryPrefix.TEKTON_PIPELINES_CONTROLLER, + metricsQuery: PipelineQuery.PIPELINERUN_DURATION_FOR_ALL_NAMESPACE, + }) + : usePipelineMetricsForNamespacePoll({ + namespace, + timespan, + delay: interval, + queryPrefix: MetricsQueryPrefix.TEKTON_PIPELINES_CONTROLLER, + metricsQuery: PipelineQuery.PIPELINERUN_DURATION_FOR_NAMESPACE, + }); + + const [totalPipelineRunsDuration, totalPipelineRunsDurationValue] = + getTotalPipelineRunsDuration( + totalPipelineRunsDurationData, + tickValues, + type, + ); + + const averageDuration = getPipelineRunAverageDuration( + totalPipelineRunsDurationValue, + totalPipelineRuns, + ); + + return ( + <> + + + {t('Duration')} + + + + + + + + {t('Average duration')} + + + + {averageDuration} + + + + + + + {t('Maximum')} + + + + {'-'} + + + + + + + {t('Total duration')} + + + + {totalPipelineRunsDuration ?? '-'} + + + + + + ); +}; + +export default PipelineRunsDurationCardK8s; diff --git a/src/components/pipelines-overview/PipelineRunsNumbersChartK8s.tsx b/src/components/pipelines-overview/PipelineRunsNumbersChartK8s.tsx new file mode 100644 index 00000000..76d5c536 --- /dev/null +++ b/src/components/pipelines-overview/PipelineRunsNumbersChartK8s.tsx @@ -0,0 +1,272 @@ +import * as React from 'react'; +import * as _ from 'lodash'; +import * as classNames from 'classnames'; +import { useTranslation } from 'react-i18next'; +import { DomainPropType, DomainTuple } from 'victory-core'; +import { + Chart, + ChartAxis, + ChartAxisProps, + ChartBar, + ChartGroup, + ChartThemeColor, + ChartVoronoiContainer, +} from '@patternfly/react-charts'; +import { Card, CardBody, CardTitle } from '@patternfly/react-core'; +import { + formatDate, + getXaxisValues, + hourformat, + parsePrometheusDuration, + monthYear, +} from './dateTime'; +import { ALL_NAMESPACES_KEY } from '../../consts'; +import { + usePipelineMetricsForAllNamespacePoll, + usePipelineMetricsForNamespaceForPipelinePoll, + usePipelineMetricsForNamespacePoll, +} from '../pipelines-metrics/hooks'; +import { + MetricsQueryPrefix, + PipelineQuery, + adjustToStartOfWeek, +} from '../pipelines-metrics/utils'; + +interface PipelinesRunsNumbersChartProps { + namespace?: string; + timespan?: number; + interval?: number; + domain?: DomainPropType; + parentName?: string; + bordered?: boolean; + width?: number; +} +type DomainType = { x?: DomainTuple; y?: DomainTuple }; + +const metricsToSummary = ( + prometheusResult: any, +): { group_value: number; total: number }[] => { + let summaryResponse = []; + if (prometheusResult?.data?.result[0]?.values) { + summaryResponse = prometheusResult?.data?.result[0]?.values.map( + (value, index, array) => { + const previousValue = index > 0 ? parseInt(array[index - 1][1]) : 0; + const currentValue = parseInt(value[1]); + const total = currentValue - previousValue; + return { + group_value: value[0], + total: total < 0 ? currentValue : total, + }; + }, + ); + return summaryResponse; + } + return summaryResponse; +}; + +const getChartData = ( + tickValues: number[] | Date[], + data: any, + type: string, +) => { + const sortedTickValues = tickValues.slice().sort((a, b) => a - b); + const chartData = sortedTickValues?.map((value, index) => { + if (index === 0) { + // Ensure the first value is always 0 + return { + x: value, + y: 0, + }; + } + const s = data?.find((d) => { + const group_date = new Date(Number(d.group_value) * 1000); + if (type == 'hour') { + return group_date.getHours() === value; + } + if (type == 'week') { + const adjustedGroupDate = adjustToStartOfWeek(new Date(group_date)); + return ( + adjustedGroupDate.toDateString() === new Date(value).toDateString() + ); + } + if (type == 'day') { + return group_date.toDateString() === new Date(value).toDateString(); + } + if (type == 'month') { + return group_date.getMonth() === value.getMonth(); + } + }); + return { + x: value, + y: s?.total || 0, + }; + }); + return chartData; +}; + +const PipelineRunsNumbersChartK8s: React.FC = ({ + namespace, + timespan, + interval, + domain, + parentName, + bordered, + width = 530, +}) => { + const { t } = useTranslation('plugin__pipelines-console-plugin'); + const startTimespan = timespan - parsePrometheusDuration('1d'); + const endDate = new Date(Date.now()).setHours(0, 0, 0, 0); + const startDate = new Date(Date.now() - startTimespan).setHours(0, 0, 0, 0); + const { x: domainX, y: domainY } = (domain as DomainType) || {}; + const domainValue: DomainPropType = { + x: domainX || [startDate, endDate], + y: domainY || undefined, + }; + + const [runSuccessRatioData] = + parentName && namespace + ? usePipelineMetricsForNamespaceForPipelinePoll({ + namespace, + timespan, + delay: interval, + queryPrefix: MetricsQueryPrefix.TEKTON_PIPELINES_CONTROLLER, + name: parentName, + metricsQuery: + PipelineQuery.PIPELINERUN_COUNT_FOR_NAMESPACE_FOR_PIPELINE, + }) + : namespace == ALL_NAMESPACES_KEY + ? usePipelineMetricsForAllNamespacePoll({ + timespan, + delay: interval, + queryPrefix: MetricsQueryPrefix.TEKTON_PIPELINES_CONTROLLER, + metricsQuery: PipelineQuery.PIPELINERUN_COUNT_FOR_ALL_NAMESPACE, + }) + : usePipelineMetricsForNamespacePoll({ + namespace, + timespan, + delay: interval, + queryPrefix: MetricsQueryPrefix.TEKTON_PIPELINES_CONTROLLER, + metricsQuery: PipelineQuery.PIPELINERUN_COUNT_FOR_NAMESPACE, + }); + const convertToSummaryData = metricsToSummary(runSuccessRatioData); + + const [tickValues, type] = getXaxisValues(timespan); + + let xTickFormat; + let dayLabel; + let showLabel = false; + let chartData = []; + switch (type) { + case 'hour': + xTickFormat = (d) => hourformat(d); + showLabel = true; + domainValue.x = [0, 23]; + dayLabel = formatDate(new Date()); + chartData = getChartData(tickValues, convertToSummaryData, 'hour'); + break; + case 'day': + xTickFormat = (d) => formatDate(d); + domainValue.x = [startDate, endDate]; + chartData = getChartData(tickValues, convertToSummaryData, 'day'); + break; + case 'week': + xTickFormat = (d) => formatDate(d); + domainValue.x = [new Date(tickValues[0]), new Date(tickValues[11])]; + chartData = getChartData(tickValues, convertToSummaryData, 'week'); + break; + case 'month': + xTickFormat = (d) => monthYear(d); + domainValue.x = [new Date(tickValues[0]), new Date(tickValues[11])]; + chartData = getChartData(tickValues, convertToSummaryData, 'month'); + break; + default: + console.log('Received wrong data'); + break; + } + const max: number = Math.max(...chartData.map((yVal) => yVal.y)); + !isNaN(max) && max > 5 + ? (domainValue.y = [0, max]) + : (domainValue.y = [0, 5]); + + if (!domainY) { + let minY: number = _.minBy(chartData, 'y')?.y ?? 0; + let maxY: number = _.maxBy(chartData, 'y')?.y ?? 0; + if (minY === 0 && maxY === 0) { + minY = -1; + maxY = 1; + } else if (minY > 0 && maxY > 0) { + minY = 0; + } else if (minY < 0 && maxY < 0) { + maxY = 0; + } + domainValue.y = [minY, maxY]; + } + + let xAxisStyle: ChartAxisProps['style'] = { + tickLabels: { fill: 'var(--pf-v5-global--Color--100)', fontSize: 12 }, + }; + const yAxisStyle: ChartAxisProps['style'] = { + tickLabels: { fill: 'var(--pf-v5-global--Color--100)', fontSize: 12 }, + }; + if (tickValues.length > 7) { + xAxisStyle = { + tickLabels: { + fill: 'var(--pf-v5-global--Color--100)', + angle: 320, + fontSize: 10, + textAnchor: 'end', + verticalAnchor: 'end', + }, + }; + } + + return ( + <> + + + {t('Number of PipelineRuns')} + + +
+ `${datum.y}`} + constrainToVisibleArea + /> + } + scale={{ x: 'time', y: 'linear' }} + domain={domainValue} + domainPadding={{ x: [30, 25] }} + height={145} + width={width} + padding={{ + top: 10, + bottom: 55, + left: 50, + }} + themeColor={ChartThemeColor.blue} + > + + + + + + +
+
+
+ + ); +}; + +export default PipelineRunsNumbersChartK8s; diff --git a/src/components/pipelines-overview/PipelineRunsStatusCardK8s.tsx b/src/components/pipelines-overview/PipelineRunsStatusCardK8s.tsx new file mode 100644 index 00000000..28c13c2b --- /dev/null +++ b/src/components/pipelines-overview/PipelineRunsStatusCardK8s.tsx @@ -0,0 +1,597 @@ +import * as React from 'react'; +import * as classNames from 'classnames'; +import { useTranslation } from 'react-i18next'; +import { DomainPropType, DomainTuple } from 'victory-core'; +import { + Chart, + ChartAxis, + ChartAxisProps, + ChartDonut, + ChartGroup, + ChartLabel, + ChartLegend, + ChartLine, + ChartVoronoiContainer, +} from '@patternfly/react-charts'; +import { + Card, + CardBody, + CardTitle, + Grid, + GridItem, + Popover, +} from '@patternfly/react-core'; +import { chart_color_black_200 as othersColor } from '@patternfly/react-tokens/dist/js/chart_color_black_200'; +import { chart_color_black_500 as cancelledColor } from '@patternfly/react-tokens/dist/js/chart_color_black_500'; +import { chart_color_green_400 as successColor } from '@patternfly/react-tokens/dist/js/chart_color_green_400'; +import { global_danger_color_100 as failureColor } from '@patternfly/react-tokens/dist/js/global_danger_color_100'; +import { chart_color_blue_300 as runningColor } from '@patternfly/react-tokens/dist/js/chart_color_blue_300'; +import { + formatDate, + getXaxisValues, + hourformat, + parsePrometheusDuration, + monthYear, +} from './dateTime'; +import './PipelinesOverview.scss'; +import { ALL_NAMESPACES_KEY } from '../../consts'; +import { OutlinedQuestionCircleIcon } from '@patternfly/react-icons'; +import { PrometheusResponse } from '@openshift-console/dynamic-plugin-sdk'; +import { + usePipelineMetricsForAllNamespacePoll, + usePipelineMetricsForNamespacePoll, + usePipelineMetricsForNamespaceForPipelinePoll, +} from '../pipelines-metrics/hooks'; +import { + MetricsQueryPrefix, + PipelineQuery, + adjustToStartOfWeek, +} from '../pipelines-metrics/utils'; +import { getTotalPipelineRuns, isMatchingFirstTickValue } from './utils'; + +interface PipelinesRunsStatusCardProps { + timespan?: number; + domain?: DomainPropType; + bordered?: boolean; + namespace: string; + interval: number; + parentName?: string; +} + +type DomainType = { x?: DomainTuple; y?: DomainTuple }; + +const getStatusSummary = (promQueryToSummaryResponse) => { + const result = { + cancelled: 0, + failed: 0, + succeeded: 0, + total: 0, + }; + promQueryToSummaryResponse?.forEach((item) => { + result.cancelled += item.cancelled; + result.failed += item.failed; + result.succeeded += item.succeeded; + result.total = result.cancelled + result.failed + result.succeeded; + }); + + return result; +}; + +export const getChartDataK8s = ( + tickValues: number[] | Date[], + data: any, + key: string, + type: string, +): { + x: number; + y: number; + name: string; +}[] => { + const { t } = useTranslation('plugin__pipelines-console-plugin'); + const k = key.toLowerCase(); + const sortedTickValues = tickValues.slice().sort((a, b) => a - b); + const chartData = sortedTickValues?.map((value, index) => { + if (index === 0) { + // Ensure the first value is always 0 + return { + x: value, + y: 0, + name: t(key), + }; + } + const s = data?.find((d) => { + const group_date = new Date(Number(d.group_value) * 1000); + if (type == 'hour') { + return group_date.getHours() === value; + } + if (type == 'week') { + const adjustedGroupDate = adjustToStartOfWeek(new Date(group_date)); + return ( + adjustedGroupDate.toDateString() === new Date(value).toDateString() + ); + } + if (type == 'day') { + return group_date.toDateString() === new Date(value).toDateString(); + } + if (type == 'month') { + return group_date.getMonth() === value.getMonth(); + } + }); + return { + x: value, + y: Math.round((100 * s?.[k]) / s?.total) || 0, + name: t(key), + }; + }); + return chartData; +}; + +export const getIncrementedValues = ( + data, + tickValues: number[] | Date[], + type: string, +) => { + const originalData = JSON.parse(JSON.stringify(data)); + for (let i = 1; i < data.length; i++) { + const prevCancelled = originalData[i - 1].cancelled; + const currentCancelled = originalData[i].cancelled; + data[i].cancelled = + currentCancelled >= prevCancelled + ? currentCancelled - prevCancelled + : currentCancelled; + + const prevFailed = originalData[i - 1].failed; + const currentFailed = originalData[i].failed; + data[i].failed = + currentFailed >= prevFailed ? currentFailed - prevFailed : currentFailed; + + const prevSucceeded = originalData[i - 1].succeeded; + const currentSucceeded = originalData[i].succeeded; + data[i].succeeded = + currentSucceeded >= prevSucceeded + ? currentSucceeded - prevSucceeded + : currentSucceeded; + } + + const firstTickValue = tickValues[0]; + data.forEach((item) => { + item.total = item.cancelled + item.failed + item.succeeded; + const isMatch = isMatchingFirstTickValue( + firstTickValue, + item.group_value, + type, + ); + if (isMatch) { + data[0].cancelled = 0; + data[0].failed = 0; + data[0].succeeded = 0; + } + }); + + return data; +}; + +const transformPrometheusResultToSummary = ( + prometheusResult: PrometheusResponse, + tickValues: number[] | Date[], + type: string, +) => { + const summary = []; + if ( + prometheusResult && + prometheusResult.data && + prometheusResult.data.result + ) { + prometheusResult.data.result.forEach((metric) => { + metric.values.forEach((value) => { + const groupValue = value[0]; + + let summaryObj = summary.find((obj) => obj.group_value === groupValue); + + if (!summaryObj) { + summaryObj = { + group_value: groupValue, + cancelled: 0, + failed: 0, + succeeded: 0, + }; + summary.push(summaryObj); + } + + switch (metric.metric.status) { + case 'cancelled': + summaryObj.cancelled = parseInt(value[1], 10); + break; + case 'failed': + summaryObj.failed = parseInt(value[1], 10); + break; + case 'success': + summaryObj.succeeded = parseInt(value[1], 10); + break; + default: + break; + } + }); + }); + } + summary.sort((a, b) => a.group_value - b.group_value); + const finalResult = getIncrementedValues(summary, tickValues, type); + return finalResult; +}; + +const PipelineRunsStatusCardK8s: React.FC = ({ + timespan, + domain, + bordered, + namespace, + interval, + parentName, +}) => { + const { t } = useTranslation('plugin__pipelines-console-plugin'); + const startTimespan = timespan - parsePrometheusDuration('1d'); + const endDate = new Date(Date.now()).setHours(0, 0, 0, 0); + const startDate = new Date(Date.now() - startTimespan).setHours(0, 0, 0, 0); + const { x: domainX, y: domainY } = (domain as DomainType) || {}; + const domainValue: DomainPropType = { + x: domainX || [startDate, endDate], + y: domainY || undefined, + }; + const [runSuccessRatioData] = + parentName && namespace + ? usePipelineMetricsForNamespaceForPipelinePoll({ + namespace, + timespan, + delay: interval, + queryPrefix: MetricsQueryPrefix.TEKTON_PIPELINES_CONTROLLER, + name: parentName, + metricsQuery: + PipelineQuery.PIPELINERUN_COUNT_FOR_STATUS_FOR_NAMESPACE_FOR_PIPELINE, + }) + : namespace == ALL_NAMESPACES_KEY + ? usePipelineMetricsForAllNamespacePoll({ + timespan, + delay: interval, + queryPrefix: MetricsQueryPrefix.TEKTON_PIPELINES_CONTROLLER, + metricsQuery: + PipelineQuery.PIPELINERUN_COUNT_FOR_STATUS_FOR_ALL_NAMESPACE, + }) + : usePipelineMetricsForNamespacePoll({ + namespace, + timespan, + delay: interval, + queryPrefix: MetricsQueryPrefix.TEKTON_PIPELINES_CONTROLLER, + metricsQuery: + PipelineQuery.PIPELINERUN_COUNT_FOR_STATUS_FOR_NAMESPACE, + }); + const [totalPipelineRunsData] = + parentName && namespace + ? usePipelineMetricsForNamespaceForPipelinePoll({ + namespace, + timespan, + delay: interval, + queryPrefix: MetricsQueryPrefix.TEKTON_PIPELINES_CONTROLLER, + name: parentName, + metricsQuery: + PipelineQuery.PIPELINERUN_COUNT_FOR_NAMESPACE_FOR_PIPELINE, + }) + : namespace == ALL_NAMESPACES_KEY + ? usePipelineMetricsForAllNamespacePoll({ + timespan, + delay: interval, + queryPrefix: MetricsQueryPrefix.TEKTON_PIPELINES_CONTROLLER, + metricsQuery: PipelineQuery.PIPELINERUN_COUNT_FOR_ALL_NAMESPACE, + }) + : usePipelineMetricsForNamespacePoll({ + namespace, + timespan, + delay: interval, + queryPrefix: MetricsQueryPrefix.TEKTON_PIPELINES_CONTROLLER, + metricsQuery: PipelineQuery.PIPELINERUN_COUNT_FOR_NAMESPACE, + }); + + const [tickValues, type] = getXaxisValues(timespan); + + const totalPipelineRuns = getTotalPipelineRuns( + totalPipelineRunsData, + tickValues, + type, + ); + + const promQueryToSummaryResponse = transformPrometheusResultToSummary( + runSuccessRatioData, + tickValues, + type, + ); + + let xTickFormat; + let dayLabel; + let showLabel = false; + let chartDataSucceededK8s = []; + let chartDataFailedK8s = []; + let chartDataCancelledK8s = []; + switch (type) { + case 'hour': + xTickFormat = (d) => hourformat(d); + showLabel = true; + domainValue.x = [0, 23]; + + dayLabel = formatDate(new Date()); + + chartDataCancelledK8s = getChartDataK8s( + tickValues, + promQueryToSummaryResponse, + 'Cancelled', + 'hour', + ); + chartDataSucceededK8s = getChartDataK8s( + tickValues, + promQueryToSummaryResponse, + 'Succeeded', + 'hour', + ); + chartDataFailedK8s = getChartDataK8s( + tickValues, + promQueryToSummaryResponse, + 'Failed', + 'hour', + ); + break; + case 'day': + xTickFormat = (d) => formatDate(d); + domainValue.x = [startDate, endDate]; + + chartDataCancelledK8s = getChartDataK8s( + tickValues, + promQueryToSummaryResponse, + 'Cancelled', + 'day', + ); + chartDataSucceededK8s = getChartDataK8s( + tickValues, + promQueryToSummaryResponse, + 'Succeeded', + 'day', + ); + chartDataFailedK8s = getChartDataK8s( + tickValues, + promQueryToSummaryResponse, + 'Failed', + 'day', + ); + + break; + case 'week': + xTickFormat = (d) => formatDate(d); + domainValue.x = [new Date(tickValues[0]), new Date(tickValues[11])]; + + chartDataCancelledK8s = getChartDataK8s( + tickValues, + promQueryToSummaryResponse, + 'Cancelled', + 'week', + ); + chartDataSucceededK8s = getChartDataK8s( + tickValues, + promQueryToSummaryResponse, + 'Succeeded', + 'week', + ); + chartDataFailedK8s = getChartDataK8s( + tickValues, + promQueryToSummaryResponse, + 'Failed', + 'week', + ); + + break; + case 'month': + xTickFormat = (d) => monthYear(d); + domainValue.x = [new Date(tickValues[0]), new Date(tickValues[11])]; + + chartDataCancelledK8s = getChartDataK8s( + tickValues, + promQueryToSummaryResponse, + 'Cancelled', + 'month', + ); + chartDataSucceededK8s = getChartDataK8s( + tickValues, + promQueryToSummaryResponse, + 'Succeeded', + 'month', + ); + chartDataFailedK8s = getChartDataK8s( + tickValues, + promQueryToSummaryResponse, + 'Failed', + 'month', + ); + + break; + default: + console.log('Received wrong data'); + break; + } + + let xAxisStyle: ChartAxisProps['style'] = { + tickLabels: { fill: 'var(--pf-v5-global--Color--100)' }, + }; + const yAxisStyle: ChartAxisProps['style'] = { + tickLabels: { fill: 'var(--pf-v5-global--Color--100)' }, + }; + if (tickValues?.length > 7) { + xAxisStyle = { + tickLabels: { + fill: 'var(--pf-v5-global--Color--100)', + angle: 320, + fontSize: 10, + textAnchor: 'end', + verticalAnchor: 'end', + }, + }; + } + + const colorScale = [ + successColor.value, + failureColor.value, + runningColor.value, + cancelledColor.value, + othersColor.value, + ]; + + const colorScaleLineChart = [ + successColor.value, + failureColor.value, + cancelledColor.value, + othersColor.value, + ]; + const donutDataObjK8s = getStatusSummary(promQueryToSummaryResponse); + const donutDataK8s = [ + { + x: t('Succeeded'), + y: Math.round((100 * donutDataObjK8s.succeeded) / donutDataObjK8s.total), + }, + { + x: t('Failed'), + y: Math.round((100 * donutDataObjK8s.failed) / donutDataObjK8s.total), + }, + + { + x: t('Cancelled'), + y: Math.round((100 * donutDataObjK8s.cancelled) / donutDataObjK8s.total), + }, + ]; + + const legendData = donutDataK8s.map((data) => { + return { + name: `${data.x}: ${isNaN(data.y) ? 0 : data.y}%`, + }; + }); + return ( + <> + + + + {t('PipelineRun status')}{' '} + + {t( + 'PipelineRun status shows the % of PipelineRuns for various statuses like "Succeeded", "Failed" and "Cancelled".', + )} + + } + > + + + + + + + + + +
+ `${datum.x}: ${datum.y}%`} + legendData={legendData} + colorScale={colorScale} + legendOrientation="vertical" + legendPosition="right" + padding={{ + bottom: 30, + right: 140, // Adjusted to accommodate legend + top: 20, + }} + legendComponent={ + + } + subTitle={t('Succeeded')} + subTitleComponent={ + + } + title={`${donutDataObjK8s.succeeded}/${totalPipelineRuns}`} + titleComponent={ + + } + width={350} + /> +
+
+ +
+ `${datum.name}: ${datum.y}%`} + constrainToVisibleArea + /> + } + scale={{ x: 'time', y: 'linear' }} + domain={domainValue} + domainPadding={{ x: [30, 25] }} + height={200} + padding={{ + top: 20, + bottom: 40, + right: 40, + left: 50, + }} + colorScale={colorScaleLineChart} + width={1000} + > + + `${v}%`} + style={yAxisStyle} + /> + + + + + + +
+
+
+
+
+ + ); +}; + +export default PipelineRunsStatusCardK8s; diff --git a/src/components/pipelines-overview/PipelineRunsTotalCardK8s.tsx b/src/components/pipelines-overview/PipelineRunsTotalCardK8s.tsx new file mode 100644 index 00000000..22d64cda --- /dev/null +++ b/src/components/pipelines-overview/PipelineRunsTotalCardK8s.tsx @@ -0,0 +1,135 @@ +import * as React from 'react'; +import * as classNames from 'classnames'; +import { useTranslation } from 'react-i18next'; +import { CheckIcon } from '@patternfly/react-icons'; +import { + Card, + CardBody, + CardTitle, + Divider, + Grid, + GridItem, + Label, +} from '@patternfly/react-core'; +import { SummaryProps, getTotalPipelineRuns } from './utils'; +import { PipelineModel, RepositoryModel } from '../../models'; +import { ALL_NAMESPACES_KEY } from '../../consts'; + +import './PipelineRunsTotalCard.scss'; +import { MetricsQueryPrefix, PipelineQuery } from '../pipelines-metrics/utils'; +import { + usePipelineMetricsForAllNamespacePoll, + usePipelineMetricsForNamespacePoll, +} from '../pipelines-metrics/hooks'; +import { getXaxisValues } from './dateTime'; + +interface PipelinesRunsDurationProps { + namespace: string; + timespan: number; + interval: number; + summaryData?: SummaryProps; + bordered?: boolean; +} + +const PipelineRunsTotalCardK8s: React.FC = ({ + namespace, + timespan, + interval, + bordered, +}) => { + const { t } = useTranslation('plugin__pipelines-console-plugin'); + + const [totalPipelineRunsData] = + namespace == ALL_NAMESPACES_KEY + ? usePipelineMetricsForAllNamespacePoll({ + timespan, + delay: interval, + queryPrefix: MetricsQueryPrefix.TEKTON_PIPELINES_CONTROLLER, + metricsQuery: PipelineQuery.PIPELINERUN_COUNT_FOR_ALL_NAMESPACE, + }) + : usePipelineMetricsForNamespacePoll({ + namespace, + timespan, + delay: interval, + queryPrefix: MetricsQueryPrefix.TEKTON_PIPELINES_CONTROLLER, + metricsQuery: PipelineQuery.PIPELINERUN_COUNT_FOR_NAMESPACE, + }); + const [tickValues, type] = getXaxisValues(timespan); + + const totalPipelineRuns = getTotalPipelineRuns( + totalPipelineRunsData, + tickValues, + type, + ); + + return ( + <> + + + {t('Total runs')} + + + + + + + + {t('Runs in pipelines')} + + + + {'-'} + + + + + + + {t('Runs in repositories')} + + + + {'-'} + + + + + + + {t('Total runs')} + + + + {totalPipelineRuns} + + + + + + ); +}; + +export default PipelineRunsTotalCardK8s; diff --git a/src/components/pipelines-overview/PipelinesOverviewPageK8s.tsx b/src/components/pipelines-overview/PipelinesOverviewPageK8s.tsx new file mode 100644 index 00000000..0f868d1c --- /dev/null +++ b/src/components/pipelines-overview/PipelinesOverviewPageK8s.tsx @@ -0,0 +1,126 @@ +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Flex, FlexItem } from '@patternfly/react-core'; +import { formatPrometheusDuration, parsePrometheusDuration } from './dateTime'; +import NameSpaceDropdown from './NamespaceDropdown'; +import TimeRangeDropdown from './TimeRangeDropdown'; +import RefreshDropdown from './RefreshDropdown'; +import { IntervalOptions, TimeRangeOptionsK8s, useQueryParams } from './utils'; +import { useActiveNamespace } from '@openshift-console/dynamic-plugin-sdk'; +import PipelineRunsStatusCardK8s from './PipelineRunsStatusCardK8s'; +import PipelineRunsNumbersChartK8s from './PipelineRunsNumbersChartK8s'; +import PipelineRunsTotalCardK8s from './PipelineRunsTotalCardK8s'; +import PipelineRunsDurationCardK8s from './PipelineRunsDurationCardK8s'; +import PipelineRunsListPageK8s from './list-pages/PipelineRunsListPageK8s'; + +const PipelinesOverviewPageK8s: React.FC = () => { + const { t } = useTranslation('plugin__pipelines-console-plugin'); + const [activeNamespace, setActiveNamespace] = useActiveNamespace(); + const [namespace, setNamespace] = React.useState(activeNamespace); + const [timespan, setTimespan] = React.useState(parsePrometheusDuration('1d')); + const [interval, setInterval] = React.useState( + parsePrometheusDuration('30s'), + ); + React.useEffect(() => { + setActiveNamespace(namespace); + }, [namespace]); + + useQueryParams({ + key: 'refreshinterval', + value: interval, + setValue: setInterval, + defaultValue: parsePrometheusDuration('30s'), + options: { ...IntervalOptions(), off: 'OFF_KEY' }, + displayFormat: (v) => (v ? formatPrometheusDuration(v) : 'off'), + loadFormat: (v) => (v == 'off' ? null : parsePrometheusDuration(v)), + }); + + useQueryParams({ + key: 'timerange', + value: timespan, + setValue: setTimespan, + defaultValue: parsePrometheusDuration('1w'), + options: TimeRangeOptionsK8s(), + displayFormat: formatPrometheusDuration, + loadFormat: parsePrometheusDuration, + }); + + return ( + <> +
+

+ {t('Overview')} +

+
+ + + + + + + + + + + +
+ + + + + + + + + + + + + +
+
+ +
+ + ); +}; + +export default PipelinesOverviewPageK8s; diff --git a/src/components/pipelines-overview/TimeRangeDropdown.tsx b/src/components/pipelines-overview/TimeRangeDropdown.tsx index b2684eec..9e82d7a6 100644 --- a/src/components/pipelines-overview/TimeRangeDropdown.tsx +++ b/src/components/pipelines-overview/TimeRangeDropdown.tsx @@ -8,8 +8,10 @@ import { } from '@patternfly/react-core'; import { map } from 'lodash'; import { useTranslation } from 'react-i18next'; +import { useFlag } from '@openshift-console/dynamic-plugin-sdk'; import { formatPrometheusDuration, parsePrometheusDuration } from './dateTime'; -import { TimeRangeOptions } from './utils'; +import { TimeRangeOptions, TimeRangeOptionsK8s } from './utils'; +import { FLAG_PIPELINE_TEKTON_RESULT_INSTALLED } from '../../consts'; interface TimeRangeDropdownProps { timespan: number; @@ -28,7 +30,11 @@ const TimeRangeDropdown: React.FC = ({ [setTimespan], ); const { t } = useTranslation('plugin__pipelines-console-plugin'); - const timeRangeOptions = TimeRangeOptions(); + const isTektonResultEnabled = useFlag(FLAG_PIPELINE_TEKTON_RESULT_INSTALLED); + + const timeRangeOptions = isTektonResultEnabled + ? TimeRangeOptions() + : TimeRangeOptionsK8s(); return (
diff --git a/src/components/pipelines-overview/__tests__/PipelinesOverview.spec.tsx b/src/components/pipelines-overview/__tests__/PipelinesOverview.spec.tsx index dbebdb56..016ff926 100644 --- a/src/components/pipelines-overview/__tests__/PipelinesOverview.spec.tsx +++ b/src/components/pipelines-overview/__tests__/PipelinesOverview.spec.tsx @@ -5,6 +5,7 @@ import { useK8sWatchResource, useActiveColumns, useActiveNamespace, + useFlag, } from '@openshift-console/dynamic-plugin-sdk'; import PipelinesOverviewPage from '../PipelinesOverviewPage'; import { getResultsSummary } from '../../utils/summary-api'; @@ -18,6 +19,7 @@ jest.mock('@openshift-console/dynamic-plugin-sdk', () => ({ useActiveColumns: jest.fn(), VirtualizedTable: jest.fn(), k8sGet: jest.fn(), + useFlag: jest.fn(), })); jest.mock('../../utils/tekton-results', () => ({ createTektonResultsSummaryUrl: jest.fn(), @@ -37,6 +39,7 @@ const useActiveNamespaceMock = useActiveNamespace as jest.Mock; const useK8sWatchResourceMock = useK8sWatchResource as jest.Mock; const useActiveColumnsMock = useActiveColumns as jest.Mock; const getResultsSummaryMock = getResultsSummary as jest.Mock; +const useFlagMock = useFlag as jest.Mock; describe('Pipeline Overview page', () => { beforeEach(() => { @@ -48,6 +51,7 @@ describe('Pipeline Overview page', () => { useK8sWatchResourceMock.mockReturnValue([[], true]); useActiveColumnsMock.mockReturnValue([[]]); getResultsSummaryMock.mockReturnValue(Promise.resolve({})); + useFlagMock.mockReturnValue(true); }); it('should render Pipeline Overview', async () => { diff --git a/src/components/pipelines-overview/dateTime.ts b/src/components/pipelines-overview/dateTime.ts index ebcea892..be8bfae1 100644 --- a/src/components/pipelines-overview/dateTime.ts +++ b/src/components/pipelines-overview/dateTime.ts @@ -161,6 +161,19 @@ export const formatTime = (time: string): string => { return timestring; }; +export const secondsToHms = (seconds: number): string => { + const h = Math.floor(seconds / 3600) + .toString() + .padStart(2, '0'); + const m = Math.floor((seconds % 3600) / 60) + .toString() + .padStart(2, '0'); + const s = Math.floor(seconds % 60) + .toString() + .padStart(2, '0'); + return `${h}:${m}:${s}`; +}; + export const formatTimeLastRunTime = (time: number): string => { if (!time) { return '-'; @@ -206,7 +219,9 @@ export const formatDate = (date: Date) => { export const timeToMinutes = (timeString: string): number => { // Parse the time string const match = timeString?.split(/[:]+/); - + if (!timeString) { + return null; + } if (match) { // Extract components const hours = parseInt(match[0]); diff --git a/src/components/pipelines-overview/index.ts b/src/components/pipelines-overview/index.ts index d1dcd739..e87504fd 100644 --- a/src/components/pipelines-overview/index.ts +++ b/src/components/pipelines-overview/index.ts @@ -1 +1,2 @@ export { default as PipelinesOverviewPage } from './PipelinesOverviewPage'; +export { default as PipelinesOverviewPageK8s } from './PipelinesOverviewPageK8s'; diff --git a/src/components/pipelines-overview/list-pages/PipelineRunsForPipelinesList.tsx b/src/components/pipelines-overview/list-pages/PipelineRunsForPipelinesList.tsx index d12b6c06..789d536a 100644 --- a/src/components/pipelines-overview/list-pages/PipelineRunsForPipelinesList.tsx +++ b/src/components/pipelines-overview/list-pages/PipelineRunsForPipelinesList.tsx @@ -21,11 +21,12 @@ type PipelineRunsForPipelinesListProps = { summaryData: SummaryProps[]; summaryDataFiltered?: SummaryProps[]; loaded: boolean; + hideLastRunTime?: boolean; }; const PipelineRunsForPipelinesList: React.FC< PipelineRunsForPipelinesListProps -> = ({ summaryData, summaryDataFiltered, loaded }) => { +> = ({ summaryData, summaryDataFiltered, loaded, hideLastRunTime }) => { const { t } = useTranslation('plugin__pipelines-console-plugin'); const EmptyMsg = () => ( @@ -33,8 +34,8 @@ const PipelineRunsForPipelinesList: React.FC< ); - const plrColumns = React.useMemo[]>( - () => [ + const plrColumns = React.useMemo[]>(() => { + const columns: TableColumn[] = [ { id: 'pipelineName', title: t('Pipeline'), @@ -98,17 +99,20 @@ const PipelineRunsForPipelinesList: React.FC< }, }, }, - { + ]; + if (!hideLastRunTime) { + columns.push({ id: 'lastRunTime', title: t('Last run time'), sort: (summary, direction: 'asc' | 'desc') => sortByTimestamp(summary, 'last_runtime', direction), transforms: [sortable], props: { className: tableColumnClasses[6] }, - }, - ], - [t], - ); + }); + } + + return columns; + }, [t, hideLastRunTime]); const [columns] = useActiveColumns({ columns: plrColumns, @@ -125,6 +129,7 @@ const PipelineRunsForPipelinesList: React.FC< loadError={false} unfilteredData={summaryData} EmptyMsg={EmptyMsg} + rowData={{ hideLastRunTime }} /> ); }; diff --git a/src/components/pipelines-overview/list-pages/PipelineRunsForPipelinesRow.tsx b/src/components/pipelines-overview/list-pages/PipelineRunsForPipelinesRow.tsx index 6a6ebf6f..b5c9406b 100644 --- a/src/components/pipelines-overview/list-pages/PipelineRunsForPipelinesRow.tsx +++ b/src/components/pipelines-overview/list-pages/PipelineRunsForPipelinesRow.tsx @@ -16,9 +16,14 @@ import { formatTime, formatTimeLastRunTime } from '../dateTime'; import { ALL_NAMESPACES_KEY } from '../../../consts'; import { PipelineModel, PipelineModelV1Beta1 } from '../../../models'; -const PipelineRunsForPipelinesRow: React.FC> = ({ - obj, -}) => { +const PipelineRunsForPipelinesRow: React.FC< + RowProps< + SummaryProps, + { + hideLastRunTime?: boolean; + } + > +> = ({ obj, rowData: { hideLastRunTime } }) => { const [activeNamespace] = useActiveNamespace(); const [namespace, name] = obj.group_value.split('/'); const clusterVersion = (window as any).SERVER_FLAGS?.releaseVersion; @@ -58,9 +63,11 @@ const PipelineRunsForPipelinesRow: React.FC> = ({ {`${Math.round( (100 * obj.succeeded) / obj.total, )}%`} - {`${formatTimeLastRunTime( - obj.last_runtime, - )}`} + {!hideLastRunTime && ( + {`${formatTimeLastRunTime( + obj.last_runtime, + )}`} + )} ); }; diff --git a/src/components/pipelines-overview/list-pages/PipelineRunsListPageK8s.tsx b/src/components/pipelines-overview/list-pages/PipelineRunsListPageK8s.tsx new file mode 100644 index 00000000..f0c97bca --- /dev/null +++ b/src/components/pipelines-overview/list-pages/PipelineRunsListPageK8s.tsx @@ -0,0 +1,263 @@ +import * as React from 'react'; +import * as classNames from 'classnames'; +import { Card, CardBody, Grid, GridItem } from '@patternfly/react-core'; +import { PrometheusResponse } from '@openshift-console/dynamic-plugin-sdk'; +import PipelineRunsForRepositoriesList from './PipelineRunsForRepositoriesList'; +import PipelineRunsForPipelinesList from './PipelineRunsForPipelinesList'; +import SearchInputField from '../SearchInput'; +import { isMatchingFirstTickValue, useQueryParams } from '../utils'; +import { ALL_NAMESPACES_KEY } from '../../../consts'; +import { + usePipelineMetricsForAllNamespacePoll, + usePipelineMetricsForNamespacePoll, +} from '../../pipelines-metrics/hooks'; +import { + MetricsQueryPrefix, + PipelineQuery, +} from '../../pipelines-metrics/utils'; +import { getXaxisValues, secondsToHms } from '../dateTime'; + +type PipelineRunsListPageProps = { + bordered?: boolean; + namespace: string; + timespan: number; + interval: number; +}; + +const processData = ( + countData: PrometheusResponse, + durationData: PrometheusResponse, + tickValues: number[] | Date[], + type: string, +) => { + if (!countData?.data?.result || !durationData?.data?.result) { + return []; + } + const firstTickValue = tickValues[0]; + const grouped: { + [key: string]: { + group_value: string; + total: number; + succeeded: number; + total_duration: number; + }; + } = {}; + + countData?.data?.result?.forEach((item) => { + const { namespace, pipeline, status } = item.metric; + if (pipeline === 'anonymous' || !pipeline) return; + + const key = `${namespace}/${pipeline}`; + const lastTimestamp = item.values[item.values.length - 1][0]; + const lastValue = parseInt(item.values[item.values.length - 1][1], 10); + + const isMatch = isMatchingFirstTickValue( + firstTickValue, + lastTimestamp, + type, + ); + if (isMatch) return; + + if (!grouped[key]) { + grouped[key] = { + group_value: key, + total: 0, + succeeded: 0, + total_duration: 0, + }; + } + + grouped[key].total += lastValue; + + if (status === 'success') { + grouped[key].succeeded += lastValue; + } + }); + + durationData?.data?.result?.forEach((item) => { + const { namespace, pipeline } = item.metric; + if (pipeline === 'anonymous' || !pipeline) return; + + const key = `${namespace}/${pipeline}`; + const lastTimestamp = item.values[item.values.length - 1][0]; + const lastDuration = parseFloat(item.values[item.values.length - 1][1]); + + const isMatch = isMatchingFirstTickValue( + firstTickValue, + lastTimestamp, + type, + ); + if (isMatch) return; + + if (!grouped[key]) { + grouped[key] = { + group_value: key, + total: 0, + succeeded: 0, + total_duration: 0, + }; + } + + grouped[key].total_duration += lastDuration; + }); + + return Object.values(grouped).map((group) => { + const avgDuration = + group.total > 0 ? group.total_duration / group.total : 0; + return { + ...group, + total_duration: secondsToHms(group.total_duration), + avg_duration: secondsToHms(avgDuration), + }; + }); +}; + +const PipelineRunsListPageK8s: React.FC = ({ + bordered, + namespace, + timespan, + interval, +}) => { + const [pageFlag, setPageFlag] = React.useState(1); + const [searchText, setSearchText] = React.useState(''); + const [tickValues, type] = getXaxisValues(timespan); + + const [pipelineRunsMetricsCountData] = + namespace == ALL_NAMESPACES_KEY + ? usePipelineMetricsForAllNamespacePoll({ + timespan, + delay: interval, + queryPrefix: MetricsQueryPrefix.TEKTON_PIPELINES_CONTROLLER, + metricsQuery: + PipelineQuery.PIPELINERUN_COUNT_WITH_METRIC_FOR_ALL_NAMESPACE, + }) + : usePipelineMetricsForNamespacePoll({ + namespace, + timespan, + delay: interval, + queryPrefix: MetricsQueryPrefix.TEKTON_PIPELINES_CONTROLLER, + metricsQuery: + PipelineQuery.PIPELINERUN_COUNT_WITH_METRIC_FOR_NAMESPACE, + }); + + const [pipelineRunsMetricsSumData] = + namespace == ALL_NAMESPACES_KEY + ? usePipelineMetricsForAllNamespacePoll({ + timespan, + delay: interval, + queryPrefix: MetricsQueryPrefix.TEKTON_PIPELINES_CONTROLLER, + metricsQuery: + PipelineQuery.PIPELINERUN_SUM_WITH_METRIC_FOR_ALL_NAMESPACE, + }) + : usePipelineMetricsForNamespacePoll({ + namespace, + timespan, + delay: interval, + queryPrefix: MetricsQueryPrefix.TEKTON_PIPELINES_CONTROLLER, + metricsQuery: PipelineQuery.PIPELINERUN_SUM_WITH_METRIC_FOR_NAMESPACE, + }); + + const summaryDataK8s = React.useMemo(() => { + return processData( + pipelineRunsMetricsCountData, + pipelineRunsMetricsSumData, + tickValues, + type, + ); + }, [pipelineRunsMetricsCountData, pipelineRunsMetricsSumData]); + + const summaryDataFiltered = React.useMemo(() => { + return summaryDataK8s.filter((summary) => + summary.group_value + .split('/')[1] + .toLowerCase() + .includes(searchText.toLowerCase()), + ); + }, [searchText, summaryDataK8s]); + + useQueryParams({ + key: 'search', + value: searchText, + setValue: setSearchText, + defaultValue: '', + }); + + useQueryParams({ + key: 'list', + value: pageFlag, + setValue: setPageFlag, + defaultValue: 1, + options: { perpipeline: 1, perrepository: 2 }, + displayFormat: (v) => (v == 1 ? 'perpipeline' : 'perrepository'), + loadFormat: (v) => (v == 'perrepository' ? 2 : 1), + }); + + // const handlePageChange = (pageNumber: number) => { + // setloaded(false); + // setSummaryData([]); + // setSummaryDataFiltered([]); + // setPageFlag(pageNumber); + // }; + const handleNameChange = (value: string) => { + setSearchText(value); + }; + return ( + + + + + {/* Lastrun Status is not provided by API */} + {/* */} + + + {/* + Since Pipeline metrics for PAC is not available, commenting this + + + handlePageChange(1)} + /> + handlePageChange(2)} + /> + + */} + + + + {pageFlag === 1 ? ( + + ) : ( + + )} + + + + + ); +}; + +export default PipelineRunsListPageK8s; diff --git a/src/components/pipelines-overview/utils.ts b/src/components/pipelines-overview/utils.ts index e3be3562..a71d428a 100644 --- a/src/components/pipelines-overview/utils.ts +++ b/src/components/pipelines-overview/utils.ts @@ -2,11 +2,13 @@ import { K8sGroupVersionKind, K8sModel, K8sResourceKindReference, + PrometheusResponse, } from '@openshift-console/dynamic-plugin-sdk'; import * as React from 'react'; import { useTranslation } from 'react-i18next'; import { useHistory } from 'react-router-dom'; import { ALL_NAMESPACES_KEY } from '../../consts'; +import { adjustToStartOfWeek } from '../pipelines-metrics/utils'; export const alphanumericCompare = (a: string, b: string): number => { return a.localeCompare(b, undefined, { @@ -58,6 +60,16 @@ export const TimeRangeOptions = () => { }; }; +export const TimeRangeOptionsK8s = () => { + const { t } = useTranslation('plugin__pipelines-console-plugin'); + return { + '1d': t('Last day'), + '2w': t('Last weeks'), + '4w 2d': t('Last month'), + '12w': t('Last quarter'), + }; +}; + export const StatusOptions = () => { const { t } = useTranslation('plugin__pipelines-console-plugin'); return { @@ -386,3 +398,154 @@ export const formatNamespaceRoute = ( return path; }; + +export const isMatchingFirstTickValue = ( + firstTickValue: number | Date, + firstPrometheusValue: number | Date, + type: string, +) => { + const group_date = new Date(Number(firstPrometheusValue) * 1000); + const tickDate = + typeof firstTickValue === 'number' + ? new Date(firstTickValue * 1000) + : firstTickValue; + let isMatch = false; + if (type === 'hour') { + isMatch = group_date.getHours() === tickDate.getHours(); + } else if (type === 'week') { + const adjustedGroupDate = adjustToStartOfWeek(new Date(group_date)); + isMatch = adjustedGroupDate.toDateString() === tickDate.toDateString(); + } else if (type === 'day') { + isMatch = group_date.toDateString() === tickDate.toDateString(); + } else if (type === 'month') { + isMatch = group_date.getMonth() === tickDate.getMonth(); + } + return isMatch; +}; + +export const getTotalPipelineRuns = ( + prometheusResult: PrometheusResponse, + tickValues: number[] | Date[], + type: string, +): number => { + let totalPLRCount = 0; + + if (prometheusResult?.data?.result[0]?.values) { + const values = prometheusResult.data.result[0].values; + let lastValue = parseInt(values[0][1], 10); + let hasDecrement = false; + let sum = 0; + + for (let i = 1; i < values.length; i++) { + const currentValue = parseInt(values[i][1], 10); + if (currentValue < lastValue) { + hasDecrement = true; + sum += lastValue; + } + lastValue = currentValue; + } + + // If there's any decrement, add the last value to the sum + if (hasDecrement) { + sum += lastValue; + totalPLRCount = sum; + } else { + // If no decrement, just take the last value + totalPLRCount = lastValue; + } + + // Check if the first tick element matches the first Prometheus value + const firstTickValue = tickValues[0]; + const firstPrometheusValue = values[0]; + const isMatch = isMatchingFirstTickValue( + firstTickValue, + firstPrometheusValue[0], + type, + ); + if (isMatch) { + totalPLRCount -= parseInt(firstPrometheusValue[1], 10); + } + } + + return totalPLRCount; +}; + +const formatDuration = (seconds: number): string => { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const remainingSeconds = Math.floor(seconds % 60); + + let formatted = ''; + if (hours > 0) { + formatted += `${hours}h `; + } + if (minutes > 0 || hours > 0) { + formatted += `${minutes}m `; + } + formatted += `${remainingSeconds}s`; + + return formatted.trim(); +}; + +export const getTotalPipelineRunsDuration = ( + prometheusResult: PrometheusResponse, + tickValues: number[] | Date[], + type: string, +): [string, number] => { + let totalPLRDuration = 0; + + if (prometheusResult?.data?.result[0]?.values) { + const values = prometheusResult.data.result[0].values; + + // Calculate total duration and check for decrements + let lastValue = parseFloat(values[0][1]); + let hasDecrement = false; + let sum = 0; + + for (let i = 1; i < values.length; i++) { + const currentValue = parseFloat(values[i][1]); + if (currentValue < lastValue) { + hasDecrement = true; + sum += lastValue; + } + lastValue = currentValue; + } + + // If there's any decrement, add the last value to the sum + if (hasDecrement) { + sum += lastValue; + totalPLRDuration = sum; + } else { + // If no decrement, just take the last value + totalPLRDuration = lastValue; + } + + // Adjust total duration if the first tick element matches the first Prometheus value + const firstTickValue = tickValues[0]; + const firstPrometheusValue = values[0]; + const isMatch = isMatchingFirstTickValue( + firstTickValue, + firstPrometheusValue[0], + type, + ); + if (isMatch) { + totalPLRDuration -= parseFloat(firstPrometheusValue[1]); + } + } + + return [formatDuration(totalPLRDuration), totalPLRDuration]; +}; + +export const getPipelineRunAverageDuration = ( + totalDuration: number, + totalPLRRuns: number, +): string => { + if (totalPLRRuns === 0) return '-'; + + const averageDuration = totalDuration / totalPLRRuns; + return formatDuration(averageDuration); +}; + +export const roundToNearestSecond = (timestamp) => { + return Math.round(timestamp); +}; diff --git a/src/components/utils/__tests__/pipeline-utils.spec.ts b/src/components/utils/__tests__/pipeline-utils.spec.ts index 606dcb79..0d4c06a5 100644 --- a/src/components/utils/__tests__/pipeline-utils.spec.ts +++ b/src/components/utils/__tests__/pipeline-utils.spec.ts @@ -23,6 +23,7 @@ import { } from '../../../test-data/taskrun-test-data'; import { ComputedStatus, ContainerStatus } from '../../../types'; import { + LatestPipelineRunStatus, appendPipelineRunStatus, containerToLogSourceStatus, getImageUrl, @@ -35,7 +36,6 @@ import { getSbomTaskRun, getSecretAnnotations, hasExternalLink, - LatestPipelineRunStatus, pipelineRunDuration, updateServiceAccount, } from '../pipeline-utils'; @@ -62,7 +62,7 @@ jest.mock('@openshift-console/dynamic-plugin-sdk'); beforeAll(() => { jest .spyOn(k8sResourceModule, 'k8sUpdate') - .mockImplementation(({ model, data }) => Promise.resolve(data)); + .mockImplementation(({ data }) => Promise.resolve(data)); }); describe('pipeline-utils ', () => { From cce473c9c8f93a2a447b162cead928b59e000907 Mon Sep 17 00:00:00 2001 From: Lokananda Prabhu Date: Wed, 31 Jul 2024 18:52:01 +0530 Subject: [PATCH 2/3] Added info alert for overview and metrics tab for k8s data --- .../en/plugin__pipelines-console-plugin.json | 3 +++ .../pipelines-metrics/PipelinesMetrics.scss | 7 ++++++ .../PipelinesMetricsPageK8s.tsx | 4 ++++ .../K8sDataLimitationAlert.tsx | 23 +++++++++++++++++++ .../pipelines-overview/PipelinesOverview.scss | 6 +++++ .../PipelinesOverviewPageK8s.tsx | 5 ++++ 6 files changed, 48 insertions(+) create mode 100644 src/components/pipelines-overview/K8sDataLimitationAlert.tsx diff --git a/locales/en/plugin__pipelines-console-plugin.json b/locales/en/plugin__pipelines-console-plugin.json index 9da3944f..797d2282 100644 --- a/locales/en/plugin__pipelines-console-plugin.json +++ b/locales/en/plugin__pipelines-console-plugin.json @@ -101,6 +101,7 @@ "Custom Task": "Custom Task", "CustomRun": "CustomRun", "CustomRuns": "CustomRuns", + "Data is incomplete. To see the full view, please enable ": "Data is incomplete. To see the full view, please enable ", "Data source": "Data source", "Decrement": "Decrement", "Default value": "Default value", @@ -172,6 +173,7 @@ "Image Registry": "Image Registry", "Image Registry Credentials": "Image Registry Credentials", "Increment": "Increment", + "Info": "Info", "Init containers": "Init containers", "Install Cosign": "Install Cosign", "Installing": "Installing", @@ -371,6 +373,7 @@ "TaskRun name": "TaskRun name", "TaskRuns": "TaskRuns", "Tasks": "Tasks", + "Tekton results": "Tekton results", "TektonResult": "TektonResult", "TektonResults": "TektonResults", "The base server url (e.g. https://github.com)": "The base server url (e.g. https://github.com)", diff --git a/src/components/pipelines-metrics/PipelinesMetrics.scss b/src/components/pipelines-metrics/PipelinesMetrics.scss index 4064ef13..768de1aa 100644 --- a/src/components/pipelines-metrics/PipelinesMetrics.scss +++ b/src/components/pipelines-metrics/PipelinesMetrics.scss @@ -25,3 +25,10 @@ } } } + +.k8s-overview-info-alert { + margin-top: var(--pf-v5-global--spacer--md); + margin-left: var(--pf-v5-global--spacer--md); + margin-right: var(--pf-v5-global--spacer--md); + margin-bottom: 0; +} diff --git a/src/components/pipelines-metrics/PipelinesMetricsPageK8s.tsx b/src/components/pipelines-metrics/PipelinesMetricsPageK8s.tsx index dbbd9cb0..ee71b9ca 100644 --- a/src/components/pipelines-metrics/PipelinesMetricsPageK8s.tsx +++ b/src/components/pipelines-metrics/PipelinesMetricsPageK8s.tsx @@ -17,6 +17,7 @@ import PipelineRunsStatusCardK8s from '../pipelines-overview/PipelineRunsStatusC import PipelineRunsNumbersChartK8s from '../pipelines-overview/PipelineRunsNumbersChartK8s'; import PipelineRunsDurationCardK8s from '../pipelines-overview/PipelineRunsDurationCardK8s'; import PipelinesAverageDurationK8s from './PipelinesAverageDurationK8s'; +import { K8sDataLimitationAlert } from '../pipelines-overview/K8sDataLimitationAlert'; type PipelinesMetricsPageProps = { obj: PipelineKind; @@ -55,6 +56,9 @@ const PipelinesMetricsPageK8s: React.FC = ({ return ( <> +
+ +
diff --git a/src/components/pipelines-overview/K8sDataLimitationAlert.tsx b/src/components/pipelines-overview/K8sDataLimitationAlert.tsx new file mode 100644 index 00000000..9ee61d5e --- /dev/null +++ b/src/components/pipelines-overview/K8sDataLimitationAlert.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Alert } from '@patternfly/react-core'; + +export const K8sDataLimitationAlert: React.FC = () => { + const { t } = useTranslation('plugin__pipelines-console-plugin'); + return ( + +

+ {t('Data is incomplete. To see the full view, please enable ')} + + {t('Tekton results')} + + + . +

+
+ ); +}; diff --git a/src/components/pipelines-overview/PipelinesOverview.scss b/src/components/pipelines-overview/PipelinesOverview.scss index 1ade8f54..0aecc8cb 100644 --- a/src/components/pipelines-overview/PipelinesOverview.scss +++ b/src/components/pipelines-overview/PipelinesOverview.scss @@ -149,3 +149,9 @@ padding-top: var(--pf-v5-global--spacer--md); padding-bottom: var(--pf-v5-global--spacer--sm); } + +.k8s-overview-info-alert { + margin-bottom: var(--pf-v5-global--spacer--md); + margin-left: var(--pf-v5-global--spacer--md); + margin-right: var(--pf-v5-global--spacer--md); +} diff --git a/src/components/pipelines-overview/PipelinesOverviewPageK8s.tsx b/src/components/pipelines-overview/PipelinesOverviewPageK8s.tsx index 0f868d1c..60e95872 100644 --- a/src/components/pipelines-overview/PipelinesOverviewPageK8s.tsx +++ b/src/components/pipelines-overview/PipelinesOverviewPageK8s.tsx @@ -12,6 +12,8 @@ import PipelineRunsNumbersChartK8s from './PipelineRunsNumbersChartK8s'; import PipelineRunsTotalCardK8s from './PipelineRunsTotalCardK8s'; import PipelineRunsDurationCardK8s from './PipelineRunsDurationCardK8s'; import PipelineRunsListPageK8s from './list-pages/PipelineRunsListPageK8s'; +import { K8sDataLimitationAlert } from './K8sDataLimitationAlert'; +import './PipelinesOverview.scss'; const PipelinesOverviewPageK8s: React.FC = () => { const { t } = useTranslation('plugin__pipelines-console-plugin'); @@ -52,6 +54,9 @@ const PipelinesOverviewPageK8s: React.FC = () => { {t('Overview')}
+
+ +
From 5309cd7cdccd2a94253d0d8a5120160e37dd818b Mon Sep 17 00:00:00 2001 From: Lokananda Prabhu Date: Wed, 7 Aug 2024 19:01:49 +0530 Subject: [PATCH 3/3] Handled deleted resources --- .../en/plugin__pipelines-console-plugin.json | 2 + src/components/common/error.tsx | 25 +++ .../pipelines-details/PipelineDetailsPage.tsx | 5 +- .../PipelineRunsForPipelinesListK8s.tsx | 147 ++++++++++++++++++ .../PipelineRunsForPipelinesRowK8s.tsx | 120 ++++++++++++++ .../list-pages/PipelineRunsListPageK8s.tsx | 17 +- 6 files changed, 311 insertions(+), 5 deletions(-) create mode 100644 src/components/common/error.tsx create mode 100644 src/components/pipelines-overview/list-pages/PipelineRunsForPipelinesListK8s.tsx create mode 100644 src/components/pipelines-overview/list-pages/PipelineRunsForPipelinesRowK8s.tsx diff --git a/locales/en/plugin__pipelines-console-plugin.json b/locales/en/plugin__pipelines-console-plugin.json index 797d2282..3dff8043 100644 --- a/locales/en/plugin__pipelines-console-plugin.json +++ b/locales/en/plugin__pipelines-console-plugin.json @@ -249,6 +249,7 @@ "Output": "Output", "Overview": "Overview", "Owner": "Owner", + "Page Not Found (404)": "Page Not Found (404)", "Parameters": "Parameters", "Partially approved": "Partially approved", "Password": "Password", @@ -304,6 +305,7 @@ "Rerun": "Rerun", "Reset": "Reset", "Resource is being fetched from Tekton Results.": "Resource is being fetched from Tekton Results.", + "Resource is deleted.": "Resource is deleted.", "Route": "Route", "Routes": "Routes", "run{{plural}} in other namespaces.": "run{{plural}} in other namespaces.", diff --git a/src/components/common/error.tsx b/src/components/common/error.tsx new file mode 100644 index 00000000..ef9175ab --- /dev/null +++ b/src/components/common/error.tsx @@ -0,0 +1,25 @@ +import * as React from 'react'; +import { Helmet } from 'react-helmet'; +import { useTranslation } from 'react-i18next'; +import { + EmptyState, + EmptyStateHeader, + EmptyStateVariant, +} from '@patternfly/react-core'; + +export const ErrorPage404: React.FC = () => { + const { t } = useTranslation('plugin__pipelines-console-plugin'); + return ( +
+ + {t('Page Not Found (404)')} + + + + +
+ ); +}; diff --git a/src/components/pipelines-details/PipelineDetailsPage.tsx b/src/components/pipelines-details/PipelineDetailsPage.tsx index 028a3fda..9af0abcf 100644 --- a/src/components/pipelines-details/PipelineDetailsPage.tsx +++ b/src/components/pipelines-details/PipelineDetailsPage.tsx @@ -33,6 +33,7 @@ import { triggerPipeline } from '../pipelines-list/PipelineKebab'; import { StartedByAnnotation } from '../../consts'; import { usePipelineTriggerTemplateNames } from '../utils/triggers'; import { resourcePathFromModel } from '../utils/utils'; +import { ErrorPage404 } from '../common/error'; const PipelineDetailsPage = () => { const { t } = useTranslation('plugin__pipelines-console-plugin'); @@ -40,7 +41,7 @@ const PipelineDetailsPage = () => { const history = useHistory(); const navigate = useNavigate(); const { name, ns: namespace } = params; - const [pipeline, loaded] = useK8sWatchResource({ + const [pipeline, loaded, loadError] = useK8sWatchResource({ groupVersionKind: getGroupVersionKindForModel(PipelineModel), namespace, name, @@ -125,7 +126,7 @@ const PipelineDetailsPage = () => { }; if (!loaded) { - return ; + return loadError ? : ; } return ( = ({ + summaryData, + summaryDataFiltered, + loaded, + hideLastRunTime, + projects, + projectsLoaded, +}) => { + const { t } = useTranslation('plugin__pipelines-console-plugin'); + const EmptyMsg = () => ( + + {t('No PipelineRuns found')} + + ); + + const plrColumns = React.useMemo[]>(() => { + const columns: TableColumn[] = [ + { + id: 'pipelineName', + title: t('Pipeline'), + sort: (summary, direction: 'asc' | 'desc') => + sortByProperty(summary, 'pipelineName', direction), + transforms: [sortable], + props: { className: tableColumnClasses[0] }, + }, + { + id: 'namespace', + title: t('Project'), + sort: (summary, direction: 'asc' | 'desc') => + sortByProperty(summary, 'namespace', direction), + transforms: [sortable], + props: { className: tableColumnClasses[1] }, + }, + { + id: 'total', + title: t('Total Pipelineruns'), + sort: 'total', + transforms: [sortable], + props: { className: tableColumnClasses[2] }, + }, + { + id: 'totalDuration', + title: t('Total duration'), + sort: (summary, direction: 'asc' | 'desc') => + sortTimeStrings(summary, 'total_duration', direction), + transforms: [sortable], + props: { className: tableColumnClasses[3] }, + }, + { + id: 'avgDuration', + title: t('Average duration'), + sort: (summary, direction: 'asc' | 'desc') => + sortTimeStrings(summary, 'avg_duration', direction), + transforms: [sortable], + props: { + className: tableColumnClasses[4], + info: { + tooltip: t( + 'An average of the time taken to run PipelineRuns. The trending shown is based on the time range selected. This metric does not show runs that are running or pending.', + ), + className: 'pipeline-overview__for-pipelines-list__tooltip', + }, + }, + }, + { + id: 'successRate', + title: t('Success rate'), + sort: (summary, direction: 'asc' | 'desc') => + sortByNumbers(summary, 'succeeded', direction), + transforms: [sortable], + props: { + className: tableColumnClasses[5], + info: { + tooltip: t( + 'Success rate measure the % of successfully completed pipeline runs in relation to the total number of pipeline runs', + ), + className: 'pipeline-overview__for-pipelines-list__tooltip', + }, + }, + }, + ]; + if (!hideLastRunTime) { + columns.push({ + id: 'lastRunTime', + title: t('Last run time'), + sort: (summary, direction: 'asc' | 'desc') => + sortByTimestamp(summary, 'last_runtime', direction), + transforms: [sortable], + props: { className: tableColumnClasses[6] }, + }); + } + + return columns; + }, [t, hideLastRunTime]); + + const [columns] = useActiveColumns({ + columns: plrColumns, + showNamespaceOverride: false, + columnManagementID: '', + }); + + return ( + + ); +}; + +export default PipelineRunsForPipelinesListK8s; diff --git a/src/components/pipelines-overview/list-pages/PipelineRunsForPipelinesRowK8s.tsx b/src/components/pipelines-overview/list-pages/PipelineRunsForPipelinesRowK8s.tsx new file mode 100644 index 00000000..4d22c2b0 --- /dev/null +++ b/src/components/pipelines-overview/list-pages/PipelineRunsForPipelinesRowK8s.tsx @@ -0,0 +1,120 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import * as React from 'react'; +import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { Tooltip } from '@patternfly/react-core'; +import { + SummaryProps, + getReferenceForModel, + listPageTableColumnClasses as tableColumnClasses, +} from '../utils'; +import { + ResourceIcon, + ResourceLink, + RowProps, + getGroupVersionKindForModel, + useActiveNamespace, +} from '@openshift-console/dynamic-plugin-sdk'; +import { formatTime, formatTimeLastRunTime } from '../dateTime'; +import { ALL_NAMESPACES_KEY } from '../../../consts'; +import { + PipelineModel, + PipelineModelV1Beta1, + ProjectModel, +} from '../../../models'; +import { Project } from '../../../types'; + +const PipelineRunsForPipelinesRowK8s: React.FC< + RowProps< + SummaryProps, + { + hideLastRunTime?: boolean; + projects: Project[]; + projectsLoaded: boolean; + } + > +> = ({ obj, rowData: { hideLastRunTime, projects, projectsLoaded } }) => { + const { t } = useTranslation('plugin__pipelines-console-plugin'); + const [activeNamespace] = useActiveNamespace(); + const [namespace, name] = obj.group_value.split('/'); + const clusterVersion = (window as any).SERVER_FLAGS?.releaseVersion; + const isV1SupportCluster = + clusterVersion?.split('.')[0] === '4' && + clusterVersion?.split('.')[1] > '13'; + const pipelineReference = getReferenceForModel( + isV1SupportCluster ? PipelineModel : PipelineModelV1Beta1, + ); + const projectReference = getReferenceForModel(ProjectModel); + + const isNamespaceExists = (namespaceName: string) => { + if (!projectsLoaded) { + return false; + } + return projects.some( + (project) => + project?.metadata && project?.metadata?.name === namespaceName, + ); + }; + + return ( + <> + + {isNamespaceExists(namespace) ? ( + + ) : ( + + + + {name} + + + )} + + {activeNamespace === ALL_NAMESPACES_KEY && ( + + {isNamespaceExists(namespace) ? ( + + ) : ( + + + + {namespace} + + + )} + + )} + + {isNamespaceExists(namespace) ? ( + + {obj.total} + + ) : ( + {obj.total} + )} + + + {formatTime(obj.total_duration)} + + {formatTime(obj.avg_duration)} + {`${Math.round( + (100 * obj.succeeded) / obj.total, + )}%`} + {!hideLastRunTime && ( + {`${formatTimeLastRunTime( + obj.last_runtime, + )}`} + )} + + ); +}; + +export default PipelineRunsForPipelinesRowK8s; diff --git a/src/components/pipelines-overview/list-pages/PipelineRunsListPageK8s.tsx b/src/components/pipelines-overview/list-pages/PipelineRunsListPageK8s.tsx index f0c97bca..41cc2423 100644 --- a/src/components/pipelines-overview/list-pages/PipelineRunsListPageK8s.tsx +++ b/src/components/pipelines-overview/list-pages/PipelineRunsListPageK8s.tsx @@ -1,9 +1,12 @@ import * as React from 'react'; import * as classNames from 'classnames'; import { Card, CardBody, Grid, GridItem } from '@patternfly/react-core'; -import { PrometheusResponse } from '@openshift-console/dynamic-plugin-sdk'; +import { + PrometheusResponse, + useK8sWatchResource, +} from '@openshift-console/dynamic-plugin-sdk'; import PipelineRunsForRepositoriesList from './PipelineRunsForRepositoriesList'; -import PipelineRunsForPipelinesList from './PipelineRunsForPipelinesList'; +import PipelineRunsForPipelinesListK8s from './PipelineRunsForPipelinesListK8s'; import SearchInputField from '../SearchInput'; import { isMatchingFirstTickValue, useQueryParams } from '../utils'; import { ALL_NAMESPACES_KEY } from '../../../consts'; @@ -16,6 +19,7 @@ import { PipelineQuery, } from '../../pipelines-metrics/utils'; import { getXaxisValues, secondsToHms } from '../dateTime'; +import { Project } from '../../../types'; type PipelineRunsListPageProps = { bordered?: boolean; @@ -122,6 +126,11 @@ const PipelineRunsListPageK8s: React.FC = ({ const [searchText, setSearchText] = React.useState(''); const [tickValues, type] = getXaxisValues(timespan); + const [projects, projectsLoaded] = useK8sWatchResource({ + isList: true, + kind: 'Project', + optional: true, + }); const [pipelineRunsMetricsCountData] = namespace == ALL_NAMESPACES_KEY ? usePipelineMetricsForAllNamespacePoll({ @@ -240,11 +249,13 @@ const PipelineRunsListPageK8s: React.FC = ({ {pageFlag === 1 ? ( - ) : (