mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Merge branch 'master' into org-page-to-react
This commit is contained in:
28
public/app/core/actions/appNotification.ts
Normal file
28
public/app/core/actions/appNotification.ts
Normal 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,
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
@@ -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, []);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -19,3 +19,4 @@ export const Label: SFC<Props> = props => {
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
46
public/app/core/components/Switch/Switch.tsx
Normal file
46
public/app/core/components/Switch/Switch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
46
public/app/core/copy/appNotification.ts
Normal file
46
public/app/core/copy/appNotification.ts
Normal 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(),
|
||||
});
|
||||
51
public/app/core/reducers/appNotification.test.ts
Normal file
51
public/app/core/reducers/appNotification.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
19
public/app/core/reducers/appNotification.ts
Normal file
19
public/app/core/reducers/appNotification.ts
Normal 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;
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
11
public/app/core/utils/connectWithReduxStore.tsx
Normal file
11
public/app/core/utils/connectWithReduxStore.tsx
Normal 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} />;
|
||||
};
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
72
public/app/features/explore/PlaceholdersBuffer.test.ts
Normal file
72
public/app/features/explore/PlaceholdersBuffer.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
112
public/app/features/explore/PlaceholdersBuffer.ts
Normal file
112
public/app/features/explore/PlaceholdersBuffer.ts
Normal 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[];
|
||||
};
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
347
public/app/plugins/datasource/prometheus/language_provider.ts
Normal file
347
public/app/plugins/datasource/prometheus/language_provider.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { parseSelector } from './prometheus';
|
||||
import { parseSelector } from '../language_utils';
|
||||
|
||||
describe('parseSelector()', () => {
|
||||
let parsed;
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
"name": "React Graph",
|
||||
"id": "graph2",
|
||||
|
||||
"state": "alpha",
|
||||
|
||||
"info": {
|
||||
"author": {
|
||||
"name": "Grafana Project",
|
||||
|
||||
@@ -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 |
@@ -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;
|
||||
|
||||
25
public/app/types/appNotifications.ts
Normal file
25
public/app/types/appNotifications.ts
Normal 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[];
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@ div.flot-text {
|
||||
height: 100%;
|
||||
|
||||
&--solo {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
margin: 0;
|
||||
.panel-container {
|
||||
border: none;
|
||||
|
||||
42
public/vendor/flot/jquery.flot.js
vendored
42
public/vendor/flot/jquery.flot.js
vendored
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user