mirror of
https://github.com/prometheus/prometheus.git
synced 2025-01-24 04:02:04 -08:00
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:
parent
7bb73a9abd
commit
1c66aea992
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
136
web/ui/react-app/src/pages/alerts/AlertContents.tsx
Normal file
136
web/ui/react-app/src/pages/alerts/AlertContents.tsx
Normal 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;
|
33
web/ui/react-app/src/pages/alerts/Alerts.tsx
Normal file
33
web/ui/react-app/src/pages/alerts/Alerts.tsx
Normal 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;
|
125
web/ui/react-app/src/pages/alerts/CollapsibleAlertPanel.tsx
Normal file
125
web/ui/react-app/src/pages/alerts/CollapsibleAlertPanel.tsx
Normal 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;
|
|
@ -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';
|
||||
|
|
Loading…
Reference in a new issue