Compare commits

...

221 Commits

Author SHA1 Message Date
Daniel Lee
9341412acc release: v4.4.1 2017-07-05 22:06:00 +02:00
Daniel Lee
16cda723d3 migrations: dashboard version migration handles nulls
If a dashboard has a null value for updated_by then the migration
for the dashboard version table fails. This change uses coalesce to
set -1 instead of null when inserting into the created_by column
which has a non-null constraint. Fixes #8783.
2017-07-05 22:06:00 +02:00
Daniel Lee
36a1ab48c5 packaging: updates for v4.4.0 2017-07-05 22:04:27 +02:00
Daniel Lee
3583057155 release: v4.4.0 2017-07-04 23:42:22 +02:00
Jesse White
1940b33dc1 fix: handling of http errors without any data (#8777) 2017-07-04 22:55:13 +02:00
Daniel Lee
d20455ab5f changelog: note for histogram fix 2017-07-04 22:45:33 +02:00
Alexander Zobnin
934c0fea6f histogram: improved ticks rendering 2017-07-04 21:49:57 +02:00
Alexander Zobnin
c1c1bcb874 histogram: don't cut negative values, issue #8628 2017-07-04 21:49:09 +02:00
Daniel Lee
1da98f5e1e Revert "Histogram fix (#8727)"
This reverts commit 8634c9d457.
2017-07-04 21:48:39 +02:00
Daniel Lee
74093c700f api: adds no-cache header for GET requests
Fixes #5356. Internet Explorer aggressively caches GET requests which
means that all API calls fetching data are cached. This fix adds a
Cache-Control header with the value no-cache to all GET requests to
the API.
2017-07-04 21:26:05 +02:00
Daniel Lee
f773a9b4c3 docs: small change 2017-07-04 21:17:43 +02:00
Daniel Lee
205be91a84 changelog: note for DingDing notifier 2017-07-04 15:25:44 +02:00
Liang Jiameng
109fd998ed Add a new notifier : DingTalk (#8473)
* add alerting notifier: DingDing

* add alerting notifier: DingDing

* add dingding unit test

* add dingding unit test

* delete debug code & format code style.

* fix build failed: dingding_test.go
2017-07-04 15:16:32 +02:00
Daniel Lee
a5afd8152d docs: small update 2017-07-03 21:32:12 +02:00
Daniel Lee
3ae5f7c632 docs: built-in variables, $__interval
Fixes #8344. Documents the $__interval, $__interval_ms and $timeFilter
variables.
2017-07-03 20:44:19 +02:00
Daniel Lee
a71423481b changelog: update 2017-07-03 18:07:51 +02:00
Daniel Lee
20a2334c87 docs: spelling 2017-07-03 14:47:54 +02:00
Ben Tranter
6f4c7a4d65 Add dashboard version history documentation (#8741)
Adds docs for the new API endpoints, and for the dashboard history feature.
2017-07-03 14:29:30 +02:00
Torkel Ödegaard
e269b3b2a0 Merge branch 'refactor-basic-diff' of https://github.com/walmartlabs/grafana into walmartlabs-refactor-basic-diff 2017-07-03 09:49:53 +02:00
Alexander Zobnin
1499c2bf74 Fix User/Org default timezone bug (#8748)
* dashboard: don't override timezone if default selected, issue #8503

* dashboard: hide UTC icon immediately after timezone changing
2017-07-03 09:20:55 +02:00
Daniel Lee
b8aa203707 signup: fix email sent logic for tempuser
Fixes #8656 and properly sets the email_sent and email_sent_on fields
for a tempuser (signup user).
2017-06-30 20:21:08 +02:00
Ben Tranter
1fd7b60efe Add more information to basic diff logic 2017-06-29 14:38:48 -04:00
Daniel Lee
fb99ddf295 influxdb: tweak to help text 2017-06-29 13:58:54 +02:00
Daniel Lee
8683aff3e9 appveyor: build fix for go tests 2017-06-29 12:19:57 +02:00
Daniel Lee
7ea5930a90 alerting: minor fix 2017-06-29 12:18:10 +02:00
Alexander Zobnin
97a7081b57 Fix 8706 (#8734)
* heatmap: fix incorrect time for UTC timezone, fixes #8706

* heatmap: fix tests for time format
2017-06-29 10:40:28 +02:00
Alexander Zobnin
8634c9d457 Histogram fix (#8727)
* histogram: don't cut negative values, issue #8628

* histogram: add percent/count option

* histogram: add tests for values normalizing

* histogram: improved ticks rendering

* histogram: fix default value in axes editor
2017-06-28 17:12:55 +02:00
Daniel Lee
3ac306a72e playlist: fixes #6727. Remember Kiosk mode 2017-06-28 16:29:46 +02:00
Daniel Lee
91ad260517 build: on windows, ignore linux packaging 2017-06-28 09:35:33 +02:00
Daniel Lee
8973b48f96 setting: add tests for windows 2017-06-28 09:35:33 +02:00
Daniel Lee
1a61d2814c docs: updates to build from source 2017-06-28 09:35:33 +02:00
Daniel Lee
b674b9dba2 heatmap: small fix for tooltip auto decimals
Closes #8717
2017-06-27 22:25:04 +02:00
Alexander Zobnin
83fbace6b9 heatmap: fix tooltip decimals calculation 2017-06-27 22:24:27 +02:00
Alexander Zobnin
12644372c4 heatmap: fix scaledDecimals calculation (use the same method as in flot.js) 2017-06-27 22:24:27 +02:00
Alexander Zobnin
c12a7d7f59 heatmap: adjust tests for fixed decimals calc 2017-06-27 22:24:27 +02:00
Alexander Zobnin
7c840cdf38 heatmap: fix tooltip decimals 2017-06-27 22:24:27 +02:00
Alexander Zobnin
b63d2b3279 heatmap: fix Y axis decimals with log scale 2017-06-27 22:24:27 +02:00
Alexander Zobnin
8e5672aee6 heatmap: fix Y axis value rounding with linear scale 2017-06-27 22:24:27 +02:00
Daniel Lee
53ea9cfbcf docs: update ha_setup with alerting deduping 2017-06-27 16:59:40 +02:00
Alexander Zobnin
1deeef9e91 Table panel: add option for preserving text formatting (#8708)
* table_panel: add option for preserving text formatting

* table_panel: fix undefined style error

* table_panel: fix class adding (add space before 'class')

* table_panel: aligin Type options labels
2017-06-27 06:38:49 -04:00
Daniel Lee
10127e8ac9 docs: small updates 2017-06-26 17:42:50 +02:00
Louis Law
8c8b1dde8a [fix] fix minor issue in gitignore file. (#8694) 2017-06-25 11:34:23 -04:00
Denis Doria
e8d01218d8 Fix label showing up when Axes are configured to not be displayed (#8697)
This should close the issue #8695
2017-06-25 14:23:37 +02:00
Denis Doria
5aac2d2078 Include user Id on the lookup api (#8698)
Implements feature request #8682
2017-06-25 14:23:03 +02:00
Torkel Ödegaard
60da730c95 mysql: fix for TIME columns, fixes #8534 2017-06-23 12:55:40 -04:00
Torkel Ödegaard
73fcc919cd change: made dashboard save message optional 2017-06-23 11:49:32 -04:00
Torkel Ödegaard
be29357d22 fix: added missing url route for annotation state history delete, fixes #8660 2017-06-23 11:48:58 -04:00
Torkel Ödegaard
86a73c359b fix: data source selector did not show, fixes #8692 2017-06-23 11:32:17 -04:00
Ben Tranter
a3d22ae9c7 Document logic behind basic diff 2017-06-22 18:23:31 -04:00
Ben Tranter
b54b43a42e Add tests for diff formatters 2017-06-22 18:08:37 -04:00
Haidara Mohamed El Mouctar
bc6a57ce32 [Docs] Add documentation for max_idle_conn and max_open_conn (#8675) 2017-06-21 10:41:56 -04:00
Daniel Lee
2479e51a6b mysql: Null value should not be considered as previous value
fixes #8655
2017-06-20 02:02:05 +02:00
Torkel Ödegaard
2a93bed453 ux: aligned tabbed view body with header title 2017-06-19 19:22:44 -04:00
Denis Doria
eaba985f25 Fix issue with kilovolt-ampere reactive (kvar) #8596 (#8650)
This changes the css to handle overflow of the string on the input fields.
If an overflow happends an ellipsis is used.
2017-06-19 16:23:12 -04:00
Torkel Ödegaard
77136d7a70 Merge branch 'master' of github.com:grafana/grafana 2017-06-19 16:02:31 -04:00
Torkel Ödegaard
8440d2d0a2 fix: fixed search issues with in active mode and keyboard nav 2017-06-19 16:02:16 -04:00
Denis Doria
41d300f69d Fix timeInterval for mysql datasource (#8651)
* Fix timeInterval for mysql datasource

This changes the > to >= and the < to <=, so the intervals are inclusive.
This should fix the #8635

* Fix validation
2017-06-19 08:58:22 -04:00
Alexander Zobnin
9a7e460865 fix heatmap count values bug introduced by #8632 2017-06-18 21:47:08 +03:00
Alexander Zobnin
8626bdfed8 Merge remote-tracking branch 'upstream/master' 2017-06-18 20:59:05 +03:00
Alexander Zobnin
1f4140057b heatmap-tooltip: normalize histogram Y axis 2017-06-18 20:58:13 +03:00
Daniel Lee
0eb297822c httpserver: fixes #8641
Changes to the http_server class meant that the TLS settings were not
getting applied anymore. This fixes so that the minimum TLS version is
1.2 again.
2017-06-17 23:10:12 +02:00
Daniel Lee
ad080af38f docs: add tutorial for using API 2017-06-17 20:59:19 +02:00
Salman Jalali
49fdbb3843 Update kbn.js 2017-06-17 06:08:10 +02:00
Torkel Ödegaard
724368d0cd fix: data source dropdown select 2017-06-16 11:43:37 -04:00
Torkel Ödegaard
25f88e9b3a Merge branch 'master' of github.com:grafana/grafana 2017-06-16 10:47:53 -04:00
Torkel Ödegaard
39ffb04be1 Merge branch 'revert-dashboard-search-nav' 2017-06-16 10:47:28 -04:00
Torkel Ödegaard
056c57d551 ux: temporary remove search 2017-06-16 10:47:17 -04:00
Brandon Arp
5d63ad21c1 allow heatmap parsing of scaled datapoints (#8632) 2017-06-16 10:45:52 -04:00
Torkel Ödegaard
a49e82e447 ux: revert dashboard search nav change, this is a tempoary change until we redesign the sidenav 2017-06-16 09:47:21 -04:00
Torkel Ödegaard
e9c8881d54 Merge branch 'metric-segment-remake' 2017-06-15 15:56:41 -04:00
Torkel Ödegaard
840099bec0 refactor: metric segment remake 2017-06-15 15:56:24 -04:00
Torkel Ödegaard
76c4bfe268 ux: new metric segment is starting to work 2017-06-15 14:03:26 -04:00
Torkel Ödegaard
5f3b5fdcb2 updated 2017-06-15 12:21:12 -04:00
Torkel Ödegaard
6a95df403a refacoring: more work on metric segment replacement 2017-06-14 19:42:45 -04:00
Torkel Ödegaard
380e7e7f04 Merge branch 'master' into metric-segment-remake 2017-06-14 16:17:13 -04:00
Torkel Ödegaard
581b977787 Merge branch 'master' of github.com:grafana/grafana 2017-06-13 18:32:38 -04:00
Torkel Ödegaard
c771dd4bd2 ux: metrics tab add query feature 2017-06-13 18:31:43 -04:00
Daniel Lee
cb720d8eaf docs: add body options for snapshot api 2017-06-13 23:17:14 +02:00
Torkel Ödegaard
9ff4ab1236 Merge branch 'master' into query_troubleshooting 2017-06-13 16:47:04 -04:00
Daniel Lee
1f92e589e8 exporter: query template var keeps refresh value..
on export if the value is not set to never. Otherwise the template
variable will not be populated with any values.""
2017-06-13 22:42:58 +02:00
Torkel Ödegaard
812ac5cb8e Merge branch 'master' of github.com:grafana/grafana 2017-06-13 13:22:08 -04:00
Torkel Ödegaard
7d642546b3 fix: restore dashboard history version did not reload route correctly when slug did not change 2017-06-13 13:21:22 -04:00
Trent White
192c447c2c create new auth icon for grafana.com so it doesn't share the same file as the main logo (#8581) 2017-06-12 17:17:00 +02:00
Martin Molnar
d10d897d65 fix: component name of plugin page contains 'undefined' (#8590) 2017-06-12 15:11:00 +02:00
Daniel Lee
6992b484bc docs: improvements to building the docs readme
Explains that you need to clone the grafana repo if you have not done that already.
2017-06-09 14:27:27 +02:00
Torkel Ödegaard
cdd5ba6198 fix: influxdb test data source error handling now works better, fixes #8577 2017-06-08 21:41:58 +02:00
Torkel Ödegaard
6c04057285 Merge branch 'master' of github.com:grafana/grafana 2017-06-08 14:39:39 +02:00
Torkel Ödegaard
217c746445 ux: close edit views when opening panel in edit mode 2017-06-08 14:23:51 +02:00
Torkel Ödegaard
95c8a76aa6 ux: minor search style improvements 2017-06-08 13:56:51 +02:00
Daniel Lee
a8e9700334 docs: add whitelist option for auth.proxy 2017-06-08 13:38:18 +02:00
Torkel Ödegaard
50b09f4f10 Merge branch 'master' of github.com:grafana/grafana 2017-06-08 11:54:11 +02:00
Torkel Ödegaard
2c75593c1a refactoring: elasticsearch raw doc size limit option PR, #8527 2017-06-08 11:53:12 +02:00
Torkel Ödegaard
3c41d0477a Merge branch 'master' of https://github.com/mk-dhia/grafana into mk-dhia-master 2017-06-08 11:22:48 +02:00
Daniel Lee
f7c48c5a5f singlestat: fix ignoring zero value for table data
When table data returns a column with the value 0, it should not ignore
it. This change checks for undefined instead of if the value is truthy.

Fixes #8531
2017-06-08 11:06:37 +02:00
Daniel Lee
c5d5d7ac5a docs: instruction on creating dummy awsconfig
To be able to build the docs an awsconfig file must exist for the Dockerfile to copy. This adds an instruction to create an empty, dummy file so that the build will work.
2017-06-08 10:32:57 +02:00
Torkel Ödegaard
71b62f5cf9 Create README.md 2017-06-08 09:15:10 +02:00
Mitsuhiro Tanda
922073a357 (cloudwatch) add new metrics (#8569) 2017-06-08 07:01:33 +02:00
Torkel Ödegaard
01ff3bbe0a ux: mini css position fix 2017-06-07 22:03:10 +02:00
Torkel Ödegaard
61bdc91272 ux: css fix to search position when sidemenu is pinned 2017-06-07 21:54:55 +02:00
Torkel Ödegaard
08b37186a5 fix: fixed failing sql unit test 2017-06-07 15:42:48 +02:00
Torkel Ödegaard
5225e4283f Update CHANGELOG.md 2017-06-07 14:39:44 +02:00
Torkel Ödegaard
e7e675e471 Merge branch 'walmartlabs-master' (Dashboard History Feature, #8472) 2017-06-07 14:30:03 +02:00
Torkel Ödegaard
391dc1e225 dasboard_history: fixed sql integration test 2017-06-07 14:25:46 +02:00
Torkel Ödegaard
46412c8475 dasboard_history: security fix, added orgId filter to dashboard version lookup 2017-06-07 14:21:40 +02:00
Torkel Ödegaard
3ba8aeb9a7 dashboard_history: fix for scenario where reverted dashbord has different title and url must change not just route reload 2017-06-07 14:02:45 +02:00
Torkel Ödegaard
64d620c987 dasboard_history: minor style fix change 2017-06-07 13:50:59 +02:00
Peter Mounce
f4debbf501 typo (#8565) 2017-06-07 13:47:08 +02:00
Torkel Ödegaard
56b3c4a3a0 dasboard_history: fixed disabled attribute so it works both by disabling and fixing style, no need for disabled class and disabled attribute 2017-06-07 13:46:36 +02:00
Torkel Ödegaard
577dfee086 dasboard_history: fixed json diff so only dashbord is compared and not the whole dashboard revision object (message and restoreFrom etc was showing up in json diff) 2017-06-07 13:36:03 +02:00
Torkel Ödegaard
8f6c9c5946 dasboard_history: some polish around styles & diff colors, and minor fixes 2017-06-07 12:39:06 +02:00
Torkel Ödegaard
ef1dfed0d8 dasboard_history: big refactoring of how the compare api looked, now a POST with a model for new & base version, refactored a simplified UI code as well, this was done to lay foundation to make it possible to do diff against current in browser unsaved version 2017-06-07 11:50:09 +02:00
Torkel Ödegaard
948e5ae74d dashboard_history: further css & markup fixes, removing style overrides & resuing existing styles and markup components 2017-06-06 23:04:14 +02:00
Torkel Ödegaard
c4e872b9da dashboard_history: minor changes and fixes 2017-06-06 16:27:28 +02:00
Torkel Ödegaard
7b5f7ed553 dashboard_history: SQL did not work when using MySQL, fixes to dashboard version numbering, so inserts start at 1, added migration to fix old dashboards with version 0 2017-06-06 15:40:10 +02:00
Torkel Ödegaard
5409f4c0eb dashboard_history: fixed issue with save as & overwrite 2017-06-06 14:39:57 +02:00
Torkel Ödegaard
9b629cd5a6 dashboard_history: fixed history srv unit tests 2017-06-06 14:28:21 +02:00
Torkel Ödegaard
546d489dd3 dashboard_history: restored unsaved changes modal to simple cancel, discard, save 2017-06-06 14:27:30 +02:00
Torkel Ödegaard
88da3a99e1 refactoring: trying to remove all the css overrides in history tab and make the styles more more inline with best practice css, and if needed use modifier overrides instead 2017-06-06 11:03:56 +02:00
Torkel Ödegaard
689e366f59 refactoring: updated api url routes so they do not conflict with slug route 2017-06-06 09:51:14 +02:00
Torkel Ödegaard
e2061312f5 refactoring: moved compare dashboard version command out of sqlstore, the code in this command did not use any sql operations and was more high level, could be moved out and use existing queries to get the versions 2017-06-06 00:15:40 +02:00
Torkel Ödegaard
e43d09e15b fix: fixed failing frontend test 2017-06-05 23:31:45 +02:00
Torkel Ödegaard
746d6cdc88 refactoring: changed name on compare command to make properties more explainatory 2017-06-05 23:29:25 +02:00
Torkel Ödegaard
9c1401849e fix: always show dashboard history for now, need a way to make dropdown more dynamic 2017-06-05 23:14:07 +02:00
Torkel Ödegaard
2a52e25d5b refactoring: removed double error alert, backendSrv is already showing an error alert 2017-06-05 23:06:31 +02:00
Torkel Ödegaard
cabbfe9adc refactoring: moved dashboard history item formating (message) and fixed dashboard history migration issue, and removed from frontend tests that where no longer needed 2017-06-05 22:59:04 +02:00
Torkel Ödegaard
f18ebea03e fix: dashboard save modals had double submit on enter after my refactoring this morning 2017-06-05 18:01:16 +02:00
Torkel Ödegaard
82d4d54dc5 refactoring: fixed broken unit test in last commit 2017-06-05 17:51:51 +02:00
Torkel Ödegaard
c87418d060 refactoring: Dashboard history restore operation is now reusing existing
operations instead of duplicating a bunch of get & save logic.
2017-06-05 17:45:27 +02:00
Daniel Lee
12219cffe0 Merge pull request #8538 from yackushevas/master
misspell: Corrected some misspelled words
2017-06-05 17:21:42 +02:00
Torkel Ödegaard
c296ae1178 refactoring: fixing unit tests broken in last commit 2017-06-05 16:52:36 +02:00
Torkel Ödegaard
e18007153d refactoring: Renamed dashboard version queries that wrongly had Command suffix, added missing OrgId to dashboard history commands and queries 2017-06-05 16:34:32 +02:00
Daniel Lee
1b79e17970 graph: better generation of y-axis ticks for log-scale
If there are too many ticks generated for the y-axis (which can occur
for log scale 2, with a small y-min and a large max), then the ticks
will be regenerated using larger jumps between the ticks.

This also handles the case when y-min is set to 0. Previously, y-min of
0 was ignored as zero is not a valid value for log scale. Now the tick
generator approximates zero by setting min to 0.1.

Ref #8244

Ref #8516
2017-06-05 15:06:22 +02:00
Torkel Ödegaard
fdfcd5cbf0 refactoring:: dashboard save modal and save as modal needed an update 2017-06-05 14:56:11 +02:00
Anton Yackushev
bab21c9069 misspell: Corrected some misspelled words 2017-06-05 15:20:34 +03:00
Torkel Ödegaard
f3980504e2 Merge branch 'master' into walmartlabs-master 2017-06-05 13:43:00 +02:00
Eirik Nygaard
1efdd92ae8 Update oauth2 lib (#8524)
* Update to latest oauth2 library using govendor

* Follow API changes
2017-06-05 10:09:27 +02:00
Dhia MOAKHAR
7c1dc2444d change size in raw_document from text to number
update query builder and specs
2017-06-04 13:20:58 +00:00
Dan Cech
f224fd8310 reduce length of dashboard columns used in compound indexes (#8507) 2017-06-04 14:28:03 +02:00
Tom Gardiner
4fe9935321 Add support for AWS/VPN metrics (#8528) 2017-06-04 14:24:04 +02:00
aykim17
d996275f8f Updated the graphTooltip description (#8532)
I added in a description since there was just TODO there originally.
2017-06-04 14:23:52 +02:00
Torkel Ödegaard
d2eca2faa1 ux: new navbar fixes, restored delete option in dashboard dropdown, fixes #8521 2017-06-04 14:18:25 +02:00
Dhia MOAKHAR
045f5e11fc [elasticsearch] Fix bug when switching from "Raw Document" metric type
when switch to "raw Document" metric type we do free all "Group by"
however when we switch back to another type we do not reset the default aggregation (date histogram)
Thus all modification will through exception as no "Group by" is defined and panel should be recreated
the fix will reintialize the "Group by" by setting default value
2017-06-03 02:55:26 +00:00
Dhia MOAKHAR
d55cc4e2a3 [elasticsearch] Fix add metric that was not working properly
when selecting Raw Documet metric type, the $scope.target.metrics was replaced by [$scope.agg],
 however the pointer to this variables is shared with metricAggs.
 Instead we free the array and add $scope.agg
2017-06-03 02:50:10 +00:00
Dhia MOAKHAR
966c2912fc [elasticsearch] Add option for result set size in raw_dcument
it allows to specify the result set size in raw_document.
 Example: table  panel could show more (or less) than 500 line if needed.
 Added test to spec
2017-06-02 23:56:48 +00:00
Torkel Ödegaard
d47c47853a ux: added css annimations for dash edit views 2017-06-02 15:29:25 +02:00
Torkel Ödegaard
3bea304bab ux: made new search more responsive 2017-06-02 14:19:39 +02:00
Torkel Ödegaard
e9d5e037e8 ux: merge branch navbarv2, new navbar with dashboard search available on all pages, closes #6475 2017-06-02 14:00:42 +02:00
sanchitraizada
77c046aac6 Implement review feedback 2017-06-01 17:57:09 -04:00
Torkel Ödegaard
1bdf82dca3 Update CHANGELOG.md 2017-05-31 14:04:19 +02:00
Torkel Ödegaard
6783d1000c Update CHANGELOG.md 2017-05-31 14:04:02 +02:00
Torkel Ödegaard
95a4ec8bf2 Merge branch 'v4.3.x' 2017-05-31 10:55:06 +02:00
Torkel Ödegaard
cd3807055e chrome: an attempt to fix scroll issue with chrome, but this did not fix the problem as it seems to be caused by some interaction between graph rendering and scrolling, #8494 2017-05-30 13:50:38 +02:00
Pranay Kanwar
26ec874fb1 Frontend query changes corresponding #8336 (#8489) 2017-05-30 09:53:11 +02:00
Torkel Ödegaard
5c1833de1f Merge branch 'master' of github.com:grafana/grafana 2017-05-29 12:21:21 +02:00
Torkel Ödegaard
f16e3e38ee Merge branch 'v4.3.x' 2017-05-29 12:20:34 +02:00
Daniel Lee
371625aeec Merge branch 'v4.3.x' 2017-05-29 11:13:14 +02:00
Daniel Lee
beced6f3a6 graph: Handle data with zeroes for log scale
fixes #8446. Data with all values equal to zero, creates a max with the
value of Infinite. The for loop for creating ticks then gets stuck in an
infinite loop. This fix resets min and max and creates some fake ticks
for the y-axis if the min and max are not finite numbers.
2017-05-29 11:12:08 +02:00
Torkel Ödegaard
5e0b03928e Merge branch 'v4.3.x' 2017-05-29 10:49:02 +02:00
Torkel Ödegaard
0d39852ef4 fix: fixed test data fake metric query, fixes #8474 2017-05-29 10:48:38 +02:00
Torkel Ödegaard
ab44c7d63e Merge branch 'master' of github.com:grafana/grafana 2017-05-29 08:52:07 +02:00
Torkel Ödegaard
ed2092e287 docs: updated alerting docs to make query conditions easier to understand, fixes #8486 2017-05-29 08:51:56 +02:00
Dan Cech
c0d5b61403 tweak column lengths for utf8mb4 support on older mysql (#8483) 2017-05-29 08:31:36 +02:00
Dan Cech
7004a84c30 tweak column lengths for utf8mb4 support on older mysql 2017-05-29 08:19:51 +02:00
Anton Yackushev
c2885430bd fix "no formatting directive in Fatalf call" (vet) (#8487) 2017-05-29 08:12:54 +02:00
Daniel Lee
e65f86147f changelog: note for #8058 2017-05-26 15:56:44 +02:00
Daniel Lee
c17d02e496 csv: remove sep metadata as only works for excel 2017-05-26 15:43:09 +02:00
Cédric Reginster
ee0d0155a5 Refactor to component based style 2017-05-26 15:43:09 +02:00
Cédric Reginster
f484b4c347 Make csv export date time format configurable
- Move export csv options to modal dialog
2017-05-26 15:43:09 +02:00
Daniel Lee
3292a48381 graph: dashes with linewidth fix. Fixes #8469
Adds series override options for dash spaces and dash length.
2017-05-26 15:09:05 +02:00
Dan Cech
8422697199 centralize oauth http calls, validate response status (#8470) 2017-05-26 14:35:32 +02:00
Ben Tranter
a927b893ae Use DBSession for getMaxVersion 2017-05-25 23:04:00 -04:00
sanchitraizada
e6616cc551 Merge pull request #10 from walmartlabs/version-control
History and Version Control for Dashboard Updates
2017-05-25 14:50:47 -07:00
Torkel Ödegaard
60d5d5fb15 docs: updated changelog 2017-05-25 20:37:01 +02:00
Torkel Ödegaard
68397d342b Merge branch 'patch-1' of https://github.com/goldeelox/grafana into goldeelox-patch-1 2017-05-25 20:34:38 +02:00
Torkel Ödegaard
09267bbfe8 docs: elasticsearch, added info about size property in templating query 2017-05-25 20:11:53 +02:00
Victor Azevedo
0a1c2a7024 use fielddata_fields in elasticsearch 2.x queries
Resolves issue #8467
2017-05-25 11:43:29 -04:00
Ben Tranter
b6e46c9eb8 History and Version Control for Dashboard Updates
A simple version control system for dashboards. Closes #1504.

Goals

1. To create a new dashboard version every time a dashboard is saved.
2. To allow users to view all versions of a given dashboard.
3. To allow users to rollback to a previous version of a dashboard.
4. To allow users to compare two versions of a dashboard.

Usage

Navigate to a dashboard, and click the settings cog. From there, click
the "Changelog" button to be brought to the Changelog view. In this
view, a table containing each version of a dashboard can be seen. Each
entry in the table represents a dashboard version. A selectable
checkbox, the version number, date created, name of the user who created
that version, and commit message is shown in the table, along with a
button that allows a user to restore to a previous version of that
dashboard. If a user wants to restore to a previous version of their
dashboard, they can do so by clicking the previously mentioned button.
If a user wants to compare two different versions of a dashboard, they
can do so by clicking the checkbox of two different dashboard versions,
then clicking the "Compare versions" button located below the dashboard.
From there, the user is brought to a view showing a summary of the
dashboard differences. Each summarized change contains a link that can
be clicked to take the user a JSON diff highlighting the changes line by
line.

Overview of Changes

Backend Changes

- A `dashboard_version` table was created to store each dashboard
  version, along with a dashboard version model and structs to represent
  the queries and commands necessary for the dashboard version API
  methods.
- API endpoints were created to support working with dashboard
  versions.
- Methods were added to create, update, read, and destroy dashboard
  versions in the database.
  - Logic was added to compute the diff between two versions, and
  display it to the user.
  - The dashboard migration logic was updated to save a "Version
  1" of each existing dashboard in the database.

Frontend Changes

- New views
- Methods to pull JSON and HTML from endpoints

New API Endpoints

Each endpoint requires the authorization header to be sent in
the format,

```
Authorization: Bearer <jwt>
```

where `<jwt>` is a JSON web token obtained from the Grafana
admin panel.

`GET "/api/dashboards/db/:dashboardId/versions?orderBy=<string>&limit=<int>&start=<int>"`

Get all dashboard versions for the given dashboard ID. Accepts
three URL parameters:

- `orderBy` String to order the results by. Possible values
  are `version`, `created`, `created_by`, `message`. Default
  is `versions`. Ordering is always in descending order.
- `limit` Maximum number of results to return
- `start` Position in results to start from

`GET "/api/dashboards/db/:dashboardId/versions/:id"`

Get an individual dashboard version by ID, for the given
dashboard ID.

`POST "/api/dashboards/db/:dashboardId/restore"`

Restore to the given dashboard version. Post body is of
content-type `application/json`, and must contain.

```json
{
  "dashboardId": <int>,
  "version": <int>
}
```

`GET "/api/dashboards/db/:dashboardId/compare/:versionA...:versionB"`

Compare two dashboard versions by ID for the given
dashboard ID, returning a JSON delta formatted
representation of the diff. The URL format follows
what GitHub does. For example, visiting
[/api/dashboards/db/18/compare/22...33](http://ec2-54-80-139-44.compute-1.amazonaws.com:3000/api/dashboards/db/18/compare/22...33)
will return the diff between versions 22 and 33 for
the dashboard ID 18.

Dependencies Added

- The Go package [gojsondiff](https://github.com/yudai/gojsondiff)
  was added and vendored.
2017-05-24 19:14:39 -04:00
Torkel Ödegaard
d1d47b5697 docs: updated changelog with fix for InfluxDB query issue, #8459 2017-05-24 16:49:30 +02:00
Torkel Ödegaard
90871ca12e Merge branch 'v4.3.x' 2017-05-24 16:48:05 +02:00
Dhia
ac28c4b233 add Referer header to logs. This is useful to link datasource requests to dashboard that originated it (#8399) 2017-05-24 13:47:29 +02:00
Torkel Ödegaard
8b7a0100b1 docs: updated changelog with info on merged PR #8405 2017-05-24 13:46:33 +02:00
Torkel Ödegaard
dcf7385cc1 sensu: added input tooltip to source property 2017-05-24 13:44:38 +02:00
joe miller
090594a0bc support setting the source and handler attribute in sensu notifications (#8405)
* support setting the handler attribute in sensu alert notifications

* allow the user to set the source attribute of Sensu notifications
2017-05-24 13:42:24 +02:00
Dan Cech
007c08f2a8 remove X-Forwarded-* headers added by nginx when proxying data source & plugin requests (#8418)
* remove X-Forwarded-* headers added by nginx when proxying data source & plugin requests

* properly handle X-Forwarded-For
2017-05-24 13:39:40 +02:00
Daniel Parks
2d29d7b3d6 Add support for AWS DMS CloudWatch metric (#8458) 2017-05-24 13:24:24 +02:00
Torkel Ödegaard
3133721422 docs: update changelog 2017-05-24 13:23:11 +02:00
Torkel Ödegaard
28bff0c1f3 Merge branch 'v4.3.x' 2017-05-24 13:22:09 +02:00
Torkel Ödegaard
e6d79dfedf docs: updated changelog 2017-05-24 11:23:41 +02:00
Torkel Ödegaard
5740702cb5 Merge branch 'master' of github.com:grafana/grafana 2017-05-24 11:21:17 +02:00
Torkel Ödegaard
a5318def66 Merge branch 'v4.3.x' 2017-05-24 11:20:35 +02:00
Prasanna Gautam
6541ffe045 minor change: more accurate variable name (#8449) 2017-05-24 08:38:13 +02:00
Torkel Ödegaard
12c8bf9b18 Update CHANGELOG.md 2017-05-23 16:45:15 +02:00
Torkel Ödegaard
d4048e1423 docs: updated download links 2017-05-23 16:18:37 +02:00
Torkel Ödegaard
6257fbced6 docs: updated changelog 2017-05-23 15:54:44 +02:00
Torkel Ödegaard
330cb92b24 Merge branch 'v4.3.x' 2017-05-23 15:52:24 +02:00
Torkel Ödegaard
3695337980 refactoring: Elasticearch filter label PR #8420 2017-05-23 15:14:23 +02:00
Torkel Ödegaard
648e8a9547 Merge branch 'master' of https://github.com/limefamily/grafana into limefamily-master 2017-05-23 14:48:14 +02:00
Torkel Ödegaard
1e3a42ca68 packaging: added rpm fonts to rpm package dependencies (#8442) 2017-05-23 13:53:03 +02:00
Torkel Ödegaard
21e61319f1 Merge branch 'master' of github.com:grafana/grafana 2017-05-23 13:52:24 +02:00
Torkel Ödegaard
48fdfe721e build: updated master version to 4.4.0-pre1 2017-05-23 13:52:07 +02:00
Torkel Ödegaard
bd8a0be3aa Update CHANGELOG.md 2017-05-23 12:26:30 +02:00
Torkel Ödegaard
7cb6466251 feat: query troubleshooter, improving json explorer 2017-05-20 23:43:59 +02:00
Torkel Ödegaard
d840645dd7 feat: metrics tab, minor change 2017-05-20 23:03:04 +02:00
Torkel Ödegaard
a8673a2e33 feat: metrics tab 2017-05-20 22:41:34 +02:00
Torkel Ödegaard
4a2c405ac0 feat: metris tab, moved data source selector 2017-05-20 18:35:34 +02:00
Torkel Ödegaard
499e01d832 feat: metrics tab reworking 2017-05-20 18:21:41 +02:00
Torkel Ödegaard
5e090b84ec feat: Copy to clipboard now works in query troubleshooter 2017-05-20 14:52:33 +02:00
Torkel Ödegaard
b8aa6a8e47 query: more work on metrics tab changes 2017-05-20 10:48:47 +02:00
Torkel Ödegaard
912301fe24 query: more work on metrics tab changes 2017-05-20 10:14:41 +02:00
Torkel Ödegaard
5909f9ef92 feat: more work on metrics tab reworkings 2017-05-19 21:32:23 +02:00
Torkel Ödegaard
5fcb966297 feat: query troubleshooter progress 2017-05-19 17:35:36 +02:00
Torkel Ödegaard
6ad1a396a5 feat: query troubleshooter 2017-05-19 16:00:01 +02:00
Torkel Ödegaard
5513d3c9d1 feat: moved json-formatter-js into core grafana to be able to make changes 2017-05-19 13:16:05 +02:00
田子宽
9c35d3f87c Add kibana like readable label to elasticsearch query builder 2017-05-19 11:38:28 +08:00
Torkel Ödegaard
f7a6c9a1e6 ux: minor change 2017-05-18 17:05:15 +02:00
Torkel Ödegaard
f65878c21d ux: working on query troubleshooting 2017-05-18 17:04:31 +02:00
Torkel Ödegaard
78dbb4dc13 config: removed trace level from config comment 2017-05-18 14:19:39 +02:00
Torkel Ödegaard
d5481fa0f1 Merge branch 'master' into query_troubleshooting 2017-05-18 14:14:33 +02:00
Torkel Ödegaard
fbc3e0371d Merge branch 'master' into query_troubleshooting 2017-05-18 13:38:11 +02:00
Torkel Ödegaard
86ce3d5e45 feat: in app query request & response troubleshooting 2017-05-17 17:02:50 +02:00
Torkel Ödegaard
b65642564a poc for new metric segment 2016-10-08 10:06:47 +02:00
309 changed files with 12587 additions and 3957 deletions

3
.gitignore vendored
View File

@@ -9,13 +9,12 @@ awsconfig
/public/vendor/npm
/tmp
vendor/phantomjs/phantomjs
vendor/phantomjs/phantomjs.exe
docs/AWS_S3_BUCKET
docs/GIT_BRANCH
docs/VERSION
docs/GITCOMMIT
docs/changed-files
docs/changed-files
# locally required config files
public/css/*.min.css

View File

@@ -1,4 +1,45 @@
# 4.3.0-stable (unreleased)
# 4.4.0 (unreleased)
## New Features
**Dashboard History**: View dashboard version history, compare any two versions (summary & json diffs), restore to old version. This big feature
was contributed by **Walmart Labs**. Big thanks to them for this massive contribution!
Initial feature request: [#4638](https://github.com/grafana/grafana/issues/4638)
Pull Request: [#8472](https://github.com/grafana/grafana/pull/8472)
## Enhancements
* **Elasticsearch**: Added filter aggregation label [#8420](https://github.com/grafana/grafana/pull/8420), thx [@tianzk](github.com/tianzk)
* **Sensu**: Added option for source and handler [#8405](https://github.com/grafana/grafana/pull/8405), thx [@joemiller](github.com/joemiller)
* **CSV**: Configurable csv export datetime format [#8058](https://github.com/grafana/grafana/issues/8058), thx [@cederigo](github.com/cederigo)
* **Table Panel**: Column style that preserves formatting/indentation (like pre tag) [#6617](https://github.com/grafana/grafana/issues/6617)
* **DingDing**: Add DingDing Alert Notifier [#8473](https://github.com/grafana/grafana/pull/8473) thx [@jiamliang](https://github.com/jiamliang)
## Minor Enhancements
* **Elasticsearch**: Add option for result set size in raw_document [#3426](https://github.com/grafana/grafana/issues/3426) [#8527](https://github.com/grafana/grafana/pull/8527), thx [@mk-dhia](github.com/mk-dhia)
## Bug Fixes
* **Graph**: Bug fix for negative values in histogram mode [#8628](https://github.com/grafana/grafana/issues/8628)
# 4.3.2 (2017-05-31)
## Bug fixes
* **InfluxDB**: Fixed issue with query editor not showing ALIAS BY input field when in text editor mode [#8459](https://github.com/grafana/grafana/issues/8459)
* **Graph Log Scale**: Fixed issue with log scale going below x-axis [#8244](https://github.com/grafana/grafana/issues/8244)
* **Playlist**: Fixed dashboard play order issue [#7688](https://github.com/grafana/grafana/issues/7688)
* **Elasticsearch**: Fixed table query issue with ES 2.x [#8467](https://github.com/grafana/grafana/issues/8467), thx [@goldeelox](https://github.com/goldeelox)
## Changes
* **Lazy Loading Of Panels**: Panels are no longer loaded as they are scrolled into view, this was reverted due to Chrome bug, might be reintroduced when Chrome fixes it's JS blocking behavior on scroll. [#8500](https://github.com/grafana/grafana/issues/8500)
# 4.3.1 (2017-05-23)
## Bug fixes
* **S3 image upload**: Fixed image url issue for us-east-1 (us standard) region. If you were missing slack images for alert notifications this should fix it. [#8444](https://github.com/grafana/grafana/issues/8444)
# 4.3.0-stable (2017-05-23)
## Bug fixes
@@ -30,7 +71,7 @@
* **Heatmap**: Heatmap Panel [#7934](https://github.com/grafana/grafana/pull/7934)
* **Elasticsearch**: histogram aggregation [#3164](https://github.com/grafana/grafana/issues/3164)
## Minor Enchancements
## Minor Enhancements
* **InfluxDB**: Small fix for the "glow" when focus the field for LIMIT and SLIMIT [#7799](https://github.com/grafana/grafana/pull/7799) thx [@thuck](https://github.com/thuck)
* **Prometheus**: Make Prometheus query field a textarea [#7663](https://github.com/grafana/grafana/issues/7663), thx [@hagen1778](https://github.com/hagen1778)

View File

@@ -18,6 +18,7 @@ Graphite, Elasticsearch, OpenTSDB, Prometheus and InfluxDB.
- [What's New in Grafana 4.1](http://docs.grafana.org/guides/whats-new-in-v4-1/)
- [What's New in Grafana 4.2](http://docs.grafana.org/guides/whats-new-in-v4-2/)
- [What's New in Grafana 4.3](http://docs.grafana.org/guides/whats-new-in-v4-3/)
- [What's New in Grafana 4.4](http://docs.grafana.org/guides/whats-new-in-v4-4/)
## Features

View File

@@ -32,6 +32,7 @@ build_script:
- grunt release
- go run build.go sha-dist
- cp dist/* .
- go test -v ./pkg/...
artifacts:
- path: grafana-*windows-*.*

View File

@@ -95,7 +95,9 @@ func main() {
case "package":
grunt(gruntBuildArg("release")...)
createLinuxPackages()
if runtime.GOOS != "windows" {
createLinuxPackages()
}
case "pkg-rpm":
grunt(gruntBuildArg("release")...)
@@ -235,7 +237,7 @@ func createRpmPackages() {
defaultFileSrc: "packaging/rpm/sysconfig/grafana-server",
systemdFileSrc: "packaging/rpm/systemd/grafana-server.service",
depends: []string{"/sbin/service", "fontconfig"},
depends: []string{"/sbin/service", "fontconfig", "freetype", "urw-fonts"},
})
}
@@ -345,7 +347,11 @@ func ChangeWorkingDir(dir string) {
}
func grunt(params ...string) {
runPrint("./node_modules/.bin/grunt", params...)
if runtime.GOOS == "windows" {
runPrint(`.\node_modules\.bin\grunt`, params...)
} else {
runPrint("./node_modules/.bin/grunt", params...)
}
}
func gruntBuildArg(task string) []string {

View File

@@ -298,7 +298,7 @@
# Use space to separate multiple modes, e.g. "console file"
;mode = console file
# Either "trace", "debug", "info", "warn", "error", "critical", default is "info"
# Either "debug", "info", "warn", "error", "critical", default is "info"
;level = info
# optional settings to set different levels for specific loggers. Ex filters = sqlstore:debug

View File

@@ -4,7 +4,6 @@ graphite:
- "8080:80"
- "2003:2003"
volumes:
- /var/docker/gfdev/graphite:/opt/graphite/storage/whisper
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro

View File

@@ -1,8 +1,7 @@
# Building The Docs
To build the docs locally, you need to have docker installed. The
docs are built using a custom [docker](https://www.docker.com/) image
and the [mkdocs](http://www.mkdocs.org/) tool.
docs are built using [Hugo](http://gohugo.io/) - a static site generator.
**Prepare the Docker Image**:
@@ -11,19 +10,40 @@ when running ``make docs-build`` depending on how your system's docker
service is configured):
```
$ git clone https://github.com/grafana/grafana.org
$ cd grafana.org
$ make docs-build
git clone https://github.com/grafana/grafana.org
cd grafana.org
make docs-build
```
**Build the Documentation**:
Now that the docker image has been prepared we can build the
docs. Switch your working directory back to the directory this file
(README.md) is in and run (possibly with ``sudo``):
grafana docs and start a docs server.
If you have not cloned the Grafana repository already then:
```
$ make docs
cd ..
git clone https://github.com/grafana/grafana
```
Switch your working directory to the directory this file
(README.md) is in.
```
cd grafana/docs
```
An AWS config file is required to build the docs Docker image and to publish the site to AWS. If you are building locally only and do not have any AWS credentials for docs.grafana.org then create an empty file named `awsconfig` in the current directory.
```
touch awsconfig
```
Then run (possibly with ``sudo``):
```
make watch
```
This command will not return control of the shell to the user. Instead
@@ -32,4 +52,21 @@ we created in the previous step.
Open [localhost:3004](http://localhost:3004) to view the docs.
### Images & Content
All markdown files are located in this repo (main grafana repo). But all images are added to the https://github.com/grafana/grafana.org repo. So the process of adding images is a bit complicated.
First you need create a feature (PR) branch of https://github.com/grafana/grafana.org so you can make change. Then add the image to the `/static/img/docs` directory. Then make a commit that adds the image.
Then run:
```
make docs-build
```
This will rebuild the docs docker container.
To be able to use the image your have to quit (CTRL-C) the `make watch` command (that you run in the same directory as this README). Then simply rerun `make watch`, it will restart the docs server but now with access to your image.
### Editing content
Changes to the markdown files should automatically cause a docs rebuild and live reload should reload the page in your browser.

View File

@@ -112,6 +112,8 @@ Grafana also supports the following Notification Channels:
- LINE
- DingDing
# Enable images in notifications {#external-image-store}
Grafana can render the panel associated with the alert rule and include that in the notification. Most Notification Channels require that this image be publicly accessable (Slack and PagerDuty for example). In order to include images in alert notifications, Grafana can upload the image to an image store. It currently supports

View File

@@ -52,12 +52,22 @@ Here you can specify the name of the alert rule and how often the scheduler shou
### Conditions
Currently the only condition type that exists is a `Query` condition that allows you to
specify a query letter, time range and an aggregation function. The letter refers to
a query you already have added in the **Metrics** tab. The result from the query and the aggregation function is
a single value that is then used in the threshold check. The query used in an alert rule cannot
contain any template variables. Currently we only support `AND` and `OR` operators between conditions and they are executed serially.
specify a query letter, time range and an aggregation function.
### Query condition example
```sql
avg() OF query(A, 5m, now) IS BELOW 14
```
- `avg()` Controls how the values for **each** serie should be reduced to a value that can be compared against the threshold. Click on the function to change it to another aggregation function.
- `query(A, 5m, now)` The letter defines what query to execute from the **Metrics** tab. The second two parameters defines the time range, `5m, now` means 5 minutes from now to now. You can also do `10m, now-2m` to define a time range that will be 10 minutes from now to 2 minutes from now. This is useful if you want to ignore the last 2 minutes of data.
- `IS BELOW 14` Defines the type of threshold and the threshold value. You can click on `IS BELOW` to change the type of threshold.
The query used in an alert rule cannot contain any template variables. Currently we only support `AND` and `OR` operators between conditions and they are executed serially.
For example, we have 3 conditions in the following order:
`condition:A(evaluates to: TRUE) OR condition:B(evaluates to: FALSE) AND condition:C(evaluates to: TRUE)`
*condition:A(evaluates to: TRUE) OR condition:B(evaluates to: FALSE) AND condition:C(evaluates to: TRUE)*
so the result will be calculated as ((TRUE OR FALSE) AND TRUE) = TRUE.
We plan to add other condition types in the future, like `Other Alert`, where you can include the state

View File

@@ -92,9 +92,10 @@ The Elasticsearch data source supports two types of queries you can use in the *
Query | Description
------------ | -------------
*{"find": "fields", "type": "keyword"} | Returns a list of field names with the index type `keyword`.
*{"find": "terms", "field": "@hostname"}* | Returns a list of values for a field using term aggregation. Query will user current dashboard time range as time range for query.
*{"find": "terms", "field": "@hostname", "size": 1000}* | Returns a list of values for a field using term aggregation. Query will user current dashboard time range as time range for query.
*{"find": "terms", "field": "@hostname", "query": '<lucene query>'}* | Returns a list of values for a field using term aggregation & and a specified lucene query filter. Query will use current dashboard time range as time range for query.
There is a default size limit of 500 on terms queries. Set the size property in your query to set a custom limit.
You can use other variables inside the query. Example query definition for a variable named `$host`.
```

View File

@@ -88,8 +88,8 @@ You can switch to raw query mode by clicking hamburger icon and then `Switch edi
- $m = replaced with measurement name
- $measurement = replaced with measurement name
- $col = replaced with column name
- $tag_hostname = replaced with the value of the hostname tag
- You can also use [[tag_hostname]] pattern replacement syntax
- $tag_exampletag = replaced with the value of the `exampletag` tag. To use your tag as an alias in the ALIAS BY field then the tag must be used to group by in the query.
- You can also use [[tag_hostname]] pattern replacement syntax. For example, in the ALIAS BY field using this text `Host: [[tag_hostname]]` would substitute in the `hostname` tag value for each legend value and an example legend value would be: `Host: server1`.
### Table query / raw data

View File

@@ -0,0 +1,50 @@
+++
title = "What's New in Grafana v4.4"
description = "Feature & improvement highlights for Grafana v4.4"
keywords = ["grafana", "new", "documentation", "4.4.0"]
type = "docs"
[menu.docs]
name = "Version 4.4"
identifier = "v4.4"
parent = "whatsnew"
weight = -2
+++
## What's New in Grafana v4.4
Grafana v4.4 is now [available for download](https://grafana.com/grafana/download/4.4.0).
**Highlights**:
- Dashboard History - version control for dashboards.
## New Features
**Dashboard History**: View dashboard version history, compare any two versions (summary & json diffs), restore to old version. This big feature
was contributed by **Walmart Labs**. Big thanks to them for this massive contribution!
Initial feature request: [#4638](https://github.com/grafana/grafana/issues/4638)
Pull Request: [#8472](https://github.com/grafana/grafana/pull/8472)
## Enhancements
* **Elasticsearch**: Added filter aggregation label [#8420](https://github.com/grafana/grafana/pull/8420), thx [@tianzk](github.com/tianzk)
* **Sensu**: Added option for source and handler [#8405](https://github.com/grafana/grafana/pull/8405), thx [@joemiller](github.com/joemiller)
* **CSV**: Configurable csv export datetime format [#8058](https://github.com/grafana/grafana/issues/8058), thx [@cederigo](github.com/cederigo)
* **Table Panel**: Column style that preserves formatting/indentation (like pre tag) [#6617](https://github.com/grafana/grafana/issues/6617)
* **DingDing**: Add DingDing Alert Notifier [#8473](https://github.com/grafana/grafana/pull/8473) thx [@jiamliang](https://github.com/jiamliang)
## Minor Enhancements
* **Elasticsearch**: Add option for result set size in raw_document [#3426](https://github.com/grafana/grafana/issues/3426) [#8527](https://github.com/grafana/grafana/pull/8527), thx [@mk-dhia](github.com/mk-dhia)
## Bug Fixes
* **Graph**: Bug fix for negative values in histogram mode [#8628](https://github.com/grafana/grafana/issues/8628)
## Download
Head to the [v4.4 download page](https://grafana.com/grafana/download) for download links & instructions.
## Thanks
A big thanks to all the Grafana users who contribute by submitting PRs, bug reports, helping out on our [community site](https://community.grafana.com/) and providing feedback!

View File

@@ -0,0 +1,321 @@
+++
title = "Dashboard Versions HTTP API "
description = "Grafana Dashboard Versions HTTP API"
keywords = ["grafana", "http", "documentation", "api", "dashboard", "versions"]
aliases = ["/http_api/dashboardversions/"]
type = "docs"
[menu.docs]
name = "Dashboard Versions"
parent = "http_api"
+++
# Dashboard Versions
## Get all dashboard versions
Query parameters:
- **limit** - Maximum number of results to return
- **start** - Version to start from when returning queries
`GET /api/dashboards/id/:dashboardId/versions`
Gets all existing dashboard versions for the dashboard with the given `dashboardId`.
**Example request for getting all dashboard versions**:
```http
GET /api/dashboards/id/1/versions?limit=2?start=0 HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
**Example Response**
```http
HTTP/1.1 200 OK
Content-Type: application/json; charset=UTF-8
Content-Length: 428
```
Status Codes:
- **200** - Ok
- **400** - Errors
- **401** - Unauthorized
- **404** - Dashboard version not found
## Get dashboard version
`GET /api/dashboards/id/:dashboardId/versions/:id`
Get the dashboard version with the given id, for the dashboard with the given id.
**Example request for getting a dashboard version**:
```http
GET /api/dashboards/id/1/versions/1 HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
**Example response**:
```http
HTTP/1.1 200 OK
Content-Type: application/json; charset=UTF-8
Content-Length: 1300
```
Status Codes:
- **200** - Ok
- **401** - Unauthorized
- **404** - Dashboard version not found
## Restore dashboard
`POST /api/dashboards/id/:dashboardId/restore`
Restores a dashboard to a given dashboard version.
**Example request for restoring a dashboard version**:
```http
POST /api/dashboards/id/1/restore
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
{
"version": 1
}
```
JSON body schema:
- **version** - The dashboard version to restore to
**Example response**:
```http
HTTP/1.1 200 OK
Content-Type: application/json; charset=UTF-8
Content-Length: 67
```
JSON response body schema:
- **slug** - the URL friendly slug of the dashboard's title
- **status** - whether the restoration was successful or not
- **version** - the new dashboard version, following the restoration
Status codes:
- **200** - OK
- **401** - Unauthorized
- **404** - Not found (dashboard not found or dashboard version not found)
- **500** - Internal server error (indicates issue retrieving dashboard tags from database)
**Example error response**
```http
HTTP/1.1 404 Not Found
Content-Type: application/json; charset=UTF-8
Content-Length: 46
```
JSON response body schema:
- **message** - Message explaining the reason for the request failure.
## Compare dashboard versions
`POST /api/dashboards/calculate-diff`
Compares two dashboard versions by calculating the JSON diff of them.
**Example request**:
```http
POST /api/dashboards/calculate-diff HTTP/1.1
Accept: text/html
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
JSON body schema:
- **base** - an object representing the base dashboard version
- **new** - an object representing the new dashboard version
- **diffType** - the type of diff to return. Can be "json" or "basic".
**Example response (JSON diff)**:
```http
HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
```
The response is a textual respresentation of the diff, with the dashboard values being in JSON, similar to the diffs seen on sites like GitHub or GitLab.
Status Codes:
- **200** - Ok
- **400** - Bad request (invalid JSON sent)
- **401** - Unauthorized
- **404** - Not found
**Example response (basic diff)**:
```http
HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
```
The response here is a summary of the changes, derived from the diff between the two JSON objects.
Status Codes:
- **200** - OK
- **400** - Bad request (invalid JSON sent)
- **401** - Unauthorized
- **404** - Not found
POST /api/dashboards/id/1/restore
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
{
"version": 1
}
```
JSON body schema:
- **version** - The dashboard version to restore to
**Example response**:
```http
HTTP/1.1 200 OK
Content-Type: application/json; charset=UTF-8
Content-Length: 67
{
"slug": "my-dashboard",
"status": "success",
"version": 3
}
```
JSON response body schema:
- **slug** - the URL friendly slug of the dashboard's title
- **status** - whether the restoration was successful or not
- **version** - the new dashboard version, following the restoration
Status codes:
- **200** - OK
- **401** - Unauthorized
- **404** - Not found (dashboard not found or dashboard version not found)
- **500** - Internal server error (indicates issue retrieving dashboard tags from database)
**Example error response**
```http
HTTP/1.1 404 Not Found
Content-Type: application/json; charset=UTF-8
Content-Length: 46
{
"message": "Dashboard version not found"
}
```
JSON response body schema:
- **message** - Message explaining the reason for the request failure.
## Compare dashboard versions
`POST /api/dashboards/calculate-diff`
Compares two dashboard versions by calculating the JSON diff of them.
**Example request**:
```http
POST /api/dashboards/calculate-diff HTTP/1.1
Accept: text/html
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
{
"base": {
"dashboardId": 1,
"version": 1
},
"new": {
"dashboardId": 1,
"version": 2
},
"diffType": "json"
}
```
JSON body schema:
- **base** - an object representing the base dashboard version
- **new** - an object representing the new dashboard version
- **diffType** - the type of diff to return. Can be "json" or "basic".
**Example response (JSON diff)**:
```http
HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
<p id="l1" class="diff-line diff-json-same">
<!-- Diff omitted -->
</p>
```
The response is a textual respresentation of the diff, with the dashboard values being in JSON, similar to the diffs seen on sites like GitHub or GitLab.
Status Codes:
- **200** - Ok
- **400** - Bad request (invalid JSON sent)
- **401** - Unauthorized
- **404** - Not found
**Example response (basic diff)**:
```http
HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
<div class="diff-group">
<!-- Diff omitted -->
</div>
```
The response here is a summary of the changes, derived from the diff between the two JSON objects.
Status Codes:
- **200** - OK
- **400** - Bad request (invalid JSON sent)
- **401** - Unauthorized
- **404** - Not found

View File

@@ -52,6 +52,15 @@ parent = "http_api"
"expires": 3600
}
JSON Body schema:
- **dashboard** Required. The complete dashboard model.
- **name** Optional. snapshot name
- **expires** - Optional. When the snapshot should expire in seconds. 3600 is 1 hour, 86400 is 1 day. Default is never to expire.
- **external** - Optional. Save the snapshot on an external server rather than locally. Default is `false`.
- **key** - Optional. Define the unique key. Required if **external** is `true`.
- **deleteKey** - Optional. Unique key used to delete the snapshot. It is different from the **key** so that only the creator can delete the snapshot. Required if **external** is `true`.
**Example Response**:
HTTP/1.1 200

View File

@@ -203,6 +203,12 @@ For MySQL, use either `true`, `false`, or `skip-verify`.
(MySQL only) The common name field of the certificate used by the `mysql` server. Not necessary if `ssl_mode` is set to `skip-verify`.
### max_idle_conn
The maximum number of connections in the idle connection pool.
### max_open_conn
The maximum number of open connections to the database.
<hr />
## [security]
@@ -444,20 +450,29 @@ false only pre-existing Grafana users will be able to login (if ldap authenticat
<hr>
## [auth.proxy]
This feature allows you to handle authentication in a http reverse proxy.
### enabled
Defaults to `false`
### header_name
Defaults to X-WEBAUTH-USER
#### header_property
Defaults to username but can also be set to email
### auto_sign_up
Set to `true` to enable auto sign up of users who do not exist in Grafana DB. Defaults to `true`.
### whitelist
Limit where auth proxy requests come from by configuring a list of IP addresses. This can be used to prevent users spoofing the X-WEBAUTH-USER header.
<hr>
## [session]

View File

@@ -15,7 +15,7 @@ weight = 1
Description | Download
------------ | -------------
Stable for Debian-based Linux | [grafana_4.3.0_amd64.deb](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_4.3.0_amd64.deb)
Stable for Debian-based Linux | [grafana_4.4.0_amd64.deb](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_4.4.0_amd64.deb)
Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing
installation.
@@ -23,9 +23,9 @@ installation.
## Install Stable
```bash
wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_4.2.0_amd64.deb
wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_4.4.0_amd64.deb
sudo apt-get install -y adduser libfontconfig
sudo dpkg -i grafana_4.2.0_amd64.deb
sudo dpkg -i grafana_4.4.0_amd64.deb
```
<!--
@@ -81,6 +81,7 @@ sudo apt-get install -y apt-transport-https
- Installs systemd service (if systemd is available) name `grafana-server.service`
- The default configuration sets the log file at `/var/log/grafana/grafana.log`
- The default configuration specifies an sqlite3 db at `/var/lib/grafana/grafana.db`
- Installs HTML/JS/CSS and other Grafana files at `/usr/share/grafana`
## Start the server (init.d service)

View File

@@ -15,7 +15,7 @@ weight = 2
Description | Download
------------ | -------------
Stable for CentOS / Fedora / OpenSuse / Redhat Linux | [4.3.0 (x86-64 rpm)](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.3.0-1.x86_64.rpm)
Stable for CentOS / Fedora / OpenSuse / Redhat Linux | [4.4.0 (x86-64 rpm)](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.4.0-1.x86_64.rpm)
Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing
installation.
@@ -24,19 +24,19 @@ installation.
You can install Grafana using Yum directly.
$ sudo yum install https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.2.0-1.x86_64.rpm
$ sudo yum install https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.4.0-1.x86_64.rpm
Or install manually using `rpm`.
#### On CentOS / Fedora / Redhat:
$ wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.2.0-1.x86_64.rpm
$ wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.4.0-1.x86_64.rpm
$ sudo yum install initscripts fontconfig
$ sudo rpm -Uvh grafana-4.2.0-1.x86_64.rpm
$ sudo rpm -Uvh grafana-4.4.0-1.x86_64.rpm
#### On OpenSuse:
$ sudo rpm -i --nodeps grafana-4.2.0-1.x86_64.rpm
$ sudo rpm -i --nodeps grafana-4.4.0-1.x86_64.rpm
## Install via YUM Repository

View File

@@ -13,7 +13,7 @@ weight = 3
Description | Download
------------ | -------------
Latest stable package for Windows | [grafana.4.3.0.windows-x64.zip](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.3.0.windows-x64.zip)
Latest stable package for Windows | [grafana.4.4.0.windows-x64.zip](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.4.0.windows-x64.zip)
Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing
installation.

View File

@@ -15,14 +15,21 @@ dev environment. Grafana ships with its own required backend server; also comple
- [Go 1.8.1](https://golang.org/dl/)
- [NodeJS LTS](https://nodejs.org/download/)
- [Git](https://git-scm.com/downloads)
## Get Code
Create a directory for the project and set your path accordingly. Then download and install Grafana into your $GOPATH directory
Create a directory for the project and set your path accordingly (or use the [default Go workspace directory](https://golang.org/doc/code.html#GOPATH)). Then download and install Grafana into your $GOPATH directory:
```
export GOPATH=`pwd`
go get github.com/grafana/grafana
```
On Windows use setx instead of export and then restart your command prompt:
```
setx GOPATH %cd%
```
You may see an error such as: `package github.com/grafana/grafana: no buildable Go source files`. This is just a warning, and you can proceed with the directions.
## Building the backend
@@ -36,6 +43,12 @@ go run build.go build # (or 'go build ./pkg/cmd/grafana-server')
The Grafana backend includes Sqlite3 which requires GCC to compile. So in order to compile Grafana on windows you need
to install GCC. We recommend [TDM-GCC](http://tdm-gcc.tdragon.net/download).
[node-gyp](https://github.com/nodejs/node-gyp#installation) is the Node.js native addon build tool and it requires extra dependencies to be installed on Windows. In a command prompt which is run as administrator, run:
```
npm --add-python-to-path='true' --debug install --global windows-build-tools
```
## Build the Front-end Assets
To build less to css for the frontend you will need a recent version of node (v0.12.0),
@@ -55,6 +68,8 @@ go get github.com/Unknwon/bra
bra run
```
If the `bra run` command does not work, make sure that the bin directory in your Go workspace directory is in the path. $GOPATH/bin (or %GOPATH%\bin in Windows) is in your path.
## Running Grafana Locally
You can run a local instance of Grafana by running:
```
@@ -94,3 +109,24 @@ Learn more about Grafana config options in the [Configuration section](/installa
## Create a pull requests
Please contribute to the Grafana project and submit a pull request! Build new features, write or update documentation, fix bugs and generally make Grafana even more awesome.
## Troubleshooting
**Problem**: PhantomJS or node-sass errors when running grunt
**Solution**: delete the node_modules directory. Install [node-gyp](https://github.com/nodejs/node-gyp#installation) properly for your platform. Then run `yarn install --pure-lockfile` again.
<br><br>
**Problem**: When running `bra run` for the first time you get an error that it is not a recognized command.
**Solution**: Add the bin directory in your Go workspace directory to the path. Per default this is `$HOME/go/bin` on Linux and `%USERPROFILE%\go\bin` on Windows or `$GOPATH/bin` (`%GOPATH%\bin` on Windows) if you have set your own workspace directory.
<br><br>
**Problem**: When executing a `go get` command on Windows and you get an error about the git repository not existing.
**Solution**: `go get` requires Git. If you run `go get` without Git then it will create an empty directory in your Go workspace for the library you are trying to get. Even after installing Git, you will get a similar error. To fix this, delete the empty directory (for example: if you tried to run `go get github.com/Unknwon/bra` then delete `%USERPROFILE%\go\src\github.com\Unknwon\bra`) and run the `go get` command again.
<br><br>
**Problem**: On Windows, getting errors about a tool not being installed even though you just installed that tool.
**Solution**: It is usually because it got added to the path and you have to restart your command prompt to use it.

View File

@@ -65,7 +65,7 @@ Each field in the dashboard JSON is explained below with its usage:
| **timezone** | timezone of dashboard, i.e. `utc` or `browser` |
| **editable** | whether a dashboard is editable or not |
| **hideControls** | whether row controls on the left in green are hidden or not |
| **graphTooltip** | TODO |
| **graphTooltip** | 0 for no shared crosshair or tooltip (default), 1 for shared crosshair, 2 for shared crosshair AND shared tooltip |
| **rows** | row metadata, see [rows section](#rows) for details |
| **time** | time range for dashboard, i.e. last 6 hours, last 7 days, etc |
| **timepicker** | timepicker metadata, see [timepicker section](#timepicker) for details |

View File

@@ -0,0 +1,40 @@
+++
title = "Dashboard Version History"
keywords = ["grafana", "dashboard", "documentation", "version", "history"]
type = "docs"
[menu.docs]
name = "Dashboard Version History"
parent = "dashboard_features"
weight = 100
+++
# Dashboard Version History
Whenever you save a version of your dashboard, a copy of that version is saved so that previous versions of your dashboard are never lost. A list of these versions is available by clicking the dashboard menu dropdown, and clicking "Version history".
<img class="no-shadow" src="/img/docs/v4/dashboard_versions_list.png">
The dashboard version history feature lets you compare and restore to previously saved dashboard versions.
## Comparing two dashboard versions
To compare two dashboard versions, select the two versions from the list that you wish to compare. Once selected, the "Compare versions" button will become clickable. Click the button to view the diff between the two versions.
<img class="no-shadow" src="/img/docs/v4/dashboard_versions_select.png">
Upon clicking the button, you'll be brought to the diff view. By default, you'll see a textual summary of the changes, like in the image below.
<img class="no-shadow" src="/img/docs/v4/dashboard_versions_diff_basic.png">
If you want to view the diff of the raw JSON that represents your dashboard, you can do that as well by clicking the "JSON Diff" tab on the left.
If you want to restore to the version you are diffing against, you can do so by clicking the "Restore to version <x>" button in the top right.
## Restoring to a previously saved dashboard version
If you need to restore to a previously saved dashboard version, you can do so by either clicking the "Restore" button on the right of a row in the dashboard version list, or by clicking the "Restore to version <x>" button appearing in the diff view. Clicking the button will bring up the following popup prompting you to confirm the restoration.
<img class="no-shadow" src="/img/docs/v4/dashboard_versions_restore.png">
After restoring to a previous version, a new version will be created containing the same exact data as the previous version, only with a different version number. This is indicated in the "Notes column" for the row in the new dashboard version. This is done simply to ensure your previous dashboard versions are not affected by the change.

View File

@@ -141,6 +141,42 @@ Use the `Interval` type to create a variable that represents a time span (eg. `1
This variable type is useful as a parameter to group by time (for InfluxDB), Date histogram interval (for Elasticsearch) or as a *summarize* function parameter (for Graphite).
Example using the template variable `myinterval` of type `Interval` in a graphite function:
```
summarize($myinterval, sum, false)
```
## Global Built-in Variables
Grafana has global built-in variables that can be used in expressions in the query editor.
### The $__interval Variable
This $__interval variable is similar to the `auto` interval variable that is described above. It can be used as a parameter to group by time (for InfluxDB), Date histogram interval (for Elasticsearch) or as a *summarize* function parameter (for Graphite).
Grafana automatically calculates an interval that can be used to group by time in queries. When there are more data points than can be shown on a graph then queries can be made more efficient by grouping by a larger interval. It is more efficient to group by 1 day than by 10s when looking at 3 months of data and the graph will look the same and the query will be faster. The `$__interval` is calculated using the time range and the width of the graph (the number of pixels).
Approximate Calculation: `(from - to) / resolution`
For example, when the time range is 1 hour and the graph is full screen, then the interval might be calculated to `2m` - points are grouped in 2 minute intervals. If the time range is 6 months and the graph is full screen, then the interval might be `1d` (1 day) - points are grouped by day.
In the InfluxDB data source, the legacy variable `$interval` is the same variable. `$__interval` should be used instead.
The InfluxDB and Elasticsearch data sources have `Group by time interval` fields that are used to hard code the interval or to set the minimum limit for the `$__interval` variable (by using the `>` syntax -> `>10m`).
### The $__interval_ms Variable
This variable is the `$__interval` variable in milliseconds (and not a time interval formatted string). For example, if the `$__interval` is `20m` then the `$__interval_ms` is `1200000`.
### The $timeFilter or $__timeFilter Variable
The `$timeFilter` variable returns the currently selected time range as an expression. For example, the time range interval `Last 7 days` expression is `time > now() - 7d`.
This is used in the WHERE clause for the InfluxDB data source. Grafana adds it automatically to InfluxDB queries when in Query Editor Mode. It has to be added manually in Text Editor Mode: `WHERE $timeFilter`.
The `$__timeFilter` is used in the MySQL data source.
## Repeating Panels
Template variables can be very useful to dynamically change your queries across a whole dashboard. If you want

View File

@@ -0,0 +1,74 @@
+++
title = "API Tutorial: How To Create API Tokens And Dashboards For A Specific Organization"
type = "docs"
keywords = ["grafana", "tutorials", "API", "Token", "Org", "Organization"]
[menu.docs]
parent = "tutorials"
weight = 10
+++
# API Tutorial: How To Create API Tokens And Dashboards For A Specific Organization
A common scenario is to want to via the Grafana API setup new Grafana organizations or to add dynamically generated dashboards to an existing organization.
## Authentication
There are two ways to authenticate against the API: basic authentication and API Tokens.
Some parts of the API are only available through basic authentication and these parts of the API usually require that the user is a Grafana Admin. But all organization actions are accessed via an API Token. An API Token is tied to an organization and can be used to create dashboards etc but only for that organization.
## How To Create A New Organization and an API Token
The task is to create a new organization and then add a Token that can be used by other users. In the examples below which use basic auth, the user is `admin` and the password is `admin`.
1. [Create the org](http://docs.grafana.org/http_api/org/#create-organisation). Here is an example using curl:
```
curl -X POST -H "Content-Type: application/json" -d '{"name":"apiorg"}' http://admin:admin@localhost:3000/api/orgs
```
This should return a response: `{"message":"Organization created","orgId":6}`. Use the orgId for the next steps.
2. Optional step. If the org was created previously and/or step 3 fails then first [add your Admin user to the org](http://docs.grafana.org/http_api/org/#add-user-in-organisation):
```
curl -X POST -H "Content-Type: application/json" -d '{"loginOrEmail":"admin", "role": "Admin"}' http://admin:admin@localhost:3000/api/orgs/<org id of new org>/users
```
3. [Switch the org context for the Admin user to the new org](http://docs.grafana.org/http_api/user/#switch-user-context):
```
curl -X POST http://admin:admin@localhost:3000/api/user/using/<id of new org>
```
4. [Create the API token](http://docs.grafana.org/http_api/auth/#create-api-key):
```
curl -X POST -H "Content-Type: application/json" -d '{"name":"apikeycurl", "role": "Admin"}' http://admin:admin@localhost:3000/api/auth/keys
```
This should return a response: `{"name":"apikeycurl","key":"eyJrIjoiR0ZXZmt1UFc0OEpIOGN5RWdUalBJTllUTk83VlhtVGwiLCJuIjoiYXBpa2V5Y3VybCIsImlkIjo2fQ=="}`.
Save the key returned here in your password manager as it is not possible to fetch again it in the future.
## How To Add A Dashboard
Using the Token that was created in the previous step, you can create a dashboard or carry out other actions without having to switch organizations.
1. [Add a dashboard](http://docs.grafana.org/http_api/dashboard/#create-update-dashboard) using the key (or bearer token as it is also called):
```
curl -X POST --insecure -H "Authorization: Bearer eyJrIjoiR0ZXZmt1UFc0OEpIOGN5RWdUalBJTllUTk83VlhtVGwiLCJuIjoiYXBpa2V5Y3VybCIsImlkIjo2fQ==" -H "Content-Type: application/json" -d '{
"dashboard": {
"id": null,
"title": "Production Overview",
"tags": [ "templated" ],
"timezone": "browser",
"rows": [
{
}
],
"schemaVersion": 6,
"version": 0
},
"overwrite": false
}' http://localhost:3000/api/dashboards/db
```
This import will not work if you exported the dashboard via the Share -> Export menu in the Grafana UI (it strips out data source names etc.). View the JSON and save it to a file instead or fetch the dashboard JSON via the API.

View File

@@ -35,6 +35,4 @@ But we suggest that you store the session in redis/memcache since it makes it ea
## Alerting
Currently alerting does not support high availability. But this is something that we will be working on in the future.
Currently alerting supports a limited form of high availability. Since v4.2.0 of Grafana, alert notifications are deduped when running multiple servers. This means all alerts are executed on every server but no duplicate alert notifications are sent due to the deduping logic. Proper load balancing of alerts will be introduced in the future.

View File

@@ -4,7 +4,7 @@
"company": "Coding Instinct AB"
},
"name": "grafana",
"version": "4.3.2",
"version": "4.4.1",
"repository": {
"type": "git",
"url": "http://github.com/grafana/grafana.git"

View File

@@ -1,5 +1,5 @@
#! /usr/bin/env bash
version=4.3.0
version=4.4.0
wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_${version}_amd64.deb

View File

@@ -223,6 +223,13 @@ func (hs *HttpServer) registerRoutes() {
// Dashboard
r.Group("/dashboards", func() {
r.Combo("/db/:slug").Get(GetDashboard).Delete(DeleteDashboard)
r.Get("/id/:dashboardId/versions", wrap(GetDashboardVersions))
r.Get("/id/:dashboardId/versions/:id", wrap(GetDashboardVersion))
r.Post("/id/:dashboardId/restore", reqEditorRole, bind(dtos.RestoreDashboardVersionCommand{}), wrap(RestoreDashboardVersion))
r.Post("/calculate-diff", bind(dtos.CalculateDiffOptions{}), wrap(CalculateDashboardDiff))
r.Post("/db", reqEditorRole, bind(m.SaveDashboardCommand{}), wrap(PostDashboard))
r.Get("/file/:file", GetDashboardFromJsonFile)
r.Get("/home", wrap(GetHomeDashboard))
@@ -278,6 +285,7 @@ func (hs *HttpServer) registerRoutes() {
}, reqEditorRole)
r.Get("/annotations", wrap(GetAnnotations))
r.Post("/annotations/mass-delete", reqOrgAdmin, bind(dtos.DeleteAnnotationsCmd{}), wrap(DeleteAnnotations))
r.Group("/annotations", func() {
r.Post("/", bind(dtos.PostAnnotationsCmd{}), wrap(PostAnnotation))

View File

@@ -30,12 +30,13 @@ var customMetricsDimensionsMap map[string]map[string]map[string]*CustomMetricsCa
func init() {
metricsMap = map[string][]string{
"AWS/ApiGateway": {"4XXError", "5XXError", "CacheHitCount", "CacheMissCount", "Count", "IntegrationLatency", "Latency"},
"AWS/ApplicationELB": {"ActiveConnectionCount", "ClientTLSNegotiationErrorCount", "HealthyHostCount", "HTTPCode_ELB_4XX_Count", "HTTPCode_ELB_5XX_Count", "HTTPCode_Target_2XX_Count", "HTTPCode_Target_3XX_Count", "HTTPCode_Target_4XX_Count", "HTTPCode_Target_5XX_Count", "NewConnectionCount", "ProcessedBytes", "RejectedConnectionCount", "RequestCount", "TargetConnectionErrorCount", "TargetResponseTime", "TargetTLSNegotiationErrorCount", "UnHealthyHostCount"},
"AWS/ApplicationELB": {"ActiveConnectionCount", "ClientTLSNegotiationErrorCount", "HealthyHostCount", "HTTPCode_ELB_4XX_Count", "HTTPCode_ELB_5XX_Count", "HTTPCode_Target_2XX_Count", "HTTPCode_Target_3XX_Count", "HTTPCode_Target_4XX_Count", "HTTPCode_Target_5XX_Count", "IPv6ProcessedBytes", "IPv6RequestCount", "NewConnectionCount", "ProcessedBytes", "RejectedConnectionCount", "RequestCount", "TargetConnectionErrorCount", "TargetResponseTime", "TargetTLSNegotiationErrorCount", "UnHealthyHostCount"},
"AWS/AutoScaling": {"GroupMinSize", "GroupMaxSize", "GroupDesiredCapacity", "GroupInServiceInstances", "GroupPendingInstances", "GroupStandbyInstances", "GroupTerminatingInstances", "GroupTotalInstances"},
"AWS/Billing": {"EstimatedCharges"},
"AWS/CloudFront": {"Requests", "BytesDownloaded", "BytesUploaded", "TotalErrorRate", "4xxErrorRate", "5xxErrorRate"},
"AWS/CloudSearch": {"SuccessfulRequests", "SearchableDocuments", "IndexUtilization", "Partitions"},
"AWS/DynamoDB": {"ConditionalCheckFailedRequests", "ConsumedReadCapacityUnits", "ConsumedWriteCapacityUnits", "OnlineIndexConsumedWriteCapacity", "OnlineIndexPercentageProgress", "OnlineIndexThrottleEvents", "ProvisionedReadCapacityUnits", "ProvisionedWriteCapacityUnits", "ReadThrottleEvents", "ReturnedBytes", "ReturnedItemCount", "ReturnedRecordsCount", "SuccessfulRequestLatency", "SystemErrors", "ThrottledRequests", "UserErrors", "WriteThrottleEvents"},
"AWS/DMS": {"FreeableMemory", "WriteIOPS", "ReadIOPS", "WriteThroughput", "ReadThroughput", "WriteLatency", "ReadLatency", "SwapUsage", "NetworkTransmitThroughput", "NetworkReceiveThroughput", "FullLoadThroughputBandwidthSource", "FullLoadThroughputBandwidthTarget", "FullLoadThroughputRowsSource", "FullLoadThroughputRowsTarget", "CDCIncomingChanges", "CDCChangesMemorySource", "CDCChangesMemoryTarget", "CDCChangesDiskSource", "CDCChangesDiskTarget", "CDCThroughputBandwidthTarget", "CDCThroughputRowsSource", "CDCThroughputRowsTarget", "CDCLatencySource", "CDCLatencyTarget"},
"AWS/DynamoDB": {"ConditionalCheckFailedRequests", "ConsumedReadCapacityUnits", "ConsumedWriteCapacityUnits", "OnlineIndexConsumedWriteCapacity", "OnlineIndexPercentageProgress", "OnlineIndexThrottleEvents", "ProvisionedReadCapacityUnits", "ProvisionedWriteCapacityUnits", "ReadThrottleEvents", "ReturnedBytes", "ReturnedItemCount", "ReturnedRecordsCount", "SuccessfulRequestLatency", "SystemErrors", "TimeToLiveDeletedItemCount", "ThrottledRequests", "UserErrors", "WriteThrottleEvents"},
"AWS/EBS": {"VolumeReadBytes", "VolumeWriteBytes", "VolumeReadOps", "VolumeWriteOps", "VolumeTotalReadTime", "VolumeTotalWriteTime", "VolumeIdleTime", "VolumeQueueLength", "VolumeThroughputPercentage", "VolumeConsumedReadWriteOps", "BurstBalance"},
"AWS/EC2": {"CPUCreditUsage", "CPUCreditBalance", "CPUUtilization", "DiskReadOps", "DiskWriteOps", "DiskReadBytes", "DiskWriteBytes", "NetworkIn", "NetworkOut", "NetworkPacketsIn", "NetworkPacketsOut", "StatusCheckFailed", "StatusCheckFailed_Instance", "StatusCheckFailed_System"},
"AWS/EC2Spot": {"AvailableInstancePoolsCount", "BidsSubmittedForCapacity", "EligibleInstancePoolCount", "FulfilledCapacity", "MaxPercentCapacityAllocation", "PendingCapacity", "PercentCapacityAllocation", "TargetCapacity", "TerminatingCapacity"},
@@ -68,27 +69,28 @@ func init() {
"CoreNodesRunning", "CoreNodesPending", "LiveDataNodes", "MRTotalNodes", "MRActiveNodes", "MRLostNodes", "MRUnhealthyNodes", "MRDecommissionedNodes", "MRRebootedNodes",
"S3BytesWritten", "S3BytesRead", "HDFSUtilization", "HDFSBytesRead", "HDFSBytesWritten", "MissingBlocks", "CorruptBlocks", "TotalLoad", "MemoryTotalMB", "MemoryReservedMB", "MemoryAvailableMB", "MemoryAllocatedMB", "PendingDeletionBlocks", "UnderReplicatedBlocks", "DfsPendingReplicationBlocks", "CapacityRemainingGB",
"HbaseBackupFailed", "MostRecentBackupDuration", "TimeSinceLastSuccessfulBackup"},
"AWS/ES": {"ClusterStatus.green", "ClusterStatus.yellow", "ClusterStatus.red", "Nodes", "SearchableDocuments", "DeletedDocuments", "CPUUtilization", "FreeStorageSpace", "JVMMemoryPressure", "AutomatedSnapshotFailure", "MasterCPUUtilization", "MasterFreeStorageSpace", "MasterJVMMemoryPressure", "ReadLatency", "WriteLatency", "ReadThroughput", "WriteThroughput", "DiskQueueLength", "ReadIOPS", "WriteIOPS"},
"AWS/ES": {"ClusterStatus.green", "ClusterStatus.yellow", "ClusterStatus.red", "ClusterUsedSpace", "Nodes", "SearchableDocuments", "DeletedDocuments", "CPUCreditBalance", "CPUUtilization", "FreeStorageSpace", "JVMMemoryPressure", "AutomatedSnapshotFailure", "MasterCPUCreditBalance", "MasterCPUUtilization", "MasterFreeStorageSpace", "MasterJVMMemoryPressure", "ReadLatency", "WriteLatency", "ReadThroughput", "WriteThroughput", "DiskQueueDepth", "ReadIOPS", "WriteIOPS"},
"AWS/Events": {"Invocations", "FailedInvocations", "TriggeredRules", "MatchedEvents", "ThrottledRules"},
"AWS/Firehose": {"DeliveryToElasticsearch.Bytes", "DeliveryToElasticsearch.Records", "DeliveryToElasticsearch.Success", "DeliveryToRedshift.Bytes", "DeliveryToRedshift.Records", "DeliveryToRedshift.Success", "DeliveryToS3.Bytes", "DeliveryToS3.DataFreshness", "DeliveryToS3.Records", "DeliveryToS3.Success", "IncomingBytes", "IncomingRecords", "DescribeDeliveryStream.Latency", "DescribeDeliveryStream.Requests", "ListDeliveryStreams.Latency", "ListDeliveryStreams.Requests", "PutRecord.Bytes", "PutRecord.Latency", "PutRecord.Requests", "PutRecordBatch.Bytes", "PutRecordBatch.Latency", "PutRecordBatch.Records", "PutRecordBatch.Requests", "UpdateDeliveryStream.Latency", "UpdateDeliveryStream.Requests"},
"AWS/IoT": {"PublishIn.Success", "PublishOut.Success", "Subscribe.Success", "Ping.Success", "Connect.Success", "GetThingShadow.Accepted"},
"AWS/Kinesis": {"GetRecords.Bytes", "GetRecords.IteratorAge", "GetRecords.IteratorAgeMilliseconds", "GetRecords.Latency", "GetRecords.Records", "GetRecords.Success", "IncomingBytes", "IncomingRecords", "PutRecord.Bytes", "PutRecord.Latency", "PutRecord.Success", "PutRecords.Bytes", "PutRecords.Latency", "PutRecords.Records", "PutRecords.Success", "ReadProvisionedThroughputExceeded", "WriteProvisionedThroughputExceeded", "IteratorAgeMilliseconds", "OutgoingBytes", "OutgoingRecords"},
"AWS/KinesisAnalytics": {"Bytes", "MillisBehindLatest", "Records", "Success"},
"AWS/Lambda": {"Invocations", "Errors", "Duration", "Throttles"},
"AWS/Lambda": {"Invocations", "Errors", "Duration", "Throttles", "IteratorAge"},
"AWS/Logs": {"IncomingBytes", "IncomingLogEvents", "ForwardedBytes", "ForwardedLogEvents", "DeliveryErrors", "DeliveryThrottling"},
"AWS/ML": {"PredictCount", "PredictFailureCount"},
"AWS/OpsWorks": {"cpu_idle", "cpu_nice", "cpu_system", "cpu_user", "cpu_waitio", "load_1", "load_5", "load_15", "memory_buffers", "memory_cached", "memory_free", "memory_swap", "memory_total", "memory_used", "procs"},
"AWS/Redshift": {"CPUUtilization", "DatabaseConnections", "HealthStatus", "MaintenanceMode", "NetworkReceiveThroughput", "NetworkTransmitThroughput", "PercentageDiskSpaceUsed", "ReadIOPS", "ReadLatency", "ReadThroughput", "WriteIOPS", "WriteLatency", "WriteThroughput"},
"AWS/RDS": {"ActiveTransactions", "AuroraBinlogReplicaLag", "AuroraReplicaLag", "AuroraReplicaLagMaximum", "AuroraReplicaLagMinimum", "BinLogDiskUsage", "BlockedTransactions", "BufferCacheHitRatio", "CommitLatency", "CommitThroughput", "CPUCreditBalance", "CPUCreditUsage", "CPUUtilization", "DatabaseConnections", "DDLLatency", "DDLThroughput", "Deadlocks", "DiskQueueDepth", "DMLLatency", "DMLThroughput", "FailedSqlStatements", "FreeableMemory", "FreeStorageSpace", "LoginFailures", "NetworkReceiveThroughput", "NetworkTransmitThroughput", "ReadIOPS", "ReadLatency", "ReadThroughput", "ReplicaLag", "ResultSetCacheHitRatio", "SelectLatency", "SelectThroughput", "SwapUsage", "TotalConnections", "VolumeReadIOPS", "VolumeWriteIOPS", "WriteIOPS", "WriteLatency", "WriteThroughput"},
"AWS/Route53": {"HealthCheckStatus", "HealthCheckPercentageHealthy", "ConnectionTime", "SSLHandshakeTime", "TimeToFirstByte"},
"AWS/RDS": {"ActiveTransactions", "AuroraBinlogReplicaLag", "AuroraReplicaLag", "AuroraReplicaLagMaximum", "AuroraReplicaLagMinimum", "BinLogDiskUsage", "BlockedTransactions", "BufferCacheHitRatio", "CommitLatency", "CommitThroughput", "BinLogDiskUsage", "CPUCreditBalance", "CPUCreditUsage", "CPUUtilization", "DatabaseConnections", "DDLLatency", "DDLThroughput", "Deadlocks", "DeleteLatency", "DeleteThroughput", "DiskQueueDepth", "DMLLatency", "DMLThroughput", "EngineUptime", "FailedSqlStatements", "FreeableMemory", "FreeLocalStorage", "FreeStorageSpace", "InsertLatency", "InsertThroughput", "LoginFailures", "NetworkReceiveThroughput", "NetworkTransmitThroughput", "NetworkThroughput", "Queries", "ReadIOPS", "ReadLatency", "ReadThroughput", "ReplicaLag", "ResultSetCacheHitRatio", "SelectLatency", "SelectThroughput", "SwapUsage", "TotalConnections", "UpdateLatency", "UpdateThroughput", "VolumeBytesUsed", "VolumeReadIOPS", "VolumeWriteIOPS", "WriteIOPS", "WriteLatency", "WriteThroughput"},
"AWS/Route53": {"ChildHealthCheckHealthyCount", "HealthCheckStatus", "HealthCheckPercentageHealthy", "ConnectionTime", "SSLHandshakeTime", "TimeToFirstByte"},
"AWS/S3": {"BucketSizeBytes", "NumberOfObjects", "AllRequests", "GetRequests", "PutRequests", "DeleteRequests", "HeadRequests", "PostRequests", "ListRequests", "BytesDownloaded", "BytesUploaded", "4xxErrors", "5xxErrors", "FirstByteLatency", "TotalRequestLatency"},
"AWS/SES": {"Bounce", "Complaint", "Delivery", "Reject", "Send"},
"AWS/SNS": {"NumberOfMessagesPublished", "PublishSize", "NumberOfNotificationsDelivered", "NumberOfNotificationsFailed"},
"AWS/SQS": {"NumberOfMessagesSent", "SentMessageSize", "NumberOfMessagesReceived", "NumberOfEmptyReceives", "NumberOfMessagesDeleted", "ApproximateNumberOfMessagesDelayed", "ApproximateNumberOfMessagesVisible", "ApproximateNumberOfMessagesNotVisible"},
"AWS/SQS": {"NumberOfMessagesSent", "SentMessageSize", "NumberOfMessagesReceived", "NumberOfEmptyReceives", "NumberOfMessagesDeleted", "ApproximateAgeOfOldestMessage", "ApproximateNumberOfMessagesDelayed", "ApproximateNumberOfMessagesVisible", "ApproximateNumberOfMessagesNotVisible"},
"AWS/StorageGateway": {"CacheHitPercent", "CachePercentUsed", "CachePercentDirty", "CloudBytesDownloaded", "CloudDownloadLatency", "CloudBytesUploaded", "UploadBufferFree", "UploadBufferPercentUsed", "UploadBufferUsed", "QueuedWrites", "ReadBytes", "ReadTime", "TotalCacheSize", "WriteBytes", "WriteTime", "TimeSinceLastRecoveryPoint", "WorkingStorageFree", "WorkingStoragePercentUsed", "WorkingStorageUsed",
"CacheHitPercent", "CachePercentUsed", "CachePercentDirty", "ReadBytes", "ReadTime", "WriteBytes", "WriteTime", "QueuedWrites"},
"AWS/SWF": {"DecisionTaskScheduleToStartTime", "DecisionTaskStartToCloseTime", "DecisionTasksCompleted", "StartedDecisionTasksTimedOutOnClose", "WorkflowStartToCloseTime", "WorkflowsCanceled", "WorkflowsCompleted", "WorkflowsContinuedAsNew", "WorkflowsFailed", "WorkflowsTerminated", "WorkflowsTimedOut",
"ActivityTaskScheduleToCloseTime", "ActivityTaskScheduleToStartTime", "ActivityTaskStartToCloseTime", "ActivityTasksCanceled", "ActivityTasksCompleted", "ActivityTasksFailed", "ScheduledActivityTasksTimedOutOnClose", "ScheduledActivityTasksTimedOutOnStart", "StartedActivityTasksTimedOutOnClose", "StartedActivityTasksTimedOutOnHeartbeat"},
"AWS/VPN": {"TunnelState", "TunnelDataIn", "TunnelDataOut"},
"AWS/WAF": {"AllowedRequests", "BlockedRequests", "CountedRequests"},
"AWS/WorkSpaces": {"Available", "Unhealthy", "ConnectionAttempt", "ConnectionSuccess", "ConnectionFailure", "SessionLaunchTime", "InSessionLatency", "SessionDisconnect"},
"KMS": {"SecondsUntilKeyMaterialExpiration"},
@@ -100,6 +102,7 @@ func init() {
"AWS/Billing": {"ServiceName", "LinkedAccount", "Currency"},
"AWS/CloudFront": {"DistributionId", "Region"},
"AWS/CloudSearch": {},
"AWS/DMS": {"ReplicationInstanceIdentifier", "ReplicationTaskIdentifier"},
"AWS/DynamoDB": {"TableName", "GlobalSecondaryIndexName", "Operation", "StreamLabel"},
"AWS/EBS": {"VolumeId"},
"AWS/EC2": {"AutoScalingGroupName", "ImageId", "InstanceId", "InstanceType"},
@@ -121,14 +124,15 @@ func init() {
"AWS/ML": {"MLModelId", "RequestMode"},
"AWS/OpsWorks": {"StackId", "LayerId", "InstanceId"},
"AWS/Redshift": {"NodeID", "ClusterIdentifier"},
"AWS/RDS": {"DBInstanceIdentifier", "DBClusterIdentifier", "DatabaseClass", "EngineName"},
"AWS/Route53": {"HealthCheckId"},
"AWS/RDS": {"DBInstanceIdentifier", "DBClusterIdentifier", "DatabaseClass", "EngineName", "Role"},
"AWS/Route53": {"HealthCheckId", "Region"},
"AWS/S3": {"BucketName", "StorageType", "FilterId"},
"AWS/SES": {},
"AWS/SNS": {"Application", "Platform", "TopicName"},
"AWS/SQS": {"QueueName"},
"AWS/StorageGateway": {"GatewayId", "GatewayName", "VolumeId"},
"AWS/SWF": {"Domain", "WorkflowTypeName", "WorkflowTypeVersion", "ActivityTypeName", "ActivityTypeVersion"},
"AWS/VPN": {"VpnId", "TunnelIpAddress"},
"AWS/WAF": {"Rule", "WebACL"},
"AWS/WorkSpaces": {"DirectoryId", "WorkspaceId"},
"KMS": {"KeyId"},

View File

@@ -2,12 +2,14 @@ package api
import (
"encoding/json"
"fmt"
"os"
"path"
"strings"
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/dashdiffs"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/log"
"github.com/grafana/grafana/pkg/metrics"
@@ -60,6 +62,9 @@ func GetDashboard(c *middleware.Context) {
creator = getUserLogin(dash.CreatedBy)
}
// make sure db version is in sync with json model version
dash.Data.Set("version", dash.Version)
dto := dtos.DashboardFullWithMeta{
Dashboard: dash.Data,
Meta: dtos.DashboardMeta{
@@ -77,6 +82,7 @@ func GetDashboard(c *middleware.Context) {
},
}
// TODO(ben): copy this performance metrics logic for the new API endpoints added
c.TimeRequest(metrics.M_Api_Dashboard_Get)
c.JSON(200, dto)
}
@@ -114,18 +120,15 @@ func DeleteDashboard(c *middleware.Context) {
func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response {
cmd.OrgId = c.OrgId
if !c.IsSignedIn {
cmd.UserId = -1
} else {
cmd.UserId = c.UserId
}
cmd.UserId = c.UserId
dash := cmd.GetDashboardModel()
// Check if Title is empty
if dash.Title == "" {
return ApiError(400, m.ErrDashboardTitleEmpty.Error(), nil)
}
if dash.Id == 0 {
limitReached, err := middleware.QuotaReached(c, "dashboard")
if err != nil {
@@ -255,6 +258,135 @@ func GetDashboardFromJsonFile(c *middleware.Context) {
c.JSON(200, &dash)
}
// GetDashboardVersions returns all dashboard versions as JSON
func GetDashboardVersions(c *middleware.Context) Response {
dashboardId := c.ParamsInt64(":dashboardId")
limit := c.QueryInt("limit")
start := c.QueryInt("start")
if limit == 0 {
limit = 1000
}
query := m.GetDashboardVersionsQuery{
OrgId: c.OrgId,
DashboardId: dashboardId,
Limit: limit,
Start: start,
}
if err := bus.Dispatch(&query); err != nil {
return ApiError(404, fmt.Sprintf("No versions found for dashboardId %d", dashboardId), err)
}
for _, version := range query.Result {
if version.RestoredFrom == version.Version {
version.Message = "Initial save (created by migration)"
continue
}
if version.RestoredFrom > 0 {
version.Message = fmt.Sprintf("Restored from version %d", version.RestoredFrom)
continue
}
if version.ParentVersion == 0 {
version.Message = "Initial save"
}
}
return Json(200, query.Result)
}
// GetDashboardVersion returns the dashboard version with the given ID.
func GetDashboardVersion(c *middleware.Context) Response {
dashboardId := c.ParamsInt64(":dashboardId")
version := c.ParamsInt(":id")
query := m.GetDashboardVersionQuery{
OrgId: c.OrgId,
DashboardId: dashboardId,
Version: version,
}
if err := bus.Dispatch(&query); err != nil {
return ApiError(500, fmt.Sprintf("Dashboard version %d not found for dashboardId %d", version, dashboardId), err)
}
creator := "Anonymous"
if query.Result.CreatedBy > 0 {
creator = getUserLogin(query.Result.CreatedBy)
}
dashVersionMeta := &m.DashboardVersionMeta{
DashboardVersion: *query.Result,
CreatedBy: creator,
}
return Json(200, dashVersionMeta)
}
// POST /api/dashboards/calculate-diff performs diffs on two dashboards
func CalculateDashboardDiff(c *middleware.Context, apiOptions dtos.CalculateDiffOptions) Response {
options := dashdiffs.Options{
OrgId: c.OrgId,
DiffType: dashdiffs.ParseDiffType(apiOptions.DiffType),
Base: dashdiffs.DiffTarget{
DashboardId: apiOptions.Base.DashboardId,
Version: apiOptions.Base.Version,
UnsavedDashboard: apiOptions.Base.UnsavedDashboard,
},
New: dashdiffs.DiffTarget{
DashboardId: apiOptions.New.DashboardId,
Version: apiOptions.New.Version,
UnsavedDashboard: apiOptions.New.UnsavedDashboard,
},
}
result, err := dashdiffs.CalculateDiff(&options)
if err != nil {
if err == m.ErrDashboardVersionNotFound {
return ApiError(404, "Dashboard version not found", err)
}
return ApiError(500, "Unable to compute diff", err)
}
if options.DiffType == dashdiffs.DiffDelta {
return Respond(200, result.Delta).Header("Content-Type", "application/json")
} else {
return Respond(200, result.Delta).Header("Content-Type", "text/html")
}
}
// RestoreDashboardVersion restores a dashboard to the given version.
func RestoreDashboardVersion(c *middleware.Context, apiCmd dtos.RestoreDashboardVersionCommand) Response {
dashboardId := c.ParamsInt64(":dashboardId")
dashQuery := m.GetDashboardQuery{Id: dashboardId, OrgId: c.OrgId}
if err := bus.Dispatch(&dashQuery); err != nil {
return ApiError(404, "Dashboard not found", nil)
}
versionQuery := m.GetDashboardVersionQuery{DashboardId: dashboardId, Version: apiCmd.Version, OrgId: c.OrgId}
if err := bus.Dispatch(&versionQuery); err != nil {
return ApiError(404, "Dashboard version not found", nil)
}
dashboard := dashQuery.Result
version := versionQuery.Result
saveCmd := m.SaveDashboardCommand{}
saveCmd.RestoredFrom = version.Version
saveCmd.OrgId = c.OrgId
saveCmd.UserId = c.UserId
saveCmd.Dashboard = version.Data
saveCmd.Dashboard.Set("version", dashboard.Version)
saveCmd.Message = fmt.Sprintf("Restored from version %d", version.Version)
return PostDashboard(c, saveCmd)
}
func GetDashboardTags(c *middleware.Context) {
query := m.GetDashboardTagsQuery{OrgId: c.OrgId}
err := bus.Dispatch(&query)

49
pkg/api/dtos/dashboard.go Normal file
View File

@@ -0,0 +1,49 @@
package dtos
import (
"time"
"github.com/grafana/grafana/pkg/components/simplejson"
)
type DashboardMeta struct {
IsStarred bool `json:"isStarred,omitempty"`
IsHome bool `json:"isHome,omitempty"`
IsSnapshot bool `json:"isSnapshot,omitempty"`
Type string `json:"type,omitempty"`
CanSave bool `json:"canSave"`
CanEdit bool `json:"canEdit"`
CanStar bool `json:"canStar"`
Slug string `json:"slug"`
Expires time.Time `json:"expires"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
UpdatedBy string `json:"updatedBy"`
CreatedBy string `json:"createdBy"`
Version int `json:"version"`
}
type DashboardFullWithMeta struct {
Meta DashboardMeta `json:"meta"`
Dashboard *simplejson.Json `json:"dashboard"`
}
type DashboardRedirect struct {
RedirectUri string `json:"redirectUri"`
}
type CalculateDiffOptions struct {
Base CalculateDiffTarget `json:"base" binding:"Required"`
New CalculateDiffTarget `json:"new" binding:"Required"`
DiffType string `json:"diffType" binding:"Required"`
}
type CalculateDiffTarget struct {
DashboardId int64 `json:"dashboardId"`
Version int `json:"version"`
UnsavedDashboard *simplejson.Json `json:"unsavedDashboard"`
}
type RestoreDashboardVersionCommand struct {
Version int `json:"version" binding:"Required"`
}

View File

@@ -4,7 +4,6 @@ import (
"crypto/md5"
"fmt"
"strings"
"time"
"github.com/grafana/grafana/pkg/components/simplejson"
m "github.com/grafana/grafana/pkg/models"
@@ -38,32 +37,6 @@ type CurrentUser struct {
HelpFlags1 m.HelpFlags1 `json:"helpFlags1"`
}
type DashboardMeta struct {
IsStarred bool `json:"isStarred,omitempty"`
IsHome bool `json:"isHome,omitempty"`
IsSnapshot bool `json:"isSnapshot,omitempty"`
Type string `json:"type,omitempty"`
CanSave bool `json:"canSave"`
CanEdit bool `json:"canEdit"`
CanStar bool `json:"canStar"`
Slug string `json:"slug"`
Expires time.Time `json:"expires"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
UpdatedBy string `json:"updatedBy"`
CreatedBy string `json:"createdBy"`
Version int `json:"version"`
}
type DashboardFullWithMeta struct {
Meta DashboardMeta `json:"meta"`
Dashboard *simplejson.Json `json:"dashboard"`
}
type DashboardRedirect struct {
RedirectUri string `json:"redirectUri"`
}
type DataSource struct {
Id int64 `json:"id"`
OrgId int64 `json:"orgId"`

View File

@@ -61,7 +61,7 @@ func (hs *HttpServer) Start(ctx context.Context) error {
return nil
}
case setting.HTTPS:
err = hs.httpSrv.ListenAndServeTLS(setting.CertFile, setting.KeyFile)
err = hs.listenAndServeTLS(setting.CertFile, setting.KeyFile)
if err == http.ErrServerClosed {
hs.log.Debug("server was shutdown gracefully")
return nil
@@ -92,7 +92,7 @@ func (hs *HttpServer) Shutdown(ctx context.Context) error {
return err
}
func (hs *HttpServer) listenAndServeTLS(listenAddr, certfile, keyfile string) error {
func (hs *HttpServer) listenAndServeTLS(certfile, keyfile string) error {
if certfile == "" {
return fmt.Errorf("cert_file cannot be empty when using HTTPS")
}
@@ -127,14 +127,11 @@ func (hs *HttpServer) listenAndServeTLS(listenAddr, certfile, keyfile string) er
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
},
}
srv := &http.Server{
Addr: listenAddr,
Handler: hs.macaron,
TLSConfig: tlsCfg,
TLSNextProto: make(map[string]func(*http.Server, *tls.Conn, http.Handler), 0),
}
return srv.ListenAndServeTLS(setting.CertFile, setting.KeyFile)
hs.httpSrv.TLSConfig = tlsCfg
hs.httpSrv.TLSNextProto = make(map[string]func(*http.Server, *tls.Conn, http.Handler), 0)
return hs.httpSrv.ListenAndServeTLS(setting.CertFile, setting.KeyFile)
}
func (hs *HttpServer) newMacaron() *macaron.Macaron {
@@ -174,6 +171,8 @@ func (hs *HttpServer) newMacaron() *macaron.Macaron {
m.Use(middleware.ValidateHostHeader(setting.Domain))
}
m.Use(middleware.AddDefaultResponseHeaders())
return m
}

View File

@@ -28,6 +28,7 @@ var (
ErrEmailNotAllowed = errors.New("Required email domain not fulfilled")
ErrSignUpNotAllowed = errors.New("Signup is not allowed for this adapter")
ErrUsersQuotaReached = errors.New("Users quota reached")
ErrNoEmail = errors.New("Login provider didn't return an email address")
)
func GenStateString() string {
@@ -63,7 +64,7 @@ func OAuthLogin(ctx *middleware.Context) {
if setting.OAuthService.OAuthInfos[name].HostedDomain == "" {
ctx.Redirect(connect.AuthCodeURL(state, oauth2.AccessTypeOnline))
} else {
ctx.Redirect(connect.AuthCodeURL(state, oauth2.SetParam("hd", setting.OAuthService.OAuthInfos[name].HostedDomain), oauth2.AccessTypeOnline))
ctx.Redirect(connect.AuthCodeURL(state, oauth2.SetAuthURLParam("hd", setting.OAuthService.OAuthInfos[name].HostedDomain), oauth2.AccessTypeOnline))
}
return
}
@@ -134,6 +135,12 @@ func OAuthLogin(ctx *middleware.Context) {
ctx.Logger.Debug("OAuthLogin got user info", "userInfo", userInfo)
// validate that we got at least an email address
if userInfo.Email == "" {
redirectWithError(ctx, ErrNoEmail)
return
}
// validate that the email is allowed to login to grafana
if !connect.IsEmailAllowed(userInfo.Email) {
redirectWithError(ctx, ErrEmailNotAllowed)

View File

@@ -78,6 +78,11 @@ func AddOrgInvite(c *middleware.Context, inviteDto dtos.AddInviteForm) Response
return ApiError(500, "Failed to send email invite", err)
}
emailSentCmd := m.UpdateTempUserWithEmailSentCommand{Code: cmd.Result.Code}
if err := bus.Dispatch(&emailSentCmd); err != nil {
return ApiError(500, "Failed to update invite with email sent info", err)
}
return ApiSuccess(fmt.Sprintf("Sent invite to %s", inviteDto.LoginOrEmail))
}

View File

@@ -42,6 +42,7 @@ func GetUserByLoginOrEmail(c *middleware.Context) Response {
}
user := query.Result
result := m.UserProfileDTO{
Id: user.Id,
Name: user.Name,
Email: user.Email,
Login: user.Login,

View File

@@ -0,0 +1,149 @@
package dashdiffs
import (
"encoding/json"
"errors"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/log"
"github.com/grafana/grafana/pkg/models"
diff "github.com/yudai/gojsondiff"
deltaFormatter "github.com/yudai/gojsondiff/formatter"
)
var (
// ErrUnsupportedDiffType occurs when an invalid diff type is used.
ErrUnsupportedDiffType = errors.New("dashdiff: unsupported diff type")
// ErrNilDiff occurs when two compared interfaces are identical.
ErrNilDiff = errors.New("dashdiff: diff is nil")
diffLogger = log.New("dashdiffs")
)
type DiffType int
const (
DiffJSON DiffType = iota
DiffBasic
DiffDelta
)
type Options struct {
OrgId int64
Base DiffTarget
New DiffTarget
DiffType DiffType
}
type DiffTarget struct {
DashboardId int64
Version int
UnsavedDashboard *simplejson.Json
}
type Result struct {
Delta []byte `json:"delta"`
}
func ParseDiffType(diff string) DiffType {
switch diff {
case "json":
return DiffJSON
case "basic":
return DiffBasic
case "delta":
return DiffDelta
}
return DiffBasic
}
// CompareDashboardVersionsCommand computes the JSON diff of two versions,
// assigning the delta of the diff to the `Delta` field.
func CalculateDiff(options *Options) (*Result, error) {
baseVersionQuery := models.GetDashboardVersionQuery{
DashboardId: options.Base.DashboardId,
Version: options.Base.Version,
OrgId: options.OrgId,
}
if err := bus.Dispatch(&baseVersionQuery); err != nil {
return nil, err
}
newVersionQuery := models.GetDashboardVersionQuery{
DashboardId: options.New.DashboardId,
Version: options.New.Version,
OrgId: options.OrgId,
}
if err := bus.Dispatch(&newVersionQuery); err != nil {
return nil, err
}
baseData := baseVersionQuery.Result.Data
newData := newVersionQuery.Result.Data
left, jsonDiff, err := getDiff(baseData, newData)
if err != nil {
return nil, err
}
result := &Result{}
switch options.DiffType {
case DiffDelta:
deltaOutput, err := deltaFormatter.NewDeltaFormatter().Format(jsonDiff)
if err != nil {
return nil, err
}
result.Delta = []byte(deltaOutput)
case DiffJSON:
jsonOutput, err := NewJSONFormatter(left).Format(jsonDiff)
if err != nil {
return nil, err
}
result.Delta = []byte(jsonOutput)
case DiffBasic:
basicOutput, err := NewBasicFormatter(left).Format(jsonDiff)
if err != nil {
return nil, err
}
result.Delta = basicOutput
default:
return nil, ErrUnsupportedDiffType
}
return result, nil
}
// getDiff computes the diff of two dashboard versions.
func getDiff(baseData, newData *simplejson.Json) (interface{}, diff.Diff, error) {
leftBytes, err := baseData.Encode()
if err != nil {
return nil, nil, err
}
rightBytes, err := newData.Encode()
if err != nil {
return nil, nil, err
}
jsonDiff, err := diff.New().Compare(leftBytes, rightBytes)
if err != nil {
return nil, nil, err
}
if !jsonDiff.Modified() {
return nil, nil, ErrNilDiff
}
left := make(map[string]interface{})
err = json.Unmarshal(leftBytes, &left)
return left, jsonDiff, nil
}

View File

@@ -0,0 +1,425 @@
package dashdiffs
import (
"bytes"
"html/template"
diff "github.com/yudai/gojsondiff"
)
// A BasicDiff holds the stateful values that are used when generating a basic
// diff from JSON tokens.
type BasicDiff struct {
narrow string
keysIdent int
writing bool
LastIndent int
Block *BasicBlock
Change *BasicChange
Summary *BasicSummary
}
// A BasicBlock represents a top-level element in a basic diff.
type BasicBlock struct {
Title string
Old interface{}
New interface{}
Change ChangeType
Changes []*BasicChange
Summaries []*BasicSummary
LineStart int
LineEnd int
}
// A BasicChange represents the change from an old to new value. There are many
// BasicChanges in a BasicBlock.
type BasicChange struct {
Key string
Old interface{}
New interface{}
Change ChangeType
LineStart int
LineEnd int
}
// A BasicSummary represents the changes within a basic block that're too deep
// or verbose to be represented in the top-level BasicBlock element, or in the
// BasicChange. Instead of showing the values in this case, we simply print
// the key and count how many times the given change was applied to that
// element.
type BasicSummary struct {
Key string
Change ChangeType
Count int
LineStart int
LineEnd int
}
type BasicFormatter struct {
jsonDiff *JSONFormatter
tpl *template.Template
}
func NewBasicFormatter(left interface{}) *BasicFormatter {
tpl := template.Must(template.New("block").Funcs(tplFuncMap).Parse(tplBlock))
tpl = template.Must(tpl.New("change").Funcs(tplFuncMap).Parse(tplChange))
tpl = template.Must(tpl.New("summary").Funcs(tplFuncMap).Parse(tplSummary))
return &BasicFormatter{
jsonDiff: NewJSONFormatter(left),
tpl: tpl,
}
}
// Format takes the diff of two JSON documents, and returns the difference
// between them summarized in an HTML document.
func (b *BasicFormatter) Format(d diff.Diff) ([]byte, error) {
// calling jsonDiff.Format(d) populates the JSON diff's "Lines" value,
// which we use to compute the basic dif
_, err := b.jsonDiff.Format(d)
if err != nil {
return nil, err
}
bd := &BasicDiff{}
blocks := bd.Basic(b.jsonDiff.Lines)
buf := &bytes.Buffer{}
err = b.tpl.ExecuteTemplate(buf, "block", blocks)
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// Basic transforms a slice of JSONLines into a slice of BasicBlocks.
func (b *BasicDiff) Basic(lines []*JSONLine) []*BasicBlock {
// init an array you can append to for the basic "blocks"
blocks := make([]*BasicBlock, 0)
for _, line := range lines {
if b.returnToTopLevelKey(line) {
if b.Block != nil {
blocks = append(blocks, b.Block)
}
}
// Record the last indent level at each pass in case we need to
// check for a change in depth inside the JSON data structures.
b.LastIndent = line.Indent
if line.Indent == 1 {
if block, ok := b.handleTopLevelChange(line); ok {
blocks = append(blocks, block)
}
}
// Here is where we handle changes for all types, appending each change
// to the current block based on the value.
//
// Values which only occupy a single line in JSON (like a string or
// int, for example) are treated as "Basic Changes" that we append to
// the current block as soon as they're detected.
//
// Values which occupy multiple lines (either slices or maps) are
// treated as "Basic Summaries". When we detect the "ChangeNil" type,
// we know we've encountered one of these types, so we record the
// starting position as well the type of the change, and stop
// performing comparisons until we find the end of that change. Upon
// finding the change, we append it to the current block, and begin
// performing comparisons again.
if line.Indent > 1 {
// check to ensure a single line change
if b.isSingleLineChange(line) {
switch line.Change {
case ChangeAdded, ChangeDeleted:
b.Block.Changes = append(b.Block.Changes, &BasicChange{
Key: line.Key,
Change: line.Change,
New: line.Val,
LineStart: line.LineNum,
})
case ChangeOld:
b.Change = &BasicChange{
Key: line.Key,
Change: line.Change,
Old: line.Val,
LineStart: line.LineNum,
}
case ChangeNew:
b.Change.New = line.Val
b.Change.LineEnd = line.LineNum
b.Block.Changes = append(b.Block.Changes, b.Change)
default:
//ok
}
// otherwise, we're dealing with a change at a deeper level. We
// know there's a change somewhere in the JSON tree, but we
// don't know exactly where, so we go deeper.
} else {
// if the change is anything but unchanged, continue processing
//
// we keep "narrowing" the key as we go deeper, in order to
// correctly report the key name for changes found within an
// object or array.
if line.Change != ChangeUnchanged {
if line.Key != "" {
b.narrow = line.Key
b.keysIdent = line.Indent
}
// if the change isn't nil, and we're not already writing
// out a change, then we've found something.
//
// First, try to determine the title of the embedded JSON
// object. If it's an empty string, then we're in an object
// or array, so we default to using the "narrowed" key.
//
// We also start recording the basic summary, until we find
// the next `ChangeUnchanged`.
if line.Change != ChangeNil {
if !b.writing {
b.writing = true
key := b.Block.Title
if b.narrow != "" {
key = b.narrow
if b.keysIdent > line.Indent {
key = b.Block.Title
}
}
b.Summary = &BasicSummary{
Key: key,
Change: line.Change,
LineStart: line.LineNum,
}
}
}
// if we find a `ChangeUnchanged`, we do one of two things:
//
// - if we're recording a change already, then we know
// we've come to the end of that change block, so we write
// that change out be recording the line number of where
// that change ends, and append it to the current block's
// summary.
//
// - if we're not recording a change, then we do nothing,
// since the BasicDiff doesn't report on unchanged JSON
// values.
} else {
if b.writing {
b.writing = false
b.Summary.LineEnd = line.LineNum
b.Block.Summaries = append(b.Block.Summaries, b.Summary)
}
}
}
}
}
return blocks
}
// returnToTopLevelKey indicates that we've moved from a key at one level deep
// in the JSON document to a top level key.
//
// In order to produce distinct "blocks" when rendering the basic diff,
// we need a way to distinguish between differnt sections of data.
// To do this, we consider the value(s) of each top-level JSON key to
// represent a distinct block for Grafana's JSON data structure, so
// we perform this check to see if we've entered a new "block". If we
// have, we simply append the existing block to the array of blocks.
func (b *BasicDiff) returnToTopLevelKey(line *JSONLine) bool {
return b.LastIndent == 2 && line.Indent == 1 && line.Change == ChangeNil
}
// handleTopLevelChange handles a change on one of the top-level keys on a JSON
// document.
//
// If the line's indentation is at level 1, then we know it's a top
// level key in the JSON document. As mentioned earlier, we treat these
// specially as they indicate their values belong to distinct blocks.
//
// At level 1, we only record single-line changes, ie, the "added",
// "deleted", "old" or "new" cases, since we know those values aren't
// arrays or maps. We only handle these cases at level 2 or deeper,
// since for those we either output a "change" or "summary". This is
// done for formatting reasons only, so we have logical "blocks" to
// display.
func (b *BasicDiff) handleTopLevelChange(line *JSONLine) (*BasicBlock, bool) {
switch line.Change {
case ChangeNil:
if line.Change == ChangeNil {
if line.Key != "" {
b.Block = &BasicBlock{
Title: line.Key,
Change: line.Change,
}
}
}
case ChangeAdded, ChangeDeleted:
return &BasicBlock{
Title: line.Key,
Change: line.Change,
New: line.Val,
LineStart: line.LineNum,
}, true
case ChangeOld:
b.Block = &BasicBlock{
Title: line.Key,
Old: line.Val,
Change: line.Change,
LineStart: line.LineNum,
}
case ChangeNew:
b.Block.New = line.Val
b.Block.LineEnd = line.LineNum
// For every "old" change there is a corresponding "new", which
// is why we wait until we detect the "new" change before
// appending the change.
return b.Block, true
default:
// ok
}
return nil, false
}
// isSingleLineChange ensures we're iterating over a single line change (ie,
// either a single line or a old-new value pair was changed in the JSON file).
func (b *BasicDiff) isSingleLineChange(line *JSONLine) bool {
return line.Key != "" && line.Val != nil && !b.writing
}
// encStateMap is used in the template helper
var (
encStateMap = map[ChangeType]string{
ChangeAdded: "added",
ChangeDeleted: "deleted",
ChangeOld: "changed",
ChangeNew: "changed",
}
// tplFuncMap is the function map for each template
tplFuncMap = template.FuncMap{
"getChange": func(c ChangeType) string {
state, ok := encStateMap[c]
if !ok {
return "changed"
}
return state
},
}
)
var (
// tplBlock is the container for the basic diff. It iterates over each
// basic block, expanding each "change" and "summary" belonging to every
// block.
tplBlock = `{{ define "block" -}}
{{ range . }}
<div class="diff-group">
<div class="diff-block">
<h2 class="diff-block-title">
<i class="diff-circle diff-circle-{{ getChange .Change }} fa fa-circle"></i>
<strong class="diff-title">{{ .Title }}</strong> {{ getChange .Change }}
</h2>
<!-- Overview -->
{{ if .Old }}
<div class="diff-label">{{ .Old }}</div>
<i class="diff-arrow fa fa-long-arrow-right"></i>
{{ end }}
{{ if .New }}
<div class="diff-label">{{ .New }}</div>
{{ end }}
{{ if .LineStart }}
<diff-link-json
line-link="{{ .LineStart }}"
line-display="{{ .LineStart }}{{ if .LineEnd }} - {{ .LineEnd }}{{ end }}"
switch-view="ctrl.getDiff('html')"
/>
{{ end }}
</div>
<!-- Basic Changes -->
{{ range .Changes }}
<ul class="diff-change-container">
{{ template "change" . }}
</ul>
{{ end }}
<!-- Basic Summary -->
{{ range .Summaries }}
{{ template "summary" . }}
{{ end }}
</div>
{{ end }}
{{ end }}`
// tplChange is the template for basic changes.
tplChange = `{{ define "change" -}}
<li class="diff-change-group">
<span class="bullet-position-container">
<div class="diff-change-item diff-change-title">{{ getChange .Change }} {{ .Key }}</div>
<div class="diff-change-item">
{{ if .Old }}
<div class="diff-label">{{ .Old }}</div>
<i class="diff-arrow fa fa-long-arrow-right"></i>
{{ end }}
{{ if .New }}
<div class="diff-label">{{ .New }}</div>
{{ end }}
</div>
{{ if .LineStart }}
<diff-link-json
line-link="{{ .LineStart }}"
line-display="{{ .LineStart }}{{ if .LineEnd }} - {{ .LineEnd }}{{ end }}"
switch-view="ctrl.getDiff('json')"
/>
{{ end }}
</span>
</li>
{{ end }}`
// tplSummary is for basic summaries.
tplSummary = `{{ define "summary" -}}
<div class="diff-group-name">
<i class="diff-circle diff-circle-{{ getChange .Change }} fa fa-circle-o diff-list-circle"></i>
{{ if .Count }}
<strong>{{ .Count }}</strong>
{{ end }}
{{ if .Key }}
<strong class="diff-summary-key">{{ .Key }}</strong>
{{ getChange .Change }}
{{ end }}
{{ if .LineStart }}
<diff-link-json
line-link="{{ .LineStart }}"
line-display="{{ .LineStart }}{{ if .LineEnd }} - {{ .LineEnd }}{{ end }}"
switch-view="ctrl.getDiff('json')"
/>
{{ end }}
</div>
{{ end }}`
)

View File

@@ -0,0 +1,477 @@
package dashdiffs
import (
"bytes"
"errors"
"fmt"
"html/template"
"sort"
diff "github.com/yudai/gojsondiff"
)
type ChangeType int
const (
ChangeNil ChangeType = iota
ChangeAdded
ChangeDeleted
ChangeOld
ChangeNew
ChangeUnchanged
)
var (
// changeTypeToSymbol is used for populating the terminating characer in
// the diff
changeTypeToSymbol = map[ChangeType]string{
ChangeNil: "",
ChangeAdded: "+",
ChangeDeleted: "-",
ChangeOld: "-",
ChangeNew: "+",
}
// changeTypeToName is used for populating class names in the diff
changeTypeToName = map[ChangeType]string{
ChangeNil: "same",
ChangeAdded: "added",
ChangeDeleted: "deleted",
ChangeOld: "old",
ChangeNew: "new",
}
)
var (
// tplJSONDiffWrapper is the template that wraps a diff
tplJSONDiffWrapper = `{{ define "JSONDiffWrapper" -}}
{{ range $index, $element := . }}
{{ template "JSONDiffLine" $element }}
{{ end }}
{{ end }}`
// tplJSONDiffLine is the template that prints each line in a diff
tplJSONDiffLine = `{{ define "JSONDiffLine" -}}
<p id="l{{ .LineNum }}" class="diff-line diff-json-{{ cton .Change }}">
<span class="diff-line-number">
{{if .LeftLine }}{{ .LeftLine }}{{ end }}
</span>
<span class="diff-line-number">
{{if .RightLine }}{{ .RightLine }}{{ end }}
</span>
<span class="diff-value diff-indent-{{ .Indent }}" title="{{ .Text }}">
{{ .Text }}
</span>
<span class="diff-line-icon">{{ ctos .Change }}</span>
</p>
{{ end }}`
)
var diffTplFuncs = template.FuncMap{
"ctos": func(c ChangeType) string {
if symbol, ok := changeTypeToSymbol[c]; ok {
return symbol
}
return ""
},
"cton": func(c ChangeType) string {
if name, ok := changeTypeToName[c]; ok {
return name
}
return ""
},
}
// JSONLine contains the data required to render each line of the JSON diff
// and contains the data required to produce the tokens output in the basic
// diff.
type JSONLine struct {
LineNum int `json:"line"`
LeftLine int `json:"leftLine"`
RightLine int `json:"rightLine"`
Indent int `json:"indent"`
Text string `json:"text"`
Change ChangeType `json:"changeType"`
Key string `json:"key"`
Val interface{} `json:"value"`
}
func NewJSONFormatter(left interface{}) *JSONFormatter {
tpl := template.Must(template.New("JSONDiffWrapper").Funcs(diffTplFuncs).Parse(tplJSONDiffWrapper))
tpl = template.Must(tpl.New("JSONDiffLine").Funcs(diffTplFuncs).Parse(tplJSONDiffLine))
return &JSONFormatter{
left: left,
Lines: []*JSONLine{},
tpl: tpl,
path: []string{},
size: []int{},
lineCount: 0,
inArray: []bool{},
}
}
type JSONFormatter struct {
left interface{}
path []string
size []int
inArray []bool
lineCount int
leftLine int
rightLine int
line *AsciiLine
Lines []*JSONLine
tpl *template.Template
}
type AsciiLine struct {
// the type of change
change ChangeType
// the actual changes - no formatting
key string
val interface{}
// level of indentation for the current line
indent int
// buffer containing the fully formatted line
buffer *bytes.Buffer
}
func (f *JSONFormatter) Format(diff diff.Diff) (result string, err error) {
if v, ok := f.left.(map[string]interface{}); ok {
f.formatObject(v, diff)
} else if v, ok := f.left.([]interface{}); ok {
f.formatArray(v, diff)
} else {
return "", fmt.Errorf("expected map[string]interface{} or []interface{}, got %T",
f.left)
}
b := &bytes.Buffer{}
err = f.tpl.ExecuteTemplate(b, "JSONDiffWrapper", f.Lines)
if err != nil {
fmt.Printf("%v\n", err)
return "", err
}
return b.String(), nil
}
func (f *JSONFormatter) formatObject(left map[string]interface{}, df diff.Diff) {
f.addLineWith(ChangeNil, "{")
f.push("ROOT", len(left), false)
f.processObject(left, df.Deltas())
f.pop()
f.addLineWith(ChangeNil, "}")
}
func (f *JSONFormatter) formatArray(left []interface{}, df diff.Diff) {
f.addLineWith(ChangeNil, "[")
f.push("ROOT", len(left), true)
f.processArray(left, df.Deltas())
f.pop()
f.addLineWith(ChangeNil, "]")
}
func (f *JSONFormatter) processArray(array []interface{}, deltas []diff.Delta) error {
patchedIndex := 0
for index, value := range array {
f.processItem(value, deltas, diff.Index(index))
patchedIndex++
}
// additional Added
for _, delta := range deltas {
switch delta.(type) {
case *diff.Added:
d := delta.(*diff.Added)
// skip items already processed
if int(d.Position.(diff.Index)) < len(array) {
continue
}
f.printRecursive(d.Position.String(), d.Value, ChangeAdded)
}
}
return nil
}
func (f *JSONFormatter) processObject(object map[string]interface{}, deltas []diff.Delta) error {
names := sortKeys(object)
for _, name := range names {
value := object[name]
f.processItem(value, deltas, diff.Name(name))
}
// Added
for _, delta := range deltas {
switch delta.(type) {
case *diff.Added:
d := delta.(*diff.Added)
f.printRecursive(d.Position.String(), d.Value, ChangeAdded)
}
}
return nil
}
func (f *JSONFormatter) processItem(value interface{}, deltas []diff.Delta, position diff.Position) error {
matchedDeltas := f.searchDeltas(deltas, position)
positionStr := position.String()
if len(matchedDeltas) > 0 {
for _, matchedDelta := range matchedDeltas {
switch matchedDelta.(type) {
case *diff.Object:
d := matchedDelta.(*diff.Object)
switch value.(type) {
case map[string]interface{}:
//ok
default:
return errors.New("Type mismatch")
}
o := value.(map[string]interface{})
f.newLine(ChangeNil)
f.printKey(positionStr)
f.print("{")
f.closeLine()
f.push(positionStr, len(o), false)
f.processObject(o, d.Deltas)
f.pop()
f.newLine(ChangeNil)
f.print("}")
f.printComma()
f.closeLine()
case *diff.Array:
d := matchedDelta.(*diff.Array)
switch value.(type) {
case []interface{}:
//ok
default:
return errors.New("Type mismatch")
}
a := value.([]interface{})
f.newLine(ChangeNil)
f.printKey(positionStr)
f.print("[")
f.closeLine()
f.push(positionStr, len(a), true)
f.processArray(a, d.Deltas)
f.pop()
f.newLine(ChangeNil)
f.print("]")
f.printComma()
f.closeLine()
case *diff.Added:
d := matchedDelta.(*diff.Added)
f.printRecursive(positionStr, d.Value, ChangeAdded)
f.size[len(f.size)-1]++
case *diff.Modified:
d := matchedDelta.(*diff.Modified)
savedSize := f.size[len(f.size)-1]
f.printRecursive(positionStr, d.OldValue, ChangeOld)
f.size[len(f.size)-1] = savedSize
f.printRecursive(positionStr, d.NewValue, ChangeNew)
case *diff.TextDiff:
savedSize := f.size[len(f.size)-1]
d := matchedDelta.(*diff.TextDiff)
f.printRecursive(positionStr, d.OldValue, ChangeOld)
f.size[len(f.size)-1] = savedSize
f.printRecursive(positionStr, d.NewValue, ChangeNew)
case *diff.Deleted:
d := matchedDelta.(*diff.Deleted)
f.printRecursive(positionStr, d.Value, ChangeDeleted)
default:
return errors.New("Unknown Delta type detected")
}
}
} else {
f.printRecursive(positionStr, value, ChangeUnchanged)
}
return nil
}
func (f *JSONFormatter) searchDeltas(deltas []diff.Delta, postion diff.Position) (results []diff.Delta) {
results = make([]diff.Delta, 0)
for _, delta := range deltas {
switch delta.(type) {
case diff.PostDelta:
if delta.(diff.PostDelta).PostPosition() == postion {
results = append(results, delta)
}
case diff.PreDelta:
if delta.(diff.PreDelta).PrePosition() == postion {
results = append(results, delta)
}
default:
panic("heh")
}
}
return
}
func (f *JSONFormatter) push(name string, size int, array bool) {
f.path = append(f.path, name)
f.size = append(f.size, size)
f.inArray = append(f.inArray, array)
}
func (f *JSONFormatter) pop() {
f.path = f.path[0 : len(f.path)-1]
f.size = f.size[0 : len(f.size)-1]
f.inArray = f.inArray[0 : len(f.inArray)-1]
}
func (f *JSONFormatter) addLineWith(change ChangeType, value string) {
f.line = &AsciiLine{
change: change,
indent: len(f.path),
buffer: bytes.NewBufferString(value),
}
f.closeLine()
}
func (f *JSONFormatter) newLine(change ChangeType) {
f.line = &AsciiLine{
change: change,
indent: len(f.path),
buffer: bytes.NewBuffer([]byte{}),
}
}
func (f *JSONFormatter) closeLine() {
leftLine := 0
rightLine := 0
f.lineCount++
switch f.line.change {
case ChangeAdded, ChangeNew:
f.rightLine++
rightLine = f.rightLine
case ChangeDeleted, ChangeOld:
f.leftLine++
leftLine = f.leftLine
case ChangeNil, ChangeUnchanged:
f.rightLine++
f.leftLine++
rightLine = f.rightLine
leftLine = f.leftLine
}
s := f.line.buffer.String()
f.Lines = append(f.Lines, &JSONLine{
LineNum: f.lineCount,
RightLine: rightLine,
LeftLine: leftLine,
Indent: f.line.indent,
Text: s,
Change: f.line.change,
Key: f.line.key,
Val: f.line.val,
})
}
func (f *JSONFormatter) printKey(name string) {
if !f.inArray[len(f.inArray)-1] {
f.line.key = name
fmt.Fprintf(f.line.buffer, `"%s": `, name)
}
}
func (f *JSONFormatter) printComma() {
f.size[len(f.size)-1]--
if f.size[len(f.size)-1] > 0 {
f.line.buffer.WriteRune(',')
}
}
func (f *JSONFormatter) printValue(value interface{}) {
switch value.(type) {
case string:
f.line.val = value
fmt.Fprintf(f.line.buffer, `"%s"`, value)
case nil:
f.line.val = "null"
f.line.buffer.WriteString("null")
default:
f.line.val = value
fmt.Fprintf(f.line.buffer, `%#v`, value)
}
}
func (f *JSONFormatter) print(a string) {
f.line.buffer.WriteString(a)
}
func (f *JSONFormatter) printRecursive(name string, value interface{}, change ChangeType) {
switch value.(type) {
case map[string]interface{}:
f.newLine(change)
f.printKey(name)
f.print("{")
f.closeLine()
m := value.(map[string]interface{})
size := len(m)
f.push(name, size, false)
keys := sortKeys(m)
for _, key := range keys {
f.printRecursive(key, m[key], change)
}
f.pop()
f.newLine(change)
f.print("}")
f.printComma()
f.closeLine()
case []interface{}:
f.newLine(change)
f.printKey(name)
f.print("[")
f.closeLine()
s := value.([]interface{})
size := len(s)
f.push("", size, true)
for _, item := range s {
f.printRecursive("", item, change)
}
f.pop()
f.newLine(change)
f.print("]")
f.printComma()
f.closeLine()
default:
f.newLine(change)
f.printKey(name)
f.printValue(value)
f.printComma()
f.closeLine()
}
}
func sortKeys(m map[string]interface{}) (keys []string) {
keys = make([]string, 0, len(m))
for key := range m {
keys = append(keys, key)
}
sort.Strings(keys)
return
}

View File

@@ -0,0 +1,131 @@
package dashdiffs
import (
"testing"
"github.com/grafana/grafana/pkg/components/simplejson"
. "github.com/smartystreets/goconvey/convey"
)
func TestDiff(t *testing.T) {
// Sample json docs for tests only
const (
leftJSON = `{
"key": "value",
"object": {
"key": "value",
"anotherObject": {
"same": "this field is the same in rightJSON",
"change": "this field should change in rightJSON",
"delete": "this field doesn't appear in rightJSON"
}
},
"array": [
"same",
"change",
"delete"
],
"embeddedArray": {
"array": [
"same",
"change",
"delete"
]
}
}`
rightJSON = `{
"key": "differentValue",
"object": {
"key": "value",
"newKey": "value",
"anotherObject": {
"same": "this field is the same in rightJSON",
"change": "this field should change in rightJSON",
"add": "this field is added"
}
},
"array": [
"same",
"changed!",
"add"
],
"embeddedArray": {
"array": [
"same",
"changed!",
"add"
]
}
}`
)
Convey("Testing dashboard diffs", t, func() {
// Compute the diff between the two JSON objects
baseData, err := simplejson.NewJson([]byte(leftJSON))
So(err, ShouldBeNil)
newData, err := simplejson.NewJson([]byte(rightJSON))
So(err, ShouldBeNil)
left, jsonDiff, err := getDiff(baseData, newData)
So(err, ShouldBeNil)
Convey("The JSONFormatter should produce the expected JSON tokens", func() {
f := NewJSONFormatter(left)
_, err := f.Format(jsonDiff)
So(err, ShouldBeNil)
// Total up the change types. If the number of different change
// types is correct, it means that the diff is producing correct
// output to the template rendered.
changeCounts := make(map[ChangeType]int)
for _, line := range f.Lines {
changeCounts[line.Change]++
}
// The expectedChangeCounts here were determined by manually
// looking at the JSON
expectedChangeCounts := map[ChangeType]int{
ChangeNil: 12,
ChangeAdded: 2,
ChangeDeleted: 1,
ChangeOld: 5,
ChangeNew: 5,
ChangeUnchanged: 5,
}
So(changeCounts, ShouldResemble, expectedChangeCounts)
})
Convey("The BasicFormatter should produce the expected BasicBlocks", func() {
f := NewBasicFormatter(left)
_, err := f.Format(jsonDiff)
So(err, ShouldBeNil)
bd := &BasicDiff{}
blocks := bd.Basic(f.jsonDiff.Lines)
changeCounts := make(map[ChangeType]int)
for _, block := range blocks {
for _, change := range block.Changes {
changeCounts[change.Change]++
}
for _, summary := range block.Summaries {
changeCounts[summary.Change]++
}
changeCounts[block.Change]++
}
expectedChangeCounts := map[ChangeType]int{
ChangeNil: 3,
ChangeAdded: 2,
ChangeDeleted: 1,
ChangeOld: 3,
}
So(changeCounts, ShouldResemble, expectedChangeCounts)
})
})
}

View File

@@ -124,7 +124,7 @@ func (m *StandardMeter) Count() int64 {
return count
}
// Mark records the occurance of n events.
// Mark records the occurrence of n events.
func (m *StandardMeter) Mark(n int64) {
m.lock.Lock()
defer m.lock.Unlock()

View File

@@ -44,6 +44,7 @@ var (
M_Alerting_Notification_Sent_Slack Counter
M_Alerting_Notification_Sent_Email Counter
M_Alerting_Notification_Sent_Webhook Counter
M_Alerting_Notification_Sent_DingDing Counter
M_Alerting_Notification_Sent_PagerDuty Counter
M_Alerting_Notification_Sent_LINE Counter
M_Alerting_Notification_Sent_Victorops Counter
@@ -116,6 +117,7 @@ func initMetricVars(settings *MetricSettings) {
M_Alerting_Notification_Sent_Slack = RegCounter("alerting.notifications_sent", "type", "slack")
M_Alerting_Notification_Sent_Email = RegCounter("alerting.notifications_sent", "type", "email")
M_Alerting_Notification_Sent_Webhook = RegCounter("alerting.notifications_sent", "type", "webhook")
M_Alerting_Notification_Sent_DingDing = RegCounter("alerting.notifications_sent", "type", "dingding")
M_Alerting_Notification_Sent_PagerDuty = RegCounter("alerting.notifications_sent", "type", "pagerduty")
M_Alerting_Notification_Sent_Victorops = RegCounter("alerting.notifications_sent", "type", "victorops")
M_Alerting_Notification_Sent_OpsGenie = RegCounter("alerting.notifications_sent", "type", "opsgenie")

View File

@@ -49,9 +49,9 @@ func Logger() macaron.Handler {
if ctx, ok := c.Data["ctx"]; ok {
ctxTyped := ctx.(*Context)
if status == 500 {
ctxTyped.Logger.Error("Request Completed", "method", req.Method, "path", req.URL.Path, "status", status, "remote_addr", c.RemoteAddr(), "time_ms", int64(timeTakenMs), "size", rw.Size())
ctxTyped.Logger.Error("Request Completed", "method", req.Method, "path", req.URL.Path, "status", status, "remote_addr", c.RemoteAddr(), "time_ms", int64(timeTakenMs), "size", rw.Size(), "referer", req.Referer())
} else {
ctxTyped.Logger.Info("Request Completed", "method", req.Method, "path", req.URL.Path, "status", status, "remote_addr", c.RemoteAddr(), "time_ms", int64(timeTakenMs), "size", rw.Size())
ctxTyped.Logger.Info("Request Completed", "method", req.Method, "path", req.URL.Path, "status", status, "remote_addr", c.RemoteAddr(), "time_ms", int64(timeTakenMs), "size", rw.Size(), "referer", req.Referer())
}
}
}

View File

@@ -245,3 +245,11 @@ func (ctx *Context) HasHelpFlag(flag m.HelpFlags1) bool {
func (ctx *Context) TimeRequest(timer metrics.Timer) {
ctx.Data["perfmon.timer"] = timer
}
func AddDefaultResponseHeaders() macaron.Handler {
return func(ctx *Context) {
if ctx.IsApiRequest() && ctx.Req.Method == "GET" {
ctx.Resp.Header().Add("Cache-Control", "no-cache")
}
}
}

View File

@@ -30,6 +30,16 @@ func TestMiddlewareContext(t *testing.T) {
So(sc.resp.Code, ShouldEqual, 200)
})
middlewareScenario("middleware should add Cache-Control header for GET requests to API", func(sc *scenarioContext) {
sc.fakeReq("GET", "/api/search").exec()
So(sc.resp.Header().Get("Cache-Control"), ShouldEqual, "no-cache")
})
middlewareScenario("middleware should not add Cache-Control header to for non-API GET requests", func(sc *scenarioContext) {
sc.fakeReq("GET", "/").exec()
So(sc.resp.Header().Get("Cache-Control"), ShouldBeEmpty)
})
middlewareScenario("Non api request should init session", func(sc *scenarioContext) {
sc.fakeReq("GET", "/").exec()
So(sc.resp.Header().Get("Set-Cookie"), ShouldContainSubstring, "grafana_sess")
@@ -327,6 +337,7 @@ func middlewareScenario(desc string, fn scenarioFunc) {
startSessionGC = func() {}
sc.m.Use(Sessioner(&session.Options{}))
sc.m.Use(OrgRedirect())
sc.m.Use(AddDefaultResponseHeaders())
sc.defaultHandler = func(c *Context) {
sc.context = c

View File

@@ -0,0 +1,71 @@
package models
import (
"errors"
"time"
"github.com/grafana/grafana/pkg/components/simplejson"
)
var (
ErrDashboardVersionNotFound = errors.New("Dashboard version not found")
ErrNoVersionsForDashboardId = errors.New("No dashboard versions found for the given DashboardId")
)
// A DashboardVersion represents the comparable data in a dashboard, allowing
// diffs of the dashboard to be performed.
type DashboardVersion struct {
Id int64 `json:"id"`
DashboardId int64 `json:"dashboardId"`
ParentVersion int `json:"parentVersion"`
RestoredFrom int `json:"restoredFrom"`
Version int `json:"version"`
Created time.Time `json:"created"`
CreatedBy int64 `json:"createdBy"`
Message string `json:"message"`
Data *simplejson.Json `json:"data"`
}
// DashboardVersionMeta extends the dashboard version model with the names
// associated with the UserIds, overriding the field with the same name from
// the DashboardVersion model.
type DashboardVersionMeta struct {
DashboardVersion
CreatedBy string `json:"createdBy"`
}
// DashboardVersionDTO represents a dashboard version, without the dashboard
// map.
type DashboardVersionDTO struct {
Id int64 `json:"id"`
DashboardId int64 `json:"dashboardId"`
ParentVersion int `json:"parentVersion"`
RestoredFrom int `json:"restoredFrom"`
Version int `json:"version"`
Created time.Time `json:"created"`
CreatedBy string `json:"createdBy"`
Message string `json:"message"`
}
//
// Queries
//
type GetDashboardVersionQuery struct {
DashboardId int64
OrgId int64
Version int
Result *DashboardVersion
}
type GetDashboardVersionsQuery struct {
DashboardId int64
OrgId int64
Limit int
Start int
Result []*DashboardVersionDTO
}

View File

@@ -98,12 +98,17 @@ func NewDashboardFromJson(data *simplejson.Json) *Dashboard {
// GetDashboardModel turns the command into the savable model
func (cmd *SaveDashboardCommand) GetDashboardModel() *Dashboard {
dash := NewDashboardFromJson(cmd.Dashboard)
userId := cmd.UserId
if dash.Data.Get("version").MustInt(0) == 0 {
dash.CreatedBy = cmd.UserId
if userId == 0 {
userId = -1
}
dash.UpdatedBy = cmd.UserId
if dash.Data.Get("version").MustInt(0) == 0 {
dash.CreatedBy = userId
}
dash.UpdatedBy = userId
dash.OrgId = cmd.OrgId
dash.PluginId = cmd.PluginId
dash.UpdateSlug()
@@ -126,11 +131,13 @@ func (dash *Dashboard) UpdateSlug() {
//
type SaveDashboardCommand struct {
Dashboard *simplejson.Json `json:"dashboard" binding:"Required"`
UserId int64 `json:"userId"`
OrgId int64 `json:"-"`
Overwrite bool `json:"overwrite"`
PluginId string `json:"-"`
Dashboard *simplejson.Json `json:"dashboard" binding:"Required"`
UserId int64 `json:"userId"`
Overwrite bool `json:"overwrite"`
Message string `json:"message"`
OrgId int64 `json:"-"`
RestoredFrom int `json:"-"`
PluginId string `json:"-"`
Result *Dashboard
}
@@ -145,7 +152,8 @@ type DeleteDashboardCommand struct {
//
type GetDashboardQuery struct {
Slug string
Slug string // required if no Id is specified
Id int64 // optional if slug is set
OrgId int64
Result *Dashboard

View File

@@ -60,6 +60,10 @@ type UpdateTempUserStatusCommand struct {
Status TempUserStatus
}
type UpdateTempUserWithEmailSentCommand struct {
Code string
}
type GetTempUsersQuery struct {
OrgId int64
Email string

View File

@@ -163,6 +163,7 @@ type SignedInUser struct {
}
type UserProfileDTO struct {
Id int64 `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Login string `json:"login"`

View File

@@ -89,7 +89,7 @@ func (e *DashAlertExtractor) GetAlerts() ([]*m.Alert, error) {
continue
}
// backward compatability check, can be removed later
// backward compatibility check, can be removed later
enabled, hasEnabled := jsonAlert.CheckGet("enabled")
if hasEnabled && enabled.MustBool() == false {
continue

View File

@@ -0,0 +1,90 @@
package notifiers
import (
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/log"
"github.com/grafana/grafana/pkg/metrics"
m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/alerting"
)
func init() {
alerting.RegisterNotifier(&alerting.NotifierPlugin{
Type: "dingding",
Name: "DingDing",
Description: "Sends HTTP POST request to DingDing",
Factory: NewDingDingNotifier,
OptionsTemplate: `
<h3 class="page-heading">DingDing settings</h3>
<div class="gf-form">
<span class="gf-form-label width-10">Url</span>
<input type="text" required class="gf-form-input max-width-26" ng-model="ctrl.model.settings.url"></input>
</div>
`,
})
}
func NewDingDingNotifier(model *m.AlertNotification) (alerting.Notifier, error) {
url := model.Settings.Get("url").MustString()
if url == "" {
return nil, alerting.ValidationError{Reason: "Could not find url property in settings"}
}
return &DingDingNotifier{
NotifierBase: NewNotifierBase(model.Id, model.IsDefault, model.Name, model.Type, model.Settings),
Url: url,
log: log.New("alerting.notifier.dingding"),
}, nil
}
type DingDingNotifier struct {
NotifierBase
Url string
log log.Logger
}
func (this *DingDingNotifier) Notify(evalContext *alerting.EvalContext) error {
this.log.Info("Sending dingding")
metrics.M_Alerting_Notification_Sent_DingDing.Inc(1)
messageUrl, err := evalContext.GetRuleUrl()
if err != nil {
this.log.Error("Failed to get messageUrl", "error", err, "dingding", this.Name)
messageUrl = ""
}
this.log.Info("messageUrl:" + messageUrl)
message := evalContext.Rule.Message
picUrl := evalContext.ImagePublicUrl
title := evalContext.GetNotificationTitle()
bodyJSON, err := simplejson.NewJson([]byte(`{
"msgtype": "link",
"link": {
"text": "` + message + `",
"title": "` + title + `",
"picUrl": "` + picUrl + `",
"messageUrl": "` + messageUrl + `"
}
}`))
if err != nil {
this.log.Error("Failed to create Json data", "error", err, "dingding", this.Name)
}
body, _ := bodyJSON.MarshalJSON()
cmd := &m.SendWebhookSync{
Url: this.Url,
Body: string(body),
}
if err := bus.DispatchCtx(evalContext.Ctx, cmd); err != nil {
this.log.Error("Failed to send DingDing", "error", err, "dingding", this.Name)
return err
}
return nil
}

View File

@@ -0,0 +1,49 @@
package notifiers
import (
"testing"
"github.com/grafana/grafana/pkg/components/simplejson"
m "github.com/grafana/grafana/pkg/models"
. "github.com/smartystreets/goconvey/convey"
)
func TestDingDingNotifier(t *testing.T) {
Convey("Line notifier tests", t, func() {
Convey("empty settings should return error", func() {
json := `{ }`
settingsJSON, _ := simplejson.NewJson([]byte(json))
model := &m.AlertNotification{
Name: "dingding_testing",
Type: "dingding",
Settings: settingsJSON,
}
_, err := NewDingDingNotifier(model)
So(err, ShouldNotBeNil)
})
Convey("settings should trigger incident", func() {
json := `
{
"url": "https://www.google.com"
}`
settingsJSON, _ := simplejson.NewJson([]byte(json))
model := &m.AlertNotification{
Name: "dingding_testing",
Type: "dingding",
Settings: settingsJSON,
}
not, err := NewDingDingNotifier(model)
notifier := not.(*DingDingNotifier)
So(err, ShouldBeNil)
So(notifier.Name, ShouldEqual, "dingding_testing")
So(notifier.Type, ShouldEqual, "dingding")
So(notifier.Url, ShouldEqual, "https://www.google.com")
})
})
}

View File

@@ -1,14 +1,15 @@
package notifiers
import (
"strconv"
"strings"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/log"
"github.com/grafana/grafana/pkg/metrics"
m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/alerting"
"strconv"
"strings"
)
func init() {
@@ -23,6 +24,14 @@ func init() {
<span class="gf-form-label width-10">Url</span>
<input type="text" required class="gf-form-input max-width-26" ng-model="ctrl.model.settings.url" placeholder="http://sensu-api.local:4567/results"></input>
</div>
<div class="gf-form">
<span class="gf-form-label width-10">Source</span>
<input type="text" class="gf-form-input max-width-14" ng-model="ctrl.model.settings.source" bs-tooltip="'If empty rule id will be used'" data-placement="right"></input>
</div>
<div class="gf-form">
<span class="gf-form-label width-10">Handler</span>
<input type="text" class="gf-form-input max-width-14" ng-model="ctrl.model.settings.handler" placeholder="default"></input>
</div>
<div class="gf-form">
<span class="gf-form-label width-10">Username</span>
<input type="text" class="gf-form-input max-width-14" ng-model="ctrl.model.settings.username"></input>
@@ -46,7 +55,9 @@ func NewSensuNotifier(model *m.AlertNotification) (alerting.Notifier, error) {
NotifierBase: NewNotifierBase(model.Id, model.IsDefault, model.Name, model.Type, model.Settings),
Url: url,
User: model.Settings.Get("username").MustString(),
Source: model.Settings.Get("source").MustString(),
Password: model.Settings.Get("password").MustString(),
Handler: model.Settings.Get("handler").MustString(),
log: log.New("alerting.notifier.sensu"),
}, nil
}
@@ -54,8 +65,10 @@ func NewSensuNotifier(model *m.AlertNotification) (alerting.Notifier, error) {
type SensuNotifier struct {
NotifierBase
Url string
Source string
User string
Password string
Handler string
log log.Logger
}
@@ -67,9 +80,13 @@ func (this *SensuNotifier) Notify(evalContext *alerting.EvalContext) error {
bodyJSON.Set("ruleId", evalContext.Rule.Id)
// Sensu alerts cannot have spaces in them
bodyJSON.Set("name", strings.Replace(evalContext.Rule.Name, " ", "_", -1))
// Sensu alerts require a command
// We set it to the grafana ruleID
bodyJSON.Set("source", "grafana_rule_"+strconv.FormatInt(evalContext.Rule.Id, 10))
// Sensu alerts require a source. We set it to the user-specified value (optional),
// else we fallback and use the grafana ruleID.
if this.Source != "" {
bodyJSON.Set("source", this.Source)
} else {
bodyJSON.Set("source", "grafana_rule_"+strconv.FormatInt(evalContext.Rule.Id, 10))
}
// Finally, sensu expects an output
// We set it to a default output
bodyJSON.Set("output", "Grafana Metric Condition Met")
@@ -83,6 +100,10 @@ func (this *SensuNotifier) Notify(evalContext *alerting.EvalContext) error {
bodyJSON.Set("status", 0)
}
if this.Handler != "" {
bodyJSON.Set("handler", this.Handler)
}
ruleUrl, err := evalContext.GetRuleUrl()
if err == nil {
bodyJSON.Set("ruleUrl", ruleUrl)

View File

@@ -29,7 +29,9 @@ func TestSensuNotifier(t *testing.T) {
Convey("from settings", func() {
json := `
{
"url": "http://sensu-api.example.com:4567/results"
"url": "http://sensu-api.example.com:4567/results",
"source": "grafana_instance_01",
"handler": "myhandler"
}`
settingsJSON, _ := simplejson.NewJson([]byte(json))
@@ -46,6 +48,8 @@ func TestSensuNotifier(t *testing.T) {
So(sensuNotifier.Name, ShouldEqual, "sensu")
So(sensuNotifier.Type, ShouldEqual, "sensu")
So(sensuNotifier.Url, ShouldEqual, "http://sensu-api.example.com:4567/results")
So(sensuNotifier.Source, ShouldEqual, "grafana_instance_01")
So(sensuNotifier.Handler, ShouldEqual, "myhandler")
})
})
})

View File

@@ -146,7 +146,7 @@ func signUpStartedHandler(evt *events.SignUpStarted) error {
return nil
}
return sendEmailCommandHandler(&m.SendEmailCommand{
err := sendEmailCommandHandler(&m.SendEmailCommand{
To: []string{evt.Email},
Template: tmplSignUpStarted,
Data: map[string]interface{}{
@@ -155,6 +155,12 @@ func signUpStartedHandler(evt *events.SignUpStarted) error {
"SignUpUrl": setting.ToAbsUrl(fmt.Sprintf("signup/?email=%s&code=%s", url.QueryEscape(evt.Email), url.QueryEscape(evt.Code))),
},
})
if err != nil {
return err
}
emailSentCmd := m.UpdateTempUserWithEmailSentCommand{Code: evt.Code}
return bus.Dispatch(&emailSentCmd)
}
func signUpCompletedHandler(evt *events.SignUpCompleted) error {

View File

@@ -3,6 +3,7 @@ package sqlstore
import (
"bytes"
"fmt"
"time"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/metrics"
@@ -62,16 +63,20 @@ func SaveDashboard(cmd *m.SaveDashboardCommand) error {
if dash.Id != sameTitle.Id {
if cmd.Overwrite {
dash.Id = sameTitle.Id
dash.Version = sameTitle.Version
} else {
return m.ErrDashboardWithSameNameExists
}
}
}
parentVersion := dash.Version
affectedRows := int64(0)
if dash.Id == 0 {
dash.Version = 1
metrics.M_Models_Dashboard_Insert.Inc(1)
dash.Data.Set("version", dash.Version)
affectedRows, err = sess.Insert(dash)
} else {
dash.Version += 1
@@ -79,10 +84,32 @@ func SaveDashboard(cmd *m.SaveDashboardCommand) error {
affectedRows, err = sess.Id(dash.Id).Update(dash)
}
if err != nil {
return err
}
if affectedRows == 0 {
return m.ErrDashboardNotFound
}
dashVersion := &m.DashboardVersion{
DashboardId: dash.Id,
ParentVersion: parentVersion,
RestoredFrom: cmd.RestoredFrom,
Version: dash.Version,
Created: time.Now(),
CreatedBy: dash.UpdatedBy,
Message: cmd.Message,
Data: dash.Data,
}
// insert version entry
if affectedRows, err = sess.Insert(dashVersion); err != nil {
return err
} else if affectedRows == 0 {
return m.ErrDashboardNotFound
}
// delete existing tabs
_, err = sess.Exec("DELETE FROM dashboard_tag WHERE dashboard_id=?", dash.Id)
if err != nil {
@@ -106,8 +133,9 @@ func SaveDashboard(cmd *m.SaveDashboardCommand) error {
}
func GetDashboard(query *m.GetDashboardQuery) error {
dashboard := m.Dashboard{Slug: query.Slug, OrgId: query.OrgId}
dashboard := m.Dashboard{Slug: query.Slug, OrgId: query.OrgId, Id: query.Id}
has, err := x.Get(&dashboard)
if err != nil {
return err
} else if has == false {
@@ -116,7 +144,6 @@ func GetDashboard(query *m.GetDashboardQuery) error {
dashboard.Data.Set("id", dashboard.Id)
query.Result = &dashboard
return nil
}
@@ -233,6 +260,7 @@ func DeleteDashboard(cmd *m.DeleteDashboardCommand) error {
"DELETE FROM star WHERE dashboard_id = ? ",
"DELETE FROM dashboard WHERE id = ?",
"DELETE FROM playlist_item WHERE type = 'dashboard_by_id' AND value = ?",
"DELETE FROM dashboard_version WHERE dashboard_id = ?",
}
for _, sql := range deletes {

View File

@@ -0,0 +1,60 @@
package sqlstore
import (
"github.com/grafana/grafana/pkg/bus"
m "github.com/grafana/grafana/pkg/models"
)
func init() {
bus.AddHandler("sql", GetDashboardVersion)
bus.AddHandler("sql", GetDashboardVersions)
}
// GetDashboardVersion gets the dashboard version for the given dashboard ID and version number.
func GetDashboardVersion(query *m.GetDashboardVersionQuery) error {
version := m.DashboardVersion{}
has, err := x.Where("dashboard_version.dashboard_id=? AND dashboard_version.version=? AND dashboard.org_id=?", query.DashboardId, query.Version, query.OrgId).
Join("LEFT", "dashboard", `dashboard.id = dashboard_version.dashboard_id`).
Get(&version)
if err != nil {
return err
}
if !has {
return m.ErrDashboardVersionNotFound
}
version.Data.Set("id", version.DashboardId)
query.Result = &version
return nil
}
// GetDashboardVersions gets all dashboard versions for the given dashboard ID.
func GetDashboardVersions(query *m.GetDashboardVersionsQuery) error {
err := x.Table("dashboard_version").
Select(`dashboard_version.id,
dashboard_version.dashboard_id,
dashboard_version.parent_version,
dashboard_version.restored_from,
dashboard_version.version,
dashboard_version.created,
dashboard_version.created_by as created_by_id,
dashboard_version.message,
dashboard_version.data,`+
dialect.Quote("user")+`.login as created_by`).
Join("LEFT", "user", `dashboard_version.created_by = `+dialect.Quote("user")+`.id`).
Join("LEFT", "dashboard", `dashboard.id = dashboard_version.dashboard_id`).
Where("dashboard_version.dashboard_id=? AND dashboard.org_id=?", query.DashboardId, query.OrgId).
OrderBy("dashboard_version.version DESC").
Limit(query.Limit, query.Start).
Find(&query.Result)
if err != nil {
return err
}
if len(query.Result) < 1 {
return m.ErrNoVersionsForDashboardId
}
return nil
}

View File

@@ -0,0 +1,103 @@
package sqlstore
import (
"reflect"
"testing"
. "github.com/smartystreets/goconvey/convey"
"github.com/grafana/grafana/pkg/components/simplejson"
m "github.com/grafana/grafana/pkg/models"
)
func updateTestDashboard(dashboard *m.Dashboard, data map[string]interface{}) {
data["title"] = dashboard.Title
saveCmd := m.SaveDashboardCommand{
OrgId: dashboard.OrgId,
Overwrite: true,
Dashboard: simplejson.NewFromAny(data),
}
err := SaveDashboard(&saveCmd)
So(err, ShouldBeNil)
}
func TestGetDashboardVersion(t *testing.T) {
Convey("Testing dashboard version retrieval", t, func() {
InitTestDB(t)
Convey("Get a Dashboard ID and version ID", func() {
savedDash := insertTestDashboard("test dash 26", 1, "diff")
query := m.GetDashboardVersionQuery{
DashboardId: savedDash.Id,
Version: savedDash.Version,
OrgId: 1,
}
err := GetDashboardVersion(&query)
So(err, ShouldBeNil)
So(savedDash.Id, ShouldEqual, query.DashboardId)
So(savedDash.Version, ShouldEqual, query.Version)
dashCmd := m.GetDashboardQuery{
OrgId: savedDash.OrgId,
Slug: savedDash.Slug,
}
err = GetDashboard(&dashCmd)
So(err, ShouldBeNil)
eq := reflect.DeepEqual(dashCmd.Result.Data, query.Result.Data)
So(eq, ShouldEqual, true)
})
Convey("Attempt to get a version that doesn't exist", func() {
query := m.GetDashboardVersionQuery{
DashboardId: int64(999),
Version: 123,
OrgId: 1,
}
err := GetDashboardVersion(&query)
So(err, ShouldNotBeNil)
So(err, ShouldEqual, m.ErrDashboardVersionNotFound)
})
})
}
func TestGetDashboardVersions(t *testing.T) {
Convey("Testing dashboard versions retrieval", t, func() {
InitTestDB(t)
savedDash := insertTestDashboard("test dash 43", 1, "diff-all")
Convey("Get all versions for a given Dashboard ID", func() {
query := m.GetDashboardVersionsQuery{DashboardId: savedDash.Id, OrgId: 1}
err := GetDashboardVersions(&query)
So(err, ShouldBeNil)
So(len(query.Result), ShouldEqual, 1)
})
Convey("Attempt to get the versions for a non-existent Dashboard ID", func() {
query := m.GetDashboardVersionsQuery{DashboardId: int64(999), OrgId: 1}
err := GetDashboardVersions(&query)
So(err, ShouldNotBeNil)
So(err, ShouldEqual, m.ErrNoVersionsForDashboardId)
So(len(query.Result), ShouldEqual, 0)
})
Convey("Get all versions for an updated dashboard", func() {
updateTestDashboard(savedDash, map[string]interface{}{
"tags": "different-tag",
})
query := m.GetDashboardVersionsQuery{DashboardId: savedDash.Id, OrgId: 1}
err := GetDashboardVersions(&query)
So(err, ShouldBeNil)
So(len(query.Result), ShouldEqual, 2)
})
})
}

View File

@@ -16,6 +16,8 @@ func InitTestDB(t *testing.T) {
//x, err := xorm.NewEngine(sqlutil.TestDB_Mysql.DriverName, sqlutil.TestDB_Mysql.ConnStr)
//x, err := xorm.NewEngine(sqlutil.TestDB_Postgres.DriverName, sqlutil.TestDB_Postgres.ConnStr)
// x.ShowSQL()
if err != nil {
t.Fatalf("Failed to init in memory sqllite3 db %v", err)
}

View File

@@ -23,67 +23,59 @@ func NewXormLogger(level glog.Lvl, grafanaLog glog.Logger) *XormLogger {
}
// Error implement core.ILogger
func (s *XormLogger) Err(v ...interface{}) error {
func (s *XormLogger) Error(v ...interface{}) {
if s.level <= glog.LvlError {
s.grafanaLog.Error(fmt.Sprint(v...))
}
return nil
}
// Errorf implement core.ILogger
func (s *XormLogger) Errf(format string, v ...interface{}) error {
func (s *XormLogger) Errorf(format string, v ...interface{}) {
if s.level <= glog.LvlError {
s.grafanaLog.Error(fmt.Sprintf(format, v...))
}
return nil
}
// Debug implement core.ILogger
func (s *XormLogger) Debug(v ...interface{}) error {
func (s *XormLogger) Debug(v ...interface{}) {
if s.level <= glog.LvlDebug {
s.grafanaLog.Debug(fmt.Sprint(v...))
}
return nil
}
// Debugf implement core.ILogger
func (s *XormLogger) Debugf(format string, v ...interface{}) error {
func (s *XormLogger) Debugf(format string, v ...interface{}) {
if s.level <= glog.LvlDebug {
s.grafanaLog.Debug(fmt.Sprintf(format, v...))
}
return nil
}
// Info implement core.ILogger
func (s *XormLogger) Info(v ...interface{}) error {
func (s *XormLogger) Info(v ...interface{}) {
if s.level <= glog.LvlInfo {
s.grafanaLog.Info(fmt.Sprint(v...))
}
return nil
}
// Infof implement core.ILogger
func (s *XormLogger) Infof(format string, v ...interface{}) error {
func (s *XormLogger) Infof(format string, v ...interface{}) {
if s.level <= glog.LvlInfo {
s.grafanaLog.Info(fmt.Sprintf(format, v...))
}
return nil
}
// Warn implement core.ILogger
func (s *XormLogger) Warning(v ...interface{}) error {
func (s *XormLogger) Warn(v ...interface{}) {
if s.level <= glog.LvlWarn {
s.grafanaLog.Warn(fmt.Sprint(v...))
}
return nil
}
// Warnf implement core.ILogger
func (s *XormLogger) Warningf(format string, v ...interface{}) error {
func (s *XormLogger) Warnf(format string, v ...interface{}) {
if s.level <= glog.LvlWarn {
s.grafanaLog.Warn(fmt.Sprintf(format, v...))
}
return nil
}
// Level implement core.ILogger
@@ -103,8 +95,7 @@ func (s *XormLogger) Level() core.LogLevel {
}
// SetLevel implement core.ILogger
func (s *XormLogger) SetLevel(l core.LogLevel) error {
return nil
func (s *XormLogger) SetLevel(l core.LogLevel) {
}
// ShowSQL implement core.ILogger

View File

@@ -8,7 +8,7 @@ func addDashboardMigration(mg *Migrator) {
Columns: []*Column{
{Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true},
{Name: "version", Type: DB_Int, Nullable: false},
{Name: "slug", Type: DB_NVarchar, Length: 190, Nullable: false},
{Name: "slug", Type: DB_NVarchar, Length: 189, Nullable: false},
{Name: "title", Type: DB_NVarchar, Length: 255, Nullable: false},
{Name: "data", Type: DB_Text, Nullable: false},
{Name: "account_id", Type: DB_BigInt, Nullable: false},
@@ -114,7 +114,7 @@ func addDashboardMigration(mg *Migrator) {
// add column to store plugin_id
mg.AddMigration("Add column plugin_id in dashboard", NewAddColumnMigration(dashboardV2, &Column{
Name: "plugin_id", Type: DB_NVarchar, Nullable: true, Length: 255,
Name: "plugin_id", Type: DB_NVarchar, Nullable: true, Length: 189,
}))
mg.AddMigration("Add index for plugin_id in dashboard", NewAddIndexMigration(dashboardV2, &Index{
@@ -129,7 +129,7 @@ func addDashboardMigration(mg *Migrator) {
mg.AddMigration("Update dashboard table charset", NewTableCharsetMigration("dashboard", []*Column{
{Name: "slug", Type: DB_NVarchar, Length: 189, Nullable: false},
{Name: "title", Type: DB_NVarchar, Length: 255, Nullable: false},
{Name: "plugin_id", Type: DB_NVarchar, Nullable: true, Length: 255},
{Name: "plugin_id", Type: DB_NVarchar, Nullable: true, Length: 189},
{Name: "data", Type: DB_MediumText, Nullable: false},
}))

View File

@@ -0,0 +1,61 @@
package migrations
import . "github.com/grafana/grafana/pkg/services/sqlstore/migrator"
func addDashboardVersionMigration(mg *Migrator) {
dashboardVersionV1 := Table{
Name: "dashboard_version",
Columns: []*Column{
{Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true},
{Name: "dashboard_id", Type: DB_BigInt},
{Name: "parent_version", Type: DB_Int, Nullable: false},
{Name: "restored_from", Type: DB_Int, Nullable: false},
{Name: "version", Type: DB_Int, Nullable: false},
{Name: "created", Type: DB_DateTime, Nullable: false},
{Name: "created_by", Type: DB_BigInt, Nullable: false},
{Name: "message", Type: DB_Text, Nullable: false},
{Name: "data", Type: DB_Text, Nullable: false},
},
Indices: []*Index{
{Cols: []string{"dashboard_id"}},
{Cols: []string{"dashboard_id", "version"}, Type: UniqueIndex},
},
}
mg.AddMigration("create dashboard_version table v1", NewAddTableMigration(dashboardVersionV1))
mg.AddMigration("add index dashboard_version.dashboard_id", NewAddIndexMigration(dashboardVersionV1, dashboardVersionV1.Indices[0]))
mg.AddMigration("add unique index dashboard_version.dashboard_id and dashboard_version.version", NewAddIndexMigration(dashboardVersionV1, dashboardVersionV1.Indices[1]))
// before new dashboards where created with version 0, now they are always inserted with version 1
const setVersionTo1WhereZeroSQL = `UPDATE dashboard SET version = 1 WHERE version = 0`
mg.AddMigration("Set dashboard version to 1 where 0", new(RawSqlMigration).
Sqlite(setVersionTo1WhereZeroSQL).
Postgres(setVersionTo1WhereZeroSQL).
Mysql(setVersionTo1WhereZeroSQL))
const rawSQL = `INSERT INTO dashboard_version
(
dashboard_id,
version,
parent_version,
restored_from,
created,
created_by,
message,
data
)
SELECT
dashboard.id,
dashboard.version,
dashboard.version,
dashboard.version,
dashboard.updated,
COALESCE(dashboard.updated_by, -1),
'',
dashboard.data
FROM dashboard;`
mg.AddMigration("save existing dashboard data in dashboard_version table v1", new(RawSqlMigration).
Sqlite(rawSQL).
Postgres(rawSQL).
Mysql(rawSQL))
}

View File

@@ -25,6 +25,7 @@ func AddMigrations(mg *Migrator) {
addAlertMigrations(mg)
addAnnotationMig(mg)
addTestDataMigrations(mg)
addDashboardVersionMigration(mg)
}
func addMigrationLogMigrations(mg *Migrator) {

View File

@@ -199,7 +199,7 @@ func LoadConfig() {
if DbCfg.Type == "sqlite3" {
UseSQLite3 = true
// only allow one connection as sqlite3 has multi threading issues that casue table locks
// only allow one connection as sqlite3 has multi threading issues that cause table locks
// DbCfg.MaxIdleConn = 1
// DbCfg.MaxOpenConn = 1
}

View File

@@ -12,6 +12,7 @@ func init() {
bus.AddHandler("sql", GetTempUsersQuery)
bus.AddHandler("sql", UpdateTempUserStatus)
bus.AddHandler("sql", GetTempUserByCode)
bus.AddHandler("sql", UpdateTempUserWithEmailSent)
}
func UpdateTempUserStatus(cmd *m.UpdateTempUserStatusCommand) error {
@@ -35,6 +36,7 @@ func CreateTempUser(cmd *m.CreateTempUserCommand) error {
Status: cmd.Status,
RemoteAddr: cmd.RemoteAddr,
InvitedByUserId: cmd.InvitedByUserId,
EmailSentOn: time.Now(),
Created: time.Now(),
Updated: time.Now(),
}
@@ -48,6 +50,19 @@ func CreateTempUser(cmd *m.CreateTempUserCommand) error {
})
}
func UpdateTempUserWithEmailSent(cmd *m.UpdateTempUserWithEmailSentCommand) error {
return inTransaction(func(sess *DBSession) error {
user := &m.TempUser{
EmailSent: true,
EmailSentOn: time.Now(),
}
_, err := sess.Where("code = ?", cmd.Code).Cols("email_sent", "email_sent_on").Update(user)
return err
})
}
func GetTempUsersQuery(query *m.GetTempUsersQuery) error {
rawSql := `SELECT
tu.id as id,

View File

@@ -54,6 +54,19 @@ func TestTempUserCommandsAndQueries(t *testing.T) {
So(err, ShouldBeNil)
})
Convey("Should be able update email sent and email sent on", func() {
cmd3 := m.UpdateTempUserWithEmailSentCommand{Code: cmd.Result.Code}
err := UpdateTempUserWithEmailSent(&cmd3)
So(err, ShouldBeNil)
query := m.GetTempUsersQuery{OrgId: 2256, Status: m.TmpUserInvitePending}
err = GetTempUsersQuery(&query)
So(err, ShouldBeNil)
So(query.Result[0].EmailSent, ShouldBeTrue)
So(query.Result[0].EmailSentOn, ShouldHappenOnOrAfter, (query.Result[0].Created))
})
})
})
}

View File

@@ -306,7 +306,7 @@ func evalEnvVarExpression(value string) string {
envVar = strings.TrimSuffix(envVar, "}")
envValue := os.Getenv(envVar)
// if env variable is hostname and it is emtpy use os.Hostname as default
// if env variable is hostname and it is empty use os.Hostname as default
if envVar == "HOSTNAME" && envValue == "" {
envValue, _ = os.Hostname()
}
@@ -635,14 +635,14 @@ func LogConfigurationInfo() {
if len(appliedCommandLineProperties) > 0 {
for _, prop := range appliedCommandLineProperties {
logger.Info("Config overriden from command line", "arg", prop)
logger.Info("Config overridden from command line", "arg", prop)
}
}
if len(appliedEnvOverrides) > 0 {
text.WriteString("\tEnvironment variables used:\n")
for _, prop := range appliedEnvOverrides {
logger.Info("Config overriden from Environment variable", "var", prop)
logger.Info("Config overridden from Environment variable", "var", prop)
}
}

View File

@@ -3,6 +3,7 @@ package setting
import (
"os"
"path/filepath"
"runtime"
"testing"
. "github.com/smartystreets/goconvey/convey"
@@ -52,13 +53,22 @@ func TestLoadingSettings(t *testing.T) {
})
Convey("Should be able to override via command line", func() {
NewConfigContext(&CommandLineArgs{
HomePath: "../../",
Args: []string{"cfg:paths.data=/tmp/data", "cfg:paths.logs=/tmp/logs"},
})
if runtime.GOOS == "windows" {
NewConfigContext(&CommandLineArgs{
HomePath: "../../",
Args: []string{`cfg:paths.data=c:\tmp\data`, `cfg:paths.logs=c:\tmp\logs`},
})
So(DataPath, ShouldEqual, `c:\tmp\data`)
So(LogsPath, ShouldEqual, `c:\tmp\logs`)
} else {
NewConfigContext(&CommandLineArgs{
HomePath: "../../",
Args: []string{"cfg:paths.data=/tmp/data", "cfg:paths.logs=/tmp/logs"},
})
So(DataPath, ShouldEqual, "/tmp/data")
So(LogsPath, ShouldEqual, "/tmp/logs")
So(DataPath, ShouldEqual, "/tmp/data")
So(LogsPath, ShouldEqual, "/tmp/logs")
}
})
Convey("Should be able to override defaults via command line", func() {
@@ -73,37 +83,67 @@ func TestLoadingSettings(t *testing.T) {
So(Domain, ShouldEqual, "test2")
})
Convey("Defaults can be overriden in specified config file", func() {
NewConfigContext(&CommandLineArgs{
HomePath: "../../",
Config: filepath.Join(HomePath, "tests/config-files/override.ini"),
Args: []string{"cfg:default.paths.data=/tmp/data"},
})
Convey("Defaults can be overridden in specified config file", func() {
if runtime.GOOS == "windows" {
NewConfigContext(&CommandLineArgs{
HomePath: "../../",
Config: filepath.Join(HomePath, "tests/config-files/override_windows.ini"),
Args: []string{`cfg:default.paths.data=c:\tmp\data`},
})
So(DataPath, ShouldEqual, "/tmp/override")
So(DataPath, ShouldEqual, `c:\tmp\override`)
} else {
NewConfigContext(&CommandLineArgs{
HomePath: "../../",
Config: filepath.Join(HomePath, "tests/config-files/override.ini"),
Args: []string{"cfg:default.paths.data=/tmp/data"},
})
So(DataPath, ShouldEqual, "/tmp/override")
}
})
Convey("Command line overrides specified config file", func() {
NewConfigContext(&CommandLineArgs{
HomePath: "../../",
Config: filepath.Join(HomePath, "tests/config-files/override.ini"),
Args: []string{"cfg:paths.data=/tmp/data"},
})
if runtime.GOOS == "windows" {
NewConfigContext(&CommandLineArgs{
HomePath: "../../",
Config: filepath.Join(HomePath, "tests/config-files/override_windows.ini"),
Args: []string{`cfg:paths.data=c:\tmp\data`},
})
So(DataPath, ShouldEqual, "/tmp/data")
So(DataPath, ShouldEqual, `c:\tmp\data`)
} else {
NewConfigContext(&CommandLineArgs{
HomePath: "../../",
Config: filepath.Join(HomePath, "tests/config-files/override.ini"),
Args: []string{"cfg:paths.data=/tmp/data"},
})
So(DataPath, ShouldEqual, "/tmp/data")
}
})
Convey("Can use environment variables in config values", func() {
os.Setenv("GF_DATA_PATH", "/tmp/env_override")
NewConfigContext(&CommandLineArgs{
HomePath: "../../",
Args: []string{"cfg:paths.data=${GF_DATA_PATH}"},
})
if runtime.GOOS == "windows" {
os.Setenv("GF_DATA_PATH", `c:\tmp\env_override`)
NewConfigContext(&CommandLineArgs{
HomePath: "../../",
Args: []string{"cfg:paths.data=${GF_DATA_PATH}"},
})
So(DataPath, ShouldEqual, "/tmp/env_override")
So(DataPath, ShouldEqual, `c:\tmp\env_override`)
} else {
os.Setenv("GF_DATA_PATH", "/tmp/env_override")
NewConfigContext(&CommandLineArgs{
HomePath: "../../",
Args: []string{"cfg:paths.data=${GF_DATA_PATH}"},
})
So(DataPath, ShouldEqual, "/tmp/env_override")
}
})
Convey("instance_name default to hostname even if hostname env is emtpy", func() {
Convey("instance_name default to hostname even if hostname env is empty", func() {
NewConfigContext(&CommandLineArgs{
HomePath: "../../",
})

View File

@@ -2,7 +2,11 @@ package social
import (
"fmt"
"io/ioutil"
"net/http"
"strings"
"github.com/grafana/grafana/pkg/log"
)
func isEmailAllowed(email string, allowedDomains []string) bool {
@@ -18,3 +22,25 @@ func isEmailAllowed(email string, allowedDomains []string) bool {
return valid
}
func HttpGet(client *http.Client, url string) ([]byte, error) {
r, err := client.Get(url)
if err != nil {
return nil, err
}
defer r.Body.Close()
body, err := ioutil.ReadAll(r.Body)
if err != nil {
return nil, err
}
if r.StatusCode >= 300 {
return nil, fmt.Errorf(string(body))
}
log.Trace("HTTP GET %s: %s %s", url, r.Status, string(body))
return body, nil
}

View File

@@ -4,7 +4,6 @@ import (
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net/http"
"github.com/grafana/grafana/pkg/models"
@@ -84,22 +83,14 @@ func (s *GenericOAuth) FetchPrivateEmail(client *http.Client) (string, error) {
IsConfirmed bool `json:"is_confirmed"`
}
emailsUrl := fmt.Sprintf(s.apiUrl + "/emails")
r, err := client.Get(emailsUrl)
body, err := HttpGet(client, fmt.Sprintf(s.apiUrl+"/emails"))
if err != nil {
return "", err
return "", fmt.Errorf("Error getting email address: %s", err)
}
defer r.Body.Close()
var records []Record
body, err := ioutil.ReadAll(r.Body)
if err != nil {
return "", err
}
err = json.Unmarshal(body, records)
err = json.Unmarshal(body, &records)
if err != nil {
var data struct {
Values []Record `json:"values"`
@@ -107,7 +98,7 @@ func (s *GenericOAuth) FetchPrivateEmail(client *http.Client) (string, error) {
err = json.Unmarshal(body, &data)
if err != nil {
return "", err
return "", fmt.Errorf("Error getting email address: %s", err)
}
records = data.Values
@@ -129,18 +120,16 @@ func (s *GenericOAuth) FetchTeamMemberships(client *http.Client) ([]int, error)
Id int `json:"id"`
}
membershipUrl := fmt.Sprintf(s.apiUrl + "/teams")
r, err := client.Get(membershipUrl)
body, err := HttpGet(client, fmt.Sprintf(s.apiUrl+"/teams"))
if err != nil {
return nil, err
return nil, fmt.Errorf("Error getting team memberships: %s", err)
}
defer r.Body.Close()
var records []Record
if err = json.NewDecoder(r.Body).Decode(&records); err != nil {
return nil, err
err = json.Unmarshal(body, &records)
if err != nil {
return nil, fmt.Errorf("Error getting team memberships: %s", err)
}
var ids = make([]int, len(records))
@@ -156,18 +145,16 @@ func (s *GenericOAuth) FetchOrganizations(client *http.Client) ([]string, error)
Login string `json:"login"`
}
url := fmt.Sprintf(s.apiUrl + "/orgs")
r, err := client.Get(url)
body, err := HttpGet(client, fmt.Sprintf(s.apiUrl+"/orgs"))
if err != nil {
return nil, err
return nil, fmt.Errorf("Error getting organizations: %s", err)
}
defer r.Body.Close()
var records []Record
if err = json.NewDecoder(r.Body).Decode(&records); err != nil {
return nil, err
err = json.Unmarshal(body, &records)
if err != nil {
return nil, fmt.Errorf("Error getting organizations: %s", err)
}
var logins = make([]string, len(records))
@@ -188,16 +175,14 @@ func (s *GenericOAuth) UserInfo(client *http.Client) (*BasicUserInfo, error) {
Attributes map[string][]string `json:"attributes"`
}
var err error
r, err := client.Get(s.apiUrl)
body, err := HttpGet(client, s.apiUrl)
if err != nil {
return nil, err
return nil, fmt.Errorf("Error getting user info: %s", err)
}
defer r.Body.Close()
if err = json.NewDecoder(r.Body).Decode(&data); err != nil {
return nil, err
err = json.Unmarshal(body, &data)
if err != nil {
return nil, fmt.Errorf("Error getting user info: %s", err)
}
userInfo := &BasicUserInfo{

View File

@@ -85,18 +85,16 @@ func (s *SocialGithub) FetchPrivateEmail(client *http.Client) (string, error) {
Verified bool `json:"verified"`
}
emailsUrl := fmt.Sprintf(s.apiUrl + "/emails")
r, err := client.Get(emailsUrl)
body, err := HttpGet(client, fmt.Sprintf(s.apiUrl+"/emails"))
if err != nil {
return "", err
return "", fmt.Errorf("Error getting email address: %s", err)
}
defer r.Body.Close()
var records []Record
if err = json.NewDecoder(r.Body).Decode(&records); err != nil {
return "", err
err = json.Unmarshal(body, &records)
if err != nil {
return "", fmt.Errorf("Error getting email address: %s", err)
}
var email = ""
@@ -114,18 +112,16 @@ func (s *SocialGithub) FetchTeamMemberships(client *http.Client) ([]int, error)
Id int `json:"id"`
}
membershipUrl := fmt.Sprintf(s.apiUrl + "/teams")
r, err := client.Get(membershipUrl)
body, err := HttpGet(client, fmt.Sprintf(s.apiUrl+"/teams"))
if err != nil {
return nil, err
return nil, fmt.Errorf("Error getting team memberships: %s", err)
}
defer r.Body.Close()
var records []Record
if err = json.NewDecoder(r.Body).Decode(&records); err != nil {
return nil, err
err = json.Unmarshal(body, &records)
if err != nil {
return nil, fmt.Errorf("Error getting team memberships: %s", err)
}
var ids = make([]int, len(records))
@@ -141,18 +137,16 @@ func (s *SocialGithub) FetchOrganizations(client *http.Client) ([]string, error)
Login string `json:"login"`
}
url := fmt.Sprintf(s.apiUrl + "/orgs")
r, err := client.Get(url)
body, err := HttpGet(client, fmt.Sprintf(s.apiUrl+"/orgs"))
if err != nil {
return nil, err
return nil, fmt.Errorf("Error getting organizations: %s", err)
}
defer r.Body.Close()
var records []Record
if err = json.NewDecoder(r.Body).Decode(&records); err != nil {
return nil, err
err = json.Unmarshal(body, &records)
if err != nil {
return nil, fmt.Errorf("Error getting organizations: %s", err)
}
var logins = make([]string, len(records))
@@ -170,16 +164,14 @@ func (s *SocialGithub) UserInfo(client *http.Client) (*BasicUserInfo, error) {
Email string `json:"email"`
}
var err error
r, err := client.Get(s.apiUrl)
body, err := HttpGet(client, s.apiUrl)
if err != nil {
return nil, err
return nil, fmt.Errorf("Error getting user info: %s", err)
}
defer r.Body.Close()
if err = json.NewDecoder(r.Body).Decode(&data); err != nil {
return nil, err
err = json.Unmarshal(body, &data)
if err != nil {
return nil, fmt.Errorf("Error getting user info: %s", err)
}
userInfo := &BasicUserInfo{

View File

@@ -2,6 +2,7 @@ package social
import (
"encoding/json"
"fmt"
"net/http"
"github.com/grafana/grafana/pkg/models"
@@ -34,16 +35,17 @@ func (s *SocialGoogle) UserInfo(client *http.Client) (*BasicUserInfo, error) {
Name string `json:"name"`
Email string `json:"email"`
}
var err error
r, err := client.Get(s.apiUrl)
body, err := HttpGet(client, s.apiUrl)
if err != nil {
return nil, err
return nil, fmt.Errorf("Error getting user info: %s", err)
}
defer r.Body.Close()
if err = json.NewDecoder(r.Body).Decode(&data); err != nil {
return nil, err
err = json.Unmarshal(body, &data)
if err != nil {
return nil, fmt.Errorf("Error getting user info: %s", err)
}
return &BasicUserInfo{
Name: data.Name,
Email: data.Email,

View File

@@ -2,6 +2,7 @@ package social
import (
"encoding/json"
"fmt"
"net/http"
"github.com/grafana/grafana/pkg/models"
@@ -57,16 +58,14 @@ func (s *SocialGrafanaCom) UserInfo(client *http.Client) (*BasicUserInfo, error)
Orgs []OrgRecord `json:"orgs"`
}
var err error
r, err := client.Get(s.url + "/api/oauth2/user")
body, err := HttpGet(client, s.url+"/api/oauth2/user")
if err != nil {
return nil, err
return nil, fmt.Errorf("Error getting user info: %s", err)
}
defer r.Body.Close()
if err = json.NewDecoder(r.Body).Decode(&data); err != nil {
return nil, err
err = json.Unmarshal(body, &data)
if err != nil {
return nil, fmt.Errorf("Error getting user info: %s", err)
}
userInfo := &BasicUserInfo{

View File

@@ -73,7 +73,7 @@ func (m *MySqlMacroEngine) EvaluateMacro(name string, args []string) (string, er
if len(args) == 0 {
return "", fmt.Errorf("missing time column argument for macro %v", name)
}
return fmt.Sprintf("%s > FROM_UNIXTIME(%d) AND %s < FROM_UNIXTIME(%d)", args[0], uint64(m.TimeRange.GetFromAsMsEpoch()/1000), args[0], uint64(m.TimeRange.GetToAsMsEpoch()/1000)), nil
return fmt.Sprintf("%s >= FROM_UNIXTIME(%d) AND %s <= FROM_UNIXTIME(%d)", args[0], uint64(m.TimeRange.GetFromAsMsEpoch()/1000), args[0], uint64(m.TimeRange.GetToAsMsEpoch()/1000)), nil
default:
return "", fmt.Errorf("Unknown macro %v", name)
}

View File

@@ -36,7 +36,7 @@ func TestMacroEngine(t *testing.T) {
sql, err := engine.Interpolate("WHERE $__timeFilter(time_column)")
So(err, ShouldBeNil)
So(sql, ShouldEqual, "WHERE time_column > FROM_UNIXTIME(18446744066914186738) AND time_column < FROM_UNIXTIME(18446744066914187038)")
So(sql, ShouldEqual, "WHERE time_column >= FROM_UNIXTIME(18446744066914186738) AND time_column <= FROM_UNIXTIME(18446744066914187038)")
})
})

View File

@@ -183,6 +183,7 @@ func (e MysqlExecutor) getTypedRowData(types []*sql.ColumnType, rows *core.Rows)
values := make([]interface{}, len(types))
for i, stype := range types {
e.log.Info("type", "type", stype)
switch stype.DatabaseTypeName() {
case mysql.FieldTypeNameTiny:
values[i] = new(int8)
@@ -209,7 +210,7 @@ func (e MysqlExecutor) getTypedRowData(types []*sql.ColumnType, rows *core.Rows)
case mysql.FieldTypeNameDateTime:
values[i] = new(time.Time)
case mysql.FieldTypeNameTime:
values[i] = new(time.Duration)
values[i] = new(string)
case mysql.FieldTypeNameYear:
values[i] = new(int16)
case mysql.FieldTypeNameNULL:
@@ -307,6 +308,9 @@ func (s *stringStringScan) Update(rows *sql.Rows) error {
return err
}
s.time = null.FloatFromPtr(nil)
s.value = null.FloatFromPtr(nil)
for i := 0; i < s.columnCount; i++ {
if rb, ok := s.rowPtrs[i].(*sql.RawBytes); ok {
s.rowValues[i] = string(*rb)

View File

@@ -52,8 +52,8 @@ func TestTimeRange(t *testing.T) {
})
Convey("now-10m ", func() {
fiveMinAgo, _ := time.ParseDuration("-10m")
expected := now.Add(fiveMinAgo)
tenMinAgo, _ := time.ParseDuration("-10m")
expected := now.Add(tenMinAgo)
res, err := tr.ParseTo()
So(err, ShouldBeNil)
So(res.Unix(), ShouldEqual, expected.Unix())

View File

@@ -0,0 +1,58 @@
///<reference path="../../headers/common.d.ts" />
import coreModule from 'app/core/core_module';
const template = `
<div class="collapse-box">
<div class="collapse-box__header">
<a class="collapse-box__header-title pointer" ng-click="ctrl.toggle()">
<span class="fa fa-fw fa-caret-right" ng-hide="ctrl.isOpen"></span>
<span class="fa fa-fw fa-caret-down" ng-hide="!ctrl.isOpen"></span>
{{ctrl.title}}
</a>
<div class="collapse-box__header-actions" ng-transclude="actions" ng-if="ctrl.isOpen"></div>
</div>
<div class="collapse-box__body" ng-transclude="body" ng-if="ctrl.isOpen">
</div>
</div>
`;
export class CollapseBoxCtrl {
isOpen: boolean;
stateChanged: () => void;
/** @ngInject **/
constructor(private $timeout) {
this.isOpen = false;
}
toggle() {
this.isOpen = !this.isOpen;
this.$timeout(() => {
this.stateChanged();
});
}
}
export function collapseBox() {
return {
restrict: 'E',
template: template,
controller: CollapseBoxCtrl,
bindToController: true,
controllerAs: 'ctrl',
scope: {
"title": "@",
"isOpen": "=?",
"stateChanged": "&"
},
transclude: {
'actions': '?collapseBoxActions',
'body': 'collapseBoxBody',
},
link: function(scope, elem, attrs) {
}
};
}
coreModule.directive('collapseBox', collapseBox);

View File

@@ -0,0 +1,251 @@
///<reference path="../../../headers/common.d.ts" />
import config from 'app/core/config';
import _ from 'lodash';
import $ from 'jquery';
import coreModule from '../../core_module';
function typeaheadMatcher(item) {
var str = this.query;
if (str[0] === '/') { str = str.substring(1); }
if (str[str.length - 1] === '/') { str = str.substring(0, str.length-1); }
return item.toLowerCase().match(str.toLowerCase());
}
export class FormDropdownCtrl {
inputElement: any;
linkElement: any;
model: any;
display: any;
text: any;
options: any;
cssClass: any;
cssClasses: any;
allowCustom: any;
labelMode: boolean;
linkMode: boolean;
cancelBlur: any;
onChange: any;
getOptions: any;
optionCache: any;
lookupText: boolean;
/** @ngInject **/
constructor(private $scope, $element, private $sce, private templateSrv, private $q) {
this.inputElement = $element.find('input').first();
this.linkElement = $element.find('a').first();
this.linkMode = true;
this.cancelBlur = null;
// listen to model changes
$scope.$watch("ctrl.model", this.modelChanged.bind(this));
if (this.labelMode) {
this.cssClasses = 'gf-form-label ' + this.cssClass;
} else {
this.cssClasses = 'gf-form-input gf-form-input--dropdown ' + this.cssClass;
}
this.inputElement.attr('data-provide', 'typeahead');
this.inputElement.typeahead({
source: this.typeaheadSource.bind(this),
minLength: 0,
items: 10000,
updater: this.typeaheadUpdater.bind(this),
matcher: typeaheadMatcher,
});
// modify typeahead lookup
// this = typeahead
var typeahead = this.inputElement.data('typeahead');
typeahead.lookup = function () {
this.query = this.$element.val() || '';
var items = this.source(this.query, $.proxy(this.process, this));
return items ? this.process(items) : items;
};
this.linkElement.keydown(evt => {
// trigger typeahead on down arrow or enter key
if (evt.keyCode === 40 || evt.keyCode === 13) {
this.linkElement.click();
}
});
this.inputElement.keydown(evt => {
if (evt.keyCode === 13) {
setTimeout(() => {
this.inputElement.blur();
}, 100);
}
});
this.inputElement.blur(this.inputBlur.bind(this));
}
getOptionsInternal(query) {
var result = this.getOptions({$query: query});
if (this.isPromiseLike(result)) {
return result;
}
return this.$q.when(result);
}
isPromiseLike(obj) {
return obj && (typeof obj.then === 'function');
}
modelChanged() {
if (_.isObject(this.model)) {
this.updateDisplay(this.model.text);
} else {
// if we have text use it
if (this.lookupText) {
this.getOptionsInternal("").then(options => {
var item = _.find(options, {value: this.model});
this.updateDisplay(item ? item.text : this.model);
});
} else {
this.updateDisplay(this.model);
}
}
}
typeaheadSource(query, callback) {
this.getOptionsInternal(query).then(options => {
this.optionCache = options;
// extract texts
let optionTexts = _.map(options, 'text');
// add custom values
if (this.allowCustom) {
if (_.indexOf(optionTexts, this.text) === -1) {
options.unshift(this.text);
}
}
callback(optionTexts);
});
}
typeaheadUpdater(text) {
if (text === this.text) {
clearTimeout(this.cancelBlur);
this.inputElement.focus();
return text;
}
this.inputElement.val(text);
this.switchToLink(true);
return text;
}
switchToLink(fromClick) {
if (this.linkMode && !fromClick) { return; }
clearTimeout(this.cancelBlur);
this.cancelBlur = null;
this.linkMode = true;
this.inputElement.hide();
this.linkElement.show();
this.updateValue(this.inputElement.val());
}
inputBlur() {
// happens long before the click event on the typeahead options
// need to have long delay because the blur
this.cancelBlur = setTimeout(this.switchToLink.bind(this), 200);
}
updateValue(text) {
if (text === '' || this.text === text) {
return;
}
this.$scope.$apply(() => {
var option = _.find(this.optionCache, {text: text});
if (option) {
if (_.isObject(this.model)) {
this.model = option;
} else {
this.model = option.value;
}
this.text = option.text;
} else if (this.allowCustom) {
if (_.isObject(this.model)) {
this.model.text = this.model.value = text;
} else {
this.model = text;
}
this.text = text;
}
// needs to call this after digest so
// property is synced with outerscope
this.$scope.$$postDigest(() => {
this.$scope.$apply(() => {
this.onChange({$option: option});
});
});
});
}
updateDisplay(text) {
this.text = text;
this.display = this.$sce.trustAsHtml(this.templateSrv.highlightVariablesAsHtml(text));
}
open() {
this.inputElement.show();
this.inputElement.css('width', (Math.max(this.linkElement.width(), 80) + 16) + 'px');
this.inputElement.focus();
this.linkElement.hide();
this.linkMode = false;
var typeahead = this.inputElement.data('typeahead');
if (typeahead) {
this.inputElement.val('');
typeahead.lookup();
}
}
}
const template = `
<input type="text"
data-provide="typeahead"
class="gf-form-input"
spellcheck="false"
style="display:none">
</input>
<a ng-class="ctrl.cssClasses"
tabindex="1"
ng-click="ctrl.open()"
give-focus="ctrl.focus"
ng-bind-html="ctrl.display">
</a>
`;
export function formDropdownDirective() {
return {
restrict: 'E',
template: template,
controller: FormDropdownCtrl,
bindToController: true,
controllerAs: 'ctrl',
scope: {
model: "=",
getOptions: "&",
onChange: "&",
cssClass: "@",
allowCustom: "@",
labelMode: "@",
lookupText: "@",
},
};
}
coreModule.directive('gfFormDropdown', formDropdownDirective);

View File

@@ -105,10 +105,14 @@ export function grafanaAppDirective(playlistSrv, contextSrv) {
if (pageClass) {
body.removeClass(pageClass);
}
pageClass = data.$$route.pageClass;
if (pageClass) {
body.addClass(pageClass);
if (data.$$route) {
pageClass = data.$$route.pageClass;
if (pageClass) {
body.addClass(pageClass);
}
}
$("#tooltip, .tooltip").remove();
// check for kiosk url param
@@ -194,6 +198,15 @@ export function grafanaAppDirective(playlistSrv, contextSrv) {
});
}
}
// hide menus
var openMenus = body.find('.navbar-page-btn--open');
if (openMenus.length > 0) {
if (target.parents('.navbar-page-btn--open').length === 0) {
openMenus.removeClass('navbar-page-btn--open');
}
}
// hide sidemenu
if (!ignoreSideMenuHide && !contextSrv.pinned && body.find('.sidemenu').length > 0) {
if (target.parents('.sidemenu').length === 0) {

View File

@@ -1,7 +1,7 @@
<div class="modal-body">
<div class="modal-header">
<h2 class="modal-header-title">
<i class="fa fa-keyboard"></i>
<i class="fa fa-keyboard-o"></i>
<span class="p-l-1">Shortcuts</span>
</h2>
@@ -20,7 +20,7 @@
<div class="modal-content help-modal">
<p class="small" style="position: absolute; top: 48px; right: 10px">
<p class="small" style="position: absolute; top: 13px; right: 44px">
<span class="shortcut-table-key">mod</span> =
<span class="muted">CTRL on windows or linux and CMD key on Mac</span>
</p>

View File

@@ -0,0 +1,113 @@
// Based on work https://github.com/mohsen1/json-formatter-js
// Licence MIT, Copyright (c) 2015 Mohsen Azimi
/*
* Escapes `"` charachters from string
*/
function escapeString(str: string): string {
return str.replace('"', '\"');
}
/*
* Determines if a value is an object
*/
export function isObject(value: any): boolean {
var type = typeof value;
return !!value && (type === 'object');
}
/*
* Gets constructor name of an object.
* From http://stackoverflow.com/a/332429
*
*/
export function getObjectName(object: Object): string {
if (object === undefined) {
return '';
}
if (object === null) {
return 'Object';
}
if (typeof object === 'object' && !object.constructor) {
return 'Object';
}
const funcNameRegex = /function ([^(]*)/;
const results = (funcNameRegex).exec((object).constructor.toString());
if (results && results.length > 1) {
return results[1];
} else {
return '';
}
}
/*
* Gets type of an object. Returns "null" for null objects
*/
export function getType(object: Object): string {
if (object === null) { return 'null'; }
return typeof object;
}
/*
* Generates inline preview for a JavaScript object based on a value
*/
export function getValuePreview (object: Object, value: string): string {
var type = getType(object);
if (type === 'null' || type === 'undefined') { return type; }
if (type === 'string') {
value = '"' + escapeString(value) + '"';
}
if (type === 'function'){
// Remove content of the function
return object.toString()
.replace(/[\r\n]/g, '')
.replace(/\{.*\}/, '') + '{…}';
}
return value;
}
/*
* Generates inline preview for a JavaScript object
*/
export function getPreview(object: string): string {
let value = '';
if (isObject(object)) {
value = getObjectName(object);
if (Array.isArray(object)) {
value += '[' + object.length + ']';
}
} else {
value = getValuePreview(object, object);
}
return value;
}
/*
* Generates a prefixed CSS class name
*/
export function cssClass(className: string): string {
return `json-formatter-${className}`;
}
/*
* Creates a new DOM element wiht given type and class
* TODO: move me to helpers
*/
export function createElement(type: string, className?: string, content?: Element|string): Element {
const el = document.createElement(type);
if (className) {
el.classList.add(cssClass(className));
}
if (content !== undefined) {
if (content instanceof Node) {
el.appendChild(content);
} else {
el.appendChild(document.createTextNode(String(content)));
}
}
return el;
}

View File

@@ -0,0 +1,431 @@
// Based on work https://github.com/mohsen1/json-formatter-js
// Licence MIT, Copyright (c) 2015 Mohsen Azimi
import {
isObject,
getObjectName,
getType,
getValuePreview,
getPreview,
cssClass,
createElement
} from './helpers';
import _ from 'lodash';
const DATE_STRING_REGEX = /(^\d{1,4}[\.|\\/|-]\d{1,2}[\.|\\/|-]\d{1,4})(\s*(?:0?[1-9]:[0-5]|1(?=[012])\d:[0-5])\d\s*[ap]m)?$/;
const PARTIAL_DATE_REGEX = /\d{2}:\d{2}:\d{2} GMT-\d{4}/;
const JSON_DATE_REGEX = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/;
// When toggleing, don't animated removal or addition of more than a few items
const MAX_ANIMATED_TOGGLE_ITEMS = 10;
const requestAnimationFrame = window.requestAnimationFrame || function(cb: ()=>void) { cb(); return 0; };
export interface JsonExplorerConfig {
animateOpen?: boolean;
animateClose?: boolean;
theme?: string;
}
const _defaultConfig: JsonExplorerConfig = {
animateOpen: true,
animateClose: true,
theme: null
};
/**
* @class JsonExplorer
*
* JsonExplorer allows you to render JSON objects in HTML with a
* **collapsible** navigation.
*/
export class JsonExplorer {
// Hold the open state after the toggler is used
private _isOpen: boolean = null;
// A reference to the element that we render to
private element: Element;
private skipChildren = false;
/**
* @param {object} json The JSON object you want to render. It has to be an
* object or array. Do NOT pass raw JSON string.
*
* @param {number} [open=1] his number indicates up to how many levels the
* rendered tree should expand. Set it to `0` to make the whole tree collapsed
* or set it to `Infinity` to expand the tree deeply
*
* @param {object} [config=defaultConfig] -
* defaultConfig = {
* hoverPreviewEnabled: false,
* hoverPreviewArrayCount: 100,
* hoverPreviewFieldCount: 5
* }
*
* Available configurations:
* #####Hover Preview
* * `hoverPreviewEnabled`: enable preview on hover
* * `hoverPreviewArrayCount`: number of array items to show in preview Any
* array larger than this number will be shown as `Array[XXX]` where `XXX`
* is length of the array.
* * `hoverPreviewFieldCount`: number of object properties to show for object
* preview. Any object with more properties that thin number will be
* truncated.
*
* @param {string} [key=undefined] The key that this object in it's parent
* context
*/
constructor(public json: any, private open = 1, private config: JsonExplorerConfig = _defaultConfig, private key?: string) {
}
/*
* is formatter open?
*/
private get isOpen(): boolean {
if (this._isOpen !== null) {
return this._isOpen;
} else {
return this.open > 0;
}
}
/*
* set open state (from toggler)
*/
private set isOpen(value: boolean) {
this._isOpen = value;
}
/*
* is this a date string?
*/
private get isDate(): boolean {
return (this.type === 'string') &&
(DATE_STRING_REGEX.test(this.json) ||
JSON_DATE_REGEX.test(this.json) ||
PARTIAL_DATE_REGEX.test(this.json));
}
/*
* is this a URL string?
*/
private get isUrl(): boolean {
return this.type === 'string' && (this.json.indexOf('http') === 0);
}
/*
* is this an array?
*/
private get isArray(): boolean {
return Array.isArray(this.json);
}
/*
* is this an object?
* Note: In this context arrays are object as well
*/
private get isObject(): boolean {
return isObject(this.json);
}
/*
* is this an empty object with no properties?
*/
private get isEmptyObject(): boolean {
return !this.keys.length && !this.isArray;
}
/*
* is this an empty object or array?
*/
private get isEmpty(): boolean {
return this.isEmptyObject || (this.keys && !this.keys.length && this.isArray);
}
/*
* did we recieve a key argument?
* This means that the formatter was called as a sub formatter of a parent formatter
*/
private get hasKey(): boolean {
return typeof this.key !== 'undefined';
}
/*
* if this is an object, get constructor function name
*/
private get constructorName(): string {
return getObjectName(this.json);
}
/*
* get type of this value
* Possible values: all JavaScript primitive types plus "array" and "null"
*/
private get type(): string {
return getType(this.json);
}
/*
* get object keys
* If there is an empty key we pad it wit quotes to make it visible
*/
private get keys(): string[] {
if (this.isObject) {
return Object.keys(this.json).map((key)=> key ? key : '""');
} else {
return [];
}
}
/**
* Toggles `isOpen` state
*
*/
toggleOpen() {
this.isOpen = !this.isOpen;
if (this.element) {
if (this.isOpen) {
this.appendChildren(this.config.animateOpen);
} else{
this.removeChildren(this.config.animateClose);
}
this.element.classList.toggle(cssClass('open'));
}
}
/**
* Open all children up to a certain depth.
* Allows actions such as expand all/collapse all
*
*/
openAtDepth(depth = 1) {
if (depth < 0) {
return;
}
this.open = depth;
this.isOpen = (depth !== 0);
if (this.element) {
this.removeChildren(false);
if (depth === 0) {
this.element.classList.remove(cssClass('open'));
} else {
this.appendChildren(this.config.animateOpen);
this.element.classList.add(cssClass('open'));
}
}
}
isNumberArray() {
return (this.json.length > 0 && this.json.length < 4) &&
(_.isNumber(this.json[0]) || _.isNumber(this.json[1]));
}
renderArray() {
const arrayWrapperSpan = createElement('span');
arrayWrapperSpan.appendChild(createElement('span', 'bracket', '['));
// some pretty handling of number arrays
if (this.isNumberArray()) {
this.json.forEach((val, index) => {
if (index > 0) {
arrayWrapperSpan.appendChild(createElement('span', 'array-comma', ','));
}
arrayWrapperSpan.appendChild(createElement('span', 'number', val));
});
this.skipChildren = true;
} else {
arrayWrapperSpan.appendChild(createElement('span', 'number', (this.json.length)));
}
arrayWrapperSpan.appendChild(createElement('span', 'bracket', ']'));
return arrayWrapperSpan;
}
/**
* Renders an HTML element and installs event listeners
*
* @returns {HTMLDivElement}
*/
render(skipRoot = false): HTMLDivElement {
// construct the root element and assign it to this.element
this.element = createElement('div', 'row');
// construct the toggler link
const togglerLink = createElement('a', 'toggler-link');
const togglerIcon = createElement('span', 'toggler');
// if this is an object we need a wrapper span (toggler)
if (this.isObject) {
togglerLink.appendChild(togglerIcon);
}
// if this is child of a parent formatter we need to append the key
if (this.hasKey) {
togglerLink.appendChild(createElement('span', 'key', `${this.key}:`));
}
// Value for objects and arrays
if (this.isObject) {
// construct the value holder element
const value = createElement('span', 'value');
// we need a wrapper span for objects
const objectWrapperSpan = createElement('span');
// get constructor name and append it to wrapper span
var constructorName = createElement('span', 'constructor-name', this.constructorName);
objectWrapperSpan.appendChild(constructorName);
// if it's an array append the array specific elements like brackets and length
if (this.isArray) {
const arrayWrapperSpan = this.renderArray();
objectWrapperSpan.appendChild(arrayWrapperSpan);
}
// append object wrapper span to toggler link
value.appendChild(objectWrapperSpan);
togglerLink.appendChild(value);
// Primitive values
} else {
// make a value holder element
const value = this.isUrl ? createElement('a') : createElement('span');
// add type and other type related CSS classes
value.classList.add(cssClass(this.type));
if (this.isDate) {
value.classList.add(cssClass('date'));
}
if (this.isUrl) {
value.classList.add(cssClass('url'));
value.setAttribute('href', this.json);
}
// Append value content to value element
const valuePreview = getValuePreview(this.json, this.json);
value.appendChild(document.createTextNode(valuePreview));
// append the value element to toggler link
togglerLink.appendChild(value);
}
// construct a children element
const children = createElement('div', 'children');
// set CSS classes for children
if (this.isObject) {
children.classList.add(cssClass('object'));
}
if (this.isArray) {
children.classList.add(cssClass('array'));
}
if (this.isEmpty) {
children.classList.add(cssClass('empty'));
}
// set CSS classes for root element
if (this.config && this.config.theme) {
this.element.classList.add(cssClass(this.config.theme));
}
if (this.isOpen) {
this.element.classList.add(cssClass('open'));
}
// append toggler and children elements to root element
if (!skipRoot) {
this.element.appendChild(togglerLink);
}
if (!this.skipChildren) {
this.element.appendChild(children);
} else {
// remove togglerIcon
togglerLink.removeChild(togglerIcon);
}
// if formatter is set to be open call appendChildren
if (this.isObject && this.isOpen) {
this.appendChildren();
}
// add event listener for toggling
if (this.isObject) {
togglerLink.addEventListener('click', this.toggleOpen.bind(this));
}
return this.element as HTMLDivElement;
}
/**
* Appends all the children to children element
* Animated option is used when user triggers this via a click
*/
appendChildren(animated = false) {
const children = this.element.querySelector(`div.${cssClass('children')}`);
if (!children || this.isEmpty) { return; }
if (animated) {
let index = 0;
const addAChild = ()=> {
const key = this.keys[index];
const formatter = new JsonExplorer(this.json[key], this.open - 1, this.config, key);
children.appendChild(formatter.render());
index += 1;
if (index < this.keys.length) {
if (index > MAX_ANIMATED_TOGGLE_ITEMS) {
addAChild();
} else {
requestAnimationFrame(addAChild);
}
}
};
requestAnimationFrame(addAChild);
} else {
this.keys.forEach(key => {
const formatter = new JsonExplorer(this.json[key], this.open - 1, this.config, key);
children.appendChild(formatter.render());
});
}
}
/**
* Removes all the children from children element
* Animated option is used when user triggers this via a click
*/
removeChildren(animated = false) {
const childrenElement = this.element.querySelector(`div.${cssClass('children')}`) as HTMLDivElement;
if (animated) {
let childrenRemoved = 0;
const removeAChild = ()=> {
if (childrenElement && childrenElement.children.length) {
childrenElement.removeChild(childrenElement.children[0]);
childrenRemoved += 1;
if (childrenRemoved > MAX_ANIMATED_TOGGLE_ITEMS) {
removeAChild();
} else {
requestAnimationFrame(removeAChild);
}
}
};
requestAnimationFrame(removeAChild);
} else {
if (childrenElement) {
childrenElement.innerHTML = '';
}
}
}
}

View File

@@ -8,11 +8,36 @@
<i class="fa fa-chevron-left"></i>
</a>
<a href="{{ctrl.titleUrl}}" class="navbar-page-btn" ng-show="ctrl.title">
<i class="{{ctrl.icon}}" ng-show="ctrl.icon"></i>
<img ng-src="{{ctrl.iconUrl}}" ng-show="ctrl.iconUrl"></i>
{{ctrl.title}}
</a>
<!-- <a class="navbar&#45;page&#45;btn navbar&#45;page&#45;btn&#45;&#45;search" ng&#45;click="ctrl.showSearch()"> -->
<!-- <i class="fa fa&#45;search"></i> -->
<!-- </a> -->
<div ng-transclude></div>
<div ng-if="::!ctrl.hasMenu">
<a href="{{::ctrl.section.url}}" class="navbar-page-btn">
<i class="{{::ctrl.section.icon}}" ng-show="::ctrl.section.icon"></i>
<img ng-src="{{::ctrl.section.iconUrl}}" ng-show="::ctrl.section.iconUrl"></i>
{{::ctrl.section.title}}
</a>
</div>
<div class="dropdown navbar-section-wrapper" ng-if="::ctrl.hasMenu">
<a href="{{::ctrl.section.url}}" class="navbar-page-btn" data-toggle="dropdown">
<i class="{{::ctrl.section.icon}}" ng-show="::ctrl.section.icon"></i>
<img ng-src="{{::ctrl.section.iconUrl}}" ng-show="::ctrl.section.iconUrl"></i>
{{::ctrl.section.title}}
<i class="fa fa-caret-down"></i>
</a>
<ul class="dropdown-menu dropdown-menu--navbar">
<li ng-repeat="navItem in ::ctrl.model.menu" ng-class="{active: navItem.active}">
<a class="pointer" ng-href="{{::navItem.url}}" ng-click="ctrl.navItemClicked(navItem, $event)">
<i class="{{::navItem.icon}}" ng-show="::navItem.icon"></i>
{{::navItem.title}}
</a>
</li>
</ul>
</div>
<div ng-transclude></div>
</div>
<dashboard-search></dashboard-search>

View File

@@ -4,10 +4,28 @@ import config from 'app/core/config';
import _ from 'lodash';
import $ from 'jquery';
import coreModule from '../../core_module';
import {NavModel, NavModelItem} from '../../nav_model_srv';
export class NavbarCtrl {
model: NavModel;
section: NavModelItem;
hasMenu: boolean;
/** @ngInject */
constructor(private $scope, private contextSrv) {
constructor(private $scope, private $rootScope, private contextSrv) {
this.section = this.model.section;
this.hasMenu = this.model.menu.length > 0;
}
showSearch() {
this.$rootScope.appEvent('show-dash-search');
}
navItemClicked(navItem, evt) {
if (navItem.clickHandler) {
navItem.clickHandler();
evt.preventDefault();
}
}
}
@@ -20,12 +38,9 @@ export function navbarDirective() {
transclude: true,
controllerAs: 'ctrl',
scope: {
title: "@",
titleUrl: "@",
iconUrl: "@",
model: "=",
},
link: function(scope, elem, attrs, ctrl) {
ctrl.icon = attrs.icon;
link: function(scope, elem) {
elem.addClass('navbar');
}
};

View File

@@ -1,9 +1,22 @@
<div class="search-backdrop" ng-if="ctrl.isOpen"></div>
<div class="search-container" ng-if="ctrl.isOpen">
<div class="search-field-wrapper">
<span style="position: relative;">
<input type="text" placeholder="Find dashboards by name" give-focus="ctrl.giveSearchFocus" tabindex="1"
ng-keydown="ctrl.keyDown($event)" ng-model="ctrl.query.query" ng-model-options="{ debounce: 500 }" spellcheck='false' ng-change="ctrl.search()" />
</span>
<div class="search-field-icon pointer" ng-click="ctrl.closeSearch()">
<i class="fa fa-search"></i>
</div>
<input type="text" placeholder="Find dashboards by name" give-focus="ctrl.giveSearchFocus" tabindex="1"
ng-keydown="ctrl.keyDown($event)"
ng-model="ctrl.query.query"
ng-model-options="{ debounce: 500 }"
spellcheck='false'
ng-change="ctrl.search()"
ng-blur="ctrl.searchInputBlur()"
/>
<div class="search-switches">
<i class="fa fa-filter"></i>
<a class="pointer" href="javascript:void 0;" ng-click="ctrl.showStarred()" tabindex="2">
@@ -24,54 +37,55 @@
</span>
</span>
</div>
<div class="search-field-spacer"></div>
</div>
<div class="search-results-container" ng-if="ctrl.tagsMode">
<div ng-repeat="tag in ctrl.results" class="pointer" style="width: 180px; float: left;"
ng-class="{'selected': $index === ctrl.selectedIndex }"
ng-click="ctrl.filterByTag(tag.term, $event)">
<a class="search-result-tag label label-tag" tag-color-from-name="tag.term">
<i class="fa fa-tag"></i>
<span>{{tag.term}} &nbsp;({{tag.count}})</span>
<div class="search-dropdown" ng-class="{'search-dropdown--fade-in': ctrl.openCompleted}">
<div class="search-results-container" ng-if="ctrl.tagsMode">
<div ng-repeat="tag in ctrl.results" class="pointer" style="width: 180px; float: left;"
ng-class="{'selected': $index === ctrl.selectedIndex }"
ng-click="ctrl.filterByTag(tag.term, $event)">
<a class="search-result-tag label label-tag" tag-color-from-name="tag.term">
<i class="fa fa-tag"></i>
<span>{{tag.term}} &nbsp;({{tag.count}})</span>
</a>
</div>
</div>
<div class="search-results-container" ng-if="!ctrl.tagsMode">
<h6 ng-hide="ctrl.results.length">No dashboards matching your query were found.</h6>
<a class="search-item pointer search-item-{{row.type}}" bindonce ng-repeat="row in ctrl.results"
ng-class="{'selected': $index == ctrl.selectedIndex}" ng-href="{{row.url}}">
<span class="search-result-tags">
<span ng-click="ctrl.filterByTag(tag, $event)" ng-repeat="tag in row.tags" tag-color-from-name="tag" class="label label-tag">
{{tag}}
</span>
<i class="fa" ng-class="{'fa-star': row.isStarred, 'fa-star-o': !row.isStarred}"></i>
</span>
<span class="search-result-link">
<i class="fa search-result-icon"></i>
<span bo-text="row.title"></span>
</span>
</a>
</div>
<div class="search-button-row">
<a class="btn btn-secondary" href="dashboard/new" ng-show="ctrl.contextSrv.isEditor" ng-click="ctrl.isOpen = false;">
<i class="fa fa-plus"></i>&nbsp; New Dashboard
</a>
<a class="btn btn-inverse" href="dashboard/new/?editview=import" ng-show="ctrl.contextSrv.isEditor" ng-click="ctrl.isOpen = false;">
<i class="fa fa-upload"></i>&nbsp; Import Dashboard
</a>
<a class="search-button-row-explore-link" target="_blank" href="https://grafana.com/dashboards?utm_source=grafana_search">
Find <img src="public/img/icn-dashboard-tiny.svg" width="14" /> dashboards on Grafana.com
</a>
</div>
</div>
<div class="search-results-container" ng-if="!ctrl.tagsMode">
<h6 ng-hide="ctrl.results.length">No dashboards matching your query were found.</h6>
<a class="search-item pointer search-item-{{row.type}}" bindonce ng-repeat="row in ctrl.results"
ng-class="{'selected': $index == ctrl.selectedIndex}" ng-href="{{row.url}}">
<span class="search-result-tags">
<span ng-click="ctrl.filterByTag(tag, $event)" ng-repeat="tag in row.tags" tag-color-from-name="tag" class="label label-tag">
{{tag}}
</span>
<i class="fa" ng-class="{'fa-star': row.isStarred, 'fa-star-o': !row.isStarred}"></i>
</span>
<span class="search-result-link">
<i class="fa search-result-icon"></i>
<span bo-text="row.title"></span>
</span>
</a>
</div>
<div class="search-button-row">
<a class="btn btn-inverse pull-left" href="dashboard/new" ng-show="ctrl.contextSrv.isEditor" ng-click="ctrl.isOpen = false;">
<i class="fa fa-plus"></i>
Create New
</a>
<a class="btn btn-inverse pull-left" href="dashboard/new/?editview=import" ng-show="ctrl.contextSrv.isEditor" ng-click="ctrl.isOpen = false;">
<i class="fa fa-upload"></i>
Import
</a>
<a class="pull-right search-button-row-explore-link" target="_blank" href="https://grafana.com/dashboards?utm_source=grafana_search">
Find <img src="public/img/icn-dashboard-tiny.svg" width="14" /> dashboards on Grafana.com
</a>
<div class="clearfix"></div>
</div>
</div>

View File

@@ -18,6 +18,8 @@ export class SearchCtrl {
showImport: boolean;
dismiss: any;
ignoreClose: any;
// triggers fade animation class
openCompleted: boolean;
/** @ngInject */
constructor(private $scope, private $location, private $timeout, private backendSrv, private contextSrv, private $rootScope) {
@@ -27,6 +29,7 @@ export class SearchCtrl {
closeSearch() {
this.isOpen = this.ignoreClose;
this.openCompleted = false;
}
openSearch(evt, payload) {
@@ -56,6 +59,7 @@ export class SearchCtrl {
}
this.$timeout(() => {
this.openCompleted = true;
this.ignoreClose = false;
this.giveSearchFocus = this.giveSearchFocus + 1;
this.search();
@@ -73,7 +77,7 @@ export class SearchCtrl {
this.moveSelection(-1);
}
if (evt.keyCode === 13) {
if (this.$scope.tagMode) {
if (this.tagsMode) {
var tag = this.results[this.selectedIndex];
if (tag) {
this.filterByTag(tag.term, null);
@@ -169,6 +173,7 @@ export function searchDirective() {
controller: SearchCtrl,
bindToController: true,
controllerAs: 'ctrl',
scope: {},
};
}

View File

@@ -7,7 +7,7 @@ import coreModule from 'app/core/core_module';
import Drop from 'tether-drop';
var template = `
<label for="check-{{ctrl.id}}" class="gf-form-label {{ctrl.labelClass}} pointer">
<label for="check-{{ctrl.id}}" class="gf-form-label {{ctrl.labelClass}} pointer" ng-show="ctrl.label">
{{ctrl.label}}
<info-popover mode="right-normal" ng-if="ctrl.tooltip" position="top center">
{{ctrl.tooltip}}
@@ -24,6 +24,7 @@ export class SwitchCtrl {
checked: any;
show: any;
id: any;
label: string;
/** @ngInject */
constructor($scope, private $timeout) {

View File

@@ -5,7 +5,9 @@ define([
function (angular, coreModule) {
'use strict';
coreModule.default.controller('ErrorCtrl', function($scope, contextSrv) {
coreModule.default.controller('ErrorCtrl', function($scope, contextSrv, navModelSrv) {
$scope.navModel = navModelSrv.getNotFoundNav();
var showSideMenu = contextSrv.sidemenu;
contextSrv.sidemenu = false;

View File

@@ -15,6 +15,7 @@ import "./directives/value_select_dropdown";
import "./directives/plugin_component";
import "./directives/rebuild_on_change";
import "./directives/give_focus";
import "./directives/diff-view";
import './jquery_extended';
import './partials';
import './components/jsontree/jsontree';
@@ -33,6 +34,7 @@ import {switchDirective} from './components/switch';
import {dashboardSelector} from './components/dashboard_selector';
import {queryPartEditorDirective} from './components/query_part/query_part_editor';
import {WizardFlow} from './components/wizard/wizard';
import {formDropdownDirective} from './components/form_dropdown/form_dropdown';
import 'app/core/controllers/all';
import 'app/core/services/all';
import 'app/core/routes/routes';
@@ -44,6 +46,9 @@ import {assignModelProperties} from './utils/model_utils';
import {contextSrv} from './services/context_srv';
import {KeybindingSrv} from './services/keybindingSrv';
import {helpModal} from './components/help/help';
import {collapseBox} from './components/collapse_box';
import {JsonExplorer} from './components/json_explorer/json_explorer';
import {NavModelSrv, NavModel} from './nav_model_srv';
export {
@@ -64,8 +69,13 @@ export {
queryPartEditorDirective,
WizardFlow,
colors,
formDropdownDirective,
assignModelProperties,
contextSrv,
KeybindingSrv,
helpModal,
collapseBox,
JsonExplorer,
NavModelSrv,
NavModel,
};

View File

@@ -1,15 +1,18 @@
define([
'jquery',
'angular',
'../core_module',
],
function ($, coreModule) {
function ($, angular, coreModule) {
'use strict';
var editViewMap = {
'settings': { src: 'public/app/features/dashboard/partials/settings.html'},
'annotations': { src: 'public/app/features/annotations/partials/editor.html'},
'templating': { src: 'public/app/features/templating/partials/editor.html'},
'import': { src: '<dash-import></dash-import>' }
'history': { html: '<gf-dashboard-history dashboard="dashboard"></gf-dashboard-history>'},
'timepicker': { src: 'public/app/features/dashboard/timepicker/dropdown.html' },
'import': { html: '<dash-import></dash-import>' }
};
coreModule.default.directive('dashEditorView', function($compile, $location, $rootScope) {
@@ -17,47 +20,53 @@ function ($, coreModule) {
restrict: 'A',
link: function(scope, elem) {
var editorScope;
var lastEditor;
var lastEditView;
function hideEditorPane() {
function hideEditorPane(hideToShowOtherView) {
if (editorScope) {
scope.appEvent('dash-editor-hidden', lastEditor);
editorScope.dismiss();
editorScope.dismiss(hideToShowOtherView);
scope.appEvent('dash-editor-hidden');
}
}
function showEditorPane(evt, payload, editview) {
if (editview) {
scope.contextSrv.editview = editViewMap[editview];
payload.src = scope.contextSrv.editview.src;
function showEditorPane(evt, options) {
if (options.editview) {
options.src = editViewMap[options.editview].src;
options.html = editViewMap[options.editview].html;
}
if (lastEditor === payload.src) {
hideEditorPane();
if (lastEditView && lastEditView === options.editview) {
hideEditorPane(false);
return;
}
hideEditorPane();
hideEditorPane(true);
lastEditor = payload.src;
editorScope = payload.scope ? payload.scope.$new() : scope.$new();
lastEditView = options.editview;
editorScope = options.scope ? options.scope.$new() : scope.$new();
editorScope.dismiss = function() {
editorScope.dismiss = function(hideToShowOtherView) {
editorScope.$destroy();
elem.empty();
lastEditor = null;
lastEditView = null;
editorScope = null;
elem.removeClass('dash-edit-view--open');
if (editview) {
if (!hideToShowOtherView) {
setTimeout(function() {
elem.empty();
}, 250);
}
if (options.editview) {
var urlParams = $location.search();
if (editview === urlParams.editview) {
if (options.editview === urlParams.editview) {
delete urlParams.editview;
$location.search(urlParams);
}
}
};
if (editview === 'import') {
if (options.editview === 'import') {
var modalScope = $rootScope.$new();
modalScope.$on("$destroy", function() {
editorScope.dismiss();
@@ -72,31 +81,50 @@ function ($, coreModule) {
return;
}
var view = payload.src;
if (view.indexOf('.html') > 0) {
view = $('<div class="tabbed-view" ng-include="' + "'" + view + "'" + '"></div>');
var view;
if (options.src) {
view = angular.element(document.createElement('div'));
view.html('<div class="tabbed-view" ng-include="' + "'" + options.src + "'" + '"></div>');
} else {
view = angular.element(document.createElement('div'));
view.addClass('tabbed-view');
view.html(options.html);
}
elem.append(view);
$compile(elem.contents())(editorScope);
$compile(view)(editorScope);
setTimeout(function() {
elem.empty();
elem.append(view);
setTimeout(function() {
elem.addClass('dash-edit-view--open');
}, 10);
}, 10);
}
scope.$watch("dashboardViewState.state.editview", function(newValue, oldValue) {
if (newValue) {
showEditorPane(null, {}, newValue);
showEditorPane(null, {editview: newValue});
} else if (oldValue) {
scope.contextSrv.editview = null;
if (lastEditor === editViewMap[oldValue]) {
if (lastEditView === oldValue) {
hideEditorPane();
}
}
});
scope.contextSrv.editview = null;
scope.$on("$destroy", hideEditorPane);
scope.onAppEvent('hide-dash-editor', hideEditorPane);
scope.onAppEvent('hide-dash-editor', function() {
hideEditorPane(false);
});
scope.onAppEvent('show-dash-editor', showEditorPane);
scope.onAppEvent('panel-fullscreen-enter', function() {
scope.appEvent('hide-dash-editor');
});
}
};
});
});

View File

@@ -0,0 +1,77 @@
///<reference path="../../headers/common.d.ts" />
import angular from 'angular';
import coreModule from '../core_module';
export class DeltaCtrl {
observer: any;
/** @ngInject */
constructor(private $rootScope) {
const waitForCompile = function(mutations) {
if (mutations.length === 1) {
this.$rootScope.appEvent('json-diff-ready');
}
};
this.observer = new MutationObserver(waitForCompile.bind(this));
const observerConfig = {
attributes: true,
attributeFilter: ['class'],
characterData: false,
childList: true,
subtree: false,
};
this.observer.observe(angular.element('.delta-html')[0], observerConfig);
}
$onDestroy() {
this.observer.disconnect();
}
}
export function delta() {
return {
controller: DeltaCtrl,
replace: false,
restrict: 'A',
};
}
coreModule.directive('diffDelta', delta);
// Link to JSON line number
export class LinkJSONCtrl {
/** @ngInject */
constructor(private $scope, private $rootScope, private $anchorScroll) {}
goToLine(line: number) {
let unbind;
const scroll = () => {
this.$anchorScroll(`l${line}`);
unbind();
};
this.$scope.switchView().then(() => {
unbind = this.$rootScope.$on('json-diff-ready', scroll.bind(this));
});
}
}
export function linkJson() {
return {
controller: LinkJSONCtrl,
controllerAs: 'ctrl',
replace: true,
restrict: 'E',
scope: {
line: '@lineDisplay',
link: '@lineLink',
switchView: '&',
},
template: `<a class="diff-linenum btn btn-inverse btn-small" ng-click="ctrl.goToLine(link)">Line {{ line }}</a>`
};
}
coreModule.directive('diffLinkJson', linkJson);

View File

@@ -1,9 +1,10 @@
define([
'angular',
'require',
'../core_module',
'app/core/utils/kbn',
],
function (angular, coreModule, kbn) {
function (angular, require, coreModule, kbn) {
'use strict';
coreModule.default.directive('tip', function($compile) {
@@ -18,6 +19,43 @@ function (angular, coreModule, kbn) {
};
});
coreModule.default.directive('clipboardButton', function() {
return {
scope: {
getText: '&clipboardButton'
},
link: function(scope, elem) {
require(['vendor/clipboard/dist/clipboard'], function(Clipboard) {
scope.clipboard = new Clipboard(elem[0], {
text: function() {
return scope.getText();
}
});
});
scope.$on('$destroy', function() {
if (scope.clipboard) {
scope.clipboard.destroy();
}
});
}
};
});
coreModule.default.directive('compile', function($compile) {
return {
restrict: 'A',
link: function(scope, element, attrs) {
scope.$watch(function(scope) {
return scope.$eval(attrs.compile);
}, function(value) {
element.html(value);
$compile(element.contents())(scope);
});
}
};
});
coreModule.default.directive('watchChange', function() {
return {
scope: { onchange: '&watchChange' },
@@ -63,10 +101,10 @@ function (angular, coreModule, kbn) {
text + tip + '</label>';
var template =
'<input class="cr1" id="' + scope.$id + model + '" type="checkbox" ' +
' ng-model="' + model + '"' + ngchange +
' ng-checked="' + model + '"></input>' +
' <label for="' + scope.$id + model + '" class="cr1"></label>';
'<input class="cr1" id="' + scope.$id + model + '" type="checkbox" ' +
' ng-model="' + model + '"' + ngchange +
' ng-checked="' + model + '"></input>' +
' <label for="' + scope.$id + model + '" class="cr1"></label>';
template = template + label;
elem.addClass('gf-form-checkbox');
@@ -91,7 +129,7 @@ function (angular, coreModule, kbn) {
var li = '<li' + (item.submenu && item.submenu.length ? ' class="dropdown-submenu"' : '') + '>' +
'<a tabindex="-1" ng-href="' + (item.href || '') + '"' + (item.click ? ' ng-click="' + item.click + '"' : '') +
(item.target ? ' target="' + item.target + '"' : '') + (item.method ? ' data-method="' + item.method + '"' : '') +
'>' + (item.text || '') + '</a>';
'>' + (item.text || '') + '</a>';
if (item.submenu && item.submenu.length) {
li += buildTemplate(item.submenu).join('\n');

View File

@@ -109,7 +109,7 @@ function pluginDirectiveLoader($compile, datasourceSrv, $rootScope, $q, $http, $
baseUrl: ds.meta.baseUrl,
name: 'query-ctrl-' + ds.meta.id,
bindings: {target: "=", panelCtrl: "=", datasource: "="},
attrs: {"target": "target", "panel-ctrl": "ctrl", datasource: "datasource"},
attrs: {"target": "target", "panel-ctrl": "ctrl.panelCtrl", datasource: "datasource"},
Component: dsModule.QueryCtrl
};
});
@@ -127,7 +127,7 @@ function pluginDirectiveLoader($compile, datasourceSrv, $rootScope, $q, $http, $
baseUrl: ds.meta.baseUrl,
name: 'query-options-ctrl-' + ds.meta.id,
bindings: {panelCtrl: "="},
attrs: {"panel-ctrl": "ctrl"},
attrs: {"panel-ctrl": "ctrl.panelCtrl"},
Component: dsModule.QueryOptionsCtrl
};
});
@@ -181,7 +181,7 @@ function pluginDirectiveLoader($compile, datasourceSrv, $rootScope, $q, $http, $
return System.import(appModel.module).then(function(appModule) {
return {
baseUrl: appModel.baseUrl,
name: 'app-page-' + appModel.appId + '-' + scope.ctrl.page.slug,
name: 'app-page-' + appModel.id + '-' + scope.ctrl.page.slug,
bindings: {appModel: "="},
attrs: {"app-model": "ctrl.appModel"},
Component: appModule[scope.ctrl.page.component],

View File

@@ -0,0 +1,226 @@
///<reference path="../headers/common.d.ts" />
import coreModule from 'app/core/core_module';
export interface NavModelItem {
title: string;
url: string;
icon?: string;
iconUrl?: string;
}
export interface NavModel {
section: NavModelItem;
menu: NavModelItem[];
}
export class NavModelSrv {
/** @ngInject */
constructor(private contextSrv) {
}
getAlertingNav(subPage) {
return {
section: {
title: 'Alerting',
url: 'plugins',
icon: 'icon-gf icon-gf-alert'
},
menu: [
{title: 'Alert List', active: subPage === 0, url: 'alerting/list', icon: 'fa fa-list-ul'},
{title: 'Notification channels', active: subPage === 1, url: 'alerting/notifications', icon: 'fa fa-bell-o'},
]
};
}
getDatasourceNav(subPage) {
return {
section: {
title: 'Data Sources',
url: 'datasources',
icon: 'icon-gf icon-gf-datasources'
},
menu: [
{title: 'List view', active: subPage === 0, url: 'datasources', icon: 'fa fa-list-ul'},
{title: 'Add data source', active: subPage === 1, url: 'datasources/new', icon: 'fa fa-plus'},
]
};
}
getPlaylistsNav(subPage) {
return {
section: {
title: 'Playlists',
url: 'playlists',
icon: 'fa fa-fw fa-film'
},
menu: [
{title: 'List view', active: subPage === 0, url: 'playlists', icon: 'fa fa-list-ul'},
{title: 'Add Playlist', active: subPage === 1, url: 'playlists/create', icon: 'fa fa-plus'},
]
};
}
getProfileNav() {
return {
section: {
title: 'User Profile',
url: 'profile',
icon: 'fa fa-fw fa-user'
},
menu: []
};
}
getNotFoundNav() {
return {
section: {
title: 'Page',
url: '',
icon: 'fa fa-fw fa-warning'
},
menu: []
};
}
getOrgNav(subPage) {
return {
section: {
title: 'Organization',
url: 'org',
icon: 'icon-gf icon-gf-users'
},
menu: [
{title: 'Preferences', active: subPage === 0, url: 'org', icon: 'fa fa-fw fa-cog'},
{title: 'Org Users', active: subPage === 1, url: 'org/users', icon: 'fa fa-fw fa-users'},
{title: 'API Keys', active: subPage === 2, url: 'org/apikeys', icon: 'fa fa-fw fa-key'},
]
};
}
getAdminNav(subPage) {
return {
section: {
title: 'Admin',
url: 'admin',
icon: 'fa fa-fw fa-cogs'
},
menu: [
{title: 'Users', active: subPage === 0, url: 'admin/users', icon: 'fa fa-fw fa-user'},
{title: 'Orgs', active: subPage === 1, url: 'admin/orgs', icon: 'fa fa-fw fa-users'},
{title: 'Server Settings', active: subPage === 2, url: 'admin/settings', icon: 'fa fa-fw fa-cogs'},
{title: 'Server Stats', active: subPage === 2, url: 'admin/stats', icon: 'fa fa-fw fa-line-chart'},
{title: 'Style Guide', active: subPage === 2, url: 'styleguide', icon: 'fa fa-fw fa-key'},
]
};
}
getPluginsNav() {
return {
section: {
title: 'Plugins',
url: 'plugins',
icon: 'icon-gf icon-gf-apps'
},
menu: []
};
}
getDashboardNav(dashboard, dashNavCtrl) {
// special handling for snapshots
if (dashboard.meta.isSnapshot) {
return {
section: {
title: dashboard.title,
icon: 'icon-gf icon-gf-snapshot'
},
menu: [
{
title: 'Go to original dashboard',
icon: 'fa fa-fw fa-external-link',
url: dashboard.snapshot.originalUrl,
}
]
};
}
var menu = [];
if (dashboard.meta.canEdit) {
menu.push({
title: 'Settings',
icon: 'fa fa-fw fa-cog',
clickHandler: () => dashNavCtrl.openEditView('settings')
});
menu.push({
title: 'Templating',
icon: 'fa fa-fw fa-code',
clickHandler: () => dashNavCtrl.openEditView('templating')
});
menu.push({
title: 'Annotations',
icon: 'fa fa-fw fa-bolt',
clickHandler: () => dashNavCtrl.openEditView('annotations')
});
if (!dashboard.meta.isHome) {
menu.push({
title: 'Version history',
icon: 'fa fa-fw fa-history',
clickHandler: () => dashNavCtrl.openEditView('history')
});
}
menu.push({
title: 'View JSON',
icon: 'fa fa-fw fa-eye',
clickHandler: () => dashNavCtrl.viewJson()
});
}
if (this.contextSrv.isEditor && !dashboard.editable) {
menu.push({
title: 'Make Editable',
icon: 'fa fa-fw fa-edit',
clickHandler: () => dashNavCtrl.makeEditable()
});
}
menu.push({
title: 'Shortcuts',
icon: 'fa fa-fw fa-keyboard-o',
clickHandler: () => dashNavCtrl.showHelpModal()
});
if (this.contextSrv.isEditor) {
menu.push({
title: 'Save As ...',
icon: 'fa fa-fw fa-save',
clickHandler: () => dashNavCtrl.saveDashboardAs()
});
}
if (dashboard.meta.canSave) {
menu.push({
title: 'Delete',
icon: 'fa fa-fw fa-trash',
clickHandler: () => dashNavCtrl.deleteDashboard()
});
}
return {
section: {
title: dashboard.title,
icon: 'icon-gf icon-gf-dashboard'
},
menu: menu
};
}
}
coreModule.service('navModelSrv', NavModelSrv);

View File

@@ -103,11 +103,13 @@ function setupAngularRoutes($routeProvider, $locationProvider) {
.when('/admin', {
templateUrl: 'public/app/features/admin/partials/admin_home.html',
controller : 'AdminHomeCtrl',
controllerAs: 'ctrl',
resolve: loadAdminBundle,
})
.when('/admin/settings', {
templateUrl: 'public/app/features/admin/partials/settings.html',
controller : 'AdminSettingsCtrl',
controllerAs: 'ctrl',
resolve: loadAdminBundle,
})
.when('/admin/users', {
@@ -129,11 +131,13 @@ function setupAngularRoutes($routeProvider, $locationProvider) {
.when('/admin/orgs', {
templateUrl: 'public/app/features/admin/partials/orgs.html',
controller : 'AdminListOrgsCtrl',
controllerAs: 'ctrl',
resolve: loadAdminBundle,
})
.when('/admin/orgs/edit/:id', {
templateUrl: 'public/app/features/admin/partials/edit_org.html',
controller : 'AdminEditOrgCtrl',
controllerAs: 'ctrl',
resolve: loadAdminBundle,
})
.when('/admin/stats', {

View File

@@ -4,6 +4,7 @@ import angular from 'angular';
import _ from 'lodash';
import config from 'app/core/config';
import coreModule from 'app/core/core_module';
import appEvents from 'app/core/app_events';
export class BackendSrv {
inFlightRequests = {};
@@ -150,7 +151,10 @@ export class BackendSrv {
}
}
return this.$http(options).catch(err => {
return this.$http(options).then(response => {
appEvents.emit('ds-request-response', response);
return response;
}).catch(err => {
if (err.status === this.HTTP_REQUEST_CANCELLED) {
throw {err, cancelled: true};
}
@@ -166,7 +170,7 @@ export class BackendSrv {
});
}
//populate error obj on Internal Error
// populate error obj on Internal Error
if (_.isString(err.data) && err.status === 500) {
err.data = {
error: err.statusText,
@@ -175,11 +179,13 @@ export class BackendSrv {
}
// for Prometheus
if (!err.data.message && _.isString(err.data.error)) {
if (err.data && !err.data.message && _.isString(err.data.error)) {
err.data.message = err.data.error;
}
appEvents.emit('ds-request-error', err);
throw err;
}).finally(() => {
// clean up
if (options.requestId) {
@@ -202,7 +208,12 @@ export class BackendSrv {
saveDashboard(dash, options) {
options = (options || {});
return this.post('/api/dashboards/db/', {dashboard: dash, overwrite: options.overwrite === true});
return this.post('/api/dashboards/db/', {
dashboard: dash,
overwrite: options.overwrite === true,
message: options.message || '',
});
}
}

View File

@@ -113,10 +113,6 @@ export class KeybindingSrv {
scope.appEvent('shift-time-forward');
});
this.bind('mod+i', () => {
scope.appEvent('quick-snapshot');
});
// edit panel
this.bind('e', () => {
if (dashboard.meta.focusPanelId && dashboard.meta.canEdit) {
@@ -225,7 +221,7 @@ export class KeybindingSrv {
}
scope.appEvent('hide-dash-editor');
scope.exitFullscreen();
scope.appEvent('panel-change-view', {fullscreen: false, edit: false});
});
}
}

View File

@@ -87,9 +87,11 @@ export default class TimeSeries {
if (override.fill !== void 0) { this.lines.fill = translateFillOption(override.fill); }
if (override.stack !== void 0) { this.stack = override.stack; }
if (override.linewidth !== void 0) {
this.lines.lineWidth = override.linewidth;
this.lines.lineWidth = this.dashes.show ? 0: override.linewidth;
this.dashes.lineWidth = override.linewidth;
}
if (override.dashLength !== void 0) { this.dashes.dashLength[0] = override.dashLength; }
if (override.spaceLength !== void 0) { this.dashes.dashLength[1] = override.spaceLength; }
if (override.nullPointMode !== void 0) { this.nullPointMode = override.nullPointMode; }
if (override.pointradius !== void 0) { this.points.radius = override.pointradius; }
if (override.steppedLine !== void 0) { this.lines.steps = override.steppedLine; }

View File

@@ -1,21 +1,24 @@
///<reference path="../../headers/common.d.ts" />
import _ from 'lodash';
import moment from 'moment';
declare var window: any;
export function exportSeriesListToCsv(seriesList) {
var text = 'sep=;\nSeries;Time;Value\n';
const DEFAULT_DATETIME_FORMAT: String = 'YYYY-MM-DDTHH:mm:ssZ';
export function exportSeriesListToCsv(seriesList, dateTimeFormat = DEFAULT_DATETIME_FORMAT) {
var text = 'Series;Time;Value\n';
_.each(seriesList, function(series) {
_.each(series.datapoints, function(dp) {
text += series.alias + ';' + new Date(dp[1]).toISOString() + ';' + dp[0] + '\n';
text += series.alias + ';' + moment(dp[1]).format(dateTimeFormat) + ';' + dp[0] + '\n';
});
});
saveSaveBlob(text, 'grafana_data_export.csv');
}
export function exportSeriesListToCsvColumns(seriesList) {
var text = 'sep=;\nTime;';
export function exportSeriesListToCsvColumns(seriesList, dateTimeFormat = DEFAULT_DATETIME_FORMAT) {
var text = 'Time;';
// add header
_.each(seriesList, function(series) {
text += series.alias + ';';
@@ -30,7 +33,7 @@ export function exportSeriesListToCsvColumns(seriesList) {
var cIndex = 0;
dataArr.push([]);
_.each(series.datapoints, function(dp) {
dataArr[0][cIndex] = new Date(dp[1]).toISOString();
dataArr[0][cIndex] = moment(dp[1]).format(dateTimeFormat);
dataArr[sIndex][cIndex] = dp[0];
cIndex++;
});
@@ -50,7 +53,7 @@ export function exportSeriesListToCsvColumns(seriesList) {
}
export function exportTableDataToCsv(table) {
var text = 'sep=;\n';
var text = '';
// add header
_.each(table.columns, function(column) {
text += (column.title || column.text) + ';';

Some files were not shown because too many files have changed in this diff Show More