Skip to content

Commit bc909c3

Browse files
committed
Health overview dashboard card
Signed-off-by: Arun Kumar Mohan <[email protected]>
1 parent 782be0e commit bc909c3

File tree

4 files changed

+363
-0
lines changed

4 files changed

+363
-0
lines changed

locales/en/plugin__odf-console.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@
188188
"Action needed": "Action needed",
189189
"Actions": "Actions",
190190
"Active health checks": "Active health checks",
191+
"Active issues": "Active issues",
191192
"Actively monitoring, waiting for trigger": "Actively monitoring, waiting for trigger",
192193
"Activity": "Activity",
193194
"Activity description": "Activity description",
@@ -1023,6 +1024,9 @@
10231024
"Headers that can be sent by the client in the request.": "Headers that can be sent by the client in the request.",
10241025
"Headers that should be exposed to the browser.": "Headers that should be exposed to the browser.",
10251026
"Health": "Health",
1027+
"Health check metrics are being collected. This may take a few moments.": "Health check metrics are being collected. This may take a few moments.",
1028+
"Health Score": "Health Score",
1029+
"Health Score Over Time": "Health Score Over Time",
10261030
"Healthy": "Healthy",
10271031
"Help": "Help",
10281032
"Hide message": "Hide message",
@@ -1119,6 +1123,7 @@
11191123
"Labels help you organize and select resources. Adding labels below will let you query for objects that have similar, overlapping or dissimilar labels.": "Labels help you organize and select resources. Adding labels below will let you query for objects that have similar, overlapping or dissimilar labels.",
11201124
"Labels must start and end with an alphanumeric character, can consist of lower-case letters, numbers, dots (.), hyphens (-), forward slash (/), underscore(_) and equal to (=)": "Labels must start and end with an alphanumeric character, can consist of lower-case letters, numbers, dots (.), hyphens (-), forward slash (/), underscore(_) and equal to (=)",
11211125
"LargeScale": "LargeScale",
1126+
"Last 24 hours": "Last 24 hours",
11221127
"Last 24 hours ({{count}})_one": "Last 24 hours ({{count}})",
11231128
"Last 24 hours ({{count}})_other": "Last 24 hours ({{count}})",
11241129
"Last available: ": "Last available: ",
@@ -1276,10 +1281,12 @@
12761281
"No assigned disaster recovery policy found": "No assigned disaster recovery policy found",
12771282
"No buckets found": "No buckets found",
12781283
"No capacity data available.": "No capacity data available.",
1284+
"No chart data available": "No chart data available",
12791285
"No clusters selected": "No clusters selected",
12801286
"No common storage class found for the selected managed clusters. To create a DR policy, a common storage class must exist, if not configured already, provision a common storage class and try again.": "No common storage class found for the selected managed clusters. To create a DR policy, a common storage class must exist, if not configured already, provision a common storage class and try again.",
12811287
"no compression": "no compression",
12821288
"No conditions found": "No conditions found",
1289+
"No data": "No data",
12831290
"No data available": "No data available",
12841291
"No datapoints found.": "No datapoints found.",
12851292
"No disaster recovery policies yet": "No disaster recovery policies yet",
@@ -2042,6 +2049,7 @@
20422049
"Type: {{selectedService}}": "Type: {{selectedService}}",
20432050
"Type: {{type}}": "Type: {{type}}",
20442051
"Typeahead single select": "Typeahead single select",
2052+
"Unable to retrieve health check data.": "Unable to retrieve health check data.",
20452053
"Unable to unsilence the selected alerts": "Unable to unsilence the selected alerts",
20462054
"Unavailable": "Unavailable",
20472055
"Unblocked": "Unblocked",
@@ -2137,6 +2145,7 @@
21372145
"View documentation": "View documentation",
21382146
"View external systems": "View external systems",
21392147
"View failed objects": "View failed objects",
2148+
"View health checks": "View health checks",
21402149
"View more": "View more",
21412150
"View namespaces": "View namespaces",
21422151
"View policies": "View policies",
@@ -2166,6 +2175,7 @@
21662175
"Volumes & Kubernetes resources are syncing slower than usual": "Volumes & Kubernetes resources are syncing slower than usual",
21672176
"Volumes are syncing slower than usual": "Volumes are syncing slower than usual",
21682177
"VolumeSnapshot classes": "VolumeSnapshot classes",
2178+
"Waiting for health checks": "Waiting for health checks",
21692179
"Warning": "Warning",
21702180
"We could not retrieve any information about the managed cluster {{clusterName}}": "We could not retrieve any information about the managed cluster {{clusterName}}",
21712181
"weeks": "weeks",
Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
import * as React from 'react';
2+
import {
3+
useCustomPrometheusPoll,
4+
usePrometheusBasePath,
5+
} from '@odf/shared/hooks/custom-prometheus-poll';
6+
import useRefWidth from '@odf/shared/hooks/ref-width';
7+
import { useCustomTranslation } from '@odf/shared/useCustomTranslationHook';
8+
// import { humanizeNumber } from '@odf/shared/utils';
9+
import { PrometheusEndpoint } from '@openshift-console/dynamic-plugin-sdk';
10+
import {
11+
Chart,
12+
ChartArea,
13+
ChartAxis,
14+
ChartGroup,
15+
ChartThemeColor,
16+
ChartVoronoiContainer,
17+
} from '@patternfly/react-charts';
18+
import {
19+
Card,
20+
CardBody,
21+
CardHeader,
22+
CardTitle,
23+
Grid,
24+
GridItem,
25+
Button,
26+
EmptyState,
27+
EmptyStateVariant,
28+
EmptyStateIcon,
29+
EmptyStateBody,
30+
EmptyStateHeader,
31+
} from '@patternfly/react-core';
32+
import { ArrowRightIcon, SpaceShuttleIcon } from '@patternfly/react-icons';
33+
import {
34+
useHealthAlerts,
35+
useSilencedAlerts,
36+
filterOutSilencedAlerts,
37+
} from '../../HealthOverview/hooks';
38+
import './health-overview-card.scss';
39+
40+
// Define your Prometheus query
41+
const HEALTH_SCORE_QUERY = 'ocs_health_score';
42+
43+
type HealthDataPoint = {
44+
x: number;
45+
y: number;
46+
name: string;
47+
};
48+
49+
export const HealthOverviewCard: React.FC = () => {
50+
const { t } = useCustomTranslation();
51+
const [containerRef] = useRefWidth();
52+
const [chartRef, chartWidth] = useRefWidth();
53+
const basePath = usePrometheusBasePath();
54+
55+
// Fetch health score metric over time (range query)
56+
const [healthScoreData, healthScoreError] = useCustomPrometheusPoll({
57+
query: HEALTH_SCORE_QUERY,
58+
endpoint: PrometheusEndpoint.QUERY_RANGE,
59+
basePath,
60+
samples: 60,
61+
timespan: 86400000, // 24 hours
62+
});
63+
64+
// Fetch alerts for counting active issues by severity
65+
const [healthAlerts] = useHealthAlerts();
66+
const { silences } = useSilencedAlerts();
67+
68+
// Filter out silenced alerts and count by severity
69+
const { criticalCount, moderateCount, minorCount } = React.useMemo(() => {
70+
// Get active alerts (exclude silenced ones)
71+
const activeAlerts = filterOutSilencedAlerts(healthAlerts, silences);
72+
73+
// Count firing alerts by severity in a single pass (O(n) instead of O(4n))
74+
return activeAlerts.reduce(
75+
(acc, alert) => {
76+
// Only count firing alerts
77+
if (alert.state !== 'firing') {
78+
return acc;
79+
}
80+
81+
// Count by severity
82+
if (alert.severity === 'critical') {
83+
acc.criticalCount++;
84+
} else if (alert.severity === 'warning') {
85+
acc.moderateCount++;
86+
} else if (alert.severity === 'info') {
87+
acc.minorCount++;
88+
}
89+
90+
return acc;
91+
},
92+
{ criticalCount: 0, moderateCount: 0, minorCount: 0 }
93+
);
94+
}, [healthAlerts, silences]);
95+
96+
// Process health score data for chart
97+
const chartData: HealthDataPoint[] = React.useMemo(() => {
98+
if (!healthScoreData?.data?.result?.[0]?.values) return [];
99+
100+
return healthScoreData.data.result[0].values.map((value) => {
101+
const [timestamp, scoreValue] = value;
102+
const score = parseFloat(scoreValue);
103+
const date = new Date(timestamp * 1000);
104+
105+
return {
106+
x: timestamp * 1000, // Use milliseconds for x-axis
107+
y: score,
108+
name: `${score.toFixed(1)}% at ${date.toLocaleTimeString()}`,
109+
};
110+
});
111+
}, [healthScoreData]);
112+
113+
// Get current health score (latest value)
114+
const currentHealthScore = React.useMemo(() => {
115+
if (chartData.length === 0) return null;
116+
return chartData[chartData.length - 1].y;
117+
}, [chartData]);
118+
119+
// Use real issue counts from alerts
120+
const criticalIssues = criticalCount;
121+
const moderateIssues = moderateCount;
122+
const minorIssues = minorCount;
123+
124+
const isLoading = !healthScoreData && !healthScoreError;
125+
const hasNoData = chartData.length === 0;
126+
const showEmptyState = isLoading || healthScoreError || hasNoData;
127+
128+
if (showEmptyState) {
129+
return (
130+
<Card isFlat>
131+
<CardHeader>
132+
<CardTitle>{t('ODF infrastructure health')}</CardTitle>
133+
</CardHeader>
134+
<CardBody>
135+
<EmptyState variant={EmptyStateVariant.lg}>
136+
<EmptyStateHeader
137+
titleText={<>{t('Waiting for health checks')}</>}
138+
icon={<EmptyStateIcon icon={SpaceShuttleIcon} />}
139+
headingLevel="h4"
140+
/>
141+
<EmptyStateBody>
142+
{healthScoreError
143+
? t('Unable to retrieve health check data.')
144+
: t(
145+
'Health check metrics are being collected. This may take a few moments.'
146+
)}
147+
</EmptyStateBody>
148+
</EmptyState>
149+
</CardBody>
150+
</Card>
151+
);
152+
}
153+
154+
return (
155+
<Card isFlat className="odf-infrastructure-health-card" ref={containerRef}>
156+
<CardHeader>
157+
<CardTitle>{t('ODF infrastructure health')}</CardTitle>
158+
</CardHeader>
159+
<CardBody>
160+
<Grid hasGutter>
161+
{/* Health Score and Chart Row */}
162+
<GridItem md={4} sm={12}>
163+
<div className="odf-infrastructure-health-card__score">
164+
{currentHealthScore !== null ? (
165+
<>
166+
<div className="odf-infrastructure-health-card__score-value">
167+
{Math.round(currentHealthScore)}%
168+
</div>
169+
<div className="odf-infrastructure-health-card__score-label">
170+
{t('Health Score')}
171+
</div>
172+
</>
173+
) : (
174+
<div className="pf-v5-u-color-200">{t('No data')}</div>
175+
)}
176+
</div>
177+
</GridItem>
178+
179+
<GridItem md={8} sm={12} ref={chartRef}>
180+
{chartData.length > 0 ? (
181+
<div className="odf-infrastructure-health-card__chart">
182+
<div className="odf-infrastructure-health-card__chart-label">
183+
{t('Last 24 hours')}
184+
</div>
185+
<Chart
186+
ariaTitle={t('Health Score Over Time')}
187+
containerComponent={
188+
<ChartVoronoiContainer
189+
labels={({ datum }) => datum.name}
190+
constrainToVisibleArea
191+
/>
192+
}
193+
height={180}
194+
width={chartWidth > 0 ? chartWidth - 40 : 600}
195+
padding={{ top: 20, bottom: 40, left: 10, right: 10 }}
196+
domain={{ y: [0, 100] }}
197+
scale={{ x: 'time', y: 'linear' }}
198+
themeColor={ChartThemeColor.blue}
199+
>
200+
<ChartAxis
201+
tickFormat={(t) => {
202+
const date = new Date(t);
203+
return date.toLocaleTimeString([], {
204+
hour: '2-digit',
205+
minute: '2-digit',
206+
hour12: true,
207+
});
208+
}}
209+
fixLabelOverlap
210+
style={{
211+
axis: { stroke: 'transparent' },
212+
tickLabels: {
213+
fontSize: 10,
214+
fill: 'var(--pf-v5-global--Color--200)',
215+
},
216+
}}
217+
/>
218+
<ChartGroup>
219+
<ChartArea
220+
data={chartData}
221+
interpolation="monotoneX"
222+
style={{
223+
data: {
224+
fill: 'var(--pf-v5-chart-color-blue-300)',
225+
fillOpacity: 0.3,
226+
stroke: 'var(--pf-v5-chart-color-blue-400)',
227+
strokeWidth: 2,
228+
},
229+
}}
230+
/>
231+
</ChartGroup>
232+
</Chart>
233+
</div>
234+
) : (
235+
<div className="pf-v5-u-color-200 pf-v5-u-text-align-center">
236+
{t('No chart data available')}
237+
</div>
238+
)}
239+
</GridItem>
240+
241+
{/* Active Issues Section */}
242+
<GridItem md={12}>
243+
<div className="odf-infrastructure-health-card__section-title">
244+
{t('Active issues')}
245+
</div>
246+
</GridItem>
247+
248+
<GridItem md={4} sm={4}>
249+
<div className="odf-infrastructure-health-card__issue-count">
250+
{criticalIssues}
251+
</div>
252+
<div className="odf-infrastructure-health-card__issue-label">
253+
{t('Critical')}
254+
</div>
255+
</GridItem>
256+
257+
<GridItem md={4} sm={4}>
258+
<div className="odf-infrastructure-health-card__issue-count">
259+
{moderateIssues}
260+
</div>
261+
<div className="odf-infrastructure-health-card__issue-label">
262+
{t('Moderate')}
263+
</div>
264+
</GridItem>
265+
266+
<GridItem md={4} sm={4}>
267+
<div className="odf-infrastructure-health-card__issue-count">
268+
{minorIssues}
269+
</div>
270+
<div className="odf-infrastructure-health-card__issue-label">
271+
{t('Minor')}
272+
</div>
273+
</GridItem>
274+
275+
{/* Action Link */}
276+
<GridItem md={12}>
277+
<Button
278+
variant="link"
279+
icon={<ArrowRightIcon />}
280+
iconPosition="end"
281+
isInline
282+
component="a"
283+
href="/odf/overview/health"
284+
>
285+
{t('View health checks')}
286+
</Button>
287+
</GridItem>
288+
</Grid>
289+
</CardBody>
290+
</Card>
291+
);
292+
};

0 commit comments

Comments
 (0)