diff --git a/.gitignore b/.gitignore index d8716ad2305..76f98fca08d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,8 @@ node_modules coverage/ .aws-config.json -dist +/dist +/tmp # locally required config files web.config diff --git a/Gruntfile.js b/Gruntfile.js index bcc542e01d6..5494a893ce3 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -11,6 +11,22 @@ module.exports = function (grunt) { docsDir: 'docs/' }; + config.mode = grunt.option('mode') || 'standalone'; + config.modeOptions = { + zipSuffix: '', + requirejs: { + paths: { config: '../config.sample' }, + excludeConfig: true, + } + }; + + if (config.mode === 'backend') { + grunt.log.writeln('Setting backend build mode'); + config.modeOptions.zipSuffix = '-backend'; + config.modeOptions.requirejs.paths = {}; + config.modeOptions.requirejs.excludeConfig = false; + } + // load plugins require('load-grunt-tasks')(grunt); @@ -34,5 +50,4 @@ module.exports = function (grunt) { // pass the config to grunt grunt.initConfig(config); - -}; \ No newline at end of file +}; diff --git a/package.json b/package.json index 054aa61d717..f53f8fb8bff 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "company": "Coding Instinct AB" }, "name": "grafana", - "version": "1.9.1", + "version": "2.0.0-alpha", "repository": { "type": "git", "url": "http://github.com/torkelo/grafana.git" @@ -16,7 +16,7 @@ "grunt-angular-templates": "^0.5.5", "grunt-cli": "~0.1.13", "grunt-contrib-clean": "~0.5.0", - "grunt-contrib-compress": "~0.5.2", + "grunt-contrib-compress": "~0.13.0", "grunt-contrib-concat": "^0.4.0", "grunt-contrib-connect": "~0.5.0", "grunt-contrib-copy": "~0.5.0", @@ -33,7 +33,7 @@ "grunt-ng-annotate": "^0.9.2", "grunt-ngmin": "0.0.3", "grunt-string-replace": "~0.2.4", - "grunt-usemin": "^2.1.1", + "grunt-usemin": "3.0.0", "jshint-stylish": "~0.1.5", "karma": "~0.12.21", "karma-chrome-launcher": "~0.1.4", @@ -65,6 +65,7 @@ "dependencies": { "grunt-jscs": "^0.8.1", "karma-sinon": "^1.0.3", + "lodash": "^2.4.1", "sinon": "^1.10.3" } } diff --git a/src/app/app.js b/src/app/app.js index b1429f02550..edb97cd0cab 100644 --- a/src/app/app.js +++ b/src/app/app.js @@ -49,8 +49,7 @@ function (angular, $, _, appLevelRequire, config) { return module; }; - app.config(function ($routeProvider, $controllerProvider, $compileProvider, $filterProvider, $provide) { - $routeProvider.otherwise({ redirectTo: config.default_route }); + app.config(function($locationProvider, $controllerProvider, $compileProvider, $filterProvider, $provide) { // this is how the internet told me to dynamically add modules :/ register_fns.controller = $controllerProvider.register; register_fns.directive = $compileProvider.directive; @@ -68,7 +67,16 @@ function (angular, $, _, appLevelRequire, config) { 'pasvaz.bindonce' ]; - var module_types = ['controllers', 'directives', 'factories', 'services', 'filters', 'routes']; + var module_types = ['controllers', 'directives', 'factories', 'services', 'filters']; + + if (window.grafanaBackend) { + module_types.push('routes'); + angular.module('grafana.routes.standalone', []); + } + else { + module_types.push('routes.standalone'); + angular.module('grafana.routes', []); + } _.each(module_types, function (type) { var module_name = 'grafana.'+type; @@ -85,7 +93,8 @@ function (angular, $, _, appLevelRequire, config) { 'directives/all', 'filters/all', 'components/partials', - 'routes/standalone/default', + 'routes/standalone/all', + 'routes/backend/all', ]; _.each(config.plugins.dependencies, function(dep) { diff --git a/src/app/components/config.js b/src/app/components/config.js new file mode 100644 index 00000000000..4d8d34d857a --- /dev/null +++ b/src/app/components/config.js @@ -0,0 +1,12 @@ +define([ + 'settings', +], +function (Settings) { + "use strict"; + + var bootData = window.grafanaBootData; + var options = bootData.settings; + + return new Settings(options); + +}); diff --git a/src/app/components/panelmeta.js b/src/app/components/panelmeta.js index fc26f1c3d0e..4ee4a9b9b55 100644 --- a/src/app/components/panelmeta.js +++ b/src/app/components/panelmeta.js @@ -6,6 +6,8 @@ function () { function PanelMeta(options) { this.description = options.description; this.fullscreen = options.fullscreen; + this.editIcon = options.editIcon; + this.panelName = options.panelName; this.menu = []; this.editorTabs = []; this.extendedMenu = []; diff --git a/src/app/components/require.backend.js b/src/app/components/require.backend.js new file mode 100644 index 00000000000..dcc382e3faf --- /dev/null +++ b/src/app/components/require.backend.js @@ -0,0 +1,100 @@ + +require.config({ + urlArgs: 'bust=' + (new Date().getTime()), + baseUrl: 'public/app', + + paths: { + config: 'components/config', + settings: 'components/settings', + kbn: 'components/kbn', + store: 'components/store', + + css: '../vendor/require/css', + text: '../vendor/require/text', + moment: '../vendor/moment', + filesaver: '../vendor/filesaver', + angular: '../vendor/angular/angular', + 'angular-route': '../vendor/angular/angular-route', + 'angular-sanitize': '../vendor/angular/angular-sanitize', + 'angular-dragdrop': '../vendor/angular/angular-dragdrop', + 'angular-strap': '../vendor/angular/angular-strap', + timepicker: '../vendor/angular/timepicker', + datepicker: '../vendor/angular/datepicker', + bindonce: '../vendor/angular/bindonce', + crypto: '../vendor/crypto.min', + spectrum: '../vendor/spectrum', + + lodash: 'components/lodash.extended', + 'lodash-src': '../vendor/lodash', + bootstrap: '../vendor/bootstrap/bootstrap', + + jquery: '../vendor/jquery/jquery-2.1.1.min', + + 'extend-jquery': 'components/extend-jquery', + + 'jquery.flot': '../vendor/jquery/jquery.flot', + 'jquery.flot.pie': '../vendor/jquery/jquery.flot.pie', + 'jquery.flot.events': '../vendor/jquery/jquery.flot.events', + 'jquery.flot.selection': '../vendor/jquery/jquery.flot.selection', + 'jquery.flot.stack': '../vendor/jquery/jquery.flot.stack', + 'jquery.flot.stackpercent':'../vendor/jquery/jquery.flot.stackpercent', + 'jquery.flot.time': '../vendor/jquery/jquery.flot.time', + 'jquery.flot.crosshair': '../vendor/jquery/jquery.flot.crosshair', + 'jquery.flot.fillbelow': '../vendor/jquery/jquery.flot.fillbelow', + + modernizr: '../vendor/modernizr-2.6.1', + + 'bootstrap-tagsinput': '../vendor/tagsinput/bootstrap-tagsinput', + }, + shim: { + + spectrum: { + deps: ['jquery'] + }, + + crypto: { + exports: 'Crypto' + }, + + angular: { + deps: ['jquery','config'], + exports: 'angular' + }, + + bootstrap: { + deps: ['jquery'] + }, + + modernizr: { + exports: 'Modernizr' + }, + + jquery: { + exports: 'jQuery' + }, + + // simple dependency declaration + // + 'jquery.flot': ['jquery'], + 'jquery.flot.pie': ['jquery', 'jquery.flot'], + 'jquery.flot.events': ['jquery', 'jquery.flot'], + 'jquery.flot.selection':['jquery', 'jquery.flot'], + 'jquery.flot.stack': ['jquery', 'jquery.flot'], + 'jquery.flot.stackpercent':['jquery', 'jquery.flot'], + 'jquery.flot.time': ['jquery', 'jquery.flot'], + 'jquery.flot.crosshair':['jquery', 'jquery.flot'], + 'jquery.flot.fillbelow':['jquery', 'jquery.flot'], + 'angular-dragdrop': ['jquery', 'angular'], + 'angular-mocks': ['angular'], + 'angular-sanitize': ['angular'], + 'angular-route': ['angular'], + 'angular-strap': ['angular', 'bootstrap','timepicker', 'datepicker'], + 'bindonce': ['angular'], + + timepicker: ['jquery', 'bootstrap'], + datepicker: ['jquery', 'bootstrap'], + + 'bootstrap-tagsinput': ['jquery'], + }, + waitSeconds: 60, +}); diff --git a/src/app/components/require.config.js b/src/app/components/require.standalone.js similarity index 97% rename from src/app/components/require.config.js rename to src/app/components/require.standalone.js index dacd6fc03d1..99737e19f74 100644 --- a/src/app/components/require.config.js +++ b/src/app/components/require.standalone.js @@ -1,9 +1,6 @@ -/** - * Bootstrap require with the needed config, then load the app.js module. - */ require.config({ - baseUrl: 'app', urlArgs: 'bust=' + (new Date().getTime()), + baseUrl: 'app', paths: { config: ['../config', '../config.sample'], @@ -47,7 +44,6 @@ require.config({ modernizr: '../vendor/modernizr-2.6.1', 'bootstrap-tagsinput': '../vendor/tagsinput/bootstrap-tagsinput', - }, shim: { diff --git a/src/app/components/settings.js b/src/app/components/settings.js index 532b2e2b133..7e896b475ea 100644 --- a/src/app/components/settings.js +++ b/src/app/components/settings.js @@ -16,24 +16,22 @@ function (_, crypto) { datasources : {}, window_title_prefix : 'Grafana - ', panels : { - 'graph': { path: 'panels/graph' }, - 'singlestat': { path: 'panels/singlestat' }, - 'text': { path: 'panels/text' } + 'graph': { path: 'panels/graph', name: 'Graph' }, + 'singlestat': { path: 'panels/singlestat', name: 'Single stat' }, + 'text': { path: 'panels/text', name: 'Text' }, + 'dashlist': { path: 'panels/dashlist', name: 'Dashboard list' }, }, - plugins : {}, - default_route : '/dashboard/file/default.json', - playlist_timespan : "1m", - unsaved_changes_warning : true, - search : { max_results: 100 }, - admin : {} + new_panel_title: 'no title (click here)', + plugins: {}, + default_route: '/dashboard/file/default.json', + playlist_timespan: "1m", + unsaved_changes_warning: true, + search: { max_results: 100 }, + admin: {}, + appSubUrl: "" }; - // This initializes a new hash on purpose, to avoid adding parameters to - // config.js without providing sane defaults - var settings = {}; - _.each(defaults, function(value, key) { - settings[key] = typeof options[key] !== 'undefined' ? options[key] : defaults[key]; - }); + var settings = _.extend({}, defaults, options); var parseBasicAuth = function(datasource) { var passwordEnd = datasource.url.indexOf('@'); diff --git a/src/app/components/store.js b/src/app/components/store.js index b9006f6e37d..84e72b96314 100644 --- a/src/app/components/store.js +++ b/src/app/components/store.js @@ -8,9 +8,15 @@ define([], function() { set: function(key, value) { window.localStorage[key] = value; }, - getBool: function(key) { + getBool: function(key, def) { + if (def !== void 0 && !this.exists(key)) { + return def; + } return window.localStorage[key] === 'true' ? true : false; }, + exists: function(key) { + return window.localStorage[key] !== void 0; + }, delete: function(key) { window.localStorage.removeItem(key); } diff --git a/src/app/controllers/all.js b/src/app/controllers/all.js index 8703cf0f2bf..bf5c8164397 100644 --- a/src/app/controllers/all.js +++ b/src/app/controllers/all.js @@ -6,4 +6,7 @@ define([ './graphiteImport', './inspectCtrl', './jsonEditorCtrl', + './loginCtrl', + './sidemenuCtrl', + './errorCtrl', ], function () {}); diff --git a/src/app/controllers/errorCtrl.js b/src/app/controllers/errorCtrl.js new file mode 100644 index 00000000000..97f606fcaa3 --- /dev/null +++ b/src/app/controllers/errorCtrl.js @@ -0,0 +1,22 @@ +define([ + 'angular', + 'app', + 'lodash' +], +function (angular) { + 'use strict'; + + var module = angular.module('grafana.controllers'); + + module.controller('ErrorCtrl', function($scope) { + + var showSideMenu = $scope.grafana.sidemenu; + $scope.grafana.sidemenu = false; + + $scope.$on('$destroy', function() { + $scope.grafana.sidemenu = showSideMenu; + }); + + }); + +}); diff --git a/src/app/controllers/grafanaCtrl.js b/src/app/controllers/grafanaCtrl.js index a50189de5f9..3922af87be8 100644 --- a/src/app/controllers/grafanaCtrl.js +++ b/src/app/controllers/grafanaCtrl.js @@ -10,32 +10,47 @@ function (angular, config, _, $, store) { var module = angular.module('grafana.controllers'); - module.controller('GrafanaCtrl', function($scope, alertSrv, utilSrv, grafanaVersion, $rootScope, $controller) { - - $scope.grafanaVersion = grafanaVersion[0] === '@' ? 'master' : grafanaVersion; - $scope._ = _; - $rootScope.profilingEnabled = store.getBool('profilingEnabled'); - $rootScope.performance = { loadStart: new Date().getTime() }; + module.controller('GrafanaCtrl', function($scope, alertSrv, utilSrv, grafanaVersion, $rootScope, $controller, userSrv, $timeout) { $scope.init = function() { + $scope.grafana = {}; + $scope.grafana.version = grafanaVersion; + $scope._ = _; + + $rootScope.profilingEnabled = store.getBool('profilingEnabled'); + $rootScope.performance = { loadStart: new Date().getTime() }; + $rootScope.appSubUrl = config.appSubUrl; + if ($rootScope.profilingEnabled) { $scope.initProfiling(); } alertSrv.init(); utilSrv.init(); $scope.dashAlerts = alertSrv; - $scope.grafana = { style: 'dark' }; - }; + $scope.grafana.lightTheme = false; + $scope.grafana.user = userSrv.getSignedInUser(); + $scope.grafana.sidemenu = store.getBool('grafana.sidemenu'); + $scope.topnav = { title: 'Grafana' }; - $scope.toggleConsole = function() { - $scope.consoleEnabled = !$scope.consoleEnabled; - store.set('grafanaConsole', $scope.consoleEnabled); + $scope.onAppEvent('logged-out', function() { + $scope.grafana.sidemenu = false; + $scope.grafana.user = {}; + }); }; $scope.initDashboard = function(dashboardData, viewScope) { $controller('DashboardCtrl', { $scope: viewScope }).init(dashboardData); }; + $scope.toggleSideMenu = function() { + $scope.grafana.sidemenu = !$scope.grafana.sidemenu; + store.set('grafana.sidemenu', $scope.grafana.sidemenu); + + $timeout(function() { + $scope.$broadcast("render"); + }, 50); + }; + $rootScope.onAppEvent = function(name, callback) { var unbind = $rootScope.$on(name, callback); this.$on('$destroy', unbind); @@ -81,7 +96,11 @@ function (angular, config, _, $, store) { $scope.initProfiling = function() { var count = 0; - $scope.$watch(function digestCounter() { count++; }, function() { }); + $scope.$watch(function digestCounter() { + count++; + }, function() { + }); + $scope.onAppEvent('dashboard-loaded', function() { count = 0; diff --git a/src/app/controllers/loginCtrl.js b/src/app/controllers/loginCtrl.js new file mode 100644 index 00000000000..1afa133235d --- /dev/null +++ b/src/app/controllers/loginCtrl.js @@ -0,0 +1,103 @@ +define([ + 'angular', + 'config', +], +function (angular, config) { + 'use strict'; + + var module = angular.module('grafana.controllers'); + + module.controller('LoginCtrl', function($scope, backendSrv) { + $scope.formModel = { + user: '', + email: '', + password: '', + }; + + $scope.grafana.sidemenu = false; + + $scope.googleAuthEnabled = config.googleAuthEnabled; + $scope.githubAuthEnabled = config.githubAuthEnabled; + $scope.disableUserSignUp = config.disableUserSignUp; + + $scope.loginMode = true; + $scope.submitBtnClass = 'btn-inverse'; + $scope.submitBtnText = 'Log in'; + $scope.strengthClass = ''; + + $scope.init = function() { + $scope.$watch("loginMode", $scope.loginModeChanged); + $scope.passwordChanged(); + }; + + // build info view model + $scope.buildInfo = { + version: config.buildInfo.version, + commit: config.buildInfo.commit, + buildstamp: new Date(config.buildInfo.buildstamp * 1000) + }; + + $scope.submit = function() { + if ($scope.loginMode) { + $scope.login(); + } else { + $scope.signUp(); + } + }; + + $scope.loginModeChanged = function(newValue) { + $scope.submitBtnText = newValue ? 'Log in' : 'Sign up'; + }; + + $scope.passwordChanged = function(newValue) { + if (!newValue) { + $scope.strengthText = ""; + $scope.strengthClass = "hidden"; + return; + } + if (newValue.length < 4) { + $scope.strengthText = "strength: weak sauce."; + $scope.strengthClass = "password-strength-bad"; + return; + } + if (newValue.length <= 6) { + $scope.strengthText = "strength: you can do better."; + $scope.strengthClass = "password-strength-ok"; + return; + } + + $scope.strengthText = "strength: strong like a bull."; + $scope.strengthClass = "password-strength-good"; + }; + + $scope.signUp = function() { + if (!$scope.loginForm.$valid) { + return; + } + + backendSrv.post('/api/user/signup', $scope.formModel).then(function() { + window.location.href = config.appSubUrl + '/'; + }); + }; + + $scope.login = function() { + delete $scope.loginError; + + if (!$scope.loginForm.$valid) { + return; + } + + backendSrv.post('/login', $scope.formModel).then(function(result) { + if (result.redirectUrl) { + window.location.href = result.redirectUrl; + } else { + window.location.href = config.appSubUrl + '/'; + } + }); + }; + + $scope.init(); + + }); + +}); diff --git a/src/app/controllers/search.js b/src/app/controllers/search.js index 3abcf4ec0a3..0cd2a3d0db3 100644 --- a/src/app/controllers/search.js +++ b/src/app/controllers/search.js @@ -64,33 +64,18 @@ function (angular, _, config, $) { $scope.selectedIndex = Math.max(Math.min($scope.selectedIndex + direction, $scope.resultCount - 1), 0); }; - $scope.goToDashboard = function(id) { + $scope.goToDashboard = function(slug) { $location.search({}); - $location.path("/dashboard/db/" + id); - }; - - $scope.shareDashboard = function(title, id, $event) { - $event.stopPropagation(); - var baseUrl = window.location.href.replace(window.location.hash,''); - - $scope.share = { - title: title, - url: baseUrl + '#dashboard/db/' + encodeURIComponent(id) - }; + $location.path("/dashboard/db/" + slug); }; $scope.searchDashboards = function(queryString) { - // bookeeping for determining stale search requests - var searchId = $scope.currentSearchId + 1; - $scope.currentSearchId = searchId > $scope.currentSearchId ? searchId : $scope.currentSearchId; + $scope.currentSearchId = $scope.currentSearchId + 1; + var localSearchId = $scope.currentSearchId; return $scope.db.searchDashboards(queryString) .then(function(results) { - // since searches are async, it's possible that these results are not for the latest search. throw - // them away if so - if (searchId < $scope.currentSearchId) { - return; - } + if (localSearchId < $scope.currentSearchId) { return; } $scope.tagsOnly = results.tagsOnly; $scope.results.dashboards = results.dashboards; @@ -125,11 +110,11 @@ function (angular, _, config, $) { $scope.deleteDashboard = function(dash, evt) { evt.stopPropagation(); - $scope.appEvent('delete-dashboard', { id: dash.id, title: dash.title }); + $scope.appEvent('delete-dashboard', { slug: dash.slug, title: dash.title }); }; - $scope.dashboardDeleted = function(evt, id) { - var dash = _.findWhere($scope.results.dashboards, {id: id}); + $scope.dashboardDeleted = function(evt, payload) { + var dash = _.findWhere($scope.results.dashboards, { slug: payload.slug }); $scope.results.dashboards = _.without($scope.results.dashboards, dash); }; @@ -154,7 +139,7 @@ function (angular, _, config, $) { }; $scope.newDashboard = function() { - $location.url('/dashboard/file/empty.json'); + $location.url('dashboard/new'); }; }); diff --git a/src/app/controllers/sidemenuCtrl.js b/src/app/controllers/sidemenuCtrl.js new file mode 100644 index 00000000000..d503cecab42 --- /dev/null +++ b/src/app/controllers/sidemenuCtrl.js @@ -0,0 +1,107 @@ +define([ + 'angular', + 'lodash', + 'jquery', + 'config', +], +function (angular, _, $, config) { + 'use strict'; + + var module = angular.module('grafana.controllers'); + + module.controller('SideMenuCtrl', function($scope, $location) { + + $scope.getUrl = function(url) { + return config.appSubUrl + url; + }; + + $scope.menu = []; + $scope.menu.push({ + text: "Dashbords", + icon: "fa fa-th-large", + href: $scope.getUrl("/"), + //startsWith: config.appSubUrl + '/dashboard/', + }); + + if ($scope.grafana.user.accountRole === 'Admin') { + $scope.menu.push({ + text: "Data Sources", + icon: "fa fa-database", + href: $scope.getUrl("/account/datasources"), + }); + $scope.menu.push({ + text: "Account", href: $scope.getUrl("/account"), + requireRole: "Admin", + icon: "fa fa-shield", + }); + $scope.menu.push({ + text: "Users", href: $scope.getUrl("/account/users"), + requireRole: "Admin", + icon: "fa fa-users", + }); + $scope.menu.push({ + text: "API Keys", href: $scope.getUrl("/account/apikeys"), + requireRole: "Admin", + icon: "fa fa-key", + }); + } + + if ($scope.grafana.user.isGrafanaAdmin) { + $scope.menu.push({ + text: "Admin", href: $scope.getUrl("/admin/users"), + icon: "fa fa-cube", + requireSignedIn: true, + links: [ + { text: 'Settings', href: $scope.getUrl("/admin/settings")}, + { text: 'Users', href: $scope.getUrl("/admin/users"), icon: "fa fa-lock" }, + { text: 'Log', href: "", icon: "fa fa-lock" }, + ] + }); + } + + $scope.updateState = function() { + var currentPath = config.appSubUrl + $location.path(); + var search = $location.search(); + + _.each($scope.menu, function(item) { + item.active = false; + + if (item.href === currentPath) { + item.active = true; + } + + if (item.startsWith) { + if (currentPath.indexOf(item.startsWith) === 0) { + item.active = true; + item.href = currentPath; + } + } + + _.each(item.links, function(link) { + link.active = false; + + if (link.editview) { + var params = {}; + _.each(search, function(value, key) { + if (value !== null) { params[key] = value; } + }); + + params.editview = link.editview; + link.href = currentPath + '?' + $.param(params); + } + + if (link.href === currentPath) { + item.active = true; + link.active = true; + } + }); + }); + + }; + + $scope.init = function() { + $scope.updateState(); + }; + }); + +}); diff --git a/src/app/directives/all.js b/src/app/directives/all.js index 879b61e2512..6190ef89099 100644 --- a/src/app/directives/all.js +++ b/src/app/directives/all.js @@ -1,7 +1,6 @@ define([ './arrayJoin', './dashUpload', - './grafanaPanel', './grafanaSimplePanel', './ngBlur', './dashEditLink', @@ -16,4 +15,5 @@ define([ './graphiteSegment', './grafanaVersionCheck', './dropdown.typeahead', + './topnav', ], function () {}); diff --git a/src/app/directives/dashEditLink.js b/src/app/directives/dashEditLink.js index 638b92fface..b17c73bc14d 100644 --- a/src/app/directives/dashEditLink.js +++ b/src/app/directives/dashEditLink.js @@ -5,6 +5,12 @@ define([ function (angular, $) { 'use strict'; + var editViewMap = { + 'settings': { src: 'app/partials/dasheditor.html', title: "Settings" }, + 'annotations': { src: 'app/features/annotations/partials/editor.html', title: "Annotations" }, + 'templating': { src: 'app/partials/templating_editor.html', title: "Templating" } + }; + angular .module('grafana.directives') .directive('dashEditorLink', function($timeout) { @@ -25,7 +31,7 @@ function (angular, $) { angular .module('grafana.directives') - .directive('dashEditorView', function($compile) { + .directive('dashEditorView', function($compile, $location) { return { restrict: 'A', link: function(scope, elem) { @@ -48,10 +54,12 @@ function (angular, $) { if (editorScope) { editorScope.dismiss(); } } - scope.$on("$destroy", hideEditorPane); - scope.onAppEvent('hide-dash-editor', hideEditorPane); + function showEditorPane(evt, payload, editview) { + if (editview) { + scope.grafana.editview = editViewMap[editview]; + payload.src = scope.grafana.editview.src; + } - scope.onAppEvent('show-dash-editor', function(evt, payload) { if (lastEditor === payload.src) { hideEditorPane(); return; @@ -65,23 +73,43 @@ function (angular, $) { editorScope = payload.scope ? payload.scope.$new() : scope.$new(); editorScope.dismiss = function() { - console.log('dismiss: '); editorScope.$destroy(); elem.empty(); lastEditor = null; editorScope = null; hideScrollbars(false); + + if (editview) { + var urlParams = $location.search(); + if (editview === urlParams.editview) { + delete urlParams.editview; + $location.search(urlParams); + } + } }; // hide page scrollbars while edit pane is visible hideScrollbars(true); var src = "'" + payload.src + "'"; - var view = $('
'); + var view = $('
'); elem.append(view); $compile(elem.contents())(editorScope); + } + + scope.$watch("dashboardViewState.state.editview", function(newValue, oldValue) { + if (newValue) { + showEditorPane(null, {}, newValue); + } else if (oldValue) { + scope.grafana.editview = null; + hideEditorPane(); + } }); + scope.grafana.editview = null; + scope.$on("$destroy", hideEditorPane); + scope.onAppEvent('hide-dash-editor', hideEditorPane); + scope.onAppEvent('show-dash-editor', showEditorPane); } }; }); diff --git a/src/app/directives/grafanaPanel.js b/src/app/directives/grafanaPanel.js deleted file mode 100644 index d73eb2bb0b9..00000000000 --- a/src/app/directives/grafanaPanel.js +++ /dev/null @@ -1,89 +0,0 @@ -define([ - 'angular', - 'jquery', - 'config', - './panelMenu', -], -function (angular, $, config) { - 'use strict'; - - angular - .module('grafana.directives') - .directive('grafanaPanel', function($compile, $parse) { - - var container = '
'; - var content = '
'; - - var panelHeader = - '
'+ - '' + - '' + - '' + - '' + - '' + - - '' + - '' + - '' + - - '
' + - '
'+ - ''; - - return { - restrict: 'E', - link: function($scope, elem, attr) { - var getter = $parse(attr.type), panelType = getter($scope); - var newScope = $scope.$new(); - - $scope.kbnJqUiDraggableOptions = { - revert: 'invalid', - helper: function() { - return $('
'); - }, - placeholder: 'keep' - }; - - // compile the module and uncloack. We're done - function loadModule($module) { - $module.appendTo(elem); - elem.wrap(container); - /* jshint indent:false */ - $compile(elem.contents())(newScope); - elem.removeClass("ng-cloak"); - - var panelCtrlElem = $(elem.children()[0]); - var panelCtrlScope = panelCtrlElem.data().$scope; - - panelCtrlScope.$watchGroup(['fullscreen', 'panel.height', 'row.height'], function() { - panelCtrlElem.css({ minHeight: panelCtrlScope.panel.height || panelCtrlScope.row.height }); - panelCtrlElem.toggleClass('panel-fullscreen', panelCtrlScope.fullscreen ? true : false); - }); - } - - newScope.$on('$destroy',function() { - elem.unbind(); - elem.remove(); - }); - - elem.addClass('ng-cloak'); - - var panelPath = config.panels[panelType].path; - - $scope.require([ - 'jquery', - 'text!'+panelPath+'/module.html', - panelPath + "/module", - ], function ($, moduleTemplate) { - var $module = $(moduleTemplate); - $module.prepend(panelHeader); - $module.first().find('.panel-header').nextAll().wrapAll(content); - loadModule($module); - }); - - } - }; - }); - -}); diff --git a/src/app/directives/tip.js b/src/app/directives/tip.js index 895015cb671..a0aac161bb2 100644 --- a/src/app/directives/tip.js +++ b/src/app/directives/tip.js @@ -18,6 +18,21 @@ function (angular, kbn) { }; }); + angular + .module('grafana.directives') + .directive('watchChange', function() { + return { + scope: { onchange: '&watchChange' }, + link: function(scope, element) { + element.on('input', function() { + scope.$apply(function () { + scope.onchange({ inputValue: element.val() }); + }); + }); + } + }; + }); + angular .module('grafana.directives') .directive('editorOptBool', function($compile) { diff --git a/src/app/directives/topnav.js b/src/app/directives/topnav.js new file mode 100644 index 00000000000..0dd497de047 --- /dev/null +++ b/src/app/directives/topnav.js @@ -0,0 +1,48 @@ +define([ + 'angular', + 'kbn' +], +function (angular) { + 'use strict'; + + angular + .module('grafana.directives') + .directive('topnav', function() { + return { + restrict: 'E', + transclude: true, + scope: { + title: "@", + section: "@", + titleAction: "&", + toggle: "&", + showMenuBtn: "=", + }, + template: + '', + link: function(scope, elem, attrs) { + scope.icon = attrs.icon; + } + }; + }); + +}); diff --git a/src/app/features/account/accountCtrl.js b/src/app/features/account/accountCtrl.js new file mode 100644 index 00000000000..8d4130bda03 --- /dev/null +++ b/src/app/features/account/accountCtrl.js @@ -0,0 +1,29 @@ +define([ + 'angular', +], +function (angular) { + 'use strict'; + + var module = angular.module('grafana.controllers'); + + module.controller('AccountCtrl', function($scope, $http, backendSrv) { + + $scope.init = function() { + $scope.getAccount(); + }; + + $scope.getAccount = function() { + backendSrv.get('/api/account').then(function(account) { + $scope.account = account; + }); + }; + + $scope.update = function() { + if (!$scope.accountForm.$valid) { return; } + backendSrv.put('/api/account', $scope.account).then($scope.getAccount); + }; + + $scope.init(); + + }); +}); diff --git a/src/app/features/account/accountUsersCtrl.js b/src/app/features/account/accountUsersCtrl.js new file mode 100644 index 00000000000..65ad8bcf847 --- /dev/null +++ b/src/app/features/account/accountUsersCtrl.js @@ -0,0 +1,38 @@ +define([ + 'angular', +], +function (angular) { + 'use strict'; + + var module = angular.module('grafana.controllers'); + + module.controller('AccountUsersCtrl', function($scope, $http, backendSrv) { + + $scope.user = { + loginOrEmail: '', + role: 'Viewer', + }; + + $scope.init = function() { + $scope.get(); + }; + + $scope.get = function() { + backendSrv.get('/api/account/users').then(function(users) { + $scope.users = users; + }); + }; + + $scope.removeUser = function(user) { + backendSrv.delete('/api/account/users/' + user.userId).then($scope.get); + }; + + $scope.addUser = function() { + if (!$scope.form.$valid) { return; } + backendSrv.post('/api/account/users', $scope.user).then($scope.get); + }; + + $scope.init(); + + }); +}); diff --git a/src/app/features/account/apiKeysCtrl.js b/src/app/features/account/apiKeysCtrl.js new file mode 100644 index 00000000000..f4ec0d681e8 --- /dev/null +++ b/src/app/features/account/apiKeysCtrl.js @@ -0,0 +1,35 @@ +define([ + 'angular', +], +function (angular) { + 'use strict'; + + var module = angular.module('grafana.controllers'); + + module.controller('ApiKeysCtrl', function($scope, $http, backendSrv) { + + $scope.roleTypes = ['Viewer', 'Editor', 'Admin']; + $scope.token = { role: 'Viewer' }; + + $scope.init = function() { + $scope.getTokens(); + }; + + $scope.getTokens = function() { + backendSrv.get('/api/auth/keys').then(function(tokens) { + $scope.tokens = tokens; + }); + }; + + $scope.removeToken = function(id) { + backendSrv.delete('/api/auth/keys/'+id).then($scope.getTokens); + }; + + $scope.addToken = function() { + backendSrv.post('/api/auth/keys', $scope.token).then($scope.getTokens); + }; + + $scope.init(); + + }); +}); diff --git a/src/app/features/account/datasourcesCtrl.js b/src/app/features/account/datasourcesCtrl.js new file mode 100644 index 00000000000..b8f9f9fda7a --- /dev/null +++ b/src/app/features/account/datasourcesCtrl.js @@ -0,0 +1,88 @@ +define([ + 'angular', +], +function (angular) { + 'use strict'; + + var module = angular.module('grafana.controllers'); + + module.controller('DataSourcesCtrl', function($scope, $http, backendSrv) { + + var defaults = { + name: '', + type: 'graphite', + url: '', + access: 'proxy' + }; + + $scope.types = [ + { name: 'Graphite', type: 'graphite' }, + { name: 'InfluxDB', type: 'influxdb' }, + { name: 'Elasticsearch', type: 'elasticsearch' }, + { name: 'OpenTSDB', type: 'opentsdb' }, + ]; + + $scope.init = function() { + $scope.reset(); + $scope.editor = {index: 0}; + $scope.datasources = []; + $scope.getDatasources(); + + $scope.$watch('editor.index', function(newVal) { + if (newVal !== 2) { + $scope.reset(); + } + }); + }; + + $scope.reset = function() { + $scope.current = angular.copy(defaults); + $scope.currentIsNew = true; + }; + + $scope.edit = function(ds) { + $scope.current = ds; + $scope.currentIsNew = false; + $scope.editor.index = 2; + }; + + $scope.cancel = function() { + $scope.reset(); + $scope.editor.index = 0; + }; + + $scope.getDatasources = function() { + backendSrv.get('/api/datasources').then(function(results) { + $scope.datasources = results; + }); + }; + + $scope.remove = function(ds) { + backendSrv.delete('/api/datasources/' + ds.id).then(function() { + $scope.getDatasources(); + }); + }; + + $scope.update = function() { + backendSrv.post('/api/datasources', $scope.current).then(function() { + $scope.editor.index = 0; + $scope.getDatasources(); + }); + }; + + $scope.add = function() { + if (!$scope.editForm.$valid) { + return; + } + + backendSrv.put('/api/datasources', $scope.current) + .then(function() { + $scope.editor.index = 0; + $scope.getDatasources(); + }); + }; + + $scope.init(); + + }); +}); diff --git a/src/app/features/account/importCtrl.js b/src/app/features/account/importCtrl.js new file mode 100644 index 00000000000..a8548491c60 --- /dev/null +++ b/src/app/features/account/importCtrl.js @@ -0,0 +1,78 @@ +define([ + 'angular', + 'lodash', +], +function (angular, _) { + 'use strict'; + + var module = angular.module('grafana.controllers'); + + module.controller('ImportCtrl', function($scope, $http, backendSrv, datasourceSrv) { + + $scope.init = function() { + $scope.datasources = []; + $scope.sourceName = 'grafana'; + $scope.destName = 'grafana'; + $scope.imported = []; + $scope.dashboards = []; + $scope.infoText = ''; + $scope.importing = false; + + _.each(datasourceSrv.getAll(), function(ds) { + if (ds.type === 'influxdb' || ds.type === 'elasticsearch') { + $scope.sourceName = ds.name; + $scope.datasources.push(ds.name); + } else if (ds.type === 'grafana') { + $scope.datasources.push(ds.name); + } + }); + }; + + $scope.startImport = function() { + $scope.sourceDs = datasourceSrv.get($scope.sourceName); + $scope.destDs = datasourceSrv.get($scope.destName); + + $scope.sourceDs.searchDashboards('title:').then(function(results) { + $scope.dashboards = results.dashboards; + + if ($scope.dashboards.length === 0) { + $scope.infoText = 'No dashboards found'; + return; + } + + $scope.importing = true; + $scope.imported = []; + $scope.next(); + }); + }; + + $scope.next = function() { + if ($scope.dashboards.length === 0) { + $scope.infoText = "Done! Imported " + $scope.imported.length + " dashboards"; + } + + var dash = $scope.dashboards.shift(); + if (!dash.title) { + console.log(dash); + return; + } + + var infoObj = {name: dash.title, info: 'Importing...'}; + $scope.imported.push(infoObj); + $scope.infoText = "Importing " + $scope.imported.length + '/' + ($scope.imported.length + $scope.dashboards.length); + + $scope.sourceDs.getDashboard(dash.id).then(function(loadedDash) { + $scope.destDs.saveDashboard(loadedDash).then(function() { + infoObj.info = "Done!"; + $scope.next(); + }, function(err) { + infoObj.info = "Error: " + err; + $scope.next(); + }); + }); + }; + + $scope.init(); + + }); +}); diff --git a/src/app/features/account/partials/account.html b/src/app/features/account/partials/account.html new file mode 100644 index 00000000000..9a4d9d3d7c9 --- /dev/null +++ b/src/app/features/account/partials/account.html @@ -0,0 +1,35 @@ + + + +
+ +
+
+
+
+
+
+
    +
  • + Account name +
  • +
  • + +
  • +
+
+
+
+
+ + + +
+ +
+
+
+
+
+ + diff --git a/src/app/features/account/partials/apikeys.html b/src/app/features/account/partials/apikeys.html new file mode 100644 index 00000000000..cc78bf89d33 --- /dev/null +++ b/src/app/features/account/partials/apikeys.html @@ -0,0 +1,55 @@ + + + +
+ +
+
+
+
+
    +
  • + Add a Token +
  • +
  • + +
  • +
  • + Role +
  • +
  • + +
  • + + +
+
+
+
+ +
+
+ + + + + + + +
{{t.name}}{{t.role}}{{t.key}} + + + +
+
+
+ +
+
+ + + diff --git a/src/app/features/account/partials/datasources.html b/src/app/features/account/partials/datasources.html new file mode 100644 index 00000000000..65b52e4c3e0 --- /dev/null +++ b/src/app/features/account/partials/datasources.html @@ -0,0 +1,119 @@ + + + +
+
+
+
+
+
+
+ +
+
+
+
+
+ No datasources defined +
+ + + + + + + + + + + + + + + +
NameUrl
+   + {{ds.name}} + + {{ds.url}} + + + default + + + + + Edit + + + + + +
+
+
+ +
+
+
+ + +
+
+ + +
+ +
+ +
+
+ + +
+
+ + +
+
+ +
+
+
InfluxDB Details
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
Elastic search details
+
+ + +
+
+
+
+ +
+ + + +
+ +
+
diff --git a/src/app/features/account/partials/import.html b/src/app/features/account/partials/import.html new file mode 100644 index 00000000000..28c695ca068 --- /dev/null +++ b/src/app/features/account/partials/import.html @@ -0,0 +1,63 @@ + + + +
+ +
+
+ + Import Dashboards +
+
+ +
+ +
+
+
+
    +
  • + Dashboard source +
  • +
  • + +
  • +
  • + Destination +
  • +
  • + +
  • +
  • + +
  • +
+
+
+
+
+ +
+
+
{{infoText}}
+ +
+ + + + + +
{{dash.name}} + {{dash.info}} +
+
+
+
+
+
+ diff --git a/src/app/features/account/partials/users.html b/src/app/features/account/partials/users.html new file mode 100644 index 00000000000..3d964aaa1e6 --- /dev/null +++ b/src/app/features/account/partials/users.html @@ -0,0 +1,54 @@ + + +
+ +
+ +
+
+
+
+
    +
  • + Username or Email +
  • +
  • + +
  • +
  • + role +
  • +
  • + +
  • +
  • + +
  • +
+
+
+
+
+
+ + +
+ + + + + + +
{{user.email}} + {{user.role}} + + + + +
+
+ +
+
+ diff --git a/src/app/features/admin/adminCtrl.js b/src/app/features/admin/adminCtrl.js new file mode 100644 index 00000000000..bda82618503 --- /dev/null +++ b/src/app/features/admin/adminCtrl.js @@ -0,0 +1,18 @@ +define([ + 'angular', +], +function (angular) { + 'use strict'; + + var module = angular.module('grafana.controllers'); + + module.controller('AdminCtrl', function($scope) { + + $scope.init = function() { + $scope.editor = {index: 0}; + }; + + $scope.init(); + + }); +}); diff --git a/src/app/features/admin/adminSettingsCtrl.js b/src/app/features/admin/adminSettingsCtrl.js new file mode 100644 index 00000000000..736e5ce55d2 --- /dev/null +++ b/src/app/features/admin/adminSettingsCtrl.js @@ -0,0 +1,18 @@ +define([ + 'angular', +], +function (angular) { + 'use strict'; + + var module = angular.module('grafana.controllers'); + + module.controller('AdminSettingsCtrl', function($scope) { + + $scope.init = function() { + + }; + + $scope.init(); + + }); +}); diff --git a/src/app/features/admin/adminUsersCtrl.js b/src/app/features/admin/adminUsersCtrl.js new file mode 100644 index 00000000000..d013465778f --- /dev/null +++ b/src/app/features/admin/adminUsersCtrl.js @@ -0,0 +1,25 @@ +define([ + 'angular', +], +function (angular) { + 'use strict'; + + var module = angular.module('grafana.controllers'); + + module.controller('AdminUsersCtrl', function($scope, backendSrv) { + + $scope.init = function() { + $scope.accounts = []; + $scope.getUsers(); + }; + + $scope.getUsers = function() { + backendSrv.get('/api/admin/users').then(function(users) { + $scope.users = users; + }); + }; + + $scope.init(); + + }); +}); diff --git a/src/app/features/admin/partials/admin.html b/src/app/features/admin/partials/admin.html new file mode 100644 index 00000000000..968d7ba23b6 --- /dev/null +++ b/src/app/features/admin/partials/admin.html @@ -0,0 +1,9 @@ + + + +
+
diff --git a/src/app/features/admin/partials/settings.html b/src/app/features/admin/partials/settings.html new file mode 100644 index 00000000000..bea1f5fd4c7 --- /dev/null +++ b/src/app/features/admin/partials/settings.html @@ -0,0 +1,11 @@ +
+ +
+
+
+
+ +
+
+
+
diff --git a/src/app/features/admin/partials/users.html b/src/app/features/admin/partials/users.html new file mode 100644 index 00000000000..57a4eca5159 --- /dev/null +++ b/src/app/features/admin/partials/users.html @@ -0,0 +1,43 @@ + + + +
+ +
+
+
+ + + + + + + + + + + + + + + + + +
IdLoginEmailNameAdmin
{{user.id}}{{user.login}}{{user.email}}{{user.name}}{{user.isAdmin}} + + + Edit + +    + + + +
+
+
+
+
diff --git a/src/app/features/all.js b/src/app/features/all.js index 55f7f607131..8e72a8221cb 100644 --- a/src/app/features/all.js +++ b/src/app/features/all.js @@ -7,4 +7,14 @@ define([ './opentsdb/datasource', './elasticsearch/datasource', './dashboard/all', + './panel/all', + './profile/profileCtrl', + './account/accountUsersCtrl', + './account/datasourcesCtrl', + './account/apiKeysCtrl', + './account/importCtrl', + './account/accountCtrl', + './admin/adminUsersCtrl', + './admin/adminSettingsCtrl', + './grafanaDatasource/datasource', ], function () {}); diff --git a/src/app/features/annotations/partials/editor.html b/src/app/features/annotations/partials/editor.html index ae2d624edd9..05ea697bc57 100644 --- a/src/app/features/annotations/partials/editor.html +++ b/src/app/features/annotations/partials/editor.html @@ -1,7 +1,7 @@
-
-
+
+
Annotations
@@ -10,10 +10,12 @@
- +
-
+
@@ -71,12 +73,12 @@
+
+ + +
+
+
- -
diff --git a/src/app/features/dashboard/all.js b/src/app/features/dashboard/all.js index 9927c440234..8433ecc54ce 100644 --- a/src/app/features/dashboard/all.js +++ b/src/app/features/dashboard/all.js @@ -9,7 +9,6 @@ define([ './keybindings', './viewStateSrv', './playlistSrv', - './panelSrv', './timeSrv', './unsavedChangesSrv', ], function () {}); diff --git a/src/app/features/dashboard/dashboardCtrl.js b/src/app/features/dashboard/dashboardCtrl.js index c88d6dfa725..fe41dcccf0c 100644 --- a/src/app/features/dashboard/dashboardCtrl.js +++ b/src/app/features/dashboard/dashboardCtrl.js @@ -4,7 +4,7 @@ define([ 'config', 'lodash', ], -function (angular, $, config, _) { +function (angular, $, config) { "use strict"; var module = angular.module('grafana.controllers'); @@ -20,15 +20,15 @@ function (angular, $, config, _) { $timeout) { $scope.editor = { index: 0 }; - $scope.panelNames = _.map(config.panels, function(value, key) { return key; }); + $scope.panels = config.panels; + var resizeEventTimeout; - this.init = function(dashboardData) { - $scope.availablePanels = config.panels; + this.init = function(dashboard) { $scope.reset_row(); $scope.registerWindowResizeEvent(); $scope.onAppEvent('show-json-editor', $scope.showJsonEditor); - $scope.setupDashboard(dashboardData); + $scope.setupDashboard(dashboard); }; $scope.registerWindowResizeEvent = function() { @@ -38,13 +38,14 @@ function (angular, $, config, _) { }); }; - $scope.setupDashboard = function(dashboardData) { + $scope.setupDashboard = function(dashboard) { $rootScope.performance.dashboardLoadStart = new Date().getTime(); $rootScope.performance.panelsInitialized = 0; $rootScope.performance.panelsRendered = 0; - $scope.dashboard = dashboardSrv.create(dashboardData); + $scope.dashboard = dashboardSrv.create(dashboard.model); $scope.dashboardViewState = dashboardViewStateSrv.create($scope); + $scope.dashboardMeta = dashboard.meta; // init services timeSrv.init($scope.dashboard); @@ -60,15 +61,11 @@ function (angular, $, config, _) { $scope.setWindowTitleAndTheme = function() { window.document.title = config.window_title_prefix + $scope.dashboard.title; - $scope.grafana.style = $scope.dashboard.style; + $scope.grafana.lightTheme = $scope.dashboard.style === 'light'; }; - $scope.isPanel = function(obj) { - if(!_.isNull(obj) && !_.isUndefined(obj) && !_.isUndefined(obj.type)) { - return true; - } else { - return false; - } + $scope.styleUpdated = function() { + $scope.grafana.lightTheme = $scope.dashboard.style === 'light'; }; $scope.add_row = function(dash, row) { diff --git a/src/app/features/dashboard/dashboardNavCtrl.js b/src/app/features/dashboard/dashboardNavCtrl.js index 14a914821b1..d2759540c49 100644 --- a/src/app/features/dashboard/dashboardNavCtrl.js +++ b/src/app/features/dashboard/dashboardNavCtrl.js @@ -22,7 +22,6 @@ function (angular, _, moment, config, store) { $scope.onAppEvent('zoom-out', function() { $scope.zoom(2); }); - }; $scope.set_default = function() { @@ -35,17 +34,29 @@ function (angular, _, moment, config, store) { alertSrv.set('Local Default Clear','Your default dashboard has been reset to the default','success', 5000); }; - $scope.saveForSharing = function() { - var clone = angular.copy($scope.dashboard); - clone.temp = true; - $scope.db.saveDashboard(clone) - .then(function(result) { + $scope.openEditView = function(editview) { + var search = _.extend($location.search(), {editview: editview}); + $location.search(search); + }; - $scope.share = { url: result.url, title: result.title }; - - }, function(err) { - alertSrv.set('Save for sharing failed', err, 'error',5000); + $scope.starDashboard = function() { + if ($scope.dashboardMeta.isStarred) { + $scope.db.unstarDashboard($scope.dashboard.id).then(function() { + $scope.dashboardMeta.isStarred = false; }); + } + else { + $scope.db.starDashboard($scope.dashboard.id).then(function() { + $scope.dashboardMeta.isStarred = true; + }); + } + }; + + $scope.shareDashboard = function() { + $scope.appEvent('show-modal', { + src: './app/features/dashboard/partials/shareModal.html', + scope: $scope.$new(), + }); }; $scope.passwordCache = function(pwd) { @@ -105,10 +116,10 @@ function (angular, _, moment, config, store) { }; $scope.deleteDashboardConfirmed = function(options) { - var id = options.id; - $scope.db.deleteDashboard(id).then(function(id) { - $scope.appEvent('dashboard-deleted', id); - $scope.appEvent('alert-success', ['Dashboard Deleted', id + ' has been deleted']); + var slug = options.slug; + $scope.db.deleteDashboard(slug).then(function() { + $scope.appEvent('dashboard-deleted', options); + $scope.appEvent('alert-success', ['Dashboard Deleted', options.title + ' has been deleted']); }, function(err) { $scope.appEvent('alert-error', ['Deleted failed', err]); }); @@ -140,10 +151,6 @@ function (angular, _, moment, config, store) { }); }; - $scope.styleUpdated = function() { - $scope.grafana.style = $scope.dashboard.style; - }; - $scope.editJson = function() { $scope.appEvent('show-json-editor', { object: $scope.dashboard }); }; diff --git a/src/app/features/dashboard/partials/shareModal.html b/src/app/features/dashboard/partials/shareModal.html new file mode 100644 index 00000000000..478ae3a1151 --- /dev/null +++ b/src/app/features/dashboard/partials/shareModal.html @@ -0,0 +1,35 @@ + + diff --git a/src/app/features/dashboard/rowCtrl.js b/src/app/features/dashboard/rowCtrl.js index dfd602751fb..bf10d9842a3 100644 --- a/src/app/features/dashboard/rowCtrl.js +++ b/src/app/features/dashboard/rowCtrl.js @@ -1,9 +1,10 @@ define([ 'angular', 'app', - 'lodash' + 'lodash', + 'config' ], -function (angular, app, _) { +function (angular, app, _, config) { 'use strict'; var module = angular.module('grafana.controllers'); @@ -107,11 +108,11 @@ function (angular, app, _) { var _as = 12 - $scope.dashboard.rowSpan($scope.row); $scope.panel = { - title: 'no title (click here)', - error : false, - span : _as < defaultSpan && _as > 0 ? _as : defaultSpan, + title: config.new_panel_title, + error: false, + span: _as < defaultSpan && _as > 0 ? _as : defaultSpan, editable: true, - type : type + type: type }; function fixRowHeight(height) { diff --git a/src/app/features/dashboard/sharePanelCtrl.js b/src/app/features/dashboard/sharePanelCtrl.js index 39909b20e19..6fe57330924 100644 --- a/src/app/features/dashboard/sharePanelCtrl.js +++ b/src/app/features/dashboard/sharePanelCtrl.js @@ -12,9 +12,12 @@ function (angular, _) { $scope.init = function() { $scope.editor = { index: 0 }; $scope.forCurrent = true; - $scope.toPanel = true; - $scope.includeTemplateVars = true; + if ($scope.panel) { + $scope.toPanel = true; + } + + $scope.includeTemplateVars = true; $scope.buildUrl(); }; @@ -26,7 +29,6 @@ function (angular, _) { baseUrl = baseUrl.substring(0, queryStart); } - var panelId = $scope.panel.id; var params = angular.copy($location.search()); var range = timeSrv.timeRangeForUrl(); @@ -50,7 +52,7 @@ function (angular, _) { } if ($scope.toPanel) { - params.panelId = panelId; + params.panelId = $scope.panel.id; params.fullscreen = true; } else { delete params.panelId; @@ -68,7 +70,10 @@ function (angular, _) { } }); - $scope.shareUrl = baseUrl + "?" + paramsArray.join('&') ; + $scope.shareUrl = baseUrl + "?" + paramsArray.join('&'); + $scope.imageUrl = $scope.shareUrl.replace('/dashboard/db/', '/render/dashboard/solo/'); + $scope.imageUrl += '&width=1000'; + $scope.imageUrl += '&height=500'; $timeout(function() { var input = $element.find('[data-share-panel-url]'); diff --git a/src/app/features/dashboard/unsavedChangesSrv.js b/src/app/features/dashboard/unsavedChangesSrv.js index 9b8d2eea198..f1782a35ea3 100644 --- a/src/app/features/dashboard/unsavedChangesSrv.js +++ b/src/app/features/dashboard/unsavedChangesSrv.js @@ -60,6 +60,7 @@ function(angular, _, config) { this.open_modal = function() { var confirmModal = $modal({ template: './app/partials/unsaved-changes.html', + modalClass: 'confirm-modal', persist: true, show: false, scope: modalScope, diff --git a/src/app/features/dashboard/viewStateSrv.js b/src/app/features/dashboard/viewStateSrv.js index c08d3cdba6b..289701c05fb 100644 --- a/src/app/features/dashboard/viewStateSrv.js +++ b/src/app/features/dashboard/viewStateSrv.js @@ -53,12 +53,13 @@ function (angular, _, $) { state.panelId = parseInt(state.panelId) || null; state.fullscreen = state.fullscreen ? true : null; state.edit = (state.edit === "true" || state.edit === true) || null; + state.editview = state.editview || null; return state; }; DashboardViewState.prototype.serializeToUrl = function() { var urlState = _.clone(this.state); - urlState.fullscreen = this.state.fullscreen ? true : null, + urlState.fullscreen = this.state.fullscreen ? true : null; urlState.edit = this.state.edit ? true : null; return urlState; }; @@ -117,7 +118,7 @@ function (angular, _, $) { self.$scope.dashboard.emit_refresh(); } else { - self.fullscreenPanel.$emit('render'); + self.fullscreenPanel.$broadcast('render'); } delete self.fullscreenPanel; }); @@ -138,7 +139,7 @@ function (angular, _, $) { panelScope.fullscreen = true; $timeout(function() { - panelScope.$emit('render'); + panelScope.$broadcast('render'); }); }; diff --git a/src/app/features/elasticsearch/datasource.js b/src/app/features/elasticsearch/datasource.js index 3c82d98f8d2..0ca8ec853f1 100644 --- a/src/app/features/elasticsearch/datasource.js +++ b/src/app/features/elasticsearch/datasource.js @@ -13,7 +13,7 @@ function (angular, _, config, kbn, moment) { module.factory('ElasticDatasource', function($q, $http, templateSrv) { function ElasticDatasource(datasource) { - this.type = 'elastic'; + this.type = 'elasticsearch'; this.basicAuth = datasource.basicAuth; this.url = datasource.url; this.name = datasource.name; diff --git a/src/app/features/grafanaDatasource/datasource.js b/src/app/features/grafanaDatasource/datasource.js new file mode 100644 index 00000000000..5ab1118a4ce --- /dev/null +++ b/src/app/features/grafanaDatasource/datasource.js @@ -0,0 +1,78 @@ +define([ + 'angular', + 'lodash', + 'kbn', +], +function (angular, _, kbn) { + 'use strict'; + + var module = angular.module('grafana.services'); + + module.factory('GrafanaDatasource', function($q, backendSrv) { + + function GrafanaDatasource() { + this.type = 'grafana'; + this.grafanaDB = true; + this.name = "grafana"; + this.supportMetrics = true; + this.editorSrc = 'app/features/grafanaDatasource/partials/query.editor.html'; + } + + GrafanaDatasource.prototype.getDashboard = function(slug, isTemp) { + var url = '/dashboards/' + slug; + + if (isTemp) { + url = '/temp/' + slug; + } + + return backendSrv.get('/api/dashboards/db/' + slug); + }; + + GrafanaDatasource.prototype.query = function(options) { + // get from & to in seconds + var from = kbn.parseDate(options.range.from).getTime(); + var to = kbn.parseDate(options.range.to).getTime(); + + return backendSrv.get('/api/metrics/test', { from: from, to: to, maxDataPoints: options.maxDataPoints }); + }; + + GrafanaDatasource.prototype.starDashboard = function(dashId) { + return backendSrv.post('/api/user/stars/dashboard/' + dashId); + }; + + GrafanaDatasource.prototype.unstarDashboard = function(dashId) { + return backendSrv.delete('/api/user/stars/dashboard/' + dashId); + }; + + GrafanaDatasource.prototype.saveDashboard = function(dashboard) { + // remove id if title has changed + if (dashboard.title !== dashboard.originalTitle) { + dashboard.id = null; + } + + return backendSrv.post('/api/dashboards/db/', { dashboard: dashboard }) + .then(function(data) { + return { title: dashboard.title, url: '/dashboard/db/' + data.slug }; + }, function(err) { + err.isHandled = true; + err.data = err.data || {}; + throw err.data.message || "Unknown error"; + }); + }; + + GrafanaDatasource.prototype.deleteDashboard = function(id) { + return backendSrv.delete('/api/dashboards/db/' + id); + }; + + GrafanaDatasource.prototype.searchDashboards = function(query) { + return backendSrv.get('/api/search/', {q: query}) + .then(function(data) { + return data; + }); + }; + + return GrafanaDatasource; + + }); + +}); diff --git a/src/app/features/grafanaDatasource/partials/query.editor.html b/src/app/features/grafanaDatasource/partials/query.editor.html new file mode 100644 index 00000000000..0d6d21d0ad2 --- /dev/null +++ b/src/app/features/grafanaDatasource/partials/query.editor.html @@ -0,0 +1,17 @@ + +
+
+
+
Test graph
+ +

+ This is just a test data source that generates random walk series. If this is your only data source + open the left side menu and navigate to the data sources admin screen and add your data sources. You can change + data source using the button to the left of the Add query button. +

+
+
+ +
+
+ diff --git a/src/app/features/influxdb/datasource.js b/src/app/features/influxdb/datasource.js index e1acd1632e7..c867fb2a4a5 100644 --- a/src/app/features/influxdb/datasource.js +++ b/src/app/features/influxdb/datasource.js @@ -15,7 +15,7 @@ function (angular, _, kbn, InfluxSeries, InfluxQueryBuilder) { module.factory('InfluxDatasource', function($q, $http, templateSrv) { function InfluxDatasource(datasource) { - this.type = 'influxDB'; + this.type = 'influxdb'; this.urls = datasource.urls; this.username = datasource.username; this.password = datasource.password; diff --git a/src/app/features/panel/all.js b/src/app/features/panel/all.js new file mode 100644 index 00000000000..31a91294964 --- /dev/null +++ b/src/app/features/panel/all.js @@ -0,0 +1,6 @@ +define([ + './panelMenu', + './panelDirective', + './panelSrv', + './soloPanelCtrl', +], function () {}); diff --git a/src/app/features/panel/panelDirective.js b/src/app/features/panel/panelDirective.js new file mode 100644 index 00000000000..f8048d53637 --- /dev/null +++ b/src/app/features/panel/panelDirective.js @@ -0,0 +1,40 @@ +define([ + 'angular', + 'jquery', + 'config', +], +function (angular, $, config) { + 'use strict'; + + angular + .module('grafana.directives') + .directive('panelLoader', function($compile, $parse) { + return { + restrict: 'E', + link: function(scope, elem, attr) { + var getter = $parse(attr.type), panelType = getter(scope); + var panelPath = config.panels[panelType].path; + + scope.require([panelPath + "/module"], function () { + var panelEl = angular.element(document.createElement('grafana-panel-' + panelType)); + elem.append(panelEl); + $compile(panelEl)(scope); + }); + } + }; + }).directive('grafanaPanel', function() { + return { + restrict: 'E', + templateUrl: 'app/features/panel/partials/panel.html', + transclude: true, + link: function(scope, elem) { + var panelContainer = elem.find('.panel-container'); + + scope.$watchGroup(['fullscreen', 'panel.height', 'row.height'], function() { + panelContainer.css({ minHeight: scope.panel.height || scope.row.height, display: 'block' }); + elem.toggleClass('panel-fullscreen', scope.fullscreen ? true : false); + }); + } + }; + }); +}); diff --git a/src/app/directives/panelMenu.js b/src/app/features/panel/panelMenu.js similarity index 100% rename from src/app/directives/panelMenu.js rename to src/app/features/panel/panelMenu.js diff --git a/src/app/features/dashboard/panelSrv.js b/src/app/features/panel/panelSrv.js similarity index 93% rename from src/app/features/dashboard/panelSrv.js rename to src/app/features/panel/panelSrv.js index 3cc945624b3..88f53e5edc6 100644 --- a/src/app/features/dashboard/panelSrv.js +++ b/src/app/features/panel/panelSrv.js @@ -1,8 +1,9 @@ define([ 'angular', 'lodash', + 'config', ], -function (angular, _) { +function (angular, _, config) { 'use strict'; var module = angular.module('grafana.services'); @@ -24,7 +25,7 @@ function (angular, _) { $scope.sharePanel = function() { $scope.appEvent('show-modal', { - src: './app/partials/share-panel.html', + src: './app/features/dashboard/partials/shareModal.html', scope: $scope.$new() }); }; @@ -77,6 +78,10 @@ function (angular, _) { $scope.editorHelpIndex = index; }; + $scope.isNewPanel = function() { + return $scope.panel.title === config.new_panel_title; + }; + $scope.toggleFullscreen = function(edit) { $scope.dashboardViewState.update({ fullscreen: true, edit: edit, panelId: $scope.panel.id }); }; diff --git a/src/app/features/panel/partials/panel.html b/src/app/features/panel/partials/panel.html new file mode 100644 index 00000000000..a89e586b492 --- /dev/null +++ b/src/app/features/panel/partials/panel.html @@ -0,0 +1,42 @@ +
+
+ + + + + + + + + + +
+
+ +
+ +
+
+ +
+
+
+
+ + {{panelMeta.panelName}} +
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+ diff --git a/src/app/features/dashboard/partials/panelTime.html b/src/app/features/panel/partials/panelTime.html similarity index 100% rename from src/app/features/dashboard/partials/panelTime.html rename to src/app/features/panel/partials/panelTime.html diff --git a/src/app/features/panel/partials/soloPanel.html b/src/app/features/panel/partials/soloPanel.html new file mode 100644 index 00000000000..b040bf8dc72 --- /dev/null +++ b/src/app/features/panel/partials/soloPanel.html @@ -0,0 +1,8 @@ +
+
+
+
+ +
+
+
diff --git a/src/app/features/panel/soloPanelCtrl.js b/src/app/features/panel/soloPanelCtrl.js new file mode 100644 index 00000000000..a9c95af56c6 --- /dev/null +++ b/src/app/features/panel/soloPanelCtrl.js @@ -0,0 +1,70 @@ +define([ + 'angular', + 'jquery', +], +function (angular, $) { + "use strict"; + + var module = angular.module('grafana.routes'); + + module.controller('SoloPanelCtrl', function($scope, $rootScope, datasourceSrv, $routeParams, dashboardSrv, timeSrv, $location) { + var panelId; + + $scope.init = function() { + var db = datasourceSrv.getGrafanaDB(); + var params = $location.search(); + panelId = parseInt(params.panelId); + + db.getDashboard($routeParams.id, false) + .then(function(dashboard) { + $scope.initPanelScope(dashboard); + }).then(null, function(error) { + $scope.appEvent('alert-error', ['Load panel error', error]); + }); + }; + + $scope.initPanelScope = function(dashboard) { + $scope.dashboard = dashboardSrv.create(dashboard.model); + $scope.grafana.style = $scope.dashboard.style; + $scope.row = { + height: $(window).height() + 'px', + }; + $scope.test = "Hej"; + $scope.$index = 0; + $scope.panel = $scope.getPanelById(panelId); + + if (!$scope.panel) { + $scope.appEvent('alert-error', ['Panel not found', '']); + return; + } + + $scope.panel.span = 12; + $scope.dashboardViewState = { + registerPanel: function() { + } + }; + + timeSrv.init($scope.dashboard); + }; + + $scope.getPanelById = function(id) { + var rows = $scope.dashboard.rows; + for (var i = 0; i < rows.length; i++) { + var row = rows[i]; + for (var j = 0; j < row.panels.length; j++) { + var panel = row.panels[j]; + if (panel.id === id) { + return panel; + } + } + } + return null; + }; + + if (!$scope.skipAutoInit) { + $scope.init(); + } + + }); + +}); diff --git a/src/app/features/profile/partials/profile.html b/src/app/features/profile/partials/profile.html new file mode 100644 index 00000000000..87d49af0bcf --- /dev/null +++ b/src/app/features/profile/partials/profile.html @@ -0,0 +1,127 @@ + + + + +
+
+ +
+
+
+ + Personal information +
+
+ +
+
+
+
+
+
    +
  • + Name +
  • +
  • + +
  • +
+
+
+
+
    +
  • + Email +
  • +
  • + +
  • +
+
+
+
+
    +
  • + Username +
  • +
  • + +
  • +
+
+
+
+ +
+ +
+
+
+
+
+ +
+ +
+
+
+ + Your accounts +
+
+
+ + + + + + + +
Name: {{ac.name}}Role: {{ac.role}} + + active now + + + + Select + +
+
+
+
+ +
+
+
+
+ + Add account +
+
+
+
+
+
    +
  • + Account name +
  • +
  • + +
  • +
  • + +
  • +
+
+
+
+
+
+
+
+ + diff --git a/src/app/features/profile/profileCtrl.js b/src/app/features/profile/profileCtrl.js new file mode 100644 index 00000000000..b028e48be1d --- /dev/null +++ b/src/app/features/profile/profileCtrl.js @@ -0,0 +1,46 @@ +define([ + 'angular', +], +function (angular) { + 'use strict'; + + var module = angular.module('grafana.controllers'); + + module.controller('ProfileCtrl', function($scope, $http, backendSrv) { + + $scope.newAccount = {name: ''}; + + $scope.init = function() { + $scope.getUser(); + $scope.getUserAccounts(); + }; + + $scope.getUser = function() { + backendSrv.get('/api/user').then(function(user) { + $scope.user = user; + }); + }; + + $scope.getUserAccounts = function() { + backendSrv.get('/api/user/accounts').then(function(accounts) { + $scope.accounts = accounts; + }); + }; + + $scope.setUsingAccount = function(account) { + backendSrv.post('/api/user/using/' + account.accountId).then($scope.getUserAccounts); + }; + + $scope.update = function() { + if (!$scope.userForm.$valid) { return; } + backendSrv.put('/api/user/', $scope.user); + }; + + $scope.createAccount = function() { + backendSrv.post('/api/account/', $scope.newAccount).then($scope.getUserAccounts); + }; + + $scope.init(); + + }); +}); diff --git a/src/app/panels/dashlist/editor.html b/src/app/panels/dashlist/editor.html new file mode 100644 index 00000000000..87a00132827 --- /dev/null +++ b/src/app/panels/dashlist/editor.html @@ -0,0 +1,41 @@ + +
+
+
+
    +
  • + Mode +
  • +
  • + +
  • +
+
+
+
+
+
+
    +
  • + Search options +
  • +
  • + Query +
  • +
  • + +
  • +
  • + Tag +
  • +
  • + +
  • +
+
+
+
+
+ diff --git a/src/app/panels/dashlist/module.html b/src/app/panels/dashlist/module.html new file mode 100644 index 00000000000..c02e993984a --- /dev/null +++ b/src/app/panels/dashlist/module.html @@ -0,0 +1,13 @@ + +
+ + diff --git a/src/app/panels/dashlist/module.js b/src/app/panels/dashlist/module.js new file mode 100644 index 00000000000..7dd3aebcc4e --- /dev/null +++ b/src/app/panels/dashlist/module.js @@ -0,0 +1,69 @@ +define([ + 'angular', + 'app', + 'lodash', + 'config', + 'components/panelmeta', +], +function (angular, app, _, config, PanelMeta) { + 'use strict'; + + var module = angular.module('grafana.panels.dashlist', []); + app.useModule(module); + + module.directive('grafanaPanelDashlist', function() { + return { + controller: 'DashListPanelCtrl', + templateUrl: 'app/panels/dashlist/module.html', + }; + }); + + module.controller('DashListPanelCtrl', function($scope, panelSrv, backendSrv) { + + $scope.panelMeta = new PanelMeta({ + panelName: 'Dash list', + editIcon: "fa fa-star", + fullscreen: true, + }); + + $scope.panelMeta.addEditorTab('Options', 'app/panels/dashlist/editor.html'); + + var defaults = { + mode: 'starred', + query: '', + tag: '', + }; + + $scope.modes = ['starred', 'search']; + + _.defaults($scope.panel, defaults); + + $scope.dashList = []; + + $scope.init = function() { + panelSrv.init($scope); + + if ($scope.isNewPanel()) { + $scope.panel.title = "Starred Dashboards"; + } + + $scope.$on('refresh', $scope.get_data); + }; + + $scope.get_data = function() { + var params = {}; + if ($scope.panel.mode === 'starred') { + params.starred = 1; + } else { + params.q = "tags:" + $scope.panel.tag + " AND title:" + $scope.panel.query; + } + + backendSrv.get('/api/search', params).then(function(result) { + $scope.dashList = result.dashboards; + $scope.panelMeta.loading = false; + }); + }; + + $scope.init(); + }); +}); diff --git a/src/app/panels/graph/module.html b/src/app/panels/graph/module.html index 20896fc5c83..09f2b4dbb59 100644 --- a/src/app/panels/graph/module.html +++ b/src/app/panels/graph/module.html @@ -1,4 +1,4 @@ -
+
@@ -24,23 +24,6 @@
-
-
-
- - Graph -
+ -
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/app/panels/graph/module.js b/src/app/panels/graph/module.js index 56a15a7b72e..0361633a9f7 100644 --- a/src/app/panels/graph/module.js +++ b/src/app/panels/graph/module.js @@ -16,17 +16,25 @@ function (angular, app, $, _, kbn, moment, TimeSeries, PanelMeta) { var module = angular.module('grafana.panels.graph'); + module.directive('grafanaPanelGraph', function() { + return { + controller: 'GraphCtrl', + templateUrl: 'app/panels/graph/module.html', + }; + }); + module.controller('GraphCtrl', function($scope, $rootScope, panelSrv, annotationsSrv, timeSrv) { $scope.panelMeta = new PanelMeta({ - description: 'Graph panel', + panelName: 'Graph', + editIcon: "fa fa-bar-chart", fullscreen: true, metricsEditor: true }); $scope.panelMeta.addEditorTab('Axes & Grid', 'app/panels/graph/axisEditor.html'); $scope.panelMeta.addEditorTab('Display Styles', 'app/panels/graph/styleEditor.html'); - $scope.panelMeta.addEditorTab('Time range', 'app/features/dashboard/partials/panelTime.html'); + $scope.panelMeta.addEditorTab('Time range', 'app/features/panel/partials/panelTime.html'); $scope.panelMeta.addExtendedMenuItem('Export CSV', '', 'exportCsv()'); $scope.panelMeta.addExtendedMenuItem('Toggle legend', '', 'toggleLegend()'); diff --git a/src/app/panels/singlestat/module.html b/src/app/panels/singlestat/module.html index 15711ea2adb..7b5d6a267bb 100644 --- a/src/app/panels/singlestat/module.html +++ b/src/app/panels/singlestat/module.html @@ -1,26 +1,4 @@ -
- +
-
- -
-
-
- - Singlestat -
- -
-
-
-
-
- -
-
-
-
-
-
-
+ diff --git a/src/app/panels/singlestat/module.js b/src/app/panels/singlestat/module.js index 75e657094ef..56072bf4f73 100644 --- a/src/app/panels/singlestat/module.js +++ b/src/app/panels/singlestat/module.js @@ -13,10 +13,18 @@ function (angular, app, _, TimeSeries, kbn, PanelMeta) { var module = angular.module('grafana.panels.singlestat'); app.useModule(module); + module.directive('grafanaPanelSinglestat', function() { + return { + controller: 'SingleStatCtrl', + templateUrl: 'app/panels/singlestat/module.html', + }; + }); + module.controller('SingleStatCtrl', function($scope, panelSrv, timeSrv) { $scope.panelMeta = new PanelMeta({ - description: 'Singlestat panel', + panelName: 'Singlestat', + editIcon: "fa fa-dashboard", fullscreen: true, metricsEditor: true }); @@ -192,7 +200,7 @@ function (angular, app, _, TimeSeries, kbn, PanelMeta) { data.colorMap = $scope.panel.colors; $scope.data = data; - $scope.$emit('render'); + $scope.$broadcast('render'); }; $scope.getFormatedValue = function(mainValue) { diff --git a/src/app/panels/text/module.html b/src/app/panels/text/module.html index a184b8283d8..5a933d5acc3 100644 --- a/src/app/panels/text/module.html +++ b/src/app/panels/text/module.html @@ -1,4 +1,3 @@ -
-

-

-
+ +

+
diff --git a/src/app/panels/text/module.js b/src/app/panels/text/module.js index ceb6bcf745c..a2af73654b2 100644 --- a/src/app/panels/text/module.js +++ b/src/app/panels/text/module.js @@ -8,15 +8,24 @@ define([ function (angular, app, _, require, PanelMeta) { 'use strict'; + var converter; + var module = angular.module('grafana.panels.text', []); app.useModule(module); - var converter; + module.directive('grafanaPanelText', function() { + return { + controller: 'TextPanelCtrl', + templateUrl: 'app/panels/text/module.html', + }; + }); - module.controller('text', function($scope, templateSrv, $sce, panelSrv) { + module.controller('TextPanelCtrl', function($scope, templateSrv, $sce, panelSrv) { $scope.panelMeta = new PanelMeta({ - description : "A static text panel that can use plain text, markdown, or (sanitized) HTML" + panelName: 'Text', + editIcon: "fa fa-text-width", + fullscreen: true, }); $scope.panelMeta.addEditorTab('Edit text', 'app/panels/text/editor.html'); diff --git a/src/app/panels/timepicker/custom.html b/src/app/panels/timepicker/custom.html index a762597eaf2..d6eba698b33 100644 --- a/src/app/panels/timepicker/custom.html +++ b/src/app/panels/timepicker/custom.html @@ -1,11 +1,14 @@ -
-
+
+
Custom time range
+
-
+