From 1c66aea992387a5f55a832264a44e5d56311f4d6 Mon Sep 17 00:00:00 2001 From: Boyko Date: Tue, 10 Dec 2019 00:42:59 +0200 Subject: [PATCH] React UI: Implement alerts page (#6402) * url filter rules param Signed-off-by: blalov Signed-off-by: Boyko Lalov Signed-off-by: blalov * address review changes Signed-off-by: blalov Signed-off-by: Boyko Lalov Signed-off-by: blalov * ui initial commit Signed-off-by: blalov Signed-off-by: Boyko Lalov Signed-off-by: blalov * improve ui Signed-off-by: blalov Signed-off-by: Boyko Lalov Signed-off-by: blalov * fix typo in component name Signed-off-by: Boyko Lalov Signed-off-by: blalov * create query link + ui enhancements Signed-off-by: Boyko Lalov Signed-off-by: blalov * add count to state labels Signed-off-by: blalov * put alerts table render in the right place Signed-off-by: blalov * refactoring Signed-off-by: blalov * fix rules endpoint test Signed-off-by: blalov * lint fixes Signed-off-by: blalov * test query params Signed-off-by: blalov * refactoring Signed-off-by: blalov * review changes Signed-off-by: blalov * adding down arrow as click indicator in Alert Signed-off-by: blalov * add period at the end of the comment Signed-off-by: blalov * review changes Signed-off-by: blalov * remove left-over css Signed-off-by: blalov * adding expand/collapse arrows on Alert Signed-off-by: blalov * create proper expression for alert name Signed-off-by: blalov --- web/api/v1/api.go | 26 +++- web/api/v1/api_test.go | 65 +++++++++ web/ui/react-app/src/App.css | 12 ++ web/ui/react-app/src/pages/Alerts.tsx | 15 -- .../src/pages/alerts/AlertContents.tsx | 136 ++++++++++++++++++ web/ui/react-app/src/pages/alerts/Alerts.tsx | 33 +++++ .../pages/alerts/CollapsibleAlertPanel.tsx | 125 ++++++++++++++++ web/ui/react-app/src/pages/index.ts | 2 +- 8 files changed, 394 insertions(+), 20 deletions(-) delete mode 100644 web/ui/react-app/src/pages/Alerts.tsx create mode 100644 web/ui/react-app/src/pages/alerts/AlertContents.tsx create mode 100644 web/ui/react-app/src/pages/alerts/Alerts.tsx create mode 100644 web/ui/react-app/src/pages/alerts/CollapsibleAlertPanel.tsx diff --git a/web/api/v1/api.go b/web/api/v1/api.go index 61a97554c..8d7e75440 100644 --- a/web/api/v1/api.go +++ b/web/api/v1/api.go @@ -824,6 +824,8 @@ type RuleGroup struct { type rule interface{} type alertingRule struct { + // State can be "pending", "firing", "inactive". + State string `json:"state"` Name string `json:"name"` Query string `json:"query"` Duration float64 `json:"duration"` @@ -849,6 +851,16 @@ type recordingRule struct { func (api *API) rules(r *http.Request) apiFuncResult { ruleGroups := api.rulesRetriever.RuleGroups() res := &RuleDiscovery{RuleGroups: make([]*RuleGroup, len(ruleGroups))} + typeParam := strings.ToLower(r.URL.Query().Get("type")) + + if typeParam != "" && typeParam != "alert" && typeParam != "record" { + err := errors.Errorf("invalid query parameter type='%v'", typeParam) + return apiFuncResult{nil, &apiError{errorBadData, err}, nil, nil} + } + + returnAlerts := typeParam == "" || typeParam == "alert" + returnRecording := typeParam == "" || typeParam == "record" + for i, grp := range ruleGroups { apiRuleGroup := &RuleGroup{ Name: grp.Name(), @@ -856,7 +868,6 @@ func (api *API) rules(r *http.Request) apiFuncResult { Interval: grp.Interval().Seconds(), Rules: []rule{}, } - for _, r := range grp.Rules() { var enrichedRule rule @@ -864,10 +875,13 @@ func (api *API) rules(r *http.Request) apiFuncResult { if r.LastError() != nil { lastError = r.LastError().Error() } - switch rule := r.(type) { case *rules.AlertingRule: + if !returnAlerts { + break + } enrichedRule = alertingRule{ + State: rule.State().String(), Name: rule.Name(), Query: rule.Query().String(), Duration: rule.Duration().Seconds(), @@ -879,6 +893,9 @@ func (api *API) rules(r *http.Request) apiFuncResult { Type: "alerting", } case *rules.RecordingRule: + if !returnRecording { + break + } enrichedRule = recordingRule{ Name: rule.Name(), Query: rule.Query().String(), @@ -891,8 +908,9 @@ func (api *API) rules(r *http.Request) apiFuncResult { err := errors.Errorf("failed to assert type of rule '%v'", rule.Name()) return apiFuncResult{nil, &apiError{errorInternal, err}, nil, nil} } - - apiRuleGroup.Rules = append(apiRuleGroup.Rules, enrichedRule) + if enrichedRule != nil { + apiRuleGroup.Rules = append(apiRuleGroup.Rules, enrichedRule) + } } res.RuleGroups[i] = apiRuleGroup } diff --git a/web/api/v1/api_test.go b/web/api/v1/api_test.go index 00ad825f2..eafe52f54 100644 --- a/web/api/v1/api_test.go +++ b/web/api/v1/api_test.go @@ -1067,6 +1067,7 @@ func testEndpoints(t *testing.T, api *API, testLabelAPI bool) { Interval: 1, Rules: []rule{ alertingRule{ + State: "inactive", Name: "test_metric3", Query: "absent(test_metric3) != 1", Duration: 1, @@ -1077,6 +1078,7 @@ func testEndpoints(t *testing.T, api *API, testLabelAPI bool) { Type: "alerting", }, alertingRule{ + State: "inactive", Name: "test_metric4", Query: "up == 1", Duration: 1, @@ -1098,6 +1100,69 @@ func testEndpoints(t *testing.T, api *API, testLabelAPI bool) { }, }, }, + { + endpoint: api.rules, + query: url.Values{ + "type": []string{"alert"}, + }, + response: &RuleDiscovery{ + RuleGroups: []*RuleGroup{ + { + Name: "grp", + File: "/path/to/file", + Interval: 1, + Rules: []rule{ + alertingRule{ + State: "inactive", + Name: "test_metric3", + Query: "absent(test_metric3) != 1", + Duration: 1, + Labels: labels.Labels{}, + Annotations: labels.Labels{}, + Alerts: []*Alert{}, + Health: "unknown", + Type: "alerting", + }, + alertingRule{ + State: "inactive", + Name: "test_metric4", + Query: "up == 1", + Duration: 1, + Labels: labels.Labels{}, + Annotations: labels.Labels{}, + Alerts: []*Alert{}, + Health: "unknown", + Type: "alerting", + }, + }, + }, + }, + }, + }, + { + endpoint: api.rules, + query: url.Values{ + "type": []string{"record"}, + }, + response: &RuleDiscovery{ + RuleGroups: []*RuleGroup{ + { + Name: "grp", + File: "/path/to/file", + Interval: 1, + Rules: []rule{ + recordingRule{ + Name: "recording-rule-1", + Query: "vector(1)", + Labels: labels.Labels{}, + Health: "unknown", + Type: "recording", + }, + }, + }, + }, + }, + }, } if testLabelAPI { diff --git a/web/ui/react-app/src/App.css b/web/ui/react-app/src/App.css index 303ee0fae..8d74af8e0 100644 --- a/web/ui/react-app/src/App.css +++ b/web/ui/react-app/src/App.css @@ -211,3 +211,15 @@ div.time-input { .add-panel-btn { margin-bottom: 20px; } + +.status-badges { + display: flex; + justify-content: space-between; + margin-bottom: 10px; + padding: 10px; +} + +.badges-wrapper > span { + margin-right: 5px; + max-height: 20px; +} diff --git a/web/ui/react-app/src/pages/Alerts.tsx b/web/ui/react-app/src/pages/Alerts.tsx deleted file mode 100644 index e06163d5c..000000000 --- a/web/ui/react-app/src/pages/Alerts.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import React, { FC } from 'react'; -import { RouteComponentProps } from '@reach/router'; -import PathPrefixProps from '../PathPrefixProps'; -import { Alert } from 'reactstrap'; - -const Alerts: FC = ({ pathPrefix }) => ( - <> -

Alerts

- - This page is still under construction. Please try it in the Classic UI. - - -); - -export default Alerts; diff --git a/web/ui/react-app/src/pages/alerts/AlertContents.tsx b/web/ui/react-app/src/pages/alerts/AlertContents.tsx new file mode 100644 index 000000000..76c611d0b --- /dev/null +++ b/web/ui/react-app/src/pages/alerts/AlertContents.tsx @@ -0,0 +1,136 @@ +import React, { FC, useState, Fragment } from 'react'; +import { ButtonGroup, Button, Row, Badge } from 'reactstrap'; +import CollapsibleAlertPanel from './CollapsibleAlertPanel'; +import Checkbox from '../../Checkbox'; +import { isPresent } from '../../utils/func'; + +export type RuleState = keyof RuleStatus; + +export interface Rule { + alerts: Alert[]; + annotations: Record; + duration: number; + health: string; + labels: Record; + name: string; + query: string; + state: RuleState; + type: string; +} + +export interface RuleStatus { + firing: T; + pending: T; + inactive: T; +} + +export interface AlertsProps { + groups?: RuleGroup[]; + statsCount: RuleStatus; +} + +interface Alert { + labels: Record; + state: RuleState; + value: string; + annotations: Record; + activeAt: string; +} + +interface RuleGroup { + name: string; + file: string; + rules: Rule[]; + interval: number; +} + +const AlertsContent: FC = ({ groups = [], statsCount }) => { + const [state, setState] = useState>({ + firing: true, + pending: true, + inactive: true, + }); + const [showAnnotations, setShowAnnotations] = useState(false); + + const toggle = (ruleState: RuleState) => () => { + setState({ + ...state, + [ruleState]: !state[ruleState], + }); + }; + + return ( + <> + + + + + + + setShowAnnotations(!showAnnotations)} + > + Show annotations + + + {groups.map((group, i) => { + return ( + + + {group.file} > {group.name} + + {group.rules.map((rule, j) => { + return ( + state[rule.state] && ( + + ) + ); + })} + + ); + })} + + ); +}; + +interface StatusBadgesProps { + rules: Rule[]; +} + +const StatusBadges: FC = ({ rules, children }) => { + const statesCounter = rules.reduce( + (acc, r) => { + return { + ...acc, + [r.state]: acc[r.state] + r.alerts.length, + }; + }, + { + firing: 0, + pending: 0, + } + ); + + return ( +
+ {children} +
+ {isPresent(statesCounter.inactive) && inactive} + {statesCounter.pending > 0 && pending ({statesCounter.pending})} + {statesCounter.firing > 0 && firing ({statesCounter.firing})} +
+
+ ); +}; + +AlertsContent.displayName = 'Alerts'; + +export default AlertsContent; diff --git a/web/ui/react-app/src/pages/alerts/Alerts.tsx b/web/ui/react-app/src/pages/alerts/Alerts.tsx new file mode 100644 index 000000000..d4e070233 --- /dev/null +++ b/web/ui/react-app/src/pages/alerts/Alerts.tsx @@ -0,0 +1,33 @@ +import React, { FC } from 'react'; +import { RouteComponentProps } from '@reach/router'; +import PathPrefixProps from '../../PathPrefixProps'; +import { useFetch } from '../../utils/useFetch'; +import { withStatusIndicator } from '../../withStatusIndicator'; +import AlertsContent, { RuleStatus, AlertsProps } from './AlertContents'; + +const AlertsWithStatusIndicator = withStatusIndicator(AlertsContent); + +const Alerts: FC = ({ pathPrefix = '' }) => { + const { response, error, isLoading } = useFetch(`${pathPrefix}/api/v1/rules?type=alert`); + + const ruleStatsCount: RuleStatus = { + inactive: 0, + pending: 0, + firing: 0, + }; + + if (response.data && response.data.groups) { + response.data.groups.forEach(el => el.rules.forEach(r => ruleStatsCount[r.state]++)); + } + + return ( + + ); +}; + +export default Alerts; diff --git a/web/ui/react-app/src/pages/alerts/CollapsibleAlertPanel.tsx b/web/ui/react-app/src/pages/alerts/CollapsibleAlertPanel.tsx new file mode 100644 index 000000000..2798e4922 --- /dev/null +++ b/web/ui/react-app/src/pages/alerts/CollapsibleAlertPanel.tsx @@ -0,0 +1,125 @@ +import React, { FC, useState, Fragment } from 'react'; +import { Link } from '@reach/router'; +import { Alert, Collapse, Table, Badge } from 'reactstrap'; +import { Rule, RuleStatus } from './AlertContents'; +import { faChevronDown, faChevronRight } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; + +interface CollapsibleAlertPanelProps { + rule: Rule; + showAnnotations: boolean; +} + +const alertColors: RuleStatus = { + firing: 'danger', + pending: 'warning', + inactive: 'success', +}; + +const createExpressionLink = (expr: string) => { + return `../graph?g0.expr=${encodeURIComponent(expr)}&g0.tab=1&g0.stacked=0&g0.range_input=1h`; +}; + +const CollapsibleAlertPanel: FC = ({ rule, showAnnotations }) => { + const [open, toggle] = useState(false); + + return ( + <> + toggle(!open)} color={alertColors[rule.state]} style={{ cursor: 'pointer' }}> + + {rule.name} ({`${rule.alerts.length} active`}) + + +
+          
+            
+ name: {rule.name} +
+
+ expr: {rule.query} +
+
+
labels:
+
severity: {rule.labels.severity}
+
+
+
annotations:
+
summary: {rule.annotations.summary}
+
+
+
+ {rule.alerts.length > 0 && ( + + + + + + + + + + + {rule.alerts.map((alert, i) => { + return ( + + + + + + + + {showAnnotations && } + + ); + })} + +
LabelsStateActive SinceValue
+ {Object.entries(alert.labels).map(([k, v], j) => { + return ( + + {k}={v} + + ); + })} + +
+ + {alert.state} + +
+
{alert.activeAt}{alert.value}
+ )} +
+ + ); +}; + +interface AnnotationsProps { + annotations: Record; +} + +export const Annotations: FC = ({ annotations }) => { + return ( + + + +
Annotations
+ + + + + {Object.entries(annotations).map(([k, v], i) => { + return ( +
+ {k} +
{v}
+
+ ); + })} + + +
+ ); +}; + +export default CollapsibleAlertPanel; diff --git a/web/ui/react-app/src/pages/index.ts b/web/ui/react-app/src/pages/index.ts index dba245f10..a002cc38e 100644 --- a/web/ui/react-app/src/pages/index.ts +++ b/web/ui/react-app/src/pages/index.ts @@ -1,4 +1,4 @@ -import Alerts from './Alerts'; +import Alerts from './alerts/Alerts'; import Config from './Config'; import Flags from './Flags'; import Rules from './Rules';