diff --git a/pkg/handler/alertingmock/alerting_mock.go b/pkg/handler/alertingmock/alerting_mock.go new file mode 100644 index 000000000..4ce2cba7d --- /dev/null +++ b/pkg/handler/alertingmock/alerting_mock.go @@ -0,0 +1,209 @@ +package alertingmock + +import ( + "encoding/json" + "fmt" + "math/rand/v2" + "net/http" + "net/url" + "strings" + + "github.com/prometheus/common/model" + "github.com/sirupsen/logrus" +) + +var mlog = logrus.WithField("module", "alertingmock") + +var namespaces = []string{"uss-enterprise", "uss-raven", "la-sirena", "sh-raan", "the-whale-probe", "uss-defiant", "scimitar", "romulan-warbird", "uss-excelsior", "galileo", "phoenix"} +var nodes = []string{"caldonia", "denobula", "vulcan"} + +// We duplicate prom model due to missing json information +type AlertingRule struct { + Name string `json:"name"` + Labels model.LabelSet `json:"labels"` + Annotations model.LabelSet `json:"annotations"` + Alerts []*Alert `json:"alerts"` + State string `json:"state"` +} + +type Alert struct { + Annotations model.LabelSet `json:"annotations"` + Labels model.LabelSet `json:"labels"` + State string `json:"state"` + Value string `json:"value"` +} + +const ( + firing int = 0x01 + pending int = 0x02 + silenced int = 0x04 +) + +func stateToString(state int) string { + if state&firing > 0 { + return "firing" + } + if state&pending > 0 { + return "pending" + } + if state&silenced > 0 { + return "silenced" + } + return "inactive" +} + +func randomState() int { + rndState := rand.IntN(10) + if rndState < 1 { + return silenced + } else if rndState < 3 { + return pending + } + return firing +} + +func createAlert(probability float64, name, resourceName string, threshold, upperBound int, targetLabels, resourceNames []string, annotations, labels model.LabelSet) (*Alert, int) { + if rand.Float64() < probability { + alertLabels := labels.Clone() + alertState := randomState() + alertLabels["alertname"] = model.LabelValue(name) + for i, lbl := range targetLabels { + // First label will be "resourceName"; next are picked randomly + if i == 0 { + alertLabels[model.LabelName(lbl)] = model.LabelValue(resourceName) + } else { + idx := rand.IntN(len(resourceNames)) + alertLabels[model.LabelName(lbl)] = model.LabelValue(resourceNames[idx]) + } + } + val := float64(threshold) + rand.Float64()*float64(upperBound-threshold) + return &Alert{ + Annotations: annotations, + Labels: alertLabels, + State: stateToString(alertState), + Value: fmt.Sprintf("%f", val), + }, alertState + } + return nil, 0 +} + +func createAlerts(probability float64, name string, threshold, upperBound int, targetLabels, resourceNames []string, annotations, labels model.LabelSet) ([]*Alert, int) { + alerts := []*Alert{} + var ruleState int + for _, resourceName := range resourceNames { + if alert, state := createAlert(probability, name, resourceName, threshold, upperBound, targetLabels, resourceNames, annotations, labels); alert != nil { + ruleState |= state + alerts = append(alerts, alert) + } + } + return alerts, ruleState +} + +func createRule(probability float64, name, severity, extraFilter string, threshold, upperBound int, bynetobs bool, nsLbl, nodeLbl []string) AlertingRule { + labels := model.LabelSet{ + "severity": model.LabelValue(severity), + } + annotations := model.LabelSet{ + "description": model.LabelValue(name + " (a complete description...)"), + "summary": model.LabelValue(name + " (a summary...)"), + } + if bynetobs { + labels["app"] = "netobserv" + } + labels["netobserv"] = "true" + var jsonNsLbl, jsonNodeLbl string + if len(nsLbl) > 0 { + var quotedLbl []string + for _, lbl := range nsLbl { + quotedLbl = append(quotedLbl, `"`+lbl+`"`) + } + jsonNsLbl = fmt.Sprintf(`"namespaceLabels":[%s],`, strings.Join(quotedLbl, ",")) + } + if len(nodeLbl) > 0 { + var quotedLbl []string + for _, lbl := range nodeLbl { + quotedLbl = append(quotedLbl, `"`+lbl+`"`) + } + jsonNodeLbl = fmt.Sprintf(`"nodeLabels":[%s],`, strings.Join(quotedLbl, ",")) + } + searchURL := "https://duckduckgo.com/?q=" + url.PathEscape(name) + var extraFilterJSON string + if extraFilter != "" { + extraFilterJSON = fmt.Sprintf(`,"trafficLinkFilter":"%s"`, extraFilter) + } + annotations["netobserv_io_network_health"] = model.LabelValue(fmt.Sprintf( + `{%s%s"threshold":"%d","upperBound":"%d","unit":"%%","links":[{"name":"Search the web", "url": "%s"}]%s}`, + jsonNsLbl, + jsonNodeLbl, + threshold, + upperBound, + searchURL, + extraFilterJSON, + )) + ruleLabels := labels.Clone() + ruleLabels["prometheus"] = "openshift-monitoring/k8s" + var alerts []*Alert + var ruleState int + if len(nsLbl) > 0 { + alerts, ruleState = createAlerts(probability, name, threshold, upperBound, nsLbl, namespaces, annotations, labels) + } else if len(nodeLbl) > 0 { + alerts, ruleState = createAlerts(probability, name, threshold, upperBound, nodeLbl, nodes, annotations, labels) + } else { + // global + alerts = []*Alert{} + if alert, state := createAlert(probability, name, "", threshold, upperBound, nil, nil, annotations, labels); alert != nil { + ruleState |= state + alerts = append(alerts, alert) + } + } + return AlertingRule{ + Name: name, + Annotations: annotations, + Labels: labels, + State: stateToString(ruleState), + Alerts: alerts, + } +} + +func GetRules() func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, _ *http.Request) { + alertingRules := []AlertingRule{ + createRule(0.4, "Packet delivery failed", "info", "", 5, 100, true, []string{"SrcK8S_Namespace", "DstK8S_Namespace"}, []string{}), + createRule(0.3, "You have reached your hourly rate limit", "info", "", 5, 100, true, []string{"SrcK8S_Namespace", "DstK8S_Namespace"}, []string{}), + createRule(0.1, "It's always DNS", "warning", `dns_flag_response_code!=\"\"`, 15, 100, true, []string{"SrcK8S_Namespace", "DstK8S_Namespace"}, []string{}), + createRule(0.1, "We're under attack", "warning", "", 20, 100, true, []string{}, []string{}), + createRule(0.1, "Sh*t - Famous last words", "critical", "", 5, 100, true, []string{}, []string{"SrcK8S_Hostname", "DstK8S_Hostname"}), + createRule(0.3, "FromIngress", "info", "", 10, 100, false, []string{"exported_namespace"}, []string{}), + createRule(0.3, "Degraded latency", "info", "", 100, 1000, true, []string{"SrcK8S_Namespace", "DstK8S_Namespace"}, []string{}), + } + res := map[string]any{ + "status": "success", + "data": map[string]any{ + "groups": []map[string]any{ + { + "name": "group-name", + "file": "file", + "interval": 30, + "rules": alertingRules, + }, + }, + }, + } + + response, err := json.Marshal(res) + if err != nil { + mlog.Errorf("Marshalling error while responding JSON: %v", err) + w.WriteHeader(http.StatusInternalServerError) + if _, err = w.Write([]byte(err.Error())); err != nil { + mlog.Errorf("Error while responding error: %v", err) + } + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if _, err = w.Write(response); err != nil { + mlog.Errorf("Error while responding JSON: %v", err) + } + } +} diff --git a/pkg/server/routes.go b/pkg/server/routes.go index 7cafd6c61..c2d7ae3bf 100644 --- a/pkg/server/routes.go +++ b/pkg/server/routes.go @@ -9,6 +9,7 @@ import ( "github.com/netobserv/network-observability-console-plugin/pkg/config" "github.com/netobserv/network-observability-console-plugin/pkg/handler" + "github.com/netobserv/network-observability-console-plugin/pkg/handler/alertingmock" "github.com/netobserv/network-observability-console-plugin/pkg/kubernetes/auth" "github.com/netobserv/network-observability-console-plugin/pkg/prometheus" ) @@ -72,6 +73,11 @@ func setupRoutes(ctx context.Context, cfg *config.Config, authChecker auth.Check r.PathPrefix("/").Handler(http.FileServer(http.Dir("./web/dist/"))) } + if cfg.Loki.UseMocks { + // Add route for alerts (otherwise, the route is provided by the Console itself) + api.HandleFunc("/prometheus/api/v1/rules", alertingmock.GetRules()) + } + return r } diff --git a/web/locales/en/plugin__netobserv-plugin.json b/web/locales/en/plugin__netobserv-plugin.json index 2656688f7..7b99bb977 100644 --- a/web/locales/en/plugin__netobserv-plugin.json +++ b/web/locales/en/plugin__netobserv-plugin.json @@ -234,6 +234,42 @@ "Previous tip": "Previous tip", "Next tip": "Next tip", "Close tips": "Close tips", + "Summary": "Summary", + "Navigate to alert details": "Navigate to alert details", + "State": "State", + "Severity": "Severity", + "Labels": "Labels", + "Description": "Description", + "Navigate to network traffic": "Navigate to network traffic", + "Global": "Global", + "critical issues": "critical issues", + "warnings": "warnings", + "minor issues": "minor issues", + "pending issues": "pending issues", + "silenced issues": "silenced issues", + "Score": "Score", + "Weight": "Weight", + "No violations found": "No violations found", + "Global rule violations": "Global rule violations", + "No rules found, health cannot be determined": "No rules found, 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", + "Network Health": "Network Health", + "Rule violations per node": "Rule violations per node", + "Rule violations per namespace": "Rule violations per namespace", "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}}", @@ -335,7 +371,6 @@ "Filtered sum of packets": "Filtered sum of packets", "Filtered average speed": "Filtered average speed", "Bps": "Bps", - "Summary": "Summary", "Query limit reached": "Query limit reached", "Filtered flows count": "Filtered flows count", "Filtered ended conversations count": "Filtered ended conversations count", diff --git a/web/src/api/routes.ts b/web/src/api/routes.ts index 772c1a14c..b50758358 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}]`); } @@ -41,8 +43,8 @@ export const getAlerts = (): Promise => { }); }; -export const getSilencedAlerts = (): Promise => { - return axios.get('/api/alertmanager/api/v2/silences').then(r => { +export const getSilencedAlerts = (match: string): Promise => { + return axios.get(`/api/alertmanager/api/v2/silences?filter=${match}`).then(r => { if (r.status >= 400) { throw new Error(`${r.statusText} [code=${r.status}]`); } diff --git a/web/src/app.tsx b/web/src/app.tsx index 68d0aebae..bee8c3096 100755 --- a/web/src/app.tsx +++ b/web/src/app.tsx @@ -31,6 +31,7 @@ import FlowCollectorStatus from './components/forms/flowCollector-status'; import FlowCollectorWizard from './components/forms/flowCollector-wizard'; import FlowMetricForm from './components/forms/flowMetric'; import FlowMetricWizard from './components/forms/flowMetric-wizard'; +import NetworkHealth from './components/health/network-health'; import NetflowTrafficDevTab from './components/netflow-traffic-dev-tab'; import NetflowTrafficParent from './components/netflow-traffic-parent'; import NetflowTab from './components/netflow-traffic-tab'; @@ -61,6 +62,10 @@ export const pages = [ id: 'udn-tab', name: 'UDN tab' }, + { + id: 'network-health', + name: 'Network Health' + }, { id: 'flowCollector-wizard', name: 'FlowCollector wizard' @@ -199,6 +204,8 @@ export const App: React.FunctionComponent = () => { return ; case 'flowMetric': return ; + case 'network-health': + return ; default: return ; } @@ -215,6 +222,7 @@ export const App: React.FunctionComponent = () => { case 'flowCollector-status': case 'flowMetric-wizard': case 'flowMetric': + case 'network-health': return <>{content}; default: return ( diff --git a/web/src/components/alerts/fetcher.tsx b/web/src/components/alerts/fetcher.tsx index c342f2765..86b1124d3 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, @@ -41,7 +41,7 @@ export const AlertFetcher: React.FC = ({ children }) => { }, []); React.useEffect(() => { - getSilencedAlerts() + getSilencedAlerts('app=netobserv') .then(result => { setSilencedAlerts( result diff --git a/web/src/components/health/__tests__/helper.spec.tsx b/web/src/components/health/__tests__/helper.spec.tsx new file mode 100644 index 000000000..242e4cef5 --- /dev/null +++ b/web/src/components/health/__tests__/helper.spec.tsx @@ -0,0 +1,96 @@ +import { AlertStates } from '@openshift-console/dynamic-plugin-sdk'; +import { AlertWithRuleName, ByResource, computeAlertScore, computeScore } from '../health-helper'; + +const mockAlert = ( + name: string, + severity: string, + state: string, + threshold: number, + value: number +): AlertWithRuleName => { + return { + ruleName: name, + labels: { alertname: name, severity: severity }, + state: state as AlertStates, + annotations: {}, + ruleID: '', + metadata: { thresholdF: threshold, threshold: '', upperBoundF: 100, upperBound: '', unit: '%', links: [] }, + value: value + }; +}; + +describe('health helpers', () => { + it('should compute unweighted alert min score', () => { + const alert = mockAlert('test', 'critical', 'firing', 10, 10); + const score = computeAlertScore(alert); + expect(score.rawScore).toBeCloseTo(9.47, 2); + expect(score.weight).toEqual(1); + }); + + it('should compute unweighted alert max score', () => { + const alert = mockAlert('test', 'critical', 'firing', 10, 100); + const score = computeAlertScore(alert); + expect(score.rawScore).toEqual(0); + expect(score.weight).toEqual(1); + }); + + it('should compute weighted alert score', () => { + const alert = mockAlert('test', 'info', 'pending', 10, 10); + const score = computeAlertScore(alert); + expect(score.rawScore).toBeCloseTo(9.47, 2); + expect(score.weight).toEqual(0.075); + }); + + it('should compute unweighted alert score with upper bound', () => { + const alert = mockAlert('test', 'critical', 'firing', 100, 500); + alert.metadata!.upperBoundF = 1000; + const score = computeAlertScore(alert); + expect(score.rawScore).toBeCloseTo(5.26, 2); + }); + + it('should compute unweighted alert score with clamping', () => { + // below threshold + const alert = mockAlert('test', 'critical', 'firing', 100, 1); + alert.metadata!.upperBoundF = 1000; + let score = computeAlertScore(alert); + expect(score.rawScore).toEqual(10); + + // above upper bound + alert.value = 5000; + score = computeAlertScore(alert); + expect(score.rawScore).toEqual(0); + }); + + it('should compute full score', () => { + // Start with an empty one => max score + const r: ByResource = { + name: 'test', + critical: { firing: [], pending: [], silenced: [], inactive: [] }, + warning: { firing: [], pending: [], silenced: [], inactive: [] }, + other: { firing: [], pending: [], silenced: [], inactive: [] }, + score: 0 + }; + expect(computeScore(r)).toEqual(10); + + // Add 3 inactive alerts => still max score + r.critical.inactive.push('test-critical'); + r.warning.inactive.push('test-warning'); + r.other.inactive.push('test-info'); + expect(computeScore(r)).toEqual(10); + + // Turn the inactive info into pending => slightly decreasing score + r.other.inactive = []; + r.other.pending = [mockAlert('test-info', 'info', 'pending', 10, 20)]; + expect(computeScore(r)).toBeCloseTo(9.9, 1); + + // Turn the inactive warning into firing => more decreasing score + r.warning.inactive = []; + r.warning.firing = [mockAlert('test-warning', 'warning', 'firing', 10, 40)]; + expect(computeScore(r)).toBeCloseTo(8.8, 1); + + // Turn the inactive critical into firing => more decrease + r.critical.inactive = []; + r.critical.firing = [mockAlert('test-critical', 'critical', 'firing', 10, 40)]; + expect(computeScore(r)).toBeCloseTo(6.4, 1); + }); +}); diff --git a/web/src/components/health/alert-details.tsx b/web/src/components/health/alert-details.tsx new file mode 100644 index 000000000..bb083145f --- /dev/null +++ b/web/src/components/health/alert-details.tsx @@ -0,0 +1,58 @@ +import * as React from 'react'; + +import { Label, Text, TextContent, TextVariants } from '@patternfly/react-core'; +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; +import { valueFormat } from '../../utils/format'; +import { AlertWithRuleName, getAlertFilteredLabels, getAlertLink } from './health-helper'; + +export interface AlertDetailsProps { + resourceName: string; + alert: AlertWithRuleName; +} + +export const AlertDetails: React.FC = ({ resourceName, alert }) => { + const { t } = useTranslation('plugin__netobserv-plugin'); + const labels = getAlertFilteredLabels(alert, resourceName); + + return ( +
+ + + + {alert.annotations['summary']} + + + {alert.state} + {alert.labels.severity} + + {labels.length === 0 + ? t('None') + : labels.map(kv => ( + + ))} + + + <> + {valueFormat(alert.value as number, 2)} + {alert.metadata.threshold && ' > ' + alert.metadata.threshold + ' ' + alert.metadata.unit} + + + {alert.annotations['description']} + +
+ ); +}; + +export const AlertDetailsValue: React.FC<{ title: string }> = ({ title, children }) => { + return ( + <> + + {title} + + {children} + + ); +}; diff --git a/web/src/components/health/alert-row.tsx b/web/src/components/health/alert-row.tsx new file mode 100644 index 000000000..51c6ef9cf --- /dev/null +++ b/web/src/components/health/alert-row.tsx @@ -0,0 +1,86 @@ +import { ActionsColumn, Td, Tr } from '@patternfly/react-table'; +import * as React from 'react'; + +import { Label, Tooltip } from '@patternfly/react-core'; +import { useTranslation } from 'react-i18next'; +import { valueFormat } from '../../utils/format'; +import { HealthColorSquare } from './health-color-square'; +import { AlertWithRuleName, getAlertFilteredLabels, getAlertLink, getTrafficLink } from './health-helper'; + +export interface AlertRowProps { + kind: string; + resourceName: string; + alert: AlertWithRuleName; + wide: boolean; +} + +export const AlertRow: React.FC = ({ kind, resourceName, alert, wide }) => { + const { t } = useTranslation('plugin__netobserv-plugin'); + + const labels = getAlertFilteredLabels(alert, resourceName); + const links = [ + { + name: t('Navigate to alert details'), + url: getAlertLink(alert) + }, + { + name: t('Navigate to network traffic'), + url: getTrafficLink(kind, resourceName, alert) + }, + ...alert.metadata.links + ]; + + return ( + + {wide && ( + + + + )} + {alert.state} + {alert.labels.severity} + + {labels.length === 0 + ? t('None') + : labels.map(kv => ( + + ))} + + + {valueFormat(alert.value as number, 2)} + {alert.metadata.threshold && ' > ' + alert.metadata.threshold + ' ' + alert.metadata.unit} + + {wide && {alert.annotations['description']}} + + { + return { + title: {l.name} + }; + })} + /> + + + ); +}; + +export const AlertSummaryCell: React.FC<{ alert: AlertWithRuleName; showTooltip: boolean }> = ({ + alert, + showTooltip +}) => { + return ( + <> + + {showTooltip ? ( + + {alert.annotations['summary']} + + ) : ( + <>{alert.annotations['summary']} + )} + + ); +}; diff --git a/web/src/components/health/health-card.tsx b/web/src/components/health/health-card.tsx new file mode 100644 index 000000000..dcc2a06eb --- /dev/null +++ b/web/src/components/health/health-card.tsx @@ -0,0 +1,106 @@ +import { ResourceLink } from '@openshift-console/dynamic-plugin-sdk'; +import { + Card, + CardBody, + CardHeader, + CardTitle, + Flex, + FlexItem, + Text, + TextContent, + TextVariants +} from '@patternfly/react-core'; +import { BellIcon, ExclamationCircleIcon, ExclamationTriangleIcon, InfoAltIcon } from '@patternfly/react-icons'; +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; +import { valueFormat } from '../../utils/format'; +import { ByResource } from './health-helper'; + +export interface HealthCardProps { + stats: ByResource; + kind?: string; + isDark: boolean; + isSelected: boolean; + onClick?: () => void; +} + +export const HealthCard: React.FC = ({ stats, kind, isDark, isSelected, onClick }) => { + const { t } = useTranslation('plugin__netobserv-plugin'); + + const pending = [...stats.critical.pending, ...stats.warning.pending, ...stats.other.pending]; + const silenced = [...stats.critical.silenced, ...stats.warning.silenced, ...stats.other.silenced]; + const classes = ['card']; + let icon = ; + if (stats.critical.firing.length > 0) { + classes.push('critical'); + icon = ; + } else if (stats.warning.firing.length > 0) { + classes.push('warning'); + icon = ; + } else if (stats.other.firing.length > 0) { + classes.push('minor'); + icon = ; + } + if (isDark) { + classes.push('dark'); + } + + return ( + + + + {icon} + + {kind ? : t('Global')} + + + + + + +
    + {stats.critical.firing.length > 0 && ( +
  • + {stats.critical.firing.length} {t('critical issues')} +
  • + )} + {stats.warning.firing.length > 0 && ( +
  • + {stats.warning.firing.length} {t('warnings')} +
  • + )} + {stats.other.firing.length > 0 && ( +
  • + {stats.other.firing.length} {t('minor issues')} +
  • + )} + {pending.length > 0 && ( +
  • + {pending.length} {t('pending issues')} +
  • + )} + {silenced.length > 0 && ( +
  • + {silenced.length} {t('silenced issues')} +
  • + )} +
+
+ + + {valueFormat(stats.score)} + + +
+
+
+ ); +}; diff --git a/web/src/components/health/health-color-square.css b/web/src/components/health/health-color-square.css new file mode 100644 index 000000000..d7d7a5485 --- /dev/null +++ b/web/src/components/health/health-color-square.css @@ -0,0 +1,23 @@ +.cell { + border-radius: 0.25rem; + width: 1rem; + height: 1rem; + display: inline-block; + vertical-align: middle; + margin-right: 0.2rem; +} + +.gradient { + border-radius: 0.1rem; + width: 5rem; + height: 0.25rem; + display: inline-block; + margin-left: 0.2rem; +} + +.gradient .vertical-mark { + position: relative; + display: block; + height: 100%; + border-right: solid 0.1rem black; +} diff --git a/web/src/components/health/health-color-square.tsx b/web/src/components/health/health-color-square.tsx new file mode 100644 index 000000000..d7d283024 --- /dev/null +++ b/web/src/components/health/health-color-square.tsx @@ -0,0 +1,90 @@ +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; +import { valueFormat } from '../../utils/format'; +import { AlertWithRuleName, computeAlertScore, computeExcessRatioStatusWeighted } from './health-helper'; + +import { Tooltip } from '@patternfly/react-core'; +import './health-color-square.css'; + +export interface HealthColorSquareProps { + alert: AlertWithRuleName; +} + +// rgb in [0,255] bounds +type Color = { r: number; g: number; b: number }; +type ColorMap = Color[]; + +const criticalColorMap: ColorMap = [ + { r: 250, g: 234, b: 232 }, + { r: 163, g: 0, b: 0 }, + { r: 44, g: 0, b: 0 }, + { r: 20, g: 0, b: 20 } +]; + +const warningColorMap: ColorMap = [ + { r: 253, g: 247, b: 231 }, + { r: 240, g: 171, b: 0 }, + { r: 236, g: 122, b: 8 }, + { r: 59, g: 31, b: 0 } +]; + +const infoColorMap: ColorMap = [ + { r: 62, g: 134, b: 53 }, + { r: 228, g: 245, b: 188 }, + { r: 154, g: 216, b: 216 } +]; + +const getCellColors = (value: number, rangeFrom: number, rangeTo: number, colorMap: ColorMap) => { + const clamped = Math.max(rangeFrom, Math.min(rangeTo, value)); + const ratio = (clamped - rangeFrom) / (rangeTo - rangeFrom); // e.g. 0.8 | 0 | 1 + const colorRatio = ratio * (colorMap.length - 1); // e.g. (length is 3) 1.6 | 0 | 2 + const colorLow = colorMap[Math.floor(colorRatio)]; // e.g. m[1] | m[0] | m[2] + const colorHigh = colorMap[Math.ceil(colorRatio)]; // e.g. m[2] | m[0] | m[2] + const remains = colorRatio - Math.floor(colorRatio); // e.g. 0.6 | 0 | 0 + const r = Math.floor((colorHigh.r - colorLow.r) * remains + colorLow.r); + const g = Math.floor((colorHigh.g - colorLow.g) * remains + colorLow.g); + const b = Math.floor((colorHigh.b - colorLow.b) * remains + colorLow.b); + const brightness = 0.21 * r + 0.72 * g + 0.07 * b; // https://www.johndcook.com/blog/2009/08/24/algorithms-convert-color-grayscale/ + const textColor = brightness > 128 ? 'var(--pf-global--palette--black-1000)' : 'var(--pf-global--palette--black-100)'; + return { + color: textColor, + backgroundColor: `rgb(${r},${g},${b})` + }; +}; + +const buildGradientCSS = (colorMap: ColorMap): string => { + const colorStops = colorMap.map(c => `rgb(${c.r},${c.g},${c.b})`); + return 'linear-gradient(to right,' + colorStops.join(',') + ')'; +}; + +export const HealthColorSquare: React.FC = ({ alert }) => { + const { t } = useTranslation('plugin__netobserv-plugin'); + + const colorMap = + alert.labels.severity === 'critical' + ? criticalColorMap + : alert.labels.severity === 'warning' + ? warningColorMap + : infoColorMap; + + const scoreForMap = computeExcessRatioStatusWeighted(alert); + const score = computeAlertScore(alert); + + return ( + + {t('Score') + ': ' + valueFormat(score.rawScore)} +
+ {t('Weight') + ': ' + score.weight} +
+
+ +
+ + } + > +
+ + ); +}; diff --git a/web/src/components/health/health-drawer-container.tsx b/web/src/components/health/health-drawer-container.tsx new file mode 100644 index 000000000..f10642c6f --- /dev/null +++ b/web/src/components/health/health-drawer-container.tsx @@ -0,0 +1,87 @@ +import { ResourceLink } from '@openshift-console/dynamic-plugin-sdk'; +import { + Drawer, + DrawerContent, + DrawerContentBody, + DrawerHead, + DrawerPanelContent, + Text, + TextContent, + TextVariants +} from '@patternfly/react-core'; +import * as React from 'react'; +import { HealthGallery } from './health-gallery'; +import { ByResource } from './health-helper'; +import { RuleDetails } from './rule-details'; + +export interface HealthDrawerContainerProps { + title: string; + stats: ByResource[]; + kind: string; + isDark: boolean; +} + +export const HealthDrawerContainer: React.FC = ({ title, stats, kind, isDark }) => { + const [selectedResource, setSelectedResource] = React.useState(undefined); + const drawerRef = React.useRef(null); + + const onExpand = () => { + drawerRef.current && drawerRef.current.focus(); + }; + + React.useEffect(() => { + if (selectedResource) { + const fromStats = stats.find(s => s.name === selectedResource.name); + if (fromStats !== selectedResource) { + setSelectedResource(fromStats); + } + } + // we want to update selectedResource when stats changes, no more + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [stats]); + + return ( + <> + + {title} + + + + + + {selectedResource !== undefined && ( + <> + + + )} + + + {selectedResource && ( +
+ +
+ )} + + } + > + + + +
+
+ + ); +}; 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/health/health-gallery.tsx b/web/src/components/health/health-gallery.tsx new file mode 100644 index 000000000..b20574a0c --- /dev/null +++ b/web/src/components/health/health-gallery.tsx @@ -0,0 +1,56 @@ +import { Bullseye, EmptyState, EmptyStateIcon, Gallery, Title } from '@patternfly/react-core'; +import { CheckCircleIcon } from '@patternfly/react-icons'; +import * as _ from 'lodash'; +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; +import { HealthCard } from './health-card'; +import { ByResource } from './health-helper'; + +export interface HealthGalleryProps { + stats: ByResource[]; + kind: string; + isDark: boolean; + selectedResource: ByResource | undefined; + setSelectedResource: (r: ByResource | undefined) => void; +} + +export const HealthGallery: React.FC = ({ + stats, + kind, + isDark, + selectedResource, + setSelectedResource +}) => { + const { t } = useTranslation('plugin__netobserv-plugin'); + + const sorted = _.orderBy(stats, r => r.score, 'asc'); + + return ( + <> + {sorted.length === 0 && ( + + + + {t('No violations found')} + + + )} + + {sorted.map(r => { + return ( + { + setSelectedResource(r.name !== selectedResource?.name ? r : undefined); + }} + /> + ); + })} + + + ); +}; diff --git a/web/src/components/health/health-global.tsx b/web/src/components/health/health-global.tsx new file mode 100644 index 000000000..57de509a0 --- /dev/null +++ b/web/src/components/health/health-global.tsx @@ -0,0 +1,52 @@ +import { + Bullseye, + EmptyState, + EmptyStateIcon, + Grid, + GridItem, + Text, + TextContent, + TextVariants, + Title +} from '@patternfly/react-core'; +import { CheckCircleIcon } from '@patternfly/react-icons'; +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; +import { HealthCard } from './health-card'; +import { ByResource, getAllAlerts } from './health-helper'; +import { RuleDetails } from './rule-details'; + +export interface HealthGlobalProps { + info: ByResource; + isDark: boolean; +} + +export const HealthGlobal: React.FC = ({ info, isDark }) => { + const { t } = useTranslation('plugin__netobserv-plugin'); + const allAlerts = getAllAlerts(info); + + return ( + <> + + {t('Global rule violations')} + + {allAlerts.length === 0 ? ( + + + + {t('No violations found')} + + + ) : ( + + + + + + + + + )} + + ); +}; diff --git a/web/src/components/health/health-helper.ts b/web/src/components/health/health-helper.ts new file mode 100644 index 000000000..bfb48dd0d --- /dev/null +++ b/web/src/components/health/health-helper.ts @@ -0,0 +1,340 @@ +import { PrometheusAlert, PrometheusLabels, Rule } from '@openshift-console/dynamic-plugin-sdk'; +import * as _ from 'lodash'; +import { SilenceMatcher } from '../../api/alert'; + +export type HealthStats = { + global: ByResource; + byNamespace: ByResource[]; + byNode: ByResource[]; +}; + +export type ByResource = { + name: string; + critical: SeverityStats; + warning: SeverityStats; + other: SeverityStats; + score: number; +}; + +type SeverityStats = { + firing: AlertWithRuleName[]; + pending: AlertWithRuleName[]; + silenced: AlertWithRuleName[]; + inactive: string[]; +}; + +export type AlertWithRuleName = PrometheusAlert & { + ruleName: string; + ruleID: string; + metadata: HealthMetadata; +}; + +type RuleWithMetadata = Rule & { + metadata: HealthMetadata; +}; + +type HealthMetadata = { + threshold?: string; + thresholdF: number; + upperBound: string; + upperBoundF: number; + unit: string; + nodeLabels?: string[]; + namespaceLabels?: string[]; + links: { name: string; url: string }[]; + trafficLinkFilter?: string; +}; + +type ScoreDetail = { + rawScore: number; + weight: number; +}; + +const getHealthMetadata = (annotations: PrometheusLabels): HealthMetadata => { + const defaultMetadata: HealthMetadata = { + thresholdF: 0, + upperBound: '100', + upperBoundF: 100, + unit: '%', + links: [] + }; + if ('netobserv_io_network_health' in annotations) { + const md = (JSON.parse(annotations['netobserv_io_network_health']) as HealthMetadata) || undefined; + if (md) { + // Setup defaults and derived + md.unit = md.unit || defaultMetadata.unit; + md.upperBound = md.upperBound || defaultMetadata.upperBound; + md.links = md.links || defaultMetadata.links; + md.thresholdF = md.threshold ? parseFloat(md.threshold) || 0 : 0; + md.upperBoundF = parseFloat(md.upperBound) || defaultMetadata.upperBoundF; + return md; + } + } + return defaultMetadata; +}; + +export const buildStats = (rules: Rule[]): HealthStats => { + const ruleWithMD: RuleWithMetadata[] = rules.map(r => { + const md = getHealthMetadata(r.annotations); + return { ...r, metadata: md }; + }); + const alerts: AlertWithRuleName[] = ruleWithMD.flatMap(r => { + return r.alerts.map(a => { + if (typeof a.value === 'string') { + a.value = parseFloat(a.value); + } + return { ...a, ruleName: r.name, ruleID: r.id, metadata: r.metadata }; + }); + }); + + 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); + // Inject inactive rules + const globalRules = ruleWithMD.filter(r => _.isEmpty(r.metadata.namespaceLabels) && _.isEmpty(r.metadata.nodeLabels)); + const namespaceRules = ruleWithMD.filter(r => !_.isEmpty(r.metadata.namespaceLabels)); + const nodeRules = ruleWithMD.filter(r => !_.isEmpty(r.metadata.nodeLabels)); + injectInactive(globalRules, [global]); + injectInactive(namespaceRules, byNamespace); + injectInactive(nodeRules, byNode); + [global, ...byNamespace, ...byNode].forEach(r => { + r.score = computeScore(r); + }); + 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)); + return statsFromGrouped('', 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]; + } + }); + }); + const stats: ByResource[] = []; + _.keys(groups).forEach(k => { + if (k) { + stats.push(statsFromGrouped(k, groups[k])); + } + }); + return stats; +}; + +const statsFromGrouped = (name: string, grouped: AlertWithRuleName[]): ByResource => { + const br: ByResource = { + name: name, + critical: { firing: [], pending: [], silenced: [], inactive: [] }, + warning: { firing: [], pending: [], silenced: [], inactive: [] }, + other: { firing: [], pending: [], silenced: [], inactive: [] }, + score: 0 + }; + _.uniqWith(grouped, (a, b) => { + return a.ruleName === b.ruleName && _.isEqual(a.labels, b.labels); + }).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); + break; + case 'pending': + stats.pending.push(alert); + break; + case 'silenced': + stats.silenced.push(alert); + break; + } + }); + return br; +}; + +const injectInactive = (rules: RuleWithMetadata[], groups: ByResource[]) => { + groups.forEach(g => { + const allAlerts = getAllAlerts(g).map(a => a.ruleName); + rules.forEach(r => { + if (allAlerts.includes(r.name)) { + // There's an alert for this rule, skip + return; + } + switch (r.labels.severity) { + case 'critical': + g.critical.inactive.push(r.name); + break; + case 'warning': + g.warning.inactive.push(r.name); + break; + default: + g.other.inactive.push(r.name); + break; + } + }); + }); +}; + +export const getAllAlerts = (g: ByResource): AlertWithRuleName[] => { + return [ + ...g.critical.firing, + ...g.warning.firing, + ...g.other.firing, + ...g.critical.pending, + ...g.warning.pending, + ...g.other.pending, + ...g.critical.silenced, + ...g.warning.silenced, + ...g.other.silenced + ]; +}; + +export const getAlertFilteredLabels = (alert: AlertWithRuleName, target: string): [string, string][] => { + return Object.entries(alert.labels).filter( + e => e[0] !== 'app' && e[0] !== 'netobserv' && e[0] !== 'severity' && e[0] !== 'alertname' && e[1] !== target + ); +}; + +export const getAlertLink = (a: AlertWithRuleName): string => { + const labels: string[] = []; + Object.keys(a.labels).forEach(k => { + labels.push(k + '=' + a.labels[k]); + }); + return `/monitoring/alerts/${a.ruleID}?${labels.join('&')}`; +}; + +export const getTrafficLink = (kind: string, resourceName: string, a: AlertWithRuleName): string => { + const filters: string[] = []; + let params = ''; + switch (kind) { + case 'Namespace': + filters.push(`src_namespace="${resourceName}"`); + params += '&bnf=true'; + break; + case 'Node': + filters.push(`src_node="${resourceName}"`); + params += '&bnf=true'; + break; + } + if (a.metadata.trafficLinkFilter) { + filters.push(a.metadata.trafficLinkFilter); + } + return `/netflow-traffic?filters=${encodeURIComponent(filters.join(';'))}${params}`; +}; + +const criticalWeight = 1; +const warningWeight = 0.5; +const minorWeight = 0.25; +const pendingWeight = 0.3; +const silencedWeight = 0.1; + +const getSeverityWeight = (a: AlertWithRuleName) => { + switch (a.labels.severity) { + case 'critical': + return criticalWeight; + case 'warning': + return warningWeight; + default: + return minorWeight; + } +}; + +const getStateWeight = (a: AlertWithRuleName) => { + switch (a.state) { + case 'pending': + return pendingWeight; + case 'silenced': + return silencedWeight; + } + return 1; +}; + +// Score [0,10]; higher is better +export const computeScore = (r: ByResource): number => { + const allAlerts = getAllAlerts(r); + const allScores = allAlerts + .map(computeAlertScore) + .concat(r.critical.inactive.map(name => ({ alertName: name, rawScore: 10, weight: criticalWeight }))) + .concat(r.warning.inactive.map(name => ({ alertName: name, rawScore: 10, weight: warningWeight }))) + .concat(r.other.inactive.map(name => ({ alertName: name, rawScore: 10, weight: minorWeight }))); + const sum = allScores.map(s => s.rawScore * s.weight).reduce((a, b) => a + b, 0); + const sumWeights = allScores.map(s => s.weight).reduce((a, b) => a + b, 0); + if (sumWeights === 0) { + return 10; + } + return sum / sumWeights; +}; + +// Score [0,1]; lower is better +const computeExcessRatio = (a: AlertWithRuleName): number => { + // Assuming the alert value is a [0-n] percentage. Needs update if more use cases come up. + const threshold = a.metadata.thresholdF / 2; + const upper = a.metadata.upperBoundF; + const vclamped = Math.min(Math.max(a.value as number, threshold), upper); + const range = upper - threshold; + return (vclamped - threshold) / range; +}; + +export const computeExcessRatioStatusWeighted = (a: AlertWithRuleName): number => { + return computeExcessRatio(a) * getStateWeight(a); +}; + +// Score [0,10]; higher is better +export const computeAlertScore = (a: AlertWithRuleName): ScoreDetail => { + return { + rawScore: 10 * (1 - computeExcessRatio(a)), + weight: getSeverityWeight(a) * getStateWeight(a) + }; +}; + +export const isSilenced = (silence: SilenceMatcher[], labels: PrometheusLabels): boolean => { + for (const matcher of silence) { + if (!(matcher.name in labels)) { + return false; + } + if (matcher.value !== labels[matcher.name]) { + return false; + } + } + return true; +}; diff --git a/web/src/components/health/health-summary.tsx b/web/src/components/health/health-summary.tsx new file mode 100644 index 000000000..32e0c6e17 --- /dev/null +++ b/web/src/components/health/health-summary.tsx @@ -0,0 +1,100 @@ +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[]; +} + +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/health/health.css b/web/src/components/health/health.css new file mode 100644 index 000000000..c3779af0b --- /dev/null +++ b/web/src/components/health/health.css @@ -0,0 +1,92 @@ +#pageSection h3 { + padding-top: 0.6rem; + padding-bottom: 0.6rem; +} + +#pageSection .icon.critical { + color: #b1380b; +} + +#pageSection .dark .icon.critical { + color: #f0561d; +} + +#pageSection .icon.warning { + color: #dca614; +} + +#pageSection .dark .icon.warning { + color: #ffcc17; +} + +#pageSection .icon.minor, +#pageSection .dark .icon.minor { + color: #9ad8d8; +} + +.health-gallery-drawer .pf-v5-c-drawer__body, +.health-gallery-drawer>div { + height: initial; +} + +.health-gallery-drawer-content { + padding: 1.5rem; +} + +.netobserv-refresh-interval-container { + margin: 0 !important; +} + +/*rotate animation for loading icon*/ +@keyframes spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +.card { + border-top: 0.2rem solid; + border-top-color: #6a6e73; +} + +.card.dark { + border-top-color: #868789; +} + +.card.critical { + border-top-color: #b1380b; +} + +.card.dark.critical { + border-top-color: #f0561d; +} + +.card.warning { + border-top-color: #dca614; +} + +.card.dark.warning { + border-top-color: #ffcc17; +} + +.card.minor, +.card.dark.minor { + border-top-color: #9ad8d8; +} + +.card-header { + min-height: 5rem; +} + +.card-header .icon { + width: 1.9rem; + height: 1.9rem; +} + +.health-gallery-drawer-content .pf-m-no-padding { + padding: 0; +} diff --git a/web/src/components/health/network-health.tsx b/web/src/components/health/network-health.tsx new file mode 100644 index 000000000..95bce61d5 --- /dev/null +++ b/web/src/components/health/network-health.tsx @@ -0,0 +1,179 @@ +import { AlertStates, Rule } from '@openshift-console/dynamic-plugin-sdk'; +import { Button, Flex, FlexItem, PageSection, Tab, Tabs, 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 { SilenceMatcher } from '../../api/alert'; +import { getAlerts, getSilencedAlerts } 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 { HealthDrawerContainer } from './health-drawer-container'; +import HealthError from './health-error'; +import { HealthGlobal } from './health-global'; +import { buildStats, isSilenced } from './health-helper'; +import { HealthSummary } from './health-summary'; +import { HealthTabTitle } from './tab-title'; + +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, undefined); + const [rawRules, setRawRules] = React.useState([]); + const [silenced, setSilenced] = React.useState([]); + const [activeTabKey, setActiveTabKey] = React.useState('global'); + + 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; + }); + setRawRules(rules); + return { + limitReached: false, + numQueries: 1, + dataSources: ['prometheus'] + }; + }) + .catch(err => { + const errStr = getHTTPErrorDetails(err, true); + setError(errStr); + }) + .finally(() => { + setLoading(false); + }); + + getSilencedAlerts('netobserv=true') + .then(res => { + const silenced = res.filter(a => a.status.state == 'active').map(a => a.matchers); + setSilenced(silenced); + }) + .catch(err => { + console.log('Could not get silenced alerts:', err); + // Showing all alerts since we could not get silenced alerts list + setSilenced([]); + }); + }, []); + + usePoll(fetch, interval); + React.useEffect(fetch, [fetch]); + + const rules = rawRules.map(r => { + const alerts = r.alerts.map(a => { + let state = a.state; + const labels = { ...r.labels, ...a.labels }; + if (silenced.some(s => isSilenced(s, labels))) { + state = 'silenced' as AlertStates; + } + return { ...a, state: state }; + }); + return { ...r, alerts: alerts }; + }); + const stats = buildStats(rules); + + return ( + + + + + {t('Network Health')} + + + + + + + +