diff --git a/CHANGELOG.md b/CHANGELOG.md index e97ab145c61..afdf52c47c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ * **Sessions**: Support for memcached as session storage, closes [#3458](https://github.com/grafana/grafana/pull/3458) * **mysql**: Grafana now supports ssl for mysql, closes [#3584](https://github.com/grafana/grafana/pull/3584) * **snapshot**: Annotations are now included in snapshots, closes [#3635](https://github.com/grafana/grafana/pull/3635) +* **Admin**: Admin can now have global overview of Grafana setup, closes [#3812](https://github.com/grafana/grafana/issues/3812) ### Bug fixes * **Playlist**: Fix for memory leak when running a playlist, closes [#3794](https://github.com/grafana/grafana/pull/3794) diff --git a/circle.yml b/circle.yml index a3280ae18e5..22dab8ab893 100644 --- a/circle.yml +++ b/circle.yml @@ -27,3 +27,10 @@ test: # js tests - ./node_modules/grunt-cli/bin/grunt test - npm run coveralls + +deployment: + master: + branch: master + owner: grafana + commands: + - ./trigger_grafana_packer.sh ${TRIGGER_GRAFANA_PACKER_CIRCLECI_TOKEN} diff --git a/docs/sources/reference/http_api.md b/docs/sources/reference/http_api.md index 7cdd8a872d5..4b0dff6c4b6 100644 --- a/docs/sources/reference/http_api.md +++ b/docs/sources/reference/http_api.md @@ -1422,6 +1422,34 @@ Keys: } } +### Grafana Stats + +`GET /api/admin/stats` + +**Example Request**: + + GET /api/admin/stats + Accept: application/json + Content-Type: application/json + Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk + +**Example Response**: + + HTTP/1.1 200 + Content-Type: application/json + + { + "user_count":2, + "org_count":1, + "dashboard_count":4, + "db_snapshot_count":2, + "db_tag_count":6, + "data_source_count":1, + "playlist_count":1, + "starred_db_count":2, + "grafana_admin_count":2 + } + ### Global Users `POST /api/admin/users` diff --git a/pkg/api/admin_settings.go b/pkg/api/admin.go similarity index 66% rename from pkg/api/admin_settings.go rename to pkg/api/admin.go index 1f800cfe558..d7f5a240416 100644 --- a/pkg/api/admin_settings.go +++ b/pkg/api/admin.go @@ -3,7 +3,9 @@ package api import ( "strings" + "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/middleware" + m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" ) @@ -27,3 +29,15 @@ func AdminGetSettings(c *middleware.Context) { c.JSON(200, settings) } + +func AdminGetStats(c *middleware.Context) { + + statsQuery := m.GetAdminStatsQuery{} + + if err := bus.Dispatch(&statsQuery); err != nil { + c.JsonApiErr(500, "Failed to get admin stats from database", err) + return + } + + c.JSON(200, statsQuery.Result) +} diff --git a/pkg/api/api.go b/pkg/api/api.go index 8bbb64eb1b2..99ab8a51ff2 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -40,6 +40,7 @@ func Register(r *macaron.Macaron) { r.Get("/admin/users/edit/:id", reqGrafanaAdmin, Index) r.Get("/admin/orgs", reqGrafanaAdmin, Index) r.Get("/admin/orgs/edit/:id", reqGrafanaAdmin, Index) + r.Get("/admin/stats", reqGrafanaAdmin, Index) r.Get("/apps", reqSignedIn, Index) r.Get("/apps/edit/*", reqSignedIn, Index) @@ -210,6 +211,7 @@ func Register(r *macaron.Macaron) { r.Delete("/users/:id", AdminDeleteUser) r.Get("/users/:id/quotas", wrap(GetUserQuotas)) r.Put("/users/:id/quotas/:target", bind(m.UpdateUserQuotaCmd{}), wrap(UpdateUserQuota)) + r.Get("/stats", AdminGetStats) }, reqGrafanaAdmin) // rendering diff --git a/pkg/models/stats.go b/pkg/models/stats.go index 30c09deb768..63f946b956b 100644 --- a/pkg/models/stats.go +++ b/pkg/models/stats.go @@ -19,3 +19,19 @@ type GetSystemStatsQuery struct { type GetDataSourceStatsQuery struct { Result []*DataSourceStats } + +type AdminStats struct { + UserCount int `json:"user_count"` + OrgCount int `json:"org_count"` + DashboardCount int `json:"dashboard_count"` + DbSnapshotCount int `json:"db_snapshot_count"` + DbTagCount int `json:"db_tag_count"` + DataSourceCount int `json:"data_source_count"` + PlaylistCount int `json:"playlist_count"` + StarredDbCount int `json:"starred_db_count"` + GrafanaAdminCount int `json:"grafana_admin_count"` +} + +type GetAdminStatsQuery struct { + Result *AdminStats +} diff --git a/pkg/services/sqlstore/stats.go b/pkg/services/sqlstore/stats.go index 6c5dfaea906..fbd22ac0f5c 100644 --- a/pkg/services/sqlstore/stats.go +++ b/pkg/services/sqlstore/stats.go @@ -8,6 +8,7 @@ import ( func init() { bus.AddHandler("sql", GetSystemStats) bus.AddHandler("sql", GetDataSourceStats) + bus.AddHandler("sql", GetAdminStats) } func GetDataSourceStats(query *m.GetDataSourceStatsQuery) error { @@ -50,3 +51,54 @@ func GetSystemStats(query *m.GetSystemStatsQuery) error { query.Result = &stats return err } + +func GetAdminStats(query *m.GetAdminStatsQuery) error { + var rawSql = `SELECT + ( + SELECT COUNT(*) + FROM ` + dialect.Quote("user") + ` + ) AS user_count, + ( + SELECT COUNT(*) + FROM ` + dialect.Quote("org") + ` + ) AS org_count, + ( + SELECT COUNT(*) + FROM ` + dialect.Quote("dashboard") + ` + ) AS dashboard_count, + ( + SELECT COUNT(*) + FROM ` + dialect.Quote("dashboard_snapshot") + ` + ) AS db_snapshot_count, + ( + SELECT COUNT( DISTINCT ( ` + dialect.Quote("term") + ` )) + FROM ` + dialect.Quote("dashboard_tag") + ` + ) AS db_tag_count, + ( + SELECT COUNT(*) + FROM ` + dialect.Quote("data_source") + ` + ) AS data_source_count, + ( + SELECT COUNT(*) + FROM ` + dialect.Quote("playlist") + ` + ) AS playlist_count, + ( + SELECT COUNT (DISTINCT ` + dialect.Quote("dashboard_id") + ` ) + FROM ` + dialect.Quote("star") + ` + ) AS starred_db_count, + ( + SELECT COUNT(*) + FROM ` + dialect.Quote("user") + ` + WHERE ` + dialect.Quote("is_admin") + ` = 1 + ) AS grafana_admin_count + ` + + var stats m.AdminStats + _, err := x.Sql(rawSql).Get(&stats) + if err != nil { + return err + } + + query.Result = &stats + return err +} diff --git a/public/app/core/components/sidemenu/sidemenu.ts b/public/app/core/components/sidemenu/sidemenu.ts index d2a640c1345..8f0c57bfade 100644 --- a/public/app/core/components/sidemenu/sidemenu.ts +++ b/public/app/core/components/sidemenu/sidemenu.ts @@ -107,6 +107,12 @@ export class SideMenuCtrl { url: this.getUrl("/admin/settings"), }); + this.mainLinks.push({ + text: "Grafana stats", + icon: "fa fa-fw fa-bar-chart", + url: this.getUrl("/admin/stats"), + }); + this.mainLinks.push({ text: "Global Users", icon: "fa fa-fw fa-user", @@ -118,6 +124,7 @@ export class SideMenuCtrl { icon: "fa fa-fw fa-users", url: this.getUrl("/admin/orgs"), }); + } updateMenu() { diff --git a/public/app/core/routes/all.js b/public/app/core/routes/all.js index cc4d73ef708..d9726ee782c 100644 --- a/public/app/core/routes/all.js +++ b/public/app/core/routes/all.js @@ -112,6 +112,11 @@ define([ templateUrl: 'app/features/admin/partials/edit_org.html', controller : 'AdminEditOrgCtrl', }) + .when('/admin/stats', { + templateUrl: 'app/features/admin/partials/stats.html', + controller : 'AdminStatsCtrl', + controllerAs: 'ctrl', + }) .when('/login', { templateUrl: 'app/partials/login.html', controller : 'LoginCtrl', diff --git a/public/app/features/admin/adminStatsCtrl.ts b/public/app/features/admin/adminStatsCtrl.ts new file mode 100644 index 00000000000..aa3ed6de343 --- /dev/null +++ b/public/app/features/admin/adminStatsCtrl.ts @@ -0,0 +1,18 @@ +/// + +import angular from 'angular'; + +export class AdminStatsCtrl { + stats: any; + + /** @ngInject */ + constructor(private backendSrv: any) {} + + init() { + this.backendSrv.get('/api/admin/stats').then(stats => { + this.stats = stats; + }); + } +} + +angular.module('grafana.controllers').controller('AdminStatsCtrl', AdminStatsCtrl); diff --git a/public/app/features/admin/all.js b/public/app/features/admin/all.js index 14bff249b0e..786210f064f 100644 --- a/public/app/features/admin/all.js +++ b/public/app/features/admin/all.js @@ -4,4 +4,5 @@ define([ './adminEditOrgCtrl', './adminEditUserCtrl', './adminSettingsCtrl', + './adminStatsCtrl', ], function () {}); diff --git a/public/app/features/admin/partials/stats.html b/public/app/features/admin/partials/stats.html new file mode 100644 index 00000000000..4949e71e441 --- /dev/null +++ b/public/app/features/admin/partials/stats.html @@ -0,0 +1,60 @@ + + + + +
+
+

+ Overview +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameValue
Total dashboards{{ctrl.stats.dashboard_count}}
Total users{{ctrl.stats.user_count}}
Total grafana admins{{ctrl.stats.grafana_admin_count}}
Total organizations{{ctrl.stats.org_count}}
Total datasources{{ctrl.stats.data_source_count}}
Total playlists{{ctrl.stats.playlist_count}}
Total snapshots{{ctrl.stats.db_snapshot_count}}
Total dashboard tags{{ctrl.stats.db_tag_count}}
Total starred dashboards{{ctrl.stats.starred_db_count}}
+
+
diff --git a/public/app/plugins/datasource/opentsdb/datasource.js b/public/app/plugins/datasource/opentsdb/datasource.js index d7cc354861f..f4680038e8d 100644 --- a/public/app/plugins/datasource/opentsdb/datasource.js +++ b/public/app/plugins/datasource/opentsdb/datasource.js @@ -72,6 +72,9 @@ function (angular, _, dateMath) { data: reqBody }; + // In case the backend is 3rd-party hosted and does not suport OPTIONS, urlencoded requests + // go as POST rather than OPTIONS+POST + options.headers = { 'Content-Type': 'application/x-www-form-urlencoded' }; return backendSrv.datasourceRequest(options); }; diff --git a/public/app/plugins/datasource/opentsdb/specs/datasource-specs.ts b/public/app/plugins/datasource/opentsdb/specs/datasource-specs.ts index b1ad1b93737..6f8b8917588 100644 --- a/public/app/plugins/datasource/opentsdb/specs/datasource-specs.ts +++ b/public/app/plugins/datasource/opentsdb/specs/datasource-specs.ts @@ -36,14 +36,14 @@ describe('opentsdb', function() { expect(requestOptions.params.q).to.be('pew'); }); - it('tag_names(cpu) should generate looku query', function() { + it('tag_names(cpu) should generate lookup query', function() { ctx.ds.metricFindQuery('tag_names(cpu)').then(function(data) { results = data; }); ctx.$rootScope.$apply(); expect(requestOptions.url).to.be('/api/search/lookup'); expect(requestOptions.params.m).to.be('cpu'); }); - it('tag_values(cpu, test) should generate looku query', function() { + it('tag_values(cpu, test) should generate lookup query', function() { ctx.ds.metricFindQuery('tag_values(cpu, hostname)').then(function(data) { results = data; }); ctx.$rootScope.$apply(); expect(requestOptions.url).to.be('/api/search/lookup'); diff --git a/public/app/plugins/panel/graph/graph.js b/public/app/plugins/panel/graph/graph.js index 1ebf75d9e73..257ddb875b1 100755 --- a/public/app/plugins/panel/graph/graph.js +++ b/public/app/plugins/panel/graph/graph.js @@ -296,7 +296,7 @@ function (angular, $, moment, _, kbn, GraphTooltip) { max: max, label: "Datetime", ticks: ticks, - timeformat: time_format(scope.interval, ticks, min, max), + timeformat: time_format(ticks, min, max), }; } @@ -436,20 +436,23 @@ function (angular, $, moment, _, kbn, GraphTooltip) { }; } - function time_format(interval, ticks, min, max) { + function time_format(ticks, min, max) { if (min && max && ticks) { - var secPerTick = ((max - min) / ticks) / 1000; + var range = max - min; + var secPerTick = (range/ticks) / 1000; + var oneDay = 86400000; + var oneYear = 31536000000; if (secPerTick <= 45) { return "%H:%M:%S"; } - if (secPerTick <= 7200) { + if (secPerTick <= 7200 || range <= oneDay) { return "%H:%M"; } if (secPerTick <= 80000) { return "%m/%d %H:%M"; } - if (secPerTick <= 2419200) { + if (secPerTick <= 2419200 || range <= oneYear) { return "%m/%d"; } return "%Y-%m"; diff --git a/public/app/plugins/panel/graph/specs/graph_specs.ts b/public/app/plugins/panel/graph/specs/graph_specs.ts index e07033583b7..fa1d01a88f8 100644 --- a/public/app/plugins/panel/graph/specs/graph_specs.ts +++ b/public/app/plugins/panel/graph/specs/graph_specs.ts @@ -7,12 +7,13 @@ import angular from 'angular'; import $ from 'jquery'; import helpers from '../../../../../test/specs/helpers'; import TimeSeries from '../../../../core/time_series2'; +import moment from 'moment'; describe('grafanaGraph', function() { beforeEach(angularMocks.module('grafana.directives')); - function graphScenario(desc, func) { + function graphScenario(desc, func, elementWidth = 500) { describe(desc, function() { var ctx: any = {}; @@ -24,7 +25,7 @@ describe('grafanaGraph', function() { beforeEach(angularMocks.inject(function($rootScope, $compile) { var scope = $rootScope.$new(); - var element = angular.element("
"); + var element = angular.element("
"); scope.height = '200px'; scope.panel = { @@ -43,8 +44,8 @@ describe('grafanaGraph', function() { scope.hiddenSeries = {}; scope.dashboard = { timezone: 'browser' }; scope.range = { - from: new Date('2014-08-09 10:00:00'), - to: new Date('2014-09-09 13:00:00') + from: moment([2015, 1, 1, 10]), + to: moment([2015, 1, 1, 22]) }; ctx.data = []; ctx.data.push(new TimeSeries({ @@ -227,4 +228,31 @@ describe('grafanaGraph', function() { expect(axis.tickFormatter(100, axis)).to.be("100%"); }); }); + + graphScenario('when panel too narrow to show x-axis dates in same granularity as wide panels', function(ctx) { + describe('and the range is less than 24 hours', function() { + ctx.setup(function(scope) { + scope.range.from = moment([2015, 1, 1, 10]); + scope.range.to = moment([2015, 1, 1, 22]); + }); + + it('should format dates as hours minutes', function() { + var axis = ctx.plotOptions.xaxis; + expect(axis.timeformat).to.be('%H:%M'); + }); + }); + + describe('and the range is less than one year', function() { + ctx.setup(function(scope) { + scope.range.from = moment([2015, 1, 1]); + scope.range.to = moment([2015, 11, 20]); + }); + + it('should format dates as month days', function() { + var axis = ctx.plotOptions.xaxis; + expect(axis.timeformat).to.be('%m/%d'); + }); + }); + + }, 10); }); diff --git a/public/app/plugins/panel/singlestat/module.ts b/public/app/plugins/panel/singlestat/module.ts index 0a171207bcf..f4e0063a912 100644 --- a/public/app/plugins/panel/singlestat/module.ts +++ b/public/app/plugins/panel/singlestat/module.ts @@ -7,6 +7,7 @@ import {SingleStatCtrl} from './controller'; angular.module('grafana.directives').directive('singleStatPanel', singleStatPanel); +/** @ngInject */ function singleStatPanel($location, linkSrv, $timeout, templateSrv) { 'use strict'; return { @@ -221,7 +222,7 @@ function singleStatPanel($location, linkSrv, $timeout, templateSrv) { function getColorForValue(data, value) { for (var i = data.thresholds.length; i > 0; i--) { - if (value >= data.thresholds[i]) { + if (value >= data.thresholds[i-1]) { return data.colorMap[i]; } } diff --git a/public/app/plugins/panel/singlestat/specs/singlestat_panel_spec.ts b/public/app/plugins/panel/singlestat/specs/singlestat_panel_spec.ts index 17b34f6bcef..0ea34c54721 100644 --- a/public/app/plugins/panel/singlestat/specs/singlestat_panel_spec.ts +++ b/public/app/plugins/panel/singlestat/specs/singlestat_panel_spec.ts @@ -7,7 +7,7 @@ describe('grafanaSingleStat', function() { describe('positive thresholds', () => { var data: any = { colorMap: ['green', 'yellow', 'red'], - thresholds: [0, 20, 50] + thresholds: [20, 50] }; it('5 should return green', () => { @@ -29,7 +29,7 @@ describe('grafanaSingleStat', function() { describe('negative thresholds', () => { var data: any = { colorMap: ['green', 'yellow', 'red'], - thresholds: [ -20, 0, 20] + thresholds: [ 0, 20] }; it('-30 should return green', () => { @@ -48,7 +48,7 @@ describe('grafanaSingleStat', function() { describe('negative thresholds', () => { var data: any = { colorMap: ['green', 'yellow', 'red'], - thresholds: [ -40, -27, 20] + thresholds: [-27, 20] }; it('-30 should return green', () => { diff --git a/trigger_grafana_packer.sh b/trigger_grafana_packer.sh new file mode 100755 index 00000000000..e4a33a00221 --- /dev/null +++ b/trigger_grafana_packer.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +_circle_token=$1 + +trigger_build_url=https://circleci.com/api/v1/project/grafana/grafana-packer/tree/master?circle-token=${_circle_token} + +curl \ +--header "Accept: application/json" \ +--header "Content-Type: application/json" \ +--request POST ${trigger_build_url} \ No newline at end of file