mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
Merge branch 'master' of github.com:elastic/kibana into latest-value
This commit is contained in:
commit
cfed72ec95
151 changed files with 3351 additions and 824 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -26,7 +26,8 @@ target
|
|||
data
|
||||
disabledPlugins
|
||||
webpackstats.json
|
||||
config/kibana.dev.yml
|
||||
config/*
|
||||
!config/kibana.yml
|
||||
coverage
|
||||
selenium
|
||||
.babelcache.json
|
||||
|
|
18
Gruntfile.js
18
Gruntfile.js
|
@ -49,23 +49,7 @@ module.exports = function (grunt) {
|
|||
'!<%= src %>/core_plugins/timelion/vendor_components/**/*.js',
|
||||
'!<%= src %>/fixtures/**/*.js',
|
||||
'!<%= root %>/test/fixtures/scenarios/**/*.js'
|
||||
],
|
||||
deepModules: {
|
||||
'caniuse-db': '1.0.30000265',
|
||||
'chalk': '1.1.0',
|
||||
'glob': '4.5.3',
|
||||
'har-validator': '1.8.0',
|
||||
'json5': '0.4.0',
|
||||
'loader-utils': '0.2.11',
|
||||
'micromatch': '2.2.0',
|
||||
'postcss-normalize-url': '2.1.1',
|
||||
'postcss-reduce-idents': '1.0.2',
|
||||
'postcss-unique-selectors': '1.0.0',
|
||||
'postcss-minify-selectors': '1.4.6',
|
||||
'postcss-single-charset': '0.3.0',
|
||||
'regenerator': '0.8.36',
|
||||
'readable-stream': '2.1.0'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
grunt.config.merge(config);
|
||||
|
|
91
README.md
91
README.md
|
@ -1,26 +1,59 @@
|
|||
# Kibana 6.0.0-alpha1
|
||||
|
||||
Kibana is an open source ([Apache Licensed](https://github.com/elastic/kibana/blob/master/LICENSE.md)), browser based analytics and search dashboard for Elasticsearch. Kibana is a snap to setup and start using. Kibana strives to be easy to get started with, while also being flexible and powerful, just like Elasticsearch.
|
||||
Kibana is your window into the [Elastic Stack](https://www.elastic.co/products). Specifically, it's
|
||||
an open source ([Apache Licensed](LICENSE.md)),
|
||||
browser-based analytics and search dashboard for Elasticsearch.
|
||||
|
||||
## Requirements
|
||||
- [Getting Started](#getting-started)
|
||||
- [Using a Kibana Release](#using-a-kibana-release)
|
||||
- [Building and Running Kibana, and/or Contributing Code](#building-and-running-kibana-andor-contributing-code)
|
||||
- [Snapshot Builds](#snapshot-builds)
|
||||
- [Documentation](#documentation)
|
||||
- [Version Compatibility with Elasticsearch](#version-compatibility-with-elasticsearch)
|
||||
- [Questions? Problems? Suggestions?](#questions-problems-suggestions)
|
||||
|
||||
- Elasticsearch master
|
||||
- Kibana binary package
|
||||
## Getting Started
|
||||
|
||||
## Installation
|
||||
If you just want to try Kibana out, check out the [Elastic Stack Getting Started Page](https://www.elastic.co/start) to give it a whirl.
|
||||
|
||||
* Download: [http://www.elastic.co/downloads/kibana](http://www.elastic.co/downloads/kibana)
|
||||
* Extract the files
|
||||
* Run `bin/kibana` on unix, or `bin\kibana.bat` on Windows.
|
||||
* Visit [http://localhost:5601](http://localhost:5601)
|
||||
If you're interested in diving a bit deeper and getting a taste of Kibana's capabilities, head over to the [Kibana Getting Started Page](https://www.elastic.co/guide/en/kibana/current/getting-started.html).
|
||||
|
||||
## Upgrade from previous version
|
||||
### Using a Kibana Release
|
||||
|
||||
* Move any custom configurations in your old kibana.yml to your new one
|
||||
* Reinstall plugins
|
||||
* Start or restart Kibana
|
||||
If you want to use a Kibana release in production, give it a test run, or just play around:
|
||||
|
||||
## Version compatibility with Elasticsearch
|
||||
- Download the latest version on the [Kibana Download Page](https://www.elastic.co/downloads/kibana).
|
||||
- Learn more about Kibana's features and capabilities on the
|
||||
[Kibana Product Page](https://www.elastic.co/products/kibana).
|
||||
- We also offer a hosted version of Kibana on our
|
||||
[Cloud Service](https://www.elastic.co/cloud/as-a-service).
|
||||
|
||||
### Building and Running Kibana, and/or Contributing Code
|
||||
|
||||
You may want to build Kibana locally to contribute some code, test out the latest features, or try
|
||||
out an open PR:
|
||||
|
||||
- [CONTRIBUTING.md](CONTRIBUTING.md) will help you get Kibana up and running.
|
||||
- If you would like to contribute code, please follow our [STYLEGUIDE.md](STYLEGUIDE.md).
|
||||
- For all other questions, check out the [FAQ.md](FAQ.md) and
|
||||
[wiki](https://github.com/elastic/kibana/wiki).
|
||||
|
||||
### Snapshot Builds
|
||||
|
||||
For the daring, snapshot builds are available. These builds are created after each commit to the master branch, and therefore are not something you should run in production.
|
||||
|
||||
| platform | |
|
||||
| --- | --- |
|
||||
| OSX | [tar](http://download.elastic.co/kibana/kibana-snapshot/kibana-6.0.0-alpha1-SNAPSHOT-darwin-x86_64.tar.gz) |
|
||||
| Linux x64 | [tar](http://download.elastic.co/kibana/kibana-snapshot/kibana-6.0.0-alpha1-SNAPSHOT-linux-x86_64.tar.gz) [deb](https://download.elastic.co/kibana/kibana-snapshot/kibana-6.0.0-alpha1-SNAPSHOT-amd64.deb) [rpm](https://download.elastic.co/kibana/kibana-snapshot/kibana-6.0.0-alpha1-SNAPSHOT-x86_64.rpm) |
|
||||
| Linux x86 | [tar](http://download.elastic.co/kibana/kibana-snapshot/kibana-6.0.0-alpha1-SNAPSHOT-linux-x86.tar.gz) [deb](https://download.elastic.co/kibana/kibana-snapshot/kibana-6.0.0-alpha1-SNAPSHOT-i386.deb) [rpm](https://download.elastic.co/kibana/kibana-snapshot/kibana-6.0.0-alpha1-SNAPSHOT-i686.rpm) |
|
||||
| Windows | [zip](http://download.elastic.co/kibana/kibana-snapshot/kibana-6.0.0-alpha1-SNAPSHOT-windows-x86.zip) |
|
||||
|
||||
## Documentation
|
||||
|
||||
Visit [Elastic.co](http://www.elastic.co/guide/en/kibana/current/index.html) for the full Kibana documentation.
|
||||
|
||||
## Version Compatibility with Elasticsearch
|
||||
|
||||
Ideally, you should be running Elasticsearch and Kibana with matching version numbers. If your Elasticsearch has an older version number or a newer _major_ number than Kibana, then Kibana will fail to run. If Elasticsearch has a newer minor or patch number than Kibana, then the Kibana Server will log a warning.
|
||||
|
||||
|
@ -36,29 +69,9 @@ _Note: The version numbers below are only examples, meant to illustrate the rela
|
|||
| ES minor number is older. | 5.__1__.2 | 5.__0__.0 | 🚫 Fatal error |
|
||||
| ES major number is older. | __5__.1.2 | __4__.0.0 | 🚫 Fatal error |
|
||||
|
||||
## Quick Start
|
||||
## Questions? Problems? Suggestions?
|
||||
|
||||
You're up and running! Fantastic! Kibana is now running on port 5601, so point your browser at http://YOURDOMAIN.com:5601.
|
||||
|
||||
The first screen you arrive at will ask you to configure an **index pattern**. An index pattern describes to Kibana how to access your data. We make the guess that you're working with log data, and we hope (because it's awesome) that you're working with Logstash. By default, we fill in `logstash-*` as your index pattern, thus the only thing you need to do is select which field contains the timestamp you'd like to use. Kibana reads your Elasticsearch mapping to find your time fields - select one from the list and hit *Create*.
|
||||
|
||||
Congratulations, you have an index pattern! You should now be looking at a paginated list of the fields in your index or indices, as well as some informative data about them. Kibana has automatically set this new index pattern as your default index pattern. If you'd like to know more about index patterns, pop into to the [Settings](#settings) section of the documentation.
|
||||
|
||||
**Did you know:** Both *indices* and *indexes* are acceptable plural forms of the word *index*. Knowledge is power.
|
||||
|
||||
Now that you've configured an index pattern, you're ready to hop over to the [Discover](#discover) screen and try out a few searches. Click on **Discover** in the navigation bar at the top of the screen.
|
||||
|
||||
## Documentation
|
||||
|
||||
Visit [Elastic.co](http://www.elastic.co/guide/en/kibana/current/index.html) for the full Kibana documentation.
|
||||
|
||||
## Snapshot Builds
|
||||
|
||||
For the daring, snapshot builds are available. These builds are created after each commit to the master branch, and therefore are not something you should run in production.
|
||||
|
||||
| platform | |
|
||||
| --- | --- |
|
||||
| OSX | [tar](http://download.elastic.co/kibana/kibana-snapshot/kibana-6.0.0-alpha1-SNAPSHOT-darwin-x86_64.tar.gz) |
|
||||
| Linux x64 | [tar](http://download.elastic.co/kibana/kibana-snapshot/kibana-6.0.0-alpha1-SNAPSHOT-linux-x86_64.tar.gz) [deb](https://download.elastic.co/kibana/kibana-snapshot/kibana-6.0.0-alpha1-SNAPSHOT-amd64.deb) [rpm](https://download.elastic.co/kibana/kibana-snapshot/kibana-6.0.0-alpha1-SNAPSHOT-x86_64.rpm) |
|
||||
| Linux x86 | [tar](http://download.elastic.co/kibana/kibana-snapshot/kibana-6.0.0-alpha1-SNAPSHOT-linux-x86.tar.gz) [deb](https://download.elastic.co/kibana/kibana-snapshot/kibana-6.0.0-alpha1-SNAPSHOT-i386.deb) [rpm](https://download.elastic.co/kibana/kibana-snapshot/kibana-6.0.0-alpha1-SNAPSHOT-i686.rpm) |
|
||||
| Windows | [zip](http://download.elastic.co/kibana/kibana-snapshot/kibana-6.0.0-alpha1-SNAPSHOT-windows-x86.zip) |
|
||||
- If you've found a bug or want to request a feature, please create a [GitHub Issue](https://github.com/elastic/kibana/issues/new).
|
||||
Please check to make sure someone else hasn't already created an issue for the same topic.
|
||||
- Need help using Kibana? Ask away on our [Kibana Discuss Forum](https://discuss.elastic.co/c/kibana) and a fellow community member or
|
||||
Elastic engineer will be glad to help you out.
|
|
@ -20,13 +20,13 @@
|
|||
# The URL of the Elasticsearch instance to use for all your queries.
|
||||
#elasticsearch.url: "http://localhost:9200"
|
||||
|
||||
# When this setting’s value is true Kibana uses the hostname specified in the server.host
|
||||
# When this setting's value is true Kibana uses the hostname specified in the server.host
|
||||
# setting. When the value of this setting is false, Kibana uses the hostname of the host
|
||||
# that connects to this Kibana instance.
|
||||
#elasticsearch.preserveHost: true
|
||||
|
||||
# Kibana uses an index in Elasticsearch to store saved searches, visualizations and
|
||||
# dashboards. Kibana creates a new index if the index doesn’t already exist.
|
||||
# dashboards. Kibana creates a new index if the index doesn't already exist.
|
||||
#kibana.index: ".kibana"
|
||||
|
||||
# The default application to load.
|
||||
|
@ -53,7 +53,7 @@
|
|||
# authority for your Elasticsearch instance.
|
||||
#elasticsearch.ssl.ca: /path/to/your/CA.pem
|
||||
|
||||
# To disregard the validity of SSL certificates, change this setting’s value to false.
|
||||
# To disregard the validity of SSL certificates, change this setting's value to false.
|
||||
#elasticsearch.ssl.verify: true
|
||||
|
||||
# Time in milliseconds to wait for Elasticsearch to respond to pings. Defaults to the value of
|
||||
|
|
|
@ -83,4 +83,4 @@ include::console/history.asciidoc[]
|
|||
|
||||
include::console/settings.asciidoc[]
|
||||
|
||||
include::console/disabling-console.asciidoc[]
|
||||
include::console/configuring-console.asciidoc[]
|
||||
|
|
57
docs/console/configuring-console.asciidoc
Normal file
57
docs/console/configuring-console.asciidoc
Normal file
|
@ -0,0 +1,57 @@
|
|||
[[configuring-console]]
|
||||
== Configuring Console
|
||||
|
||||
You can add the following options in the `config/kibana.yml` file:
|
||||
|
||||
`console.enabled`:: *Default: true* Set to false to disable Console. Toggling this will cause the server to regenerate assets on the next startup, which may cause a delay before pages start being served.
|
||||
|
||||
`console.proxyFilter`:: *Default: `.*`* A list of regular expressions that are used to validate any outgoing request from Console. If none
|
||||
of these match, the request will be rejected. See <<securing-console>> for more details.
|
||||
|
||||
`console.proxyConfig`:: A list of configuration options that are based on the proxy target. Use this to set custom timeouts or SSL settings for specific hosts. This is done by defining a set of `match` criteria using wildcards/globs which will be checked against each request. The configuration from all matching rules will then be merged together to configure the proxy used for that request.
|
||||
+
|
||||
The valid match keys are `match.protocol`, `match.host`, `match.port`, and `match.path`. All of these keys default to `*`, which means they will match any value.
|
||||
+
|
||||
Example:
|
||||
+
|
||||
[source,yaml]
|
||||
--------
|
||||
console.proxyConfig:
|
||||
- match:
|
||||
host: "*.internal.org" # allow any host that ends in .internal.org
|
||||
port: "{9200..9299}" # allow any port from 9200-9299
|
||||
|
||||
ssl:
|
||||
ca: "/opt/certs/internal.ca"
|
||||
# "key" and "cert" are also valid options here
|
||||
|
||||
- match:
|
||||
protocol: "https"
|
||||
|
||||
ssl:
|
||||
verify: false # allows any certificate to be used, even self-signed certs
|
||||
|
||||
# since this rule has no "match" section it matches everything
|
||||
- timeout: 180000 # 3 minutes
|
||||
--------
|
||||
|
||||
[[securing-console]]
|
||||
=== Securing Console
|
||||
|
||||
Console is meant to be used as a local development tool. As such, it will send requests to any host & port combination,
|
||||
just as a local curl command would. To overcome the CORS limitations enforced by browsers, Console's Node.js backend
|
||||
serves as a proxy to send requests on behalf of the browser. However, if put on a server and exposed to the internet
|
||||
this can become a security risk. In those cases, we highly recommend you lock down the proxy by setting the
|
||||
`console.proxyFilter` setting. The setting accepts a list of regular expressions that are evaluated against each URL
|
||||
the proxy is requested to retrieve. If none of the regular expressions match the proxy will reject the request.
|
||||
|
||||
Here is an example configuration the only allows Console to connect to localhost:
|
||||
|
||||
[source,yaml]
|
||||
--------
|
||||
console.proxyFilter:
|
||||
- ^https?://(localhost|127\.0\.0\.1|\[::0\]).*
|
||||
--------
|
||||
|
||||
You will need to restart Kibana for these changes to take effect.
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
[[disabling-console]]
|
||||
== Disable Console
|
||||
|
||||
If the users of Kibana have no requirements or need to access any of the Console functionality, it can
|
||||
be disabled completely and not even show up as an available app by setting the `console.enabled` Kibana server setting to `false`:
|
||||
|
||||
[source,yaml]
|
||||
--------
|
||||
console.enabled: false
|
||||
--------
|
|
@ -79,8 +79,8 @@ ca: /path/to/your/ca/cacert.pem
|
|||
[[load-balancing]]
|
||||
=== Load Balancing Across Multiple Elasticsearch Nodes
|
||||
If you have multiple nodes in your Elasticsearch cluster, the easiest way to distribute Kibana requests
|
||||
across the nodes is to run an Elasticsearch _client_ node on the same machine as Kibana.
|
||||
Elasticsearch client nodes are essentially smart load balancers that are part of the cluster. They
|
||||
across the nodes is to run an Elasticsearch _Coordinating only_ node on the same machine as Kibana.
|
||||
Elasticsearch Coordinating only nodes are essentially smart load balancers that are part of the cluster. They
|
||||
process incoming HTTP requests, redirect operations to the other nodes in the cluster as needed, and
|
||||
gather and return the results. For more information, see
|
||||
{es-ref}modules-node.html[Node] in the Elasticsearch reference.
|
||||
|
@ -88,15 +88,16 @@ gather and return the results. For more information, see
|
|||
To use a local client node to load balance Kibana requests:
|
||||
|
||||
. Install Elasticsearch on the same machine as Kibana.
|
||||
. Configure the node as a client node. In `elasticsearch.yml`, set both `node.data` and `node.master` to `false`:
|
||||
. Configure the node as a Coordinating only node. In `elasticsearch.yml`, set `node.data`, `node.master` and `node.ingest` to `false`:
|
||||
+
|
||||
--------
|
||||
# 3. You want this node to be neither master nor data node, but
|
||||
# 3. You want this node to be neither master nor data node nor ingest node, but
|
||||
# to act as a "search load balancer" (fetching data from nodes,
|
||||
# aggregating results, etc.)
|
||||
#
|
||||
node.master: false
|
||||
node.data: false
|
||||
node.ingest: false
|
||||
--------
|
||||
. Configure the client node to join your Elasticsearch cluster. In `elasticsearch.yml`, set the `cluster.name` to the
|
||||
name of your cluster.
|
||||
|
|
|
@ -65,3 +65,7 @@ The minimum value is 100.
|
|||
`status.allowAnonymous`:: *Default: false* If authentication is enabled, setting this to `true` allows
|
||||
unauthenticated users to access the Kibana server status API and status page.
|
||||
`console.enabled`:: *Default: true* Set to false to disable Console. Toggling this will cause the server to regenerate assets on the next startup, which may cause a delay before pages start being served.
|
||||
`console.proxyFilter`:: *Default: `.*`* A list of regular expressions that are used to validate any outgoing request from Console. If none of these match, the request will be rejected.
|
||||
`console.proxyConfig`:: A list of configuration options that are based on the proxy target. Use this to set custom timeouts or SSL settings for specific hosts. This is done by defining a set of `match` criteria using wildcards/globs which will be checked against each request. The configuration from all matching rules will then be merged together to configure the proxy used for that request.
|
||||
+
|
||||
The valid match keys are `match.protocol`, `match.host`, `match.port`, and `match.path`. All of these keys default to `*`, which means they will match any value. See <<configuring-console>> for an example.
|
||||
|
|
|
@ -33,6 +33,7 @@ different series.
|
|||
instructions.
|
||||
<<metric-chart,Metric>>:: Display a single number.
|
||||
<<pie-chart,Pie chart>>:: Display each source's contribution to a total.
|
||||
<<tagcloud-chart,Tag cloud>>:: Display words as a cloud in which the size of the word correspond to its importance
|
||||
<<tilemap,Tile map>>:: Associate the results of an aggregation with geographic
|
||||
locations.
|
||||
Timeseries:: Compute and combine data from multiple time series
|
||||
|
@ -118,3 +119,5 @@ include::visualize/pie.asciidoc[]
|
|||
include::visualize/tilemap.asciidoc[]
|
||||
|
||||
include::visualize/vertbar.asciidoc[]
|
||||
|
||||
include::visualize/tagcloud.asciidoc[]
|
||||
|
|
44
docs/visualize/tagcloud.asciidoc
Normal file
44
docs/visualize/tagcloud.asciidoc
Normal file
|
@ -0,0 +1,44 @@
|
|||
[[tagcloud-chart]]
|
||||
== Tag Clouds
|
||||
|
||||
A tag cloud visualization is a visual representation of text data, typically used to visualize free form text.
|
||||
Tags are usually single words, and the importance of each tag is shown with font size or color.
|
||||
|
||||
The font size for each word is determined by the _metrics_ aggregation. The following aggregations are available for
|
||||
this chart:
|
||||
|
||||
include::y-axis-aggs.asciidoc[]
|
||||
|
||||
|
||||
The _buckets_ aggregations determine what information is being retrieved from your data set.
|
||||
|
||||
Before you choose a buckets aggregation, select the *Split Tags* option.
|
||||
|
||||
You can specify the following bucket aggregations for tag cloud visualization:
|
||||
|
||||
*Terms*:: A {es-ref}search-aggregations-bucket-terms-aggregation.html[_terms_] aggregation enables you to specify the top
|
||||
or bottom _n_ elements of a given field to display, ordered by count or a custom metric.
|
||||
|
||||
You can click the *Advanced* link to display more customization options for your metrics or bucket aggregation:
|
||||
|
||||
*JSON Input*:: A text field where you can add specific JSON-formatted properties to merge with the aggregation
|
||||
definition, as in the following example:
|
||||
|
||||
[source,shell]
|
||||
{ "script" : "doc['grade'].value * 1.2" }
|
||||
|
||||
NOTE: In Elasticsearch releases 1.4.3 and later, this functionality requires you to enable
|
||||
{es-ref}modules-scripting.html[dynamic Groovy scripting].
|
||||
|
||||
|
||||
Select the *Options* tab to change the following aspects of the chart:
|
||||
|
||||
*Text Scale*:: You can select *linear*, *log*, or *square root* scales for the text scale. You can use a log
|
||||
scale to display data that varies exponentially or a square root scale to
|
||||
regularize the display of data sets with variabilities that are themselves highly variable.
|
||||
*Orientation*:: You can select how to orientate your text in the tag cloud. You can choose one of the following options:
|
||||
Single, right angles and multiple.
|
||||
*Font Size*:: Allows you to set minimum and maximum font size to use for this visualization.
|
||||
|
||||
|
||||
include::visualization-raw-data.asciidoc[]
|
|
@ -101,6 +101,7 @@
|
|||
"commander": "2.8.1",
|
||||
"css-loader": "0.17.0",
|
||||
"d3": "3.5.6",
|
||||
"d3-cloud": "1.2.1",
|
||||
"dragula": "3.7.0",
|
||||
"elasticsearch": "12.0.0-rc5",
|
||||
"elasticsearch-browser": "12.0.0-rc5",
|
||||
|
@ -138,6 +139,7 @@
|
|||
"mkdirp": "0.5.1",
|
||||
"moment": "2.13.0",
|
||||
"moment-timezone": "0.5.4",
|
||||
"no-ui-slider": "1.2.0",
|
||||
"node-fetch": "1.3.2",
|
||||
"node-uuid": "1.4.7",
|
||||
"pegjs": "0.9.0",
|
||||
|
@ -168,6 +170,7 @@
|
|||
"auto-release-sinon": "1.0.3",
|
||||
"babel-eslint": "4.1.8",
|
||||
"chai": "3.5.0",
|
||||
"cheerio": "0.22.0",
|
||||
"chokidar": "1.6.0",
|
||||
"chromedriver": "2.24.1",
|
||||
"elasticdump": "2.1.1",
|
||||
|
@ -202,7 +205,7 @@
|
|||
"karma-safari-launcher": "0.1.1",
|
||||
"license-checker": "5.1.2",
|
||||
"load-grunt-config": "0.19.2",
|
||||
"makelogs": "3.0.2",
|
||||
"makelogs": "3.1.1",
|
||||
"marked-text-renderer": "0.1.0",
|
||||
"mocha": "2.5.3",
|
||||
"murmurhash3js": "3.0.1",
|
||||
|
|
72
src/cli_plugin/install/__tests__/rename.js
Normal file
72
src/cli_plugin/install/__tests__/rename.js
Normal file
|
@ -0,0 +1,72 @@
|
|||
import expect from 'expect.js';
|
||||
import sinon from 'sinon';
|
||||
import fs from 'fs';
|
||||
|
||||
import { renamePlugin } from '../rename';
|
||||
|
||||
describe('plugin folder rename', function () {
|
||||
let renameStub;
|
||||
|
||||
beforeEach(function () {
|
||||
renameStub = sinon.stub();
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
fs.rename.restore();
|
||||
});
|
||||
|
||||
it('should rethrow any exceptions', function () {
|
||||
renameStub = sinon.stub(fs, 'rename', function (from, to, cb) {
|
||||
cb({
|
||||
code: 'error'
|
||||
});
|
||||
});
|
||||
|
||||
return renamePlugin('/foo/bar', '/bar/foo')
|
||||
.catch(function (err) {
|
||||
expect(err.code).to.be('error');
|
||||
expect(renameStub.callCount).to.be(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('should resolve if there are no errors', function () {
|
||||
renameStub = sinon.stub(fs, 'rename', function (from, to, cb) {
|
||||
cb();
|
||||
});
|
||||
|
||||
return renamePlugin('/foo/bar', '/bar/foo')
|
||||
.then(function (err) {
|
||||
expect(renameStub.callCount).to.be(1);
|
||||
})
|
||||
.catch(function () {
|
||||
throw new Error('We shouln\'t have any errors');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Windows', function () {
|
||||
let platform;
|
||||
beforeEach(function () {
|
||||
platform = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'win32'
|
||||
});
|
||||
});
|
||||
afterEach(function () {
|
||||
Object.defineProperty(process, 'platform', platform);
|
||||
});
|
||||
|
||||
it('should retry on Windows EPERM errors for up to 3 seconds', function () {
|
||||
this.timeout(5000);
|
||||
renameStub = sinon.stub(fs, 'rename', function (from, to, cb) {
|
||||
cb({
|
||||
code: 'EPERM'
|
||||
});
|
||||
});
|
||||
return renamePlugin('/foo/bar', '/bar/foo')
|
||||
.catch(function (err) {
|
||||
expect(err.code).to.be('EPERM');
|
||||
expect(renameStub.callCount).to.be.greaterThan(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -2,8 +2,8 @@ import { download } from './download';
|
|||
import Promise from 'bluebird';
|
||||
import { cleanPrevious, cleanArtifacts } from './cleanup';
|
||||
import { extract, getPackData } from './pack';
|
||||
import { renamePlugin } from './rename';
|
||||
import { sync as rimrafSync } from 'rimraf';
|
||||
import { renameSync } from 'fs';
|
||||
import { existingInstall, rebuildCache, assertVersion } from './kibana';
|
||||
import mkdirp from 'mkdirp';
|
||||
|
||||
|
@ -27,7 +27,7 @@ export default async function install(settings, logger) {
|
|||
|
||||
assertVersion(settings);
|
||||
|
||||
renameSync(settings.workingPath, settings.plugins[0].path);
|
||||
await renamePlugin(settings.workingPath, settings.plugins[0].path);
|
||||
|
||||
await rebuildCache(settings, logger);
|
||||
|
||||
|
|
21
src/cli_plugin/install/rename.js
Normal file
21
src/cli_plugin/install/rename.js
Normal file
|
@ -0,0 +1,21 @@
|
|||
import { rename } from 'fs';
|
||||
import { delay } from 'lodash';
|
||||
|
||||
export function renamePlugin(workingPath, finalPath) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
const start = Date.now();
|
||||
const retryTime = 3000;
|
||||
const retryDelay = 100;
|
||||
rename(workingPath, finalPath, function retry(err) {
|
||||
if (err) {
|
||||
// In certain cases on Windows, such as running AV, plugin folders can be locked shortly after extracting
|
||||
// Retry for up to retryTime seconds
|
||||
const windowsEPERM = process.platform === 'win32' && err.code === 'EPERM';
|
||||
const retryAvailable = Date.now() - start < retryTime;
|
||||
if (windowsEPERM && retryAvailable) return delay(rename, retryDelay, workingPath, finalPath, retry);
|
||||
reject(err);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
|
@ -56,6 +56,12 @@ describe('plugins/elasticsearch', () => {
|
|||
client.nodes.info = sinon.stub().returns(Promise.resolve({ nodes: nodes }));
|
||||
}
|
||||
|
||||
function setNodeWithoutHTTP(version) {
|
||||
const nodes = { 'node-without-http': { version, ip: 'ip' } };
|
||||
const client = server.plugins.elasticsearch.client;
|
||||
client.nodes.info = sinon.stub().returns(Promise.resolve({ nodes: nodes }));
|
||||
}
|
||||
|
||||
it('returns true with single a node that matches', async () => {
|
||||
setNodes('5.1.0');
|
||||
const result = await checkEsVersion(server, KIBANA_VERSION);
|
||||
|
@ -99,6 +105,24 @@ describe('plugins/elasticsearch', () => {
|
|||
expect(server.log.getCall(1).args[0]).to.contain('warning');
|
||||
});
|
||||
|
||||
it('warns if a node is off by a patch version and without http publish address', async () => {
|
||||
setNodeWithoutHTTP('5.1.1');
|
||||
await checkEsVersion(server, KIBANA_VERSION);
|
||||
sinon.assert.callCount(server.log, 2);
|
||||
expect(server.log.getCall(0).args[0]).to.contain('debug');
|
||||
expect(server.log.getCall(1).args[0]).to.contain('warning');
|
||||
});
|
||||
|
||||
it('errors if a node incompatible and without http publish address', async () => {
|
||||
setNodeWithoutHTTP('6.1.1');
|
||||
try {
|
||||
await checkEsVersion(server, KIBANA_VERSION);
|
||||
} catch (e) {
|
||||
expect(e.message).to.contain('incompatible nodes');
|
||||
expect(e).to.be.a(Error);
|
||||
}
|
||||
});
|
||||
|
||||
it('only warns once per node list', async () => {
|
||||
setNodes('5.1.1');
|
||||
|
||||
|
|
|
@ -53,9 +53,7 @@ describe('plugins/elasticsearch', function () {
|
|||
expect(params.body.mappings.config.properties)
|
||||
.to.have.property('buildNum');
|
||||
expect(params.body.mappings.config.properties.buildNum)
|
||||
.to.have.property('type', 'string');
|
||||
expect(params.body.mappings.config.properties.buildNum)
|
||||
.to.have.property('index', 'not_analyzed');
|
||||
.to.have.property('type', 'keyword');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -1,27 +0,0 @@
|
|||
import getBasicAuthRealm from '../get_basic_auth_realm';
|
||||
import expect from 'expect.js';
|
||||
const exception = '[security_exception] missing authentication token for REST request [/logstash-*/_search],' +
|
||||
' with: {"header":{"WWW-Authenticate":"Basic realm=\\"shield\\""}}';
|
||||
|
||||
|
||||
describe('plugins/elasticsearch', function () {
|
||||
describe('lib/get_basic_auth_realm', function () {
|
||||
|
||||
it('should return null if passed something other than a string', function () {
|
||||
expect(getBasicAuthRealm({})).to.be(null);
|
||||
expect(getBasicAuthRealm(500)).to.be(null);
|
||||
expect(getBasicAuthRealm([exception])).to.be(null);
|
||||
});
|
||||
|
||||
// TODO: This should be updated to match header strings when the client supports that
|
||||
it('should return the realm when passed an elasticsearch security exception', function () {
|
||||
expect(getBasicAuthRealm(exception)).to.be('shield');
|
||||
});
|
||||
|
||||
it('should return null when no basic realm information is found', function () {
|
||||
expect(getBasicAuthRealm('Basically nothing="the universe"')).to.be(null);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
|
@ -1,14 +1,14 @@
|
|||
import _ from 'lodash';
|
||||
import Promise from 'bluebird';
|
||||
import Boom from 'boom';
|
||||
import getBasicAuthRealm from './get_basic_auth_realm';
|
||||
import toPath from 'lodash/internal/toPath';
|
||||
import filterHeaders from './filter_headers';
|
||||
|
||||
module.exports = (server, client) => {
|
||||
return (req, endpoint, params = {}) => {
|
||||
return (req, endpoint, clientParams = {}, options = {}) => {
|
||||
const wrap401Errors = options.wrap401Errors !== false;
|
||||
const filteredHeaders = filterHeaders(req.headers, server.config().get('elasticsearch.requestHeadersWhitelist'));
|
||||
_.set(params, 'headers', filteredHeaders);
|
||||
_.set(clientParams, 'headers', filteredHeaders);
|
||||
const path = toPath(endpoint);
|
||||
const api = _.get(client, path);
|
||||
let apiContext = _.get(client, path.slice(0, -1));
|
||||
|
@ -16,16 +16,16 @@ module.exports = (server, client) => {
|
|||
apiContext = client;
|
||||
}
|
||||
if (!api) throw new Error(`callWithRequest called with an invalid endpoint: ${endpoint}`);
|
||||
return api.call(apiContext, params)
|
||||
return api.call(apiContext, clientParams)
|
||||
.catch((err) => {
|
||||
if (err.status === 401) {
|
||||
// TODO: The err.message is temporary until we have support for getting headers in the client.
|
||||
// Once we have that, we should be able to pass the contents of the WWW-Authenticate head to getRealm
|
||||
const realm = getBasicAuthRealm(err.message) || 'Authorization Required';
|
||||
const options = { realm: realm };
|
||||
return Promise.reject(Boom.unauthorized('Unauthorized', 'Basic', options));
|
||||
if (!wrap401Errors || err.statusCode !== 401) {
|
||||
return Promise.reject(err);
|
||||
}
|
||||
return Promise.reject(err);
|
||||
|
||||
const boomError = Boom.wrap(err, err.statusCode);
|
||||
const wwwAuthHeader = _.get(err, 'body.error.header[WWW-Authenticate]');
|
||||
boomError.output.headers['WWW-Authenticate'] = wwwAuthHeader || 'Basic realm="Authorization Required"';
|
||||
throw boomError;
|
||||
});
|
||||
};
|
||||
};
|
||||
|
|
|
@ -44,7 +44,8 @@ module.exports = function checkEsVersion(server, kibanaVersion) {
|
|||
|
||||
function getHumanizedNodeNames(nodes) {
|
||||
return nodes.map(node => {
|
||||
return 'v' + node.version + ' @ ' + node.http.publish_address + ' (' + node.ip + ')';
|
||||
const publishAddress = _.get(node, 'http.publish_address') ? (_.get(node, 'http.publish_address') + ' ') : '';
|
||||
return 'v' + node.version + ' @ ' + publishAddress + '(' + node.ip + ')';
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -52,7 +53,7 @@ module.exports = function checkEsVersion(server, kibanaVersion) {
|
|||
const simplifiedNodes = warningNodes.map(node => ({
|
||||
version: node.version,
|
||||
http: {
|
||||
publish_address: node.http.publish_address,
|
||||
publish_address: _.get(node, 'http.publish_address')
|
||||
},
|
||||
ip: node.ip,
|
||||
}));
|
||||
|
@ -78,7 +79,7 @@ module.exports = function checkEsVersion(server, kibanaVersion) {
|
|||
throw new Error(
|
||||
`This version of Kibana requires Elasticsearch v` +
|
||||
`${kibanaVersion} on all nodes. I found ` +
|
||||
`the following incompatible nodes in your cluster: ${incompatibleNodeNames.join(',')}`
|
||||
`the following incompatible nodes in your cluster: ${incompatibleNodeNames.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,7 +0,0 @@
|
|||
export default function getBasicAuthRealm(message) {
|
||||
if (!message || typeof message !== 'string') return null;
|
||||
|
||||
const parts = message.match(/Basic\ realm=\\"(.*)\\"/);
|
||||
if (parts && parts.length === 2) return parts[1];
|
||||
else return null;
|
||||
};
|
|
@ -81,13 +81,20 @@ module.exports = function (plugin, server) {
|
|||
});
|
||||
}
|
||||
|
||||
function waitForEsVersion() {
|
||||
return checkEsVersion(server, kibanaVersion.get()).catch(err => {
|
||||
plugin.status.red(err);
|
||||
return Promise.delay(REQUEST_DELAY).then(waitForEsVersion);
|
||||
});
|
||||
}
|
||||
|
||||
function setGreenStatus() {
|
||||
return plugin.status.green('Kibana index ready');
|
||||
}
|
||||
|
||||
function check() {
|
||||
return waitForPong()
|
||||
.then(() => checkEsVersion(server, kibanaVersion.get()))
|
||||
.then(waitForEsVersion)
|
||||
.then(waitForShards)
|
||||
.then(setGreenStatus)
|
||||
.then(_.partial(migrateConfig, server))
|
||||
|
|
|
@ -2,8 +2,7 @@ export const mappings = {
|
|||
config: {
|
||||
properties: {
|
||||
buildNum: {
|
||||
type: 'string',
|
||||
index: 'not_analyzed'
|
||||
type: 'keyword'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -47,6 +47,7 @@ export default function HistogramVisType(Private) {
|
|||
modes: ['stacked', 'overlap', 'percentage', 'wiggle', 'silhouette'],
|
||||
editor: areaTemplate
|
||||
},
|
||||
implementsRenderComplete: true,
|
||||
schemas: new Schemas([
|
||||
{
|
||||
group: 'metrics',
|
||||
|
|
|
@ -43,6 +43,7 @@ export default function HistogramVisType(Private) {
|
|||
modes: ['stacked', 'percentage', 'grouped'],
|
||||
editor: histogramTemplate
|
||||
},
|
||||
implementsRenderComplete: true,
|
||||
schemas: new Schemas([
|
||||
{
|
||||
group: 'metrics',
|
||||
|
|
|
@ -46,6 +46,7 @@ export default function HistogramVisType(Private) {
|
|||
scales: ['linear', 'log', 'square root'],
|
||||
editor: lineTemplate
|
||||
},
|
||||
implementsRenderComplete: true,
|
||||
schemas: new Schemas([
|
||||
{
|
||||
group: 'metrics',
|
||||
|
|
|
@ -37,6 +37,7 @@ export default function HistogramVisType(Private) {
|
|||
},
|
||||
responseConverter: false,
|
||||
hierarchicalData: true,
|
||||
implementsRenderComplete: true,
|
||||
schemas: new Schemas([
|
||||
{
|
||||
group: 'metrics',
|
||||
|
|
|
@ -81,6 +81,7 @@ export default function TileMapVisType(Private, getAppState, courier, config) {
|
|||
}
|
||||
},
|
||||
responseConverter: geoJsonConverter,
|
||||
implementsRenderComplete: true,
|
||||
schemas: new Schemas([
|
||||
{
|
||||
group: 'metrics',
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import _ from 'lodash';
|
||||
import PluginsKibanaDashboardComponentsPanelLibVisualizationProvider from 'plugins/kibana/dashboard/components/panel/lib/visualization';
|
||||
import PluginsKibanaDashboardComponentsPanelLibSearchProvider from 'plugins/kibana/dashboard/components/panel/lib/search';
|
||||
export default function loadPanelFunction(Private) { // Inject services here
|
||||
import { visualizationLoaderProvider } from 'plugins/kibana/dashboard/components/panel/lib/visualization';
|
||||
import { searchLoaderProvider } from 'plugins/kibana/dashboard/components/panel/lib/search';
|
||||
|
||||
export function loadPanelProvider(Private) { // Inject services here
|
||||
return function (panel, $scope) { // Function parameters here
|
||||
const panelTypes = {
|
||||
visualization: Private(PluginsKibanaDashboardComponentsPanelLibVisualizationProvider),
|
||||
search: Private(PluginsKibanaDashboardComponentsPanelLibSearchProvider)
|
||||
visualization: Private(visualizationLoaderProvider),
|
||||
search: Private(searchLoaderProvider)
|
||||
};
|
||||
|
||||
try {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
export default function searchLoader(savedSearches, Private) { // Inject services here
|
||||
export function searchLoaderProvider(savedSearches, Private) { // Inject services here
|
||||
return function (panel, $scope) { // Function parameters here
|
||||
return savedSearches.get(panel.id)
|
||||
.then(function (savedSearch) {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import UtilsBrushEventProvider from 'ui/utils/brush_event';
|
||||
import FilterBarFilterBarClickHandlerProvider from 'ui/filter_bar/filter_bar_click_handler';
|
||||
|
||||
export default function visualizationLoader(savedVisualizations, Private) { // Inject services here
|
||||
export function visualizationLoaderProvider(savedVisualizations, Private) { // Inject services here
|
||||
const brushEvent = Private(UtilsBrushEventProvider);
|
||||
const filterBarClickHandler = Private(FilterBarFilterBarClickHandlerProvider);
|
||||
|
||||
|
@ -10,7 +10,7 @@ export default function visualizationLoader(savedVisualizations, Private) { // I
|
|||
.then(function (savedVis) {
|
||||
// $scope.state comes via $scope inheritence from the dashboard app. Don't love this.
|
||||
savedVis.vis.listeners.click = filterBarClickHandler($scope.state);
|
||||
savedVis.vis.listeners.brush = brushEvent;
|
||||
savedVis.vis.listeners.brush = brushEvent($scope.state);
|
||||
|
||||
return {
|
||||
savedObj: savedVis,
|
||||
|
|
|
@ -28,13 +28,16 @@
|
|||
search-source="savedObj.searchSource"
|
||||
show-spy-panel="chrome.getVisible()"
|
||||
ui-state="uiState"
|
||||
render-counter
|
||||
class="panel-content">
|
||||
</visualize>
|
||||
|
||||
<doc-table ng-switch-when="search"
|
||||
<doc-table
|
||||
ng-switch-when="search"
|
||||
search-source="savedObj.searchSource"
|
||||
sorting="panel.sort"
|
||||
columns="panel.columns"
|
||||
render-counter
|
||||
class="panel-content"
|
||||
filter="filter">
|
||||
</doc-table>
|
||||
|
|
|
@ -1,19 +1,16 @@
|
|||
import moment from 'moment';
|
||||
import $ from 'jquery';
|
||||
import _ from 'lodash';
|
||||
import 'ui/visualize';
|
||||
import 'ui/doc_table';
|
||||
import PluginsKibanaDashboardComponentsPanelLibLoadPanelProvider from 'plugins/kibana/dashboard/components/panel/lib/load_panel';
|
||||
import { loadPanelProvider } from 'plugins/kibana/dashboard/components/panel/lib/load_panel';
|
||||
import FilterManagerProvider from 'ui/filter_manager';
|
||||
import UtilsBrushEventProvider from 'ui/utils/brush_event';
|
||||
import uiModules from 'ui/modules';
|
||||
import panelTemplate from 'plugins/kibana/dashboard/components/panel/panel.html';
|
||||
|
||||
uiModules
|
||||
.get('app/dashboard')
|
||||
.directive('dashboardPanel', function (savedVisualizations, savedSearches, Notifier, Private, $injector) {
|
||||
const loadPanel = Private(PluginsKibanaDashboardComponentsPanelLibLoadPanelProvider);
|
||||
const loadPanel = Private(loadPanelProvider);
|
||||
const filterManager = Private(FilterManagerProvider);
|
||||
const notify = new Notifier();
|
||||
|
||||
const services = require('plugins/kibana/management/saved_object_registry').all().map(function (serviceObj) {
|
||||
const service = $injector.get(serviceObj.service);
|
||||
|
@ -23,9 +20,6 @@ uiModules
|
|||
};
|
||||
});
|
||||
|
||||
|
||||
const brushEvent = Private(UtilsBrushEventProvider);
|
||||
|
||||
const getPanelId = function (panel) {
|
||||
return ['P', panel.panelIndex].join('-');
|
||||
};
|
||||
|
@ -33,8 +27,7 @@ uiModules
|
|||
return {
|
||||
restrict: 'E',
|
||||
template: panelTemplate,
|
||||
requires: '^dashboardGrid',
|
||||
link: function ($scope, $el) {
|
||||
link: function ($scope) {
|
||||
// using $scope inheritance, panels are available in AppState
|
||||
const $state = $scope.state;
|
||||
|
||||
|
|
|
@ -16,7 +16,6 @@ app.directive('dashboardGrid', function ($compile, Notifier) {
|
|||
$el = $('<ul>').appendTo($container);
|
||||
|
||||
const $window = $(window);
|
||||
const $body = $(document.body);
|
||||
const binder = new Binder($scope);
|
||||
|
||||
// appState from controller
|
||||
|
@ -37,20 +36,6 @@ app.directive('dashboardGrid', function ($compile, Notifier) {
|
|||
function init() {
|
||||
$el.addClass('gridster');
|
||||
|
||||
// See issue https://github.com/elastic/kibana/issues/2138 and the
|
||||
// subsequent fix for why we need to sort here. Short story is that
|
||||
// gridster can fail to render widgets in the correct order, depending
|
||||
// on the specific order of the panels.
|
||||
// See https://github.com/ducksboard/gridster.js/issues/147
|
||||
// for some additional back story.
|
||||
$state.panels.sort((a, b) => {
|
||||
if (a.row === b.row) {
|
||||
return a.col - b.col;
|
||||
} else {
|
||||
return a.row - b.row;
|
||||
}
|
||||
});
|
||||
|
||||
gridster = $el.gridster({
|
||||
max_cols: COLS,
|
||||
min_cols: COLS,
|
||||
|
@ -86,7 +71,23 @@ app.directive('dashboardGrid', function ($compile, Notifier) {
|
|||
const added = _.difference(panels, currentPanels);
|
||||
|
||||
if (removed.length) removed.forEach(removePanel);
|
||||
if (added.length) added.forEach(addPanel);
|
||||
if (added.length) {
|
||||
// See issue https://github.com/elastic/kibana/issues/2138 and the
|
||||
// subsequent fix for why we need to sort here. Short story is that
|
||||
// gridster can fail to render widgets in the correct order, depending
|
||||
// on the specific order of the panels.
|
||||
// See https://github.com/ducksboard/gridster.js/issues/147
|
||||
// for some additional back story.
|
||||
added.sort((a, b) => {
|
||||
if (a.row === b.row) {
|
||||
return a.col - b.col;
|
||||
} else {
|
||||
return a.row - b.row;
|
||||
}
|
||||
});
|
||||
|
||||
added.forEach(addPanel);
|
||||
};
|
||||
|
||||
// ensure that every panel can be serialized now that we are done
|
||||
$state.panels.forEach(makePanelSerializeable);
|
||||
|
@ -221,7 +222,6 @@ app.directive('dashboardGrid', function ($compile, Notifier) {
|
|||
g.min_widget_width = (g.options.widget_margins[0] * 2) + g.options.widget_base_dimensions[0];
|
||||
g.min_widget_height = (g.options.widget_margins[1] * 2) + g.options.widget_base_dimensions[1];
|
||||
|
||||
// const serializedGrid = g.serialize();
|
||||
g.$widgets.each(function (i, widget) {
|
||||
g.resize_widget($(widget));
|
||||
});
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
>
|
||||
<span
|
||||
ng-show="dash.id"
|
||||
ng-bind="::dash.title"
|
||||
ng-bind="dash.lastSavedTitle"
|
||||
></span>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import _ from 'lodash';
|
||||
import $ from 'jquery';
|
||||
import angular from 'angular';
|
||||
import chrome from 'ui/chrome';
|
||||
import 'ui/courier';
|
||||
|
@ -17,8 +16,8 @@ import stateMonitorFactory from 'ui/state_management/state_monitor_factory';
|
|||
import uiRoutes from 'ui/routes';
|
||||
import uiModules from 'ui/modules';
|
||||
import indexTemplate from 'plugins/kibana/dashboard/index.html';
|
||||
|
||||
require('ui/saved_objects/saved_object_registry').register(require('plugins/kibana/dashboard/services/saved_dashboard_register'));
|
||||
import { savedDashboardRegister } from 'plugins/kibana/dashboard/services/saved_dashboard_register';
|
||||
require('ui/saved_objects/saved_object_registry').register(savedDashboardRegister);
|
||||
|
||||
const app = uiModules.get('app/dashboard', [
|
||||
'elasticsearch',
|
||||
|
@ -108,26 +107,32 @@ app.directive('dashboardApp', function (Notifier, courier, AppState, timefilter,
|
|||
key: 'new',
|
||||
description: 'New Dashboard',
|
||||
run: function () { kbnUrl.change('/dashboard', {}); },
|
||||
testId: 'dashboardNewButton',
|
||||
}, {
|
||||
key: 'add',
|
||||
description: 'Add a panel to the dashboard',
|
||||
template: require('plugins/kibana/dashboard/partials/pick_visualization.html')
|
||||
template: require('plugins/kibana/dashboard/partials/pick_visualization.html'),
|
||||
testId: 'dashboardAddPanelButton',
|
||||
}, {
|
||||
key: 'save',
|
||||
description: 'Save Dashboard',
|
||||
template: require('plugins/kibana/dashboard/partials/save_dashboard.html')
|
||||
template: require('plugins/kibana/dashboard/partials/save_dashboard.html'),
|
||||
testId: 'dashboardSaveButton',
|
||||
}, {
|
||||
key: 'open',
|
||||
description: 'Open Saved Dashboard',
|
||||
template: require('plugins/kibana/dashboard/partials/load_dashboard.html')
|
||||
template: require('plugins/kibana/dashboard/partials/load_dashboard.html'),
|
||||
testId: 'dashboardOpenButton',
|
||||
}, {
|
||||
key: 'share',
|
||||
description: 'Share Dashboard',
|
||||
template: require('plugins/kibana/dashboard/partials/share.html')
|
||||
template: require('plugins/kibana/dashboard/partials/share.html'),
|
||||
testId: 'dashboardShareButton',
|
||||
}, {
|
||||
key: 'options',
|
||||
description: 'Options',
|
||||
template: require('plugins/kibana/dashboard/partials/options.html')
|
||||
template: require('plugins/kibana/dashboard/partials/options.html'),
|
||||
testId: 'dashboardOptionsButton',
|
||||
}];
|
||||
|
||||
$scope.refresh = _.bindKey(courier, 'fetch');
|
||||
|
@ -138,10 +143,11 @@ app.directive('dashboardApp', function (Notifier, courier, AppState, timefilter,
|
|||
|
||||
courier.setRootSearchSource(dash.searchSource);
|
||||
|
||||
const docTitle = Private(DocTitleProvider);
|
||||
|
||||
function init() {
|
||||
updateQueryOnRootSource();
|
||||
|
||||
const docTitle = Private(DocTitleProvider);
|
||||
if (dash.id) {
|
||||
docTitle.change(dash.title);
|
||||
}
|
||||
|
@ -222,7 +228,6 @@ app.directive('dashboardApp', function (Notifier, courier, AppState, timefilter,
|
|||
};
|
||||
|
||||
$scope.save = function () {
|
||||
$state.title = dash.id = dash.title;
|
||||
$state.save();
|
||||
|
||||
const timeRestoreObj = _.pick(timefilter.refreshInterval, ['display', 'pause', 'section', 'value']);
|
||||
|
@ -241,6 +246,8 @@ app.directive('dashboardApp', function (Notifier, courier, AppState, timefilter,
|
|||
notify.info('Saved Dashboard as "' + dash.title + '"');
|
||||
if (dash.id !== $routeParams.id) {
|
||||
kbnUrl.change('/dashboard/{{id}}', {id: dash.id});
|
||||
} else {
|
||||
docTitle.change(dash.lastSavedTitle);
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
role="form"
|
||||
ng-submit="opts.save()"
|
||||
>
|
||||
<div class="localDropdownTitle">Save Dashboard</div>
|
||||
<div class="localDropdownTitle">Save {{opts.dashboard.getDisplayName()}}</div>
|
||||
<input
|
||||
class="localDropdownInput"
|
||||
id="dashboardTitle"
|
||||
|
@ -11,12 +11,18 @@
|
|||
placeholder="Dashboard title"
|
||||
input-focus="select"
|
||||
>
|
||||
|
||||
<saved-object-save-as-check-box saved-object="opts.dashboard"></saved-object-save-as-check-box>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" ng-model="opts.dashboard.timeRestore" ng-checked="opts.dashboard.timeRestore">
|
||||
Store time with dashboard
|
||||
<kbn-info info="Change the time filter to the currently selected time each time this dashboard is loaded"></kbn-info>
|
||||
Store time with {{opts.dashboard.getDisplayName()}}
|
||||
<kbn-info placement="bottom" info="Change the time filter to the currently selected time each time this dashboard is loaded"></kbn-info>
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" ng-disabled="!opts.dashboard.title" class="btn btn-primary" aria-label="Save dashboard">Save</button>
|
||||
|
||||
<button type="submit" ng-disabled="!opts.dashboard.title" class="btn btn-primary" aria-label="Save dashboard">
|
||||
Save
|
||||
</button>
|
||||
</form>
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import angular from 'angular';
|
||||
import _ from 'lodash';
|
||||
import moment from 'moment';
|
||||
import uiModules from 'ui/modules';
|
||||
const module = uiModules.get('app/dashboard');
|
||||
|
||||
|
@ -47,20 +46,20 @@ module.factory('SavedDashboard', function (courier, config) {
|
|||
|
||||
// if type:dashboard has no mapping, we push this mapping into ES
|
||||
SavedDashboard.mapping = {
|
||||
title: 'string',
|
||||
title: 'text',
|
||||
hits: 'integer',
|
||||
description: 'string',
|
||||
panelsJSON: 'string',
|
||||
optionsJSON: 'string',
|
||||
uiStateJSON: 'string',
|
||||
description: 'text',
|
||||
panelsJSON: 'keyword',
|
||||
optionsJSON: 'keyword',
|
||||
uiStateJSON: 'keyword',
|
||||
version: 'integer',
|
||||
timeRestore: 'boolean',
|
||||
timeTo: 'string',
|
||||
timeFrom: 'string',
|
||||
timeTo: 'keyword',
|
||||
timeFrom: 'keyword',
|
||||
refreshInterval: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
display: {type: 'string'},
|
||||
display: {type: 'keyword'},
|
||||
pause: { type: 'boolean'},
|
||||
section: { type: 'integer'},
|
||||
value: { type: 'integer'}
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
export default function savedDashboardFn(savedDashboards) {
|
||||
export function savedDashboardRegister(savedDashboards) {
|
||||
return savedDashboards;
|
||||
};
|
||||
|
|
|
@ -115,19 +115,23 @@ function discoverController($scope, config, courier, $route, $window, Notifier,
|
|||
$scope.topNavMenu = [{
|
||||
key: 'new',
|
||||
description: 'New Search',
|
||||
run: function () { kbnUrl.change('/discover'); }
|
||||
run: function () { kbnUrl.change('/discover'); },
|
||||
testId: 'discoverNewButton',
|
||||
}, {
|
||||
key: 'save',
|
||||
description: 'Save Search',
|
||||
template: require('plugins/kibana/discover/partials/save_search.html')
|
||||
template: require('plugins/kibana/discover/partials/save_search.html'),
|
||||
testId: 'discoverSaveButton',
|
||||
}, {
|
||||
key: 'open',
|
||||
description: 'Load Saved Search',
|
||||
template: require('plugins/kibana/discover/partials/load_search.html')
|
||||
description: 'Open Saved Search',
|
||||
template: require('plugins/kibana/discover/partials/load_search.html'),
|
||||
testId: 'discoverOpenButton',
|
||||
}, {
|
||||
key: 'share',
|
||||
description: 'Share Search',
|
||||
template: require('plugins/kibana/discover/partials/share_search.html')
|
||||
template: require('plugins/kibana/discover/partials/share_search.html'),
|
||||
testId: 'discoverShareButton',
|
||||
}];
|
||||
$scope.timefilter = timefilter;
|
||||
|
||||
|
@ -314,7 +318,6 @@ function discoverController($scope, config, courier, $route, $window, Notifier,
|
|||
$scope.opts.saveDataSource = function () {
|
||||
return $scope.updateDataSource()
|
||||
.then(function () {
|
||||
savedSearch.id = savedSearch.title;
|
||||
savedSearch.columns = $scope.state.columns;
|
||||
savedSearch.sort = $scope.state.sort;
|
||||
|
||||
|
@ -330,6 +333,7 @@ function discoverController($scope, config, courier, $route, $window, Notifier,
|
|||
} else {
|
||||
// Update defaults so that "reload saved query" functions correctly
|
||||
$state.setDefaults(getStateDefaults());
|
||||
docTitle.change(savedSearch.lastSavedTitle);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -543,7 +547,7 @@ function discoverController($scope, config, courier, $route, $window, Notifier,
|
|||
timefilter.time.to = moment(e.point.x + e.data.ordered.interval);
|
||||
timefilter.time.mode = 'absolute';
|
||||
},
|
||||
brush: brushEvent
|
||||
brush: brushEvent($scope.state)
|
||||
},
|
||||
aggs: visStateAggs
|
||||
});
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
<div data-transclude-slot="topLeftCorner" class="localBreadcrumbs">
|
||||
<div class="localBreadcrumb">
|
||||
<span ng-show="opts.savedSearch.id" class="localBreadcrumb__emphasis">
|
||||
<span data-test-subj="discoverCurrentQuery" ng-bind="::opts.savedSearch.title"></span>
|
||||
<span data-test-subj="discoverCurrentQuery" ng-bind="opts.savedSearch.lastSavedTitle"></span>
|
||||
<i aria-label="Reload Saved Search" tooltip="Reload Saved Search" ng-click="resetQuery();" class="fa fa-undo small"></i>
|
||||
</span>
|
||||
<span data-test-subj="discoverQueryHits" class="localBreadcrumb__emphasis">{{(hits || 0) | number:0}}</span>
|
||||
|
@ -125,7 +125,8 @@
|
|||
sorting="state.sort"
|
||||
columns="state.columns"
|
||||
infinite-scroll="true"
|
||||
filter="filterQuery">
|
||||
filter="filterQuery"
|
||||
render-counter>
|
||||
</doc-table>
|
||||
|
||||
<div ng-if="rows.length == opts.sampleSize" class="discover-table-footer">
|
||||
|
|
|
@ -10,6 +10,8 @@
|
|||
input-focus="select"
|
||||
placeholder="Name this search..."
|
||||
>
|
||||
|
||||
<saved-object-save-as-check-box saved-object="opts.savedSearch"></saved-object-save-as-check-box>
|
||||
<button ng-disabled="!opts.savedSearch.title" data-test-subj="discover-save-search-btn" type="submit" class="btn btn-primary">
|
||||
Save
|
||||
</button>
|
||||
|
|
|
@ -31,11 +31,11 @@ module.factory('SavedSearch', function (courier) {
|
|||
SavedSearch.type = 'search';
|
||||
|
||||
SavedSearch.mapping = {
|
||||
title: 'string',
|
||||
description: 'string',
|
||||
title: 'text',
|
||||
description: 'text',
|
||||
hits: 'integer',
|
||||
columns: 'string',
|
||||
sort: 'string',
|
||||
columns: 'keyword',
|
||||
sort: 'keyword',
|
||||
version: 'integer'
|
||||
};
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
>
|
||||
<span
|
||||
ng-show="savedVis.id"
|
||||
ng-bind="::savedVis.title"
|
||||
ng-bind="savedVis.lastSavedTitle"
|
||||
></span>
|
||||
</div>
|
||||
|
||||
|
@ -80,6 +80,7 @@
|
|||
<div class="vis-editor-canvas" ng-class="{ embedded: !chrome.getVisible() }">
|
||||
<visualize
|
||||
vis="vis"
|
||||
render-counter
|
||||
ui-state="uiState"
|
||||
show-spy-panel="chrome.getVisible()"
|
||||
editable-vis="editableVis"
|
||||
|
|
|
@ -104,23 +104,28 @@ function VisEditor($scope, $route, timefilter, AppState, $location, kbnUrl, $tim
|
|||
$scope.topNavMenu = [{
|
||||
key: 'new',
|
||||
description: 'New Visualization',
|
||||
run: function () { kbnUrl.change('/visualize', {}); }
|
||||
run: function () { kbnUrl.change('/visualize', {}); },
|
||||
testId: 'visualizeNewButton',
|
||||
}, {
|
||||
key: 'save',
|
||||
description: 'Save Visualization',
|
||||
template: require('plugins/kibana/visualize/editor/panels/save.html'),
|
||||
description: 'Save Visualization'
|
||||
testId: 'visualizeSaveButton',
|
||||
}, {
|
||||
key: 'open',
|
||||
template: require('plugins/kibana/visualize/editor/panels/load.html'),
|
||||
description: 'Open Saved Visualization',
|
||||
template: require('plugins/kibana/visualize/editor/panels/load.html'),
|
||||
testId: 'visualizeOpenButton',
|
||||
}, {
|
||||
key: 'share',
|
||||
description: 'Share Visualization',
|
||||
template: require('plugins/kibana/visualize/editor/panels/share.html'),
|
||||
description: 'Share Visualization'
|
||||
testId: 'visualizeShareButton',
|
||||
}, {
|
||||
key: 'refresh',
|
||||
description: 'Refresh',
|
||||
run: function () { $scope.fetch(); }
|
||||
run: function () { $scope.fetch(); },
|
||||
testId: 'visualizeRefreshButton',
|
||||
}];
|
||||
|
||||
if (savedVis.id) {
|
||||
|
@ -187,7 +192,7 @@ function VisEditor($scope, $route, timefilter, AppState, $location, kbnUrl, $tim
|
|||
$scope.$on('$destroy', () => stateMonitor.destroy());
|
||||
|
||||
editableVis.listeners.click = vis.listeners.click = filterBarClickHandler($state);
|
||||
editableVis.listeners.brush = vis.listeners.brush = brushEvent;
|
||||
editableVis.listeners.brush = vis.listeners.brush = brushEvent($state);
|
||||
|
||||
// track state of editable vis vs. "actual" vis
|
||||
$scope.stageEditableVis = transferVisState(editableVis, vis, true);
|
||||
|
@ -287,7 +292,6 @@ function VisEditor($scope, $route, timefilter, AppState, $location, kbnUrl, $tim
|
|||
* Called when the user clicks "Save" button.
|
||||
*/
|
||||
$scope.doSave = function () {
|
||||
savedVis.id = savedVis.title;
|
||||
// vis.title was not bound and it's needed to reflect title into visState
|
||||
$state.vis.title = savedVis.title;
|
||||
savedVis.visState = $state.vis;
|
||||
|
@ -300,8 +304,11 @@ function VisEditor($scope, $route, timefilter, AppState, $location, kbnUrl, $tim
|
|||
|
||||
if (id) {
|
||||
notify.info('Saved Visualization "' + savedVis.title + '"');
|
||||
if (savedVis.id === $route.current.params.id) return;
|
||||
kbnUrl.change('/visualize/edit/{{id}}', {id: savedVis.id});
|
||||
if (savedVis.id === $route.current.params.id) {
|
||||
docTitle.change(savedVis.lastSavedTitle);
|
||||
} else {
|
||||
kbnUrl.change('/visualize/edit/{{id}}', {id: savedVis.id});
|
||||
}
|
||||
}
|
||||
}, notify.fatal);
|
||||
};
|
||||
|
|
|
@ -11,6 +11,9 @@
|
|||
ng-model="opts.savedVis.title"
|
||||
required
|
||||
>
|
||||
|
||||
<saved-object-save-as-check-box saved-object="opts.savedVis"></saved-object-save-as-check-box>
|
||||
|
||||
<button
|
||||
data-test-subj="saveVisualizationButton"
|
||||
type="submit"
|
||||
|
|
|
@ -53,11 +53,11 @@ uiModules
|
|||
SavedVis.type = 'visualization';
|
||||
|
||||
SavedVis.mapping = {
|
||||
title: 'string',
|
||||
title: 'text',
|
||||
visState: 'json',
|
||||
uiStateJSON: 'string',
|
||||
description: 'string',
|
||||
savedSearchId: 'string',
|
||||
uiStateJSON: 'keyword',
|
||||
description: 'text',
|
||||
savedSearchId: 'keyword',
|
||||
version: 'integer'
|
||||
};
|
||||
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
<bread-crumbs></bread-crumbs>
|
||||
<div class="visualizeWizardBreadcrumbs">
|
||||
<bread-crumbs></bread-crumbs>
|
||||
</div>
|
||||
<div class="wizard">
|
||||
<div class="wizard-column">
|
||||
<h3 class="wizard-sub-title">Create New Visualization</h3>
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
<bread-crumbs></bread-crumbs>
|
||||
<div class="visualizeWizardBreadcrumbs">
|
||||
<bread-crumbs></bread-crumbs>
|
||||
</div>
|
||||
<div class="wizard">
|
||||
<div class="wizard-column wizard-column--small">
|
||||
<h3 class="wizard-sub-title">From a New Search, Select Index</h3>
|
||||
|
|
|
@ -6,7 +6,7 @@ import 'plugins/kibana/discover/saved_searches/saved_searches';
|
|||
import routes from 'ui/routes';
|
||||
import RegistryVisTypesProvider from 'ui/registry/vis_types';
|
||||
import uiModules from 'ui/modules';
|
||||
|
||||
import './wizard.less';
|
||||
|
||||
const templateStep = function (num, txt) {
|
||||
return '<div ng-controller="VisualizeWizardStep' + num + '" class="container-fluid vis-wizard">' + txt + '</div>';
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
.visualizeWizardBreadcrumbs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 32px;
|
||||
}
|
|
@ -8,12 +8,12 @@ export default function registerDelete(server) {
|
|||
const { key } = req.params;
|
||||
const uiSettings = server.uiSettings();
|
||||
uiSettings
|
||||
.remove(key)
|
||||
.remove(req, key)
|
||||
.then(() => uiSettings
|
||||
.getUserProvided()
|
||||
.getUserProvided(req)
|
||||
.then(settings => reply({ settings }).type('application/json'))
|
||||
)
|
||||
.catch(reason => reply(Boom.wrap(reason)));
|
||||
.catch(err => reply(Boom.wrap(err, err.statusCode)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -7,9 +7,9 @@ export default function registerGet(server) {
|
|||
handler: function (req, reply) {
|
||||
server
|
||||
.uiSettings()
|
||||
.getUserProvided()
|
||||
.getUserProvided(req)
|
||||
.then(settings => reply({ settings }).type('application/json'))
|
||||
.catch(reason => reply(Boom.wrap(reason)));
|
||||
.catch(err => reply(Boom.wrap(err, err.statusCode)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -9,12 +9,12 @@ export default function registerSet(server) {
|
|||
const { value } = req.payload;
|
||||
const uiSettings = server.uiSettings();
|
||||
uiSettings
|
||||
.set(key, value)
|
||||
.set(req, key, value)
|
||||
.then(() => uiSettings
|
||||
.getUserProvided()
|
||||
.getUserProvided(req)
|
||||
.then(settings => reply({ settings }).type('application/json'))
|
||||
)
|
||||
.catch(reason => reply(Boom.wrap(reason)));
|
||||
.catch(err => reply(Boom.wrap(err, err.statusCode)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -9,12 +9,12 @@ export default function registerSet(server) {
|
|||
const { changes } = req.payload;
|
||||
const uiSettings = server.uiSettings();
|
||||
uiSettings
|
||||
.setMany(changes)
|
||||
.setMany(req, changes)
|
||||
.then(() => uiSettings
|
||||
.getUserProvided()
|
||||
.getUserProvided(req)
|
||||
.then(settings => reply({ settings }).type('application/json'))
|
||||
)
|
||||
.catch(reason => reply(Boom.wrap(reason)));
|
||||
.catch(err => reply(Boom.wrap(err, err.statusCode)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,24 +1,24 @@
|
|||
import ngMock from 'ng_mock';
|
||||
import expect from 'expect.js';
|
||||
import $ from 'jquery';
|
||||
|
||||
describe('markdown vis controller', function () {
|
||||
let $scope;
|
||||
let $el;
|
||||
|
||||
beforeEach(ngMock.module('kibana/markdown_vis'));
|
||||
beforeEach(ngMock.inject(function ($rootScope, $controller) {
|
||||
$scope = $rootScope.$new();
|
||||
$scope.vis = {
|
||||
emit: function () {}
|
||||
};
|
||||
$controller('KbnMarkdownVisController', {$scope: $scope});
|
||||
const $element = $('<div>');
|
||||
$controller('KbnMarkdownVisController', { $scope, $element });
|
||||
$scope.$digest();
|
||||
}));
|
||||
|
||||
it('should set html from markdown params', function () {
|
||||
expect($scope).to.not.have.property('html');
|
||||
$scope.vis.params = {
|
||||
markdown: 'This is a test of the [markdown](http://daringfireball.net/projects/markdown) vis.'
|
||||
$scope.vis = {
|
||||
params: {
|
||||
markdown: 'This is a test of the [markdown](http://daringfireball.net/projects/markdown) vis.'
|
||||
}
|
||||
};
|
||||
$scope.$digest();
|
||||
|
||||
|
|
|
@ -9,11 +9,11 @@ marked.setOptions({
|
|||
|
||||
|
||||
const module = uiModules.get('kibana/markdown_vis', ['kibana', 'ngSanitize']);
|
||||
module.controller('KbnMarkdownVisController', function ($scope) {
|
||||
module.controller('KbnMarkdownVisController', function ($scope, $element) {
|
||||
$scope.$watch('vis.params.markdown', function (html) {
|
||||
if (html) {
|
||||
$scope.html = marked(html);
|
||||
}
|
||||
$scope.vis.emit('renderComplete');
|
||||
$element.trigger('renderComplete');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import ngMock from 'ng_mock';
|
||||
import expect from 'expect.js';
|
||||
import $ from 'jquery';
|
||||
|
||||
describe('metric vis', function () {
|
||||
let $scope;
|
||||
|
@ -11,7 +12,8 @@ describe('metric vis', function () {
|
|||
beforeEach(ngMock.module('kibana/metric_vis'));
|
||||
beforeEach(ngMock.inject(function ($rootScope, $controller) {
|
||||
$scope = $rootScope.$new();
|
||||
$controller('KbnMetricVisController', {$scope: $scope});
|
||||
const $element = $('<div>');
|
||||
$controller('KbnMetricVisController', { $scope, $element });
|
||||
$scope.$digest();
|
||||
}));
|
||||
|
||||
|
|
|
@ -5,7 +5,7 @@ import uiModules from 'ui/modules';
|
|||
// didn't already
|
||||
const module = uiModules.get('kibana/metric_vis', ['kibana']);
|
||||
|
||||
module.controller('KbnMetricVisController', function ($scope, Private) {
|
||||
module.controller('KbnMetricVisController', function ($scope, $element, Private) {
|
||||
const tabifyAggResponse = Private(AggResponseTabifyTabifyProvider);
|
||||
|
||||
const metrics = $scope.metrics = [];
|
||||
|
@ -34,7 +34,7 @@ module.controller('KbnMetricVisController', function ($scope, Private) {
|
|||
if (resp) {
|
||||
metrics.length = 0;
|
||||
$scope.processTableGroups(tabifyAggResponse($scope.vis, resp));
|
||||
$scope.vis.emit('renderComplete');
|
||||
$element.trigger('renderComplete');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
@ -8,7 +8,7 @@ const module = uiModules.get('kibana/table_vis', ['kibana']);
|
|||
|
||||
// add a controller to tha module, which will transform the esResponse into a
|
||||
// tabular format that we can pass to the table directive
|
||||
module.controller('KbnTableVisController', function ($scope, Private) {
|
||||
module.controller('KbnTableVisController', function ($scope, $element, Private) {
|
||||
const tabifyAggResponse = Private(AggResponseTabifyTabifyProvider);
|
||||
|
||||
var uiStateSort = ($scope.uiState) ? $scope.uiState.get('vis.params.sort') : {};
|
||||
|
@ -44,7 +44,7 @@ module.controller('KbnTableVisController', function ($scope, Private) {
|
|||
return table.rows.length > 0;
|
||||
});
|
||||
|
||||
$scope.vis.emit('renderComplete');
|
||||
$element.trigger('renderComplete');
|
||||
}
|
||||
|
||||
$scope.hasSomeRows = hasSomeRows;
|
||||
|
|
8
src/core_plugins/tagcloud/index.js
Normal file
8
src/core_plugins/tagcloud/index.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
export default function (kibana) {
|
||||
|
||||
return new kibana.Plugin({
|
||||
uiExports: {
|
||||
visTypes: ['plugins/tagcloud/tag_cloud_vis']
|
||||
}
|
||||
});
|
||||
};
|
4
src/core_plugins/tagcloud/package.json
Normal file
4
src/core_plugins/tagcloud/package.json
Normal file
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"name": "tagcloud",
|
||||
"version": "kibana"
|
||||
}
|
462
src/core_plugins/tagcloud/public/__tests__/tag_cloud.js
Normal file
462
src/core_plugins/tagcloud/public/__tests__/tag_cloud.js
Normal file
|
@ -0,0 +1,462 @@
|
|||
import expect from 'expect.js';
|
||||
import _ from 'lodash';
|
||||
import TagCloud from 'plugins/tagcloud/tag_cloud';
|
||||
import d3 from 'd3';
|
||||
import {fromNode, delay} from 'bluebird';
|
||||
|
||||
describe('tag cloud tests', function () {
|
||||
|
||||
const minValue = 1;
|
||||
const maxValue = 9;
|
||||
const midValue = (minValue + maxValue) / 2;
|
||||
const baseTest = {
|
||||
data: [
|
||||
{text: 'foo', value: minValue},
|
||||
{text: 'bar', value: midValue},
|
||||
{text: 'foobar', value: maxValue},
|
||||
],
|
||||
options: {
|
||||
orientation: 'single',
|
||||
scale: 'linear',
|
||||
minFontSize: 10,
|
||||
maxFontSize: 36
|
||||
},
|
||||
expected: [
|
||||
{
|
||||
text: 'foo',
|
||||
fontSize: '10px'
|
||||
},
|
||||
{
|
||||
text: 'bar',
|
||||
fontSize: '23px'
|
||||
},
|
||||
{
|
||||
text: 'foobar',
|
||||
fontSize: '36px'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const singleLayoutTest = _.cloneDeep(baseTest);
|
||||
|
||||
const rightAngleLayoutTest = _.cloneDeep(baseTest);
|
||||
rightAngleLayoutTest.options.orientation = 'right angled';
|
||||
|
||||
const multiLayoutTest = _.cloneDeep(baseTest);
|
||||
multiLayoutTest.options.orientation = 'multiple';
|
||||
|
||||
const mapWithLog = d3.scale.log();
|
||||
mapWithLog.range([baseTest.options.minFontSize, baseTest.options.maxFontSize]);
|
||||
mapWithLog.domain([minValue, maxValue]);
|
||||
const logScaleTest = _.cloneDeep(baseTest);
|
||||
logScaleTest.options.scale = 'log';
|
||||
logScaleTest.expected[1].fontSize = Math.round(mapWithLog(midValue)) + 'px';
|
||||
|
||||
const mapWithSqrt = d3.scale.sqrt();
|
||||
mapWithSqrt.range([baseTest.options.minFontSize, baseTest.options.maxFontSize]);
|
||||
mapWithSqrt.domain([minValue, maxValue]);
|
||||
const sqrtScaleTest = _.cloneDeep(baseTest);
|
||||
sqrtScaleTest.options.scale = 'square root';
|
||||
sqrtScaleTest.expected[1].fontSize = Math.round(mapWithSqrt(midValue)) + 'px';
|
||||
|
||||
const biggerFontTest = _.cloneDeep(baseTest);
|
||||
biggerFontTest.options.minFontSize = 36;
|
||||
biggerFontTest.options.maxFontSize = 72;
|
||||
biggerFontTest.expected[0].fontSize = '36px';
|
||||
biggerFontTest.expected[1].fontSize = '54px';
|
||||
biggerFontTest.expected[2].fontSize = '72px';
|
||||
|
||||
const trimDataTest = _.cloneDeep(baseTest);
|
||||
trimDataTest.data.splice(1, 1);
|
||||
trimDataTest.expected.splice(1, 1);
|
||||
|
||||
|
||||
let domNode;
|
||||
let tagCloud;
|
||||
|
||||
function setupDOM() {
|
||||
domNode = document.createElement('div');
|
||||
domNode.style.top = '0';
|
||||
domNode.style.left = '0';
|
||||
domNode.style.width = '512px';
|
||||
domNode.style.height = '512px';
|
||||
domNode.style.position = 'fixed';
|
||||
domNode.style['pointer-events'] = 'none';
|
||||
document.body.appendChild(domNode);
|
||||
}
|
||||
|
||||
function teardownDOM() {
|
||||
domNode.innerHTML = '';
|
||||
document.body.removeChild(domNode);
|
||||
}
|
||||
|
||||
|
||||
[
|
||||
singleLayoutTest,
|
||||
rightAngleLayoutTest,
|
||||
multiLayoutTest,
|
||||
logScaleTest,
|
||||
sqrtScaleTest,
|
||||
biggerFontTest,
|
||||
trimDataTest
|
||||
].forEach(function (test) {
|
||||
|
||||
describe(`should position elements correctly for options: ${JSON.stringify(test.options)}`, function () {
|
||||
|
||||
beforeEach(async function () {
|
||||
setupDOM();
|
||||
tagCloud = new TagCloud(domNode);
|
||||
tagCloud.setData(test.data);
|
||||
tagCloud.setOptions(test.options);
|
||||
await fromNode(cb => tagCloud.once('renderComplete', cb));
|
||||
});
|
||||
|
||||
afterEach(teardownDOM);
|
||||
|
||||
it('completeness should be ok', handleExpectedBlip(function () {
|
||||
expect(tagCloud.getStatus()).to.equal(TagCloud.STATUS.COMPLETE);
|
||||
}));
|
||||
|
||||
it('positions should be ok', handleExpectedBlip(function () {
|
||||
const textElements = domNode.querySelectorAll('text');
|
||||
verifyTagProperties(test.expected, textElements, tagCloud);
|
||||
}));
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
[
|
||||
5,
|
||||
100,
|
||||
200,
|
||||
300,
|
||||
500
|
||||
].forEach(function (timeout) {
|
||||
describe(`should only send single renderComplete event at the very end, using ${timeout}ms timeout`, function () {
|
||||
|
||||
beforeEach(async function () {
|
||||
setupDOM();
|
||||
|
||||
//TagCloud takes at least 600ms to complete (due to d3 animation)
|
||||
//renderComplete should only notify at the last one
|
||||
tagCloud = new TagCloud(domNode);
|
||||
tagCloud.setData(baseTest.data);
|
||||
tagCloud.setOptions(baseTest.options);
|
||||
|
||||
//this timeout modifies the settings before the cloud is rendered.
|
||||
//the cloud needs to use the correct options
|
||||
setTimeout(() => tagCloud.setOptions(logScaleTest.options), timeout);
|
||||
await fromNode(cb => tagCloud.once('renderComplete', cb));
|
||||
|
||||
});
|
||||
|
||||
afterEach(teardownDOM);
|
||||
|
||||
it('completeness should be ok', handleExpectedBlip(function () {
|
||||
expect(tagCloud.getStatus()).to.equal(TagCloud.STATUS.COMPLETE);
|
||||
}));
|
||||
|
||||
it('positions should be ok', handleExpectedBlip(function () {
|
||||
const textElements = domNode.querySelectorAll('text');
|
||||
verifyTagProperties(logScaleTest.expected, textElements, tagCloud);
|
||||
}));
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
describe('should use the latest state before notifying (when modifying options multiple times)', function () {
|
||||
|
||||
beforeEach(async function () {
|
||||
setupDOM();
|
||||
tagCloud = new TagCloud(domNode);
|
||||
tagCloud.setData(baseTest.data);
|
||||
tagCloud.setOptions(baseTest.options);
|
||||
tagCloud.setOptions(logScaleTest.options);
|
||||
await fromNode(cb => tagCloud.once('renderComplete', cb));
|
||||
});
|
||||
|
||||
afterEach(teardownDOM);
|
||||
|
||||
it('completeness should be ok', handleExpectedBlip(function () {
|
||||
expect(tagCloud.getStatus()).to.equal(TagCloud.STATUS.COMPLETE);
|
||||
}));
|
||||
it('positions should be ok', handleExpectedBlip(function () {
|
||||
const textElements = domNode.querySelectorAll('text');
|
||||
verifyTagProperties(logScaleTest.expected, textElements, tagCloud);
|
||||
}));
|
||||
|
||||
});
|
||||
|
||||
describe('should use the latest state before notifying (when modifying data multiple times)', function () {
|
||||
|
||||
beforeEach(async function () {
|
||||
setupDOM();
|
||||
tagCloud = new TagCloud(domNode);
|
||||
tagCloud.setData(baseTest.data);
|
||||
tagCloud.setOptions(baseTest.options);
|
||||
tagCloud.setData(trimDataTest.data);
|
||||
await fromNode(cb => tagCloud.once('renderComplete', cb));
|
||||
});
|
||||
|
||||
afterEach(teardownDOM);
|
||||
|
||||
it('completeness should be ok', handleExpectedBlip(function () {
|
||||
expect(tagCloud.getStatus()).to.equal(TagCloud.STATUS.COMPLETE);
|
||||
}));
|
||||
it('positions should be ok', handleExpectedBlip(function () {
|
||||
const textElements = domNode.querySelectorAll('text');
|
||||
verifyTagProperties(trimDataTest.expected, textElements, tagCloud);
|
||||
}));
|
||||
|
||||
});
|
||||
|
||||
describe('should not get multiple render-events', function () {
|
||||
|
||||
let counter;
|
||||
beforeEach(function () {
|
||||
counter = 0;
|
||||
setupDOM();
|
||||
return new Promise((resolve, reject)=> {
|
||||
tagCloud = new TagCloud(domNode);
|
||||
tagCloud.setData(baseTest.data);
|
||||
tagCloud.setOptions(baseTest.options);
|
||||
|
||||
setTimeout(() => {
|
||||
//this should be overridden by later changes
|
||||
tagCloud.setData(sqrtScaleTest.data);
|
||||
tagCloud.setOptions(sqrtScaleTest.options);
|
||||
}, 100);
|
||||
|
||||
|
||||
setTimeout(() => {
|
||||
//latest change
|
||||
tagCloud.setData(logScaleTest.data);
|
||||
tagCloud.setOptions(logScaleTest.options);
|
||||
}, 300);
|
||||
|
||||
tagCloud.on('renderComplete', function onRender() {
|
||||
if (counter > 0) {
|
||||
reject('Should not get multiple render events');
|
||||
}
|
||||
counter += 1;
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(teardownDOM);
|
||||
|
||||
it('completeness should be ok', handleExpectedBlip(function () {
|
||||
expect(tagCloud.getStatus()).to.equal(TagCloud.STATUS.COMPLETE);
|
||||
}));
|
||||
it('positions should be ok', handleExpectedBlip(function () {
|
||||
const textElements = domNode.querySelectorAll('text');
|
||||
verifyTagProperties(logScaleTest.expected, textElements, tagCloud);
|
||||
}));
|
||||
|
||||
});
|
||||
|
||||
|
||||
describe('should show correct data when state-updates are interleaved with resize event', function () {
|
||||
|
||||
beforeEach(async function () {
|
||||
setupDOM();
|
||||
tagCloud = new TagCloud(domNode);
|
||||
tagCloud.setData(logScaleTest.data);
|
||||
tagCloud.setOptions(logScaleTest.options);
|
||||
|
||||
await delay(1000);//let layout run
|
||||
domNode.style.width = '600px';
|
||||
domNode.style.height = '600px';
|
||||
tagCloud.resize();//triggers new layout
|
||||
setTimeout(() => {//change the options at the very end too
|
||||
tagCloud.setData(baseTest.data);
|
||||
tagCloud.setOptions(baseTest.options);
|
||||
}, 200);
|
||||
await fromNode(cb => tagCloud.once('renderComplete', cb));
|
||||
|
||||
});
|
||||
|
||||
afterEach(teardownDOM);
|
||||
|
||||
it('completeness should be ok', handleExpectedBlip(function () {
|
||||
expect(tagCloud.getStatus()).to.equal(TagCloud.STATUS.COMPLETE);
|
||||
}));
|
||||
it('positions should be ok', handleExpectedBlip(function () {
|
||||
const textElements = domNode.querySelectorAll('text');
|
||||
verifyTagProperties(baseTest.expected, textElements, tagCloud);
|
||||
}));
|
||||
|
||||
|
||||
});
|
||||
|
||||
|
||||
describe(`should not put elements in view when container is too small`, function () {
|
||||
|
||||
beforeEach(async function () {
|
||||
setupDOM();
|
||||
domNode.style.width = '1px';
|
||||
domNode.style.height = '1px';
|
||||
tagCloud = new TagCloud(domNode);
|
||||
tagCloud.setData(baseTest.data);
|
||||
tagCloud.setOptions(baseTest.options);
|
||||
await fromNode(cb => tagCloud.once('renderComplete', cb));
|
||||
});
|
||||
|
||||
afterEach(teardownDOM);
|
||||
|
||||
it('completeness should not be ok', function () {
|
||||
expect(tagCloud.getStatus()).to.equal(TagCloud.STATUS.INCOMPLETE);
|
||||
});
|
||||
it('positions should not be ok', function () {
|
||||
const textElements = domNode.querySelectorAll('text');
|
||||
for (let i = 0; i < textElements; i++) {
|
||||
const bbox = textElements[i].getBoundingClientRect();
|
||||
verifyBbox(bbox, false, tagCloud);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe(`tags should fit after making container bigger`, function () {
|
||||
|
||||
beforeEach(async function () {
|
||||
setupDOM();
|
||||
domNode.style.width = '1px';
|
||||
domNode.style.height = '1px';
|
||||
|
||||
tagCloud = new TagCloud(domNode);
|
||||
tagCloud.setData(baseTest.data);
|
||||
tagCloud.setOptions(baseTest.options);
|
||||
await fromNode(cb => tagCloud.once('renderComplete', cb));
|
||||
|
||||
//make bigger
|
||||
domNode.style.width = '512px';
|
||||
domNode.style.height = '512px';
|
||||
tagCloud.resize();
|
||||
await fromNode(cb => tagCloud.once('renderComplete', cb));
|
||||
|
||||
});
|
||||
|
||||
afterEach(teardownDOM);
|
||||
|
||||
it('completeness should be ok', handleExpectedBlip(function () {
|
||||
expect(tagCloud.getStatus()).to.equal(TagCloud.STATUS.COMPLETE);
|
||||
}));
|
||||
|
||||
});
|
||||
|
||||
describe(`tags should no longer fit after making container smaller`, function () {
|
||||
|
||||
beforeEach(async function () {
|
||||
setupDOM();
|
||||
tagCloud = new TagCloud(domNode);
|
||||
tagCloud.setData(baseTest.data);
|
||||
tagCloud.setOptions(baseTest.options);
|
||||
await fromNode(cb => tagCloud.once('renderComplete', cb));
|
||||
|
||||
//make smaller
|
||||
domNode.style.width = '1px';
|
||||
domNode.style.height = '1px';
|
||||
tagCloud.resize();
|
||||
await fromNode(cb => tagCloud.once('renderComplete', cb));
|
||||
|
||||
});
|
||||
|
||||
afterEach(teardownDOM);
|
||||
|
||||
it('completeness should not be ok', function () {
|
||||
expect(tagCloud.getStatus()).to.equal(TagCloud.STATUS.INCOMPLETE);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
function verifyTagProperties(expectedValues, actualElements, tagCloud) {
|
||||
expect(actualElements.length).to.equal(expectedValues.length);
|
||||
expectedValues.forEach((test, index) => {
|
||||
try {
|
||||
expect(actualElements[index].style.fontSize).to.equal(test.fontSize);
|
||||
} catch (e) {
|
||||
throw new Error('fontsize is not correct: ' + e.message);
|
||||
}
|
||||
try {
|
||||
expect(actualElements[index].innerHTML).to.equal(test.text);
|
||||
} catch (e) {
|
||||
throw new Error('fontsize is not correct: ' + e.message);
|
||||
}
|
||||
isInsideContainer(actualElements[index], tagCloud);
|
||||
});
|
||||
}
|
||||
|
||||
function isInsideContainer(actualElement, tagCloud) {
|
||||
const bbox = actualElement.getBoundingClientRect();
|
||||
verifyBbox(bbox, true, tagCloud);
|
||||
}
|
||||
|
||||
function verifyBbox(bbox, shouldBeInside, tagCloud) {
|
||||
const message = ` | bbox-of-tag: ${JSON.stringify([bbox.left, bbox.top, bbox.right, bbox.bottom])} vs
|
||||
bbox-of-container: ${domNode.offsetWidth},${domNode.offsetHeight}
|
||||
debugInfo: ${JSON.stringify(tagCloud.getDebugInfo())}`;
|
||||
|
||||
try {
|
||||
expect(bbox.top >= 0 && bbox.top <= domNode.offsetHeight).to.be(shouldBeInside);
|
||||
} catch (e) {
|
||||
throw new Error('top boundary of tag should have been ' + (shouldBeInside ? 'inside' : 'outside') + message);
|
||||
}
|
||||
try {
|
||||
expect(bbox.bottom >= 0 && bbox.bottom <= domNode.offsetHeight).to.be(shouldBeInside);
|
||||
} catch (e) {
|
||||
throw new Error('bottom boundary of tag should have been ' + (shouldBeInside ? 'inside' : 'outside') + message);
|
||||
}
|
||||
try {
|
||||
expect(bbox.left >= 0 && bbox.left <= domNode.offsetWidth).to.be(shouldBeInside);
|
||||
} catch (e) {
|
||||
throw new Error('left boundary of tag should have been ' + (shouldBeInside ? 'inside' : 'outside') + message);
|
||||
}
|
||||
try {
|
||||
expect(bbox.right >= 0 && bbox.right <= domNode.offsetWidth).to.be(shouldBeInside);
|
||||
} catch (e) {
|
||||
throw new Error('right boundary of tag should have been ' + (shouldBeInside ? 'inside' : 'outside') + message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In CI, this entire suite "blips" about 1/5 times.
|
||||
* This blip causes the majority of these tests fail for the exact same reason: One tag is centered inside the container,
|
||||
* while the others are moved out.
|
||||
* This has not been reproduced locally yet.
|
||||
* It may be an issue with the 3rd party d3-cloud that snags.
|
||||
*
|
||||
* The test suite should continue to catch reliably catch regressions of other sorts: unexpected and other uncaught errors,
|
||||
* scaling issues, ordering issues
|
||||
*
|
||||
*/
|
||||
function shouldAssert() {
|
||||
const debugInfo = tagCloud.getDebugInfo();
|
||||
const count = debugInfo.positions.length;
|
||||
const largest = debugInfo.positions.pop();//test suite puts largest tag at the end.
|
||||
|
||||
|
||||
const centered = (largest[1] === 0 && largest[2] === 0);
|
||||
const inside = debugInfo.positions.filter(position => {
|
||||
return debugInfo.size[0] <= position[1] && position[1] <= debugInfo.size[0]
|
||||
&& debugInfo.size[1] <= position[2] && position[2] <= debugInfo.size[1];
|
||||
});
|
||||
|
||||
return centered && inside.length === count - 1;
|
||||
|
||||
}
|
||||
|
||||
function handleExpectedBlip(assertion) {
|
||||
return function () {
|
||||
if (!shouldAssert()) {
|
||||
console.warn('Skipping assertion.');
|
||||
return;
|
||||
}
|
||||
assertion();
|
||||
};
|
||||
}
|
||||
|
||||
});
|
374
src/core_plugins/tagcloud/public/tag_cloud.js
Normal file
374
src/core_plugins/tagcloud/public/tag_cloud.js
Normal file
|
@ -0,0 +1,374 @@
|
|||
import d3 from 'd3';
|
||||
import d3TagCloud from 'd3-cloud';
|
||||
import vislibComponentsSeedColorsProvider from 'ui/vislib/components/color/seed_colors';
|
||||
import {EventEmitter} from 'events';
|
||||
|
||||
|
||||
const ORIENTATIONS = {
|
||||
'single': () => 0,
|
||||
'right angled': (tag) => {
|
||||
return hashCode(tag.text) % 2 * 90;
|
||||
},
|
||||
'multiple': (tag) => {
|
||||
const hashcode = Math.abs(hashCode(tag.text));
|
||||
return ((hashcode % 12) * 15) - 90;//fan out 12 * 15 degrees over top-right and bottom-right quadrant (=-90 deg offset)
|
||||
}
|
||||
};
|
||||
const D3_SCALING_FUNCTIONS = {
|
||||
'linear': () => d3.scale.linear(),
|
||||
'log': () => d3.scale.log(),
|
||||
'square root': () => d3.scale.sqrt()
|
||||
};
|
||||
|
||||
|
||||
class TagCloud extends EventEmitter {
|
||||
|
||||
constructor(domNode) {
|
||||
|
||||
super();
|
||||
|
||||
//DOM
|
||||
this._element = domNode;
|
||||
this._d3SvgContainer = d3.select(this._element).append('svg');
|
||||
this._svgGroup = this._d3SvgContainer.append('g');
|
||||
this._size = [1, 1];
|
||||
this.resize();
|
||||
|
||||
//SETTING (non-configurable)
|
||||
this._fontFamily = 'Impact';
|
||||
this._fontStyle = 'normal';
|
||||
this._fontWeight = 'normal';
|
||||
this._spiral = 'archimedean';//layout shape
|
||||
this._timeInterval = 1000;//time allowed for layout algorithm
|
||||
this._padding = 5;
|
||||
|
||||
//OPTIONS
|
||||
this._orientation = 'single';
|
||||
this._minFontSize = 10;
|
||||
this._maxFontSize = 36;
|
||||
this._textScale = 'linear';
|
||||
this._optionsAsString = null;
|
||||
|
||||
//DATA
|
||||
this._words = null;
|
||||
|
||||
//UTIL
|
||||
this._handle = null;
|
||||
this._queue = [];
|
||||
this._allInViewBox = false;
|
||||
this._inFlight = false;
|
||||
|
||||
}
|
||||
|
||||
setOptions(options) {
|
||||
if (JSON.stringify(options) === this._optionsAsString) {
|
||||
return;
|
||||
}
|
||||
this._optionsAsString = JSON.stringify(options);
|
||||
this._orientation = options.orientation;
|
||||
this._minFontSize = Math.min(options.minFontSize, options.maxFontSize);
|
||||
this._maxFontSize = Math.max(options.minFontSize, options.maxFontSize);
|
||||
this._textScale = options.scale;
|
||||
this._invalidate(false);
|
||||
}
|
||||
|
||||
|
||||
resize() {
|
||||
const newWidth = this._element.offsetWidth;
|
||||
const newHeight = this._element.offsetHeight;
|
||||
if (newWidth < 1 || newHeight < 1) {
|
||||
return;
|
||||
}
|
||||
if (newWidth === this._size[0] && newHeight === this._size[1]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wasInside = this._size[0] >= this._cloudWidth && this._size[1] >= this._cloudHeight;
|
||||
const willBeInside = this._cloudWidth <= newWidth && this._cloudHeight <= newHeight;
|
||||
this._size[0] = newWidth;
|
||||
this._size[1] = newHeight;
|
||||
if (wasInside && willBeInside && this._allInViewBox) {
|
||||
this._invalidate(true);
|
||||
} else {
|
||||
this._invalidate(false);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
setData(data) {
|
||||
this._words = data.map(toWordTag);
|
||||
this._invalidate(false);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
clearTimeout(this._handle);
|
||||
this._element.innerHTML = '';
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
return this._allInViewBox ? TagCloud.STATUS.COMPLETE : TagCloud.STATUS.INCOMPLETE;
|
||||
}
|
||||
|
||||
_updateContainerSize() {
|
||||
this._d3SvgContainer.attr('width', this._size[0]);
|
||||
this._d3SvgContainer.attr('height', this._size[1]);
|
||||
this._svgGroup.attr('width', this._size[0]);
|
||||
this._svgGroup.attr('height', this._size[1]);
|
||||
}
|
||||
|
||||
_processQueue() {
|
||||
|
||||
if (!this._queue.length) {
|
||||
this.emit('renderComplete');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._inFlight) {
|
||||
return;
|
||||
}
|
||||
|
||||
const job = this._queue.pop();
|
||||
this._inFlight = true;
|
||||
|
||||
if (job.words.length) {
|
||||
this._onLayoutEnd(job);
|
||||
} else {
|
||||
this._emptyCloud(job);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
_emptyCloud(job) {
|
||||
this._svgGroup.selectAll('text').remove();
|
||||
this._cloudWidth = 0;
|
||||
this._cloudHeight = 0;
|
||||
this._allInViewBox = true;
|
||||
this._inFlight = false;
|
||||
this._currentJob = job;
|
||||
this._processQueue();
|
||||
}
|
||||
|
||||
_onLayoutEnd(job) {
|
||||
|
||||
if (this._handle !== null) {//a new configuration is coming, no need to update
|
||||
this._processQueue();
|
||||
return;
|
||||
}
|
||||
|
||||
this._currentJob = null;
|
||||
const affineTransform = positionWord.bind(null, this._element.offsetWidth / 2, this._element.offsetHeight / 2);
|
||||
const svgTextNodes = this._svgGroup.selectAll('text');
|
||||
const stage = svgTextNodes.data(job.words, getText);
|
||||
|
||||
const enterSelection = stage.enter();
|
||||
const enteringTags = enterSelection.append('text');
|
||||
enteringTags.style('font-size', getSizeInPixels);
|
||||
enteringTags.style('font-style', this._fontStyle);
|
||||
enteringTags.style('font-weight', () => this._fontWeight);
|
||||
enteringTags.style('font-family', () => this._fontFamily);
|
||||
enteringTags.style('fill', getFill);
|
||||
enteringTags.attr('text-anchor', () => 'middle');
|
||||
enteringTags.attr('transform', affineTransform);
|
||||
enteringTags.text(getText);
|
||||
|
||||
const self = this;
|
||||
enteringTags.on({
|
||||
click: function (event) {
|
||||
self.emit('select', event.text);
|
||||
},
|
||||
mouseover: function (d) {
|
||||
d3.select(this).style('cursor', 'pointer');
|
||||
},
|
||||
mouseout: function (d) {
|
||||
d3.select(this).style('cursor', 'default');
|
||||
}
|
||||
});
|
||||
|
||||
const movingTags = stage.transition();
|
||||
movingTags.duration(600);
|
||||
movingTags.style('font-size', getSizeInPixels);
|
||||
movingTags.style('font-style', this._fontStyle);
|
||||
movingTags.style('font-weight', () => this._fontWeight);
|
||||
movingTags.style('font-family', () => this._fontFamily);
|
||||
movingTags.attr('transform', affineTransform);
|
||||
|
||||
const exitingTags = stage.exit();
|
||||
const exitTransition = exitingTags.transition();
|
||||
exitTransition.duration(200);
|
||||
exitingTags.style('fill-opacity', 1e-6);
|
||||
exitingTags.attr('font-size', 1);
|
||||
exitingTags.remove();
|
||||
|
||||
let exits = 0;
|
||||
let moves = 0;
|
||||
const resolveWhenDone = () => {
|
||||
if (exits === 0 && moves === 0) {
|
||||
const cloudBBox = this._svgGroup[0][0].getBBox();
|
||||
this._cloudWidth = cloudBBox.width;
|
||||
this._cloudHeight = cloudBBox.height;
|
||||
this._allInViewBox = cloudBBox.x >= 0 && cloudBBox.y >= 0 &&
|
||||
cloudBBox.x + cloudBBox.width <= this._element.offsetWidth &&
|
||||
cloudBBox.y + cloudBBox.height <= this._element.offsetHeight;
|
||||
|
||||
this._inFlight = false;
|
||||
this._currentJob = job;
|
||||
this._processQueue();
|
||||
}
|
||||
};
|
||||
exitTransition.each(_ => exits++);
|
||||
exitTransition.each('end', () => {
|
||||
exits--;
|
||||
resolveWhenDone();
|
||||
});
|
||||
movingTags.each(_ => moves++);
|
||||
movingTags.each('end', () => {
|
||||
moves--;
|
||||
resolveWhenDone();
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
_makeTextSizeMapper() {
|
||||
const mapSizeToFontSize = D3_SCALING_FUNCTIONS[this._textScale]();
|
||||
const range = this._words.length === 1 ? [this._maxFontSize, this._maxFontSize] : [this._minFontSize, this._maxFontSize];
|
||||
mapSizeToFontSize.range(range);
|
||||
if (this._words) {
|
||||
mapSizeToFontSize.domain(d3.extent(this._words, getValue));
|
||||
}
|
||||
return mapSizeToFontSize;
|
||||
}
|
||||
|
||||
_makeJob() {
|
||||
return {
|
||||
words: this._words.map(toWordTag)
|
||||
};
|
||||
}
|
||||
|
||||
_invalidate(keepLayout) {
|
||||
|
||||
if (!this._words) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(this._handle);
|
||||
this._handle = setTimeout(() => {
|
||||
this._handle = null;
|
||||
this._updateContainerSize();
|
||||
if (keepLayout && this._currentJob && this._queue.length === 0) {
|
||||
this._scheduleLayout({
|
||||
words: this._currentJob.words.map(tag => {
|
||||
return {
|
||||
x: tag.x,
|
||||
y: tag.y,
|
||||
rotate: tag.rotate,
|
||||
size: tag.size,
|
||||
text: tag.text
|
||||
};
|
||||
})
|
||||
});
|
||||
} else {
|
||||
this._updateLayout();
|
||||
}
|
||||
}, 0);//unhook from callstack. this avoids kicking off multiple layouts if multiple changes come in succession
|
||||
}
|
||||
|
||||
_scheduleLayout(job) {
|
||||
this._queue.unshift(job);
|
||||
this._processQueue();
|
||||
}
|
||||
|
||||
_updateLayout() {
|
||||
|
||||
const job = this._makeJob();
|
||||
const mapSizeToFontSize = this._makeTextSizeMapper();
|
||||
|
||||
const tagCloudLayoutGenerator = d3TagCloud();
|
||||
tagCloudLayoutGenerator.size(this._size);
|
||||
tagCloudLayoutGenerator.padding(this._padding);
|
||||
tagCloudLayoutGenerator.rotate(ORIENTATIONS[this._orientation]);
|
||||
tagCloudLayoutGenerator.font(this._fontFamily);
|
||||
tagCloudLayoutGenerator.fontStyle(this._fontStyle);
|
||||
tagCloudLayoutGenerator.fontWeight(this._fontWeight);
|
||||
tagCloudLayoutGenerator.fontSize(tag => {
|
||||
return mapSizeToFontSize(tag.value);
|
||||
});
|
||||
tagCloudLayoutGenerator.random(seed);
|
||||
tagCloudLayoutGenerator.spiral(this._spiral);
|
||||
tagCloudLayoutGenerator.words(job.words);
|
||||
tagCloudLayoutGenerator.text(getText);
|
||||
tagCloudLayoutGenerator.timeInterval(this._timeInterval);
|
||||
tagCloudLayoutGenerator.on('end', () => this._scheduleLayout(job));
|
||||
tagCloudLayoutGenerator.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns debug info. For debugging only.
|
||||
* @return {*}
|
||||
*/
|
||||
getDebugInfo() {
|
||||
const debug = {};
|
||||
debug.positions = this._currentJob ? this._currentJob.words.map(tag => [tag.text, tag.x, tag.y, tag.rotate]) : [];
|
||||
debug.size = this._size.slice();
|
||||
return debug;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
TagCloud.STATUS = {COMPLETE: 0, INCOMPLETE: 1};
|
||||
|
||||
function seed() {
|
||||
return 0.5;//constant seed (not random) to ensure constant layouts for identical data
|
||||
}
|
||||
|
||||
function toWordTag(word) {
|
||||
return {value: word.value, text: word.text};
|
||||
}
|
||||
|
||||
|
||||
function getText(word) {
|
||||
return word.text;
|
||||
}
|
||||
|
||||
function positionWord(xTranslate, yTranslate, word) {
|
||||
|
||||
if (isNaN(word.x) || isNaN(word.y) || isNaN(word.rotate)) {
|
||||
//move off-screen
|
||||
return `translate(${xTranslate * 3}, ${yTranslate * 3})rotate(0)`;
|
||||
}
|
||||
|
||||
return `translate(${word.x + xTranslate}, ${word.y + yTranslate})rotate(${word.rotate})`;
|
||||
}
|
||||
|
||||
function getValue(tag) {
|
||||
return tag.value;
|
||||
}
|
||||
|
||||
function getSizeInPixels(tag) {
|
||||
return `${tag.size}px`;
|
||||
}
|
||||
|
||||
const colorScale = d3.scale.ordinal().range(vislibComponentsSeedColorsProvider());
|
||||
function getFill(tag) {
|
||||
return colorScale(tag.text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a string to a number. Ensures there is no random element in positioning strings
|
||||
* Retrieved from http://stackoverflow.com/questions/26057572/string-to-unique-hash-in-javascript-jquery
|
||||
* @param string
|
||||
*/
|
||||
function hashCode(string) {
|
||||
string = JSON.stringify(string);
|
||||
let hash = 0;
|
||||
if (string.length === 0) {
|
||||
return hash;
|
||||
}
|
||||
for (let i = 0; i < string.length; i++) {
|
||||
let char = string.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash; // Convert to 32bit integer
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
export default TagCloud;
|
47
src/core_plugins/tagcloud/public/tag_cloud.less
Normal file
47
src/core_plugins/tagcloud/public/tag_cloud.less
Normal file
|
@ -0,0 +1,47 @@
|
|||
.tagcloud-vis {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tagcloud-notifications {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.tagcloud-incomplete-message {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tagcloud-truncated-message {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tagcloud-custom-label {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
color: inherit;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.tag-cloud-fontsize-slider {
|
||||
margin-top: 38px;
|
||||
margin-bottom: 36px;
|
||||
margin-left: 12px;
|
||||
margin-right: 24px;
|
||||
}
|
||||
|
||||
.tag-cloud-fontsize-slider .noUi-connect {
|
||||
background: #e4e4e4;
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
<div ng-controller="KbnTagCloudController" class="tagcloud-vis">
|
||||
<div ng-show="vis.params.hideLabel" class="tagcloud-custom-label"></div>
|
||||
<div class="tagcloud-notifications">
|
||||
<div class="tagcloud-truncated-message">The number of tags has been truncated to avoid long draw times.</div>
|
||||
<div class="tagcloud-incomplete-message">The container is too small to display the entire cloud. Tags may appear cropped or be ommitted.</div>
|
||||
</div>
|
||||
</div>
|
112
src/core_plugins/tagcloud/public/tag_cloud_controller.js
Normal file
112
src/core_plugins/tagcloud/public/tag_cloud_controller.js
Normal file
|
@ -0,0 +1,112 @@
|
|||
import _ from 'lodash';
|
||||
import uiModules from 'ui/modules';
|
||||
import TagCloud from 'plugins/tagcloud/tag_cloud';
|
||||
import AggConfigResult from 'ui/vis/agg_config_result';
|
||||
import FilterBarFilterBarClickHandlerProvider from 'ui/filter_bar/filter_bar_click_handler';
|
||||
|
||||
const module = uiModules.get('kibana/tagcloud', ['kibana']);
|
||||
module.controller('KbnTagCloudController', function ($scope, $element, Private, getAppState) {
|
||||
|
||||
const containerNode = $element[0];
|
||||
const filterBarClickHandler = Private(FilterBarFilterBarClickHandlerProvider);
|
||||
const maxTagCount = 200;
|
||||
let truncated = false;
|
||||
|
||||
const tagCloud = new TagCloud(containerNode);
|
||||
tagCloud.on('select', (event) => {
|
||||
const appState = getAppState();
|
||||
const clickHandler = filterBarClickHandler(appState);
|
||||
const aggs = $scope.vis.aggs.getResponseAggs();
|
||||
const aggConfigResult = new AggConfigResult(aggs[0], false, event, event);
|
||||
clickHandler({point: {aggConfigResult: aggConfigResult}});
|
||||
});
|
||||
tagCloud.on('renderComplete', () => {
|
||||
|
||||
const truncatedMessage = containerNode.querySelector('.tagcloud-truncated-message');
|
||||
const incompleteMessage = containerNode.querySelector('.tagcloud-incomplete-message');
|
||||
|
||||
if (!$scope.vis.aggs[0] || !$scope.vis.aggs[1]) {
|
||||
incompleteMessage.style.display = 'none';
|
||||
truncatedMessage.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const bucketName = containerNode.querySelector('.tagcloud-custom-label');
|
||||
bucketName.innerHTML = `${$scope.vis.aggs[0].makeLabel()} - ${$scope.vis.aggs[1].makeLabel()}`;
|
||||
|
||||
|
||||
truncatedMessage.style.display = truncated ? 'block' : 'none';
|
||||
|
||||
|
||||
const status = tagCloud.getStatus();
|
||||
|
||||
if (TagCloud.STATUS.COMPLETE === status) {
|
||||
incompleteMessage.style.display = 'none';
|
||||
} else if (TagCloud.STATUS.INCOMPLETE === status) {
|
||||
incompleteMessage.style.display = 'block';
|
||||
}
|
||||
|
||||
|
||||
$element.trigger('renderComplete');
|
||||
});
|
||||
|
||||
$scope.$watch('esResponse', async function (response) {
|
||||
|
||||
if (!response) {
|
||||
tagCloud.setData([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const tagsAggId = _.first(_.pluck($scope.vis.aggs.bySchemaName.segment, 'id'));
|
||||
if (!tagsAggId || !response.aggregations) {
|
||||
tagCloud.setData([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const metricsAgg = _.first($scope.vis.aggs.bySchemaName.metric);
|
||||
const buckets = response.aggregations[tagsAggId].buckets;
|
||||
|
||||
const tags = buckets.map((bucket) => {
|
||||
return {
|
||||
text: bucket.key,
|
||||
value: getValue(metricsAgg, bucket)
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
if (tags.length > maxTagCount) {
|
||||
tags.length = maxTagCount;
|
||||
truncated = true;
|
||||
} else {
|
||||
truncated = false;
|
||||
}
|
||||
|
||||
tagCloud.setData(tags);
|
||||
});
|
||||
|
||||
|
||||
$scope.$watch('vis.params', (options) => tagCloud.setOptions(options));
|
||||
|
||||
$scope.$watch(getContainerSize, _.debounce(() => {
|
||||
tagCloud.resize();
|
||||
}, 1000, {trailing: true}), true);
|
||||
|
||||
|
||||
function getContainerSize() {
|
||||
return {width: $element.width(), height: $element.height()};
|
||||
}
|
||||
|
||||
function getValue(metricsAgg, bucket) {
|
||||
let size = metricsAgg.getValue(bucket);
|
||||
if (typeof size !== 'number' || isNaN(size)) {
|
||||
try {
|
||||
size = bucket[1].values[0].value;//lift out first value (e.g. median aggregations return as array)
|
||||
} catch (e) {
|
||||
size = 1;//punt
|
||||
}
|
||||
}
|
||||
return size;
|
||||
}
|
||||
|
||||
|
||||
});
|
58
src/core_plugins/tagcloud/public/tag_cloud_vis.js
Normal file
58
src/core_plugins/tagcloud/public/tag_cloud_vis.js
Normal file
|
@ -0,0 +1,58 @@
|
|||
import 'plugins/tagcloud/tag_cloud.less';
|
||||
import 'plugins/tagcloud/tag_cloud_controller';
|
||||
import 'plugins/tagcloud/tag_cloud_vis_params';
|
||||
import TemplateVisTypeTemplateVisTypeProvider from 'ui/template_vis_type/template_vis_type';
|
||||
import VisSchemasProvider from 'ui/vis/schemas';
|
||||
import tagCloudTemplate from 'plugins/tagcloud/tag_cloud_controller.html';
|
||||
import visTypes from 'ui/registry/vis_types';
|
||||
|
||||
visTypes.register(function TagCloudProvider(Private) {
|
||||
const TemplateVisType = Private(TemplateVisTypeTemplateVisTypeProvider);
|
||||
const Schemas = Private(VisSchemasProvider);
|
||||
|
||||
return new TemplateVisType({
|
||||
name: 'tagcloud',
|
||||
title: 'Tag cloud',
|
||||
implementsRenderComplete: true,
|
||||
description: 'A tag cloud visualization is a visual representation of text data, ' +
|
||||
'typically used to visualize free form text. Tags are usually single words. The font size of word corresponds' +
|
||||
'with its importance.',
|
||||
icon: 'fa-cloud',
|
||||
template: tagCloudTemplate,
|
||||
params: {
|
||||
defaults: {
|
||||
scale: 'linear',
|
||||
orientation: 'single',
|
||||
minFontSize: 18,
|
||||
maxFontSize: 72
|
||||
},
|
||||
scales: ['linear', 'log', 'square root'],
|
||||
orientations: ['single', 'right angled', 'multiple'],
|
||||
editor: '<tagcloud-vis-params></tagcloud-vis-params>'
|
||||
},
|
||||
schemas: new Schemas([
|
||||
{
|
||||
group: 'metrics',
|
||||
name: 'metric',
|
||||
title: 'Tag Size',
|
||||
min: 1,
|
||||
max: 1,
|
||||
aggFilter: ['!std_dev', '!percentiles', '!percentile_ranks'],
|
||||
defaults: [
|
||||
{ schema: 'metric', type: 'count' }
|
||||
]
|
||||
},
|
||||
{
|
||||
group: 'buckets',
|
||||
name: 'segment',
|
||||
icon: 'fa fa-cloud',
|
||||
title: 'Tags',
|
||||
min: 1,
|
||||
max: 1,
|
||||
aggFilter: ['terms']
|
||||
}
|
||||
])
|
||||
});
|
||||
});
|
||||
|
||||
|
21
src/core_plugins/tagcloud/public/tag_cloud_vis_params.html
Normal file
21
src/core_plugins/tagcloud/public/tag_cloud_vis_params.html
Normal file
|
@ -0,0 +1,21 @@
|
|||
<div class="form-group">
|
||||
<div class="form-group">
|
||||
<label>Text Scale</label>
|
||||
<select class="form-control" ng-model="vis.params.scale" ng-options="mode for mode in vis.type.params.scales"></select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Orientations</label>
|
||||
<select class="form-control" ng-model="vis.params.orientation" ng-options="mode for mode in vis.type.params.orientations"></select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Font Size</label>
|
||||
<div class="tag-cloud-fontsize-slider"></div>
|
||||
</div>
|
||||
<div>
|
||||
<label>
|
||||
<input type="checkbox" value="{{hideLabel}}" ng-model="vis.params.hideLabel" name="hideLabel"
|
||||
ng-checked="vis.params.hideLabel">
|
||||
Show Label
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
32
src/core_plugins/tagcloud/public/tag_cloud_vis_params.js
Normal file
32
src/core_plugins/tagcloud/public/tag_cloud_vis_params.js
Normal file
|
@ -0,0 +1,32 @@
|
|||
import uiModules from 'ui/modules';
|
||||
import tagCloudVisParamsTemplate from 'plugins/tagcloud/tag_cloud_vis_params.html';
|
||||
import noUiSlider from 'no-ui-slider';
|
||||
import 'no-ui-slider/css/nouislider.css';
|
||||
import 'no-ui-slider/css/nouislider.pips.css';
|
||||
import 'no-ui-slider/css/nouislider.tooltips.css';
|
||||
|
||||
uiModules.get('kibana/table_vis')
|
||||
.directive('tagcloudVisParams', function () {
|
||||
return {
|
||||
restrict: 'E',
|
||||
template: tagCloudVisParamsTemplate,
|
||||
link: function ($scope, $element) {
|
||||
const sliderContainer = $element[0];
|
||||
var slider = sliderContainer.querySelector('.tag-cloud-fontsize-slider');
|
||||
noUiSlider.create(slider, {
|
||||
start: [$scope.vis.params.minFontSize, $scope.vis.params.maxFontSize],
|
||||
connect: true,
|
||||
tooltips: true,
|
||||
step: 1,
|
||||
range: {'min': 1, 'max': 100},
|
||||
format: {to: (value) => parseInt(value) + 'px', from: value => parseInt(value)}
|
||||
});
|
||||
slider.noUiSlider.on('change', function () {
|
||||
const fontSize = slider.noUiSlider.get();
|
||||
$scope.vis.params.minFontSize = parseInt(fontSize[0], 10);
|
||||
$scope.vis.params.maxFontSize = parseInt(fontSize[1], 10);
|
||||
$scope.$apply();
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
|
@ -28,7 +28,6 @@ require('ui/saved_objects/saved_object_registry').register(require('plugins/time
|
|||
|
||||
// TODO: Expose an api for dismissing notifications
|
||||
var unsafeNotifications = require('ui/notify')._notifs;
|
||||
//var ConfigTemplate = require('ui/config_template');
|
||||
|
||||
require('ui/routes').enable();
|
||||
|
||||
|
@ -47,7 +46,8 @@ require('ui/routes')
|
|||
});
|
||||
|
||||
app.controller('timelion', function (
|
||||
$scope, $http, timefilter, AppState, courier, $route, $routeParams, kbnUrl, Notifier, config, $timeout, Private, savedVisualizations) {
|
||||
$scope, $http, timefilter, AppState, courier, $route, $routeParams,
|
||||
kbnUrl, Notifier, config, $timeout, Private, savedVisualizations, safeConfirm) {
|
||||
|
||||
// TODO: For some reason the Kibana core doesn't correctly do this for all apps.
|
||||
moment.tz.setDefault(config.get('dateFormat:tz'));
|
||||
|
@ -62,32 +62,52 @@ app.controller('timelion', function (
|
|||
|
||||
var defaultExpression = '.es(*)';
|
||||
var savedSheet = $route.current.locals.savedSheet;
|
||||
var blankSheet = [defaultExpression];
|
||||
|
||||
$scope.topNavMenu = [{
|
||||
key: 'new',
|
||||
description: 'New Sheet',
|
||||
run: function () { kbnUrl.change('/'); }
|
||||
run: function () { kbnUrl.change('/'); },
|
||||
testId: 'timelionNewButton',
|
||||
}, {
|
||||
key: 'add',
|
||||
description: 'Add a chart',
|
||||
run: function () { $scope.newCell(); }
|
||||
run: function () { $scope.newCell(); },
|
||||
testId: 'timelionAddChartButton',
|
||||
}, {
|
||||
key: 'save',
|
||||
description: 'Save Sheet',
|
||||
template: require('plugins/timelion/partials/save_sheet.html')
|
||||
template: require('plugins/timelion/partials/save_sheet.html'),
|
||||
testId: 'timelionSaveButton',
|
||||
}, {
|
||||
key: 'delete',
|
||||
description: 'Delete current sheet',
|
||||
disableButton: function () {
|
||||
return !savedSheet.id;
|
||||
},
|
||||
run: function () {
|
||||
var title = savedSheet.title;
|
||||
safeConfirm('Are you sure you want to delete the sheet ' + title + ' ?').then(function () {
|
||||
savedSheet.delete().then(() => {
|
||||
notify.info('Deleted ' + title);
|
||||
kbnUrl.change('/');
|
||||
}).catch(notify.fatal);
|
||||
});},
|
||||
testId: 'timelionDeleteButton',
|
||||
}, {
|
||||
key: 'open',
|
||||
description: 'Load Sheet',
|
||||
template: require('plugins/timelion/partials/load_sheet.html')
|
||||
description: 'Open Sheet',
|
||||
template: require('plugins/timelion/partials/load_sheet.html'),
|
||||
testId: 'timelionOpenButton',
|
||||
}, {
|
||||
key: 'options',
|
||||
description: 'Options',
|
||||
template: require('plugins/timelion/partials/sheet_options.html')
|
||||
template: require('plugins/timelion/partials/sheet_options.html'),
|
||||
testId: 'timelionOptionsButton',
|
||||
}, {
|
||||
key: 'docs',
|
||||
description: 'Documentation',
|
||||
template: '<timelion-docs></timelion-docs>'
|
||||
template: '<timelion-docs></timelion-docs>',
|
||||
testId: 'timelionDocsButton',
|
||||
}];
|
||||
|
||||
|
||||
|
@ -143,7 +163,7 @@ app.controller('timelion', function (
|
|||
}
|
||||
});
|
||||
|
||||
$scope.$watch(function () { return savedSheet.title; }, function (newTitle) {
|
||||
$scope.$watch(function () { return savedSheet.lastSavedTitle; }, function (newTitle) {
|
||||
docTitle.change(savedSheet.id ? newTitle : undefined);
|
||||
});
|
||||
|
||||
|
@ -202,13 +222,11 @@ app.controller('timelion', function (
|
|||
$scope.safeSearch = _.debounce($scope.search, 500);
|
||||
|
||||
function saveSheet() {
|
||||
savedSheet.id = savedSheet.title;
|
||||
savedSheet.timelion_sheet = $scope.state.sheet;
|
||||
savedSheet.timelion_interval = $scope.state.interval;
|
||||
savedSheet.timelion_columns = $scope.state.columns;
|
||||
savedSheet.timelion_rows = $scope.state.rows;
|
||||
savedSheet.save().then(function (id) {
|
||||
//$scope.configTemplate.close('save');
|
||||
if (id) {
|
||||
notify.info('Saved sheet as "' + savedSheet.title + '"');
|
||||
if (savedSheet.id !== $routeParams.id) {
|
||||
|
@ -220,7 +238,6 @@ app.controller('timelion', function (
|
|||
|
||||
function saveExpression(title) {
|
||||
savedVisualizations.get({type: 'timelion'}).then(function (savedExpression) {
|
||||
savedExpression.id = title;
|
||||
savedExpression.visState.params = {
|
||||
expression: $scope.state.sheet[$scope.state.selected],
|
||||
interval: $scope.state.interval
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
<div data-transclude-slots>
|
||||
<div data-transclude-slot="topLeftCorner">
|
||||
<span class="localTitle" ng-show="opts.savedSheet.id">
|
||||
{{opts.savedSheet.title}}
|
||||
{{opts.savedSheet.lastSavedTitle}}
|
||||
|
||||
<span class="fa fa-bolt" ng-click="showStats = !showStats"></span>
|
||||
|
||||
|
|
|
@ -217,6 +217,7 @@ module.exports = function timechartFn(Private, config, $rootScope, timefilter, $
|
|||
// This is kind of gross, it means that you can't replace a global value with a null
|
||||
// best you can do is an empty string. Deal with it.
|
||||
if (objVal == null) return srcVal;
|
||||
if (srcVal == null) return objVal;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -14,6 +14,8 @@
|
|||
<label for="savedSheet" class="control-label">Save sheet as</label>
|
||||
<input id="savedSheet" ng-model="opts.savedSheet.title" input-focus="select" class="form-control" placeholder="Name this sheet...">
|
||||
</div>
|
||||
|
||||
<saved-object-save-as-check-box saved-object="opts.savedSheet"></saved-object-save-as-check-box>
|
||||
<div class="form-group">
|
||||
<button ng-disabled="!opts.savedSheet.title" type="submit" class="btn btn-primary">
|
||||
Save
|
||||
|
@ -43,9 +45,7 @@
|
|||
<input id="savedExpression" ng-model="panelTitle" input-focus="select" class="form-control" placeholder="Name this panel">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<button ng-disabled="!panelTitle" type="submit" class="btn btn-primary">
|
||||
Save
|
||||
</button>
|
||||
<button ng-disabled="!panelTitle" type="submit" class="btn btn-primary">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
@ -38,12 +38,12 @@ module.factory('SavedSheet', function (courier, config) {
|
|||
|
||||
// if type:sheet has no mapping, we push this mapping into ES
|
||||
SavedSheet.mapping = {
|
||||
title: 'string',
|
||||
title: 'text',
|
||||
hits: 'integer',
|
||||
description: 'string',
|
||||
timelion_sheet: 'string',
|
||||
timelion_interval: 'string',
|
||||
timelion_other_interval: 'string',
|
||||
description: 'text',
|
||||
timelion_sheet: 'keyword',
|
||||
timelion_interval: 'keyword',
|
||||
timelion_other_interval: 'keyword',
|
||||
timelion_chart_height: 'integer',
|
||||
timelion_columns: 'integer',
|
||||
timelion_rows: 'integer',
|
||||
|
|
|
@ -6,7 +6,16 @@ define(function (require) {
|
|||
|
||||
var _ = require('lodash');
|
||||
var module = require('ui/modules').get('kibana/timelion_vis', ['kibana']);
|
||||
module.controller('TimelionVisController', function ($scope, Private, Notifier, $http, $rootScope, timefilter, getAppState) {
|
||||
module.controller('TimelionVisController', function (
|
||||
$scope,
|
||||
$element,
|
||||
Private,
|
||||
Notifier,
|
||||
$http,
|
||||
$rootScope,
|
||||
timefilter,
|
||||
getAppState
|
||||
) {
|
||||
var queryFilter = Private(require('ui/filter_bar/query_filter'));
|
||||
var timezone = Private(require('plugins/timelion/services/timezone'))();
|
||||
var dashboardContext = Private(require('plugins/timelion/services/dashboard_context'));
|
||||
|
@ -61,7 +70,7 @@ define(function (require) {
|
|||
|
||||
$scope.$on('renderComplete', event => {
|
||||
event.stopPropagation();
|
||||
$scope.vis.emit('renderComplete');
|
||||
$element.trigger('renderComplete');
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
@ -10,56 +10,43 @@ function replyWithError(e, reply) {
|
|||
|
||||
|
||||
module.exports = (server) => {
|
||||
|
||||
server.route({
|
||||
method: ['POST', 'GET'],
|
||||
path: '/api/timelion/run',
|
||||
handler: (request, reply) => {
|
||||
handler: async (request, reply) => {
|
||||
try {
|
||||
const uiSettings = await server.uiSettings().getAll(request);
|
||||
|
||||
// I don't really like this, but we need to get all of the settings
|
||||
// before every request. This just sucks because its going to slow things
|
||||
// down. Meh.
|
||||
return server.uiSettings().getAll().then((uiSettings) => {
|
||||
var sheet;
|
||||
var tlConfig = require('../handlers/lib/tl_config.js')({
|
||||
server: server,
|
||||
request: request,
|
||||
const tlConfig = require('../handlers/lib/tl_config.js')({
|
||||
server,
|
||||
request,
|
||||
settings: _.defaults(uiSettings, timelionDefaults) // Just in case they delete some setting.
|
||||
});
|
||||
var chainRunner = chainRunnerFn(tlConfig);
|
||||
|
||||
try {
|
||||
sheet = chainRunner.processRequest(request.payload || {
|
||||
sheet: [request.query.expression],
|
||||
time: {
|
||||
from: request.query.from,
|
||||
to: request.query.to,
|
||||
interval: request.query.interval,
|
||||
timezone: request.query.timezone
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
replyWithError(e, reply);
|
||||
return;
|
||||
}
|
||||
|
||||
return Promise.all(sheet).then((sheet) => {
|
||||
var response = {
|
||||
sheet: sheet,
|
||||
stats: chainRunner.getStats()
|
||||
};
|
||||
reply(response);
|
||||
}).catch((e) => {
|
||||
// TODO Maybe we should just replace everywhere we throw with Boom? Probably.
|
||||
if (e.isBoom) {
|
||||
reply(e);
|
||||
} else {
|
||||
replyWithError(e, reply);
|
||||
const chainRunner = chainRunnerFn(tlConfig);
|
||||
const sheet = await Promise.all(chainRunner.processRequest(request.payload || {
|
||||
sheet: [request.query.expression],
|
||||
time: {
|
||||
from: request.query.from,
|
||||
to: request.query.to,
|
||||
interval: request.query.interval,
|
||||
timezone: request.query.timezone
|
||||
}
|
||||
}));
|
||||
|
||||
reply({
|
||||
sheet,
|
||||
stats: chainRunner.getStats()
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
} catch (err) {
|
||||
// TODO Maybe we should just replace everywhere we throw with Boom? Probably.
|
||||
if (err.isBoom) {
|
||||
reply(err);
|
||||
} else {
|
||||
replyWithError(err, reply);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@ module.exports = function (server) {
|
|||
path: '/api/timelion/validate/es',
|
||||
handler: function (request, reply) {
|
||||
|
||||
return server.uiSettings().getAll().then((uiSettings) => {
|
||||
return server.uiSettings().getAll(request).then((uiSettings) => {
|
||||
var callWithRequest = server.plugins.elasticsearch.callWithRequest;
|
||||
|
||||
var timefield = uiSettings['timelion:es.timefield'];
|
||||
|
|
32
src/server/http/__tests__/short_url_error.js
Normal file
32
src/server/http/__tests__/short_url_error.js
Normal file
|
@ -0,0 +1,32 @@
|
|||
import Boom from 'boom';
|
||||
import expect from 'expect.js';
|
||||
import _ from 'lodash';
|
||||
import { handleShortUrlError } from '../short_url_error';
|
||||
|
||||
describe('handleShortUrlError()', () => {
|
||||
const caughtErrors = [{
|
||||
status: 401
|
||||
}, {
|
||||
status: 403
|
||||
}, {
|
||||
status: 404
|
||||
}];
|
||||
|
||||
const uncaughtErrors = [{
|
||||
status: null
|
||||
}, {
|
||||
status: 500
|
||||
}];
|
||||
|
||||
caughtErrors.forEach((err) => {
|
||||
it(`should handle ${err.status} errors`, function () {
|
||||
expect(_.get(handleShortUrlError(err), 'output.statusCode')).to.be(err.status);
|
||||
});
|
||||
});
|
||||
|
||||
uncaughtErrors.forEach((err) => {
|
||||
it(`should not handle unknown errors`, function () {
|
||||
expect(_.get(handleShortUrlError(err), 'output.statusCode')).to.be(500);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -6,6 +6,7 @@ import Boom from 'boom';
|
|||
import Hapi from 'hapi';
|
||||
import getDefaultRoute from './get_default_route';
|
||||
import versionCheckMixin from './version_check';
|
||||
import { handleShortUrlError } from './short_url_error';
|
||||
import { shortUrlAssertValid } from './short_url_assert_valid';
|
||||
|
||||
module.exports = async function (kbnServer, server, config) {
|
||||
|
@ -114,11 +115,11 @@ module.exports = async function (kbnServer, server, config) {
|
|||
path: '/goto/{urlId}',
|
||||
handler: async function (request, reply) {
|
||||
try {
|
||||
const url = await shortUrlLookup.getUrl(request.params.urlId);
|
||||
const url = await shortUrlLookup.getUrl(request.params.urlId, request);
|
||||
shortUrlAssertValid(url);
|
||||
reply().redirect(config.get('server.basePath') + url);
|
||||
} catch (err) {
|
||||
reply(err);
|
||||
reply(handleShortUrlError(err));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -129,10 +130,10 @@ module.exports = async function (kbnServer, server, config) {
|
|||
handler: async function (request, reply) {
|
||||
try {
|
||||
shortUrlAssertValid(request.payload.url);
|
||||
const urlId = await shortUrlLookup.generateUrlId(request.payload.url);
|
||||
const urlId = await shortUrlLookup.generateUrlId(request.payload.url, request);
|
||||
reply(urlId);
|
||||
} catch (err) {
|
||||
reply(err);
|
||||
reply(handleShortUrlError(err));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
9
src/server/http/short_url_error.js
Normal file
9
src/server/http/short_url_error.js
Normal file
|
@ -0,0 +1,9 @@
|
|||
import Boom from 'boom';
|
||||
|
||||
export function handleShortUrlError(err) {
|
||||
if (err.isBoom) return err;
|
||||
if (err.status === 401) return Boom.unauthorized();
|
||||
if (err.status === 403) return Boom.forbidden();
|
||||
if (err.status === 404) return Boom.notFound();
|
||||
return Boom.badImplementation();
|
||||
}
|
|
@ -1,12 +1,12 @@
|
|||
import crypto from 'crypto';
|
||||
|
||||
export default function (server) {
|
||||
async function updateMetadata(urlId, urlDoc) {
|
||||
const client = server.plugins.elasticsearch.client;
|
||||
async function updateMetadata(urlId, urlDoc, req) {
|
||||
const callWithRequest = server.plugins.elasticsearch.callWithRequest;
|
||||
const kibanaIndex = server.config().get('kibana.index');
|
||||
|
||||
try {
|
||||
await client.update({
|
||||
await callWithRequest(req, 'update', {
|
||||
index: kibanaIndex,
|
||||
type: 'url',
|
||||
id: urlId,
|
||||
|
@ -23,12 +23,12 @@ export default function (server) {
|
|||
}
|
||||
}
|
||||
|
||||
async function getUrlDoc(urlId) {
|
||||
async function getUrlDoc(urlId, req) {
|
||||
const urlDoc = await new Promise((resolve, reject) => {
|
||||
const client = server.plugins.elasticsearch.client;
|
||||
const callWithRequest = server.plugins.elasticsearch.callWithRequest;
|
||||
const kibanaIndex = server.config().get('kibana.index');
|
||||
|
||||
client.get({
|
||||
callWithRequest(req, 'get', {
|
||||
index: kibanaIndex,
|
||||
type: 'url',
|
||||
id: urlId
|
||||
|
@ -44,12 +44,12 @@ export default function (server) {
|
|||
return urlDoc;
|
||||
}
|
||||
|
||||
async function createUrlDoc(url, urlId) {
|
||||
async function createUrlDoc(url, urlId, req) {
|
||||
const newUrlId = await new Promise((resolve, reject) => {
|
||||
const client = server.plugins.elasticsearch.client;
|
||||
const callWithRequest = server.plugins.elasticsearch.callWithRequest;
|
||||
const kibanaIndex = server.config().get('kibana.index');
|
||||
|
||||
client.index({
|
||||
callWithRequest(req, 'index', {
|
||||
index: kibanaIndex,
|
||||
type: 'url',
|
||||
id: urlId,
|
||||
|
@ -80,19 +80,19 @@ export default function (server) {
|
|||
}
|
||||
|
||||
return {
|
||||
async generateUrlId(url) {
|
||||
async generateUrlId(url, req) {
|
||||
const urlId = createUrlId(url);
|
||||
const urlDoc = await getUrlDoc(urlId);
|
||||
const urlDoc = await getUrlDoc(urlId, req);
|
||||
if (urlDoc) return urlId;
|
||||
|
||||
return createUrlDoc(url, urlId);
|
||||
return createUrlDoc(url, urlId, req);
|
||||
},
|
||||
async getUrl(urlId) {
|
||||
async getUrl(urlId, req) {
|
||||
try {
|
||||
const urlDoc = await getUrlDoc(urlId);
|
||||
const urlDoc = await getUrlDoc(urlId, req);
|
||||
if (!urlDoc) throw new Error('Requested shortened url does not exist in kibana index');
|
||||
|
||||
updateMetadata(urlId, urlDoc);
|
||||
updateMetadata(urlId, urlDoc, req);
|
||||
|
||||
return urlDoc._source.url;
|
||||
} catch (err) {
|
||||
|
|
|
@ -18,6 +18,9 @@ export default function (kbnServer, server, config) {
|
|||
handler: function (request, reply) {
|
||||
return reply({
|
||||
name: config.get('server.name'),
|
||||
version: config.get('pkg.version'),
|
||||
buildNum: config.get('pkg.buildNum'),
|
||||
buildSha: config.get('pkg.buildSha'),
|
||||
uuid: config.get('server.uuid'),
|
||||
status: kbnServer.status.toJSON(),
|
||||
metrics: kbnServer.metrics
|
||||
|
|
|
@ -45,7 +45,7 @@ exports.all = [
|
|||
severity: -1,
|
||||
icon: 'toggle-off',
|
||||
nicknames: [
|
||||
'I\'m I even a thing?'
|
||||
'Am I even a thing?'
|
||||
]
|
||||
}
|
||||
];
|
||||
|
|
17
src/test_utils/stub_mapper.js
Normal file
17
src/test_utils/stub_mapper.js
Normal file
|
@ -0,0 +1,17 @@
|
|||
import MapperService from 'ui/index_patterns/_mapper';
|
||||
import stubbedLogstashFields from 'fixtures/logstash_fields';
|
||||
import sinon from 'auto-release-sinon';
|
||||
|
||||
export function stubMapper(Private, mockLogstashFields = Private(stubbedLogstashFields)) {
|
||||
let stubbedMapper = Private(MapperService);
|
||||
|
||||
sinon.stub(stubbedMapper, 'getFieldsForIndexPattern', function () {
|
||||
return Promise.resolve(mockLogstashFields.filter(field => field.scripted === false));
|
||||
});
|
||||
|
||||
sinon.stub(stubbedMapper, 'clearCache', function () {
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
return stubbedMapper;
|
||||
}
|
13
src/ui/__tests__/fixtures/test_app/index.js
Normal file
13
src/ui/__tests__/fixtures/test_app/index.js
Normal file
|
@ -0,0 +1,13 @@
|
|||
module.exports = kibana => new kibana.Plugin({
|
||||
uiExports: {
|
||||
app: {
|
||||
name: 'test_app',
|
||||
main: 'plugins/test_app/index.js',
|
||||
injectVars() {
|
||||
return {
|
||||
from_test_app: true
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
4
src/ui/__tests__/fixtures/test_app/package.json
Normal file
4
src/ui/__tests__/fixtures/test_app/package.json
Normal file
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"name": "test_app",
|
||||
"version": "kibana"
|
||||
}
|
0
src/ui/__tests__/fixtures/test_app/public/index.js
Normal file
0
src/ui/__tests__/fixtures/test_app/public/index.js
Normal file
125
src/ui/__tests__/ui_exports_replace_injected_vars.js
Normal file
125
src/ui/__tests__/ui_exports_replace_injected_vars.js
Normal file
|
@ -0,0 +1,125 @@
|
|||
import { resolve } from 'path';
|
||||
|
||||
import { delay } from 'bluebird';
|
||||
import expect from 'expect.js';
|
||||
import sinon from 'sinon';
|
||||
import cheerio from 'cheerio';
|
||||
import { noop } from 'lodash';
|
||||
|
||||
import KbnServer from '../../server/kbn_server';
|
||||
|
||||
const getInjectedVarsFromResponse = (resp) => {
|
||||
const $ = cheerio.load(resp.payload);
|
||||
const data = $('kbn-initial-state').attr('data');
|
||||
return JSON.parse(data).vars;
|
||||
};
|
||||
|
||||
const injectReplacer = (kbnServer, replacer) => {
|
||||
// normally the replacer would be defined in a plugin's uiExports,
|
||||
// but that requires stubbing out an entire plugin directory for
|
||||
// each test, so we fake it and jam the replacer into uiExports
|
||||
kbnServer.uiExports.injectedVarsReplacers.push(replacer);
|
||||
};
|
||||
|
||||
describe('UiExports', function () {
|
||||
describe('#replaceInjectedVars', function () {
|
||||
this.slow(2000);
|
||||
this.timeout(10000);
|
||||
|
||||
let kbnServer;
|
||||
beforeEach(async () => {
|
||||
kbnServer = new KbnServer({
|
||||
server: { port: 0 }, // pick a random open port
|
||||
logging: { silent: true }, // no logs
|
||||
optimize: { enabled: false },
|
||||
uiSettings: { enabled: false },
|
||||
plugins: {
|
||||
paths: [resolve(__dirname, './fixtures/test_app')] // inject an app so we can hit /app/{id}
|
||||
},
|
||||
});
|
||||
|
||||
await kbnServer.ready();
|
||||
kbnServer.status.get('ui settings').state = 'green';
|
||||
kbnServer.server.decorate('server', 'uiSettings', () => {
|
||||
return { getDefaults: noop, getUserProvided: noop };
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await kbnServer.close();
|
||||
kbnServer = null;
|
||||
});
|
||||
|
||||
it('allows sync replacing of injected vars', async () => {
|
||||
injectReplacer(kbnServer, () => ({ a: 1 }));
|
||||
|
||||
const resp = await kbnServer.inject('/app/test_app');
|
||||
const injectedVars = getInjectedVarsFromResponse(resp);
|
||||
|
||||
expect(injectedVars).to.eql({ a: 1 });
|
||||
});
|
||||
|
||||
it('allows async replacing of injected vars', async () => {
|
||||
const asyncThing = () => delay(100).return('world');
|
||||
|
||||
injectReplacer(kbnServer, async () => {
|
||||
return {
|
||||
hello: await asyncThing()
|
||||
};
|
||||
});
|
||||
|
||||
const resp = await kbnServer.inject('/app/test_app');
|
||||
const injectedVars = getInjectedVarsFromResponse(resp);
|
||||
|
||||
expect(injectedVars).to.eql({
|
||||
hello: 'world'
|
||||
});
|
||||
});
|
||||
|
||||
it('passes originalInjectedVars, request, and server to replacer', async () => {
|
||||
const stub = sinon.stub();
|
||||
injectReplacer(kbnServer, () => ({ foo: 'bar' }));
|
||||
injectReplacer(kbnServer, stub);
|
||||
|
||||
await kbnServer.inject('/app/test_app');
|
||||
|
||||
sinon.assert.calledOnce(stub);
|
||||
expect(stub.firstCall.args[0]).to.eql({ foo: 'bar' }); // originalInjectedVars
|
||||
expect(stub.firstCall.args[1]).to.have.property('path', '/app/test_app'); // request
|
||||
expect(stub.firstCall.args[1]).to.have.property('server', kbnServer.server); // request
|
||||
expect(stub.firstCall.args[2]).to.be(kbnServer.server);
|
||||
});
|
||||
|
||||
it('calls the methods sequentially', async () => {
|
||||
injectReplacer(kbnServer, () => ({ name: '' }));
|
||||
injectReplacer(kbnServer, orig => ({ name: orig.name + 's' }));
|
||||
injectReplacer(kbnServer, orig => ({ name: orig.name + 'a' }));
|
||||
injectReplacer(kbnServer, orig => ({ name: orig.name + 'm' }));
|
||||
|
||||
const resp = await kbnServer.inject('/app/test_app');
|
||||
const injectedVars = getInjectedVarsFromResponse(resp);
|
||||
|
||||
expect(injectedVars).to.eql({ name: 'sam' });
|
||||
});
|
||||
|
||||
it('propogates errors thrown in replacers', async () => {
|
||||
injectReplacer(kbnServer, async () => {
|
||||
await delay(100);
|
||||
throw new Error('replacer failed');
|
||||
});
|
||||
|
||||
const resp = await kbnServer.inject('/app/test_app');
|
||||
expect(resp).to.have.property('statusCode', 500);
|
||||
});
|
||||
|
||||
it('starts off with the injected vars for the app merged with the default injected vars', async () => {
|
||||
const stub = sinon.stub();
|
||||
injectReplacer(kbnServer, stub);
|
||||
kbnServer.uiExports.defaultInjectedVars.from_defaults = true;
|
||||
|
||||
const resp = await kbnServer.inject('/app/test_app');
|
||||
sinon.assert.calledOnce(stub);
|
||||
expect(stub.firstCall.args[0]).to.eql({ from_defaults: true, from_test_app: true });
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,7 +1,9 @@
|
|||
import { format as formatUrl } from 'url';
|
||||
import { readFileSync as readFile } from 'fs';
|
||||
import { defaults } from 'lodash';
|
||||
import { props } from 'bluebird';
|
||||
import Boom from 'boom';
|
||||
import { reduce as reduceAsync } from 'bluebird';
|
||||
import { resolve } from 'path';
|
||||
import fromRoot from '../utils/from_root';
|
||||
import UiExports from './ui_exports';
|
||||
|
@ -43,20 +45,26 @@ export default async (kbnServer, server, config) => {
|
|||
server.route({
|
||||
path: '/app/{id}',
|
||||
method: 'GET',
|
||||
handler: function (req, reply) {
|
||||
async handler(req, reply) {
|
||||
const id = req.params.id;
|
||||
const app = uiExports.apps.byId[id];
|
||||
if (!app) return reply(Boom.notFound('Unknown app ' + id));
|
||||
|
||||
if (kbnServer.status.isGreen()) {
|
||||
return reply.renderApp(app);
|
||||
} else {
|
||||
return reply.renderStatusPage();
|
||||
try {
|
||||
if (kbnServer.status.isGreen()) {
|
||||
await reply.renderApp(app);
|
||||
} else {
|
||||
await reply.renderStatusPage();
|
||||
}
|
||||
} catch (err) {
|
||||
reply(Boom.wrap(err));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function getPayload(app) {
|
||||
async function getKibanaPayload({ app, request, includeUserProvidedConfig }) {
|
||||
const uiSettings = server.uiSettings();
|
||||
|
||||
return {
|
||||
app: app,
|
||||
nav: uiExports.navLinks.inOrder,
|
||||
|
@ -66,36 +74,47 @@ export default async (kbnServer, server, config) => {
|
|||
basePath: config.get('server.basePath'),
|
||||
serverName: config.get('server.name'),
|
||||
devMode: config.get('env.dev'),
|
||||
uiSettings: {
|
||||
defaults: await server.uiSettings().getDefaults(),
|
||||
user: {}
|
||||
},
|
||||
vars: defaults(app.getInjectedVars() || {}, uiExports.defaultInjectedVars),
|
||||
uiSettings: await props({
|
||||
defaults: uiSettings.getDefaults(),
|
||||
user: includeUserProvidedConfig && uiSettings.getUserProvided(request)
|
||||
}),
|
||||
vars: await reduceAsync(
|
||||
uiExports.injectedVarsReplacers,
|
||||
async (acc, replacer) => await replacer(acc, request, server),
|
||||
defaults(await app.getInjectedVars() || {}, uiExports.defaultInjectedVars)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function viewAppWithPayload(app, payload) {
|
||||
return this.view(app.templateName, {
|
||||
app: app,
|
||||
kibanaPayload: payload,
|
||||
bundlePath: `${config.get('server.basePath')}/bundles`,
|
||||
});
|
||||
}
|
||||
|
||||
async function renderApp(app) {
|
||||
const isElasticsearchPluginRed = server.plugins.elasticsearch.status.state === 'red';
|
||||
const payload = await getPayload(app);
|
||||
if (!isElasticsearchPluginRed) {
|
||||
payload.uiSettings.user = await server.uiSettings().getUserProvided();
|
||||
async function renderApp({ app, reply, includeUserProvidedConfig = true }) {
|
||||
try {
|
||||
return reply.view(app.templateName, {
|
||||
app,
|
||||
kibanaPayload: await getKibanaPayload({
|
||||
app,
|
||||
request: reply.request,
|
||||
includeUserProvidedConfig
|
||||
}),
|
||||
bundlePath: `${config.get('server.basePath')}/bundles`,
|
||||
});
|
||||
} catch (err) {
|
||||
reply(err);
|
||||
}
|
||||
return viewAppWithPayload.call(this, app, payload);
|
||||
}
|
||||
|
||||
async function renderAppWithDefaultConfig(app) {
|
||||
const payload = await getPayload(app);
|
||||
return viewAppWithPayload.call(this, app, payload);
|
||||
}
|
||||
server.decorate('reply', 'renderApp', function (app) {
|
||||
return renderApp({
|
||||
app,
|
||||
reply: this,
|
||||
includeUserProvidedConfig: true,
|
||||
});
|
||||
});
|
||||
|
||||
server.decorate('reply', 'renderApp', renderApp);
|
||||
server.decorate('reply', 'renderAppWithDefaultConfig', renderAppWithDefaultConfig);
|
||||
server.decorate('reply', 'renderAppWithDefaultConfig', function (app) {
|
||||
return renderApp({
|
||||
app,
|
||||
reply: this,
|
||||
includeUserProvidedConfig: false,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
|
|
@ -25,6 +25,8 @@ describe('initXAxis', function () {
|
|||
}
|
||||
}
|
||||
};
|
||||
const field = {};
|
||||
const indexPattern = {};
|
||||
|
||||
it('sets the xAxisFormatter if the agg is not ordered', function () {
|
||||
let chart = _.cloneDeep(baseChart);
|
||||
|
@ -37,11 +39,19 @@ describe('initXAxis', function () {
|
|||
it('makes the chart ordered if the agg is ordered', function () {
|
||||
let chart = _.cloneDeep(baseChart);
|
||||
chart.aspects.x.agg.type.ordered = true;
|
||||
chart.aspects.x.agg.params = {
|
||||
field: field
|
||||
};
|
||||
chart.aspects.x.agg.vis = {
|
||||
indexPattern: indexPattern
|
||||
};
|
||||
|
||||
initXAxis(chart);
|
||||
expect(chart)
|
||||
.to.have.property('xAxisLabel', 'label')
|
||||
.and.have.property('xAxisFormatter', chart.aspects.x.agg.fieldFormatter())
|
||||
.and.have.property('indexPattern', indexPattern)
|
||||
.and.have.property('xAxisField', field)
|
||||
.and.have.property('ordered');
|
||||
|
||||
expect(chart.ordered)
|
||||
|
@ -53,11 +63,19 @@ describe('initXAxis', function () {
|
|||
let chart = _.cloneDeep(baseChart);
|
||||
chart.aspects.x.agg.type.ordered = true;
|
||||
chart.aspects.x.agg.write = _.constant({ params: { interval: 10 } });
|
||||
chart.aspects.x.agg.params = {
|
||||
field: field
|
||||
};
|
||||
chart.aspects.x.agg.vis = {
|
||||
indexPattern: indexPattern
|
||||
};
|
||||
|
||||
initXAxis(chart);
|
||||
expect(chart)
|
||||
.to.have.property('xAxisLabel', 'label')
|
||||
.and.have.property('xAxisFormatter', chart.aspects.x.agg.fieldFormatter())
|
||||
.and.have.property('indexPattern', indexPattern)
|
||||
.and.have.property('xAxisField', field)
|
||||
.and.have.property('ordered');
|
||||
|
||||
expect(chart.ordered)
|
||||
|
|
|
@ -7,6 +7,9 @@ define(function () {
|
|||
|
||||
if (!x.agg || !x.agg.type.ordered) return;
|
||||
|
||||
chart.indexPattern = x.agg.vis.indexPattern;
|
||||
chart.xAxisField = x.agg.params.field;
|
||||
|
||||
chart.ordered = {};
|
||||
let xAggOutput = x.agg.write();
|
||||
if (xAggOutput.params.interval) {
|
||||
|
|
|
@ -1,10 +1,8 @@
|
|||
import _ from 'lodash';
|
||||
import AggTypesMetricsMetricAggTypeProvider from 'ui/agg_types/metrics/metric_agg_type';
|
||||
import AggTypesMetricsGetResponseAggConfigClassProvider from 'ui/agg_types/metrics/get_response_agg_config_class';
|
||||
import AggTypesMetricsPercentilesProvider from 'ui/agg_types/metrics/percentiles';
|
||||
export default function AggTypeMetricMedianProvider(Private) {
|
||||
|
||||
let MetricAggType = Private(AggTypesMetricsMetricAggTypeProvider);
|
||||
let getResponseAggConfigClass = Private(AggTypesMetricsGetResponseAggConfigClassProvider);
|
||||
let percentiles = Private(AggTypesMetricsPercentilesProvider);
|
||||
|
||||
return new MetricAggType({
|
||||
|
|
|
@ -33,3 +33,4 @@ import 'ui/typeahead';
|
|||
import 'ui/url';
|
||||
import 'ui/validate_date_interval';
|
||||
import 'ui/watch_multi';
|
||||
import 'ui/courier/saved_object/ui/saved_object_save_as_checkbox';
|
||||
|
|
|
@ -91,7 +91,12 @@ any custom setting configuration watchers for "${key}" may fix this issue.`);
|
|||
if (value === null) {
|
||||
delete settings[key].userValue;
|
||||
} else {
|
||||
settings[key].userValue = value;
|
||||
const { type } = settings[key];
|
||||
if (type === 'json' && typeof value !== 'string') {
|
||||
settings[key].userValue = angular.toJson(value);
|
||||
} else {
|
||||
settings[key].userValue = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
470
src/ui/public/courier/__tests__/saved_object.js
Normal file
470
src/ui/public/courier/__tests__/saved_object.js
Normal file
|
@ -0,0 +1,470 @@
|
|||
/**
|
||||
* Tests functionality in ui/public/courier/saved_object/saved_object.js
|
||||
*/
|
||||
|
||||
import ngMock from 'ng_mock';
|
||||
import expect from 'expect.js';
|
||||
import sinon from 'auto-release-sinon';
|
||||
import BluebirdPromise from 'bluebird';
|
||||
|
||||
import SavedObjectFactory from '../saved_object/saved_object';
|
||||
import IndexPatternFactory from 'ui/index_patterns/_index_pattern';
|
||||
import DocSourceProvider from '../data_source/doc_source';
|
||||
|
||||
import { stubMapper } from 'test_utils/stub_mapper';
|
||||
|
||||
|
||||
describe('Saved Object', function () {
|
||||
require('test_utils/no_digest_promises').activateForSuite();
|
||||
|
||||
let SavedObject;
|
||||
let IndexPattern;
|
||||
let esStub;
|
||||
let DocSource;
|
||||
|
||||
/**
|
||||
* Some default es stubbing to avoid timeouts and allow a default type of 'dashboard'.
|
||||
*/
|
||||
function mockEsService() {
|
||||
// Allows the type 'dashboard' to be used.
|
||||
// Unfortunately we need to use bluebird here instead of native promises because there is
|
||||
// a call to finally.
|
||||
sinon.stub(esStub.indices, 'getFieldMapping').returns(BluebirdPromise.resolve({
|
||||
'.kibana' : {
|
||||
'mappings': {
|
||||
'dashboard': {}
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// Necessary to avoid a timeout condition.
|
||||
sinon.stub(esStub.indices, 'putMapping').returns(BluebirdPromise.resolve());
|
||||
sinon.stub(esStub.indices, 'refresh').returns(BluebirdPromise.resolve());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a fake doc response with the given index and id, of type dashboard
|
||||
* that can be used to stub es calls.
|
||||
* @param indexPatternId
|
||||
* @param additionalOptions - object that will be assigned to the mocked doc response.
|
||||
* @returns {{_source: {}, _index: *, _type: string, _id: *, found: boolean}}
|
||||
*/
|
||||
function getMockedDocResponse(indexPatternId, additionalOptions = {}) {
|
||||
return Object.assign(
|
||||
{
|
||||
_source: {},
|
||||
_index: indexPatternId,
|
||||
_type: 'dashboard',
|
||||
_id: indexPatternId,
|
||||
found: true
|
||||
},
|
||||
additionalOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stubs some of the es retrieval calls so it returns the given response.
|
||||
* @param {Object} mockDocResponse
|
||||
*/
|
||||
function stubESResponse(mockDocResponse) {
|
||||
sinon.stub(esStub, 'mget').returns(BluebirdPromise.resolve({ docs: [mockDocResponse] }));
|
||||
sinon.stub(esStub, 'index').returns(BluebirdPromise.resolve(mockDocResponse));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new saved object with the given configuration and initializes it.
|
||||
* Returns the promise that will be completed when the initialization finishes.
|
||||
*
|
||||
* @param {Object} config
|
||||
* @returns {Promise<SavedObject>} A promise that resolves with an instance of
|
||||
* SavedObject
|
||||
*/
|
||||
function createInitializedSavedObject(config = {}) {
|
||||
let savedObject = new SavedObject(config);
|
||||
return savedObject.init();
|
||||
}
|
||||
|
||||
beforeEach(ngMock.module('kibana'));
|
||||
beforeEach(ngMock.inject(function (es, Private) {
|
||||
SavedObject = Private(SavedObjectFactory);
|
||||
IndexPattern = Private(IndexPatternFactory);
|
||||
esStub = es;
|
||||
DocSource = Private(DocSourceProvider);
|
||||
|
||||
mockEsService();
|
||||
stubMapper(Private);
|
||||
}));
|
||||
|
||||
describe('save', function () {
|
||||
describe(' with copyOnSave', function () {
|
||||
it('as true creates a copy on save success', function () {
|
||||
const mockDocResponse = getMockedDocResponse('myId');
|
||||
stubESResponse(mockDocResponse);
|
||||
let newUniqueId;
|
||||
return createInitializedSavedObject({type: 'dashboard', id: 'myId'}).then(savedObject => {
|
||||
sinon.stub(DocSource.prototype, 'doIndex', function () {
|
||||
newUniqueId = savedObject.id;
|
||||
expect(newUniqueId).to.not.be('myId');
|
||||
mockDocResponse._id = newUniqueId;
|
||||
return BluebirdPromise.resolve(newUniqueId);
|
||||
});
|
||||
savedObject.copyOnSave = true;
|
||||
return savedObject.save()
|
||||
.then((id) => {
|
||||
expect(id).to.be(newUniqueId);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('as true does not create a copy when save fails', function () {
|
||||
const mockDocResponse = getMockedDocResponse('myId');
|
||||
stubESResponse(mockDocResponse);
|
||||
let originalId = 'id1';
|
||||
return createInitializedSavedObject({type: 'dashboard', id: originalId}).then(savedObject => {
|
||||
sinon.stub(DocSource.prototype, 'doIndex', function () {
|
||||
return BluebirdPromise.reject('simulated error');
|
||||
});
|
||||
savedObject.copyOnSave = true;
|
||||
return savedObject.save().then(() => {
|
||||
throw new Error('Expected a rejection');
|
||||
}).catch(() => {
|
||||
expect(savedObject.id).to.be(originalId);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('as false does not create a copy', function () {
|
||||
const mockDocResponse = getMockedDocResponse('myId');
|
||||
stubESResponse(mockDocResponse);
|
||||
const id = 'myId';
|
||||
return createInitializedSavedObject({type: 'dashboard', id: id}).then(savedObject => {
|
||||
sinon.stub(DocSource.prototype, 'doIndex', function () {
|
||||
expect(savedObject.id).to.be(id);
|
||||
return BluebirdPromise.resolve(id);
|
||||
});
|
||||
savedObject.copyOnSave = false;
|
||||
return savedObject.save()
|
||||
.then((id) => {
|
||||
expect(id).to.be(id);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('returns id from server on success', function () {
|
||||
return createInitializedSavedObject({type: 'dashboard'}).then(savedObject => {
|
||||
const mockDocResponse = getMockedDocResponse('myId');
|
||||
stubESResponse(mockDocResponse);
|
||||
return savedObject.save()
|
||||
.then((id) => {
|
||||
expect(id).to.be('myId');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('updates isSaving variable', function () {
|
||||
it('on success', function () {
|
||||
let id = 'id';
|
||||
stubESResponse(getMockedDocResponse(id));
|
||||
return createInitializedSavedObject({type: 'dashboard', id: id}).then(savedObject => {
|
||||
sinon.stub(DocSource.prototype, 'doIndex', () => {
|
||||
expect(savedObject.isSaving).to.be(true);
|
||||
return BluebirdPromise.resolve(id);
|
||||
});
|
||||
expect(savedObject.isSaving).to.be(false);
|
||||
return savedObject.save()
|
||||
.then((id) => {
|
||||
expect(savedObject.isSaving).to.be(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('on failure', function () {
|
||||
return createInitializedSavedObject({type: 'dashboard'}).then(savedObject => {
|
||||
sinon.stub(DocSource.prototype, 'doIndex', () => {
|
||||
expect(savedObject.isSaving).to.be(true);
|
||||
return BluebirdPromise.reject();
|
||||
});
|
||||
expect(savedObject.isSaving).to.be(false);
|
||||
return savedObject.save()
|
||||
.catch(() => {
|
||||
expect(savedObject.isSaving).to.be(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyESResp', function () {
|
||||
it('throws error if not found', function () {
|
||||
return createInitializedSavedObject({ type: 'dashboard' }).then(savedObject => {
|
||||
const response = {found: false};
|
||||
try {
|
||||
savedObject.applyESResp(response);
|
||||
expect(true).to.be(false);
|
||||
} catch (err) {
|
||||
expect(!!err).to.be(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves original defaults if not overridden', function () {
|
||||
const id = 'anid';
|
||||
const preserveMeValue = 'here to stay!';
|
||||
const config = {
|
||||
defaults: {
|
||||
preserveMe: preserveMeValue
|
||||
},
|
||||
type: 'dashboard',
|
||||
id: id
|
||||
};
|
||||
|
||||
const mockDocResponse = getMockedDocResponse(id);
|
||||
stubESResponse(mockDocResponse);
|
||||
|
||||
const savedObject = new SavedObject(config);
|
||||
return savedObject.init()
|
||||
.then(() => {
|
||||
expect(savedObject._source.preserveMe).to.equal(preserveMeValue);
|
||||
const response = {found: true, _source: {}};
|
||||
return savedObject.applyESResp(response);
|
||||
}).then(() => {
|
||||
expect(savedObject._source.preserveMe).to.equal(preserveMeValue);
|
||||
});
|
||||
});
|
||||
|
||||
it('overrides defaults', function () {
|
||||
const id = 'anid';
|
||||
const config = {
|
||||
defaults: {
|
||||
flower: 'rose'
|
||||
},
|
||||
type: 'dashboard',
|
||||
id: id
|
||||
};
|
||||
|
||||
const mockDocResponse = getMockedDocResponse(id);
|
||||
stubESResponse(mockDocResponse);
|
||||
|
||||
const savedObject = new SavedObject(config);
|
||||
return savedObject.init()
|
||||
.then(() => {
|
||||
expect(savedObject._source.flower).to.equal('rose');
|
||||
const response = {
|
||||
found: true,
|
||||
_source: {
|
||||
flower: 'orchid'
|
||||
}
|
||||
};
|
||||
return savedObject.applyESResp(response);
|
||||
}).then(() => {
|
||||
expect(savedObject._source.flower).to.equal('orchid');
|
||||
});
|
||||
});
|
||||
|
||||
it('overrides previous _source and default values', function () {
|
||||
const id = 'anid';
|
||||
const config = {
|
||||
defaults: {
|
||||
dinosaurs: {
|
||||
tRex: 'is the scariest'
|
||||
}
|
||||
},
|
||||
type: 'dashboard',
|
||||
id: id
|
||||
};
|
||||
|
||||
const mockDocResponse = getMockedDocResponse(
|
||||
id,
|
||||
{ _source: { dinosaurs: { tRex: 'is not so bad'}, } });
|
||||
stubESResponse(mockDocResponse);
|
||||
|
||||
|
||||
let savedObject = new SavedObject(config);
|
||||
return savedObject.init()
|
||||
.then(() => {
|
||||
const response = {
|
||||
found: true,
|
||||
_source: { dinosaurs: { tRex: 'has big teeth' } }
|
||||
};
|
||||
|
||||
return savedObject.applyESResp(response);
|
||||
})
|
||||
.then(() => {
|
||||
expect(savedObject._source.dinosaurs.tRex).to.equal('has big teeth');
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe ('config', function () {
|
||||
|
||||
it('afterESResp is called', function () {
|
||||
const afterESRespCallback = sinon.spy();
|
||||
const config = {
|
||||
type: 'dashboard',
|
||||
afterESResp: afterESRespCallback
|
||||
};
|
||||
|
||||
return createInitializedSavedObject(config).then(() => {
|
||||
expect(afterESRespCallback.called).to.be(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('init is called', function () {
|
||||
const initCallback = sinon.spy();
|
||||
const config = {
|
||||
type: 'dashboard',
|
||||
init: initCallback
|
||||
};
|
||||
|
||||
return createInitializedSavedObject(config).then(() => {
|
||||
expect(initCallback.called).to.be(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchSource', function () {
|
||||
|
||||
it('when true, creates index', function () {
|
||||
const indexPatternId = 'testIndexPattern';
|
||||
const afterESRespCallback = sinon.spy();
|
||||
|
||||
const config = {
|
||||
type: 'dashboard',
|
||||
afterESResp: afterESRespCallback,
|
||||
searchSource: true,
|
||||
indexPattern: indexPatternId
|
||||
};
|
||||
|
||||
stubESResponse(getMockedDocResponse(indexPatternId));
|
||||
|
||||
const savedObject = new SavedObject(config);
|
||||
expect(!!savedObject.searchSource.get('index')).to.be(false);
|
||||
|
||||
return savedObject.init().then(() => {
|
||||
expect(afterESRespCallback.called).to.be(true);
|
||||
const index = savedObject.searchSource.get('index');
|
||||
expect(index instanceof IndexPattern).to.be(true);
|
||||
expect(index.id).to.equal(indexPatternId);
|
||||
});
|
||||
});
|
||||
|
||||
it('when false, does not create index', function () {
|
||||
const indexPatternId = 'testIndexPattern';
|
||||
const afterESRespCallback = sinon.spy();
|
||||
|
||||
const config = {
|
||||
type: 'dashboard',
|
||||
afterESResp: afterESRespCallback,
|
||||
searchSource: false,
|
||||
indexPattern: indexPatternId
|
||||
};
|
||||
|
||||
stubESResponse(getMockedDocResponse(indexPatternId));
|
||||
|
||||
const savedObject = new SavedObject(config);
|
||||
expect(!!savedObject.searchSource).to.be(false);
|
||||
|
||||
return savedObject.init().then(() => {
|
||||
expect(afterESRespCallback.called).to.be(true);
|
||||
expect(!!savedObject.searchSource).to.be(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('type', function () {
|
||||
it('that is not specified throws an error', function () {
|
||||
const config = {};
|
||||
|
||||
const savedObject = new SavedObject(config);
|
||||
try {
|
||||
savedObject.init();
|
||||
expect(false).to.be(true);
|
||||
} catch (err) {
|
||||
expect(err).to.not.be(null);
|
||||
}
|
||||
});
|
||||
|
||||
it('that is invalid invalid throws an error', function () {
|
||||
const config = { type: 'notypeexists' };
|
||||
|
||||
const savedObject = new SavedObject(config);
|
||||
try {
|
||||
savedObject.init();
|
||||
expect(false).to.be(true);
|
||||
} catch (err) {
|
||||
expect(err).to.not.be(null);
|
||||
}
|
||||
});
|
||||
|
||||
it('that is valid passes', function () {
|
||||
const config = { type: 'dashboard' };
|
||||
return new SavedObject(config).init();
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaults', function () {
|
||||
|
||||
function getTestDefaultConfig(extraOptions) {
|
||||
return Object.assign({
|
||||
defaults: { testDefault: 'hi' },
|
||||
type: 'dashboard'
|
||||
}, extraOptions);
|
||||
}
|
||||
|
||||
function expectDefaultApplied(config) {
|
||||
return createInitializedSavedObject(config).then((savedObject) => {
|
||||
expect(savedObject.defaults).to.be(config.defaults);
|
||||
});
|
||||
}
|
||||
|
||||
describe('applied to object when id', function () {
|
||||
|
||||
it('is not specified', function () {
|
||||
expectDefaultApplied(getTestDefaultConfig());
|
||||
});
|
||||
|
||||
it('is undefined', function () {
|
||||
expectDefaultApplied(getTestDefaultConfig({ id: undefined }));
|
||||
});
|
||||
|
||||
it('is 0', function () {
|
||||
expectDefaultApplied(getTestDefaultConfig({ id: 0 }));
|
||||
});
|
||||
|
||||
it('is false', function () {
|
||||
expectDefaultApplied(getTestDefaultConfig({ id: false }));
|
||||
});
|
||||
});
|
||||
|
||||
it('applied to source if an id is given', function () {
|
||||
const myId = 'myid';
|
||||
const customDefault = 'hi';
|
||||
const initialOverwriteMeValue = 'this should get overwritten by the server response';
|
||||
|
||||
const config = {
|
||||
defaults: {
|
||||
overwriteMe: initialOverwriteMeValue,
|
||||
customDefault: customDefault
|
||||
},
|
||||
type: 'dashboard',
|
||||
id: myId
|
||||
};
|
||||
|
||||
const serverValue = 'this should override the initial default value given';
|
||||
|
||||
const mockDocResponse = getMockedDocResponse(
|
||||
myId,
|
||||
{ _source: { overwriteMe: serverValue } });
|
||||
|
||||
stubESResponse(mockDocResponse);
|
||||
|
||||
return createInitializedSavedObject(config).then((savedObject) => {
|
||||
expect(!!savedObject._source).to.be(true);
|
||||
expect(savedObject.defaults).to.be(config.defaults);
|
||||
expect(savedObject._source.overwriteMe).to.be(serverValue);
|
||||
expect(savedObject._source.customDefault).to.be(customDefault);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -38,7 +38,7 @@ export default function normalizeSortRequest(config) {
|
|||
inline: indexField.script,
|
||||
lang: indexField.lang
|
||||
},
|
||||
type: indexField.type,
|
||||
type: castSortType(indexField.type),
|
||||
order: direction
|
||||
};
|
||||
} else {
|
||||
|
@ -56,3 +56,20 @@ export default function normalizeSortRequest(config) {
|
|||
return normalized;
|
||||
}
|
||||
};
|
||||
|
||||
// The ES API only supports sort scripts of type 'number' and 'string'
|
||||
function castSortType(type) {
|
||||
const typeCastings = {
|
||||
number: 'number',
|
||||
string: 'string',
|
||||
date: 'number',
|
||||
boolean: 'string'
|
||||
};
|
||||
|
||||
const castedType = typeCastings[type];
|
||||
if (!castedType) {
|
||||
throw new Error(`Unsupported script sort type: ${type}`);
|
||||
}
|
||||
|
||||
return castedType;
|
||||
}
|
||||
|
|
|
@ -13,7 +13,7 @@ import angular from 'angular';
|
|||
import _ from 'lodash';
|
||||
|
||||
import errors from 'ui/errors';
|
||||
import slugifyId from 'ui/utils/slugify_id';
|
||||
import uuid from 'node-uuid';
|
||||
import MappingSetupProvider from 'ui/utils/mapping_setup';
|
||||
|
||||
import DocSourceProvider from '../data_source/doc_source';
|
||||
|
@ -38,7 +38,19 @@ export default function SavedObjectFactory(es, kbnIndex, Promise, Private, Notif
|
|||
let docSource = new DocSource();
|
||||
|
||||
// type name for this object, used as the ES-type
|
||||
let type = config.type;
|
||||
const type = config.type;
|
||||
|
||||
self.getDisplayName = function () {
|
||||
return type;
|
||||
};
|
||||
|
||||
/**
|
||||
* Flips to true during a save operation, and back to false once the save operation
|
||||
* completes.
|
||||
* @type {boolean}
|
||||
*/
|
||||
self.isSaving = false;
|
||||
self.defaults = config.defaults || {};
|
||||
|
||||
// Create a notifier for sending alerts
|
||||
let notify = new Notifier({
|
||||
|
@ -48,18 +60,18 @@ export default function SavedObjectFactory(es, kbnIndex, Promise, Private, Notif
|
|||
// mapping definition for the fields that this object will expose
|
||||
let mapping = mappingSetup.expandShorthand(config.mapping);
|
||||
|
||||
// default field values, assigned when the source is loaded
|
||||
let defaults = config.defaults || {};
|
||||
|
||||
let afterESResp = config.afterESResp || _.noop;
|
||||
let customInit = config.init || _.noop;
|
||||
|
||||
// optional search source which this object configures
|
||||
self.searchSource = config.searchSource && new SearchSource();
|
||||
self.searchSource = config.searchSource ? new SearchSource() : undefined;
|
||||
|
||||
// the id of the document
|
||||
self.id = config.id || void 0;
|
||||
self.defaults = config.defaults;
|
||||
|
||||
// Whether to create a copy when the object is saved. This should eventually go away
|
||||
// in favor of a better rename/save flow.
|
||||
self.copyOnSave = false;
|
||||
|
||||
/**
|
||||
* Asynchronously initialize this object - will only run
|
||||
|
@ -74,9 +86,9 @@ export default function SavedObjectFactory(es, kbnIndex, Promise, Private, Notif
|
|||
|
||||
// tell the docSource where to find the doc
|
||||
docSource
|
||||
.index(kbnIndex)
|
||||
.type(type)
|
||||
.id(self.id);
|
||||
.index(kbnIndex)
|
||||
.type(type)
|
||||
.id(self.id);
|
||||
|
||||
// check that the mapping for this type is defined
|
||||
return mappingSetup.isDefined(type)
|
||||
|
@ -88,7 +100,7 @@ export default function SavedObjectFactory(es, kbnIndex, Promise, Private, Notif
|
|||
properties: {
|
||||
// setup the searchSource mapping, even if it is not used but this type yet
|
||||
searchSourceJSON: {
|
||||
type: 'string'
|
||||
type: 'keyword'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -100,15 +112,14 @@ export default function SavedObjectFactory(es, kbnIndex, Promise, Private, Notif
|
|||
// If there is not id, then there is no document to fetch from elasticsearch
|
||||
if (!self.id) {
|
||||
// just assign the defaults and be done
|
||||
_.assign(self, defaults);
|
||||
return hydrateIndexPattern().then(function () {
|
||||
_.assign(self, self.defaults);
|
||||
return hydrateIndexPattern().then(() => {
|
||||
return afterESResp.call(self);
|
||||
});
|
||||
}
|
||||
|
||||
// fetch the object from ES
|
||||
return docSource.fetch()
|
||||
.then(self.applyESResp);
|
||||
return docSource.fetch().then(self.applyESResp);
|
||||
})
|
||||
.then(function () {
|
||||
return customInit.call(self);
|
||||
|
@ -133,7 +144,7 @@ export default function SavedObjectFactory(es, kbnIndex, Promise, Private, Notif
|
|||
}
|
||||
|
||||
// assign the defaults to the response
|
||||
_.defaults(self._source, defaults);
|
||||
_.defaults(self._source, self.defaults);
|
||||
|
||||
// transform the source using _deserializers
|
||||
_.forOwn(mapping, function ittr(fieldMapping, fieldName) {
|
||||
|
@ -144,15 +155,16 @@ export default function SavedObjectFactory(es, kbnIndex, Promise, Private, Notif
|
|||
|
||||
// Give obj all of the values in _source.fields
|
||||
_.assign(self, self._source);
|
||||
self.lastSavedTitle = self.title;
|
||||
|
||||
return Promise.try(function () {
|
||||
return Promise.try(() => {
|
||||
parseSearchSource(meta.searchSourceJSON);
|
||||
return hydrateIndexPattern();
|
||||
})
|
||||
.then(hydrateIndexPattern)
|
||||
.then(function () {
|
||||
.then(() => {
|
||||
return Promise.cast(afterESResp.call(self, resp));
|
||||
})
|
||||
.then(function () {
|
||||
.then(() => {
|
||||
// Any time obj is updated, re-call applyESResp
|
||||
docSource.onUpdate().then(self.applyESResp, notify.fatal);
|
||||
});
|
||||
|
@ -181,28 +193,31 @@ export default function SavedObjectFactory(es, kbnIndex, Promise, Private, Notif
|
|||
* After creation or fetching from ES, ensure that the searchSources index indexPattern
|
||||
* is an bonafide IndexPattern object.
|
||||
*
|
||||
* @return {[type]} [description]
|
||||
* @return {Promise<IndexPattern | null>}
|
||||
*/
|
||||
function hydrateIndexPattern() {
|
||||
return Promise.try(function () {
|
||||
if (self.searchSource) {
|
||||
if (!self.searchSource) { return Promise.resolve(null); }
|
||||
|
||||
let index = config.indexPattern || self.searchSource.getOwn('index');
|
||||
if (!index) return;
|
||||
if (config.clearSavedIndexPattern) {
|
||||
self.searchSource.set('index', undefined);
|
||||
return;
|
||||
}
|
||||
if (config.clearSavedIndexPattern) {
|
||||
self.searchSource.set('index', undefined);
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
if (!(index instanceof indexPatterns.IndexPattern)) {
|
||||
index = indexPatterns.get(index);
|
||||
}
|
||||
let index = config.indexPattern || self.searchSource.getOwn('index');
|
||||
|
||||
return Promise.resolve(index).then(function (indexPattern) {
|
||||
self.searchSource.set('index', indexPattern);
|
||||
});
|
||||
}
|
||||
});
|
||||
if (!index) { return Promise.resolve(null); }
|
||||
|
||||
// If index is not an IndexPattern object at this point, then it's a string id of an index.
|
||||
if (!(index instanceof indexPatterns.IndexPattern)) {
|
||||
index = indexPatterns.get(index);
|
||||
}
|
||||
|
||||
// At this point index will either be an IndexPattern, if cached, or a promise that
|
||||
// will return an IndexPattern, if not cached.
|
||||
return Promise.resolve(index)
|
||||
.then((indexPattern) => {
|
||||
self.searchSource.set('index', indexPattern);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -231,59 +246,55 @@ export default function SavedObjectFactory(es, kbnIndex, Promise, Private, Notif
|
|||
};
|
||||
|
||||
/**
|
||||
* Save this object
|
||||
* Returns true if the object's original title has been changed. New objects return false.
|
||||
* @return {boolean}
|
||||
*/
|
||||
self.isTitleChanged = function () {
|
||||
return self._source && self._source.title !== self.title;
|
||||
};
|
||||
|
||||
/**
|
||||
* Saves this object.
|
||||
*
|
||||
* @return {Promise}
|
||||
* @resolved {String} - The id of the doc
|
||||
*/
|
||||
self.save = function () {
|
||||
// Save the original id in case the save fails.
|
||||
let originalId = self.id;
|
||||
// Read https://github.com/elastic/kibana/issues/9056 and
|
||||
// https://github.com/elastic/kibana/issues/9012 for some background into why this copyOnSave variable
|
||||
// exists.
|
||||
// The goal is to move towards a better rename flow, but since our users have been conditioned
|
||||
// to expect a 'save as' flow during a rename, we are keeping the logic the same until a better
|
||||
// UI/UX can be worked out.
|
||||
if (this.copyOnSave) {
|
||||
self.id = null;
|
||||
}
|
||||
|
||||
let body = self.serialize();
|
||||
|
||||
// Slugify the object id
|
||||
self.id = slugifyId(self.id);
|
||||
|
||||
// ensure that the docSource has the current self.id
|
||||
// Create a unique id for this object if it doesn't have one already.
|
||||
self.id = this.id || uuid.v1();
|
||||
// ensure that the docSource has the current id
|
||||
docSource.id(self.id);
|
||||
|
||||
// index the document
|
||||
return self.saveSource(body);
|
||||
};
|
||||
let source = self.serialize();
|
||||
|
||||
self.saveSource = function (source) {
|
||||
let finish = function (id) {
|
||||
self.id = id;
|
||||
return es.indices.refresh({
|
||||
index: kbnIndex
|
||||
})
|
||||
.then(function () {
|
||||
self.isSaving = true;
|
||||
return docSource.doIndex(source)
|
||||
.then((id) => { self.id = id; })
|
||||
.then(self.refreshIndex)
|
||||
.then(() => {
|
||||
self.isSaving = false;
|
||||
self.lastSavedTitle = self.title;
|
||||
return self.id;
|
||||
})
|
||||
.catch(function (err) {
|
||||
self.isSaving = false;
|
||||
self.id = originalId;
|
||||
return Promise.reject(err);
|
||||
});
|
||||
};
|
||||
|
||||
return docSource.doCreate(source)
|
||||
.then(finish)
|
||||
.catch(function (err) {
|
||||
// record exists, confirm overwriting
|
||||
if (_.get(err, 'origError.status') === 409) {
|
||||
let confirmMessage = 'Are you sure you want to overwrite ' + self.title + '?';
|
||||
|
||||
return safeConfirm(confirmMessage).then(
|
||||
function () {
|
||||
return docSource.doIndex(source).then(finish);
|
||||
},
|
||||
_.noop // if the user doesn't overwrite record, just swallow the error
|
||||
);
|
||||
}
|
||||
return Promise.reject(err);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Destroy this object
|
||||
*
|
||||
* @return {undefined}
|
||||
*/
|
||||
self.destroy = function () {
|
||||
docSource.cancelQueued();
|
||||
if (self.searchSource) {
|
||||
|
@ -291,20 +302,26 @@ export default function SavedObjectFactory(es, kbnIndex, Promise, Private, Notif
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Queries es to refresh the index.
|
||||
* @returns {Promise}
|
||||
*/
|
||||
self.refreshIndex = function () {
|
||||
return es.indices.refresh({ index: kbnIndex });
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete this object from Elasticsearch
|
||||
* @return {promise}
|
||||
*/
|
||||
self.delete = function () {
|
||||
return es.delete({
|
||||
index: kbnIndex,
|
||||
type: type,
|
||||
id: this.id
|
||||
}).then(function () {
|
||||
return es.indices.refresh({
|
||||
index: kbnIndex
|
||||
});
|
||||
});
|
||||
return es.delete(
|
||||
{
|
||||
index: kbnIndex,
|
||||
type: type,
|
||||
id: this.id
|
||||
})
|
||||
.then(() => { return this.refreshIndex(); });
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
<div ng-hide="!savedObject.id || savedObject.isSaving">
|
||||
<div ng-hide="!savedObject.isTitleChanged() || savedObject.copyOnSave" class="localDropdownWarning">
|
||||
In previous versions of Kibana, changing the name of a {{savedObject.getDisplayName()}} would make a copy with the new name. Use the 'Save as a new {{savedObject.getDisplayName()}}' checkbox to do this now.
|
||||
</div>
|
||||
<label>
|
||||
<input type="checkbox" ng-model="savedObject.copyOnSave" ng-checked="savedObject.copyOnSave">
|
||||
Save as a new {{savedObject.getDisplayName()}}
|
||||
</label>
|
||||
</div>
|
|
@ -0,0 +1,14 @@
|
|||
import uiModules from 'ui/modules';
|
||||
import saveObjectSaveAsCheckboxTemplate from './saved_object_save_as_checkbox.html';
|
||||
|
||||
uiModules
|
||||
.get('kibana')
|
||||
.directive('savedObjectSaveAsCheckBox', function () {
|
||||
return {
|
||||
restrict: 'E',
|
||||
template: saveObjectSaveAsCheckboxTemplate,
|
||||
scope: {
|
||||
savedObject: '='
|
||||
}
|
||||
};
|
||||
});
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue