Merge branch 'master' into org-page-to-react

This commit is contained in:
Peter Holmberg
2018-10-29 14:26:11 +01:00
77 changed files with 1906 additions and 1010 deletions

View File

@@ -0,0 +1,28 @@
import { AppNotification } from 'app/types/';
export enum ActionTypes {
AddAppNotification = 'ADD_APP_NOTIFICATION',
ClearAppNotification = 'CLEAR_APP_NOTIFICATION',
}
interface AddAppNotificationAction {
type: ActionTypes.AddAppNotification;
payload: AppNotification;
}
interface ClearAppNotificationAction {
type: ActionTypes.ClearAppNotification;
payload: number;
}
export type Action = AddAppNotificationAction | ClearAppNotificationAction;
export const clearAppNotification = (appNotificationId: number) => ({
type: ActionTypes.ClearAppNotification,
payload: appNotificationId,
});
export const notifyApp = (appNotification: AppNotification) => ({
type: ActionTypes.AddAppNotification,
payload: appNotification,
});

View File

@@ -1,4 +1,5 @@
import { updateLocation } from './location';
import { updateNavIndex, UpdateNavIndexAction } from './navModel';
import { notifyApp, clearAppNotification } from './appNotification';
export { updateLocation, updateNavIndex, UpdateNavIndexAction };
export { updateLocation, updateNavIndex, UpdateNavIndexAction, notifyApp, clearAppNotification };

View File

@@ -5,10 +5,12 @@ import EmptyListCTA from './components/EmptyListCTA/EmptyListCTA';
import { SearchResult } from './components/search/SearchResult';
import { TagFilter } from './components/TagFilter/TagFilter';
import { SideMenu } from './components/sidemenu/SideMenu';
import AppNotificationList from './components/AppNotifications/AppNotificationList';
export function registerAngularDirectives() {
react2AngularDirective('passwordStrength', PasswordStrength, ['password']);
react2AngularDirective('sidemenu', SideMenu, []);
react2AngularDirective('appNotificationsList', AppNotificationList, []);
react2AngularDirective('pageHeader', PageHeader, ['model', 'noTabs']);
react2AngularDirective('emptyListCta', EmptyListCTA, ['model']);
react2AngularDirective('searchResult', SearchResult, []);

View File

@@ -0,0 +1,38 @@
import React, { Component } from 'react';
import { AppNotification } from 'app/types';
interface Props {
appNotification: AppNotification;
onClearNotification: (id) => void;
}
export default class AppNotificationItem extends Component<Props> {
shouldComponentUpdate(nextProps) {
return this.props.appNotification.id !== nextProps.appNotification.id;
}
componentDidMount() {
const { appNotification, onClearNotification } = this.props;
setTimeout(() => {
onClearNotification(appNotification.id);
}, appNotification.timeout);
}
render() {
const { appNotification, onClearNotification } = this.props;
return (
<div className={`alert-${appNotification.severity} alert`}>
<div className="alert-icon">
<i className={appNotification.icon} />
</div>
<div className="alert-body">
<div className="alert-title">{appNotification.title}</div>
<div className="alert-text">{appNotification.text}</div>
</div>
<button type="button" className="alert-close" onClick={() => onClearNotification(appNotification.id)}>
<i className="fa fa fa-remove" />
</button>
</div>
);
}
}

View File

@@ -0,0 +1,60 @@
import React, { PureComponent } from 'react';
import appEvents from 'app/core/app_events';
import AppNotificationItem from './AppNotificationItem';
import { notifyApp, clearAppNotification } from 'app/core/actions';
import { connectWithStore } from 'app/core/utils/connectWithReduxStore';
import { AppNotification, StoreState } from 'app/types';
import {
createErrorNotification,
createSuccessNotification,
createWarningNotification,
} from '../../copy/appNotification';
export interface Props {
appNotifications: AppNotification[];
notifyApp: typeof notifyApp;
clearAppNotification: typeof clearAppNotification;
}
export class AppNotificationList extends PureComponent<Props> {
componentDidMount() {
const { notifyApp } = this.props;
appEvents.on('alert-warning', options => notifyApp(createWarningNotification(options[0], options[1])));
appEvents.on('alert-success', options => notifyApp(createSuccessNotification(options[0], options[1])));
appEvents.on('alert-error', options => notifyApp(createErrorNotification(options[0], options[1])));
}
onClearAppNotification = id => {
this.props.clearAppNotification(id);
};
render() {
const { appNotifications } = this.props;
return (
<div>
{appNotifications.map((appNotification, index) => {
return (
<AppNotificationItem
key={`${appNotification.id}-${index}`}
appNotification={appNotification}
onClearNotification={id => this.onClearAppNotification(id)}
/>
);
})}
</div>
);
}
}
const mapStateToProps = (state: StoreState) => ({
appNotifications: state.appNotifications.appNotifications,
});
const mapDispatchToProps = {
notifyApp,
clearAppNotification,
};
export default connectWithStore(AppNotificationList, mapStateToProps, mapDispatchToProps);

View File

@@ -19,3 +19,4 @@ export const Label: SFC<Props> = props => {
</span>
);
};

View File

@@ -0,0 +1,46 @@
import React, { PureComponent } from 'react';
import _ from 'lodash';
export interface Props {
label: string;
checked: boolean;
labelClass?: string;
switchClass?: string;
onChange: (event) => any;
}
export interface State {
id: any;
}
export class Switch extends PureComponent<Props, State> {
state = {
id: _.uniqueId(),
};
internalOnChange = event => {
event.stopPropagation();
this.props.onChange(event);
};
render() {
const { labelClass, switchClass, label, checked } = this.props;
const labelId = `check-${this.state.id}`;
const labelClassName = `gf-form-label ${labelClass} pointer`;
const switchClassName = `gf-form-switch ${switchClass}`;
return (
<div className="gf-form">
{label && (
<label htmlFor={labelId} className={labelClassName}>
{label}
</label>
)}
<div className={switchClassName}>
<input id={labelId} type="checkbox" checked={checked} onChange={this.internalOnChange} />
<label htmlFor={labelId} />
</div>
</div>
);
}
}

View File

@@ -0,0 +1,46 @@
import { AppNotification, AppNotificationSeverity, AppNotificationTimeout } from 'app/types';
const defaultSuccessNotification: AppNotification = {
title: '',
text: '',
severity: AppNotificationSeverity.Success,
icon: 'fa fa-check',
timeout: AppNotificationTimeout.Success,
};
const defaultWarningNotification: AppNotification = {
title: '',
text: '',
severity: AppNotificationSeverity.Warning,
icon: 'fa fa-exclamation',
timeout: AppNotificationTimeout.Warning,
};
const defaultErrorNotification: AppNotification = {
title: '',
text: '',
severity: AppNotificationSeverity.Error,
icon: 'fa fa-exclamation-triangle',
timeout: AppNotificationTimeout.Error,
};
export const createSuccessNotification = (title: string, text?: string): AppNotification => ({
...defaultSuccessNotification,
title: title,
text: text,
id: Date.now(),
});
export const createErrorNotification = (title: string, text?: string): AppNotification => ({
...defaultErrorNotification,
title: title,
text: text,
id: Date.now(),
});
export const createWarningNotification = (title: string, text?: string): AppNotification => ({
...defaultWarningNotification,
title: title,
text: text,
id: Date.now(),
});

View File

@@ -0,0 +1,51 @@
import { appNotificationsReducer } from './appNotification';
import { ActionTypes } from '../actions/appNotification';
import { AppNotificationSeverity, AppNotificationTimeout } from 'app/types/';
describe('clear alert', () => {
it('should filter alert', () => {
const id1 = 1540301236048;
const id2 = 1540301248293;
const initialState = {
appNotifications: [
{
id: id1,
severity: AppNotificationSeverity.Success,
icon: 'success',
title: 'test',
text: 'test alert',
timeout: AppNotificationTimeout.Success,
},
{
id: id2,
severity: AppNotificationSeverity.Warning,
icon: 'warning',
title: 'test2',
text: 'test alert fail 2',
timeout: AppNotificationTimeout.Warning,
},
],
};
const result = appNotificationsReducer(initialState, {
type: ActionTypes.ClearAppNotification,
payload: id2,
});
const expectedResult = {
appNotifications: [
{
id: id1,
severity: AppNotificationSeverity.Success,
icon: 'success',
title: 'test',
text: 'test alert',
timeout: AppNotificationTimeout.Success,
},
],
};
expect(result).toEqual(expectedResult);
});
});

View File

@@ -0,0 +1,19 @@
import { AppNotification, AppNotificationsState } from 'app/types/';
import { Action, ActionTypes } from '../actions/appNotification';
export const initialState: AppNotificationsState = {
appNotifications: [] as AppNotification[],
};
export const appNotificationsReducer = (state = initialState, action: Action): AppNotificationsState => {
switch (action.type) {
case ActionTypes.AddAppNotification:
return { ...state, appNotifications: state.appNotifications.concat([action.payload]) };
case ActionTypes.ClearAppNotification:
return {
...state,
appNotifications: state.appNotifications.filter(appNotification => appNotification.id !== action.payload),
};
}
return state;
};

View File

@@ -1,7 +1,9 @@
import { navIndexReducer as navIndex } from './navModel';
import { locationReducer as location } from './location';
import { appNotificationsReducer as appNotifications } from './appNotification';
export default {
navIndex,
location,
appNotifications,
};

View File

