diff --git a/web/api/v1/api.go b/web/api/v1/api.go index 4aad787d6f..9bfee44b58 100644 --- a/web/api/v1/api.go +++ b/web/api/v1/api.go @@ -897,33 +897,39 @@ type RuleGroup struct { // In order to preserve rule ordering, while exposing type (alerting or recording) // specific properties, both alerting and recording rules are exposed in the // same array. - Rules []rule `json:"rules"` - Interval float64 `json:"interval"` + Rules []rule `json:"rules"` + Interval float64 `json:"interval"` + EvaluationTime float64 `json:"evaluationTime"` + LastEvaluation time.Time `json:"lastEvaluation"` } 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"` - Labels labels.Labels `json:"labels"` - Annotations labels.Labels `json:"annotations"` - Alerts []*Alert `json:"alerts"` - Health rules.RuleHealth `json:"health"` - LastError string `json:"lastError,omitempty"` + State string `json:"state"` + Name string `json:"name"` + Query string `json:"query"` + Duration float64 `json:"duration"` + Labels labels.Labels `json:"labels"` + Annotations labels.Labels `json:"annotations"` + Alerts []*Alert `json:"alerts"` + Health rules.RuleHealth `json:"health"` + LastError string `json:"lastError,omitempty"` + EvaluationTime float64 `json:"evaluationTime"` + LastEvaluation time.Time `json:"lastEvaluation"` // Type of an alertingRule is always "alerting". Type string `json:"type"` } type recordingRule struct { - Name string `json:"name"` - Query string `json:"query"` - Labels labels.Labels `json:"labels,omitempty"` - Health rules.RuleHealth `json:"health"` - LastError string `json:"lastError,omitempty"` + Name string `json:"name"` + Query string `json:"query"` + Labels labels.Labels `json:"labels,omitempty"` + Health rules.RuleHealth `json:"health"` + LastError string `json:"lastError,omitempty"` + EvaluationTime float64 `json:"evaluationTime"` + LastEvaluation time.Time `json:"lastEvaluation"` // Type of a recordingRule is always "recording". Type string `json:"type"` } @@ -943,10 +949,12 @@ func (api *API) rules(r *http.Request) apiFuncResult { for i, grp := range ruleGroups { apiRuleGroup := &RuleGroup{ - Name: grp.Name(), - File: grp.File(), - Interval: grp.Interval().Seconds(), - Rules: []rule{}, + Name: grp.Name(), + File: grp.File(), + Interval: grp.Interval().Seconds(), + Rules: []rule{}, + EvaluationTime: grp.GetEvaluationDuration().Seconds(), + LastEvaluation: grp.GetEvaluationTimestamp(), } for _, r := range grp.Rules() { var enrichedRule rule @@ -961,28 +969,32 @@ func (api *API) rules(r *http.Request) apiFuncResult { break } enrichedRule = alertingRule{ - State: rule.State().String(), - Name: rule.Name(), - Query: rule.Query().String(), - Duration: rule.Duration().Seconds(), - Labels: rule.Labels(), - Annotations: rule.Annotations(), - Alerts: rulesAlertsToAPIAlerts(rule.ActiveAlerts()), - Health: rule.Health(), - LastError: lastError, - Type: "alerting", + State: rule.State().String(), + Name: rule.Name(), + Query: rule.Query().String(), + Duration: rule.Duration().Seconds(), + Labels: rule.Labels(), + Annotations: rule.Annotations(), + Alerts: rulesAlertsToAPIAlerts(rule.ActiveAlerts()), + Health: rule.Health(), + LastError: lastError, + EvaluationTime: rule.GetEvaluationDuration().Seconds(), + LastEvaluation: rule.GetEvaluationTimestamp(), + Type: "alerting", } case *rules.RecordingRule: if !returnRecording { break } enrichedRule = recordingRule{ - Name: rule.Name(), - Query: rule.Query().String(), - Labels: rule.Labels(), - Health: rule.Health(), - LastError: lastError, - Type: "recording", + Name: rule.Name(), + Query: rule.Query().String(), + Labels: rule.Labels(), + Health: rule.Health(), + LastError: lastError, + EvaluationTime: rule.GetEvaluationDuration().Seconds(), + LastEvaluation: rule.GetEvaluationTimestamp(), + Type: "recording", } default: err := errors.Errorf("failed to assert type of rule '%v'", rule.Name()) diff --git a/web/ui/react-app/src/App.css b/web/ui/react-app/src/App.css index 72b23d2641..8d05302464 100644 --- a/web/ui/react-app/src/App.css +++ b/web/ui/react-app/src/App.css @@ -237,3 +237,14 @@ button.execute-btn { margin-right: 5px; max-height: 20px; } + +.rules-head { + font-weight: 600; +} + +.rule_cell { + white-space: pre-wrap; + background-color: #F5F5F5; + display: block; + font-family: monospace; +} diff --git a/web/ui/react-app/src/pages/alerts/AlertContents.tsx b/web/ui/react-app/src/pages/alerts/AlertContents.tsx index fee7f3558b..8f09e5f455 100644 --- a/web/ui/react-app/src/pages/alerts/AlertContents.tsx +++ b/web/ui/react-app/src/pages/alerts/AlertContents.tsx @@ -3,21 +3,10 @@ import { Badge } from 'reactstrap'; import CollapsibleAlertPanel from './CollapsibleAlertPanel'; import Checkbox from '../../components/Checkbox'; import { isPresent } from '../../utils'; +import { Rule } from '../../types/types'; 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; @@ -29,7 +18,7 @@ export interface AlertsProps { statsCount: RuleStatus; } -interface Alert { +export interface Alert { labels: Record; state: RuleState; value: string; diff --git a/web/ui/react-app/src/pages/alerts/CollapsibleAlertPanel.tsx b/web/ui/react-app/src/pages/alerts/CollapsibleAlertPanel.tsx index 2798e49226..02d700e3a9 100644 --- a/web/ui/react-app/src/pages/alerts/CollapsibleAlertPanel.tsx +++ b/web/ui/react-app/src/pages/alerts/CollapsibleAlertPanel.tsx @@ -1,9 +1,11 @@ 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 { RuleStatus } from './AlertContents'; +import { Rule } from '../../types/types'; import { faChevronDown, faChevronRight } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { createExpressionLink } from '../../utils/index'; interface CollapsibleAlertPanelProps { rule: Rule; @@ -16,10 +18,6 @@ const alertColors: RuleStatus = { 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); diff --git a/web/ui/react-app/src/pages/rules/Rules.tsx b/web/ui/react-app/src/pages/rules/Rules.tsx index e39a8d1ede..a44a721f53 100644 --- a/web/ui/react-app/src/pages/rules/Rules.tsx +++ b/web/ui/react-app/src/pages/rules/Rules.tsx @@ -1,15 +1,16 @@ import React, { FC } from 'react'; import { RouteComponentProps } from '@reach/router'; import PathPrefixProps from '../../types/PathPrefixProps'; -import { Alert } from 'reactstrap'; +import { useFetch } from '../../hooks/useFetch'; +import { withStatusIndicator } from '../../components/withStatusIndicator'; +import { RulesMap, RulesContent } from './RulesContent'; -const Rules: FC = ({ pathPrefix }) => ( - <> -

Rules

- - This page is still under construction. Please try it in the Classic UI. - - -); +const RulesWithStatusIndicator = withStatusIndicator(RulesContent); + +const Rules: FC = ({ pathPrefix }) => { + const { response, error, isLoading } = useFetch(`${pathPrefix}/api/v1/rules`); + + return ; +}; export default Rules; diff --git a/web/ui/react-app/src/pages/rules/RulesContent.tsx b/web/ui/react-app/src/pages/rules/RulesContent.tsx new file mode 100644 index 0000000000..1144e43517 --- /dev/null +++ b/web/ui/react-app/src/pages/rules/RulesContent.tsx @@ -0,0 +1,131 @@ +import React, { FC } from 'react'; +import { RouteComponentProps } from '@reach/router'; +import { APIResponse } from '../../hooks/useFetch'; +import { Alert, Table, Badge } from 'reactstrap'; +import { Link } from '@reach/router'; +import { formatRelative, createExpressionLink, humanizeDuration } from '../../utils'; +import { Rule } from '../../types/types'; +import { now } from 'moment'; + +interface RulesContentProps { + response: APIResponse; +} + +interface RuleGroup { + name: string; + file: string; + rules: Rule[]; + evaluationTime: string; + lastEvaluation: string; +} + +export interface RulesMap { + groups: RuleGroup[]; +} + +const GraphExpressionLink: FC<{ expr: string; title: string }> = props => { + return ( + <> + {props.title}: + + {props.expr} + +
+ + ); +}; + +export const RulesContent: FC = ({ response }) => { + const getBadgeColor = (state: string) => { + switch (state) { + case 'ok': + return 'success'; + + case 'err': + return 'danger'; + + case 'unknown': + return 'warning'; + } + }; + + if (response.data) { + const groups: RuleGroup[] = response.data.groups; + return ( + <> +

Rules

+ {groups.map((g, i) => { + return ( + + + + + + + + + + + + + + + + + {g.rules.map((r, i) => { + return ( + + {r.alerts ? ( + + ) : ( + + )} + + + + + + ); + })} + +
+ +

{g.name}

+
+
+

{formatRelative(g.lastEvaluation, now())} ago

+
+

{humanizeDuration(parseFloat(g.evaluationTime) * 1000)}

+
RuleStateErrorLast EvaluationEvaluation Time
+ + +
+ labels: + {Object.entries(r.labels).map(([key, value]) => ( +
+ {key}: {value} +
+ ))} +
+
+ annotations: + {Object.entries(r.annotations).map(([key, value]) => ( +
+ {key}: {value} +
+ ))} +
+
+ + + + {r.health.toUpperCase()} + {r.lastError ? {r.lastError} : null}{formatRelative(r.lastEvaluation, now())} ago{humanizeDuration(parseFloat(r.evaluationTime) * 1000)}
+ ); + })} + + ); + } + + return null; +}; diff --git a/web/ui/react-app/src/types/types.ts b/web/ui/react-app/src/types/types.ts index 11829edefb..a30e64107a 100644 --- a/web/ui/react-app/src/types/types.ts +++ b/web/ui/react-app/src/types/types.ts @@ -1,3 +1,5 @@ +import { Alert, RuleState } from '../pages/alerts/AlertContents'; + export interface Metric { [key: string]: string; } @@ -7,3 +9,18 @@ export interface QueryParams { endTime: number; resolution: number; } + +export interface Rule { + alerts: Alert[]; + annotations: Record; + duration: number; + evaluationTime: string; + health: string; + labels: Record; + lastError?: string; + lastEvaluation: string; + name: string; + query: string; + state: RuleState; + type: string; +} diff --git a/web/ui/react-app/src/utils/index.ts b/web/ui/react-app/src/utils/index.ts index ed2f212af2..82915f0b78 100644 --- a/web/ui/react-app/src/utils/index.ts +++ b/web/ui/react-app/src/utils/index.ts @@ -197,3 +197,7 @@ export const toQueryString = ({ key, options }: PanelMeta) => { export const encodePanelOptionsToQueryString = (panels: PanelMeta[]) => { return `?${panels.map(toQueryString).join('&')}`; }; + +export const createExpressionLink = (expr: string) => { + return `../graph?g0.expr=${encodeURIComponent(expr)}&g0.tab=1&g0.stacked=0&g0.range_input=1h`; +};