Alerting: visual alert type picker (#46111)

This commit is contained in:
Gilles De Mey 2022-03-18 14:33:32 +01:00 committed by GitHub
parent 644e7e7c19
commit 64d4e6fcaa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 301 additions and 77 deletions

View File

@ -115,7 +115,7 @@ const Heading = ({ children, className, 'aria-label': ariaLabel }: ChildProps &
) : (
<>{children}</>
)}
{isSelected !== undefined && <input aria-label="option" type="radio" checked={isSelected} />}
{isSelected !== undefined && <input aria-label="option" type="radio" readOnly checked={isSelected} />}
</h2>
);
};

View File

@ -17,8 +17,8 @@ import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
import { DashboardSearchHit } from 'app/features/search/types';
import { getDefaultQueries } from './utils/rule-form';
import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor';
import * as api from 'app/features/manage-dashboards/state/actions';
import { GrafanaAlertStateDecision } from 'app/types/unified-alerting-dto';
import { searchFolders } from '../../../../app/features/manage-dashboards/state/actions';
jest.mock('./components/rule-editor/ExpressionEditor', () => ({
// eslint-disable-next-line react/display-name
@ -29,6 +29,7 @@ jest.mock('./components/rule-editor/ExpressionEditor', () => ({
jest.mock('./api/ruler');
jest.mock('./utils/config');
jest.mock('../../../../app/features/manage-dashboards/state/actions');
// there's no angular scope in test and things go terribly wrong when trying to render the query editor row.
// lets just skip it
@ -39,7 +40,7 @@ jest.mock('app/features/query/components/QueryEditorRow', () => ({
const mocks = {
getAllDataSources: jest.mocked(getAllDataSources),
searchFolders: jest.mocked(searchFolders),
api: {
fetchRulerRulesGroup: jest.mocked(fetchRulerRulesGroup),
setRulerRuleGroup: jest.mocked(setRulerRuleGroup),
@ -80,6 +81,10 @@ const ui = {
save: byRole('button', { name: 'Save' }),
addAnnotation: byRole('button', { name: /Add info/ }),
addLabel: byRole('button', { name: /Add label/ }),
// alert type buttons
grafanaManagedAlert: byRole('button', { name: /Grafana managed/ }),
lotexAlert: byRole('button', { name: /Cortex or Loki alert/ }),
lotexRecordingRule: byRole('button', { name: /Cortex or Loki recording rule/ }),
},
};
@ -123,10 +128,13 @@ describe('RuleEditor', () => {
},
],
});
mocks.searchFolders.mockResolvedValue([]);
await renderRuleEditor();
await waitFor(() => expect(mocks.searchFolders).toHaveBeenCalled());
userEvent.type(await ui.inputs.name.find(), 'my great new rule');
await clickSelectOption(ui.inputs.alertType.get(), /Cortex\/Loki managed alert/);
userEvent.click(await ui.buttons.lotexAlert.get());
const dataSourceSelect = ui.inputs.dataSource.get();
userEvent.click(byRole('combobox').get(dataSourceSelect));
await clickSelectOption(dataSourceSelect, 'Prom (default)');
@ -165,7 +173,40 @@ describe('RuleEditor', () => {
});
it('can create new grafana managed alert', async () => {
const searchFolderMock = jest.spyOn(api, 'searchFolders').mockResolvedValue([
const dataSources = {
default: mockDataSource(
{
type: 'prometheus',
name: 'Prom',
isDefault: true,
},
{ alerting: true }
),
};
setDataSourceSrv(new MockDataSourceSrv(dataSources));
mocks.getAllDataSources.mockReturnValue(Object.values(dataSources));
mocks.api.setRulerRuleGroup.mockResolvedValue();
mocks.api.fetchRulerRulesNamespace.mockResolvedValue([]);
mocks.api.fetchRulerRulesGroup.mockResolvedValue({
name: 'group2',
rules: [],
});
mocks.api.fetchRulerRules.mockResolvedValue({
namespace1: [
{
name: 'group1',
rules: [],
},
],
namespace2: [
{
name: 'group2',
rules: [],
},
],
});
mocks.searchFolders.mockResolvedValue([
{
title: 'Folder A',
id: 1,
@ -176,24 +217,13 @@ describe('RuleEditor', () => {
},
] as DashboardSearchHit[]);
const dataSources = {
default: mockDataSource({
type: 'prometheus',
name: 'Prom',
isDefault: true,
}),
};
setDataSourceSrv(new MockDataSourceSrv(dataSources));
mocks.api.setRulerRuleGroup.mockResolvedValue();
mocks.api.fetchRulerRulesNamespace.mockResolvedValue([]);
// fill out the form
await renderRuleEditor();
await waitFor(() => expect(mocks.searchFolders).toHaveBeenCalled());
userEvent.type(await ui.inputs.name.find(), 'my great new rule');
await clickSelectOption(ui.inputs.alertType.get(), /Classic Grafana alerts based on thresholds/);
const folderInput = await ui.inputs.folder.find();
await waitFor(() => expect(searchFolderMock).toHaveBeenCalled());
await clickSelectOption(folderInput, 'Folder A');
userEvent.type(ui.inputs.annotationValue(0).get(), 'some summary');
@ -264,12 +294,17 @@ describe('RuleEditor', () => {
},
],
});
mocks.searchFolders.mockResolvedValue([]);
await renderRuleEditor();
await waitFor(() => expect(mocks.searchFolders).toHaveBeenCalled());
userEvent.type(await ui.inputs.name.find(), 'my great new recording rule');
await clickSelectOption(ui.inputs.alertType.get(), /Cortex\/Loki managed recording rule/);
userEvent.click(await ui.buttons.lotexRecordingRule.get());
const dataSourceSelect = ui.inputs.dataSource.get();
userEvent.click(byRole('combobox').get(dataSourceSelect));
await clickSelectOption(dataSourceSelect, 'Prom (default)');
await waitFor(() => expect(mocks.api.fetchRulerRules).toHaveBeenCalled());
await clickSelectOption(ui.inputs.namespace.get(), 'namespace2');
@ -320,7 +355,6 @@ describe('RuleEditor', () => {
uid: 'abcd',
id: 1,
};
const searchFolderMock = jest.spyOn(api, 'searchFolders').mockResolvedValue([folder] as DashboardSearchHit[]);
const getFolderByUid = jest.fn().mockResolvedValue({
...folder,
canSave: true,
@ -338,6 +372,7 @@ describe('RuleEditor', () => {
} as any as BackendSrv;
setBackendSrv(backendSrv);
setDataSourceSrv(new MockDataSourceSrv(dataSources));
mocks.getAllDataSources.mockReturnValue(Object.values(dataSources));
mocks.api.setRulerRuleGroup.mockResolvedValue();
mocks.api.fetchRulerRulesNamespace.mockResolvedValue([]);
mocks.api.fetchRulerRules.mockResolvedValue({
@ -365,9 +400,10 @@ describe('RuleEditor', () => {
},
],
});
mocks.searchFolders.mockResolvedValue([folder] as DashboardSearchHit[]);
await renderRuleEditor(uid);
await waitFor(() => expect(searchFolderMock).toHaveBeenCalled());
await waitFor(() => expect(mocks.searchFolders).toHaveBeenCalled());
// check that it's filled in
const nameInput = await ui.inputs.name.find();
@ -484,11 +520,14 @@ describe('RuleEditor', () => {
setDataSourceSrv(new MockDataSourceSrv(dataSources));
mocks.getAllDataSources.mockReturnValue(Object.values(dataSources));
mocks.searchFolders.mockResolvedValue([]);
// render rule editor, select cortex/loki managed alerts
await renderRuleEditor();
await waitFor(() => expect(mocks.searchFolders).toHaveBeenCalled());
await ui.inputs.name.find();
await clickSelectOption(ui.inputs.alertType.get(), /Cortex\/Loki managed alert/);
userEvent.click(await ui.buttons.lotexAlert.get());
// wait for ui theck each datasource if it supports rule editing
await waitFor(() => expect(mocks.api.fetchRulerRulesGroup).toHaveBeenCalledTimes(4));

View File

@ -45,6 +45,7 @@ export const AlertRuleForm: FC<Props> = ({ existing }) => {
...getDefaultFormValues(),
queries: getDefaultQueries(),
...(queryParams['defaults'] ? JSON.parse(queryParams['defaults'] as string) : {}),
type: RuleFormType.grafana,
};
}, [existing, queryParams]);

View File

@ -1,15 +1,15 @@
import React, { FC, useMemo } from 'react';
import { DataSourceInstanceSettings, GrafanaTheme2, SelectableValue } from '@grafana/data';
import { Field, Input, InputControl, Select, useStyles2 } from '@grafana/ui';
import React, { FC } from 'react';
import { DataSourceInstanceSettings, GrafanaTheme2 } from '@grafana/data';
import { Field, Input, InputControl, useStyles2 } from '@grafana/ui';
import { css } from '@emotion/css';
import { RuleEditorSection } from './RuleEditorSection';
import { useFormContext } from 'react-hook-form';
import { RuleFormType, RuleFormValues } from '../../types/rule-form';
import { Folder, RuleFolderPicker } from './RuleFolderPicker';
import { GroupAndNamespaceFields } from './GroupAndNamespaceFields';
import { contextSrv } from 'app/core/services/context_srv';
import { CloudRulesSourcePicker } from './CloudRulesSourcePicker';
import { checkForPathSeparator } from './util';
import { RuleTypePicker } from './rule-types/RuleTypePicker';
interface Props {
editingExistingRule: boolean;
@ -30,38 +30,36 @@ export const AlertTypeStep: FC<Props> = ({ editingExistingRule }) => {
watch,
formState: { errors },
setValue,
getValues,
} = useFormContext<RuleFormValues & { location?: string }>();
const ruleFormType = watch('type');
const dataSourceName = watch('dataSourceName');
const alertTypeOptions = useMemo((): SelectableValue[] => {
const result = [
{
label: 'Grafana managed alert',
value: RuleFormType.grafana,
description: 'Classic Grafana alerts based on thresholds.',
},
];
if (contextSrv.isEditor) {
result.push({
label: 'Cortex/Loki managed alert',
value: RuleFormType.cloudAlerting,
description: 'Alert based on a system or application behavior. Based on Prometheus.',
});
result.push({
label: 'Cortex/Loki managed recording rule',
value: RuleFormType.cloudRecording,
description: 'Recording rule to pre-compute frequently needed or expensive calculations. Based on Prometheus.',
});
}
return result;
}, []);
return (
<RuleEditorSection stepNo={1} title="Rule type">
<Field
disabled={editingExistingRule}
error={errors.type?.message}
invalid={!!errors.type?.message}
data-testid="alert-type-picker"
>
<InputControl
render={({ field: { onChange } }) => (
<RuleTypePicker
aria-label="Rule type"
selected={getValues('type') ?? RuleFormType.grafana}
onChange={onChange}
/>
)}
name="type"
control={control}
rules={{
required: { value: true, message: 'Please select alert type' },
}}
/>
</Field>
<Field
className={styles.formInput}
label="Rule name"
@ -88,31 +86,6 @@ export const AlertTypeStep: FC<Props> = ({ editingExistingRule }) => {
/>
</Field>
<div className={styles.flexRow}>
<Field
disabled={editingExistingRule}
label="Rule type"
className={styles.formInput}
error={errors.type?.message}
invalid={!!errors.type?.message}
data-testid="alert-type-picker"
>
<InputControl
render={({ field: { onChange, ref, ...field } }) => (
<Select
menuShouldPortal
aria-label="Rule type"
{...field}
options={alertTypeOptions}
onChange={(v: SelectableValue) => onChange(v?.value)}
/>
)}
name="type"
control={control}
rules={{
required: { value: true, message: 'Please select alert type' },
}}
/>
</Field>
{(ruleFormType === RuleFormType.cloudRecording || ruleFormType === RuleFormType.cloudAlerting) && (
<Field
className={styles.formInput}

View File

@ -0,0 +1,32 @@
import React, { FC } from 'react';
import { RuleType, SharedProps } from './RuleType';
import { DisabledTooltip } from './DisabledTooltip';
import { RuleFormType } from '../../../types/rule-form';
interface Props extends SharedProps {
onClick: (value: RuleFormType) => void;
}
const CortexFlavoredType: FC<Props> = ({ selected = false, disabled = false, onClick }) => {
return (
<DisabledTooltip visible={disabled}>
<RuleType
name="Cortex or Loki alert"
description={
<span>
Use a Cortex or Loki datasource.
<br />
Expressions are not supported.
</span>
}
image="/public/img/alerting/cortex_logo.svg"
selected={selected}
disabled={disabled}
value={RuleFormType.cloudAlerting}
onClick={onClick}
/>
</DisabledTooltip>
);
};
export { CortexFlavoredType };

View File

@ -0,0 +1,28 @@
import React, { FC } from 'react';
import { RuleType, SharedProps } from './RuleType';
import { DisabledTooltip } from './DisabledTooltip';
import { RuleFormType } from '../../../types/rule-form';
const RecordingRuleType: FC<SharedProps> = ({ selected = false, disabled = false, onClick }) => {
return (
<DisabledTooltip visible={disabled}>
<RuleType
name="Cortex or Loki recording rule"
description={
<span>
Precompute expressions.
<br />
Should be combined with an alert rule.
</span>
}
image="/public/img/alerting/cortex_logo_recording.svg"
selected={selected}
disabled={disabled}
value={RuleFormType.cloudRecording}
onClick={onClick}
/>
</DisabledTooltip>
);
};
export { RecordingRuleType };

View File

@ -0,0 +1,20 @@
import React, { FC } from 'react';
import { Tooltip } from '@grafana/ui';
type Props = {
visible: boolean;
};
const DisabledTooltip: FC<Props> = ({ children, visible = false }) => {
if (!visible) {
return <>{children}</>;
}
return (
<Tooltip content="You do not appear to have any compatible datasources." placement="top">
<div>{children}</div>
</Tooltip>
);
};
export { DisabledTooltip };

View File

@ -0,0 +1,25 @@
import React, { FC } from 'react';
import { RuleType, SharedProps } from './RuleType';
import { RuleFormType } from '../../../types/rule-form';
const GrafanaManagedRuleType: FC<SharedProps> = ({ selected = false, disabled, onClick }) => {
return (
<RuleType
name="Grafana managed alert"
description={
<span>
Supports multiple data sources of any kind.
<br />
Transform data with expressions.
</span>
}
image="/public/img/grafana_icon.svg"
selected={selected}
disabled={disabled}
value={RuleFormType.grafana}
onClick={onClick}
/>
);
};
export { GrafanaManagedRuleType };

View File

@ -0,0 +1,52 @@
import { css, cx } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { Card, useStyles2 } from '@grafana/ui';
import React, { FC, ReactNode } from 'react';
import { RuleFormType } from '../../../types/rule-form';
interface Props extends SharedProps {
image: string;
name: string;
description: ReactNode;
value: RuleFormType;
}
// these properties are shared between all Rule Types
export interface SharedProps {
selected?: boolean;
disabled?: boolean;
onClick: (value: RuleFormType) => void;
}
const RuleType: FC<Props> = (props) => {
const { name, description, image, selected = false, value, onClick, disabled = false } = props;
const styles = useStyles2(getStyles);
const cardStyles = cx({
[styles.wrapper]: true,
[styles.disabled]: disabled,
});
return (
<Card className={cardStyles} isSelected={selected} onClick={() => onClick(value)} disabled={disabled}>
<Card.Figure>
<img src={image} />
</Card.Figure>
<Card.Heading>{name}</Card.Heading>
<Card.Description>{description}</Card.Description>
</Card>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
wrapper: css`
width: 380px;
cursor: pointer;
user-select: none;
`,
disabled: css`
opacity: 0.5;
`,
});
export { RuleType };

View File

@ -0,0 +1,52 @@
import { useStyles2 } from '@grafana/ui';
import { isEmpty } from 'lodash';
import React, { FC } from 'react';
import { useRulesSourcesWithRuler } from '../../../hooks/useRuleSourcesWithRuler';
import { RuleFormType } from '../../../types/rule-form';
import { GrafanaManagedRuleType } from './GrafanaManagedAlert';
import { CortexFlavoredType } from './CortexOrLokiAlert';
import { RecordingRuleType } from './CortexOrLokiRecordingRule';
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data/src';
import { Stack } from '@grafana/experimental';
interface RuleTypePickerProps {
onChange: (value: RuleFormType) => void;
selected: RuleFormType;
}
const RuleTypePicker: FC<RuleTypePickerProps> = ({ selected, onChange }) => {
const rulesSourcesWithRuler = useRulesSourcesWithRuler();
const hasLotexDatasources = !isEmpty(rulesSourcesWithRuler);
const styles = useStyles2(getStyles);
return (
<>
<Stack direction="row" gap={2}>
<GrafanaManagedRuleType selected={selected === RuleFormType.grafana} onClick={onChange} />
<CortexFlavoredType
selected={selected === RuleFormType.cloudAlerting}
onClick={onChange}
disabled={!hasLotexDatasources}
/>
<RecordingRuleType
selected={selected === RuleFormType.cloudRecording}
onClick={onChange}
disabled={!hasLotexDatasources}
/>
</Stack>
<small className={styles.meta}>
Select &ldquo;Grafana managed&rdquo; unless you have a Cortex or Loki data source with the Ruler API enabled.
</small>
</>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
meta: css`
color: ${theme.colors.text.disabled};
`,
});
export { RuleTypePicker };

View File

@ -0,0 +1 @@
<svg viewBox="-4.2 -3.7 439.14 439.14" xmlns="http://www.w3.org/2000/svg"><path d="m329.643 275.202a54.609 54.609 0 1 0 -54.608-54.61 54.672 54.672 0 0 0 54.608 54.61zm0-89.35a34.947 34.947 0 1 1 -34.947 34.948 34.947 34.947 0 0 1 34.947-34.948z" fill="none"/><path d="m417.343 216.014c0-111.3-90.55-201.85-201.85-201.85s-201.85 90.55-201.85 201.85 90.55 201.85 201.85 201.85 201.85-90.55 201.85-201.85zm-292.54 9.436h-18.2a35.326 35.326 0 1 1 .278-6.204h22.287v.592l.064-.025 22.046 55.117 24.993-57.842h29.413l26.339-65.423 23.712 67.75h13.11a60.813 60.813 0 0 1 27.464-49.658l-58.457-55.534a34.96 34.96 0 1 1 4.39-4.389l59.643 56.66a60.816 60.816 0 1 1 -1.999 107.117l-58.118 58.118-4.386-4.386 57.134-57.135a60.828 60.828 0 0 1 -25.476-44.59h-17.707l-19.716-56.331-21.743 54.005h-29.525l-29.292 67.79zm54.916 128.737a34.947 34.947 0 1 1 34.947 34.947 34.947 34.947 0 0 1 -34.947-34.947z" fill="none"/><g fill="#3b697e"><path d="m426.65 216.014c0-116.432-94.725-211.156-211.157-211.156s-211.155 94.724-211.155 211.156 94.722 211.156 211.155 211.156 211.157-94.724 211.157-211.156zm-413.006 0c0-111.3 90.55-201.85 201.85-201.85s201.849 90.55 201.849 201.85-90.55 201.85-201.85 201.85-201.85-90.55-201.85-201.85z"/><circle cx="329.643" cy="220.8" r="34.947"/><circle cx="214.666" cy="354.187" r="34.947"/><path d="m209.874 223.292 21.743-54.005 19.716 56.331h17.707a60.828 60.828 0 0 0 25.476 44.59l-57.134 57.135 4.386 4.386 58.118-58.118a60.81 60.81 0 1 0 1.999-107.116l-59.643-56.66a34.954 34.954 0 1 0 -4.39 4.388l58.457 55.534a60.813 60.813 0 0 0 -27.463 49.657h-13.111l-23.712-67.749-26.339 65.423h-29.413l-24.993 57.841-22.046-55.117-.064.026v-.592h-22.287a34.958 34.958 0 1 0 -.278 6.204h18.2l26.254 65.632 29.292-67.79zm119.769-57.307a54.609 54.609 0 1 1 -54.608 54.608 54.67 54.67 0 0 1 54.608-54.608z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -0,0 +1 @@
<svg clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" viewBox="0 0 440 440" xmlns="http://www.w3.org/2000/svg"><path d="m333.843 278.902h.001c29.958 0 54.609-24.651 54.609-54.609s-24.651-54.609-54.609-54.609c-29.957 0-54.608 24.651-54.609 54.608.034 29.945 24.663 54.575 54.608 54.61zm0-89.35c19.171 0 34.947 15.776 34.947 34.947s-15.776 34.947-34.947 34.947-34.946-15.775-34.947-34.946v-.001c0-19.171 15.776-34.947 34.947-34.947z" fill="none"/><path d="m421.543 219.714c0-111.3-90.55-201.85-201.85-201.85s-201.85 90.55-201.85 201.85 90.55 201.85 201.85 201.85 201.85-90.55 201.85-201.85zm-292.54 9.436h-18.2c-2.333 17.465-17.395 30.649-35.015 30.649-19.379 0-35.326-15.947-35.326-35.326 0-19.38 15.947-35.326 35.326-35.326 18.804 0 34.48 15.013 35.293 33.799h22.287v.592l.064-.025 22.046 55.117 24.993-57.842h29.413l26.339-65.423 23.712 67.75h13.11c.397-20.053 10.689-38.663 27.464-49.658l-58.457-55.534c-6.288 5.257-14.229 8.14-22.425 8.14-19.179 0-34.96-15.782-34.96-34.96 0-19.179 15.781-34.96 34.96-34.96 19.178 0 34.96 15.781 34.96 34.96 0 8.199-2.884 16.142-8.145 22.431l59.643 56.66c8.597-4.416 18.124-6.72 27.789-6.72 33.363 0 60.816 27.453 60.816 60.816 0 33.362-27.453 60.816-60.816 60.816-10.433 0-20.692-2.685-29.788-7.795l-58.118 58.118-4.386-4.386 57.134-57.135c-14.649-10.368-23.983-26.705-25.476-44.59h-17.707l-19.716-56.331-21.743 54.005h-29.525l-29.292 67.79zm54.916 128.737c0-19.171 15.776-34.947 34.947-34.947s34.947 15.776 34.947 34.947-15.776 34.947-34.947 34.947-34.947-15.776-34.947-34.947z" fill="none"/><g fill="#3b697e"><path d="m430.85 219.714c0-116.432-94.725-211.156-211.157-211.156s-211.155 94.724-211.155 211.156 94.722 211.156 211.155 211.156 211.157-94.724 211.157-211.156zm-413.006 0c0-111.3 90.55-201.85 201.85-201.85s201.849 90.55 201.849 201.85-90.55 201.85-201.85 201.85-201.85-90.55-201.85-201.85h.001z" fill-rule="nonzero"/><circle cx="333.843" cy="224.5" r="34.947"/><circle cx="218.866" cy="357.887" r="34.947"/><path d="m214.074 226.992 21.743-54.005 19.716 56.331h17.707c1.493 17.885 10.827 34.222 25.476 44.59l-57.134 57.135 4.386 4.386 58.118-58.118c9.093 5.106 19.348 7.789 29.776 7.789 33.36 0 60.81-27.451 60.81-60.81s-27.45-60.81-60.81-60.81c-9.66 0-19.183 2.302-27.777 6.715l-59.643-56.66c5.261-6.288 8.146-14.231 8.146-22.43 0-19.175-15.779-34.954-34.954-34.954s-34.954 15.779-34.954 34.954 15.779 34.954 34.954 34.954c8.193 0 16.132-2.881 22.418-8.136l58.457 55.534c-16.774 10.995-27.066 29.605-27.463 49.657h-13.111l-23.712-67.749-26.339 65.423h-29.413l-24.993 57.841-22.046-55.117-.064.026v-.592h-22.287c-.821-18.577-16.328-33.415-34.924-33.415-19.177 0-34.958 15.781-34.958 34.958 0 19.178 15.781 34.958 34.958 34.958 17.425 0 32.323-13.028 34.646-30.297h18.2l26.254 65.632 29.292-67.79zm119.769-57.307h.001c29.958 0 54.609 24.651 54.609 54.609s-24.651 54.609-54.609 54.609-54.609-24.651-54.609-54.609v-.001c.034-29.944 24.664-54.574 54.608-54.608z" fill-rule="nonzero"/></g><path d="m337.399 336.8c0-17.198-13.961-31.16-31.159-31.16h-264.898c-17.198 0-31.161 13.962-31.161 31.16v62.32c0 17.198 13.963 31.16 31.161 31.16h264.898c17.198 0 31.159-13.962 31.159-31.16z"/><circle cx="84.842" cy="367.962" fill="#f00" r="36.225"/><path d="m142.162 395.885v-55.846h23.732c5.968 0 10.305.501 13.009 1.504 2.706 1.003 4.87 2.787 6.496 5.353 1.625 2.565 2.438 5.498 2.438 8.799 0 4.19-1.233 7.651-3.696 10.381s-6.145 4.45-11.047 5.161c2.438 1.423 4.451 2.984 6.038 4.686 1.587 1.701 3.727 4.724 6.418 9.066l6.82 10.896h-13.485l-8.153-12.153c-2.895-4.342-4.875-7.079-5.942-8.209s-2.197-1.904-3.39-2.323c-1.194-.42-3.086-.63-5.677-.63h-2.285v23.315zm11.276-32.229h8.342c5.41 0 8.788-.228 10.133-.685 1.346-.456 2.4-1.245 3.162-2.362.761-1.117 1.143-2.514 1.143-4.19 0-1.879-.502-3.397-1.505-4.552-1.004-1.156-2.419-1.886-4.248-2.19-.914-.127-3.657-.191-8.228-.191h-8.799z" fill="#fff" fill-rule="nonzero"/><path d="m198.465 395.885v-55.846h41.407v9.447h-30.132v12.38h28.037v9.41h-28.037v15.199h31.199v9.41z" fill="#fff" fill-rule="nonzero"/><path d="m286.233 375.351 10.933 3.467c-1.677 6.094-4.464 10.621-8.362 13.581-3.898 2.958-8.844 4.437-14.838 4.437-7.415 0-13.509-2.533-18.285-7.6-4.774-5.065-7.161-11.992-7.161-20.78 0-9.294 2.4-16.513 7.2-21.656s11.111-7.714 18.932-7.714c6.832 0 12.381 2.02 16.648 6.057 2.539 2.387 4.444 5.815 5.713 10.285l-11.16 2.667c-.662-2.895-2.039-5.181-4.134-6.857-2.096-1.676-4.641-2.514-7.637-2.514-4.14 0-7.5 1.486-10.077 4.457s-3.867 7.784-3.867 14.438c0 7.059 1.27 12.088 3.81 15.085s5.841 4.494 9.904 4.494c2.997 0 5.575-.952 7.734-2.857 2.158-1.904 3.707-4.901 4.647-8.99z" fill="#fff" fill-rule="nonzero"/></svg>

After

Width:  |  Height:  |  Size: 4.5 KiB