mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Explore: Adds LogQueryField for InfluxDb (#17450)
* Wip: Intial commit * Wip: Adds intial InfluxLogsQueryField * Refactor: Adds measurements to InfluxLogQueryField * Style: Tweaks styles and adds chosen measurement to measurements * Refactor: Adds remove filter row * refactor: make influx datasource typed Uses the new api for exporting the plugin. * adds metricFindQuery to DataSourceApi metricFindQuery, getTagKeys and getTagValues now returns a promise * influx: minor improvements Limits logs result to 1000. Don't show adhoc filter until measurement have been selected. * Refactor: Adds fields to Cascader and uses chosen field as log column Co-authored-by: Marcus <marcus.efraimsson@gmail.com>
This commit is contained in:
parent
a95c7bfa6f
commit
591ea0bfe3
@ -30,6 +30,11 @@ export class DataSourcePlugin<
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setConfigCtrl(ConfigCtrl: any) {
|
||||||
|
this.angularConfigCtrl = ConfigCtrl;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
setQueryCtrl(QueryCtrl: any) {
|
setQueryCtrl(QueryCtrl: any) {
|
||||||
this.components.QueryCtrl = QueryCtrl;
|
this.components.QueryCtrl = QueryCtrl;
|
||||||
return this;
|
return this;
|
||||||
@ -199,6 +204,21 @@ export abstract class DataSourceApi<
|
|||||||
options?: TContextQueryOptions
|
options?: TContextQueryOptions
|
||||||
) => Promise<DataQueryResponse>;
|
) => Promise<DataQueryResponse>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variable query action.
|
||||||
|
*/
|
||||||
|
metricFindQuery?(query: any, options?: any): Promise<MetricFindValue[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get tag keys for adhoc filters
|
||||||
|
*/
|
||||||
|
getTagKeys?(options: any): Promise<MetricFindValue[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get tag values for adhoc filters
|
||||||
|
*/
|
||||||
|
getTagValues?(options: { key: any }): Promise<MetricFindValue[]>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set after constructor call, as the data source instance is the most common thing to pass around
|
* Set after constructor call, as the data source instance is the most common thing to pass around
|
||||||
* we attach the components to this instance for easy access
|
* we attach the components to this instance for easy access
|
||||||
@ -396,6 +416,10 @@ export interface QueryHint {
|
|||||||
fix?: QueryFix;
|
fix?: QueryFix;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MetricFindValue {
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DataSourceJsonData {
|
export interface DataSourceJsonData {
|
||||||
authType?: string;
|
authType?: string;
|
||||||
defaultRegion?: string;
|
defaultRegion?: string;
|
||||||
@ -440,6 +464,7 @@ export interface DataSourceInstanceSettings<T extends DataSourceJsonData = DataS
|
|||||||
jsonData: T;
|
jsonData: T;
|
||||||
username?: string;
|
username?: string;
|
||||||
password?: string; // when access is direct, for some legacy datasources
|
password?: string; // when access is direct, for some legacy datasources
|
||||||
|
database?: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This is the full Authorization header if basic auth is ennabled.
|
* This is the full Authorization header if basic auth is ennabled.
|
||||||
|
83
public/app/features/explore/AdHocFilter.tsx
Normal file
83
public/app/features/explore/AdHocFilter.tsx
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import React, { useContext } from 'react';
|
||||||
|
import { Select, GrafanaTheme, ThemeContext, SelectOptionItem } from '@grafana/ui';
|
||||||
|
import { css, cx } from 'emotion';
|
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme) => ({
|
||||||
|
keyValueContainer: css`
|
||||||
|
label: key-value-container;
|
||||||
|
display: flex;
|
||||||
|
flex-flow: row nowrap;
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
enum ChangeType {
|
||||||
|
Key = 'key',
|
||||||
|
Value = 'value',
|
||||||
|
Operator = 'operator',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
keys: string[];
|
||||||
|
keysPlaceHolder?: string;
|
||||||
|
initialKey?: string;
|
||||||
|
initialOperator?: string;
|
||||||
|
initialValue?: string;
|
||||||
|
values?: string[];
|
||||||
|
valuesPlaceHolder?: string;
|
||||||
|
onKeyChanged: (key: string) => void;
|
||||||
|
onValueChanged: (value: string) => void;
|
||||||
|
onOperatorChanged: (operator: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AdHocFilter: React.FunctionComponent<Props> = props => {
|
||||||
|
const theme = useContext(ThemeContext);
|
||||||
|
const styles = getStyles(theme);
|
||||||
|
|
||||||
|
const onChange = (changeType: ChangeType) => (item: SelectOptionItem<string>) => {
|
||||||
|
const { onKeyChanged, onValueChanged, onOperatorChanged } = props;
|
||||||
|
switch (changeType) {
|
||||||
|
case ChangeType.Key:
|
||||||
|
onKeyChanged(item.value);
|
||||||
|
break;
|
||||||
|
case ChangeType.Operator:
|
||||||
|
onOperatorChanged(item.value);
|
||||||
|
break;
|
||||||
|
case ChangeType.Value:
|
||||||
|
onValueChanged(item.value);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stringToOption = (value: string) => ({ label: value, value: value });
|
||||||
|
|
||||||
|
const { keys, initialKey, keysPlaceHolder, initialOperator, values, initialValue, valuesPlaceHolder } = props;
|
||||||
|
const operators = ['=', '!='];
|
||||||
|
const keysAsOptions = keys ? keys.map(stringToOption) : [];
|
||||||
|
const selectedKey = initialKey ? keysAsOptions.filter(option => option.value === initialKey) : null;
|
||||||
|
const valuesAsOptions = values ? values.map(stringToOption) : [];
|
||||||
|
const selectedValue = initialValue ? valuesAsOptions.filter(option => option.value === initialValue) : null;
|
||||||
|
const operatorsAsOptions = operators.map(stringToOption);
|
||||||
|
const selectedOperator = initialOperator
|
||||||
|
? operatorsAsOptions.filter(option => option.value === initialOperator)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cx([styles.keyValueContainer])}>
|
||||||
|
<Select
|
||||||
|
options={keysAsOptions}
|
||||||
|
isSearchable
|
||||||
|
value={selectedKey}
|
||||||
|
onChange={onChange(ChangeType.Key)}
|
||||||
|
placeholder={keysPlaceHolder}
|
||||||
|
/>
|
||||||
|
<Select options={operatorsAsOptions} value={selectedOperator} onChange={onChange(ChangeType.Operator)} />
|
||||||
|
<Select
|
||||||
|
options={valuesAsOptions}
|
||||||
|
isSearchable
|
||||||
|
value={selectedValue}
|
||||||
|
onChange={onChange(ChangeType.Value)}
|
||||||
|
placeholder={valuesPlaceHolder}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
138
public/app/features/explore/AdHocFilterField.tsx
Normal file
138
public/app/features/explore/AdHocFilterField.tsx
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { DataSourceApi, DataQuery, DataSourceJsonData } from '@grafana/ui';
|
||||||
|
import { AdHocFilter } from './AdHocFilter';
|
||||||
|
|
||||||
|
export interface KeyValuePair {
|
||||||
|
keys: string[];
|
||||||
|
key: string;
|
||||||
|
operator: string;
|
||||||
|
value: string;
|
||||||
|
values: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Props<TQuery extends DataQuery = DataQuery, TOptions extends DataSourceJsonData = DataSourceJsonData> {
|
||||||
|
datasource: DataSourceApi<TQuery, TOptions>;
|
||||||
|
onPairsChanged: (pairs: KeyValuePair[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface State {
|
||||||
|
pairs: KeyValuePair[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AdHocFilterField<
|
||||||
|
TQuery extends DataQuery = DataQuery,
|
||||||
|
TOptions extends DataSourceJsonData = DataSourceJsonData
|
||||||
|
> extends React.PureComponent<Props<TQuery, TOptions>, State> {
|
||||||
|
state: State = { pairs: [] };
|
||||||
|
|
||||||
|
async componentDidMount() {
|
||||||
|
const tagKeys = this.props.datasource.getTagKeys ? await this.props.datasource.getTagKeys({}) : [];
|
||||||
|
const keys = tagKeys.map(tagKey => tagKey.text);
|
||||||
|
const pairs = [{ key: null, operator: null, value: null, keys, values: [] }];
|
||||||
|
this.setState({ pairs });
|
||||||
|
}
|
||||||
|
|
||||||
|
onKeyChanged = (index: number) => async (key: string) => {
|
||||||
|
const { datasource, onPairsChanged } = this.props;
|
||||||
|
const tagValues = datasource.getTagValues ? await datasource.getTagValues({ key }) : [];
|
||||||
|
const values = tagValues.map(tagValue => tagValue.text);
|
||||||
|
const newPairs = this.updatePairAt(index, { key, values });
|
||||||
|
|
||||||
|
this.setState({ pairs: newPairs });
|
||||||
|
onPairsChanged(newPairs);
|
||||||
|
};
|
||||||
|
|
||||||
|
onValueChanged = (index: number) => (value: string) => {
|
||||||
|
const newPairs = this.updatePairAt(index, { value });
|
||||||
|
|
||||||
|
this.setState({ pairs: newPairs });
|
||||||
|
this.props.onPairsChanged(newPairs);
|
||||||
|
};
|
||||||
|
|
||||||
|
onOperatorChanged = (index: number) => (operator: string) => {
|
||||||
|
const newPairs = this.updatePairAt(index, { operator });
|
||||||
|
|
||||||
|
this.setState({ pairs: newPairs });
|
||||||
|
this.props.onPairsChanged(newPairs);
|
||||||
|
};
|
||||||
|
|
||||||
|
onAddFilter = async () => {
|
||||||
|
const { pairs } = this.state;
|
||||||
|
const tagKeys = this.props.datasource.getTagKeys ? await this.props.datasource.getTagKeys({}) : [];
|
||||||
|
const keys = tagKeys.map(tagKey => tagKey.text);
|
||||||
|
const newPairs = pairs.concat({ key: null, operator: null, value: null, keys, values: [] });
|
||||||
|
|
||||||
|
this.setState({ pairs: newPairs });
|
||||||
|
};
|
||||||
|
|
||||||
|
onRemoveFilter = async (index: number) => {
|
||||||
|
const { pairs } = this.state;
|
||||||
|
const newPairs = pairs.reduce((allPairs, pair, pairIndex) => {
|
||||||
|
if (pairIndex === index) {
|
||||||
|
return allPairs;
|
||||||
|
}
|
||||||
|
return allPairs.concat(pair);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
this.setState({ pairs: newPairs });
|
||||||
|
};
|
||||||
|
|
||||||
|
private updatePairAt = (index: number, pair: Partial<KeyValuePair>) => {
|
||||||
|
const { pairs } = this.state;
|
||||||
|
const newPairs: KeyValuePair[] = [];
|
||||||
|
for (let pairIndex = 0; pairIndex < pairs.length; pairIndex++) {
|
||||||
|
const newPair = pairs[pairIndex];
|
||||||
|
if (index === pairIndex) {
|
||||||
|
newPairs.push({
|
||||||
|
...newPair,
|
||||||
|
key: pair.key || newPair.key,
|
||||||
|
value: pair.value || newPair.value,
|
||||||
|
operator: pair.operator || newPair.operator,
|
||||||
|
keys: pair.keys || newPair.keys,
|
||||||
|
values: pair.values || newPair.values,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
newPairs.push(newPair);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newPairs;
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { pairs } = this.state;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{pairs.map((pair, index) => {
|
||||||
|
const adHocKey = `adhoc-filter-${index}-${pair.key}-${pair.value}`;
|
||||||
|
return (
|
||||||
|
<div className="align-items-center flex-grow-1" key={adHocKey}>
|
||||||
|
<AdHocFilter
|
||||||
|
keys={pair.keys}
|
||||||
|
values={pair.values}
|
||||||
|
initialKey={pair.key}
|
||||||
|
initialOperator={pair.operator}
|
||||||
|
initialValue={pair.value}
|
||||||
|
onKeyChanged={this.onKeyChanged(index)}
|
||||||
|
onOperatorChanged={this.onOperatorChanged(index)}
|
||||||
|
onValueChanged={this.onValueChanged(index)}
|
||||||
|
/>
|
||||||
|
{index < pairs.length - 1 && <span> AND </span>}
|
||||||
|
{index < pairs.length - 1 && (
|
||||||
|
<button className="gf-form-label gf-form-label--btn" onClick={() => this.onRemoveFilter(index)}>
|
||||||
|
<i className="fa fa-minus" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{index === pairs.length - 1 && (
|
||||||
|
<button className="gf-form-label gf-form-label--btn" onClick={this.onAddFilter}>
|
||||||
|
<i className="fa fa-plus" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,102 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { ExploreQueryFieldProps } from '@grafana/ui';
|
||||||
|
// @ts-ignore
|
||||||
|
import Cascader from 'rc-cascader';
|
||||||
|
|
||||||
|
import InfluxQueryModel from '../influx_query_model';
|
||||||
|
import { AdHocFilterField, KeyValuePair } from 'app/features/explore/AdHocFilterField';
|
||||||
|
import { TemplateSrv } from 'app/features/templating/template_srv';
|
||||||
|
import InfluxDatasource from '../datasource';
|
||||||
|
import { InfluxQueryBuilder } from '../query_builder';
|
||||||
|
import { InfluxQuery, InfluxOptions } from '../types';
|
||||||
|
import { CascaderOption } from '../../loki/components/LokiQueryFieldForm';
|
||||||
|
|
||||||
|
export interface Props extends ExploreQueryFieldProps<InfluxDatasource, InfluxQuery, InfluxOptions> {}
|
||||||
|
|
||||||
|
export interface State {
|
||||||
|
measurements: CascaderOption[];
|
||||||
|
measurement: string;
|
||||||
|
field: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InfluxLogsQueryField extends React.PureComponent<Props, State> {
|
||||||
|
templateSrv: TemplateSrv = new TemplateSrv();
|
||||||
|
state: State = { measurements: [], measurement: null, field: null };
|
||||||
|
|
||||||
|
async componentDidMount() {
|
||||||
|
const { datasource } = this.props;
|
||||||
|
const queryBuilder = new InfluxQueryBuilder({ measurement: '', tags: [] }, datasource.database);
|
||||||
|
const measureMentsQuery = queryBuilder.buildExploreQuery('MEASUREMENTS');
|
||||||
|
const influxMeasurements = await datasource.metricFindQuery(measureMentsQuery);
|
||||||
|
|
||||||
|
const measurements = [];
|
||||||
|
for (let index = 0; index < influxMeasurements.length; index++) {
|
||||||
|
const measurementObj = influxMeasurements[index];
|
||||||
|
const queryBuilder = new InfluxQueryBuilder({ measurement: measurementObj.text, tags: [] }, datasource.database);
|
||||||
|
const fieldsQuery = queryBuilder.buildExploreQuery('FIELDS');
|
||||||
|
const influxFields = await datasource.metricFindQuery(fieldsQuery);
|
||||||
|
const fields = influxFields.map((field: any) => ({
|
||||||
|
label: field.text,
|
||||||
|
value: field.text,
|
||||||
|
children: [],
|
||||||
|
}));
|
||||||
|
measurements.push({
|
||||||
|
label: measurementObj.text,
|
||||||
|
value: measurementObj.text,
|
||||||
|
children: fields,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState({ measurements });
|
||||||
|
}
|
||||||
|
|
||||||
|
onMeasurementsChange = async (values: string[]) => {
|
||||||
|
const { query } = this.props;
|
||||||
|
const measurement = values[0];
|
||||||
|
const field = values[1];
|
||||||
|
|
||||||
|
this.setState({ measurement, field }, () => {
|
||||||
|
this.onPairsChanged((query as any).tags);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
onPairsChanged = (pairs: KeyValuePair[]) => {
|
||||||
|
const { query } = this.props;
|
||||||
|
const { measurement, field } = this.state;
|
||||||
|
const queryModel = new InfluxQueryModel(
|
||||||
|
{
|
||||||
|
...query,
|
||||||
|
resultFormat: 'table',
|
||||||
|
groupBy: [],
|
||||||
|
select: [[{ type: 'field', params: [field] }]],
|
||||||
|
tags: pairs,
|
||||||
|
limit: '1000',
|
||||||
|
measurement,
|
||||||
|
},
|
||||||
|
this.templateSrv
|
||||||
|
);
|
||||||
|
|
||||||
|
this.props.onChange(queryModel.target);
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { datasource } = this.props;
|
||||||
|
const { measurements, measurement, field } = this.state;
|
||||||
|
const cascadeText = measurement ? `Measurements (${measurement}/${field})` : 'Measurements';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="gf-form-inline gf-form-inline--nowrap">
|
||||||
|
<div className="gf-form flex-shrink-0">
|
||||||
|
<Cascader options={measurements} onChange={this.onMeasurementsChange}>
|
||||||
|
<button className="gf-form-label gf-form-label--btn">
|
||||||
|
{cascadeText} <i className="fa fa-caret-down" />
|
||||||
|
</button>
|
||||||
|
</Cascader>
|
||||||
|
</div>
|
||||||
|
<div className="flex-shrink-1 flex-flow-column-nowrap">
|
||||||
|
{measurement && <AdHocFilterField onPairsChanged={this.onPairsChanged} datasource={datasource} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -2,11 +2,16 @@ import _ from 'lodash';
|
|||||||
|
|
||||||
import * as dateMath from '@grafana/ui/src/utils/datemath';
|
import * as dateMath from '@grafana/ui/src/utils/datemath';
|
||||||
import InfluxSeries from './influx_series';
|
import InfluxSeries from './influx_series';
|
||||||
import InfluxQuery from './influx_query';
|
import InfluxQueryModel from './influx_query_model';
|
||||||
import ResponseParser from './response_parser';
|
import ResponseParser from './response_parser';
|
||||||
import { InfluxQueryBuilder } from './query_builder';
|
import { InfluxQueryBuilder } from './query_builder';
|
||||||
|
import { DataSourceApi, DataSourceInstanceSettings } from '@grafana/ui';
|
||||||
|
import { InfluxQuery, InfluxOptions } from './types';
|
||||||
|
import { BackendSrv } from 'app/core/services/backend_srv';
|
||||||
|
import { TemplateSrv } from 'app/features/templating/template_srv';
|
||||||
|
import { IQService } from 'angular';
|
||||||
|
|
||||||
export default class InfluxDatasource {
|
export default class InfluxDatasource extends DataSourceApi<InfluxQuery, InfluxOptions> {
|
||||||
type: string;
|
type: string;
|
||||||
urls: any;
|
urls: any;
|
||||||
username: string;
|
username: string;
|
||||||
@ -20,7 +25,13 @@ export default class InfluxDatasource {
|
|||||||
httpMode: string;
|
httpMode: string;
|
||||||
|
|
||||||
/** @ngInject */
|
/** @ngInject */
|
||||||
constructor(instanceSettings, private $q, private backendSrv, private templateSrv) {
|
constructor(
|
||||||
|
instanceSettings: DataSourceInstanceSettings<InfluxOptions>,
|
||||||
|
private $q: IQService,
|
||||||
|
private backendSrv: BackendSrv,
|
||||||
|
private templateSrv: TemplateSrv
|
||||||
|
) {
|
||||||
|
super(instanceSettings);
|
||||||
this.type = 'influxdb';
|
this.type = 'influxdb';
|
||||||
this.urls = _.map(instanceSettings.url.split(','), url => {
|
this.urls = _.map(instanceSettings.url.split(','), url => {
|
||||||
return url.trim();
|
return url.trim();
|
||||||
@ -32,9 +43,10 @@ export default class InfluxDatasource {
|
|||||||
this.database = instanceSettings.database;
|
this.database = instanceSettings.database;
|
||||||
this.basicAuth = instanceSettings.basicAuth;
|
this.basicAuth = instanceSettings.basicAuth;
|
||||||
this.withCredentials = instanceSettings.withCredentials;
|
this.withCredentials = instanceSettings.withCredentials;
|
||||||
this.interval = (instanceSettings.jsonData || {}).timeInterval;
|
const settingsData = instanceSettings.jsonData || ({} as InfluxOptions);
|
||||||
|
this.interval = settingsData.timeInterval;
|
||||||
|
this.httpMode = settingsData.httpMode || 'GET';
|
||||||
this.responseParser = new ResponseParser();
|
this.responseParser = new ResponseParser();
|
||||||
this.httpMode = instanceSettings.jsonData.httpMode || 'GET';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
query(options) {
|
query(options) {
|
||||||
@ -55,7 +67,7 @@ export default class InfluxDatasource {
|
|||||||
// backward compatibility
|
// backward compatibility
|
||||||
scopedVars.interval = scopedVars.__interval;
|
scopedVars.interval = scopedVars.__interval;
|
||||||
|
|
||||||
queryModel = new InfluxQuery(target, this.templateSrv, scopedVars);
|
queryModel = new InfluxQueryModel(target, this.templateSrv, scopedVars);
|
||||||
return queryModel.render(true);
|
return queryModel.render(true);
|
||||||
}).reduce((acc, current) => {
|
}).reduce((acc, current) => {
|
||||||
if (current !== '') {
|
if (current !== '') {
|
||||||
|
@ -1,17 +1,19 @@
|
|||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import queryPart from './query_part';
|
import queryPart from './query_part';
|
||||||
import kbn from 'app/core/utils/kbn';
|
import kbn from 'app/core/utils/kbn';
|
||||||
|
import { InfluxQuery } from './types';
|
||||||
|
|
||||||
export default class InfluxQuery {
|
export default class InfluxQueryModel {
|
||||||
target: any;
|
target: InfluxQuery;
|
||||||
selectModels: any[];
|
selectModels: any[];
|
||||||
queryBuilder: any;
|
queryBuilder: any;
|
||||||
groupByParts: any;
|
groupByParts: any;
|
||||||
templateSrv: any;
|
templateSrv: any;
|
||||||
scopedVars: any;
|
scopedVars: any;
|
||||||
|
refId: string;
|
||||||
|
|
||||||
/** @ngInject */
|
/** @ngInject */
|
||||||
constructor(target, templateSrv?, scopedVars?) {
|
constructor(target: InfluxQuery, templateSrv?, scopedVars?) {
|
||||||
this.target = target;
|
this.target = target;
|
||||||
this.templateSrv = templateSrv;
|
this.templateSrv = templateSrv;
|
||||||
this.scopedVars = scopedVars;
|
this.scopedVars = scopedVars;
|
@ -1,10 +1,12 @@
|
|||||||
import InfluxDatasource from './datasource';
|
import InfluxDatasource from './datasource';
|
||||||
import { InfluxQueryCtrl } from './query_ctrl';
|
import { InfluxQueryCtrl } from './query_ctrl';
|
||||||
|
import { InfluxLogsQueryField } from './components/InfluxLogsQueryField';
|
||||||
import {
|
import {
|
||||||
createChangeHandler,
|
createChangeHandler,
|
||||||
createResetHandler,
|
createResetHandler,
|
||||||
PasswordFieldEnum,
|
PasswordFieldEnum,
|
||||||
} from '../../../features/datasources/utils/passwordHandlers';
|
} from '../../../features/datasources/utils/passwordHandlers';
|
||||||
|
import { DataSourcePlugin } from '@grafana/ui';
|
||||||
|
|
||||||
class InfluxConfigCtrl {
|
class InfluxConfigCtrl {
|
||||||
static templateUrl = 'partials/config.html';
|
static templateUrl = 'partials/config.html';
|
||||||
@ -25,9 +27,8 @@ class InfluxAnnotationsQueryCtrl {
|
|||||||
static templateUrl = 'partials/annotations.editor.html';
|
static templateUrl = 'partials/annotations.editor.html';
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export const plugin = new DataSourcePlugin(InfluxDatasource)
|
||||||
InfluxDatasource as Datasource,
|
.setConfigCtrl(InfluxConfigCtrl)
|
||||||
InfluxQueryCtrl as QueryCtrl,
|
.setQueryCtrl(InfluxQueryCtrl)
|
||||||
InfluxConfigCtrl as ConfigCtrl,
|
.setAnnotationQueryCtrl(InfluxAnnotationsQueryCtrl)
|
||||||
InfluxAnnotationsQueryCtrl as AnnotationsQueryCtrl,
|
.setExploreLogsQueryField(InfluxLogsQueryField);
|
||||||
};
|
|
||||||
|
@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
"defaultMatchFormat": "regex values",
|
"defaultMatchFormat": "regex values",
|
||||||
"metrics": true,
|
"metrics": true,
|
||||||
|
"logs": true,
|
||||||
"annotations": true,
|
"annotations": true,
|
||||||
"alerting": true,
|
"alerting": true,
|
||||||
|
|
||||||
|
@ -1,14 +1,14 @@
|
|||||||
import angular from 'angular';
|
import angular from 'angular';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import { InfluxQueryBuilder } from './query_builder';
|
import { InfluxQueryBuilder } from './query_builder';
|
||||||
import InfluxQuery from './influx_query';
|
import InfluxQueryModel from './influx_query_model';
|
||||||
import queryPart from './query_part';
|
import queryPart from './query_part';
|
||||||
import { QueryCtrl } from 'app/plugins/sdk';
|
import { QueryCtrl } from 'app/plugins/sdk';
|
||||||
|
|
||||||
export class InfluxQueryCtrl extends QueryCtrl {
|
export class InfluxQueryCtrl extends QueryCtrl {
|
||||||
static templateUrl = 'partials/query.editor.html';
|
static templateUrl = 'partials/query.editor.html';
|
||||||
|
|
||||||
queryModel: InfluxQuery;
|
queryModel: InfluxQueryModel;
|
||||||
queryBuilder: any;
|
queryBuilder: any;
|
||||||
groupBySegment: any;
|
groupBySegment: any;
|
||||||
resultFormats: any[];
|
resultFormats: any[];
|
||||||
@ -23,7 +23,7 @@ export class InfluxQueryCtrl extends QueryCtrl {
|
|||||||
constructor($scope, $injector, private templateSrv, private $q, private uiSegmentSrv) {
|
constructor($scope, $injector, private templateSrv, private $q, private uiSegmentSrv) {
|
||||||
super($scope, $injector);
|
super($scope, $injector);
|
||||||
this.target = this.target;
|
this.target = this.target;
|
||||||
this.queryModel = new InfluxQuery(this.target, templateSrv, this.panel.scopedVars);
|
this.queryModel = new InfluxQueryModel(this.target, templateSrv, this.panel.scopedVars);
|
||||||
this.queryBuilder = new InfluxQueryBuilder(this.target, this.datasource.database);
|
this.queryBuilder = new InfluxQueryBuilder(this.target, this.datasource.database);
|
||||||
this.groupBySegment = this.uiSegmentSrv.newPlusButton();
|
this.groupBySegment = this.uiSegmentSrv.newPlusButton();
|
||||||
this.resultFormats = [{ text: 'Time series', value: 'time_series' }, { text: 'Table', value: 'table' }];
|
this.resultFormats = [{ text: 'Time series', value: 'time_series' }, { text: 'Table', value: 'table' }];
|
||||||
|
@ -1,12 +1,13 @@
|
|||||||
import InfluxQuery from '../influx_query';
|
import InfluxQueryModel from '../influx_query_model';
|
||||||
|
|
||||||
describe('InfluxQuery', () => {
|
describe('InfluxQuery', () => {
|
||||||
const templateSrv = { replace: val => val };
|
const templateSrv = { replace: val => val };
|
||||||
|
|
||||||
describe('render series with mesurement only', () => {
|
describe('render series with mesurement only', () => {
|
||||||
it('should generate correct query', () => {
|
it('should generate correct query', () => {
|
||||||
const query = new InfluxQuery(
|
const query = new InfluxQueryModel(
|
||||||
{
|
{
|
||||||
|
refId: 'A',
|
||||||
measurement: 'cpu',
|
measurement: 'cpu',
|
||||||
},
|
},
|
||||||
templateSrv,
|
templateSrv,
|
||||||
@ -20,8 +21,9 @@ describe('InfluxQuery', () => {
|
|||||||
|
|
||||||
describe('render series with policy only', () => {
|
describe('render series with policy only', () => {
|
||||||
it('should generate correct query', () => {
|
it('should generate correct query', () => {
|
||||||
const query = new InfluxQuery(
|
const query = new InfluxQueryModel(
|
||||||
{
|
{
|
||||||
|
refId: 'A',
|
||||||
measurement: 'cpu',
|
measurement: 'cpu',
|
||||||
policy: '5m_avg',
|
policy: '5m_avg',
|
||||||
},
|
},
|
||||||
@ -38,8 +40,9 @@ describe('InfluxQuery', () => {
|
|||||||
|
|
||||||
describe('render series with math and alias', () => {
|
describe('render series with math and alias', () => {
|
||||||
it('should generate correct query', () => {
|
it('should generate correct query', () => {
|
||||||
const query = new InfluxQuery(
|
const query = new InfluxQueryModel(
|
||||||
{
|
{
|
||||||
|
refId: 'A',
|
||||||
measurement: 'cpu',
|
measurement: 'cpu',
|
||||||
select: [
|
select: [
|
||||||
[
|
[
|
||||||
@ -63,8 +66,9 @@ describe('InfluxQuery', () => {
|
|||||||
|
|
||||||
describe('series with single tag only', () => {
|
describe('series with single tag only', () => {
|
||||||
it('should generate correct query', () => {
|
it('should generate correct query', () => {
|
||||||
const query = new InfluxQuery(
|
const query = new InfluxQueryModel(
|
||||||
{
|
{
|
||||||
|
refId: 'A',
|
||||||
measurement: 'cpu',
|
measurement: 'cpu',
|
||||||
groupBy: [{ type: 'time', params: ['auto'] }],
|
groupBy: [{ type: 'time', params: ['auto'] }],
|
||||||
tags: [{ key: 'hostname', value: 'server\\1' }],
|
tags: [{ key: 'hostname', value: 'server\\1' }],
|
||||||
@ -82,8 +86,9 @@ describe('InfluxQuery', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should switch regex operator with tag value is regex', () => {
|
it('should switch regex operator with tag value is regex', () => {
|
||||||
const query = new InfluxQuery(
|
const query = new InfluxQueryModel(
|
||||||
{
|
{
|
||||||
|
refId: 'A',
|
||||||
measurement: 'cpu',
|
measurement: 'cpu',
|
||||||
groupBy: [{ type: 'time', params: ['auto'] }],
|
groupBy: [{ type: 'time', params: ['auto'] }],
|
||||||
tags: [{ key: 'app', value: '/e.*/' }],
|
tags: [{ key: 'app', value: '/e.*/' }],
|
||||||
@ -101,8 +106,9 @@ describe('InfluxQuery', () => {
|
|||||||
|
|
||||||
describe('series with multiple tags only', () => {
|
describe('series with multiple tags only', () => {
|
||||||
it('should generate correct query', () => {
|
it('should generate correct query', () => {
|
||||||
const query = new InfluxQuery(
|
const query = new InfluxQueryModel(
|
||||||
{
|
{
|
||||||
|
refId: 'A',
|
||||||
measurement: 'cpu',
|
measurement: 'cpu',
|
||||||
groupBy: [{ type: 'time', params: ['auto'] }],
|
groupBy: [{ type: 'time', params: ['auto'] }],
|
||||||
tags: [{ key: 'hostname', value: 'server1' }, { key: 'app', value: 'email', condition: 'AND' }],
|
tags: [{ key: 'hostname', value: 'server1' }, { key: 'app', value: 'email', condition: 'AND' }],
|
||||||
@ -121,8 +127,9 @@ describe('InfluxQuery', () => {
|
|||||||
|
|
||||||
describe('series with tags OR condition', () => {
|
describe('series with tags OR condition', () => {
|
||||||
it('should generate correct query', () => {
|
it('should generate correct query', () => {
|
||||||
const query = new InfluxQuery(
|
const query = new InfluxQueryModel(
|
||||||
{
|
{
|
||||||
|
refId: 'A',
|
||||||
measurement: 'cpu',
|
measurement: 'cpu',
|
||||||
groupBy: [{ type: 'time', params: ['auto'] }],
|
groupBy: [{ type: 'time', params: ['auto'] }],
|
||||||
tags: [{ key: 'hostname', value: 'server1' }, { key: 'hostname', value: 'server2', condition: 'OR' }],
|
tags: [{ key: 'hostname', value: 'server1' }, { key: 'hostname', value: 'server2', condition: 'OR' }],
|
||||||
@ -141,8 +148,9 @@ describe('InfluxQuery', () => {
|
|||||||
|
|
||||||
describe('field name with single quote should be escaped and', () => {
|
describe('field name with single quote should be escaped and', () => {
|
||||||
it('should generate correct query', () => {
|
it('should generate correct query', () => {
|
||||||
const query = new InfluxQuery(
|
const query = new InfluxQueryModel(
|
||||||
{
|
{
|
||||||
|
refId: 'A',
|
||||||
measurement: 'cpu',
|
measurement: 'cpu',
|
||||||
groupBy: [{ type: 'time', params: ['auto'] }],
|
groupBy: [{ type: 'time', params: ['auto'] }],
|
||||||
tags: [{ key: 'name', value: "Let's encrypt." }, { key: 'hostname', value: 'server2', condition: 'OR' }],
|
tags: [{ key: 'name', value: "Let's encrypt." }, { key: 'hostname', value: 'server2', condition: 'OR' }],
|
||||||
@ -161,8 +169,9 @@ describe('InfluxQuery', () => {
|
|||||||
|
|
||||||
describe('query with value condition', () => {
|
describe('query with value condition', () => {
|
||||||
it('should not quote value', () => {
|
it('should not quote value', () => {
|
||||||
const query = new InfluxQuery(
|
const query = new InfluxQueryModel(
|
||||||
{
|
{
|
||||||
|
refId: 'A',
|
||||||
measurement: 'cpu',
|
measurement: 'cpu',
|
||||||
groupBy: [],
|
groupBy: [],
|
||||||
tags: [{ key: 'value', value: '5', operator: '>' }],
|
tags: [{ key: 'value', value: '5', operator: '>' }],
|
||||||
@ -178,8 +187,9 @@ describe('InfluxQuery', () => {
|
|||||||
|
|
||||||
describe('series with groupByTag', () => {
|
describe('series with groupByTag', () => {
|
||||||
it('should generate correct query', () => {
|
it('should generate correct query', () => {
|
||||||
const query = new InfluxQuery(
|
const query = new InfluxQueryModel(
|
||||||
{
|
{
|
||||||
|
refId: 'A',
|
||||||
measurement: 'cpu',
|
measurement: 'cpu',
|
||||||
tags: [],
|
tags: [],
|
||||||
groupBy: [{ type: 'time', interval: 'auto' }, { type: 'tag', params: ['host'] }],
|
groupBy: [{ type: 'time', interval: 'auto' }, { type: 'tag', params: ['host'] }],
|
||||||
@ -195,8 +205,9 @@ describe('InfluxQuery', () => {
|
|||||||
|
|
||||||
describe('render series without group by', () => {
|
describe('render series without group by', () => {
|
||||||
it('should generate correct query', () => {
|
it('should generate correct query', () => {
|
||||||
const query = new InfluxQuery(
|
const query = new InfluxQueryModel(
|
||||||
{
|
{
|
||||||
|
refId: 'A',
|
||||||
measurement: 'cpu',
|
measurement: 'cpu',
|
||||||
select: [[{ type: 'field', params: ['value'] }]],
|
select: [[{ type: 'field', params: ['value'] }]],
|
||||||
groupBy: [],
|
groupBy: [],
|
||||||
@ -211,8 +222,9 @@ describe('InfluxQuery', () => {
|
|||||||
|
|
||||||
describe('render series without group by and fill', () => {
|
describe('render series without group by and fill', () => {
|
||||||
it('should generate correct query', () => {
|
it('should generate correct query', () => {
|
||||||
const query = new InfluxQuery(
|
const query = new InfluxQueryModel(
|
||||||
{
|
{
|
||||||
|
refId: 'A',
|
||||||
measurement: 'cpu',
|
measurement: 'cpu',
|
||||||
select: [[{ type: 'field', params: ['value'] }]],
|
select: [[{ type: 'field', params: ['value'] }]],
|
||||||
groupBy: [{ type: 'time' }, { type: 'fill', params: ['0'] }],
|
groupBy: [{ type: 'time' }, { type: 'fill', params: ['0'] }],
|
||||||
@ -227,8 +239,9 @@ describe('InfluxQuery', () => {
|
|||||||
|
|
||||||
describe('when adding group by part', () => {
|
describe('when adding group by part', () => {
|
||||||
it('should add tag before fill', () => {
|
it('should add tag before fill', () => {
|
||||||
const query = new InfluxQuery(
|
const query = new InfluxQueryModel(
|
||||||
{
|
{
|
||||||
|
refId: 'A',
|
||||||
measurement: 'cpu',
|
measurement: 'cpu',
|
||||||
groupBy: [{ type: 'time' }, { type: 'fill' }],
|
groupBy: [{ type: 'time' }, { type: 'fill' }],
|
||||||
},
|
},
|
||||||
@ -244,8 +257,9 @@ describe('InfluxQuery', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should add tag last if no fill', () => {
|
it('should add tag last if no fill', () => {
|
||||||
const query = new InfluxQuery(
|
const query = new InfluxQueryModel(
|
||||||
{
|
{
|
||||||
|
refId: 'A',
|
||||||
measurement: 'cpu',
|
measurement: 'cpu',
|
||||||
groupBy: [],
|
groupBy: [],
|
||||||
},
|
},
|
||||||
@ -261,8 +275,9 @@ describe('InfluxQuery', () => {
|
|||||||
|
|
||||||
describe('when adding select part', () => {
|
describe('when adding select part', () => {
|
||||||
it('should add mean after after field', () => {
|
it('should add mean after after field', () => {
|
||||||
const query = new InfluxQuery(
|
const query = new InfluxQueryModel(
|
||||||
{
|
{
|
||||||
|
refId: 'A',
|
||||||
measurement: 'cpu',
|
measurement: 'cpu',
|
||||||
select: [[{ type: 'field', params: ['value'] }]],
|
select: [[{ type: 'field', params: ['value'] }]],
|
||||||
},
|
},
|
||||||
@ -276,8 +291,9 @@ describe('InfluxQuery', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should replace sum by mean', () => {
|
it('should replace sum by mean', () => {
|
||||||
const query = new InfluxQuery(
|
const query = new InfluxQueryModel(
|
||||||
{
|
{
|
||||||
|
refId: 'A',
|
||||||
measurement: 'cpu',
|
measurement: 'cpu',
|
||||||
select: [[{ type: 'field', params: ['value'] }, { type: 'mean' }]],
|
select: [[{ type: 'field', params: ['value'] }, { type: 'mean' }]],
|
||||||
},
|
},
|
||||||
@ -291,8 +307,9 @@ describe('InfluxQuery', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should add math before alias', () => {
|
it('should add math before alias', () => {
|
||||||
const query = new InfluxQuery(
|
const query = new InfluxQueryModel(
|
||||||
{
|
{
|
||||||
|
refId: 'A',
|
||||||
measurement: 'cpu',
|
measurement: 'cpu',
|
||||||
select: [[{ type: 'field', params: ['value'] }, { type: 'mean' }, { type: 'alias' }]],
|
select: [[{ type: 'field', params: ['value'] }, { type: 'mean' }, { type: 'alias' }]],
|
||||||
},
|
},
|
||||||
@ -306,8 +323,9 @@ describe('InfluxQuery', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should add math last', () => {
|
it('should add math last', () => {
|
||||||
const query = new InfluxQuery(
|
const query = new InfluxQueryModel(
|
||||||
{
|
{
|
||||||
|
refId: 'A',
|
||||||
measurement: 'cpu',
|
measurement: 'cpu',
|
||||||
select: [[{ type: 'field', params: ['value'] }, { type: 'mean' }]],
|
select: [[{ type: 'field', params: ['value'] }, { type: 'mean' }]],
|
||||||
},
|
},
|
||||||
@ -321,8 +339,9 @@ describe('InfluxQuery', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should replace math', () => {
|
it('should replace math', () => {
|
||||||
const query = new InfluxQuery(
|
const query = new InfluxQueryModel(
|
||||||
{
|
{
|
||||||
|
refId: 'A',
|
||||||
measurement: 'cpu',
|
measurement: 'cpu',
|
||||||
select: [[{ type: 'field', params: ['value'] }, { type: 'mean' }, { type: 'math' }]],
|
select: [[{ type: 'field', params: ['value'] }, { type: 'mean' }, { type: 'math' }]],
|
||||||
},
|
},
|
||||||
@ -336,8 +355,9 @@ describe('InfluxQuery', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should add math when one only query part', () => {
|
it('should add math when one only query part', () => {
|
||||||
const query = new InfluxQuery(
|
const query = new InfluxQueryModel(
|
||||||
{
|
{
|
||||||
|
refId: 'A',
|
||||||
measurement: 'cpu',
|
measurement: 'cpu',
|
||||||
select: [[{ type: 'field', params: ['value'] }]],
|
select: [[{ type: 'field', params: ['value'] }]],
|
||||||
},
|
},
|
||||||
@ -352,7 +372,7 @@ describe('InfluxQuery', () => {
|
|||||||
|
|
||||||
describe('when render adhoc filters', () => {
|
describe('when render adhoc filters', () => {
|
||||||
it('should generate correct query segment', () => {
|
it('should generate correct query segment', () => {
|
||||||
const query = new InfluxQuery({ measurement: 'cpu' }, templateSrv, {});
|
const query = new InfluxQueryModel({ refId: 'A', measurement: 'cpu' }, templateSrv, {});
|
||||||
|
|
||||||
const queryText = query.renderAdhocFilters([
|
const queryText = query.renderAdhocFilters([
|
||||||
{ key: 'key1', operator: '=', value: 'value1' },
|
{ key: 'key1', operator: '=', value: 'value1' },
|
35
public/app/plugins/datasource/influxdb/types.ts
Normal file
35
public/app/plugins/datasource/influxdb/types.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { DataQuery, DataSourceJsonData } from '@grafana/ui/src/types';
|
||||||
|
|
||||||
|
export interface InfluxOptions extends DataSourceJsonData {
|
||||||
|
timeInterval: string;
|
||||||
|
httpMode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InfluxQueryPart {
|
||||||
|
type: string;
|
||||||
|
params?: string[];
|
||||||
|
interval?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InfluxQueryTag {
|
||||||
|
key: string;
|
||||||
|
operator?: string;
|
||||||
|
condition?: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InfluxQuery extends DataQuery {
|
||||||
|
policy?: string;
|
||||||
|
measurement?: string;
|
||||||
|
resultFormat?: 'time_series' | 'table';
|
||||||
|
orderByTime?: string;
|
||||||
|
tags?: InfluxQueryTag[];
|
||||||
|
groupBy?: InfluxQueryPart[];
|
||||||
|
select?: InfluxQueryPart[][];
|
||||||
|
limit?: string;
|
||||||
|
slimit?: string;
|
||||||
|
tz?: string;
|
||||||
|
fill?: string;
|
||||||
|
rawQuery?: boolean;
|
||||||
|
query?: string;
|
||||||
|
}
|
@ -5,6 +5,7 @@ import {
|
|||||||
DataQueryResponse,
|
DataQueryResponse,
|
||||||
DataSourceApi,
|
DataSourceApi,
|
||||||
DataSourceInstanceSettings,
|
DataSourceInstanceSettings,
|
||||||
|
MetricFindValue,
|
||||||
} from '@grafana/ui/src/types';
|
} from '@grafana/ui/src/types';
|
||||||
import { InputQuery, InputOptions } from './types';
|
import { InputQuery, InputOptions } from './types';
|
||||||
|
|
||||||
@ -27,7 +28,7 @@ export class InputDatasource extends DataSourceApi<InputQuery, InputOptions> {
|
|||||||
return `Shared Data From: ${this.name} (${describeSeriesData(this.data)})`;
|
return `Shared Data From: ${this.name} (${describeSeriesData(this.data)})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
metricFindQuery(query: string, options?: any) {
|
metricFindQuery(query: string, options?: any): Promise<MetricFindValue[]> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const names = [];
|
const names = [];
|
||||||
for (const series of this.data) {
|
for (const series of this.data) {
|
||||||
|
@ -95,6 +95,11 @@ button.close {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.flex-flow-column-nowrap {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: column nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.center-vh {
|
.center-vh {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -103,3 +108,9 @@ button.close {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
justify-items: center;
|
justify-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.align-items-center {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row nowrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user