React UI: Implement alerts page (#6402)

* url filter rules param

Signed-off-by: blalov <boiskila@gmail.com>
Signed-off-by: Boyko Lalov <boiskila@gmail.com>
Signed-off-by: blalov <boiskila@gmail.com>

* address review changes

Signed-off-by: blalov <boiskila@gmail.com>
Signed-off-by: Boyko Lalov <boiskila@gmail.com>
Signed-off-by: blalov <boiskila@gmail.com>

* ui initial commit

Signed-off-by: blalov <boiskila@gmail.com>
Signed-off-by: Boyko Lalov <boiskila@gmail.com>
Signed-off-by: blalov <boiskila@gmail.com>

* improve ui

Signed-off-by: blalov <boiskila@gmail.com>
Signed-off-by: Boyko Lalov <boiskila@gmail.com>
Signed-off-by: blalov <boiskila@gmail.com>

* fix typo in component name

Signed-off-by: Boyko Lalov <boiskila@gmail.com>
Signed-off-by: blalov <boiskila@gmail.com>

* create query link + ui enhancements

Signed-off-by: Boyko Lalov <boiskila@gmail.com>
Signed-off-by: blalov <boiskila@gmail.com>

* add count to state labels

Signed-off-by: blalov <boiskila@gmail.com>

* put alerts table render in the right place

Signed-off-by: blalov <boiskila@gmail.com>

* refactoring

Signed-off-by: blalov <boiskila@gmail.com>

* fix rules endpoint test

Signed-off-by: blalov <boiskila@gmail.com>

* lint fixes

Signed-off-by: blalov <boiskila@gmail.com>

* test query params

Signed-off-by: blalov <boiskila@gmail.com>

* refactoring

Signed-off-by: blalov <boiskila@gmail.com>

* review changes

Signed-off-by: blalov <boiskila@gmail.com>

* adding down arrow as click indicator in Alert

Signed-off-by: blalov <boiskila@gmail.com>

* add period at the end of the comment

Signed-off-by: blalov <boiskila@gmail.com>

* review changes

Signed-off-by: blalov <boiskila@gmail.com>

* remove left-over css

Signed-off-by: blalov <boiskila@gmail.com>

* adding expand/collapse arrows on Alert

Signed-off-by: blalov <boiskila@gmail.com>

* create proper expression for alert name

Signed-off-by: blalov <boiskila@gmail.com>
This commit is contained in:
Boyko 2019-12-10 00:42:59 +02:00 committed by Julius Volz
parent 7bb73a9abd
commit 1c66aea992
8 changed files with 394 additions and 20 deletions

View file

@ -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
}

View file

@ -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 {

View file

@ -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;
}

View file

@ -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<RouteComponentProps & PathPrefixProps> = ({ pathPrefix }) => (
<>
<h2>Alerts</h2>
<Alert color="warning">
This page is still under construction. Please try it in the <a href={`${pathPrefix}/alerts`}>Classic UI</a>.
</Alert>
</>
);
export default Alerts;

View file

@ -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<any>;
export interface Rule {
alerts: Alert[];
annotations: Record<string, string>;
duration: number;
health: string;
labels: Record<string, string>;
name: string;
query: string;
state: RuleState;
type: string;
}
export interface RuleStatus<T> {
firing: T;
pending: T;
inactive: T;
}
export interface AlertsProps {
groups?: RuleGroup[];
statsCount: RuleStatus<number>;
}
interface Alert {
labels: Record<string, string>;
state: RuleState;
value: string;
annotations: Record<string, string>;
activeAt: string;
}
interface RuleGroup {
name: string;
file: string;
rules: Rule[];
interval: number;
}
const AlertsContent: FC<AlertsProps> = ({ groups = [], statsCount }) => {
const [state, setState] = useState<RuleStatus<boolean>>({
firing: true,
pending: true,
inactive: true,
});
const [showAnnotations, setShowAnnotations] = useState(false);
const toggle = (ruleState: RuleState) => () => {
setState({
...state,
[ruleState]: !state[ruleState],
});
};
return (
<>
<ButtonGroup className="mb-3">
<Button active={state.inactive} onClick={toggle('inactive')} color="primary">
Inactive ({statsCount.inactive})
</Button>
<Button active={state.pending} onClick={toggle('pending')} color="primary">
Pending ({statsCount.pending})
</Button>
<Button active={state.firing} onClick={toggle('firing')} color="primary">
Firing ({statsCount.firing})
</Button>
</ButtonGroup>
<Row className="mb-2">
<Checkbox
id="show-annotations"
wrapperStyles={{ margin: '0 0 0 15px', alignSelf: 'center' }}
onClick={() => setShowAnnotations(!showAnnotations)}
>
Show annotations
</Checkbox>
</Row>
{groups.map((group, i) => {
return (
<Fragment key={i}>
<StatusBadges rules={group.rules}>
{group.file} > {group.name}
</StatusBadges>
{group.rules.map((rule, j) => {
return (
state[rule.state] && (
<CollapsibleAlertPanel key={rule.name + j} showAnnotations={showAnnotations} rule={rule} />
)
);
})}
</Fragment>
);
})}
</>
);
};
interface StatusBadgesProps {
rules: Rule[];
}
const StatusBadges: FC<StatusBadgesProps> = ({ rules, children }) => {
const statesCounter = rules.reduce<any>(
(acc, r) => {
return {
...acc,
[r.state]: acc[r.state] + r.alerts.length,
};
},
{
firing: 0,
pending: 0,
}
);
return (
<div className="status-badges border rounded-sm" style={{ lineHeight: 1.1 }}>
{children}
<div className="badges-wrapper">
{isPresent(statesCounter.inactive) && <Badge color="success">inactive</Badge>}
{statesCounter.pending > 0 && <Badge color="warning">pending ({statesCounter.pending})</Badge>}
{statesCounter.firing > 0 && <Badge color="danger">firing ({statesCounter.firing})</Badge>}
</div>
</div>
);
};
AlertsContent.displayName = 'Alerts';
export default AlertsContent;

