From fc0a34850f71ff64fe249379195f09ffb0e28c92 Mon Sep 17 00:00:00 2001 From: Joel Takvorian Date: Tue, 5 Aug 2025 17:33:44 +0200 Subject: [PATCH 01/21] NETOBSERV-2328: New health tab - New health tab in Network Traffic page - Based on alerts, shows a global health status - Based on alerts, shows a list of rule violations, organized per namespace, node or global. - Details with links the the monitoring Alerting page --- web/locales/en/plugin__netobserv-plugin.json | 25 +++ web/src/api/routes.ts | 6 +- web/src/components/alerts/fetcher.tsx | 4 +- .../drawer/netflow-traffic-drawer.tsx | 22 ++- web/src/components/netflow-traffic.tsx | 13 +- .../components/tabs/health/health-summary.tsx | 109 +++++++++++ .../tabs/health/health-violation-table.tsx | 178 ++++++++++++++++++ web/src/components/tabs/health/health.css | 11 ++ web/src/components/tabs/health/health.tsx | 108 +++++++++++ web/src/components/tabs/health/helper.ts | 161 ++++++++++++++++ .../components/tabs/health/rule-details.tsx | 62 ++++++ web/src/components/tabs/tabs-container.tsx | 1 + 12 files changed, 691 insertions(+), 9 deletions(-) create mode 100644 web/src/components/tabs/health/health-summary.tsx create mode 100644 web/src/components/tabs/health/health-violation-table.tsx create mode 100644 web/src/components/tabs/health/health.css create mode 100644 web/src/components/tabs/health/health.tsx create mode 100644 web/src/components/tabs/health/helper.ts create mode 100644 web/src/components/tabs/health/rule-details.tsx diff --git a/web/locales/en/plugin__netobserv-plugin.json b/web/locales/en/plugin__netobserv-plugin.json index 2656688f7..67cd68f8b 100644 --- a/web/locales/en/plugin__netobserv-plugin.json +++ b/web/locales/en/plugin__netobserv-plugin.json @@ -382,6 +382,30 @@ "Results": "Results", "Query summary": "Query summary", "Find in view": "Find in view", + "The network looks healthy": "The network looks healthy", + "There are critical network issues": "There are critical network issues", + "There are network warnings": "There are network warnings", + "{{firingAlerts}} critical issues, from {{firingRules}} distinct rules": "{{firingAlerts}} critical issues, from {{firingRules}} distinct rules", + "({{pendingAlerts}} pending critical issues, from {{pendingRules}} distinct rules)": "({{pendingAlerts}} pending critical issues, from {{pendingRules}} distinct rules)", + "No critical issues out of {{total}} rules": "No critical issues out of {{total}} rules", + "{{firingAlerts}} warnings, from {{firingRules}} distinct rules": "{{firingAlerts}} warnings, from {{firingRules}} distinct rules", + "({{pendingAlerts}} pending warnings, from {{pendingRules}} distinct rules)": "({{pendingAlerts}} pending warnings, from {{pendingRules}} distinct rules)", + "No warnings out of {{total}} rules": "No warnings out of {{total}} rules", + "{{firingAlerts}} minor issues, from {{firingRules}} distinct rules": "{{firingAlerts}} minor issues, from {{firingRules}} distinct rules", + "({{pendingAlerts}} pending minor issues, from {{pendingRules}} distinct rules)": "({{pendingAlerts}} pending minor issues, from {{pendingRules}} distinct rules)", + "No minor issues out of {{total}} rules": "No minor issues out of {{total}} rules", + "(global)": "(global)", + "Critical": "Critical", + "Warning": "Warning", + "Other": "Other", + "Pending": "Pending", + "Silenced": "Silenced", + "Rules": "Rules", + "No violations found": "No violations found", + "Global rule violations": "Global rule violations", + "Rule violations per node": "Rule violations per node", + "Rule violations per namespace": "Rule violations per namespace", + "Navigate to alert details": "Navigate to alert details", "Show all graphs": "Show all graphs", "Focus on this graph": "Focus on this graph", "Show total": "Show total", @@ -416,6 +440,7 @@ "Sorry, 3D view is not implemented anymore.": "Sorry, 3D view is not implemented anymore.", "Traffic flows": "Traffic flows", "Topology": "Topology", + "Health": "Health", "Hide histogram": "Hide histogram", "Show histogram": "Show histogram", "Hide advanced options": "Hide advanced options", diff --git a/web/src/api/routes.ts b/web/src/api/routes.ts index 772c1a14c..b65c11520 100644 --- a/web/src/api/routes.ts +++ b/web/src/api/routes.ts @@ -32,8 +32,10 @@ export const getFlowRecords = (params: FlowQuery): Promise => { }); }; -export const getAlerts = (): Promise => { - return axios.get('/api/prometheus/api/v1/rules?type=alert').then(r => { +export const getAlerts = (match: string): Promise => { + const matchKeyEnc = encodeURIComponent("match[]"); + const matchValEnc = encodeURIComponent("{"+match+"}"); + return axios.get(`/api/prometheus/api/v1/rules?type=alert&${matchKeyEnc}=${matchValEnc}`).then(r => { if (r.status >= 400) { throw new Error(`${r.statusText} [code=${r.status}]`); } diff --git a/web/src/components/alerts/fetcher.tsx b/web/src/components/alerts/fetcher.tsx index c342f2765..51a16420b 100644 --- a/web/src/components/alerts/fetcher.tsx +++ b/web/src/components/alerts/fetcher.tsx @@ -11,12 +11,12 @@ export const AlertFetcher: React.FC = ({ children }) => { const [alerts, setAlerts] = React.useState([]); const [silencedAlerts, setSilencedAlerts] = React.useState(null); React.useEffect(() => { - getAlerts() + getAlerts('app="netobserv"') // matching app="netobserv" catches all netobserv-owned alerts .then(result => { setAlerts( result.data.groups.flatMap(group => { return group.rules - .filter(rule => !!rule.labels.app && rule.labels.app == 'netobserv' && rule.state == 'firing') + .filter(rule => rule.state == 'firing' && !('netobserv_io_network_health' in rule.annotations)) .map(rule => { const key = [ group.file, diff --git a/web/src/components/drawer/netflow-traffic-drawer.tsx b/web/src/components/drawer/netflow-traffic-drawer.tsx index dd90050ca..6aaaa1478 100644 --- a/web/src/components/drawer/netflow-traffic-drawer.tsx +++ b/web/src/components/drawer/netflow-traffic-drawer.tsx @@ -1,4 +1,4 @@ -import { K8sModel } from '@openshift-console/dynamic-plugin-sdk'; +import { K8sModel, Rule } from '@openshift-console/dynamic-plugin-sdk'; import { Drawer, DrawerContent, DrawerContentBody, Flex, FlexItem } from '@patternfly/react-core'; import { t } from 'i18next'; import _ from 'lodash'; @@ -32,6 +32,7 @@ import { SearchEvent, SearchHandle } from '../search/search'; import { NetflowOverview, NetflowOverviewHandle } from '../tabs/netflow-overview/netflow-overview'; import { NetflowTable, NetflowTableHandle } from '../tabs/netflow-table/netflow-table'; import { NetflowTopology, NetflowTopologyHandle } from '../tabs/netflow-topology/netflow-topology'; +import { Health, HealthHandle } from '../tabs/health/health'; import ElementPanel from './element/element-panel'; import './netflow-traffic-drawer.css'; import RecordPanel from './record/record-panel'; @@ -40,6 +41,7 @@ export type NetflowTrafficDrawerHandle = { getOverviewHandle: () => NetflowOverviewHandle | null; getTableHandle: () => NetflowTableHandle | null; getTopologyHandle: () => NetflowTopologyHandle | null; + getHealthHandle: () => HealthHandle | null; }; export interface NetflowTrafficDrawerProps { @@ -108,6 +110,8 @@ export const NetflowTrafficDrawer: React.FC = React.f const overviewRef = React.useRef(null); const tableRef = React.useRef(null); const topologyRef = React.useRef(null); + const healthRef = React.useRef(null); + const [selectedRule, setSelectedRule] = React.useState(undefined); const { defaultFilters, @@ -128,7 +132,8 @@ export const NetflowTrafficDrawer: React.FC = React.f React.useImperativeHandle(ref, () => ({ getOverviewHandle: () => overviewRef.current, getTableHandle: () => tableRef.current, - getTopologyHandle: () => topologyRef.current + getTopologyHandle: () => topologyRef.current, + getHealthHandle: () => healthRef.current })); const onRecordSelect = React.useCallback( @@ -318,6 +323,19 @@ export const NetflowTrafficDrawer: React.FC = React.f /> ); break; + case 'health': + content = ( + + ); + break; default: content = null; break; diff --git a/web/src/components/netflow-traffic.tsx b/web/src/components/netflow-traffic.tsx index 04aa9def7..606691bfc 100644 --- a/web/src/components/netflow-traffic.tsx +++ b/web/src/components/netflow-traffic.tsx @@ -59,7 +59,7 @@ import ChipsPopover from './toolbar/filters/chips-popover'; import HistogramToolbar from './toolbar/histogram-toolbar'; import ViewOptionsToolbar from './toolbar/view-options-toolbar'; -export type ViewId = 'overview' | 'table' | 'topology'; +export type ViewId = 'overview' | 'table' | 'topology' | 'health'; export interface NetflowTrafficProps { forcedNamespace?: string; @@ -97,8 +97,8 @@ export const NetflowTraffic: React.FC = ({ // Callbacks const allowLoki = React.useCallback(() => { - return model.config.dataSources.some(ds => ds === 'loki'); - }, [model.config.dataSources]); + return model.config.dataSources.some(ds => ds === 'loki') && model.selectedViewId !== 'health'; + }, [model.config.dataSources, model.selectedViewId]); const allowProm = React.useCallback(() => { return model.config.dataSources.some(ds => ds === 'prom') && model.selectedViewId !== 'table'; @@ -440,6 +440,13 @@ export const NetflowTraffic: React.FC = ({ model.setTopologyUDNIds([]); } break; + case 'health': + if (allowProm()) { + promises = drawerRef.current?.getHealthHandle()?.fetch(clearMetrics); + } else { + model.setError(t('Only available when FlowCollector.loki.enable is true')); + } + break; default: console.error('tick called on not implemented view Id', model.selectedViewId); model.setLoading(false); diff --git a/web/src/components/tabs/health/health-summary.tsx b/web/src/components/tabs/health/health-summary.tsx new file mode 100644 index 000000000..57c6d0412 --- /dev/null +++ b/web/src/components/tabs/health/health-summary.tsx @@ -0,0 +1,109 @@ +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Rule } from '@openshift-console/dynamic-plugin-sdk'; +import { Alert, AlertVariant } from '@patternfly/react-core'; + +export interface HealthSummaryProps { + rules: Rule[]; +} + +export const HealthSummary: React.FC = ({ rules }) => { + const { t } = useTranslation('plugin__netobserv-plugin'); + + if (rules.length === 0) { + return ( + + <> + {t('Check alert definitions in FlowCollector "spec.processor.metrics.alertGroups" and "spec.processor.metrics.disableAlerts".')} +
+ {t('Make sure that Prometheus and AlertManager are running.')} + +
+ ); + } + + const stats = { + critical: { + firingAlerts: rules.flatMap(r => r.alerts).filter(a => a.state === 'firing' && a.labels.severity === 'critical') + .length, + firingRules: rules.filter(a => a.state === 'firing' && a.labels.severity === 'critical').length, + pendingAlerts: rules.flatMap(r => r.alerts).filter(a => a.state === 'pending' && a.labels.severity === 'critical') + .length, + pendingRules: rules.filter(a => a.state === 'pending' && a.labels.severity === 'critical').length, + total: rules.filter(a => a.labels.severity === 'critical').length + }, + warning: { + firingAlerts: rules.flatMap(r => r.alerts).filter(a => a.state === 'firing' && a.labels.severity === 'warning') + .length, + firingRules: rules.filter(a => a.state === 'firing' && a.labels.severity === 'warning').length, + pendingAlerts: rules.flatMap(r => r.alerts).filter(a => a.state === 'pending' && a.labels.severity === 'warning') + .length, + pendingRules: rules.filter(a => a.state === 'pending' && a.labels.severity === 'warning').length, + total: rules.filter(a => a.labels.severity === 'warning').length + }, + info: { + firingAlerts: rules.flatMap(r => r.alerts).filter(a => a.state === 'firing' && a.labels.severity === 'info') + .length, + firingRules: rules.filter(a => a.state === 'firing' && a.labels.severity === 'info').length, + pendingAlerts: rules.flatMap(r => r.alerts).filter(a => a.state === 'pending' && a.labels.severity === 'info') + .length, + pendingRules: rules.filter(a => a.state === 'pending' && a.labels.severity === 'info').length, + total: rules.filter(a => a.labels.severity === 'info').length + } + }; + + let variant: AlertVariant = AlertVariant.success; + let title = t('The network looks healthy'); + if (stats.critical.firingAlerts > 0) { + variant = AlertVariant.danger; + title = t('There are critical network issues'); + } else if (stats.warning.firingAlerts > 0) { + variant = AlertVariant.warning; + title = t('There are network warnings'); + } else if (stats.info.firingAlerts > 0) { + title = t('The network looks relatively healthy, with minor issues'); + } + + const details: string[] = []; + if (stats.critical.firingAlerts > 0) { + details.push( + t('{{firingAlerts}} critical issues, from {{firingRules}} distinct rules', stats.critical) + ); + } else if (stats.critical.pendingAlerts > 0) { + details.push( + t( + '({{pendingAlerts}} pending critical issues, from {{pendingRules}} distinct rules)', + stats.critical + ) + ); + } else if (variant === AlertVariant.success) { + details.push(t('No critical issues out of {{total}} rules', stats.critical)); + } + if (stats.warning.firingAlerts > 0) { + details.push(t('{{firingAlerts}} warnings, from {{firingRules}} distinct rules', stats.warning)); + } else if (stats.warning.pendingAlerts > 0) { + details.push( + t('{{pendingAlerts}} pending warnings, from {{pendingRules}} distinct rules', stats.warning) + ); + } else if (variant === AlertVariant.success) { + details.push(t('No warnings out of {{total}} rules', stats.warning)); + } + if (stats.info.firingAlerts > 0) { + details.push(t('{{firingAlerts}} minor issues, from {{firingRules}} distinct rules', stats.info)); + } else if (stats.info.pendingAlerts > 0) { + details.push( + t('{{pendingAlerts}} pending minor issues, from {{pendingRules}} distinct rules', stats.info) + ); + } else if (variant === AlertVariant.success) { + details.push(t('No minor issues out of {{total}} rules', stats.info)); + } + return ( + +
    + {details.map((text, i) => ( +
  • {text}
  • + ))} +
+
+ ); +}; diff --git a/web/src/components/tabs/health/health-violation-table.tsx b/web/src/components/tabs/health/health-violation-table.tsx new file mode 100644 index 000000000..ffb986b0d --- /dev/null +++ b/web/src/components/tabs/health/health-violation-table.tsx @@ -0,0 +1,178 @@ +import { Bullseye, EmptyState, EmptyStateIcon, Text, TextContent, TextVariants, Title } from '@patternfly/react-core'; +import { Table, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; +import * as React from 'react'; +import * as _ from 'lodash'; + +import { useTranslation } from 'react-i18next'; +import { ByResource, getRulesPreview } from './helper'; +import { ThSortType } from '@patternfly/react-table/dist/esm/components/Table/base/types'; +import { ResourceLink } from '@openshift-console/dynamic-plugin-sdk'; +import { CheckCircleIcon } from '@patternfly/react-icons'; +import { RuleDetails } from './rule-details'; + +export interface HealthViolationTableProps { + title: string; + stats: ByResource[]; + kind?: string; +} + +type Column = { + title: string; + value: (r: ByResource) => string | number; + display?: (r: ByResource) => JSX.Element; + sort: ThSortType; +}; + +export const HealthViolationTable: React.FC = ({ title, stats, kind }) => { + const { t } = useTranslation('plugin__netobserv-plugin'); + + const [activeSortIndex, setActiveSortIndex] = React.useState(undefined); + const [activeSortDirection, setActiveSortDirection] = React.useState<'asc' | 'desc'>('desc'); + const [expandedRowNames, setExpandedRowNames] = React.useState([]); + + const buildSortParams = (index: number, defaultDir: 'asc' | 'desc'): ThSortType => { + return { + columnIndex: index, + sortBy: { + index: activeSortIndex, + direction: activeSortDirection, + defaultDirection: defaultDir + }, + onSort: (_event, index, direction) => { + setActiveSortIndex(index); + setActiveSortDirection(direction); + } + }; + } + + const columns: Column[] = [ + { + title: t('Name'), + value: (r: ByResource) => r.name, + display: (r: ByResource) => { + return kind ? ( + + ) : t('(global)') + }, + sort: buildSortParams(0, 'asc') + }, + { + title: t('Critical'), + value: (r: ByResource) => r.critical.firing.length, + sort: buildSortParams(1, 'desc') + }, + { + title: t('Warning'), + value: (r: ByResource) => r.warning.firing.length, + sort: buildSortParams(2, 'desc') + }, + { + title: t('Other'), + value: (r: ByResource) => r.other.firing.length, + sort: buildSortParams(3, 'desc') + }, + { + title: t('Pending'), + value: (r: ByResource) => r.critical.pending.length + r.warning.pending.length + r.other.pending.length, + sort: buildSortParams(4, 'desc') + }, + { + title: t('Silenced'), + value: (r: ByResource) => r.critical.silenced.length + r.warning.silenced.length + r.other.silenced.length, + sort: buildSortParams(5, 'desc') + }, + { + title: t('Rules'), + value: (r: ByResource) => getRulesPreview(r), + sort: buildSortParams(6, 'asc') + }, + ]; + const nbCols = columns.length + 1; + + let sortIndex = activeSortIndex; + if (sortIndex === undefined) { + sortIndex = 1; // critical + if (!stats.some(r => r.critical.firing.length > 0)) { + if (stats.some(r => r.warning.firing.length > 0)) { + sortIndex = 2; // warning + } else if (stats.some(r => r.other.firing.length > 0)) { + sortIndex = 3; // other + } + } + } + + const sorted = _.orderBy(stats, columns[sortIndex].value, activeSortDirection); + + const toggleExpanded = (name: string) => { + const index = expandedRowNames.indexOf(name); + const newExpanded: string[] = index >= 0 + ? [...expandedRowNames.slice(0, index), ...expandedRowNames.slice(index + 1, expandedRowNames.length)] + : [...expandedRowNames, name]; + setExpandedRowNames(newExpanded); + } + + return ( + <> + + + {title} + + + + ))} + + + {sorted.length === 0 && ( + + + + + + )} + {sorted.map((r, i) => ( + + <> + + {/* */} + ))} + + {expandedRowNames.includes(r.name) && ( + + + + )} + + + ))} +
+ {columns.map(c => ({c.title}
+ + + + + {t('No violations found')} + + + +
toggleExpanded(r.name), + expandId: 'expandable' + }} + /> + {columns.map(c => ({c.display ? c.display(r) : c.value(r)}
+ +
+
+
+ + ); +}; diff --git a/web/src/components/tabs/health/health.css b/web/src/components/tabs/health/health.css new file mode 100644 index 000000000..eeb07d357 --- /dev/null +++ b/web/src/components/tabs/health/health.css @@ -0,0 +1,11 @@ +/* #table-container>.pf-v5-c-table tbody>tr>* { + vertical-align: top; +} + +#table-container.dark { + background: #0f1214; +} + +#table-container.light { + background: #f0f0f0; +} */ diff --git a/web/src/components/tabs/health/health.tsx b/web/src/components/tabs/health/health.tsx new file mode 100644 index 000000000..62163fa62 --- /dev/null +++ b/web/src/components/tabs/health/health.tsx @@ -0,0 +1,108 @@ +import { Bullseye, Spinner } from '@patternfly/react-core'; +import * as _ from 'lodash'; +import * as React from 'react'; +import { murmur3 } from 'murmurhash-js'; + +import { Rule } from '@openshift-console/dynamic-plugin-sdk'; +import { useTranslation } from 'react-i18next'; +import { Stats } from '../../../api/loki'; +import { getAlerts } from '../../../api/routes'; +import { Config } from '../../../model/config'; +import { HealthSummary } from './health-summary'; +import { buildStats, HealthStats } from './helper'; +import { HealthViolationTable } from './health-violation-table'; + +import './health.css'; + +export type HealthHandle = { + fetch: (initFunction: () => void) => Promise | undefined; +}; + +export interface HealthProps { + ref?: React.Ref; + loading?: boolean; + selectedRule?: Rule; + onSelect: (rule?: Rule) => void; + resetDefaultFilters?: (c?: Config) => void; + clearFilters?: () => void; + isDark?: boolean; +} + +export const Health: React.FC = React.forwardRef( + (props, ref: React.Ref) => { + const { t } = useTranslation('plugin__netobserv-plugin'); + const [rules, setRules] = React.useState([]); + const [stats, setStats] = React.useState({ global: [], byNamespace: [], byNode: [] }); + + const fetch = React.useCallback((initFunction: () => void) => { + initFunction(); + + const promises: Promise[] = [ + // matching netobserv="true" catches all alerts designed for netobserv (not necessarily owned by it) + getAlerts('netobserv="true"').then(res => { + const rules = res.data.groups.flatMap(group => { + // Inject rule id, for links to the alerting page + // Warning: ID generation may in theory differ with openshift version (in practice, this has been stable across versions since 4.12 at least) + // See https://github.com/openshift/console/blob/29374f38308c4ebe9ea461a5d69eb3e4956c7086/frontend/public/components/monitoring/utils.ts#L47-L56 + group.rules.forEach(r => { + const key = [ + group.file, + group.name, + r.name, + r.duration, + r.query, + ..._.map(r.labels, (k, v) => `${k}=${v}`), + ].join(','); + r.id = String(murmur3(key, 'monitoring-salt' as any)); + }); + return group.rules; + }); + setRules(rules); + setStats(buildStats(rules)); + return { + limitReached: false, + numQueries: 1, + dataSources: ['prometheus'] + }; + }) + ]; + return Promise.all(promises); + }, []); + + React.useImperativeHandle(ref, () => ({ fetch })); + + if (_.isEmpty(rules) && props.loading) { + return ( + + + + ); + } + + // TODO: add checkboxes: include pending, include silenced + + return ( + <> + + + + + + ); + } +); +Health.displayName = 'Health'; diff --git a/web/src/components/tabs/health/helper.ts b/web/src/components/tabs/health/helper.ts new file mode 100644 index 000000000..8440aec1c --- /dev/null +++ b/web/src/components/tabs/health/helper.ts @@ -0,0 +1,161 @@ +import { PrometheusAlert, PrometheusLabels, Rule } from '@openshift-console/dynamic-plugin-sdk'; +import * as _ from 'lodash'; + +export type HealthStats = { + global: ByResource[], + byNamespace: ByResource[]; + byNode: ByResource[]; +}; + +export type ByResource = { + name: string; + alerts: AlertWithRuleName[]; + critical: SeverityStats; + warning: SeverityStats; + other: SeverityStats; +}; + +type SeverityStats = { + firing: string[]; + pending: string[]; + silenced: string[]; +}; + +export type AlertWithRuleName = PrometheusAlert & { + ruleName: string; + ruleID: string; + metadata?: HealthMetadata; +}; + +export const buildStats = (rules: Rule[]): HealthStats => { + const alerts: AlertWithRuleName[] = rules.flatMap(r => { + const md = getHealthMetadata(r.annotations); + return r.alerts.map(a => { + if (typeof a.value === 'string') { + a.value = parseFloat(a.value); + } + return { ...a, ruleName: r.name, ruleID: r.id, metadata: md }; + }) + }); + + const namespaceLabels: string[] = []; + const nodeLabels: string[] = []; + alerts.forEach(a => { + if (a.metadata?.namespaceLabels) { + a.metadata.namespaceLabels.forEach(l => { + if (!namespaceLabels.includes(l)) { + namespaceLabels.push(l); + } + }); + } + if (a.metadata?.nodeLabels) { + a.metadata.nodeLabels.forEach(l => { + if (!nodeLabels.includes(l)) { + nodeLabels.push(l); + } + }); + } + }); + const global = filterGlobals(alerts, [...namespaceLabels, ...nodeLabels]); + const byNamespace = groupBy(alerts, namespaceLabels); + const byNode = groupBy(alerts, nodeLabels); + return { global, byNamespace, byNode }; +}; + +const filterGlobals = (alerts: AlertWithRuleName[], nonGlobalLabels: string[]): ByResource[] => { + // Keep only rules where none of the non-global labels are set + const filtered = alerts.filter(a => !nonGlobalLabels.some(l => (l in a.labels))); + if (filtered.length === 0) { + return []; + } + return statsFromGrouped({"global": filtered}); +}; + +const groupBy = (alerts: AlertWithRuleName[], labels: string[]): ByResource[] => { + if (labels.length === 0) { + return []; + } + const groups: {[key: string]: AlertWithRuleName[]} = {}; + labels.forEach(l => { + const byLabel = _.groupBy(alerts.filter(a => l in a.labels), a => a.labels[l]); + _.keys(byLabel).forEach(k => { + if (k in groups) { + groups[k].push(...byLabel[k]); + } else { + groups[k] = byLabel[k]; + } + }); + }); + return statsFromGrouped(groups); +}; + +const statsFromGrouped = (g: _.Dictionary): ByResource[] => { + const stats: ByResource[] = []; + _.keys(g).forEach(k => { + if (k) { + const br: ByResource = { + name: k, + alerts: g[k], + critical: { firing: [], pending: [], silenced: [] }, + warning: { firing: [], pending: [], silenced: [] }, + other: { firing: [], pending: [], silenced: [] } + }; + stats.push(br); + g[k].forEach(alert => { + let stats: SeverityStats; + switch (alert.labels.severity) { + case 'critical': + stats = br.critical; + break; + case 'warning': + stats = br.warning; + break; + default: + stats = br.other; + break; + } + switch (alert.state) { + case 'firing': + stats.firing.push(alert.ruleName); + break; + case 'pending': + stats.pending.push(alert.ruleName); + break; + case 'silenced': + stats.silenced.push(alert.ruleName); + break; + } + }); + } + }); + return stats; +}; + +export const getRulesPreview = (byr: ByResource): string => { + const r: string[] = []; + [byr.critical.firing, byr.warning.firing, byr.other.firing].forEach(list => { + list.forEach(name => { + if (r.length < 3 && !r.includes(name)) { + r.push(name); + } + }); + }); + if (r.length < 3) { + return r.join(', '); + } + return r.join(', ') + '...'; +}; + +type HealthMetadata = { + threshold: string; + unit: string; + nodeLabels?: string[]; + namespaceLabels?: string[]; +}; + +export const getHealthMetadata = (annotations: PrometheusLabels): HealthMetadata | undefined => { + if ('netobserv_io_network_health' in annotations) { + return JSON.parse(annotations['netobserv_io_network_health']) as HealthMetadata; + } + return undefined; +} diff --git a/web/src/components/tabs/health/rule-details.tsx b/web/src/components/tabs/health/rule-details.tsx new file mode 100644 index 000000000..f04493c6d --- /dev/null +++ b/web/src/components/tabs/health/rule-details.tsx @@ -0,0 +1,62 @@ +import { Table, Tbody, Td, Tr } from '@patternfly/react-table'; +import * as React from 'react'; + +import { AlertWithRuleName, ByResource, getHealthMetadata } from './helper'; +import { Label } from '@patternfly/react-core'; +import { valueFormat } from '../../../utils/format'; +import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; + +export interface RuleDetailsProps { + info: ByResource; +} + +export const RuleDetails: React.FC = ({ info }) => { + const { t } = useTranslation('plugin__netobserv-plugin'); + + const buildLink = (r: AlertWithRuleName): string => { + const labels: string[] = []; + Object.keys(r.labels).forEach(k => { + labels.push(k + '=' + r.labels[k]); + }); + return `/monitoring/alerts/${r.ruleID}?${labels.join('&')}`; + } + + return ( + + + {info.alerts.map((a, i) => { + const md = getHealthMetadata(a.annotations); + return ( + + + + + + + + ); + })} + +
+ + {a.annotations['summary']} + + {a.state}{a.labels.severity} + {Object.keys(a.labels) + .filter(k => k !== 'app' && k !== 'netobserv' && k !== 'severity' && k !== 'alertname' && a.labels[k] !== info.name) + .map(k => ()) + } + + {valueFormat(a.value as number, 2)} + {md?.threshold && (' > ' + md.threshold + ' ' + md.unit)} +
+ ); +}; diff --git a/web/src/components/tabs/tabs-container.tsx b/web/src/components/tabs/tabs-container.tsx index c3bcaaedf..1ee93248f 100644 --- a/web/src/components/tabs/tabs-container.tsx +++ b/web/src/components/tabs/tabs-container.tsx @@ -51,6 +51,7 @@ export const TabsContainer: React.FC = props => { eventKey={'topology'} title={{t('Topology')}} /> + {t('Health')}} /> {props.selectedViewId === 'table' && ( From 272c7c8138780a40d659d1fcf1d22084e437020e Mon Sep 17 00:00:00 2001 From: Joel Takvorian Date: Wed, 6 Aug 2025 16:01:54 +0200 Subject: [PATCH 02/21] Revamp as a new page --- web/locales/en/plugin__netobserv-plugin.json | 55 +++---- web/src/api/routes.ts | 4 +- .../drawer/netflow-traffic-drawer.tsx | 22 +-- web/src/components/health/health-error.tsx | 28 ++++ .../{tabs => }/health/health-summary.tsx | 27 ++-- .../health/health-violation-table.tsx | 43 +++--- web/src/components/health/health.css | 14 ++ .../components/{tabs => }/health/helper.ts | 17 ++- web/src/components/health/network-health.tsx | 135 ++++++++++++++++++ .../{tabs => }/health/rule-details.tsx | 39 ++--- web/src/components/netflow-traffic.tsx | 13 +- web/src/components/tabs/health/health.css | 11 -- web/src/components/tabs/health/health.tsx | 108 -------------- web/src/components/tabs/tabs-container.tsx | 1 - web/src/utils/local-storage-hook.ts | 1 + web/webpack.config.ts | 20 +++ 16 files changed, 298 insertions(+), 240 deletions(-) create mode 100644 web/src/components/health/health-error.tsx rename web/src/components/{tabs => }/health/health-summary.tsx (85%) rename web/src/components/{tabs => }/health/health-violation-table.tsx (84%) create mode 100644 web/src/components/health/health.css rename web/src/components/{tabs => }/health/helper.ts (94%) create mode 100644 web/src/components/health/network-health.tsx rename web/src/components/{tabs => }/health/rule-details.tsx (62%) delete mode 100644 web/src/components/tabs/health/health.css delete mode 100644 web/src/components/tabs/health/health.tsx diff --git a/web/locales/en/plugin__netobserv-plugin.json b/web/locales/en/plugin__netobserv-plugin.json index 67cd68f8b..1db0f5279 100644 --- a/web/locales/en/plugin__netobserv-plugin.json +++ b/web/locales/en/plugin__netobserv-plugin.json @@ -234,6 +234,36 @@ "Previous tip": "Previous tip", "Next tip": "Next tip", "Close tips": "Close tips", + "No rules configured, health cannot be determined": "No rules configured, health cannot be determined", + "Check alert definitions in FlowCollector \"spec.processor.metrics.alertGroups\" and \"spec.processor.metrics.disableAlerts\".": "Check alert definitions in FlowCollector \"spec.processor.metrics.alertGroups\" and \"spec.processor.metrics.disableAlerts\".", + "Make sure that Prometheus and AlertManager are running.": "Make sure that Prometheus and AlertManager are running.", + "The network looks healthy": "The network looks healthy", + "There are critical network issues": "There are critical network issues", + "There are network warnings": "There are network warnings", + "The network looks relatively healthy, with minor issues": "The network looks relatively healthy, with minor issues", + "{{firingAlerts}} critical issues, from {{firingRules}} distinct rules": "{{firingAlerts}} critical issues, from {{firingRules}} distinct rules", + "({{pendingAlerts}} pending critical issues, from {{pendingRules}} distinct rules)": "({{pendingAlerts}} pending critical issues, from {{pendingRules}} distinct rules)", + "No critical issues out of {{total}} rules": "No critical issues out of {{total}} rules", + "{{firingAlerts}} warnings, from {{firingRules}} distinct rules": "{{firingAlerts}} warnings, from {{firingRules}} distinct rules", + "{{pendingAlerts}} pending warnings, from {{pendingRules}} distinct rules": "{{pendingAlerts}} pending warnings, from {{pendingRules}} distinct rules", + "No warnings out of {{total}} rules": "No warnings out of {{total}} rules", + "{{firingAlerts}} minor issues, from {{firingRules}} distinct rules": "{{firingAlerts}} minor issues, from {{firingRules}} distinct rules", + "{{pendingAlerts}} pending minor issues, from {{pendingRules}} distinct rules": "{{pendingAlerts}} pending minor issues, from {{pendingRules}} distinct rules", + "No minor issues out of {{total}} rules": "No minor issues out of {{total}} rules", + "(global)": "(global)", + "Critical": "Critical", + "Warning": "Warning", + "Other": "Other", + "Pending": "Pending", + "Silenced": "Silenced", + "Rules": "Rules", + "No violations found": "No violations found", + "Network Health": "Network Health", + "Refresh interval": "Refresh interval", + "Global rule violations": "Global rule violations", + "Rule violations per node": "Rule violations per node", + "Rule violations per namespace": "Rule violations per namespace", + "Navigate to alert details": "Navigate to alert details", "No results found": "No results found", "Clear or reset filters and try again.": "Clear or reset filters and try again.", "Check for errors in health dashboard. Status endpoint is returning: {{statusError}}": "Check for errors in health dashboard. Status endpoint is returning: {{statusError}}", @@ -328,7 +358,6 @@ "Kind not managed": "Kind not managed", "Query is slow": "Query is slow", "Time range": "Time range", - "Refresh interval": "Refresh interval", "Network Traffic": "Network Traffic", "Filtered sum of bytes": "Filtered sum of bytes", "B": "B", @@ -382,30 +411,6 @@ "Results": "Results", "Query summary": "Query summary", "Find in view": "Find in view", - "The network looks healthy": "The network looks healthy", - "There are critical network issues": "There are critical network issues", - "There are network warnings": "There are network warnings", - "{{firingAlerts}} critical issues, from {{firingRules}} distinct rules": "{{firingAlerts}} critical issues, from {{firingRules}} distinct rules", - "({{pendingAlerts}} pending critical issues, from {{pendingRules}} distinct rules)": "({{pendingAlerts}} pending critical issues, from {{pendingRules}} distinct rules)", - "No critical issues out of {{total}} rules": "No critical issues out of {{total}} rules", - "{{firingAlerts}} warnings, from {{firingRules}} distinct rules": "{{firingAlerts}} warnings, from {{firingRules}} distinct rules", - "({{pendingAlerts}} pending warnings, from {{pendingRules}} distinct rules)": "({{pendingAlerts}} pending warnings, from {{pendingRules}} distinct rules)", - "No warnings out of {{total}} rules": "No warnings out of {{total}} rules", - "{{firingAlerts}} minor issues, from {{firingRules}} distinct rules": "{{firingAlerts}} minor issues, from {{firingRules}} distinct rules", - "({{pendingAlerts}} pending minor issues, from {{pendingRules}} distinct rules)": "({{pendingAlerts}} pending minor issues, from {{pendingRules}} distinct rules)", - "No minor issues out of {{total}} rules": "No minor issues out of {{total}} rules", - "(global)": "(global)", - "Critical": "Critical", - "Warning": "Warning", - "Other": "Other", - "Pending": "Pending", - "Silenced": "Silenced", - "Rules": "Rules", - "No violations found": "No violations found", - "Global rule violations": "Global rule violations", - "Rule violations per node": "Rule violations per node", - "Rule violations per namespace": "Rule violations per namespace", - "Navigate to alert details": "Navigate to alert details", "Show all graphs": "Show all graphs", "Focus on this graph": "Focus on this graph", "Show total": "Show total", diff --git a/web/src/api/routes.ts b/web/src/api/routes.ts index b65c11520..c6e87c6eb 100644 --- a/web/src/api/routes.ts +++ b/web/src/api/routes.ts @@ -33,8 +33,8 @@ export const getFlowRecords = (params: FlowQuery): Promise => { }; export const getAlerts = (match: string): Promise => { - const matchKeyEnc = encodeURIComponent("match[]"); - const matchValEnc = encodeURIComponent("{"+match+"}"); + const matchKeyEnc = encodeURIComponent('match[]'); + const matchValEnc = encodeURIComponent('{' + match + '}'); return axios.get(`/api/prometheus/api/v1/rules?type=alert&${matchKeyEnc}=${matchValEnc}`).then(r => { if (r.status >= 400) { throw new Error(`${r.statusText} [code=${r.status}]`); diff --git a/web/src/components/drawer/netflow-traffic-drawer.tsx b/web/src/components/drawer/netflow-traffic-drawer.tsx index 6aaaa1478..dd90050ca 100644 --- a/web/src/components/drawer/netflow-traffic-drawer.tsx +++ b/web/src/components/drawer/netflow-traffic-drawer.tsx @@ -1,4 +1,4 @@ -import { K8sModel, Rule } from '@openshift-console/dynamic-plugin-sdk'; +import { K8sModel } from '@openshift-console/dynamic-plugin-sdk'; import { Drawer, DrawerContent, DrawerContentBody, Flex, FlexItem } from '@patternfly/react-core'; import { t } from 'i18next'; import _ from 'lodash'; @@ -32,7 +32,6 @@ import { SearchEvent, SearchHandle } from '../search/search'; import { NetflowOverview, NetflowOverviewHandle } from '../tabs/netflow-overview/netflow-overview'; import { NetflowTable, NetflowTableHandle } from '../tabs/netflow-table/netflow-table'; import { NetflowTopology, NetflowTopologyHandle } from '../tabs/netflow-topology/netflow-topology'; -import { Health, HealthHandle } from '../tabs/health/health'; import ElementPanel from './element/element-panel'; import './netflow-traffic-drawer.css'; import RecordPanel from './record/record-panel'; @@ -41,7 +40,6 @@ export type NetflowTrafficDrawerHandle = { getOverviewHandle: () => NetflowOverviewHandle | null; getTableHandle: () => NetflowTableHandle | null; getTopologyHandle: () => NetflowTopologyHandle | null; - getHealthHandle: () => HealthHandle | null; }; export interface NetflowTrafficDrawerProps { @@ -110,8 +108,6 @@ export const NetflowTrafficDrawer: React.FC = React.f const overviewRef = React.useRef(null); const tableRef = React.useRef(null); const topologyRef = React.useRef(null); - const healthRef = React.useRef(null); - const [selectedRule, setSelectedRule] = React.useState(undefined); const { defaultFilters, @@ -132,8 +128,7 @@ export const NetflowTrafficDrawer: React.FC = React.f React.useImperativeHandle(ref, () => ({ getOverviewHandle: () => overviewRef.current, getTableHandle: () => tableRef.current, - getTopologyHandle: () => topologyRef.current, - getHealthHandle: () => healthRef.current + getTopologyHandle: () => topologyRef.current })); const onRecordSelect = React.useCallback( @@ -323,19 +318,6 @@ export const NetflowTrafficDrawer: React.FC = React.f /> ); break; - case 'health': - content = ( - - ); - break; default: content = null; break; diff --git a/web/src/components/health/health-error.tsx b/web/src/components/health/health-error.tsx new file mode 100644 index 000000000..4ea8973a0 --- /dev/null +++ b/web/src/components/health/health-error.tsx @@ -0,0 +1,28 @@ +import { EmptyState, EmptyStateBody, EmptyStateIcon, Text, TextVariants, Title } from '@patternfly/react-core'; +import { ExclamationCircleIcon } from '@patternfly/react-icons'; +import * as React from 'react'; + +export interface HealthErrorProps { + title: string; + body: string; +} + +export const HealthError: React.FC = ({ title, body }) => { + return ( +
+ + + + {title} + + + + {body} + + + +
+ ); +}; + +export default HealthError; diff --git a/web/src/components/tabs/health/health-summary.tsx b/web/src/components/health/health-summary.tsx similarity index 85% rename from web/src/components/tabs/health/health-summary.tsx rename to web/src/components/health/health-summary.tsx index 57c6d0412..7563d4abd 100644 --- a/web/src/components/tabs/health/health-summary.tsx +++ b/web/src/components/health/health-summary.tsx @@ -1,7 +1,7 @@ -import * as React from 'react'; -import { useTranslation } from 'react-i18next'; import { Rule } from '@openshift-console/dynamic-plugin-sdk'; import { Alert, AlertVariant } from '@patternfly/react-core'; +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; export interface HealthSummaryProps { rules: Rule[]; @@ -14,8 +14,10 @@ export const HealthSummary: React.FC = ({ rules }) => { return ( <> - {t('Check alert definitions in FlowCollector "spec.processor.metrics.alertGroups" and "spec.processor.metrics.disableAlerts".')} -
+ {t( + 'Check alert definitions in FlowCollector "spec.processor.metrics.alertGroups" and "spec.processor.metrics.disableAlerts".' + )} +
{t('Make sure that Prometheus and AlertManager are running.')}
@@ -66,15 +68,10 @@ export const HealthSummary: React.FC = ({ rules }) => { const details: string[] = []; if (stats.critical.firingAlerts > 0) { - details.push( - t('{{firingAlerts}} critical issues, from {{firingRules}} distinct rules', stats.critical) - ); + details.push(t('{{firingAlerts}} critical issues, from {{firingRules}} distinct rules', stats.critical)); } else if (stats.critical.pendingAlerts > 0) { details.push( - t( - '({{pendingAlerts}} pending critical issues, from {{pendingRules}} distinct rules)', - stats.critical - ) + t('({{pendingAlerts}} pending critical issues, from {{pendingRules}} distinct rules)', stats.critical) ); } else if (variant === AlertVariant.success) { details.push(t('No critical issues out of {{total}} rules', stats.critical)); @@ -82,18 +79,14 @@ export const HealthSummary: React.FC = ({ rules }) => { if (stats.warning.firingAlerts > 0) { details.push(t('{{firingAlerts}} warnings, from {{firingRules}} distinct rules', stats.warning)); } else if (stats.warning.pendingAlerts > 0) { - details.push( - t('{{pendingAlerts}} pending warnings, from {{pendingRules}} distinct rules', stats.warning) - ); + details.push(t('{{pendingAlerts}} pending warnings, from {{pendingRules}} distinct rules', stats.warning)); } else if (variant === AlertVariant.success) { details.push(t('No warnings out of {{total}} rules', stats.warning)); } if (stats.info.firingAlerts > 0) { details.push(t('{{firingAlerts}} minor issues, from {{firingRules}} distinct rules', stats.info)); } else if (stats.info.pendingAlerts > 0) { - details.push( - t('{{pendingAlerts}} pending minor issues, from {{pendingRules}} distinct rules', stats.info) - ); + details.push(t('{{pendingAlerts}} pending minor issues, from {{pendingRules}} distinct rules', stats.info)); } else if (variant === AlertVariant.success) { details.push(t('No minor issues out of {{total}} rules', stats.info)); } diff --git a/web/src/components/tabs/health/health-violation-table.tsx b/web/src/components/health/health-violation-table.tsx similarity index 84% rename from web/src/components/tabs/health/health-violation-table.tsx rename to web/src/components/health/health-violation-table.tsx index ffb986b0d..0cabeb6ea 100644 --- a/web/src/components/tabs/health/health-violation-table.tsx +++ b/web/src/components/health/health-violation-table.tsx @@ -1,13 +1,13 @@ import { Bullseye, EmptyState, EmptyStateIcon, Text, TextContent, TextVariants, Title } from '@patternfly/react-core'; import { Table, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; -import * as React from 'react'; import * as _ from 'lodash'; +import * as React from 'react'; -import { useTranslation } from 'react-i18next'; -import { ByResource, getRulesPreview } from './helper'; -import { ThSortType } from '@patternfly/react-table/dist/esm/components/Table/base/types'; import { ResourceLink } from '@openshift-console/dynamic-plugin-sdk'; import { CheckCircleIcon } from '@patternfly/react-icons'; +import { ThSortType } from '@patternfly/react-table/dist/esm/components/Table/base/types'; +import { useTranslation } from 'react-i18next'; +import { ByResource, getRulesPreview } from './helper'; import { RuleDetails } from './rule-details'; export interface HealthViolationTableProps { @@ -43,16 +43,14 @@ export const HealthViolationTable: React.FC = ({ titl setActiveSortDirection(direction); } }; - } + }; const columns: Column[] = [ { title: t('Name'), value: (r: ByResource) => r.name, display: (r: ByResource) => { - return kind ? ( - - ) : t('(global)') + return kind ? : t('(global)'); }, sort: buildSortParams(0, 'asc') }, @@ -85,7 +83,7 @@ export const HealthViolationTable: React.FC = ({ titl title: t('Rules'), value: (r: ByResource) => getRulesPreview(r), sort: buildSortParams(6, 'asc') - }, + } ]; const nbCols = columns.length + 1; @@ -105,11 +103,12 @@ export const HealthViolationTable: React.FC = ({ titl const toggleExpanded = (name: string) => { const index = expandedRowNames.indexOf(name); - const newExpanded: string[] = index >= 0 - ? [...expandedRowNames.slice(0, index), ...expandedRowNames.slice(index + 1, expandedRowNames.length)] - : [...expandedRowNames, name]; + const newExpanded: string[] = + index >= 0 + ? [...expandedRowNames.slice(0, index), ...expandedRowNames.slice(index + 1, expandedRowNames.length)] + : [...expandedRowNames, name]; setExpandedRowNames(newExpanded); - } + }; return ( <> @@ -126,7 +125,9 @@ export const HealthViolationTable: React.FC = ({ titl - {columns.map(c => ({c.title}))} + {columns.map((c, i) => ( + {c.title} + ))} {sorted.length === 0 && ( @@ -136,9 +137,7 @@ export const HealthViolationTable: React.FC = ({ titl - - {t('No violations found')} - + {t('No violations found')} @@ -146,10 +145,10 @@ export const HealthViolationTable: React.FC = ({ titl )} {sorted.map((r, i) => ( - + <> - {/* */} + {/* */} = ({ titl expandId: 'expandable' }} /> - {columns.map(c => ({c.display ? c.display(r) : c.value(r)}))} + {columns.map((c, i) => ( + {c.display ? c.display(r) : c.value(r)} + ))} {expandedRowNames.includes(r.name) && ( - + )} diff --git a/web/src/components/health/health.css b/web/src/components/health/health.css new file mode 100644 index 000000000..e88c0abfa --- /dev/null +++ b/web/src/components/health/health.css @@ -0,0 +1,14 @@ +.netobserv-refresh-interval-container { + margin: 0 !important; +} + +/*rotate animation for loading icon*/ +@keyframes spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} diff --git a/web/src/components/tabs/health/helper.ts b/web/src/components/health/helper.ts similarity index 94% rename from web/src/components/tabs/health/helper.ts rename to web/src/components/health/helper.ts index 8440aec1c..dfadc0ae7 100644 --- a/web/src/components/tabs/health/helper.ts +++ b/web/src/components/health/helper.ts @@ -2,7 +2,7 @@ import { PrometheusAlert, PrometheusLabels, Rule } from '@openshift-console/dyna import * as _ from 'lodash'; export type HealthStats = { - global: ByResource[], + global: ByResource[]; byNamespace: ByResource[]; byNode: ByResource[]; }; @@ -35,7 +35,7 @@ export const buildStats = (rules: Rule[]): HealthStats => { a.value = parseFloat(a.value); } return { ...a, ruleName: r.name, ruleID: r.id, metadata: md }; - }) + }); }); const namespaceLabels: string[] = []; @@ -64,20 +64,23 @@ export const buildStats = (rules: Rule[]): HealthStats => { const filterGlobals = (alerts: AlertWithRuleName[], nonGlobalLabels: string[]): ByResource[] => { // Keep only rules where none of the non-global labels are set - const filtered = alerts.filter(a => !nonGlobalLabels.some(l => (l in a.labels))); + const filtered = alerts.filter(a => !nonGlobalLabels.some(l => l in a.labels)); if (filtered.length === 0) { return []; } - return statsFromGrouped({"global": filtered}); + return statsFromGrouped({ global: filtered }); }; const groupBy = (alerts: AlertWithRuleName[], labels: string[]): ByResource[] => { if (labels.length === 0) { return []; } - const groups: {[key: string]: AlertWithRuleName[]} = {}; + const groups: { [key: string]: AlertWithRuleName[] } = {}; labels.forEach(l => { - const byLabel = _.groupBy(alerts.filter(a => l in a.labels), a => a.labels[l]); + const byLabel = _.groupBy( + alerts.filter(a => l in a.labels), + a => a.labels[l] + ); _.keys(byLabel).forEach(k => { if (k in groups) { groups[k].push(...byLabel[k]); @@ -158,4 +161,4 @@ export const getHealthMetadata = (annotations: PrometheusLabels): HealthMetadata return JSON.parse(annotations['netobserv_io_network_health']) as HealthMetadata; } return undefined; -} +}; diff --git a/web/src/components/health/network-health.tsx b/web/src/components/health/network-health.tsx new file mode 100644 index 000000000..7fc3576d0 --- /dev/null +++ b/web/src/components/health/network-health.tsx @@ -0,0 +1,135 @@ +import { Rule } from '@openshift-console/dynamic-plugin-sdk'; +import { + Button, + Flex, + FlexItem, + PageSection, + TextVariants, + Title +} from '@patternfly/react-core'; +import { SyncAltIcon } from '@patternfly/react-icons'; +import * as _ from 'lodash'; +import { murmur3 } from 'murmurhash-js'; +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; +import { getAlerts } from '../../api/routes'; +import { getHTTPErrorDetails } from '../../utils/errors'; +import { localStorageHealthRefreshKey, useLocalStorage } from '../../utils/local-storage-hook'; +import { usePoll } from '../../utils/poll-hook'; +import { useTheme } from '../../utils/theme-hook'; +import { RefreshDropdown } from '../dropdowns/refresh-dropdown'; +import HealthError from './health-error'; +import { HealthSummary } from './health-summary'; +import { HealthViolationTable } from './health-violation-table'; +import { buildStats, HealthStats } from './helper'; + +import './health.css'; + +export const NetworkHealth: React.FC<{}> = ({}) => { + const { t } = useTranslation('plugin__netobserv-plugin'); + const isDarkTheme = useTheme(); + const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState(); + const [interval, setInterval] = useLocalStorage(localStorageHealthRefreshKey, 30000); + const [rules, setRules] = React.useState([]); + const [stats, setStats] = React.useState({ global: [], byNamespace: [], byNode: [] }); + + const fetch = React.useCallback(() => { + setLoading(true); + setError(undefined); + + // matching netobserv="true" catches all alerts designed for netobserv (not necessarily owned by it) + getAlerts('netobserv="true"') + .then(res => { + const rules = res.data.groups.flatMap(group => { + // Inject rule id, for links to the alerting page + // Warning: ID generation may in theory differ with openshift version (in practice, this has been stable across versions since 4.12 at least) + // See https://github.com/openshift/console/blob/29374f38308c4ebe9ea461a5d69eb3e4956c7086/frontend/public/components/monitoring/utils.ts#L47-L56 + group.rules.forEach(r => { + const key = [ + group.file, + group.name, + r.name, + r.duration, + r.query, + ..._.map(r.labels, (k, v) => `${k}=${v}`) + ].join(','); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + r.id = String(murmur3(key, 'monitoring-salt' as any)); + }); + return group.rules; + }); + setRules(rules); + setStats(buildStats(rules)); + return { + limitReached: false, + numQueries: 1, + dataSources: ['prometheus'] + }; + }) + .catch(err => { + const errStr = getHTTPErrorDetails(err, true); + setError(errStr); + }) + .finally(() => { + setLoading(false); + }); + }, []); + + usePoll(fetch, interval); + React.useEffect(fetch, []); + + return ( + + + + + {t('Network Health')} + + + + + + + +