@@ -1,100 +1,12 @@
import angular from 'angular';
import _ from 'lodash';
import coreModule from 'app/core/core_module';
import appEvents from 'app/core/app_events';
export class AlertSrv {
list: any[];
constructor() {}
/** @ngInject */
constructor(private $timeout, private $rootScope) {
this.list = [];
}
init() {
this.$rootScope.onAppEvent(
'alert-error',
(e, alert) => {
this.set(alert[0], alert[1], 'error', 12000);
},
this.$rootScope
);
this.$rootScope.onAppEvent(
'alert-warning',
(e, alert) => {
this.set(alert[0], alert[1], 'warning', 5000);
},
this.$rootScope
);
this.$rootScope.onAppEvent(
'alert-success',
(e, alert) => {
this.set(alert[0], alert[1], 'success', 3000);
},
this.$rootScope
);
appEvents.on('alert-warning', options => this.set(options[0], options[1], 'warning', 5000));
appEvents.on('alert-success', options => this.set(options[0], options[1], 'success', 3000));
appEvents.on('alert-error', options => this.set(options[0], options[1], 'error', 7000));
}
getIconForSeverity(severity) {
switch (severity) {
case 'success':
return 'fa fa-check';
case 'error':
return 'fa fa-exclamation-triangle';
default:
return 'fa fa-exclamation';
}
}
set(title, text, severity, timeout) {
if (_.isObject(text)) {
console.log('alert error', text);
if (text.statusText) {
text = `HTTP Error (${text.status}) ${text.statusText}`;
}
}
const newAlert = {
title: title || '',
text: text || '',
severity: severity || 'info',
icon: this.getIconForSeverity(severity),
};
const newAlertJson = angular.toJson(newAlert);
// remove same alert if it already exists
_.remove(this.list, value => {
return angular.toJson(value) === newAlertJson;
});
this.list.push(newAlert);
if (timeout > 0) {
this.$timeout(() => {
this.list = _.without(this.list, newAlert);
}, timeout);
}
if (!this.$rootScope.$$phase) {
this.$rootScope.$digest();
}
return newAlert;
}
clear(alert) {
this.list = _.without(this.list, alert);
}
clearAll() {
this.list = [];
set() {
console.log('old depricated alert srv being used');
}
}
// this is just added to not break old plugins that might be using it
coreModule.service('alertSrv', AlertSrv);

View File

@@ -9,7 +9,7 @@ export class BackendSrv {
private noBackendCache: boolean;
/** @ngInject */
constructor(private $http, private alertSrv, private $q, private $timeout, private contextSrv) {}
constructor(private $http, private $q, private $timeout, private contextSrv) {}
get(url, params?) {
return this.request({ method: 'GET', url: url, params: params });
@@ -49,14 +49,14 @@ export class BackendSrv {
}
if (err.status === 422) {
this.alertSrv.set('Validation failed', data.message, 'warning', 4000);
appEvents.emit('alert-warning', ['Validation failed', data.message]);
throw data;
}
data.severity = 'error';
let severity = 'error';
if (err.status < 500) {
data.severity = 'warning';
severity = 'warning';
}
if (data.message) {
@@ -66,7 +66,8 @@ export class BackendSrv {
description = message;
message = 'Error';
}
this.alertSrv.set(message, description, data.severity, 10000);
appEvents.emit('alert-' + severity, [message, description]);
}
throw data;
@@ -93,7 +94,7 @@ export class BackendSrv {
if (options.method !== 'GET') {
if (results && results.data.message) {
if (options.showSuccessAlert !== false) {
this.alertSrv.set(results.data.message, '', 'success', 3000);
appEvents.emit('alert-success', [results.data.message]);
}
}
}

View File

@@ -9,7 +9,7 @@ describe('backend_srv', () => {
return Promise.resolve({});
};
const _backendSrv = new BackendSrv(_httpBackend, {}, {}, {}, {});
const _backendSrv = new BackendSrv(_httpBackend, {}, {}, {});
describe('when handling errors', () => {
it('should return the http status code', async () => {

View File

@@ -0,0 +1,11 @@
import React from 'react';
import { connect } from 'react-redux';
import { store } from '../../store/configureStore';
export function connectWithStore(WrappedComponent, ...args) {
const ConnectedWrappedComponent = connect(...args)(WrappedComponent);
return props => {
return <ConnectedWrappedComponent {...props} store={store} />;
};
}

View File

@@ -1,25 +1,32 @@
import './editor_ctrl';
// Libaries
import angular from 'angular';
import _ from 'lodash';
// Components
import './editor_ctrl';
import coreModule from 'app/core/core_module';
// Utils & Services
import { makeRegions, dedupAnnotations } from './events_processing';
// Types
import { DashboardModel } from '../dashboard/dashboard_model';
export class AnnotationsSrv {
globalAnnotationsPromise: any;
alertStatesPromise: any;
datasourcePromises: any;
/** @ngInject */
constructor(private $rootScope, private $q, private datasourceSrv, private backendSrv, private timeSrv) {
$rootScope.onAppEvent('refresh', this.clearCache.bind(this), $rootScope);
$rootScope.onAppEvent('dashboard-initialized', this.clearCache.bind(this), $rootScope);
}
constructor(private $rootScope, private $q, private datasourceSrv, private backendSrv, private timeSrv) {}
clearCache() {
this.globalAnnotationsPromise = null;
this.alertStatesPromise = null;
this.datasourcePromises = null;
init(dashboard: DashboardModel) {
// clear promises on refresh events
dashboard.on('refresh', () => {
this.globalAnnotationsPromise = null;
this.alertStatesPromise = null;
this.datasourcePromises = null;
});
}
getAnnotations(options) {

View File

@@ -1,6 +1,12 @@
// Utils
import config from 'app/core/config';
import appEvents from 'app/core/app_events';
import coreModule from 'app/core/core_module';
// Services
import { AnnotationsSrv } from '../annotations/annotations_srv';
// Types
import { DashboardModel } from './dashboard_model';
import { PanelModel } from './panel_model';
@@ -21,6 +27,7 @@ export class DashboardCtrl {
private dashboardSrv,
private unsavedChangesSrv,
private dashboardViewStateSrv,
private annotationsSrv: AnnotationsSrv,
public playlistSrv
) {
// temp hack due to way dashboards are loaded
@@ -49,6 +56,7 @@ export class DashboardCtrl {
// init services
this.timeSrv.init(dashboard);
this.alertingSrv.init(dashboard, data.alerts);
this.annotationsSrv.init(dashboard);
// template values service needs to initialize completely before
// the rest of the dashboard can load
@@ -72,7 +80,7 @@ export class DashboardCtrl {
this.keybindingSrv.setupDashboardBindings(this.$scope, dashboard);
this.setWindowTitleAndTheme();
this.$scope.appEvent('dashboard-initialized', dashboard);
appEvents.emit('dashboard-initialized', dashboard);
})
.catch(this.onInitFailed.bind(this, 'Dashboard init failed', true));
}

View File

@@ -21,15 +21,14 @@ function GridWrapper({
className,
isResizable,
isDraggable,
isFullscreen,
}) {
if (size.width === 0) {
console.log('size is zero!');
}
const width = size.width > 0 ? size.width : lastGridWidth;
if (width !== lastGridWidth) {
onWidthChange();
lastGridWidth = width;
if (!isFullscreen && Math.abs(width - lastGridWidth) > 8) {
onWidthChange();
lastGridWidth = width;
}
}
return (
@@ -197,6 +196,7 @@ export class DashboardGrid extends React.Component<DashboardGridProps, any> {
onDragStop={this.onDragStop}
onResize={this.onResize}
onResizeStop={this.onResizeStop}
isFullscreen={this.props.dashboard.meta.fullscreen}
>
{this.renderPanels()}
</SizedReactLayoutGrid>

View File

@@ -1,5 +1,4 @@
import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
import Tooltip from 'app/core/components/Tooltip/Tooltip';
import SlideDown from 'app/core/components/Animations/SlideDown';
import { StoreState, FolderInfo } from 'app/types';
@@ -13,7 +12,7 @@ import {
import PermissionList from 'app/core/components/PermissionList/PermissionList';
import AddPermission from 'app/core/components/PermissionList/AddPermission';
import PermissionsInfo from 'app/core/components/PermissionList/PermissionsInfo';
import { store } from 'app/store/configureStore';
import { connectWithStore } from '../../../core/utils/connectWithReduxStore';
export interface Props {
dashboardId: number;
@@ -95,13 +94,6 @@ export class DashboardPermissions extends PureComponent<Props, State> {
}
}
function connectWithStore(WrappedComponent, ...args) {
const ConnectedWrappedComponent = connect(...args)(WrappedComponent);
return props => {
return <ConnectedWrappedComponent {...props} store={store} />;
};
}
const mapStateToProps = (state: StoreState) => ({
permissions: state.dashboard.permissions,
});

View File

@@ -11,7 +11,7 @@ const template = `
`;
/** @ngInject */
function uploadDashboardDirective(timer, alertSrv, $location) {
function uploadDashboardDirective(timer, $location) {
return {
restrict: 'E',
template: template,
@@ -59,7 +59,7 @@ function uploadDashboardDirective(timer, alertSrv, $location) {
// Something
elem[0].addEventListener('change', file_selected, false);
} else {
alertSrv.set('Oops', 'Sorry, the HTML5 File APIs are not fully supported in this browser.', 'error');
appEvents.emit('alert-error', ['Oops', 'The HTML5 File APIs are not fully supported in this browser']);
}
},
};

View File

@@ -373,9 +373,10 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
this.onModifyQueries({ type: 'ADD_FILTER', key: columnKey, value: rowValue });
};
onModifyQueries = (action: object, index?: number) => {
onModifyQueries = (action, index?: number) => {
const { datasource } = this.state;
if (datasource && datasource.modifyQuery) {
const preventSubmit = action.preventSubmit;
this.setState(
state => {
const { queries, queryTransactions } = state;
@@ -391,16 +392,26 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
nextQueryTransactions = [];
} else {
// Modify query only at index
nextQueries = [
...queries.slice(0, index),
{
key: generateQueryKey(index),
query: datasource.modifyQuery(this.queryExpressions[index], action),
},
...queries.slice(index + 1),
];
// Discard transactions related to row query
nextQueryTransactions = queryTransactions.filter(qt => qt.rowIndex !== index);
nextQueries = queries.map((q, i) => {
// Synchronise all queries with local query cache to ensure consistency
q.query = this.queryExpressions[i];
return i === index
? {
key: generateQueryKey(index),
query: datasource.modifyQuery(q.query, action),
}
: q;
});
nextQueryTransactions = queryTransactions
// Consume the hint corresponding to the action
.map(qt => {
if (qt.hints != null && qt.rowIndex === index) {
qt.hints = qt.hints.filter(hint => hint.fix.action !== action);
}
return qt;
})
// Preserve previous row query transaction to keep results visible if next query is incomplete
.filter(qt => preventSubmit || qt.rowIndex !== index);
}
this.queryExpressions = nextQueries.map(q => q.query);
return {
@@ -408,7 +419,8 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
queryTransactions: nextQueryTransactions,
};
},
() => this.onSubmit()
// Accepting certain fixes do not result in a well-formed query which should not be submitted
!preventSubmit ? () => this.onSubmit() : null
);
}
};
@@ -695,11 +707,6 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
});
}
request = url => {
const { datasource } = this.state;
return datasource.metadataRequest(url);
};
cloneState(): ExploreState {
// Copy state, but copy queries including modifications
return {
@@ -831,9 +838,9 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
{datasource && !datasourceError ? (
<div className="explore-container">
<QueryRows
datasource={datasource}
history={history}
queries={queries}
request={this.request}
onAddQueryRow={this.onAddQueryRow}
onChangeQuery={this.onChangeQuery}
onClickHintFix={this.onModifyQueries}

View File

@@ -0,0 +1,72 @@
import PlaceholdersBuffer from './PlaceholdersBuffer';
describe('PlaceholdersBuffer', () => {
it('does nothing if no placeholders are defined', () => {
const text = 'metric';
const buffer = new PlaceholdersBuffer(text);
expect(buffer.hasPlaceholders()).toBe(false);
expect(buffer.toString()).toBe(text);
expect(buffer.getNextMoveOffset()).toBe(0);
});
it('respects the traversal order of placeholders', () => {
const text = 'sum($2 offset $1) by ($3)';
const buffer = new PlaceholdersBuffer(text);
expect(buffer.hasPlaceholders()).toBe(true);
expect(buffer.toString()).toBe('sum( offset ) by ()');
expect(buffer.getNextMoveOffset()).toBe(12);
buffer.setNextPlaceholderValue('1h');
expect(buffer.hasPlaceholders()).toBe(true);
expect(buffer.toString()).toBe('sum( offset 1h) by ()');
expect(buffer.getNextMoveOffset()).toBe(-10);
buffer.setNextPlaceholderValue('metric');
expect(buffer.hasPlaceholders()).toBe(true);
expect(buffer.toString()).toBe('sum(metric offset 1h) by ()');
expect(buffer.getNextMoveOffset()).toBe(16);
buffer.setNextPlaceholderValue('label');
expect(buffer.hasPlaceholders()).toBe(false);
expect(buffer.toString()).toBe('sum(metric offset 1h) by (label)');
expect(buffer.getNextMoveOffset()).toBe(0);
});
it('respects the traversal order of adjacent placeholders', () => {
const text = '$1$3$2$4';
const buffer = new PlaceholdersBuffer(text);
expect(buffer.hasPlaceholders()).toBe(true);
expect(buffer.toString()).toBe('');
expect(buffer.getNextMoveOffset()).toBe(0);
buffer.setNextPlaceholderValue('1');
expect(buffer.hasPlaceholders()).toBe(true);
expect(buffer.toString()).toBe('1');
expect(buffer.getNextMoveOffset()).toBe(0);
buffer.setNextPlaceholderValue('2');
expect(buffer.hasPlaceholders()).toBe(true);
expect(buffer.toString()).toBe('12');
expect(buffer.getNextMoveOffset()).toBe(-1);
buffer.setNextPlaceholderValue('3');
expect(buffer.hasPlaceholders()).toBe(true);
expect(buffer.toString()).toBe('132');
expect(buffer.getNextMoveOffset()).toBe(1);
buffer.setNextPlaceholderValue('4');
expect(buffer.hasPlaceholders()).toBe(false);
expect(buffer.toString()).toBe('1324');
expect(buffer.getNextMoveOffset()).toBe(0);
});
});

View File

@@ -0,0 +1,112 @@
/**
* Provides a stateful means of managing placeholders in text.
*
* Placeholders are numbers prefixed with the `$` character (e.g. `$1`).
* Each number value represents the order in which a placeholder should
* receive focus if multiple placeholders exist.
*
* Example scenario given `sum($3 offset $1) by($2)`:
* 1. `sum( offset |) by()`
* 2. `sum( offset 1h) by(|)`
* 3. `sum(| offset 1h) by (label)`
*/
export default class PlaceholdersBuffer {
private nextMoveOffset: number;
private orders: number[];
private parts: string[];
constructor(text: string) {
const result = this.parse(text);
const nextPlaceholderIndex = result.orders.length ? result.orders[0] : 0;
this.nextMoveOffset = this.getOffsetBetween(result.parts, 0, nextPlaceholderIndex);
this.orders = result.orders;
this.parts = result.parts;
}
clearPlaceholders() {
this.nextMoveOffset = 0;
this.orders = [];
}
getNextMoveOffset(): number {
return this.nextMoveOffset;
}
hasPlaceholders(): boolean {
return this.orders.length > 0;
}
setNextPlaceholderValue(value: string) {
if (this.orders.length === 0) {
return;
}
const currentPlaceholderIndex = this.orders[0];
this.parts[currentPlaceholderIndex] = value;
this.orders = this.orders.slice(1);
if (this.orders.length === 0) {
this.nextMoveOffset = 0;
return;
}
const nextPlaceholderIndex = this.orders[0];
// Case should never happen but handle it gracefully in case
if (currentPlaceholderIndex === nextPlaceholderIndex) {
this.nextMoveOffset = 0;
return;
}
const backwardMove = currentPlaceholderIndex > nextPlaceholderIndex;
const indices = backwardMove
? { start: nextPlaceholderIndex + 1, end: currentPlaceholderIndex + 1 }
: { start: currentPlaceholderIndex + 1, end: nextPlaceholderIndex };
this.nextMoveOffset = (backwardMove ? -1 : 1) * this.getOffsetBetween(this.parts, indices.start, indices.end);
}
toString(): string {
return this.parts.join('');
}
private getOffsetBetween(parts: string[], startIndex: number, endIndex: number) {
return parts.slice(startIndex, endIndex).reduce((offset, part) => offset + part.length, 0);
}
private parse(text: string): ParseResult {
const placeholderRegExp = /\$(\d+)/g;
const parts = [];
const orders = [];
let textOffset = 0;
while (true) {
const match = placeholderRegExp.exec(text);
if (!match) {
break;
}
const part = text.slice(textOffset, match.index);
parts.push(part);
// Accounts for placeholders at text boundaries
if (part !== '') {
parts.push('');
}
const order = parseInt(match[1], 10);
orders.push({ index: parts.length - 1, order });
textOffset += part.length + match.length;
}
// Ensures string serialisation still works if no placeholders were parsed
// and also accounts for the remainder of text with placeholders
parts.push(text.slice(textOffset));
return {
// Placeholder values do not necessarily appear sequentially so sort the
// indices to traverse in priority order
orders: orders.sort((o1, o2) => o1.order - o2.order).map(o => o.index),
parts,
};
}
}
type ParseResult = {
/**
* Indices to placeholder items in `parts` in traversal order.
*/
orders: number[];
/**
* Parts comprising the original text with placeholders occupying distinct items.
*/
parts: string[];
};

View File

@@ -1,231 +1,4 @@
import React from 'react';
import Enzyme, { shallow } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
import Plain from 'slate-plain-serializer';
import PromQueryField, { groupMetricsByPrefix, RECORDING_RULES_GROUP } from './PromQueryField';
Enzyme.configure({ adapter: new Adapter() });
describe('PromQueryField typeahead handling', () => {
const defaultProps = {
request: () => ({ data: { data: [] } }),
};
it('returns default suggestions on emtpty context', () => {
const instance = shallow(<PromQueryField {...defaultProps} />).instance() as PromQueryField;
const result = instance.getTypeahead({ text: '', prefix: '', wrapperClasses: [] });
expect(result.context).toBeUndefined();
expect(result.refresher).toBeUndefined();
expect(result.suggestions.length).toEqual(2);
});
describe('range suggestions', () => {
it('returns range suggestions in range context', () => {
const instance = shallow(<PromQueryField {...defaultProps} />).instance() as PromQueryField;
const result = instance.getTypeahead({ text: '1', prefix: '1', wrapperClasses: ['context-range'] });
expect(result.context).toBe('context-range');
expect(result.refresher).toBeUndefined();
expect(result.suggestions).toEqual([
{
items: [{ label: '1m' }, { label: '5m' }, { label: '10m' }, { label: '30m' }, { label: '1h' }],
label: 'Range vector',
},
]);
});
});
describe('metric suggestions', () => {
it('returns metrics suggestions by default', () => {
const instance = shallow(
<PromQueryField {...defaultProps} metrics={['foo', 'bar']} />
).instance() as PromQueryField;
const result = instance.getTypeahead({ text: 'a', prefix: 'a', wrapperClasses: [] });
expect(result.context).toBeUndefined();
expect(result.refresher).toBeUndefined();
expect(result.suggestions.length).toEqual(2);
});
it('returns default suggestions after a binary operator', () => {
const instance = shallow(
<PromQueryField {...defaultProps} metrics={['foo', 'bar']} />
).instance() as PromQueryField;
const result = instance.getTypeahead({ text: '*', prefix: '', wrapperClasses: [] });
expect(result.context).toBeUndefined();
expect(result.refresher).toBeUndefined();
expect(result.suggestions.length).toEqual(2);
});
});
describe('label suggestions', () => {
it('returns default label suggestions on label context and no metric', () => {
const instance = shallow(<PromQueryField {...defaultProps} />).instance() as PromQueryField;
const value = Plain.deserialize('{}');
const range = value.selection.merge({
anchorOffset: 1,
});
const valueWithSelection = value.change().select(range).value;
const result = instance.getTypeahead({
text: '',
prefix: '',
wrapperClasses: ['context-labels'],
value: valueWithSelection,
});
expect(result.context).toBe('context-labels');
expect(result.suggestions).toEqual([{ items: [{ label: 'job' }, { label: 'instance' }], label: 'Labels' }]);
});
it('returns label suggestions on label context and metric', () => {
const instance = shallow(
<PromQueryField {...defaultProps} labelKeys={{ '{__name__="metric"}': ['bar'] }} />
).instance() as PromQueryField;
const value = Plain.deserialize('metric{}');
const range = value.selection.merge({
anchorOffset: 7,
});
const valueWithSelection = value.change().select(range).value;
const result = instance.getTypeahead({
text: '',
prefix: '',
wrapperClasses: ['context-labels'],
value: valueWithSelection,
});
expect(result.context).toBe('context-labels');
expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
});
it('returns label suggestions on label context but leaves out labels that already exist', () => {
const instance = shallow(
<PromQueryField
{...defaultProps}
labelKeys={{ '{job1="foo",job2!="foo",job3=~"foo"}': ['bar', 'job1', 'job2', 'job3'] }}
/>
).instance() as PromQueryField;
const value = Plain.deserialize('{job1="foo",job2!="foo",job3=~"foo",}');
const range = value.selection.merge({
anchorOffset: 36,
});
const valueWithSelection = value.change().select(range).value;
const result = instance.getTypeahead({
text: '',
prefix: '',
wrapperClasses: ['context-labels'],
value: valueWithSelection,
});
expect(result.context).toBe('context-labels');
expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
});
it('returns label value suggestions inside a label value context after a negated matching operator', () => {
const instance = shallow(
<PromQueryField
{...defaultProps}
labelKeys={{ '{}': ['label'] }}
labelValues={{ '{}': { label: ['a', 'b', 'c'] } }}
/>
).instance() as PromQueryField;
const value = Plain.deserialize('{label!=}');
const range = value.selection.merge({ anchorOffset: 8 });
const valueWithSelection = value.change().select(range).value;
const result = instance.getTypeahead({
text: '!=',
prefix: '',
wrapperClasses: ['context-labels'],
labelKey: 'label',
value: valueWithSelection,
});
expect(result.context).toBe('context-label-values');
expect(result.suggestions).toEqual([
{
items: [{ label: 'a' }, { label: 'b' }, { label: 'c' }],
label: 'Label values for "label"',
},
]);
});
it('returns a refresher on label context and unavailable metric', () => {
const instance = shallow(
<PromQueryField {...defaultProps} labelKeys={{ '{__name__="foo"}': ['bar'] }} />
).instance() as PromQueryField;
const value = Plain.deserialize('metric{}');
const range = value.selection.merge({
anchorOffset: 7,
});
const valueWithSelection = value.change().select(range).value;
const result = instance.getTypeahead({
text: '',
prefix: '',
wrapperClasses: ['context-labels'],
value: valueWithSelection,
});
expect(result.context).toBeUndefined();
expect(result.refresher).toBeInstanceOf(Promise);
expect(result.suggestions).toEqual([]);
});
it('returns label values on label context when given a metric and a label key', () => {
const instance = shallow(
<PromQueryField
{...defaultProps}
labelKeys={{ '{__name__="metric"}': ['bar'] }}
labelValues={{ '{__name__="metric"}': { bar: ['baz'] } }}
/>
).instance() as PromQueryField;
const value = Plain.deserialize('metric{bar=ba}');
const range = value.selection.merge({
anchorOffset: 13,
});
const valueWithSelection = value.change().select(range).value;
const result = instance.getTypeahead({
text: '=ba',
prefix: 'ba',
wrapperClasses: ['context-labels'],
labelKey: 'bar',
value: valueWithSelection,
});
expect(result.context).toBe('context-label-values');
expect(result.suggestions).toEqual([{ items: [{ label: 'baz' }], label: 'Label values for "bar"' }]);
});
it('returns label suggestions on aggregation context and metric w/ selector', () => {
const instance = shallow(
<PromQueryField {...defaultProps} labelKeys={{ '{__name__="metric",foo="xx"}': ['bar'] }} />
).instance() as PromQueryField;
const value = Plain.deserialize('sum(metric{foo="xx"}) by ()');
const range = value.selection.merge({
anchorOffset: 26,
});
const valueWithSelection = value.change().select(range).value;
const result = instance.getTypeahead({
text: '',
prefix: '',
wrapperClasses: ['context-aggregation'],
value: valueWithSelection,
});
expect(result.context).toBe('context-aggregation');
expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
});
it('returns label suggestions on aggregation context and metric w/o selector', () => {
const instance = shallow(
<PromQueryField {...defaultProps} labelKeys={{ '{__name__="metric"}': ['bar'] }} />
).instance() as PromQueryField;
const value = Plain.deserialize('sum(metric) by ()');
const range = value.selection.merge({
anchorOffset: 16,
});
const valueWithSelection = value.change().select(range).value;
const result = instance.getTypeahead({
text: '',
prefix: '',
wrapperClasses: ['context-aggregation'],
value: valueWithSelection,
});
expect(result.context).toBe('context-aggregation');
expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
});
});
});
import { groupMetricsByPrefix, RECORDING_RULES_GROUP } from './PromQueryField';
describe('groupMetricsByPrefix()', () => {
it('returns an empty group for no metrics', () => {

View File

@@ -1,67 +1,23 @@
import _ from 'lodash';
import moment from 'moment';
import React from 'react';
import { Value } from 'slate';
import Cascader from 'rc-cascader';
import PluginPrism from 'slate-prism';
import Prism from 'prismjs';
import { TypeaheadOutput } from 'app/types/explore';
// dom also includes Element polyfills
import { getNextCharacter, getPreviousCousin } from './utils/dom';
import PrismPromql, { FUNCTIONS } from './slate-plugins/prism/promql';
import BracesPlugin from './slate-plugins/braces';
import RunnerPlugin from './slate-plugins/runner';
import { processLabels, RATE_RANGES, cleanText, parseSelector } from './utils/prometheus';
import TypeaheadField, {
Suggestion,
SuggestionGroup,
TypeaheadInput,
TypeaheadFieldState,
TypeaheadOutput,
} from './QueryField';
import TypeaheadField, { TypeaheadInput, TypeaheadFieldState } from './QueryField';
const DEFAULT_KEYS = ['job', 'instance'];
const EMPTY_SELECTOR = '{}';
const HISTOGRAM_GROUP = '__histograms__';
const HISTOGRAM_SELECTOR = '{le!=""}'; // Returns all timeseries for histograms
const HISTORY_ITEM_COUNT = 5;
const HISTORY_COUNT_CUTOFF = 1000 * 60 * 60 * 24; // 24h
const METRIC_MARK = 'metric';
const PRISM_SYNTAX = 'promql';
export const RECORDING_RULES_GROUP = '__recording_rules__';
export const wrapLabel = (label: string) => ({ label });
export const setFunctionMove = (suggestion: Suggestion): Suggestion => {
suggestion.move = -1;
return suggestion;
};
// Syntax highlighting
Prism.languages[PRISM_SYNTAX] = PrismPromql;
function setPrismTokens(language, field, values, alias = 'variable') {
Prism.languages[language][field] = {
alias,
pattern: new RegExp(`(?:^|\\s)(${values.join('|')})(?:$|\\s)`),
};
}
export function addHistoryMetadata(item: Suggestion, history: any[]): Suggestion {
const cutoffTs = Date.now() - HISTORY_COUNT_CUTOFF;
const historyForItem = history.filter(h => h.ts > cutoffTs && h.query === item.label);
const count = historyForItem.length;
const recent = historyForItem[0];
let hint = `Queried ${count} times in the last 24h.`;
if (recent) {
const lastQueried = moment(recent.ts).fromNow();
hint = `${hint} Last queried ${lastQueried}.`;
}
return {
...item,
documentation: hint,
};
}
export function groupMetricsByPrefix(metrics: string[], delimiter = '_'): CascaderOption[] {
// Filter out recording rules and insert as first option
const ruleRegex = /:\w+:/;
@@ -133,48 +89,36 @@ interface CascaderOption {
}
interface PromQueryFieldProps {
datasource: any;
error?: string;
hint?: any;
histogramMetrics?: string[];
history?: any[];
initialQuery?: string | null;
labelKeys?: { [index: string]: string[] }; // metric -> [labelKey,...]
labelValues?: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...]
metrics?: string[];
metricsByPrefix?: CascaderOption[];
onClickHintFix?: (action: any) => void;
onPressEnter?: () => void;
onQueryChange?: (value: string, override?: boolean) => void;
portalOrigin?: string;
request?: (url: string) => any;
supportsLogs?: boolean; // To be removed after Logging gets its own query field
}
interface PromQueryFieldState {
histogramMetrics: string[];
labelKeys: { [index: string]: string[] }; // metric -> [labelKey,...]
labelValues: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...]
logLabelOptions: any[];
metrics: string[];
metricsOptions: any[];
metricsByPrefix: CascaderOption[];
syntaxLoaded: boolean;
}
interface PromTypeaheadInput {
text: string;
prefix: string;
wrapperClasses: string[];
labelKey?: string;
value?: Value;
}
class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryFieldState> {
plugins: any[];
languageProvider: any;
constructor(props: PromQueryFieldProps, context) {
super(props, context);
if (props.datasource.languageProvider) {
this.languageProvider = props.datasource.languageProvider;
}
this.plugins = [
BracesPlugin(),
RunnerPlugin({ handler: props.onPressEnter }),
@@ -185,26 +129,16 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
];
this.state = {
histogramMetrics: props.histogramMetrics || [],
labelKeys: props.labelKeys || {},
labelValues: props.labelValues || {},
logLabelOptions: [],
metrics: props.metrics || [],
metricsByPrefix: props.metricsByPrefix || [],
metricsByPrefix: [],
metricsOptions: [],
syntaxLoaded: false,
};
}
componentDidMount() {
// Temporarily reused by logging
const { supportsLogs } = this.props;
if (supportsLogs) {
this.fetchLogLabels();
} else {
// Usual actions
this.fetchMetricNames();
this.fetchHistogramMetrics();
if (this.languageProvider) {
this.languageProvider.start().then(() => this.onReceiveMetrics());
}
}
@@ -262,15 +196,19 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
};
onReceiveMetrics = () => {
const { histogramMetrics, metrics, metricsByPrefix } = this.state;
const { histogramMetrics, metrics } = this.languageProvider;
if (!metrics) {
return;
}
// Update global prism config
setPrismTokens(PRISM_SYNTAX, METRIC_MARK, metrics);
Prism.languages[PRISM_SYNTAX] = this.languageProvider.getSyntax();
Prism.languages[PRISM_SYNTAX][METRIC_MARK] = {
alias: 'variable',
pattern: new RegExp(`(?:^|\\s)(${metrics.join('|')})(?:$|\\s)`),
};
// Build metrics tree
const metricsByPrefix = groupMetricsByPrefix(metrics);
const histogramOptions = histogramMetrics.map(hm => ({ label: hm, value: hm }));
const metricsOptions = [
{ label: 'Histograms', value: HISTOGRAM_GROUP, children: histogramOptions },
@@ -281,6 +219,11 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
};
onTypeahead = (typeahead: TypeaheadInput): TypeaheadOutput => {
if (!this.languageProvider) {
return { suggestions: [] };
}
const { history } = this.props;
const { prefix, text, value, wrapperNode } = typeahead;
// Get DOM-dependent context
@@ -289,279 +232,20 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
const labelKey = labelKeyNode && labelKeyNode.textContent;
const nextChar = getNextCharacter();
const result = this.getTypeahead({ text, value, prefix, wrapperClasses, labelKey });
const result = this.languageProvider.provideCompletionItems(
{ text, value, prefix, wrapperClasses, labelKey },
{ history }
);
console.log('handleTypeahead', wrapperClasses, text, prefix, nextChar, labelKey, result.context);
return result;
};
// Keep this DOM-free for testing
getTypeahead({ prefix, wrapperClasses, text }: PromTypeaheadInput): TypeaheadOutput {
// Syntax spans have 3 classes by default. More indicate a recognized token
const tokenRecognized = wrapperClasses.length > 3;
// Determine candidates by CSS context
if (_.includes(wrapperClasses, 'context-range')) {
// Suggestions for metric[|]
return this.getRangeTypeahead();
} else if (_.includes(wrapperClasses, 'context-labels')) {
// Suggestions for metric{|} and metric{foo=|}, as well as metric-independent label queries like {|}
return this.getLabelTypeahead.apply(this, arguments);
} else if (_.includes(wrapperClasses, 'context-aggregation')) {
return this.getAggregationTypeahead.apply(this, arguments);
} else if (
// Show default suggestions in a couple of scenarios
(prefix && !tokenRecognized) || // Non-empty prefix, but not inside known token
(prefix === '' && !text.match(/^[\]})\s]+$/)) || // Empty prefix, but not following a closing brace
text.match(/[+\-*/^%]/) // Anything after binary operator
) {
return this.getEmptyTypeahead();
}
return {
suggestions: [],
};
}
getEmptyTypeahead(): TypeaheadOutput {
const { history } = this.props;
const { metrics } = this.state;
const suggestions: SuggestionGroup[] = [];
if (history && history.length > 0) {
const historyItems = _.chain(history)
.uniqBy('query')
.take(HISTORY_ITEM_COUNT)
.map(h => h.query)
.map(wrapLabel)
.map(item => addHistoryMetadata(item, history))
.value();
suggestions.push({
prefixMatch: true,
skipSort: true,
label: 'History',
items: historyItems,
});
}
suggestions.push({
prefixMatch: true,
label: 'Functions',
items: FUNCTIONS.map(setFunctionMove),
});
if (metrics) {
suggestions.push({
label: 'Metrics',
items: metrics.map(wrapLabel),
});
}
return { suggestions };
}
getRangeTypeahead(): TypeaheadOutput {
return {
context: 'context-range',
suggestions: [
{
label: 'Range vector',
items: [...RATE_RANGES].map(wrapLabel),
},
],
};
}
getAggregationTypeahead({ value }: PromTypeaheadInput): TypeaheadOutput {
let refresher: Promise<any> = null;
const suggestions: SuggestionGroup[] = [];
// sum(foo{bar="1"}) by (|)
const line = value.anchorBlock.getText();
const cursorOffset: number = value.anchorOffset;
// sum(foo{bar="1"}) by (
const leftSide = line.slice(0, cursorOffset);
const openParensAggregationIndex = leftSide.lastIndexOf('(');
const openParensSelectorIndex = leftSide.slice(0, openParensAggregationIndex).lastIndexOf('(');
const closeParensSelectorIndex = leftSide.slice(openParensSelectorIndex).indexOf(')') + openParensSelectorIndex;
// foo{bar="1"}
const selectorString = leftSide.slice(openParensSelectorIndex + 1, closeParensSelectorIndex);
const selector = parseSelector(selectorString, selectorString.length - 2).selector;
const labelKeys = this.state.labelKeys[selector];
if (labelKeys) {
suggestions.push({ label: 'Labels', items: labelKeys.map(wrapLabel) });
} else {
refresher = this.fetchSeriesLabels(selector);
}
return {
refresher,
suggestions,
context: 'context-aggregation',
};
}
getLabelTypeahead({ text, wrapperClasses, labelKey, value }: PromTypeaheadInput): TypeaheadOutput {
let context: string;
let refresher: Promise<any> = null;
const suggestions: SuggestionGroup[] = [];
const line = value.anchorBlock.getText();
const cursorOffset: number = value.anchorOffset;
// Get normalized selector
let selector;
let parsedSelector;
try {
parsedSelector = parseSelector(line, cursorOffset);
selector = parsedSelector.selector;
} catch {
selector = EMPTY_SELECTOR;
}
const containsMetric = selector.indexOf('__name__=') > -1;
const existingKeys = parsedSelector ? parsedSelector.labelKeys : [];
if ((text && text.match(/^!?=~?/)) || _.includes(wrapperClasses, 'attr-value')) {
// Label values
if (labelKey && this.state.labelValues[selector] && this.state.labelValues[selector][labelKey]) {
const labelValues = this.state.labelValues[selector][labelKey];
context = 'context-label-values';
suggestions.push({
label: `Label values for "${labelKey}"`,
items: labelValues.map(wrapLabel),
});
}
} else {
// Label keys
const labelKeys = this.state.labelKeys[selector] || (containsMetric ? null : DEFAULT_KEYS);
if (labelKeys) {
const possibleKeys = _.difference(labelKeys, existingKeys);
if (possibleKeys.length > 0) {
context = 'context-labels';
suggestions.push({ label: `Labels`, items: possibleKeys.map(wrapLabel) });
}
}
}
// Query labels for selector
// Temporarily add skip for logging
if (selector && !this.state.labelValues[selector] && !this.props.supportsLogs) {
if (selector === EMPTY_SELECTOR) {
// Query label values for default labels
refresher = Promise.all(DEFAULT_KEYS.map(key => this.fetchLabelValues(key)));
} else {
refresher = this.fetchSeriesLabels(selector, !containsMetric);
}
}
return { context, refresher, suggestions };
}
request = url => {
if (this.props.request) {
return this.props.request(url);
}
return fetch(url);
};
fetchHistogramMetrics() {
this.fetchSeriesLabels(HISTOGRAM_SELECTOR, true, () => {
const histogramSeries = this.state.labelValues[HISTOGRAM_SELECTOR];
if (histogramSeries && histogramSeries['__name__']) {
const histogramMetrics = histogramSeries['__name__'].slice().sort();
this.setState({ histogramMetrics }, this.onReceiveMetrics);
}
});
}
// Temporarily here while reusing this field for logging
async fetchLogLabels() {
const url = '/api/prom/label';
try {
const res = await this.request(url);
const body = await (res.data || res.json());
const labelKeys = body.data.slice().sort();
const labelKeysBySelector = {
...this.state.labelKeys,
[EMPTY_SELECTOR]: labelKeys,
};
const labelValuesByKey = {};
const logLabelOptions = [];
for (const key of labelKeys) {
const valuesUrl = `/api/prom/label/${key}/values`;
const res = await this.request(valuesUrl);
const body = await (res.data || res.json());
const values = body.data.slice().sort();
labelValuesByKey[key] = values;
logLabelOptions.push({
label: key,
value: key,
children: values.map(value => ({ label: value, value })),
});
}
const labelValues = { [EMPTY_SELECTOR]: labelValuesByKey };
this.setState({ labelKeys: labelKeysBySelector, labelValues, logLabelOptions });
} catch (e) {
console.error(e);
}
}
async fetchLabelValues(key: string) {
const url = `/api/v1/label/${key}/values`;
try {
const res = await this.request(url);
const body = await (res.data || res.json());
const exisingValues = this.state.labelValues[EMPTY_SELECTOR];
const values = {
...exisingValues,
[key]: body.data,
};
const labelValues = {
...this.state.labelValues,
[EMPTY_SELECTOR]: values,
};
this.setState({ labelValues });
} catch (e) {
console.error(e);
}
}
async fetchSeriesLabels(name: string, withName?: boolean, callback?: () => void) {
const url = `/api/v1/series?match[]=${name}`;
try {
const res = await this.request(url);
const body = await (res.data || res.json());
const { keys, values } = processLabels(body.data, withName);
const labelKeys = {
...this.state.labelKeys,
[name]: keys,
};
const labelValues = {
...this.state.labelValues,
[name]: values,
};
this.setState({ labelKeys, labelValues }, callback);
} catch (e) {
console.error(e);
}
}
async fetchMetricNames() {
const url = '/api/v1/label/__name__/values';
try {
const res = await this.request(url);
const body = await (res.data || res.json());
const metrics = body.data;
const metricsByPrefix = groupMetricsByPrefix(metrics);
this.setState({ metrics, metricsByPrefix }, this.onReceiveMetrics);
} catch (error) {
console.error(error);
}
}
render() {
const { error, hint, initialQuery, supportsLogs } = this.props;
const { logLabelOptions, metricsOptions, syntaxLoaded } = this.state;
const cleanText = this.languageProvider ? this.languageProvider.cleanText : undefined;
return (
<div className="prom-query-field">

View File

@@ -5,95 +5,28 @@ import { Change, Value } from 'slate';
import { Editor } from 'slate-react';
import Plain from 'slate-plain-serializer';
import { CompletionItem, CompletionItemGroup, TypeaheadOutput } from 'app/types/explore';
import ClearPlugin from './slate-plugins/clear';
import NewlinePlugin from './slate-plugins/newline';
import Typeahead from './Typeahead';
import { makeFragment, makeValue } from './Value';
import PlaceholdersBuffer from './PlaceholdersBuffer';
export const TYPEAHEAD_DEBOUNCE = 100;
function getSuggestionByIndex(suggestions: SuggestionGroup[], index: number): Suggestion {
function getSuggestionByIndex(suggestions: CompletionItemGroup[], index: number): CompletionItem {
// Flatten suggestion groups
const flattenedSuggestions = suggestions.reduce((acc, g) => acc.concat(g.items), []);
const correctedIndex = Math.max(index, 0) % flattenedSuggestions.length;
return flattenedSuggestions[correctedIndex];
}
function hasSuggestions(suggestions: SuggestionGroup[]): boolean {
function hasSuggestions(suggestions: CompletionItemGroup[]): boolean {
return suggestions && suggestions.length > 0;
}
export interface Suggestion {
/**
* The label of this completion item. By default
* this is also the text that is inserted when selecting
* this completion.
*/
label: string;
/**
* The kind of this completion item. Based on the kind
* an icon is chosen by the editor.
*/
kind?: string;
/**
* A human-readable string with additional information
* about this item, like type or symbol information.
*/
detail?: string;
/**
* A human-readable string, can be Markdown, that represents a doc-comment.
*/
documentation?: string;
/**
* A string that should be used when comparing this item
* with other items. When `falsy` the `label` is used.
*/
sortText?: string;
/**
* A string that should be used when filtering a set of
* completion items. When `falsy` the `label` is used.
*/
filterText?: string;
/**
* A string or snippet that should be inserted in a document when selecting
* this completion. When `falsy` the `label` is used.
*/
insertText?: string;
/**
* Delete number of characters before the caret position,
* by default the letters from the beginning of the word.
*/
deleteBackwards?: number;
/**
* Number of steps to move after the insertion, can be negative.
*/
move?: number;
}
export interface SuggestionGroup {
/**
* Label that will be displayed for all entries of this group.
*/
label: string;
/**
* List of suggestions of this group.
*/
items: Suggestion[];
/**
* If true, match only by prefix (and not mid-word).
*/
prefixMatch?: boolean;
/**
* If true, do not filter items in this group based on the search.
*/
skipFilter?: boolean;
/**
* If true, do not sort items.
*/
skipSort?: boolean;
}
interface TypeaheadFieldProps {
additionalPlugins?: any[];
cleanText?: (text: string) => string;
@@ -110,7 +43,7 @@ interface TypeaheadFieldProps {
}
export interface TypeaheadFieldState {
suggestions: SuggestionGroup[];
suggestions: CompletionItemGroup[];
typeaheadContext: string | null;
typeaheadIndex: number;
typeaheadPrefix: string;
@@ -127,20 +60,17 @@ export interface TypeaheadInput {
wrapperNode: Element;
}
export interface TypeaheadOutput {
context?: string;
refresher?: Promise<{}>;
suggestions: SuggestionGroup[];
}
class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadFieldState> {
menuEl: HTMLElement | null;
placeholdersBuffer: PlaceholdersBuffer;
plugins: any[];
resetTimer: any;
constructor(props, context) {
super(props, context);
this.placeholdersBuffer = new PlaceholdersBuffer(props.initialValue || '');
// Base plugins
this.plugins = [ClearPlugin(), NewlinePlugin(), ...props.additionalPlugins];
@@ -150,7 +80,7 @@ class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadField
typeaheadIndex: 0,
typeaheadPrefix: '',
typeaheadText: '',
value: makeValue(props.initialValue || '', props.syntax),
value: makeValue(this.placeholdersBuffer.toString(), props.syntax),
};
}
@@ -175,12 +105,14 @@ class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadField
componentWillReceiveProps(nextProps: TypeaheadFieldProps) {
if (nextProps.syntaxLoaded && !this.props.syntaxLoaded) {
// Need a bogus edit to re-render the editor after syntax has fully loaded
this.onChange(
this.state.value
.change()
.insertText(' ')
.deleteBackward()
);
const change = this.state.value
.change()
.insertText(' ')
.deleteBackward();
if (this.placeholdersBuffer.hasPlaceholders()) {
change.move(this.placeholdersBuffer.getNextMoveOffset()).focus();
}
this.onChange(change);
}
}
@@ -293,7 +225,7 @@ class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadField
}
}, TYPEAHEAD_DEBOUNCE);
applyTypeahead(change: Change, suggestion: Suggestion): Change {
applyTypeahead(change: Change, suggestion: CompletionItem): Change {
const { cleanText, onWillApplySuggestion, syntax } = this.props;
const { typeaheadPrefix, typeaheadText } = this.state;
let suggestionText = suggestion.insertText || suggestion.label;
@@ -363,7 +295,17 @@ class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadField
}
const suggestion = getSuggestionByIndex(suggestions, typeaheadIndex);
this.applyTypeahead(change, suggestion);
const nextChange = this.applyTypeahead(change, suggestion);
const insertTextOperation = nextChange.operations.find(operation => operation.type === 'insert_text');
if (insertTextOperation) {
const suggestionText = insertTextOperation.text;
this.placeholdersBuffer.setNextPlaceholderValue(suggestionText);
if (this.placeholdersBuffer.hasPlaceholders()) {
nextChange.move(this.placeholdersBuffer.getNextMoveOffset()).focus();
}
}
return true;
}
break;
@@ -410,6 +352,8 @@ class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadField
// If we dont wait here, menu clicks wont work because the menu
// will be gone.
this.resetTimer = setTimeout(this.resetTypeahead, 100);
// Disrupting placeholder entry wipes all remaining placeholders needing input
this.placeholdersBuffer.clearPlaceholders();
if (onBlur) {
onBlur();
}
@@ -422,7 +366,7 @@ class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadField
}
};
onClickMenu = (item: Suggestion) => {
onClickMenu = (item: CompletionItem) => {
// Manually triggering change
const change = this.applyTypeahead(this.state.value.change(), item);
this.onChange(change);

View File

@@ -1,12 +1,12 @@
import React, { PureComponent } from 'react';
import { QueryTransaction } from 'app/types/explore';
import { QueryTransaction, HistoryItem, Query, QueryHint } from 'app/types/explore';
// TODO make this datasource-plugin-dependent
import QueryField from './PromQueryField';
import QueryTransactions from './QueryTransactions';
function getFirstHintFromTransactions(transactions: QueryTransaction[]) {
function getFirstHintFromTransactions(transactions: QueryTransaction[]): QueryHint {
const transaction = transactions.find(qt => qt.hints && qt.hints.length > 0);
if (transaction) {
return transaction.hints[0];
@@ -14,7 +14,30 @@ function getFirstHintFromTransactions(transactions: QueryTransaction[]) {
return undefined;
}
class QueryRow extends PureComponent<any, {}> {
interface QueryRowEventHandlers {
onAddQueryRow: (index: number) => void;
onChangeQuery: (value: string, index: number, override?: boolean) => void;
onClickHintFix: (action: object, index?: number) => void;
onExecuteQuery: () => void;
onRemoveQueryRow: (index: number) => void;
}
interface QueryRowCommonProps {
className?: string;
datasource: any;
history: HistoryItem[];
// Temporarily
supportsLogs?: boolean;
transactions: QueryTransaction[];
}
type QueryRowProps = QueryRowCommonProps &
QueryRowEventHandlers & {
index: number;
query: string;
};
class QueryRow extends PureComponent<QueryRowProps> {
onChangeQuery = (value, override?: boolean) => {
const { index, onChangeQuery } = this.props;
if (onChangeQuery) {
@@ -55,8 +78,8 @@ class QueryRow extends PureComponent<any, {}> {
};
render() {
const { history, query, request, supportsLogs, transactions } = this.props;
const transactionWithError = transactions.find(t => t.error);
const { datasource, history, query, supportsLogs, transactions } = this.props;
const transactionWithError = transactions.find(t => t.error !== undefined);
const hint = getFirstHintFromTransactions(transactions);
const queryError = transactionWithError ? transactionWithError.error : null;
return (
@@ -66,6 +89,7 @@ class QueryRow extends PureComponent<any, {}> {
</div>
<div className="query-row-field">
<QueryField
datasource={datasource}
error={queryError}
hint={hint}
initialQuery={query}
@@ -73,7 +97,6 @@ class QueryRow extends PureComponent<any, {}> {
onClickHintFix={this.onClickHintFix}
onPressEnter={this.onPressEnter}
onQueryChange={this.onChangeQuery}
request={request}
supportsLogs={supportsLogs}
/>
</div>
@@ -93,9 +116,14 @@ class QueryRow extends PureComponent<any, {}> {
}
}
export default class QueryRows extends PureComponent<any, {}> {
type QueryRowsProps = QueryRowCommonProps &
QueryRowEventHandlers & {
queries: Query[];
};
export default class QueryRows extends PureComponent<QueryRowsProps> {
render() {
const { className = '', queries, queryHints, transactions, ...handlers } = this.props;
const { className = '', queries, transactions, ...handlers } = this.props;
return (
<div className={className}>
{queries.map((q, index) => (

View File

@@ -1,7 +1,7 @@
import React from 'react';
import Highlighter from 'react-highlight-words';
import { Suggestion, SuggestionGroup } from './QueryField';
import { CompletionItem, CompletionItemGroup } from 'app/types/explore';
function scrollIntoView(el: HTMLElement) {
if (!el || !el.offsetParent) {
@@ -15,12 +15,12 @@ function scrollIntoView(el: HTMLElement) {
interface TypeaheadItemProps {
isSelected: boolean;
item: Suggestion;
item: CompletionItem;
onClickItem: (Suggestion) => void;
prefix?: string;
}
class TypeaheadItem extends React.PureComponent<TypeaheadItemProps, {}> {
class TypeaheadItem extends React.PureComponent<TypeaheadItemProps> {
el: HTMLElement;
componentDidUpdate(prevProps) {
@@ -53,14 +53,14 @@ class TypeaheadItem extends React.PureComponent<TypeaheadItemProps, {}> {
}
interface TypeaheadGroupProps {
items: Suggestion[];
items: CompletionItem[];
label: string;
onClickItem: (Suggestion) => void;
selected: Suggestion;
onClickItem: (CompletionItem) => void;
selected: CompletionItem;
prefix?: string;
}
class TypeaheadGroup extends React.PureComponent<TypeaheadGroupProps, {}> {
class TypeaheadGroup extends React.PureComponent<TypeaheadGroupProps> {
render() {
const { items, label, selected, onClickItem, prefix } = this.props;
return (
@@ -85,13 +85,13 @@ class TypeaheadGroup extends React.PureComponent<TypeaheadGroupProps, {}> {
}
interface TypeaheadProps {
groupedItems: SuggestionGroup[];
groupedItems: CompletionItemGroup[];
menuRef: any;
selectedItem: Suggestion | null;
selectedItem: CompletionItem | null;
onClickItem: (Suggestion) => void;
prefix?: string;
}
class Typeahead extends React.PureComponent<TypeaheadProps, {}> {
class Typeahead extends React.PureComponent<TypeaheadProps> {
render() {
const { groupedItems, menuRef, selectedItem, onClickItem, prefix } = this.props;
return (

View File

@@ -14,7 +14,7 @@ export class SoloPanelCtrl {
const params = $location.search();
panelId = parseInt(params.panelId, 10);
$scope.onAppEvent('dashboard-initialized', $scope.initPanelScope);
appEvents.on('dashboard-initialized', $scope.initPanelScope);
// if no uid, redirect to new route based on slug
if (!($routeParams.type === 'script' || $routeParams.type === 'snapshot') && !$routeParams.uid) {

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { connect } from 'react-redux';
import { Label } from 'app/core/components/Forms/Forms';
import { Label } from 'app/core/components/Label/Label';
import { Team } from '../../types';
import { updateTeam } from './state/actions';
import { getRouteParamsId } from '../../core/selectors/location';

View File

@@ -37,6 +37,9 @@ export default class CloudWatchDatasource {
item.namespace = this.templateSrv.replace(item.namespace, options.scopedVars);
item.metricName = this.templateSrv.replace(item.metricName, options.scopedVars);
item.dimensions = this.convertDimensionFormat(item.dimensions, options.scopedVars);
item.statistics = item.statistics.map(s => {
return this.templateSrv.replace(s, options.scopedVars);
});
item.period = String(this.getPeriod(item, options)); // use string format for period in graph query, and alerting
item.id = this.templateSrv.replace(item.id, options.scopedVars);
item.expression = this.templateSrv.replace(item.expression, options.scopedVars);

View File

@@ -5,6 +5,7 @@ import kbn from 'app/core/utils/kbn';
import * as dateMath from 'app/core/utils/datemath';
import PrometheusMetricFindQuery from './metric_find_query';
import { ResultTransformer } from './result_transformer';
import PrometheusLanguageProvider from './language_provider';
import { BackendSrv } from 'app/core/services/backend_srv';
import addLabelToQuery from './add_label_to_query';
@@ -60,6 +61,7 @@ export class PrometheusDatasource {
interval: string;
queryTimeout: string;
httpMethod: string;
languageProvider: PrometheusLanguageProvider;
resultTransformer: ResultTransformer;
/** @ngInject */
@@ -76,6 +78,7 @@ export class PrometheusDatasource {
this.httpMethod = instanceSettings.jsonData.httpMethod || 'GET';
this.resultTransformer = new ResultTransformer(templateSrv);
this.ruleMappings = {};
this.languageProvider = new PrometheusLanguageProvider(this);
}
init() {
@@ -461,6 +464,9 @@ export class PrometheusDatasource {
case 'ADD_RATE': {
return `rate(${query}[5m])`;
}
case 'ADD_SUM': {
return `sum(${query.trim()}) by ($1)`;
}
case 'EXPAND_RULES': {
const mapping = action.mapping;
if (mapping) {

View File

@@ -0,0 +1,347 @@
import _ from 'lodash';
import moment from 'moment';
import {
CompletionItem,
CompletionItemGroup,
LanguageProvider,
TypeaheadInput,
TypeaheadOutput,
} from 'app/types/explore';
import { parseSelector, processLabels, RATE_RANGES } from './language_utils';
import PromqlSyntax, { FUNCTIONS } from './promql';
const DEFAULT_KEYS = ['job', 'instance'];
const EMPTY_SELECTOR = '{}';
const HISTOGRAM_SELECTOR = '{le!=""}'; // Returns all timeseries for histograms
const HISTORY_ITEM_COUNT = 5;
const HISTORY_COUNT_CUTOFF = 1000 * 60 * 60 * 24; // 24h
const wrapLabel = (label: string) => ({ label });
const setFunctionMove = (suggestion: CompletionItem): CompletionItem => {
suggestion.move = -1;
return suggestion;
};
export function addHistoryMetadata(item: CompletionItem, history: any[]): CompletionItem {
const cutoffTs = Date.now() - HISTORY_COUNT_CUTOFF;
const historyForItem = history.filter(h => h.ts > cutoffTs && h.query === item.label);
const count = historyForItem.length;
const recent = historyForItem[0];
let hint = `Queried ${count} times in the last 24h.`;
if (recent) {
const lastQueried = moment(recent.ts).fromNow();
hint = `${hint} Last queried ${lastQueried}.`;
}
return {
...item,
documentation: hint,
};
}
export default class PromQlLanguageProvider extends LanguageProvider {
histogramMetrics?: string[];
labelKeys?: { [index: string]: string[] }; // metric -> [labelKey,...]
labelValues?: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...]
metrics?: string[];
logLabelOptions: any[];
supportsLogs?: boolean;
started: boolean;
constructor(datasource: any, initialValues?: any) {
super();
this.datasource = datasource;
this.histogramMetrics = [];
this.labelKeys = {};
this.labelValues = {};
this.metrics = [];
this.supportsLogs = false;
this.started = false;
Object.assign(this, initialValues);
}
// Strip syntax chars
cleanText = s => s.replace(/[{}[\]="(),!~+\-*/^%]/g, '').trim();
getSyntax() {
return PromqlSyntax;
}
request = url => {
return this.datasource.metadataRequest(url);
};
start = () => {
if (!this.started) {
this.started = true;
return Promise.all([this.fetchMetricNames(), this.fetchHistogramMetrics()]);
}
return Promise.resolve([]);
};
// Keep this DOM-free for testing
provideCompletionItems({ prefix, wrapperClasses, text }: TypeaheadInput, context?: any): TypeaheadOutput {
// Syntax spans have 3 classes by default. More indicate a recognized token
const tokenRecognized = wrapperClasses.length > 3;
// Determine candidates by CSS context
if (_.includes(wrapperClasses, 'context-range')) {
// Suggestions for metric[|]
return this.getRangeCompletionItems();
} else if (_.includes(wrapperClasses, 'context-labels')) {
// Suggestions for metric{|} and metric{foo=|}, as well as metric-independent label queries like {|}
return this.getLabelCompletionItems.apply(this, arguments);
} else if (_.includes(wrapperClasses, 'context-aggregation')) {
return this.getAggregationCompletionItems.apply(this, arguments);
} else if (
// Show default suggestions in a couple of scenarios
(prefix && !tokenRecognized) || // Non-empty prefix, but not inside known token
(prefix === '' && !text.match(/^[\]})\s]+$/)) || // Empty prefix, but not following a closing brace
text.match(/[+\-*/^%]/) // Anything after binary operator
) {
return this.getEmptyCompletionItems(context || {});
}
return {
suggestions: [],
};
}
getEmptyCompletionItems(context: any): TypeaheadOutput {
const { history } = context;
const { metrics } = this;
const suggestions: CompletionItemGroup[] = [];
if (history && history.length > 0) {
const historyItems = _.chain(history)
.uniqBy('query')
.take(HISTORY_ITEM_COUNT)
.map(h => h.query)
.map(wrapLabel)
.map(item => addHistoryMetadata(item, history))
.value();
suggestions.push({
prefixMatch: true,
skipSort: true,
label: 'History',
items: historyItems,
});
}
suggestions.push({
prefixMatch: true,
label: 'Functions',
items: FUNCTIONS.map(setFunctionMove),
});
if (metrics) {
suggestions.push({
label: 'Metrics',
items: metrics.map(wrapLabel),
});
}
return { suggestions };
}
getRangeCompletionItems(): TypeaheadOutput {
return {
context: 'context-range',
suggestions: [
{
label: 'Range vector',
items: [...RATE_RANGES].map(wrapLabel),
},
],
};
}
getAggregationCompletionItems({ value }: TypeaheadInput): TypeaheadOutput {
let refresher: Promise<any> = null;
const suggestions: CompletionItemGroup[] = [];
// Stitch all query lines together to support multi-line queries
let queryOffset;
const queryText = value.document.getBlocks().reduce((text, block) => {
const blockText = block.getText();
if (value.anchorBlock.key === block.key) {
// Newline characters are not accounted for but this is irrelevant
// for the purpose of extracting the selector string
queryOffset = value.anchorOffset + text.length;
}
text += blockText;
return text;
}, '');
const leftSide = queryText.slice(0, queryOffset);
const openParensAggregationIndex = leftSide.lastIndexOf('(');
const openParensSelectorIndex = leftSide.slice(0, openParensAggregationIndex).lastIndexOf('(');
const closeParensSelectorIndex = leftSide.slice(openParensSelectorIndex).indexOf(')') + openParensSelectorIndex;
let selectorString = leftSide.slice(openParensSelectorIndex + 1, closeParensSelectorIndex);
// Range vector syntax not accounted for by subsequent parse so discard it if present
selectorString = selectorString.replace(/\[[^\]]+\]$/, '');
const selector = parseSelector(selectorString, selectorString.length - 2).selector;
const labelKeys = this.labelKeys[selector];
if (labelKeys) {
suggestions.push({ label: 'Labels', items: labelKeys.map(wrapLabel) });
} else {
refresher = this.fetchSeriesLabels(selector);
}
return {
refresher,
suggestions,
context: 'context-aggregation',
};
}
getLabelCompletionItems({ text, wrapperClasses, labelKey, value }: TypeaheadInput): TypeaheadOutput {
let context: string;
let refresher: Promise<any> = null;
const suggestions: CompletionItemGroup[] = [];
const line = value.anchorBlock.getText();
const cursorOffset: number = value.anchorOffset;
// Get normalized selector
let selector;
let parsedSelector;
try {
parsedSelector = parseSelector(line, cursorOffset);
selector = parsedSelector.selector;
} catch {
selector = EMPTY_SELECTOR;
}
const containsMetric = selector.indexOf('__name__=') > -1;
const existingKeys = parsedSelector ? parsedSelector.labelKeys : [];
if ((text && text.match(/^!?=~?/)) || _.includes(wrapperClasses, 'attr-value')) {
// Label values
if (labelKey && this.labelValues[selector] && this.labelValues[selector][labelKey]) {
const labelValues = this.labelValues[selector][labelKey];
context = 'context-label-values';
suggestions.push({
label: `Label values for "${labelKey}"`,
items: labelValues.map(wrapLabel),
});
}
} else {
// Label keys
const labelKeys = this.labelKeys[selector] || (containsMetric ? null : DEFAULT_KEYS);
if (labelKeys) {
const possibleKeys = _.difference(labelKeys, existingKeys);
if (possibleKeys.length > 0) {
context = 'context-labels';
suggestions.push({ label: `Labels`, items: possibleKeys.map(wrapLabel) });
}
}
}
// Query labels for selector
// Temporarily add skip for logging
if (selector && !this.labelValues[selector] && !this.supportsLogs) {
if (selector === EMPTY_SELECTOR) {
// Query label values for default labels
refresher = Promise.all(DEFAULT_KEYS.map(key => this.fetchLabelValues(key)));
} else {
refresher = this.fetchSeriesLabels(selector, !containsMetric);
}
}
return { context, refresher, suggestions };
}
async fetchMetricNames() {
const url = '/api/v1/label/__name__/values';
try {
const res = await this.request(url);
const body = await (res.data || res.json());
this.metrics = body.data;
} catch (error) {
console.error(error);
}
}
async fetchHistogramMetrics() {
await this.fetchSeriesLabels(HISTOGRAM_SELECTOR, true);
const histogramSeries = this.labelValues[HISTOGRAM_SELECTOR];
if (histogramSeries && histogramSeries['__name__']) {
this.histogramMetrics = histogramSeries['__name__'].slice().sort();
}
}
// Temporarily here while reusing this field for logging
async fetchLogLabels() {
const url = '/api/prom/label';
try {
const res = await this.request(url);
const body = await (res.data || res.json());
const labelKeys = body.data.slice().sort();
const labelKeysBySelector = {
...this.labelKeys,
[EMPTY_SELECTOR]: labelKeys,
};
const labelValuesByKey = {};
this.logLabelOptions = [];
for (const key of labelKeys) {
const valuesUrl = `/api/prom/label/${key}/values`;
const res = await this.request(valuesUrl);
const body = await (res.data || res.json());
const values = body.data.slice().sort();
labelValuesByKey[key] = values;
this.logLabelOptions.push({
label: key,
value: key,
children: values.map(value => ({ label: value, value })),
});
}
this.labelValues = { [EMPTY_SELECTOR]: labelValuesByKey };
this.labelKeys = labelKeysBySelector;
} catch (e) {
console.error(e);
}
}
async fetchLabelValues(key: string) {
const url = `/api/v1/label/${key}/values`;
try {
const res = await this.request(url);
const body = await (res.data || res.json());
const exisingValues = this.labelValues[EMPTY_SELECTOR];
const values = {
...exisingValues,
[key]: body.data,
};
this.labelValues = {
...this.labelValues,
[EMPTY_SELECTOR]: values,
};
} catch (e) {
console.error(e);
}
}
async fetchSeriesLabels(name: string, withName?: boolean) {
const url = `/api/v1/series?match[]=${name}`;
try {
const res = await this.request(url);
const body = await (res.data || res.json());
const { keys, values } = processLabels(body.data, withName);
this.labelKeys = {
...this.labelKeys,
[name]: keys,
};
this.labelValues = {
...this.labelValues,
[name]: values,
};
} catch (e) {
console.error(e);
}
}
}

View File

@@ -23,9 +23,6 @@ export function processLabels(labels, withName = false) {
return { values, keys: Object.keys(values) };
}
// Strip syntax chars
export const cleanText = s => s.replace(/[{}[\]="(),!~+\-*/^%]/g, '').trim();
// const cleanSelectorRegexp = /\{(\w+="[^"\n]*?")(,\w+="[^"\n]*?")*\}/;
const selectorRegexp = /\{[^}]*?\}/;
const labelRegexp = /\b(\w+)(!?=~?)("[^"\n]*?")/g;

View File

@@ -1,6 +1,13 @@
import _ from 'lodash';
export function getQueryHints(query: string, series?: any[], datasource?: any): any[] {
import { QueryHint } from 'app/types/explore';
/**
* Number of time series results needed before starting to suggest sum aggregation hints
*/
export const SUM_HINT_THRESHOLD_COUNT = 20;
export function getQueryHints(query: string, series?: any[], datasource?: any): QueryHint[] {
const hints = [];
// ..._bucket metric needs a histogram_quantile()
@@ -88,5 +95,24 @@ export function getQueryHints(query: string, series?: any[], datasource?: any):
});
}
}
if (series.length >= SUM_HINT_THRESHOLD_COUNT) {
const simpleMetric = query.trim().match(/^\w+$/);
if (simpleMetric) {
hints.push({
type: 'ADD_SUM',
label: 'Many time series results returned.',
fix: {
label: 'Consider aggregating with sum().',
action: {
type: 'ADD_SUM',
query: query,
preventSubmit: true,
},
},
});
}
}
return hints.length > 0 ? hints : null;
}

View File

@@ -0,0 +1,273 @@
import Plain from 'slate-plain-serializer';
import LanguageProvider from '../language_provider';
describe('Language completion provider', () => {
const datasource = {
metadataRequest: () => ({ data: { data: [] } }),
};
it('returns default suggestions on emtpty context', () => {
const instance = new LanguageProvider(datasource);
const result = instance.provideCompletionItems({ text: '', prefix: '', wrapperClasses: [] });
expect(result.context).toBeUndefined();
expect(result.refresher).toBeUndefined();
expect(result.suggestions.length).toEqual(2);
});
describe('range suggestions', () => {
it('returns range suggestions in range context', () => {
const instance = new LanguageProvider(datasource);
const result = instance.provideCompletionItems({ text: '1', prefix: '1', wrapperClasses: ['context-range'] });
expect(result.context).toBe('context-range');
expect(result.refresher).toBeUndefined();
expect(result.suggestions).toEqual([
{
items: [{ label: '1m' }, { label: '5m' }, { label: '10m' }, { label: '30m' }, { label: '1h' }],
label: 'Range vector',
},
]);
});
});
describe('metric suggestions', () => {
it('returns metrics suggestions by default', () => {
const instance = new LanguageProvider(datasource, { metrics: ['foo', 'bar'] });
const result = instance.provideCompletionItems({ text: 'a', prefix: 'a', wrapperClasses: [] });
expect(result.context).toBeUndefined();
expect(result.refresher).toBeUndefined();
expect(result.suggestions.length).toEqual(2);
});
it('returns default suggestions after a binary operator', () => {
const instance = new LanguageProvider(datasource, { metrics: ['foo', 'bar'] });
const result = instance.provideCompletionItems({ text: '*', prefix: '', wrapperClasses: [] });
expect(result.context).toBeUndefined();
expect(result.refresher).toBeUndefined();
expect(result.suggestions.length).toEqual(2);
});
});
describe('label suggestions', () => {
it('returns default label suggestions on label context and no metric', () => {
const instance = new LanguageProvider(datasource);
const value = Plain.deserialize('{}');
const range = value.selection.merge({
anchorOffset: 1,
});
const valueWithSelection = value.change().select(range).value;
const result = instance.provideCompletionItems({
text: '',
prefix: '',
wrapperClasses: ['context-labels'],
value: valueWithSelection,
});
expect(result.context).toBe('context-labels');
expect(result.suggestions).toEqual([{ items: [{ label: 'job' }, { label: 'instance' }], label: 'Labels' }]);
});
it('returns label suggestions on label context and metric', () => {
const instance = new LanguageProvider(datasource, { labelKeys: { '{__name__="metric"}': ['bar'] } });
const value = Plain.deserialize('metric{}');
const range = value.selection.merge({
anchorOffset: 7,
});
const valueWithSelection = value.change().select(range).value;
const result = instance.provideCompletionItems({
text: '',
prefix: '',
wrapperClasses: ['context-labels'],
value: valueWithSelection,
});
expect(result.context).toBe('context-labels');
expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
});
it('returns label suggestions on label context but leaves out labels that already exist', () => {
const instance = new LanguageProvider(datasource, {
labelKeys: { '{job1="foo",job2!="foo",job3=~"foo"}': ['bar', 'job1', 'job2', 'job3'] },
});
const value = Plain.deserialize('{job1="foo",job2!="foo",job3=~"foo",}');
const range = value.selection.merge({
anchorOffset: 36,
});
const valueWithSelection = value.change().select(range).value;
const result = instance.provideCompletionItems({
text: '',
prefix: '',
wrapperClasses: ['context-labels'],
value: valueWithSelection,
});
expect(result.context).toBe('context-labels');
expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
});
it('returns label value suggestions inside a label value context after a negated matching operator', () => {
const instance = new LanguageProvider(datasource, {
labelKeys: { '{}': ['label'] },
labelValues: { '{}': { label: ['a', 'b', 'c'] } },
});
const value = Plain.deserialize('{label!=}');
const range = value.selection.merge({ anchorOffset: 8 });
const valueWithSelection = value.change().select(range).value;
const result = instance.provideCompletionItems({
text: '!=',
prefix: '',
wrapperClasses: ['context-labels'],
labelKey: 'label',
value: valueWithSelection,
});
expect(result.context).toBe('context-label-values');
expect(result.suggestions).toEqual([
{
items: [{ label: 'a' }, { label: 'b' }, { label: 'c' }],
label: 'Label values for "label"',
},
]);
});
it('returns a refresher on label context and unavailable metric', () => {
const instance = new LanguageProvider(datasource, { labelKeys: { '{__name__="foo"}': ['bar'] } });
const value = Plain.deserialize('metric{}');
const range = value.selection.merge({
anchorOffset: 7,
});
const valueWithSelection = value.change().select(range).value;
const result = instance.provideCompletionItems({
text: '',
prefix: '',
wrapperClasses: ['context-labels'],
value: valueWithSelection,
});
expect(result.context).toBeUndefined();
expect(result.refresher).toBeInstanceOf(Promise);
expect(result.suggestions).toEqual([]);
});
it('returns label values on label context when given a metric and a label key', () => {
const instance = new LanguageProvider(datasource, {
labelKeys: { '{__name__="metric"}': ['bar'] },
labelValues: { '{__name__="metric"}': { bar: ['baz'] } },
});
const value = Plain.deserialize('metric{bar=ba}');
const range = value.selection.merge({
anchorOffset: 13,
});
const valueWithSelection = value.change().select(range).value;
const result = instance.provideCompletionItems({
text: '=ba',
prefix: 'ba',
wrapperClasses: ['context-labels'],
labelKey: 'bar',
value: valueWithSelection,
});
expect(result.context).toBe('context-label-values');
expect(result.suggestions).toEqual([{ items: [{ label: 'baz' }], label: 'Label values for "bar"' }]);
});
it('returns label suggestions on aggregation context and metric w/ selector', () => {
const instance = new LanguageProvider(datasource, { labelKeys: { '{__name__="metric",foo="xx"}': ['bar'] } });
const value = Plain.deserialize('sum(metric{foo="xx"}) by ()');
const range = value.selection.merge({
anchorOffset: 26,
});
const valueWithSelection = value.change().select(range).value;
const result = instance.provideCompletionItems({
text: '',
prefix: '',
wrapperClasses: ['context-aggregation'],
value: valueWithSelection,
});
expect(result.context).toBe('context-aggregation');
expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
});
it('returns label suggestions on aggregation context and metric w/o selector', () => {
const instance = new LanguageProvider(datasource, { labelKeys: { '{__name__="metric"}': ['bar'] } });
const value = Plain.deserialize('sum(metric) by ()');
const range = value.selection.merge({
anchorOffset: 16,
});
const valueWithSelection = value.change().select(range).value;
const result = instance.provideCompletionItems({
text: '',
prefix: '',
wrapperClasses: ['context-aggregation'],
value: valueWithSelection,
});
expect(result.context).toBe('context-aggregation');
expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
});
it('returns label suggestions inside a multi-line aggregation context', () => {
const instance = new LanguageProvider(datasource, {
labelKeys: { '{__name__="metric"}': ['label1', 'label2', 'label3'] },
});
const value = Plain.deserialize('sum(\nmetric\n)\nby ()');
const aggregationTextBlock = value.document.getBlocksAsArray()[3];
const range = value.selection.moveToStartOf(aggregationTextBlock).merge({ anchorOffset: 4 });
const valueWithSelection = value.change().select(range).value;
const result = instance.provideCompletionItems({
text: '',
prefix: '',
wrapperClasses: ['context-aggregation'],
value: valueWithSelection,
});
expect(result.context).toBe('context-aggregation');
expect(result.suggestions).toEqual([
{
items: [{ label: 'label1' }, { label: 'label2' }, { label: 'label3' }],
label: 'Labels',
},
]);
});
it('returns label suggestions inside an aggregation context with a range vector', () => {
const instance = new LanguageProvider(datasource, {
labelKeys: { '{__name__="metric"}': ['label1', 'label2', 'label3'] },
});
const value = Plain.deserialize('sum(rate(metric[1h])) by ()');
const range = value.selection.merge({
anchorOffset: 26,
});
const valueWithSelection = value.change().select(range).value;
const result = instance.provideCompletionItems({
text: '',
prefix: '',
wrapperClasses: ['context-aggregation'],
value: valueWithSelection,
});
expect(result.context).toBe('context-aggregation');
expect(result.suggestions).toEqual([
{
items: [{ label: 'label1' }, { label: 'label2' }, { label: 'label3' }],
label: 'Labels',
},
]);
});
it('returns label suggestions inside an aggregation context with a range vector and label', () => {
const instance = new LanguageProvider(datasource, {
labelKeys: { '{__name__="metric",label1="value"}': ['label1', 'label2', 'label3'] },
});
const value = Plain.deserialize('sum(rate(metric{label1="value"}[1h])) by ()');
const range = value.selection.merge({
anchorOffset: 42,
});
const valueWithSelection = value.change().select(range).value;
const result = instance.provideCompletionItems({
text: '',
prefix: '',
wrapperClasses: ['context-aggregation'],
value: valueWithSelection,
});
expect(result.context).toBe('context-aggregation');
expect(result.suggestions).toEqual([
{
items: [{ label: 'label1' }, { label: 'label2' }, { label: 'label3' }],
label: 'Labels',
},
]);
});
});
});

View File

@@ -1,4 +1,4 @@
import { parseSelector } from './prometheus';
import { parseSelector } from '../language_utils';
describe('parseSelector()', () => {
let parsed;

View File

@@ -1,4 +1,4 @@
import { getQueryHints } from '../query_hints';
import { getQueryHints, SUM_HINT_THRESHOLD_COUNT } from '../query_hints';
describe('getQueryHints()', () => {
it('returns no hints for no series', () => {
@@ -79,4 +79,25 @@ describe('getQueryHints()', () => {
},
});
});
it('returns a sum hint when many time series results are returned for a simple metric', () => {
const seriesCount = SUM_HINT_THRESHOLD_COUNT;
const series = Array.from({ length: seriesCount }, _ => ({
datapoints: [[0, 0], [0, 0]],
}));
const hints = getQueryHints('metric', series);
expect(hints.length).toBe(1);
expect(hints[0]).toMatchObject({
type: 'ADD_SUM',
label: 'Many time series results returned.',
fix: {
label: 'Consider aggregating with sum().',
action: {
type: 'ADD_SUM',
query: 'metric',
preventSubmit: true,
},
},
});
});
});

View File

@@ -114,7 +114,6 @@ export default class StackdriverDatasource {
if (!queryRes.series) {
return;
}
this.projectName = queryRes.meta.defaultProject;
const unit = this.resolvePanelUnitFromTargets(options.targets);
queryRes.series.forEach(series => {
let timeSerie: any = {

View File

@@ -5,6 +5,7 @@ import React, { PureComponent } from 'react';
// Components
import Graph from 'app/viz/Graph';
import { getTimeSeriesVMs } from 'app/viz/state/timeSeries';
import { Switch } from 'app/core/components/Switch/Switch';
// Types
import { PanelProps, NullValueMode } from 'app/types';
@@ -35,8 +36,15 @@ export class Graph2 extends PureComponent<Props> {
}
export class TextOptions extends PureComponent<any> {
onChange = () => {};
render() {
return <p>Text2 Options component</p>;
return (
<div className="section gf-form-group">
<h5 className="section-heading">Draw Modes</h5>
<Switch label="Lines" checked={true} onChange={this.onChange} />
</div>
);
}
}

View File

@@ -3,6 +3,8 @@
"name": "React Graph",
"id": "graph2",
"state": "alpha",
"info": {
"author": {
"name": "Grafana Project",

View File

@@ -1,33 +1,83 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 19.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 19.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="100px" height="100px" viewBox="0 0 100 100" style="enable-background:new 0 0 100 100;" xml:space="preserve">
<style type="text/css">
.st0{opacity:0.26;fill:url(#SVGID_1_);}
.st1{fill:url(#SVGID_2_);}
.st2{fill:url(#SVGID_3_);}
.st3{fill:url(#SVGID_4_);}
.st4{fill:url(#SVGID_5_);}
.st5{fill:none;stroke:url(#SVGID_6_);stroke-miterlimit:10;}
</style>
<g>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="32.3342" y1="95.7019" x2="32.3342" y2="5.2695">
<stop offset="0" style="stop-color:#FFDE17"/>
<stop offset="0.0803" style="stop-color:#FFD210"/>
<stop offset="0.1774" style="stop-color:#FEC90D"/>
<stop offset="0.2809" style="stop-color:#FDC70C"/>
<stop offset="0.6685" style="stop-color:#F3903F"/>
<stop offset="0.8876" style="stop-color:#ED683C"/>
<stop offset="1" style="stop-color:#E93E3A"/>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="50" y1="65.6698" x2="50" y2="93.5681">
<stop offset="0" style="stop-color:#FFF23A"/>
<stop offset="4.010540e-02" style="stop-color:#FEE62D"/>
<stop offset="0.1171" style="stop-color:#FED41A"/>
<stop offset="0.1964" style="stop-color:#FDC90F"/>
<stop offset="0.2809" style="stop-color:#FDC60B"/>
<stop offset="0.6685" style="stop-color:#F28F3F"/>
<stop offset="0.8876" style="stop-color:#ED693C"/>
<stop offset="1" style="stop-color:#E83E39"/>
</linearGradient>
<path style="fill:url(#SVGID_1_);" d="M48.173,57.757V39.825c0-1.302,1.055-2.357,2.357-2.357h9.691
c0.897,0,1.346-1.084,0.712-1.718L34.112,0.737c-0.982-0.982-2.574-0.982-3.556,0L3.735,35.75
c-0.634,0.634-0.185,1.718,0.712,1.718h9.691c1.302,0,2.357,1.055,2.357,2.357v17.932c0,0.958,0.776,1.734,1.734,1.734h28.21
C47.397,59.491,48.173,58.715,48.173,57.757z"/>
<linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="67.6658" y1="94.1706" x2="67.6658" y2="3.7383">
<stop offset="0" style="stop-color:#FFDE17"/>
<stop offset="0.0803" style="stop-color:#FFD210"/>
<stop offset="0.1774" style="stop-color:#FEC90D"/>
<stop offset="0.2809" style="stop-color:#FDC70C"/>
<stop offset="0.6685" style="stop-color:#F3903F"/>
<stop offset="0.8876" style="stop-color:#ED683C"/>
<stop offset="1" style="stop-color:#E93E3A"/>
<path class="st0" d="M97.6,83.8H2.4c-1.3,0-2.4-1.1-2.4-2.4v-1.8l17-1l19.2-4.3l16.3-1.6l16.5,0l15.8-4.7l15.1-3v16.3
C100,82.8,98.9,83.8,97.6,83.8z"/>
<g>
<linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="19.098" y1="76.0776" x2="19.098" y2="27.8027">
<stop offset="0" style="stop-color:#FFF23A"/>
<stop offset="4.010540e-02" style="stop-color:#FEE62D"/>
<stop offset="0.1171" style="stop-color:#FED41A"/>
<stop offset="0.1964" style="stop-color:#FDC90F"/>
<stop offset="0.2809" style="stop-color:#FDC60B"/>
<stop offset="0.6685" style="stop-color:#F28F3F"/>
<stop offset="0.8876" style="stop-color:#ED693C"/>
<stop offset="1" style="stop-color:#E83E39"/>
</linearGradient>
<path class="st1" d="M19.6,64.3V38.9l-5.2,3.9l-3.5-6l9.4-6.9h6.8v34.4H19.6z"/>
<linearGradient id="SVGID_3_" gradientUnits="userSpaceOnUse" x1="42.412" y1="76.0776" x2="42.412" y2="27.8027">
<stop offset="0" style="stop-color:#FFF23A"/>
<stop offset="4.010540e-02" style="stop-color:#FEE62D"/>
<stop offset="0.1171" style="stop-color:#FED41A"/>
<stop offset="0.1964" style="stop-color:#FDC90F"/>
<stop offset="0.2809" style="stop-color:#FDC60B"/>
<stop offset="0.6685" style="stop-color:#F28F3F"/>
<stop offset="0.8876" style="stop-color:#ED693C"/>
<stop offset="1" style="stop-color:#E83E39"/>
</linearGradient>
<path class="st2" d="M53.1,39.4c0,1.1-0.1,2.2-0.4,3.2c-0.3,1-0.7,1.9-1.2,2.8c-0.5,0.9-1,1.7-1.7,2.5c-0.6,0.8-1.2,1.6-1.9,2.3
l-6.4,7.4h11.1v6.7H32.3v-6.9l10.5-12c0.8-1,1.5-2,2-3c0.5-1,0.7-2,0.7-2.9c0-1-0.2-1.9-0.7-2.6c-0.5-0.7-1.2-1.1-2.2-1.1
c-0.9,0-1.7,0.4-2.3,1.1c-0.6,0.8-1,1.9-1.1,3.3l-7.3-0.7c0.4-3.5,1.6-6.1,3.6-7.9c2-1.7,4.5-2.6,7.4-2.6c1.6,0,3,0.2,4.3,0.7
c1.3,0.5,2.3,1.2,3.2,2c0.9,0.9,1.6,1.9,2.1,3.2C52.8,36.4,53.1,37.8,53.1,39.4z"/>
<linearGradient id="SVGID_4_" gradientUnits="userSpaceOnUse" x1="60.3739" y1="76.0776" x2="60.3739" y2="27.8027">
<stop offset="0" style="stop-color:#FFF23A"/>
<stop offset="4.010540e-02" style="stop-color:#FEE62D"/>
<stop offset="0.1171" style="stop-color:#FED41A"/>
<stop offset="0.1964" style="stop-color:#FDC90F"/>
<stop offset="0.2809" style="stop-color:#FDC60B"/>
<stop offset="0.6685" style="stop-color:#F28F3F"/>
<stop offset="0.8876" style="stop-color:#ED693C"/>
<stop offset="1" style="stop-color:#E83E39"/>
</linearGradient>
<path class="st3" d="M64.5,60.4c0,1.2-0.4,2.3-1.2,3.1c-0.8,0.8-1.8,1.3-3,1.3c-1.2,0-2.2-0.4-3-1.3c-0.8-0.8-1.1-1.9-1.1-3.1
c0-1.2,0.4-2.2,1.1-3.1c0.8-0.9,1.8-1.3,3-1.3c1.2,0,2.2,0.4,3,1.3C64.1,58.1,64.5,59.2,64.5,60.4z"/>
<linearGradient id="SVGID_5_" gradientUnits="userSpaceOnUse" x1="77.5234" y1="76.0776" x2="77.5234" y2="27.8027">
<stop offset="0" style="stop-color:#FFF23A"/>
<stop offset="4.010540e-02" style="stop-color:#FEE62D"/>
<stop offset="0.1171" style="stop-color:#FED41A"/>
<stop offset="0.1964" style="stop-color:#FDC90F"/>
<stop offset="0.2809" style="stop-color:#FDC60B"/>
<stop offset="0.6685" style="stop-color:#F28F3F"/>
<stop offset="0.8876" style="stop-color:#ED693C"/>
<stop offset="1" style="stop-color:#E83E39"/>
</linearGradient>
<path class="st4" d="M85.5,57.4v6.9h-6.9v-6.9H66v-6.6l10.1-20.9h9.4V51H89v6.4H85.5z M78.8,37.5L78.8,37.5l-6,13.5h6V37.5z"/>
</g>
<linearGradient id="SVGID_6_" gradientUnits="userSpaceOnUse" x1="-2.852199e-02" y1="72.3985" x2="100.0976" y2="72.3985">
<stop offset="0" style="stop-color:#F28F3F"/>
<stop offset="1" style="stop-color:#F28F3F"/>
</linearGradient>
<path style="fill:url(#SVGID_2_);" d="M95.553,62.532h-9.691c-1.302,0-2.357-1.055-2.357-2.357V42.243
c0-0.958-0.776-1.734-1.734-1.734h-28.21c-0.958,0-1.734,0.776-1.734,1.734v17.932c0,1.302-1.055,2.357-2.357,2.357h-9.691
c-0.897,0-1.346,1.084-0.712,1.718l26.821,35.013c0.982,0.982,2.574,0.982,3.556,0L96.265,64.25
C96.898,63.616,96.45,62.532,95.553,62.532z"/>
<polyline class="st5" points="0,79.7 17,78.7 36.2,74.4 52.5,72.8 69,72.9 84.9,68.1 100,65.1 "/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

@@ -17,7 +17,6 @@ export class GrafanaCtrl {
/** @ngInject */
constructor(
$scope,
alertSrv,
utilSrv,
$rootScope,
$controller,
@@ -41,11 +40,8 @@ export class GrafanaCtrl {
$scope._ = _;
profiler.init(config, $rootScope);
alertSrv.init();
utilSrv.init();
bridgeSrv.init();
$scope.dashAlerts = alertSrv;
};
$rootScope.colors = colors;

View File

@@ -0,0 +1,25 @@
export interface AppNotification {
id?: number;
severity: AppNotificationSeverity;
icon: string;
title: string;
text: string;
timeout: AppNotificationTimeout;
}
export enum AppNotificationSeverity {
Success = 'success',
Warning = 'warning',
Error = 'error',
Info = 'info',
}
export enum AppNotificationTimeout {
Warning = 5000,
Success = 3000,
Error = 7000,
}
export interface AppNotificationsState {
appNotifications: AppNotification[];
}

View File

@@ -1,3 +1,75 @@
import { Value } from 'slate';
export interface CompletionItem {
/**
* The label of this completion item. By default
* this is also the text that is inserted when selecting
* this completion.
*/
label: string;
/**
* The kind of this completion item. Based on the kind
* an icon is chosen by the editor.
*/
kind?: string;
/**
* A human-readable string with additional information
* about this item, like type or symbol information.
*/
detail?: string;
/**
* A human-readable string, can be Markdown, that represents a doc-comment.
*/
documentation?: string;
/**
* A string that should be used when comparing this item
* with other items. When `falsy` the `label` is used.
*/
sortText?: string;
/**
* A string that should be used when filtering a set of
* completion items. When `falsy` the `label` is used.
*/
filterText?: string;
/**
* A string or snippet that should be inserted in a document when selecting
* this completion. When `falsy` the `label` is used.
*/
insertText?: string;
/**
* Delete number of characters before the caret position,
* by default the letters from the beginning of the word.
*/
deleteBackwards?: number;
/**
* Number of steps to move after the insertion, can be negative.
*/
move?: number;
}
export interface CompletionItemGroup {
/**
* Label that will be displayed for all entries of this group.
*/
label: string;
/**
* List of suggestions of this group.
*/
items: CompletionItem[];
/**
* If true, match only by prefix (and not mid-word).
*/
prefixMatch?: boolean;
/**
* If true, do not filter items in this group based on the search.
*/
skipFilter?: boolean;
/**
* If true, do not sort items.
*/
skipSort?: boolean;
}
interface ExploreDatasource {
value: string;
label: string;
@@ -8,6 +80,26 @@ export interface HistoryItem {
query: string;
}
export abstract class LanguageProvider {
datasource: any;
request: (url) => Promise<any>;
start: () => Promise<any>;
}
export interface TypeaheadInput {
text: string;
prefix: string;
wrapperClasses: string[];
labelKey?: string;
value?: Value;
}
export interface TypeaheadOutput {
context?: string;
refresher?: Promise<{}>;
suggestions: CompletionItemGroup[];
}
export interface Range {
from: string;
to: string;
@@ -18,11 +110,29 @@ export interface Query {
key?: string;
}
export interface QueryFix {
type: string;
label: string;
action?: QueryFixAction;
}
export interface QueryFixAction {
type: string;
query?: string;
preventSubmit?: boolean;
}
export interface QueryHint {
type: string;
label: string;
fix?: QueryFix;
}
export interface QueryTransaction {
id: string;
done: boolean;
error?: string;
hints?: any[];
hints?: QueryHint[];
latency: number;
options: any;
query: string;

View File

@@ -23,6 +23,12 @@ import {
import { PanelProps } from './panel';
import { PluginDashboard, PluginMeta, Plugin, PluginsState } from './plugins';
import { Organization, OrganizationPreferences, OrganizationState } from './organization';
import {
AppNotification,
AppNotificationSeverity,
AppNotificationsState,
AppNotificationTimeout,
} from './appNotifications';
export {
Team,
@@ -74,6 +80,10 @@ export {
Organization,
OrganizationState,
OrganizationPreferences,
AppNotification,
AppNotificationsState,
AppNotificationSeverity,
AppNotificationTimeout,
};
export interface StoreState {
@@ -87,4 +97,5 @@ export interface StoreState {
dataSources: DataSourcesState;
users: UsersState;
organization: OrganizationState;
appNotifications: AppNotificationsState;
}

View File

@@ -7,13 +7,13 @@
.alert {
padding: 1.25rem 2rem 1.25rem 1.5rem;
margin-bottom: $line-height-base;
margin-bottom: $panel-margin / 2;
text-shadow: 0 2px 0 rgba(255, 255, 255, 0.5);
background: $alert-error-bg;
position: relative;
color: $white;
text-shadow: 0 1px 0 rgba(0, 0, 0, 0.2);
border-radius: 2px;
border-radius: $border-radius;
display: flex;
flex-direction: row;
}

View File

@@ -21,6 +21,9 @@ div.flot-text {
height: 100%;
&--solo {
position: fixed;
bottom: 0;
right: 0;
margin: 0;
.panel-container {
border: none;

View File

@@ -2271,9 +2271,51 @@ Licensed under the MIT license.
});
}
function drawOrphanedPoints(series) {
/* Filters series data for points with no neighbors before or after
* and plots single 0.5 radius points for them so that they are displayed.
*/
var abandonedPoints = [];
var beforeX = null;
var afterX = null;
var datapoints = series.datapoints;
// find any points with no neighbors before or after
var emptyPoints = [];
for (var j = 0; j < datapoints.pointsize - 2; j++) {
emptyPoints.push(0);
}
for (var i = 0; i < datapoints.points.length; i += datapoints.pointsize) {
var x = datapoints.points[i], y = datapoints.points[i + 1];
if (i === datapoints.points.length - datapoints.pointsize) {
afterX = null;
} else {
afterX = datapoints.points[i + datapoints.pointsize];
}
if (x !== null && y !== null && beforeX === null && afterX === null) {
abandonedPoints.push(x);
abandonedPoints.push(y);
abandonedPoints.push.apply(abandonedPoints, emptyPoints);
}
beforeX = x;
}
var olddatapoints = datapoints.points
datapoints.points = abandonedPoints;
series.points.radius = series.lines.lineWidth/2;
// plot the orphan points with a radius of lineWidth/2
drawSeriesPoints(series);
// reset old info
datapoints.points = olddatapoints;
}
function drawSeries(series) {
if (series.lines.show)
drawSeriesLines(series);
if (!series.points.show && !series.bars.show) {
// not necessary if user wants points displayed for everything
drawOrphanedPoints(series);
}
if (series.bars.show)
drawSeriesBars(series);
if (series.points.show)

View File

@@ -14,6 +14,9 @@
<link rel="icon" type="image/png" href="public/img/fav32.png">
<link rel="mask-icon" href="public/img/grafana_mask_icon.svg" color="#F05A28">
<link rel="apple-touch-icon" sizes="180x180" href="public/img/apple-touch-icon.png">
<link rel="stylesheet" href="public/build/grafana.[[ .Theme ]].css?v[[ .BuildVersion ]]+[[ .BuildCommit ]]">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="msapplication-TileColor" content="#2b5797">
@@ -23,13 +26,6 @@
<body class="theme-[[ .Theme ]]">
<style>
body {
margin: 0;
height: 100%;
width: 100%;
position: absolute;
}
.preloader {
height: 100%;
flex-direction: column;
@@ -38,14 +34,6 @@
align-items: center;
}
.theme-light .preloader {
background: linear-gradient(-60deg, #f7f8fa, #f5f6f9 70%, #f7f8fa 98%);
}
.theme-dark .preloader {
background: linear-gradient(180deg, #222426 10px, #161719 100px);
}
.preloader__enter {
opacity: 0;
animation-name: preloader-fade-in;
@@ -200,21 +188,8 @@
<grafana-app class="grafana-app" ng-cloak>
<sidemenu class="sidemenu"></sidemenu>
<app-notifications-list class="page-alert-list"></app-notifications-list>
<div class="page-alert-list">
<div ng-repeat='alert in dashAlerts.list' class="alert-{{alert.severity}} alert">
<div class="alert-icon">
<i class="{{alert.icon}}"></i>
</div>
<div class="alert-body">
<div class="alert-title">{{alert.title}}</div>
<div class="alert-text" ng-bind='alert.text'></div>
</div>
<button type="button" class="alert-close" ng-click="dashAlerts.clear(alert)">
<i class="fa fa fa-remove"></i>
</button>
</div>
</div>
<div class="main-view">
<div class="scroll-canvas" page-scrollbar>
@@ -266,14 +241,7 @@
navTree: [[.NavTree]]
};
// load css async
var myCSS = document.createElement("link");
myCSS.rel = "stylesheet";
myCSS.href = "public/build/grafana.[[ .Theme ]].css?v[[ .BuildVersion ]]+[[ .BuildCommit ]]";
// insert it at the end of the head in a legacy-friendly manner
document.head.insertBefore(myCSS, document.head.childNodes[document.head.childNodes.length - 1].nextSibling);
// switch loader to show all has loaded
// In case the js files fails to load the code below will show an info message.
window.onload = function() {
var preloader = document.getElementsByClassName("preloader");
if (preloader.length) {