View file

@ -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<RouteComponentProps & PathPrefixProps> = ({ pathPrefix = '' }) => {
const { response, error, isLoading } = useFetch<AlertsProps>(`${pathPrefix}/api/v1/rules?type=alert`);
const ruleStatsCount: RuleStatus<number> = {
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 (
<AlertsWithStatusIndicator
statsCount={ruleStatsCount}
groups={response.data && response.data.groups}
error={error}
isLoading={isLoading}
/>
);
};
export default Alerts;

View file

@ -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<string> = {
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<CollapsibleAlertPanelProps> = ({ rule, showAnnotations }) => {
const [open, toggle] = useState(false);
return (
<>
<Alert fade={false} onClick={() => toggle(!open)} color={alertColors[rule.state]} style={{ cursor: 'pointer' }}>
<FontAwesomeIcon icon={open ? faChevronDown : faChevronRight} fixedWidth />
<strong>{rule.name}</strong> ({`${rule.alerts.length} active`})
</Alert>
<Collapse isOpen={open} className="mb-2">
<pre style={{ background: '#f5f5f5', padding: 15 }}>
<code>
<div>
name: <Link to={createExpressionLink(`ALERTS{alertname="${rule.name}"}`)}>{rule.name}</Link>
</div>
<div>
expr: <Link to={createExpressionLink(rule.query)}>{rule.query}</Link>
</div>
<div>
<div>labels:</div>
<div className="ml-5">severity: {rule.labels.severity}</div>
</div>
<div>
<div>annotations:</div>
<div className="ml-5">summary: {rule.annotations.summary}</div>
</div>
</code>
</pre>
{rule.alerts.length > 0 && (
<Table bordered size="sm">
<thead>
<tr>
<th>Labels</th>
<th>State</th>
<th>Active Since</th>
<th>Value</th>
</tr>
</thead>
<tbody>
{rule.alerts.map((alert, i) => {
return (
<Fragment key={i}>
<tr>
<td style={{ verticalAlign: 'middle' }}>
{Object.entries(alert.labels).map(([k, v], j) => {
return (
<Badge key={j} color="primary" className="mr-1">
{k}={v}
</Badge>
);
})}
</td>
<td>
<h5 className="m-0">
<Badge color={alertColors[alert.state] + ' text-uppercase'} className="px-3">
{alert.state}
</Badge>
</h5>
</td>
<td>{alert.activeAt}</td>
<td>{alert.value}</td>
</tr>
{showAnnotations && <Annotations annotations={alert.annotations} />}
</Fragment>
);
})}
</tbody>
</Table>
)}
</Collapse>
</>
);
};
interface AnnotationsProps {
annotations: Record<string, string>;
}
export const Annotations: FC<AnnotationsProps> = ({ annotations }) => {
return (
<Fragment>
<tr>
<td colSpan={4}>
<h5 className="font-weight-bold">Annotations</h5>
</td>
</tr>
<tr>
<td colSpan={4}>
{Object.entries(annotations).map(([k, v], i) => {
return (
<div key={i}>
<strong>{k}</strong>
<div>{v}</div>
</div>
);
})}
</td>
</tr>
</Fragment>
);
};
export default CollapsibleAlertPanel;

View file

@ -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';