diff --git a/CHANGELOG.md b/CHANGELOG.md index a6a0f6d7f8c..1a623d5ea8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ * **Alerting**: Notification reminders [#7330](https://github.com/grafana/grafana/issues/7330), thx [@jbaublitz](https://github.com/jbaublitz) * **Dashboard**: TV & Kiosk mode changes, new cycle view mode button in dashboard toolbar [#13025](https://github.com/grafana/grafana/pull/13025) * **OAuth**: Gitlab OAuth with support for filter by groups [#5623](https://github.com/grafana/grafana/issues/5623), thx [@BenoitKnecht](https://github.com/BenoitKnecht) +* **Postgres**: Graphical query builder [#10095](https://github.com/grafana/grafana/issues/10095), thx [svenklemm](https://github.com/svenklemm) ### New Features @@ -19,6 +20,9 @@ ### Minor +* **Units**: Adds bitcoin axes unit. [#13125](https://github.com/grafana/grafana/pull/13125) +* **GrafanaCli**: Fixed issue with grafana-cli install plugin resulting in corrupt http response from source error. Fixes [#13079](https://github.com/grafana/grafana/issues/13079) +* **Logging**: Reopen log files after receiving a SIGHUP signal [#13112](https://github.com/grafana/grafana/pull/13112), thx [@filewalkwithme](https://github.com/filewalkwithme) * **Api**: Delete nonexistent datasource should return 404 [#12313](https://github.com/grafana/grafana/issues/12313), thx [@AustinWinstanley](https://github.com/AustinWinstanley) * **Dashboard**: Fix selecting current dashboard from search should not reload dashboard [#12248](https://github.com/grafana/grafana/issues/12248) * **Dashboard**: Use uid when linking to dashboards internally in a dashboard [#10705](https://github.com/grafana/grafana/issues/10705) diff --git a/conf/defaults.ini b/conf/defaults.ini index 90fc144c6e0..fff9f630690 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -538,3 +538,8 @@ container_name = [external_image_storage.local] # does not require any configuration + +[rendering] +# Options to configure external image rendering server like https://github.com/grafana/grafana-image-renderer +server_url = +callback_url = diff --git a/conf/sample.ini b/conf/sample.ini index 4291071e026..2b2ae497e36 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -460,3 +460,8 @@ log_queries = [external_image_storage.local] # does not require any configuration + +[rendering] +# Options to configure external image rendering server like https://github.com/grafana/grafana-image-renderer +;server_url = +;callback_url = diff --git a/docs/sources/administration/provisioning.md b/docs/sources/administration/provisioning.md index c57fb1683f0..f3d4091defa 100644 --- a/docs/sources/administration/provisioning.md +++ b/docs/sources/administration/provisioning.md @@ -155,7 +155,7 @@ Since not all datasources have the same configuration settings we only have the | tlsSkipVerify | boolean | *All* | Controls whether a client verifies the server's certificate chain and host name. | | graphiteVersion | string | Graphite | Graphite version | | timeInterval | string | Elastic, InfluxDB & Prometheus | Lowest interval/step value that should be used for this data source | -| esVersion | number | Elastic | Elasticsearch version as an number (2/5/56) | +| esVersion | number | Elastic | Elasticsearch version as a number (2/5/56) | | timeField | string | Elastic | Which field that should be used as timestamp | | interval | string | Elastic | Index date time format | | authType | string | Cloudwatch | Auth provider. keys/credentials/arn | @@ -165,6 +165,8 @@ Since not all datasources have the same configuration settings we only have the | tsdbVersion | string | OpenTSDB | Version | | tsdbResolution | string | OpenTSDB | Resolution | | sslmode | string | PostgreSQL | SSLmode. 'disable', 'require', 'verify-ca' or 'verify-full' | +| postgresVersion | number | PostgreSQL | Postgres version as a number (903/904/905/906/1000) meaning v9.3, v9.4, ..., v10 | +| timescaledb | boolean | PostgreSQL | Enable usage of TimescaleDB extension | #### Secure Json Data diff --git a/docs/sources/features/datasources/postgres.md b/docs/sources/features/datasources/postgres.md index 1d195a01349..4dfe6929bc1 100644 --- a/docs/sources/features/datasources/postgres.md +++ b/docs/sources/features/datasources/postgres.md @@ -31,7 +31,9 @@ Name | Description *User* | Database user's login/username *Password* | Database user's password *SSL Mode* | This option determines whether or with what priority a secure SSL TCP/IP connection will be negotiated with the server. -*TimescaleDB* | With this option enabled Grafana will use TimescaleDB features, e.g. use ```time_bucket``` for grouping by time (only available in Grafana 5.3+). +*Version* | This option determines which functions are available in the query builder (only available in Grafana 5.3+). +*TimescaleDB* | TimescaleDB is a time-series database built as a PostgreSQL extension. If enabled, Grafana will use `time_bucket` in the `$__timeGroup` macro and display TimescaleDB specific aggregate functions in the query builder (only available in Grafana 5.3+). + ### Database User Permissions (Important!) @@ -292,5 +294,6 @@ datasources: password: "Password!" jsonData: sslmode: "disable" # disable/require/verify-ca/verify-full + postgresVersion: 903 # 903=9.3, 904=9.4, 905=9.5, 906=9.6, 1000=10 timescaledb: false ``` diff --git a/pkg/cmd/grafana-cli/commands/install_command.go b/pkg/cmd/grafana-cli/commands/install_command.go index 9bdb73a5858..5d4969e06af 100644 --- a/pkg/cmd/grafana-cli/commands/install_command.go +++ b/pkg/cmd/grafana-cli/commands/install_command.go @@ -152,7 +152,7 @@ func downloadFile(pluginName, filePath, url string) (err error) { return err } - r, err := zip.NewReader(bytes.NewReader(body), resp.ContentLength) + r, err := zip.NewReader(bytes.NewReader(body), int64(len(body))) if err != nil { return err } diff --git a/pkg/cmd/grafana-server/main.go b/pkg/cmd/grafana-server/main.go index f00e6bba0fd..f1e298671d7 100644 --- a/pkg/cmd/grafana-server/main.go +++ b/pkg/cmd/grafana-server/main.go @@ -96,13 +96,17 @@ func main() { func listenToSystemSignals(server *GrafanaServerImpl) { signalChan := make(chan os.Signal, 1) - ignoreChan := make(chan os.Signal, 1) + sighupChan := make(chan os.Signal, 1) - signal.Notify(ignoreChan, syscall.SIGHUP) + signal.Notify(sighupChan, syscall.SIGHUP) signal.Notify(signalChan, os.Interrupt, os.Kill, syscall.SIGTERM) - select { - case sig := <-signalChan: - server.Shutdown(fmt.Sprintf("System signal: %s", sig)) + for { + select { + case _ = <-sighupChan: + log.Reload() + case sig := <-signalChan: + server.Shutdown(fmt.Sprintf("System signal: %s", sig)) + } } } diff --git a/pkg/log/file.go b/pkg/log/file.go index d137adbf3de..b8430dc6086 100644 --- a/pkg/log/file.go +++ b/pkg/log/file.go @@ -236,3 +236,20 @@ func (w *FileLogWriter) Close() { func (w *FileLogWriter) Flush() { w.mw.fd.Sync() } + +// Reload file logger +func (w *FileLogWriter) Reload() { + // block Logger's io.Writer + w.mw.Lock() + defer w.mw.Unlock() + + // Close + fd := w.mw.fd + fd.Close() + + // Open again + err := w.StartLogger() + if err != nil { + fmt.Fprintf(os.Stderr, "Reload StartLogger: %s\n", err) + } +} diff --git a/pkg/log/handlers.go b/pkg/log/handlers.go index 14a96fdcdb4..804d8fcbd70 100644 --- a/pkg/log/handlers.go +++ b/pkg/log/handlers.go @@ -3,3 +3,7 @@ package log type DisposableHandler interface { Close() } + +type ReloadableHandler interface { + Reload() +} diff --git a/pkg/log/log.go b/pkg/log/log.go index 0e6874e1b4b..d0e6ea89f27 100644 --- a/pkg/log/log.go +++ b/pkg/log/log.go @@ -21,10 +21,12 @@ import ( var Root log15.Logger var loggersToClose []DisposableHandler +var loggersToReload []ReloadableHandler var filters map[string]log15.Lvl func init() { loggersToClose = make([]DisposableHandler, 0) + loggersToReload = make([]ReloadableHandler, 0) Root = log15.Root() Root.SetHandler(log15.DiscardHandler()) } @@ -115,6 +117,12 @@ func Close() { loggersToClose = make([]DisposableHandler, 0) } +func Reload() { + for _, logger := range loggersToReload { + logger.Reload() + } +} + func GetLogLevelFor(name string) Lvl { if level, ok := filters[name]; ok { switch level { @@ -230,6 +238,7 @@ func ReadLoggingConfig(modes []string, logsPath string, cfg *ini.File) { fileHandler.Init() loggersToClose = append(loggersToClose, fileHandler) + loggersToReload = append(loggersToReload, fileHandler) handler = fileHandler case "syslog": sysLogHandler := NewSyslog(sec, format) diff --git a/pkg/services/rendering/http_mode.go b/pkg/services/rendering/http_mode.go index 9084ca27353..d47dfaeaae1 100644 --- a/pkg/services/rendering/http_mode.go +++ b/pkg/services/rendering/http_mode.go @@ -2,6 +2,7 @@ package rendering import ( "context" + "fmt" "io" "net" "net/http" @@ -20,14 +21,13 @@ var netTransport = &http.Transport{ TLSHandshakeTimeout: 5 * time.Second, } +var netClient = &http.Client{ + Transport: netTransport, +} + func (rs *RenderingService) renderViaHttp(ctx context.Context, opts Opts) (*RenderResult, error) { filePath := rs.getFilePathForNewImage() - var netClient = &http.Client{ - Timeout: opts.Timeout, - Transport: netTransport, - } - rendererUrl, err := url.Parse(rs.Cfg.RendererUrl) if err != nil { return nil, err @@ -35,10 +35,10 @@ func (rs *RenderingService) renderViaHttp(ctx context.Context, opts Opts) (*Rend queryParams := rendererUrl.Query() queryParams.Add("url", rs.getURL(opts.Path)) - queryParams.Add("renderKey", rs.getRenderKey(opts.UserId, opts.OrgId, opts.OrgRole)) + queryParams.Add("renderKey", rs.getRenderKey(opts.OrgId, opts.UserId, opts.OrgRole)) queryParams.Add("width", strconv.Itoa(opts.Width)) queryParams.Add("height", strconv.Itoa(opts.Height)) - queryParams.Add("domain", rs.getLocalDomain()) + queryParams.Add("domain", rs.domain) queryParams.Add("timezone", isoTimeOffsetToPosixTz(opts.Timezone)) queryParams.Add("encoding", opts.Encoding) queryParams.Add("timeout", strconv.Itoa(int(opts.Timeout.Seconds()))) @@ -49,20 +49,48 @@ func (rs *RenderingService) renderViaHttp(ctx context.Context, opts Opts) (*Rend return nil, err } + reqContext, cancel := context.WithTimeout(ctx, opts.Timeout+time.Second*2) + defer cancel() + + req = req.WithContext(reqContext) + // make request to renderer server resp, err := netClient.Do(req) if err != nil { - return nil, err + rs.log.Error("Failed to send request to remote rendering service.", "error", err) + return nil, fmt.Errorf("Failed to send request to remote rendering service. %s", err) } // save response to file defer resp.Body.Close() + + // check for timeout first + if reqContext.Err() == context.DeadlineExceeded { + rs.log.Info("Rendering timed out") + return nil, ErrTimeout + } + + // if we didnt get a 200 response, something went wrong. + if resp.StatusCode != http.StatusOK { + rs.log.Error("Remote rendering request failed", "error", resp.Status) + return nil, fmt.Errorf("Remote rendering request failed. %d: %s", resp.StatusCode, resp.Status) + } + out, err := os.Create(filePath) if err != nil { return nil, err } defer out.Close() - io.Copy(out, resp.Body) + _, err = io.Copy(out, resp.Body) + if err != nil { + // check that we didnt timeout while receiving the response. + if reqContext.Err() == context.DeadlineExceeded { + rs.log.Info("Rendering timed out") + return nil, ErrTimeout + } + rs.log.Error("Remote rendering request failed", "error", err) + return nil, fmt.Errorf("Remote rendering request failed. %s", err) + } return &RenderResult{FilePath: filePath}, err } diff --git a/pkg/services/rendering/phantomjs.go b/pkg/services/rendering/phantomjs.go index 87ccaf6b5d2..1bd7489c153 100644 --- a/pkg/services/rendering/phantomjs.go +++ b/pkg/services/rendering/phantomjs.go @@ -49,7 +49,7 @@ func (rs *RenderingService) renderViaPhantomJS(ctx context.Context, opts Opts) ( fmt.Sprintf("width=%v", opts.Width), fmt.Sprintf("height=%v", opts.Height), fmt.Sprintf("png=%v", pngPath), - fmt.Sprintf("domain=%v", rs.getLocalDomain()), + fmt.Sprintf("domain=%v", rs.domain), fmt.Sprintf("timeout=%v", opts.Timeout.Seconds()), fmt.Sprintf("renderKey=%v", renderKey), } diff --git a/pkg/services/rendering/plugin_mode.go b/pkg/services/rendering/plugin_mode.go index 550779ad7c3..58fef2b095f 100644 --- a/pkg/services/rendering/plugin_mode.go +++ b/pkg/services/rendering/plugin_mode.go @@ -77,10 +77,10 @@ func (rs *RenderingService) renderViaPlugin(ctx context.Context, opts Opts) (*Re Height: int32(opts.Height), FilePath: pngPath, Timeout: int32(opts.Timeout.Seconds()), - RenderKey: rs.getRenderKey(opts.UserId, opts.OrgId, opts.OrgRole), + RenderKey: rs.getRenderKey(opts.OrgId, opts.UserId, opts.OrgRole), Encoding: opts.Encoding, Timezone: isoTimeOffsetToPosixTz(opts.Timezone), - Domain: rs.getLocalDomain(), + Domain: rs.domain, }) if err != nil { diff --git a/pkg/services/rendering/rendering.go b/pkg/services/rendering/rendering.go index 799aecc3e88..ff4a67cc9b6 100644 --- a/pkg/services/rendering/rendering.go +++ b/pkg/services/rendering/rendering.go @@ -3,6 +3,8 @@ package rendering import ( "context" "fmt" + "net/url" + "os" "path/filepath" plugin "github.com/hashicorp/go-plugin" @@ -27,12 +29,31 @@ type RenderingService struct { grpcPlugin pluginModel.RendererPlugin pluginInfo *plugins.RendererPlugin renderAction renderFunc + domain string Cfg *setting.Cfg `inject:""` } func (rs *RenderingService) Init() error { rs.log = log.New("rendering") + + // ensure ImagesDir exists + err := os.MkdirAll(rs.Cfg.ImagesDir, 0700) + if err != nil { + return err + } + + // set value used for domain attribute of renderKey cookie + if rs.Cfg.RendererUrl != "" { + // RendererCallbackUrl has already been passed, it wont generate an error. + u, _ := url.Parse(rs.Cfg.RendererCallbackUrl) + rs.domain = u.Hostname() + } else if setting.HttpAddr != setting.DEFAULT_HTTP_ADDR { + rs.domain = setting.HttpAddr + } else { + rs.domain = "localhost" + } + return nil } @@ -82,16 +103,17 @@ func (rs *RenderingService) getFilePathForNewImage() string { } func (rs *RenderingService) getURL(path string) string { - // &render=1 signals to the legacy redirect layer to - return fmt.Sprintf("%s://%s:%s/%s&render=1", setting.Protocol, rs.getLocalDomain(), setting.HttpPort, path) -} + if rs.Cfg.RendererUrl != "" { + // The backend rendering service can potentially be remote. + // So we need to use the root_url to ensure the rendering service + // can reach this Grafana instance. + + // &render=1 signals to the legacy redirect layer to + return fmt.Sprintf("%s%s&render=1", rs.Cfg.RendererCallbackUrl, path) -func (rs *RenderingService) getLocalDomain() string { - if setting.HttpAddr != setting.DEFAULT_HTTP_ADDR { - return setting.HttpAddr } - - return "localhost" + // &render=1 signals to the legacy redirect layer to + return fmt.Sprintf("%s://%s:%s/%s&render=1", setting.Protocol, rs.domain, setting.HttpPort, path) } func (rs *RenderingService) getRenderKey(orgId, userId int64, orgRole models.RoleType) string { diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index eb61568261d..789622ca0dd 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -197,6 +197,7 @@ type Cfg struct { ImagesDir string PhantomDir string RendererUrl string + RendererCallbackUrl string DisableBruteForceLoginProtection bool TempDataLifetime time.Duration @@ -641,6 +642,18 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error { // Rendering renderSec := iniFile.Section("rendering") cfg.RendererUrl = renderSec.Key("server_url").String() + cfg.RendererCallbackUrl = renderSec.Key("callback_url").String() + if cfg.RendererCallbackUrl == "" { + cfg.RendererCallbackUrl = AppUrl + } else { + if cfg.RendererCallbackUrl[len(cfg.RendererCallbackUrl)-1] != '/' { + cfg.RendererCallbackUrl += "/" + } + _, err := url.Parse(cfg.RendererCallbackUrl) + if err != nil { + log.Fatal(4, "Invalid callback_url(%s): %s", cfg.RendererCallbackUrl, err) + } + } cfg.ImagesDir = filepath.Join(DataPath, "png") cfg.PhantomDir = filepath.Join(HomePath, "tools/phantomjs") cfg.TempDataLifetime = iniFile.Section("paths").Key("temp_data_lifetime").MustDuration(time.Second * 3600 * 24) diff --git a/pkg/setting/setting_test.go b/pkg/setting/setting_test.go index 9de22c86811..affb3c3e7ca 100644 --- a/pkg/setting/setting_test.go +++ b/pkg/setting/setting_test.go @@ -20,6 +20,7 @@ func TestLoadingSettings(t *testing.T) { So(err, ShouldBeNil) So(AdminUser, ShouldEqual, "admin") + So(cfg.RendererCallbackUrl, ShouldEqual, "http://localhost:3000/") }) Convey("Should be able to override via environment variables", func() { @@ -178,5 +179,15 @@ func TestLoadingSettings(t *testing.T) { So(InstanceName, ShouldEqual, hostname) }) + Convey("Reading callback_url should add trailing slash", func() { + cfg := NewCfg() + cfg.Load(&CommandLineArgs{ + HomePath: "../../", + Args: []string{"cfg:rendering.callback_url=http://myserver/renderer"}, + }) + + So(cfg.RendererCallbackUrl, ShouldEqual, "http://myserver/renderer/") + }) + }) } diff --git a/public/app/containers/Teams/TeamList.tsx b/public/app/containers/Teams/TeamList.tsx index d0feee75184..2a5743bea96 100644 --- a/public/app/containers/Teams/TeamList.tsx +++ b/public/app/containers/Teams/TeamList.tsx @@ -6,6 +6,7 @@ import { NavStore } from 'app/stores/NavStore/NavStore'; import { TeamsStore, Team } from 'app/stores/TeamsStore/TeamsStore'; import { BackendSrv } from 'app/core/services/backend_srv'; import DeleteButton from 'app/core/components/DeleteButton/DeleteButton'; +import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; interface Props { nav: typeof NavStore.Type; @@ -61,48 +62,81 @@ export class TeamList extends React.Component { ); } + renderTeamList(teams) { + return ( +
+
+
+ +
+ +
+ + + New team + +
+ +
+ + + + + + + + + {teams.filteredTeams.map(team => this.renderTeamMember(team))} +
+ NameEmailMembers +
+
+
+ ); + } + + renderEmptyList() { + return ( +
+ +
+ ); + } + render() { const { nav, teams } = this.props; + let view; + + if (teams.filteredTeams.length > 0) { + view = this.renderTeamList(teams); + } else { + view = this.renderEmptyList(); + } + return (
-
-
-
- -
- - - -
- - - - - - - - - {teams.filteredTeams.map(team => this.renderTeamMember(team))} -
- NameEmailMembers -
-
-
+ {view}
); } diff --git a/public/app/core/angular_wrappers.ts b/public/app/core/angular_wrappers.ts index e7295646172..18e9d8dbd84 100644 --- a/public/app/core/angular_wrappers.ts +++ b/public/app/core/angular_wrappers.ts @@ -4,10 +4,12 @@ import PageHeader from './components/PageHeader/PageHeader'; import EmptyListCTA from './components/EmptyListCTA/EmptyListCTA'; import { SearchResult } from './components/search/SearchResult'; import { TagFilter } from './components/TagFilter/TagFilter'; +import { SideMenu } from './components/sidemenu/SideMenu'; import DashboardPermissions from './components/Permissions/DashboardPermissions'; export function registerAngularDirectives() { react2AngularDirective('passwordStrength', PasswordStrength, ['password']); + react2AngularDirective('sidemenu', SideMenu, []); react2AngularDirective('pageHeader', PageHeader, ['model', 'noTabs']); react2AngularDirective('emptyListCta', EmptyListCTA, ['model']); react2AngularDirective('searchResult', SearchResult, []); diff --git a/public/app/core/components/sidemenu/BottomNavLinks.test.tsx b/public/app/core/components/sidemenu/BottomNavLinks.test.tsx new file mode 100644 index 00000000000..8eaed4ca264 --- /dev/null +++ b/public/app/core/components/sidemenu/BottomNavLinks.test.tsx @@ -0,0 +1,96 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import BottomNavLinks from './BottomNavLinks'; +import appEvents from '../../app_events'; + +jest.mock('../../app_events', () => ({ + emit: jest.fn(), +})); + +const setup = (propOverrides?: object) => { + const props = Object.assign( + { + link: {}, + user: { + isGrafanaAdmin: false, + isSignedIn: false, + orgCount: 2, + orgRole: '', + orgId: 1, + orgName: 'Grafana', + timezone: 'UTC', + helpFlags1: 1, + lightTheme: false, + hasEditPermissionInFolders: false, + }, + }, + propOverrides + ); + return shallow(); +}; + +describe('Render', () => { + it('should render component', () => { + const wrapper = setup(); + + expect(wrapper).toMatchSnapshot(); + }); + + it('should render organisation switcher', () => { + const wrapper = setup({ + link: { + showOrgSwitcher: true, + }, + }); + + expect(wrapper).toMatchSnapshot(); + }); + + it('should render subtitle', () => { + const wrapper = setup({ + link: { + subTitle: 'subtitle', + }, + }); + + expect(wrapper).toMatchSnapshot(); + }); + + it('should render children', () => { + const wrapper = setup({ + link: { + children: [ + { + id: '1', + }, + { + id: '2', + }, + { + id: '3', + }, + { + id: '4', + hideFromMenu: true, + }, + ], + }, + }); + + expect(wrapper).toMatchSnapshot(); + }); +}); + +describe('Functions', () => { + describe('item clicked', () => { + const wrapper = setup(); + const mockEvent = { preventDefault: jest.fn() }; + it('should emit show modal event if url matches shortcut', () => { + const child = { url: '/shortcuts' }; + const instance = wrapper.instance() as BottomNavLinks; + instance.itemClicked(mockEvent, child); + + expect(appEvents.emit).toHaveBeenCalledWith('show-modal', { templateHtml: '' }); + }); + }); +}); diff --git a/public/app/core/components/sidemenu/BottomNavLinks.tsx b/public/app/core/components/sidemenu/BottomNavLinks.tsx new file mode 100644 index 00000000000..21c83864c62 --- /dev/null +++ b/public/app/core/components/sidemenu/BottomNavLinks.tsx @@ -0,0 +1,78 @@ +import React, { PureComponent } from 'react'; +import appEvents from '../../app_events'; +import { User } from '../../services/context_srv'; + +export interface Props { + link: any; + user: User; +} + +class BottomNavLinks extends PureComponent { + itemClicked = (event, child) => { + if (child.url === '/shortcuts') { + event.preventDefault(); + appEvents.emit('show-modal', { + templateHtml: '', + }); + } + }; + + switchOrg = () => { + appEvents.emit('show-modal', { + templateHtml: '', + }); + }; + + render() { + const { link, user } = this.props; + return ( +
+ + + {link.icon && } + {link.img && } + + + +
+ ); + } +} + +export default BottomNavLinks; diff --git a/public/app/core/components/sidemenu/BottomSection.test.tsx b/public/app/core/components/sidemenu/BottomSection.test.tsx new file mode 100644 index 00000000000..40307895c57 --- /dev/null +++ b/public/app/core/components/sidemenu/BottomSection.test.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import BottomSection from './BottomSection'; + +jest.mock('../../config', () => ({ + bootData: { + navTree: [ + { + id: 'profile', + hideFromMenu: true, + }, + { + hideFromMenu: true, + }, + { + hideFromMenu: false, + }, + { + hideFromMenu: true, + }, + ], + }, + user: { + orgCount: 5, + orgName: 'Grafana', + }, +})); + +jest.mock('app/core/services/context_srv', () => ({ + contextSrv: { + sidemenu: true, + isSignedIn: false, + isGrafanaAdmin: false, + hasEditPermissionFolders: false, + }, +})); + +describe('Render', () => { + it('should render component', () => { + const wrapper = shallow(); + + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/public/app/core/components/sidemenu/BottomSection.tsx b/public/app/core/components/sidemenu/BottomSection.tsx new file mode 100644 index 00000000000..ee5175ca763 --- /dev/null +++ b/public/app/core/components/sidemenu/BottomSection.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import _ from 'lodash'; +import SignIn from './SignIn'; +import BottomNavLinks from './BottomNavLinks'; +import { contextSrv } from 'app/core/services/context_srv'; +import config from '../../config'; + +export default function BottomSection() { + const navTree = _.cloneDeep(config.bootData.navTree); + const bottomNav = _.filter(navTree, item => item.hideFromMenu); + const isSignedIn = contextSrv.isSignedIn; + const user = contextSrv.user; + + if (user && user.orgCount > 1) { + const profileNode = _.find(bottomNav, { id: 'profile' }); + if (profileNode) { + profileNode.showOrgSwitcher = true; + } + } + + return ( +
+ {!isSignedIn && } + {bottomNav.map((link, index) => { + return ; + })} +
+ ); +} diff --git a/public/app/core/components/sidemenu/DropDownChild.test.tsx b/public/app/core/components/sidemenu/DropDownChild.test.tsx new file mode 100644 index 00000000000..6a15ca5b23f --- /dev/null +++ b/public/app/core/components/sidemenu/DropDownChild.test.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import DropDownChild from './DropDownChild'; + +const setup = (propOverrides?: object) => { + const props = Object.assign( + { + child: { + divider: true, + }, + }, + propOverrides + ); + + return shallow(); +}; + +describe('Render', () => { + it('should render component', () => { + const wrapper = setup(); + + expect(wrapper).toMatchSnapshot(); + }); + + it('should render icon if exists', () => { + const wrapper = setup({ + child: { + divider: false, + icon: 'icon-test', + }, + }); + + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/public/app/core/components/sidemenu/DropDownChild.tsx b/public/app/core/components/sidemenu/DropDownChild.tsx new file mode 100644 index 00000000000..1a577d185e5 --- /dev/null +++ b/public/app/core/components/sidemenu/DropDownChild.tsx @@ -0,0 +1,21 @@ +import React, { SFC } from 'react'; + +export interface Props { + child: any; +} + +const DropDownChild: SFC = props => { + const { child } = props; + const listItemClassName = child.divider ? 'divider' : ''; + + return ( +
  • + + {child.icon && } + {child.text} + +
  • + ); +}; + +export default DropDownChild; diff --git a/public/app/core/components/sidemenu/SideMenu.test.tsx b/public/app/core/components/sidemenu/SideMenu.test.tsx new file mode 100644 index 00000000000..2a262adca5a --- /dev/null +++ b/public/app/core/components/sidemenu/SideMenu.test.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { SideMenu } from './SideMenu'; +import appEvents from '../../app_events'; +import { contextSrv } from 'app/core/services/context_srv'; + +jest.mock('../../app_events', () => ({ + emit: jest.fn(), +})); + +jest.mock('app/core/services/context_srv', () => ({ + contextSrv: { + sidemenu: true, + user: {}, + isSignedIn: false, + isGrafanaAdmin: false, + isEditor: false, + hasEditPermissionFolders: false, + toggleSideMenu: jest.fn(), + }, +})); + +const setup = (propOverrides?: object) => { + const props = Object.assign( + { + loginUrl: '', + user: {}, + mainLinks: [], + bottomeLinks: [], + isSignedIn: false, + }, + propOverrides + ); + + return shallow(); +}; + +describe('Render', () => { + it('should render component', () => { + const wrapper = setup(); + + expect(wrapper).toMatchSnapshot(); + }); +}); + +describe('Functions', () => { + describe('toggle side menu', () => { + const wrapper = setup(); + const instance = wrapper.instance() as SideMenu; + instance.toggleSideMenu(); + + it('should call contextSrv.toggleSideMenu', () => { + expect(contextSrv.toggleSideMenu).toHaveBeenCalled(); + }); + + it('should emit toggle sidemenu event', () => { + expect(appEvents.emit).toHaveBeenCalledWith('toggle-sidemenu'); + }); + }); + + describe('toggle side menu on mobile', () => { + const wrapper = setup(); + const instance = wrapper.instance() as SideMenu; + instance.toggleSideMenuSmallBreakpoint(); + + it('should emit toggle sidemenu event', () => { + expect(appEvents.emit).toHaveBeenCalledWith('toggle-sidemenu-mobile'); + }); + }); +}); diff --git a/public/app/core/components/sidemenu/SideMenu.tsx b/public/app/core/components/sidemenu/SideMenu.tsx new file mode 100644 index 00000000000..0092c1e3842 --- /dev/null +++ b/public/app/core/components/sidemenu/SideMenu.tsx @@ -0,0 +1,32 @@ +import React, { PureComponent } from 'react'; +import appEvents from '../../app_events'; +import { contextSrv } from 'app/core/services/context_srv'; +import TopSection from './TopSection'; +import BottomSection from './BottomSection'; + +export class SideMenu extends PureComponent { + toggleSideMenu = () => { + contextSrv.toggleSideMenu(); + appEvents.emit('toggle-sidemenu'); + }; + + toggleSideMenuSmallBreakpoint = () => { + appEvents.emit('toggle-sidemenu-mobile'); + }; + + render() { + return [ +
    + graphana_logo +
    , +
    + + +  Close + +
    , + , + , + ]; + } +} diff --git a/public/app/core/components/sidemenu/SideMenuDropDown.test.tsx b/public/app/core/components/sidemenu/SideMenuDropDown.test.tsx new file mode 100644 index 00000000000..c98d78ca021 --- /dev/null +++ b/public/app/core/components/sidemenu/SideMenuDropDown.test.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import SideMenuDropDown from './SideMenuDropDown'; + +const setup = (propOverrides?: object) => { + const props = Object.assign( + { + link: { + text: 'link', + }, + }, + propOverrides + ); + + return shallow(); +}; + +describe('Render', () => { + it('should render component', () => { + const wrapper = setup(); + + expect(wrapper).toMatchSnapshot(); + }); + + it('should render children', () => { + const wrapper = setup({ + link: { + text: 'link', + children: [{ id: 1 }, { id: 2 }, { id: 3 }], + }, + }); + + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/public/app/core/components/sidemenu/SideMenuDropDown.tsx b/public/app/core/components/sidemenu/SideMenuDropDown.tsx new file mode 100644 index 00000000000..7cd7554f82c --- /dev/null +++ b/public/app/core/components/sidemenu/SideMenuDropDown.tsx @@ -0,0 +1,23 @@ +import React, { SFC } from 'react'; +import DropDownChild from './DropDownChild'; + +interface Props { + link: any; +} + +const SideMenuDropDown: SFC = props => { + const { link } = props; + return ( +
      +
    • + {link.text} +
    • + {link.children && + link.children.map((child, index) => { + return ; + })} +
    + ); +}; + +export default SideMenuDropDown; diff --git a/public/app/core/components/sidemenu/SignIn.test.tsx b/public/app/core/components/sidemenu/SignIn.test.tsx new file mode 100644 index 00000000000..c202926086f --- /dev/null +++ b/public/app/core/components/sidemenu/SignIn.test.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import SignIn from './SignIn'; + +describe('Render', () => { + it('should render component', () => { + const wrapper = shallow(); + + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/public/app/core/components/sidemenu/SignIn.tsx b/public/app/core/components/sidemenu/SignIn.tsx new file mode 100644 index 00000000000..17dd913823a --- /dev/null +++ b/public/app/core/components/sidemenu/SignIn.tsx @@ -0,0 +1,23 @@ +import React, { SFC } from 'react'; + +const SignIn: SFC = () => { + const loginUrl = `login?redirect=${encodeURIComponent(window.location.pathname)}`; + return ( + + ); +}; + +export default SignIn; diff --git a/public/app/core/components/sidemenu/TopSection.test.tsx b/public/app/core/components/sidemenu/TopSection.test.tsx new file mode 100644 index 00000000000..6ed01479833 --- /dev/null +++ b/public/app/core/components/sidemenu/TopSection.test.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import TopSection from './TopSection'; + +jest.mock('../../config', () => ({ + bootData: { + navTree: [ + { id: '1', hideFromMenu: true }, + { id: '2', hideFromMenu: true }, + { id: '3', hideFromMenu: false }, + { id: '4', hideFromMenu: true }, + ], + }, +})); + +const setup = (propOverrides?: object) => { + const props = Object.assign( + { + mainLinks: [], + }, + propOverrides + ); + + return shallow(); +}; + +describe('Render', () => { + it('should render component', () => { + const wrapper = setup(); + + expect(wrapper).toMatchSnapshot(); + }); + + it('should render items', () => { + const wrapper = setup({ + mainLinks: [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }], + }); + + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/public/app/core/components/sidemenu/TopSection.tsx b/public/app/core/components/sidemenu/TopSection.tsx new file mode 100644 index 00000000000..c6bf5df8242 --- /dev/null +++ b/public/app/core/components/sidemenu/TopSection.tsx @@ -0,0 +1,19 @@ +import React, { SFC } from 'react'; +import _ from 'lodash'; +import TopSectionItem from './TopSectionItem'; +import config from '../../config'; + +const TopSection: SFC = () => { + const navTree = _.cloneDeep(config.bootData.navTree); + const mainLinks = _.filter(navTree, item => !item.hideFromMenu); + + return ( +
    + {mainLinks.map((link, index) => { + return ; + })} +
    + ); +}; + +export default TopSection; diff --git a/public/app/core/components/sidemenu/TopSectionItem.test.tsx b/public/app/core/components/sidemenu/TopSectionItem.test.tsx new file mode 100644 index 00000000000..e7d3c4b7c75 --- /dev/null +++ b/public/app/core/components/sidemenu/TopSectionItem.test.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import TopSectionItem from './TopSectionItem'; + +const setup = (propOverrides?: object) => { + const props = Object.assign( + { + link: {}, + }, + propOverrides + ); + + return shallow(); +}; + +describe('Render', () => { + it('should render component', () => { + const wrapper = setup(); + + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/public/app/core/components/sidemenu/TopSectionItem.tsx b/public/app/core/components/sidemenu/TopSectionItem.tsx new file mode 100644 index 00000000000..4a207cc0df9 --- /dev/null +++ b/public/app/core/components/sidemenu/TopSectionItem.tsx @@ -0,0 +1,23 @@ +import React, { SFC } from 'react'; +import SideMenuDropDown from './SideMenuDropDown'; + +export interface Props { + link: any; +} + +const TopSectionItem: SFC = props => { + const { link } = props; + return ( +
    + + + + {link.img && } + + + {link.children && } +
    + ); +}; + +export default TopSectionItem; diff --git a/public/app/core/components/sidemenu/__snapshots__/BottomNavLinks.test.tsx.snap b/public/app/core/components/sidemenu/__snapshots__/BottomNavLinks.test.tsx.snap new file mode 100644 index 00000000000..f3181b617ad --- /dev/null +++ b/public/app/core/components/sidemenu/__snapshots__/BottomNavLinks.test.tsx.snap @@ -0,0 +1,163 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render should render children 1`] = ` + +`; + +exports[`Render should render component 1`] = ` +
    + + + +
      +
    • + +
    • +
    +
    +`; + +exports[`Render should render organisation switcher 1`] = ` + +`; + +exports[`Render should render subtitle 1`] = ` +
    + + + +
      +
    • + + subtitle + +
    • +
    • + +
    • +
    +
    +`; diff --git a/public/app/core/components/sidemenu/__snapshots__/BottomSection.test.tsx.snap b/public/app/core/components/sidemenu/__snapshots__/BottomSection.test.tsx.snap new file mode 100644 index 00000000000..ea92e8b7343 --- /dev/null +++ b/public/app/core/components/sidemenu/__snapshots__/BottomSection.test.tsx.snap @@ -0,0 +1,34 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render should render component 1`] = ` +
    + + + + +
    +`; diff --git a/public/app/core/components/sidemenu/__snapshots__/DropDownChild.test.tsx.snap b/public/app/core/components/sidemenu/__snapshots__/DropDownChild.test.tsx.snap new file mode 100644 index 00000000000..fcfd8ea936a --- /dev/null +++ b/public/app/core/components/sidemenu/__snapshots__/DropDownChild.test.tsx.snap @@ -0,0 +1,21 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render should render component 1`] = ` +
  • + +
  • +`; + +exports[`Render should render icon if exists 1`] = ` +
  • + + + +
  • +`; diff --git a/public/app/core/components/sidemenu/__snapshots__/SideMenu.test.tsx.snap b/public/app/core/components/sidemenu/__snapshots__/SideMenu.test.tsx.snap new file mode 100644 index 00000000000..78e64209749 --- /dev/null +++ b/public/app/core/components/sidemenu/__snapshots__/SideMenu.test.tsx.snap @@ -0,0 +1,22 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render should render component 1`] = ` +Array [ +
    , +
    , + , + , +] +`; diff --git a/public/app/core/components/sidemenu/__snapshots__/SideMenuDropDown.test.tsx.snap b/public/app/core/components/sidemenu/__snapshots__/SideMenuDropDown.test.tsx.snap new file mode 100644 index 00000000000..861168c1cc3 --- /dev/null +++ b/public/app/core/components/sidemenu/__snapshots__/SideMenuDropDown.test.tsx.snap @@ -0,0 +1,59 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render should render children 1`] = ` +
      +
    • + + link + +
    • + + + +
    +`; + +exports[`Render should render component 1`] = ` +
      +
    • + + link + +
    • +
    +`; diff --git a/public/app/core/components/sidemenu/__snapshots__/SignIn.test.tsx.snap b/public/app/core/components/sidemenu/__snapshots__/SignIn.test.tsx.snap new file mode 100644 index 00000000000..ba21be63b51 --- /dev/null +++ b/public/app/core/components/sidemenu/__snapshots__/SignIn.test.tsx.snap @@ -0,0 +1,40 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render should render component 1`] = ` + +`; diff --git a/public/app/core/components/sidemenu/__snapshots__/TopSection.test.tsx.snap b/public/app/core/components/sidemenu/__snapshots__/TopSection.test.tsx.snap new file mode 100644 index 00000000000..c78ec726302 --- /dev/null +++ b/public/app/core/components/sidemenu/__snapshots__/TopSection.test.tsx.snap @@ -0,0 +1,33 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render should render component 1`] = ` +
    + +
    +`; + +exports[`Render should render items 1`] = ` +
    + +
    +`; diff --git a/public/app/core/components/sidemenu/__snapshots__/TopSectionItem.test.tsx.snap b/public/app/core/components/sidemenu/__snapshots__/TopSectionItem.test.tsx.snap new file mode 100644 index 00000000000..f7ff56bff6b --- /dev/null +++ b/public/app/core/components/sidemenu/__snapshots__/TopSectionItem.test.tsx.snap @@ -0,0 +1,17 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render should render component 1`] = ` + +`; diff --git a/public/app/core/components/sidemenu/sidemenu.html b/public/app/core/components/sidemenu/sidemenu.html deleted file mode 100644 index 3a4ce11333e..00000000000 --- a/public/app/core/components/sidemenu/sidemenu.html +++ /dev/null @@ -1,81 +0,0 @@ - - - - - -  Close - - -
    - -
    - - \ No newline at end of file diff --git a/public/app/core/components/sidemenu/sidemenu.ts b/public/app/core/components/sidemenu/sidemenu.ts deleted file mode 100644 index f2798f874bd..00000000000 --- a/public/app/core/components/sidemenu/sidemenu.ts +++ /dev/null @@ -1,89 +0,0 @@ -import _ from 'lodash'; -import config from 'app/core/config'; -import $ from 'jquery'; -import coreModule from '../../core_module'; -import appEvents from 'app/core/app_events'; - -export class SideMenuCtrl { - user: any; - mainLinks: any; - bottomNav: any; - loginUrl: string; - isSignedIn: boolean; - isOpenMobile: boolean; - - /** @ngInject */ - constructor(private $scope, private $rootScope, private $location, private contextSrv, private $timeout) { - this.isSignedIn = contextSrv.isSignedIn; - this.user = contextSrv.user; - - const navTree = _.cloneDeep(config.bootData.navTree); - this.mainLinks = _.filter(navTree, item => !item.hideFromMenu); - this.bottomNav = _.filter(navTree, item => item.hideFromMenu); - this.loginUrl = 'login?redirect=' + encodeURIComponent(this.$location.path()); - - if (contextSrv.user.orgCount > 1) { - const profileNode = _.find(this.bottomNav, { id: 'profile' }); - if (profileNode) { - profileNode.showOrgSwitcher = true; - } - } - - this.$scope.$on('$routeChangeSuccess', () => { - this.loginUrl = 'login?redirect=' + encodeURIComponent(this.$location.path()); - }); - } - - toggleSideMenu() { - this.contextSrv.toggleSideMenu(); - appEvents.emit('toggle-sidemenu'); - - this.$timeout(() => { - this.$rootScope.$broadcast('render'); - }); - } - - toggleSideMenuSmallBreakpoint() { - appEvents.emit('toggle-sidemenu-mobile'); - } - - switchOrg() { - this.$rootScope.appEvent('show-modal', { - templateHtml: '', - }); - } - - itemClicked(item, evt) { - if (item.url === '/shortcuts') { - appEvents.emit('show-modal', { - templateHtml: '', - }); - evt.preventDefault(); - } - } -} - -export function sideMenuDirective() { - return { - restrict: 'E', - templateUrl: 'public/app/core/components/sidemenu/sidemenu.html', - controller: SideMenuCtrl, - bindToController: true, - controllerAs: 'ctrl', - scope: {}, - link: function(scope, elem) { - // hack to hide dropdown menu - elem.on('click.dropdown', '.dropdown-menu a', function(evt) { - const menu = $(evt.target).parents('.dropdown-menu'); - const parent = menu.parent(); - menu.detach(); - - setTimeout(function() { - parent.append(menu); - }, 100); - }); - }, - }; -} - -coreModule.directive('sidemenu', sideMenuDirective); diff --git a/public/app/core/components/sql_part/sql_part.ts b/public/app/core/components/sql_part/sql_part.ts new file mode 100644 index 00000000000..a9b76b1ed2d --- /dev/null +++ b/public/app/core/components/sql_part/sql_part.ts @@ -0,0 +1,74 @@ +import _ from 'lodash'; + +export class SqlPartDef { + type: string; + style: string; + label: string; + params: any[]; + defaultParams: any[]; + wrapOpen: string; + wrapClose: string; + separator: string; + + constructor(options: any) { + this.type = options.type; + if (options.label) { + this.label = options.label; + } else { + this.label = this.type[0].toUpperCase() + this.type.substring(1) + ':'; + } + this.style = options.style; + if (this.style === 'function') { + this.wrapOpen = '('; + this.wrapClose = ')'; + this.separator = ', '; + } else { + this.wrapOpen = ' '; + this.wrapClose = ' '; + this.separator = ' '; + } + this.params = options.params; + this.defaultParams = options.defaultParams; + } +} + +export class SqlPart { + part: any; + def: SqlPartDef; + params: any[]; + label: string; + name: string; + datatype: string; + + constructor(part: any, def: any) { + this.part = part; + this.def = def; + if (!this.def) { + throw { message: 'Could not find sql part ' + part.type }; + } + + this.datatype = part.datatype; + + if (part.name) { + this.name = part.name; + this.label = def.label + ' ' + part.name; + } else { + this.name = ''; + this.label = def.label; + } + + part.params = part.params || _.clone(this.def.defaultParams); + this.params = part.params; + } + + updateParam(strValue, index) { + // handle optional parameters + if (strValue === '' && this.def.params[index].optional) { + this.params.splice(index, 1); + } else { + this.params[index] = strValue; + } + + this.part.params = this.params; + } +} diff --git a/public/app/core/components/sql_part/sql_part_editor.ts b/public/app/core/components/sql_part/sql_part_editor.ts new file mode 100644 index 00000000000..5d0f63a6953 --- /dev/null +++ b/public/app/core/components/sql_part/sql_part_editor.ts @@ -0,0 +1,199 @@ +import _ from 'lodash'; +import $ from 'jquery'; +import coreModule from 'app/core/core_module'; + +const template = ` + +
    -
    -
    {{ctrl.lastQueryError}}
    -
    +
    +
    {{ctrl.lastQueryError}}
    +
    diff --git a/public/app/plugins/datasource/postgres/plugin.json b/public/app/plugins/datasource/postgres/plugin.json index af2dbc4468e..2c2e1690a65 100644 --- a/public/app/plugins/datasource/postgres/plugin.json +++ b/public/app/plugins/datasource/postgres/plugin.json @@ -19,4 +19,5 @@ "alerting": true, "annotations": true, "metrics": true + } diff --git a/public/app/plugins/datasource/postgres/postgres_query.ts b/public/app/plugins/datasource/postgres/postgres_query.ts new file mode 100644 index 00000000000..fd0987f2761 --- /dev/null +++ b/public/app/plugins/datasource/postgres/postgres_query.ts @@ -0,0 +1,285 @@ +import _ from 'lodash'; + +export default class PostgresQuery { + target: any; + templateSrv: any; + scopedVars: any; + + /** @ngInject */ + constructor(target, templateSrv?, scopedVars?) { + this.target = target; + this.templateSrv = templateSrv; + this.scopedVars = scopedVars; + + target.format = target.format || 'time_series'; + target.timeColumn = target.timeColumn || 'time'; + target.metricColumn = target.metricColumn || 'none'; + + target.group = target.group || []; + target.where = target.where || [{ type: 'macro', name: '$__timeFilter', params: [] }]; + target.select = target.select || [[{ type: 'column', params: ['value'] }]]; + + // handle pre query gui panels gracefully + if (!('rawQuery' in this.target)) { + if ('rawSql' in target) { + // pre query gui panel + target.rawQuery = true; + } else { + // new panel + target.rawQuery = false; + } + } + + // give interpolateQueryStr access to this + this.interpolateQueryStr = this.interpolateQueryStr.bind(this); + } + + // remove identifier quoting from identifier to use in metadata queries + unquoteIdentifier(value) { + if (value[0] === '"' && value[value.length - 1] === '"') { + return value.substring(1, value.length - 1).replace(/""/g, '"'); + } else { + return value; + } + } + + quoteIdentifier(value) { + return '"' + value.replace(/"/g, '""') + '"'; + } + + quoteLiteral(value) { + return "'" + value.replace(/'/g, "''") + "'"; + } + + escapeLiteral(value) { + return value.replace(/'/g, "''"); + } + + hasTimeGroup() { + return _.find(this.target.group, (g: any) => g.type === 'time'); + } + + hasMetricColumn() { + return this.target.metricColumn !== 'none'; + } + + interpolateQueryStr(value, variable, defaultFormatFn) { + // if no multi or include all do not regexEscape + if (!variable.multi && !variable.includeAll) { + return this.escapeLiteral(value); + } + + if (typeof value === 'string') { + return this.quoteLiteral(value); + } + + const escapedValues = _.map(value, this.quoteLiteral); + return escapedValues.join(','); + } + + render(interpolate?) { + const target = this.target; + + // new query with no table set yet + if (!this.target.rawQuery && !('table' in this.target)) { + return ''; + } + + if (!target.rawQuery) { + target.rawSql = this.buildQuery(); + } + + if (interpolate) { + return this.templateSrv.replace(target.rawSql, this.scopedVars, this.interpolateQueryStr); + } else { + return target.rawSql; + } + } + + hasUnixEpochTimecolumn() { + return ['int4', 'int8', 'float4', 'float8', 'numeric'].indexOf(this.target.timeColumnType) > -1; + } + + buildTimeColumn(alias = true) { + const timeGroup = this.hasTimeGroup(); + let query; + let macro = '$__timeGroup'; + + if (timeGroup) { + let args; + if (timeGroup.params.length > 1 && timeGroup.params[1] !== 'none') { + args = timeGroup.params.join(','); + } else { + args = timeGroup.params[0]; + } + if (this.hasUnixEpochTimecolumn()) { + macro = '$__unixEpochGroup'; + } + if (alias) { + macro += 'Alias'; + } + query = macro + '(' + this.target.timeColumn + ',' + args + ')'; + } else { + query = this.target.timeColumn; + if (alias) { + query += ' AS "time"'; + } + } + + return query; + } + + buildMetricColumn() { + if (this.hasMetricColumn()) { + return this.target.metricColumn + ' AS metric'; + } + + return ''; + } + + buildValueColumns() { + let query = ''; + for (const column of this.target.select) { + query += ',\n ' + this.buildValueColumn(column); + } + + return query; + } + + buildValueColumn(column) { + let query = ''; + + const columnName = _.find(column, (g: any) => g.type === 'column'); + query = columnName.params[0]; + + const aggregate = _.find(column, (g: any) => g.type === 'aggregate' || g.type === 'percentile'); + const windows = _.find(column, (g: any) => g.type === 'window' || g.type === 'moving_window'); + + if (aggregate) { + const func = aggregate.params[0]; + switch (aggregate.type) { + case 'aggregate': + if (func === 'first' || func === 'last') { + query = func + '(' + query + ',' + this.target.timeColumn + ')'; + } else { + query = func + '(' + query + ')'; + } + break; + case 'percentile': + query = func + '(' + aggregate.params[1] + ') WITHIN GROUP (ORDER BY ' + query + ')'; + break; + } + } + + if (windows) { + const overParts = []; + if (this.hasMetricColumn()) { + overParts.push('PARTITION BY ' + this.target.metricColumn); + } + overParts.push('ORDER BY ' + this.buildTimeColumn(false)); + + const over = overParts.join(' '); + let curr: string; + let prev: string; + switch (windows.type) { + case 'window': + switch (windows.params[0]) { + case 'increase': + curr = query; + prev = 'lag(' + curr + ') OVER (' + over + ')'; + query = '(CASE WHEN ' + curr + ' >= ' + prev + ' THEN ' + curr + ' - ' + prev + ' ELSE ' + curr + ' END)'; + break; + case 'rate': + let timeColumn = this.target.timeColumn; + if (aggregate) { + timeColumn = 'min(' + timeColumn + ')'; + } + + curr = query; + prev = 'lag(' + curr + ') OVER (' + over + ')'; + query = '(CASE WHEN ' + curr + ' >= ' + prev + ' THEN ' + curr + ' - ' + prev + ' ELSE ' + curr + ' END)'; + query += '/extract(epoch from ' + timeColumn + ' - lag(' + timeColumn + ') OVER (' + over + '))'; + break; + default: + query = windows.params[0] + '(' + query + ') OVER (' + over + ')'; + break; + } + break; + case 'moving_window': + query = windows.params[0] + '(' + query + ') OVER (' + over + ' ROWS ' + windows.params[1] + ' PRECEDING)'; + break; + } + } + + const alias = _.find(column, (g: any) => g.type === 'alias'); + if (alias) { + query += ' AS ' + this.quoteIdentifier(alias.params[0]); + } + + return query; + } + + buildWhereClause() { + let query = ''; + const conditions = _.map(this.target.where, (tag, index) => { + switch (tag.type) { + case 'macro': + return tag.name + '(' + this.target.timeColumn + ')'; + break; + case 'expression': + return tag.params.join(' '); + break; + } + }); + + if (conditions.length > 0) { + query = '\nWHERE\n ' + conditions.join(' AND\n '); + } + + return query; + } + + buildGroupClause() { + let query = ''; + let groupSection = ''; + + for (let i = 0; i < this.target.group.length; i++) { + const part = this.target.group[i]; + if (i > 0) { + groupSection += ', '; + } + if (part.type === 'time') { + groupSection += '1'; + } else { + groupSection += part.params[0]; + } + } + + if (groupSection.length) { + query = '\nGROUP BY ' + groupSection; + if (this.hasMetricColumn()) { + query += ',2'; + } + } + return query; + } + + buildQuery() { + let query = 'SELECT'; + + query += '\n ' + this.buildTimeColumn(); + if (this.hasMetricColumn()) { + query += ',\n ' + this.buildMetricColumn(); + } + query += this.buildValueColumns(); + + query += '\nFROM ' + this.target.table; + + query += this.buildWhereClause(); + query += this.buildGroupClause(); + + query += '\nORDER BY 1'; + + return query; + } +} diff --git a/public/app/plugins/datasource/postgres/query_ctrl.ts b/public/app/plugins/datasource/postgres/query_ctrl.ts index fceca1e2037..1cb8bfa5a05 100644 --- a/public/app/plugins/datasource/postgres/query_ctrl.ts +++ b/public/app/plugins/datasource/postgres/query_ctrl.ts @@ -1,12 +1,10 @@ import _ from 'lodash'; +import appEvents from 'app/core/app_events'; +import { PostgresMetaQuery } from './meta_query'; import { QueryCtrl } from 'app/plugins/sdk'; - -export interface PostgresQuery { - refId: string; - format: string; - alias: string; - rawSql: string; -} +import { SqlPart } from 'app/core/components/sql_part/sql_part'; +import PostgresQuery from './postgres_query'; +import sqlPart from './sql_part'; export interface QueryMeta { sql: string; @@ -26,17 +24,29 @@ export class PostgresQueryCtrl extends QueryCtrl { showLastQuerySQL: boolean; formats: any[]; - target: PostgresQuery; + queryModel: PostgresQuery; + metaBuilder: PostgresMetaQuery; lastQueryMeta: QueryMeta; lastQueryError: string; showHelp: boolean; + tableSegment: any; + whereAdd: any; + timeColumnSegment: any; + metricColumnSegment: any; + selectMenu: any[]; + selectParts: SqlPart[][]; + groupParts: SqlPart[]; + whereParts: SqlPart[]; + groupAdd: any; /** @ngInject */ - constructor($scope, $injector) { + constructor($scope, $injector, private templateSrv, private $q, private uiSegmentSrv) { super($scope, $injector); + this.target = this.target; + this.queryModel = new PostgresQuery(this.target, templateSrv, this.panel.scopedVars); + this.metaBuilder = new PostgresMetaQuery(this.target, this.queryModel); + this.updateProjection(); - this.target.format = this.target.format || 'time_series'; - this.target.alias = ''; this.formats = [{ text: 'Time series', value: 'time_series' }, { text: 'Table', value: 'table' }]; if (!this.target.rawSql) { @@ -44,15 +54,231 @@ export class PostgresQueryCtrl extends QueryCtrl { if (this.panelCtrl.panel.type === 'table') { this.target.format = 'table'; this.target.rawSql = 'SELECT 1'; + this.target.rawQuery = true; } else { this.target.rawSql = defaultQuery; + this.datasource.metricFindQuery(this.metaBuilder.findMetricTable()).then(result => { + if (result.length > 0) { + this.target.table = result[0].text; + let segment = this.uiSegmentSrv.newSegment(this.target.table); + this.tableSegment.html = segment.html; + this.tableSegment.value = segment.value; + + this.target.timeColumn = result[1].text; + segment = this.uiSegmentSrv.newSegment(this.target.timeColumn); + this.timeColumnSegment.html = segment.html; + this.timeColumnSegment.value = segment.value; + + this.target.timeColumnType = 'timestamp'; + this.target.select = [[{ type: 'column', params: [result[2].text] }]]; + this.updateProjection(); + this.panelCtrl.refresh(); + } + }); } } + if (!this.target.table) { + this.tableSegment = uiSegmentSrv.newSegment({ value: 'select table', fake: true }); + } else { + this.tableSegment = uiSegmentSrv.newSegment(this.target.table); + } + + this.timeColumnSegment = uiSegmentSrv.newSegment(this.target.timeColumn); + this.metricColumnSegment = uiSegmentSrv.newSegment(this.target.metricColumn); + + this.buildSelectMenu(); + this.whereAdd = this.uiSegmentSrv.newPlusButton(); + this.groupAdd = this.uiSegmentSrv.newPlusButton(); + this.panelCtrl.events.on('data-received', this.onDataReceived.bind(this), $scope); this.panelCtrl.events.on('data-error', this.onDataError.bind(this), $scope); } + updateProjection() { + this.selectParts = _.map(this.target.select, function(parts: any) { + return _.map(parts, sqlPart.create).filter(n => n); + }); + this.whereParts = _.map(this.target.where, sqlPart.create).filter(n => n); + this.groupParts = _.map(this.target.group, sqlPart.create).filter(n => n); + } + + updatePersistedParts() { + this.target.select = _.map(this.selectParts, function(selectParts) { + return _.map(selectParts, function(part: any) { + return { type: part.def.type, datatype: part.datatype, params: part.params }; + }); + }); + this.target.where = _.map(this.whereParts, function(part: any) { + return { type: part.def.type, datatype: part.datatype, name: part.name, params: part.params }; + }); + this.target.group = _.map(this.groupParts, function(part: any) { + return { type: part.def.type, datatype: part.datatype, params: part.params }; + }); + } + + buildSelectMenu() { + this.selectMenu = []; + const aggregates = { + text: 'Aggregate Functions', + value: 'aggregate', + submenu: [ + { text: 'Average', value: 'avg' }, + { text: 'Count', value: 'count' }, + { text: 'Maximum', value: 'max' }, + { text: 'Minimum', value: 'min' }, + { text: 'Sum', value: 'sum' }, + { text: 'Standard deviation', value: 'stddev' }, + { text: 'Variance', value: 'variance' }, + ], + }; + + // first and last aggregate are timescaledb specific + if (this.datasource.jsonData.timescaledb === true) { + aggregates.submenu.push({ text: 'First', value: 'first' }); + aggregates.submenu.push({ text: 'Last', value: 'last' }); + } + + this.selectMenu.push(aggregates); + + // ordered set aggregates require postgres 9.4+ + if (this.datasource.jsonData.postgresVersion >= 904) { + const aggregates2 = { + text: 'Ordered-Set Aggregate Functions', + value: 'percentile', + submenu: [ + { text: 'Percentile (continuous)', value: 'percentile_cont' }, + { text: 'Percentile (discrete)', value: 'percentile_disc' }, + ], + }; + this.selectMenu.push(aggregates2); + } + + const windows = { + text: 'Window Functions', + value: 'window', + submenu: [ + { text: 'Increase', value: 'increase' }, + { text: 'Rate', value: 'rate' }, + { text: 'Sum', value: 'sum' }, + { text: 'Moving Average', value: 'avg', type: 'moving_window' }, + ], + }; + this.selectMenu.push(windows); + + this.selectMenu.push({ text: 'Alias', value: 'alias' }); + this.selectMenu.push({ text: 'Column', value: 'column' }); + } + + toggleEditorMode() { + if (this.target.rawQuery) { + appEvents.emit('confirm-modal', { + title: 'Warning', + text2: 'Switching to query builder may overwrite your raw SQL.', + icon: 'fa-exclamation', + yesText: 'Switch', + onConfirm: () => { + this.target.rawQuery = !this.target.rawQuery; + }, + }); + } else { + this.target.rawQuery = !this.target.rawQuery; + } + } + + resetPlusButton(button) { + const plusButton = this.uiSegmentSrv.newPlusButton(); + button.html = plusButton.html; + button.value = plusButton.value; + } + + getTableSegments() { + return this.datasource + .metricFindQuery(this.metaBuilder.buildTableQuery()) + .then(this.transformToSegments({})) + .catch(this.handleQueryError.bind(this)); + } + + tableChanged() { + this.target.table = this.tableSegment.value; + this.target.where = []; + this.target.group = []; + this.updateProjection(); + + const segment = this.uiSegmentSrv.newSegment('none'); + this.metricColumnSegment.html = segment.html; + this.metricColumnSegment.value = segment.value; + this.target.metricColumn = 'none'; + + const task1 = this.datasource.metricFindQuery(this.metaBuilder.buildColumnQuery('time')).then(result => { + // check if time column is still valid + if (result.length > 0 && !_.find(result, (r: any) => r.text === this.target.timeColumn)) { + const segment = this.uiSegmentSrv.newSegment(result[0].text); + this.timeColumnSegment.html = segment.html; + this.timeColumnSegment.value = segment.value; + } + return this.timeColumnChanged(false); + }); + const task2 = this.datasource.metricFindQuery(this.metaBuilder.buildColumnQuery('value')).then(result => { + if (result.length > 0) { + this.target.select = [[{ type: 'column', params: [result[0].text] }]]; + this.updateProjection(); + } + }); + + this.$q.all([task1, task2]).then(() => { + this.panelCtrl.refresh(); + }); + } + + getTimeColumnSegments() { + return this.datasource + .metricFindQuery(this.metaBuilder.buildColumnQuery('time')) + .then(this.transformToSegments({})) + .catch(this.handleQueryError.bind(this)); + } + + timeColumnChanged(refresh?: boolean) { + this.target.timeColumn = this.timeColumnSegment.value; + return this.datasource.metricFindQuery(this.metaBuilder.buildDatatypeQuery(this.target.timeColumn)).then(result => { + if (result.length === 1) { + if (this.target.timeColumnType !== result[0].text) { + this.target.timeColumnType = result[0].text; + } + let partModel; + if (this.queryModel.hasUnixEpochTimecolumn()) { + partModel = sqlPart.create({ type: 'macro', name: '$__unixEpochFilter', params: [] }); + } else { + partModel = sqlPart.create({ type: 'macro', name: '$__timeFilter', params: [] }); + } + + if (this.whereParts.length >= 1 && this.whereParts[0].def.type === 'macro') { + // replace current macro + this.whereParts[0] = partModel; + } else { + this.whereParts.splice(0, 0, partModel); + } + } + + this.updatePersistedParts(); + if (refresh !== false) { + this.panelCtrl.refresh(); + } + }); + } + + getMetricColumnSegments() { + return this.datasource + .metricFindQuery(this.metaBuilder.buildColumnQuery('metric')) + .then(this.transformToSegments({ addNone: true })) + .catch(this.handleQueryError.bind(this)); + } + + metricColumnChanged() { + this.target.metricColumn = this.metricColumnSegment.value; + this.panelCtrl.refresh(); + } + onDataReceived(dataList) { this.lastQueryMeta = null; this.lastQueryError = null; @@ -72,4 +298,356 @@ export class PostgresQueryCtrl extends QueryCtrl { } } } + + transformToSegments(config) { + return results => { + const segments = _.map(results, segment => { + return this.uiSegmentSrv.newSegment({ + value: segment.text, + expandable: segment.expandable, + }); + }); + + if (config.addTemplateVars) { + for (const variable of this.templateSrv.variables) { + let value; + value = '$' + variable.name; + if (config.templateQuoter && variable.multi === false) { + value = config.templateQuoter(value); + } + + segments.unshift( + this.uiSegmentSrv.newSegment({ + type: 'template', + value: value, + expandable: true, + }) + ); + } + } + + if (config.addNone) { + segments.unshift(this.uiSegmentSrv.newSegment({ type: 'template', value: 'none', expandable: true })); + } + + return segments; + }; + } + + findAggregateIndex(selectParts) { + return _.findIndex(selectParts, (p: any) => p.def.type === 'aggregate' || p.def.type === 'percentile'); + } + + findWindowIndex(selectParts) { + return _.findIndex(selectParts, (p: any) => p.def.type === 'window' || p.def.type === 'moving_window'); + } + + addSelectPart(selectParts, item, subItem) { + let partType = item.value; + if (subItem && subItem.type) { + partType = subItem.type; + } + let partModel = sqlPart.create({ type: partType }); + if (subItem) { + partModel.params[0] = subItem.value; + } + let addAlias = false; + + switch (partType) { + case 'column': + const parts = _.map(selectParts, function(part: any) { + return sqlPart.create({ type: part.def.type, params: _.clone(part.params) }); + }); + this.selectParts.push(parts); + break; + case 'percentile': + case 'aggregate': + // add group by if no group by yet + if (this.target.group.length === 0) { + this.addGroup('time', '$__interval'); + } + const aggIndex = this.findAggregateIndex(selectParts); + if (aggIndex !== -1) { + // replace current aggregation + selectParts[aggIndex] = partModel; + } else { + selectParts.splice(1, 0, partModel); + } + if (!_.find(selectParts, (p: any) => p.def.type === 'alias')) { + addAlias = true; + } + break; + case 'moving_window': + case 'window': + const windowIndex = this.findWindowIndex(selectParts); + if (windowIndex !== -1) { + // replace current window function + selectParts[windowIndex] = partModel; + } else { + const aggIndex = this.findAggregateIndex(selectParts); + if (aggIndex !== -1) { + selectParts.splice(aggIndex + 1, 0, partModel); + } else { + selectParts.splice(1, 0, partModel); + } + } + if (!_.find(selectParts, (p: any) => p.def.type === 'alias')) { + addAlias = true; + } + break; + case 'alias': + addAlias = true; + break; + } + + if (addAlias) { + // set initial alias name to column name + partModel = sqlPart.create({ type: 'alias', params: [selectParts[0].params[0].replace(/"/g, '')] }); + if (selectParts[selectParts.length - 1].def.type === 'alias') { + selectParts[selectParts.length - 1] = partModel; + } else { + selectParts.push(partModel); + } + } + + this.updatePersistedParts(); + this.panelCtrl.refresh(); + } + + removeSelectPart(selectParts, part) { + if (part.def.type === 'column') { + // remove all parts of column unless its last column + if (this.selectParts.length > 1) { + const modelsIndex = _.indexOf(this.selectParts, selectParts); + this.selectParts.splice(modelsIndex, 1); + } + } else { + const partIndex = _.indexOf(selectParts, part); + selectParts.splice(partIndex, 1); + } + + this.updatePersistedParts(); + } + + handleSelectPartEvent(selectParts, part, evt) { + switch (evt.name) { + case 'get-param-options': { + switch (part.def.type) { + case 'aggregate': + return this.datasource + .metricFindQuery(this.metaBuilder.buildAggregateQuery()) + .then(this.transformToSegments({})) + .catch(this.handleQueryError.bind(this)); + case 'column': + return this.datasource + .metricFindQuery(this.metaBuilder.buildColumnQuery('value')) + .then(this.transformToSegments({})) + .catch(this.handleQueryError.bind(this)); + } + } + case 'part-param-changed': { + this.updatePersistedParts(); + this.panelCtrl.refresh(); + break; + } + case 'action': { + this.removeSelectPart(selectParts, part); + this.panelCtrl.refresh(); + break; + } + case 'get-part-actions': { + return this.$q.when([{ text: 'Remove', value: 'remove-part' }]); + } + } + } + + handleGroupPartEvent(part, index, evt) { + switch (evt.name) { + case 'get-param-options': { + return this.datasource + .metricFindQuery(this.metaBuilder.buildColumnQuery()) + .then(this.transformToSegments({})) + .catch(this.handleQueryError.bind(this)); + } + case 'part-param-changed': { + this.updatePersistedParts(); + this.panelCtrl.refresh(); + break; + } + case 'action': { + this.removeGroup(part, index); + this.panelCtrl.refresh(); + break; + } + case 'get-part-actions': { + return this.$q.when([{ text: 'Remove', value: 'remove-part' }]); + } + } + } + + addGroup(partType, value) { + let params = [value]; + if (partType === 'time') { + params = ['$__interval', 'none']; + } + const partModel = sqlPart.create({ type: partType, params: params }); + + if (partType === 'time') { + // put timeGroup at start + this.groupParts.splice(0, 0, partModel); + } else { + this.groupParts.push(partModel); + } + + // add aggregates when adding group by + for (const selectParts of this.selectParts) { + if (!selectParts.some(part => part.def.type === 'aggregate')) { + const aggregate = sqlPart.create({ type: 'aggregate', params: ['avg'] }); + selectParts.splice(1, 0, aggregate); + if (!selectParts.some(part => part.def.type === 'alias')) { + const alias = sqlPart.create({ type: 'alias', params: [selectParts[0].part.params[0]] }); + selectParts.push(alias); + } + } + } + + this.updatePersistedParts(); + } + + removeGroup(part, index) { + if (part.def.type === 'time') { + // remove aggregations + this.selectParts = _.map(this.selectParts, (s: any) => { + return _.filter(s, (part: any) => { + if (part.def.type === 'aggregate' || part.def.type === 'percentile') { + return false; + } + return true; + }); + }); + } + + this.groupParts.splice(index, 1); + this.updatePersistedParts(); + } + + handleWherePartEvent(whereParts, part, evt, index) { + switch (evt.name) { + case 'get-param-options': { + switch (evt.param.name) { + case 'left': + return this.datasource + .metricFindQuery(this.metaBuilder.buildColumnQuery()) + .then(this.transformToSegments({})) + .catch(this.handleQueryError.bind(this)); + case 'right': + if (['int4', 'int8', 'float4', 'float8', 'timestamp', 'timestamptz'].indexOf(part.datatype) > -1) { + // don't do value lookups for numerical fields + return this.$q.when([]); + } else { + return this.datasource + .metricFindQuery(this.metaBuilder.buildValueQuery(part.params[0])) + .then( + this.transformToSegments({ + addTemplateVars: true, + templateQuoter: (v: string) => { + return this.queryModel.quoteLiteral(v); + }, + }) + ) + .catch(this.handleQueryError.bind(this)); + } + case 'op': + return this.$q.when(this.uiSegmentSrv.newOperators(this.metaBuilder.getOperators(part.datatype))); + default: + return this.$q.when([]); + } + } + case 'part-param-changed': { + this.updatePersistedParts(); + this.datasource.metricFindQuery(this.metaBuilder.buildDatatypeQuery(part.params[0])).then((d: any) => { + if (d.length === 1) { + part.datatype = d[0].text; + } + }); + this.panelCtrl.refresh(); + break; + } + case 'action': { + // remove element + whereParts.splice(index, 1); + this.updatePersistedParts(); + this.panelCtrl.refresh(); + break; + } + case 'get-part-actions': { + return this.$q.when([{ text: 'Remove', value: 'remove-part' }]); + } + } + } + + getWhereOptions() { + const options = []; + if (this.queryModel.hasUnixEpochTimecolumn()) { + options.push(this.uiSegmentSrv.newSegment({ type: 'macro', value: '$__unixEpochFilter' })); + } else { + options.push(this.uiSegmentSrv.newSegment({ type: 'macro', value: '$__timeFilter' })); + } + options.push(this.uiSegmentSrv.newSegment({ type: 'expression', value: 'Expression' })); + return this.$q.when(options); + } + + addWhereAction(part, index) { + switch (this.whereAdd.type) { + case 'macro': { + const partModel = sqlPart.create({ type: 'macro', name: this.whereAdd.value, params: [] }); + if (this.whereParts.length >= 1 && this.whereParts[0].def.type === 'macro') { + // replace current macro + this.whereParts[0] = partModel; + } else { + this.whereParts.splice(0, 0, partModel); + } + break; + } + default: { + this.whereParts.push(sqlPart.create({ type: 'expression', params: ['value', '=', 'value'] })); + } + } + + this.updatePersistedParts(); + this.resetPlusButton(this.whereAdd); + this.panelCtrl.refresh(); + } + + getGroupOptions() { + return this.datasource + .metricFindQuery(this.metaBuilder.buildColumnQuery('group')) + .then(tags => { + const options = []; + if (!this.queryModel.hasTimeGroup()) { + options.push(this.uiSegmentSrv.newSegment({ type: 'time', value: 'time($__interval,none)' })); + } + for (const tag of tags) { + options.push(this.uiSegmentSrv.newSegment({ type: 'column', value: tag.text })); + } + return options; + }) + .catch(this.handleQueryError.bind(this)); + } + + addGroupAction() { + switch (this.groupAdd.value) { + default: { + this.addGroup(this.groupAdd.type, this.groupAdd.value); + } + } + + this.resetPlusButton(this.groupAdd); + this.panelCtrl.refresh(); + } + + handleQueryError(err) { + this.error = err.message || 'Failed to issue metric query'; + return []; + } } diff --git a/public/app/plugins/datasource/postgres/specs/datasource.test.ts b/public/app/plugins/datasource/postgres/specs/datasource.test.ts index 2c0c0554250..49541028caf 100644 --- a/public/app/plugins/datasource/postgres/specs/datasource.test.ts +++ b/public/app/plugins/datasource/postgres/specs/datasource.test.ts @@ -2,22 +2,33 @@ import moment from 'moment'; import { PostgresDatasource } from '../datasource'; import { CustomVariable } from 'app/features/templating/custom_variable'; -describe('PostgreSQLDatasource', function() { +describe('PostgreSQLDatasource', () => { const instanceSettings = { name: 'postgresql' }; const backendSrv = {}; const templateSrv = { replace: jest.fn(text => text), }; + const raw = { + from: moment.utc('2018-04-25 10:00'), + to: moment.utc('2018-04-25 11:00'), + }; const ctx = { backendSrv, + timeSrvMock: { + timeRange: () => ({ + from: raw.from, + to: raw.to, + raw: raw, + }), + }, } as any; beforeEach(() => { - ctx.ds = new PostgresDatasource(instanceSettings, backendSrv, {}, templateSrv); + ctx.ds = new PostgresDatasource(instanceSettings, backendSrv, {}, templateSrv, ctx.timeSrvMock); }); - describe('When performing annotationQuery', function() { + describe('When performing annotationQuery', () => { let results; const annotationName = 'MyAnno'; @@ -51,16 +62,16 @@ describe('PostgreSQLDatasource', function() { }, }; - beforeEach(function() { + beforeEach(() => { ctx.backendSrv.datasourceRequest = jest.fn(options => { return Promise.resolve({ data: response, status: 200 }); }); - ctx.ds.annotationQuery(options).then(function(data) { + ctx.ds.annotationQuery(options).then(data => { results = data; }); }); - it('should return annotation list', function() { + it('should return annotation list', () => { expect(results.length).toBe(3); expect(results[0].text).toBe('some text'); @@ -74,7 +85,7 @@ describe('PostgreSQLDatasource', function() { }); }); - describe('When performing metricFindQuery', function() { + describe('When performing metricFindQuery', () => { let results; const query = 'select * from atable'; const response = { @@ -94,23 +105,23 @@ describe('PostgreSQLDatasource', function() { }, }; - beforeEach(function() { + beforeEach(() => { ctx.backendSrv.datasourceRequest = jest.fn(options => { return Promise.resolve({ data: response, status: 200 }); }); - ctx.ds.metricFindQuery(query).then(function(data) { + ctx.ds.metricFindQuery(query).then(data => { results = data; }); }); - it('should return list of all column values', function() { + it('should return list of all column values', () => { expect(results.length).toBe(6); expect(results[0].text).toBe('aTitle'); expect(results[5].text).toBe('some text3'); }); }); - describe('When performing metricFindQuery with key, value columns', function() { + describe('When performing metricFindQuery with key, value columns', () => { let results; const query = 'select * from atable'; const response = { @@ -130,16 +141,16 @@ describe('PostgreSQLDatasource', function() { }, }; - beforeEach(function() { + beforeEach(() => { ctx.backendSrv.datasourceRequest = jest.fn(options => { return Promise.resolve({ data: response, status: 200 }); }); - ctx.ds.metricFindQuery(query).then(function(data) { + ctx.ds.metricFindQuery(query).then(data => { results = data; }); }); - it('should return list of as text, value', function() { + it('should return list of as text, value', () => { expect(results.length).toBe(3); expect(results[0].text).toBe('aTitle'); expect(results[0].value).toBe('value1'); @@ -148,7 +159,7 @@ describe('PostgreSQLDatasource', function() { }); }); - describe('When performing metricFindQuery with key, value columns and with duplicate keys', function() { + describe('When performing metricFindQuery with key, value columns and with duplicate keys', () => { let results; const query = 'select * from atable'; const response = { @@ -172,13 +183,13 @@ describe('PostgreSQLDatasource', function() { ctx.backendSrv.datasourceRequest = jest.fn(options => { return Promise.resolve({ data: response, status: 200 }); }); - ctx.ds.metricFindQuery(query).then(function(data) { + ctx.ds.metricFindQuery(query).then(data => { results = data; }); //ctx.$rootScope.$apply(); }); - it('should return list of unique keys', function() { + it('should return list of unique keys', () => { expect(results.length).toBe(1); expect(results[0].text).toBe('aTitle'); expect(results[0].value).toBe('same'); @@ -186,7 +197,7 @@ describe('PostgreSQLDatasource', function() { }); describe('When interpolating variables', () => { - beforeEach(function() { + beforeEach(() => { ctx.variable = new CustomVariable({}, {}); }); @@ -219,6 +230,7 @@ describe('PostgreSQLDatasource', function() { it('should return a quoted value', () => { ctx.variable.multi = true; expect(ctx.ds.interpolateVariable("a'bc", ctx.variable)).toEqual("'a''bc'"); + expect(ctx.ds.interpolateVariable("a'b'c", ctx.variable)).toEqual("'a''b''c'"); }); }); diff --git a/public/app/plugins/datasource/postgres/specs/postgres_query.test.ts b/public/app/plugins/datasource/postgres/specs/postgres_query.test.ts new file mode 100644 index 00000000000..877bd47618b --- /dev/null +++ b/public/app/plugins/datasource/postgres/specs/postgres_query.test.ts @@ -0,0 +1,155 @@ +import PostgresQuery from '../postgres_query'; + +describe('PostgresQuery', function() { + const templateSrv = { + replace: jest.fn(text => text), + }; + + describe('When initializing', function() { + it('should not be in SQL mode', function() { + const query = new PostgresQuery({}, templateSrv); + expect(query.target.rawQuery).toBe(false); + }); + it('should be in SQL mode for pre query builder queries', function() { + const query = new PostgresQuery({ rawSql: 'SELECT 1' }, templateSrv); + expect(query.target.rawQuery).toBe(true); + }); + }); + + describe('When generating time column SQL', function() { + const query = new PostgresQuery({}, templateSrv); + + query.target.timeColumn = 'time'; + expect(query.buildTimeColumn()).toBe('time AS "time"'); + query.target.timeColumn = '"time"'; + expect(query.buildTimeColumn()).toBe('"time" AS "time"'); + }); + + describe('When generating time column SQL with group by time', function() { + let query = new PostgresQuery( + { timeColumn: 'time', group: [{ type: 'time', params: ['5m', 'none'] }] }, + templateSrv + ); + expect(query.buildTimeColumn()).toBe('$__timeGroupAlias(time,5m)'); + expect(query.buildTimeColumn(false)).toBe('$__timeGroup(time,5m)'); + + query = new PostgresQuery({ timeColumn: 'time', group: [{ type: 'time', params: ['5m', 'NULL'] }] }, templateSrv); + expect(query.buildTimeColumn()).toBe('$__timeGroupAlias(time,5m,NULL)'); + + query = new PostgresQuery( + { timeColumn: 'time', timeColumnType: 'int4', group: [{ type: 'time', params: ['5m', 'none'] }] }, + templateSrv + ); + expect(query.buildTimeColumn()).toBe('$__unixEpochGroupAlias(time,5m)'); + expect(query.buildTimeColumn(false)).toBe('$__unixEpochGroup(time,5m)'); + }); + + describe('When generating metric column SQL', function() { + const query = new PostgresQuery({}, templateSrv); + + query.target.metricColumn = 'host'; + expect(query.buildMetricColumn()).toBe('host AS metric'); + query.target.metricColumn = '"host"'; + expect(query.buildMetricColumn()).toBe('"host" AS metric'); + }); + + describe('When generating value column SQL', function() { + const query = new PostgresQuery({}, templateSrv); + + let column = [{ type: 'column', params: ['value'] }]; + expect(query.buildValueColumn(column)).toBe('value'); + column = [{ type: 'column', params: ['value'] }, { type: 'alias', params: ['alias'] }]; + expect(query.buildValueColumn(column)).toBe('value AS "alias"'); + column = [ + { type: 'column', params: ['v'] }, + { type: 'alias', params: ['a'] }, + { type: 'aggregate', params: ['max'] }, + ]; + expect(query.buildValueColumn(column)).toBe('max(v) AS "a"'); + column = [ + { type: 'column', params: ['v'] }, + { type: 'alias', params: ['a'] }, + { type: 'window', params: ['increase'] }, + ]; + expect(query.buildValueColumn(column)).toBe( + '(CASE WHEN v >= lag(v) OVER (ORDER BY time) THEN v - lag(v) OVER (ORDER BY time) ELSE v END) AS "a"' + ); + }); + + describe('When generating value column SQL with metric column', function() { + const query = new PostgresQuery({}, templateSrv); + query.target.metricColumn = 'host'; + + let column = [{ type: 'column', params: ['value'] }]; + expect(query.buildValueColumn(column)).toBe('value'); + column = [{ type: 'column', params: ['value'] }, { type: 'alias', params: ['alias'] }]; + expect(query.buildValueColumn(column)).toBe('value AS "alias"'); + column = [ + { type: 'column', params: ['v'] }, + { type: 'alias', params: ['a'] }, + { type: 'aggregate', params: ['max'] }, + ]; + expect(query.buildValueColumn(column)).toBe('max(v) AS "a"'); + column = [ + { type: 'column', params: ['v'] }, + { type: 'alias', params: ['a'] }, + { type: 'window', params: ['increase'] }, + ]; + expect(query.buildValueColumn(column)).toBe( + '(CASE WHEN v >= lag(v) OVER (PARTITION BY host ORDER BY time) THEN v - lag(v) OVER (PARTITION BY host ORDER BY time) ELSE v END) AS "a"' + ); + column = [ + { type: 'column', params: ['v'] }, + { type: 'alias', params: ['a'] }, + { type: 'aggregate', params: ['max'] }, + { type: 'window', params: ['increase'] }, + ]; + expect(query.buildValueColumn(column)).toBe( + '(CASE WHEN max(v) >= lag(max(v)) OVER (PARTITION BY host ORDER BY time) ' + + 'THEN max(v) - lag(max(v)) OVER (PARTITION BY host ORDER BY time) ELSE max(v) END) AS "a"' + ); + }); + + describe('When generating WHERE clause', function() { + const query = new PostgresQuery({ where: [] }, templateSrv); + + expect(query.buildWhereClause()).toBe(''); + + query.target.timeColumn = 't'; + query.target.where = [{ type: 'macro', name: '$__timeFilter' }]; + expect(query.buildWhereClause()).toBe('\nWHERE\n $__timeFilter(t)'); + + query.target.where = [{ type: 'expression', params: ['v', '=', '1'] }]; + expect(query.buildWhereClause()).toBe('\nWHERE\n v = 1'); + + query.target.where = [{ type: 'macro', name: '$__timeFilter' }, { type: 'expression', params: ['v', '=', '1'] }]; + expect(query.buildWhereClause()).toBe('\nWHERE\n $__timeFilter(t) AND\n v = 1'); + }); + + describe('When generating GROUP BY clause', function() { + const query = new PostgresQuery({ group: [], metricColumn: 'none' }, templateSrv); + + expect(query.buildGroupClause()).toBe(''); + query.target.group = [{ type: 'time', params: ['5m'] }]; + expect(query.buildGroupClause()).toBe('\nGROUP BY 1'); + query.target.metricColumn = 'm'; + expect(query.buildGroupClause()).toBe('\nGROUP BY 1,2'); + }); + + describe('When generating complete statement', function() { + const target = { + timeColumn: 't', + table: 'table', + select: [[{ type: 'column', params: ['value'] }]], + where: [], + }; + let result = 'SELECT\n t AS "time",\n value\nFROM table\nORDER BY 1'; + const query = new PostgresQuery(target, templateSrv); + + expect(query.buildQuery()).toBe(result); + + query.target.metricColumn = 'm'; + result = 'SELECT\n t AS "time",\n m AS metric,\n value\nFROM table\nORDER BY 1'; + expect(query.buildQuery()).toBe(result); + }); +}); diff --git a/public/app/plugins/datasource/postgres/sql_part.ts b/public/app/plugins/datasource/postgres/sql_part.ts new file mode 100644 index 00000000000..695060f6366 --- /dev/null +++ b/public/app/plugins/datasource/postgres/sql_part.ts @@ -0,0 +1,137 @@ +import { SqlPartDef, SqlPart } from 'app/core/components/sql_part/sql_part'; + +const index = []; + +function createPart(part): any { + const def = index[part.type]; + if (!def) { + return null; + } + + return new SqlPart(part, def); +} + +function register(options: any) { + index[options.type] = new SqlPartDef(options); +} + +register({ + type: 'column', + style: 'label', + params: [{ type: 'column', dynamicLookup: true }], + defaultParams: ['value'], +}); + +register({ + type: 'expression', + style: 'expression', + label: 'Expr:', + params: [ + { name: 'left', type: 'string', dynamicLookup: true }, + { name: 'op', type: 'string', dynamicLookup: true }, + { name: 'right', type: 'string', dynamicLookup: true }, + ], + defaultParams: ['value', '=', 'value'], +}); + +register({ + type: 'macro', + style: 'label', + label: 'Macro:', + params: [], + defaultParams: [], +}); + +register({ + type: 'aggregate', + style: 'label', + params: [ + { + name: 'name', + type: 'string', + options: ['avg', 'count', 'min', 'max', 'sum', 'stddev', 'variance'], + }, + ], + defaultParams: ['avg'], +}); + +register({ + type: 'percentile', + label: 'Aggregate:', + style: 'label', + params: [ + { + name: 'name', + type: 'string', + options: ['percentile_cont', 'percentile_disc'], + }, + { + name: 'fraction', + type: 'number', + options: ['0.5', '0.75', '0.9', '0.95', '0.99'], + }, + ], + defaultParams: ['percentile_cont', '0.95'], +}); + +register({ + type: 'alias', + style: 'label', + params: [{ name: 'name', type: 'string', quote: 'double' }], + defaultParams: ['alias'], +}); + +register({ + type: 'time', + style: 'function', + label: 'time', + params: [ + { + name: 'interval', + type: 'interval', + options: ['$__interval', '1s', '10s', '1m', '5m', '10m', '15m', '1h'], + }, + { + name: 'fill', + type: 'string', + options: ['none', 'NULL', 'previous', '0'], + }, + ], + defaultParams: ['$__interval', 'none'], +}); + +register({ + type: 'window', + style: 'label', + params: [ + { + name: 'function', + type: 'string', + options: ['increase', 'rate', 'sum'], + }, + ], + defaultParams: ['increase'], +}); + +register({ + type: 'moving_window', + style: 'label', + label: 'Moving Window:', + params: [ + { + name: 'function', + type: 'string', + options: ['avg'], + }, + { + name: 'window_size', + type: 'number', + options: ['3', '5', '7', '10', '20'], + }, + ], + defaultParams: ['avg', '5'], +}); + +export default { + create: createPart, +}; diff --git a/public/app/plugins/datasource/prometheus/specs/completer.test.ts b/public/app/plugins/datasource/prometheus/specs/completer.test.ts index 05952e1dc4a..36765ef708b 100644 --- a/public/app/plugins/datasource/prometheus/specs/completer.test.ts +++ b/public/app/plugins/datasource/prometheus/specs/completer.test.ts @@ -4,7 +4,7 @@ import { BackendSrv } from 'app/core/services/backend_srv'; jest.mock('../datasource'); jest.mock('app/core/services/backend_srv'); -describe('Prometheus editor completer', function() { +describe('Prometheus editor completer', () => { function getSessionStub(data) { return { getTokenAt: jest.fn(() => data.currentToken), diff --git a/public/app/plugins/datasource/prometheus/specs/datasource.test.ts b/public/app/plugins/datasource/prometheus/specs/datasource.test.ts index 35d13684240..064f1bc1818 100644 --- a/public/app/plugins/datasource/prometheus/specs/datasource.test.ts +++ b/public/app/plugins/datasource/prometheus/specs/datasource.test.ts @@ -437,7 +437,7 @@ describe('PrometheusDatasource', () => { backendSrv.datasourceRequest = jest.fn(() => Promise.resolve(response)); ctx.ds = new PrometheusDatasource(instanceSettings, q, backendSrv as any, templateSrv, timeSrv); - await ctx.ds.query(query).then(function(data) { + await ctx.ds.query(query).then(data => { results = data; }); }); @@ -487,7 +487,7 @@ describe('PrometheusDatasource', () => { backendSrv.datasourceRequest = jest.fn(() => Promise.resolve(response)); ctx.ds = new PrometheusDatasource(instanceSettings, q, backendSrv as any, templateSrv, timeSrv); - await ctx.ds.query(query).then(function(data) { + await ctx.ds.query(query).then(data => { results = data; }); }); @@ -548,7 +548,7 @@ describe('PrometheusDatasource', () => { backendSrv.datasourceRequest = jest.fn(() => Promise.resolve(response)); ctx.ds = new PrometheusDatasource(instanceSettings, q, backendSrv as any, templateSrv, timeSrv); - await ctx.ds.query(query).then(function(data) { + await ctx.ds.query(query).then(data => { results = data; }); }); @@ -603,7 +603,7 @@ describe('PrometheusDatasource', () => { backendSrv.datasourceRequest = jest.fn(() => Promise.resolve(response)); ctx.ds = new PrometheusDatasource(instanceSettings, q, backendSrv as any, templateSrv, timeSrv); - await ctx.ds.annotationQuery(options).then(function(data) { + await ctx.ds.annotationQuery(options).then(data => { results = data; }); }); @@ -642,7 +642,7 @@ describe('PrometheusDatasource', () => { backendSrv.datasourceRequest = jest.fn(() => Promise.resolve(response)); ctx.ds = new PrometheusDatasource(instanceSettings, q, backendSrv as any, templateSrv, timeSrv); - await ctx.ds.query(query).then(function(data) { + await ctx.ds.query(query).then(data => { results = data; }); }); @@ -1156,7 +1156,7 @@ describe('PrometheusDatasource for POST', () => { }; backendSrv.datasourceRequest = jest.fn(() => Promise.resolve(response)); ctx.ds = new PrometheusDatasource(instanceSettings, q, backendSrv as any, templateSrv, timeSrv); - await ctx.ds.query(query).then(function(data) { + await ctx.ds.query(query).then(data => { results = data; }); }); diff --git a/public/app/plugins/datasource/prometheus/specs/metric_find_query.test.ts b/public/app/plugins/datasource/prometheus/specs/metric_find_query.test.ts index bfbf241ba06..1466bd8ac96 100644 --- a/public/app/plugins/datasource/prometheus/specs/metric_find_query.test.ts +++ b/public/app/plugins/datasource/prometheus/specs/metric_find_query.test.ts @@ -3,7 +3,7 @@ import { PrometheusDatasource } from '../datasource'; import PrometheusMetricFindQuery from '../metric_find_query'; import q from 'q'; -describe('PrometheusMetricFindQuery', function() { +describe('PrometheusMetricFindQuery', () => { const instanceSettings = { url: 'proxied', directUrl: 'direct', diff --git a/public/app/plugins/panel/graph/specs/align_yaxes.test.ts b/public/app/plugins/panel/graph/specs/align_yaxes.test.ts index da3aff91275..d87de91fdd5 100644 --- a/public/app/plugins/panel/graph/specs/align_yaxes.test.ts +++ b/public/app/plugins/panel/graph/specs/align_yaxes.test.ts @@ -1,6 +1,6 @@ import { alignYLevel } from '../align_yaxes'; -describe('Graph Y axes aligner', function() { +describe('Graph Y axes aligner', () => { let yaxes, expected; let alignY = 0; diff --git a/public/app/plugins/panel/graph/specs/data_processor.test.ts b/public/app/plugins/panel/graph/specs/data_processor.test.ts index 2e8d4eb6eab..b919bb0f3b1 100644 --- a/public/app/plugins/panel/graph/specs/data_processor.test.ts +++ b/public/app/plugins/panel/graph/specs/data_processor.test.ts @@ -1,6 +1,6 @@ import { DataProcessor } from '../data_processor'; -describe('Graph DataProcessor', function() { +describe('Graph DataProcessor', () => { const panel: any = { xaxis: {}, }; diff --git a/public/app/plugins/panel/graph/specs/graph.test.ts b/public/app/plugins/panel/graph/specs/graph.test.ts index 037a02a5bf0..d86e860b27d 100644 --- a/public/app/plugins/panel/graph/specs/graph.test.ts +++ b/public/app/plugins/panel/graph/specs/graph.test.ts @@ -1,5 +1,5 @@ jest.mock('app/features/annotations/all', () => ({ - EventManager: function() { + EventManager: () => { return { on: () => {}, addFlotEvents: () => {}, @@ -40,7 +40,7 @@ const scope = { }; let link; -describe('grafanaGraph', function() { +describe('grafanaGraph', () => { const setupCtx = (beforeRender?) => { config.bootData = { user: { @@ -242,7 +242,7 @@ describe('grafanaGraph', function() { }); }); - it('should apply axis transform, autoscaling (if necessary) and ticks', function() { + it('should apply axis transform, autoscaling (if necessary) and ticks', () => { const axisAutoscale = ctx.plotOptions.yaxes[0]; expect(axisAutoscale.transform(100)).toBe(2); expect(axisAutoscale.inverseTransform(-3)).toBeCloseTo(0.001); @@ -277,7 +277,7 @@ describe('grafanaGraph', function() { }); }); - it('should not set min and max and should create some fake ticks', function() { + it('should not set min and max and should create some fake ticks', () => { const axisAutoscale = ctx.plotOptions.yaxes[0]; expect(axisAutoscale.transform(100)).toBe(2); expect(axisAutoscale.inverseTransform(-3)).toBeCloseTo(0.001); @@ -303,7 +303,7 @@ describe('grafanaGraph', function() { ctx.data[0].yaxis = 1; }); }); - it('should set min to 0.1 and add a tick for 0.1', function() { + it('should set min to 0.1 and add a tick for 0.1', () => { const axisAutoscale = ctx.plotOptions.yaxes[0]; expect(axisAutoscale.transform(100)).toBe(2); expect(axisAutoscale.inverseTransform(-3)).toBeCloseTo(0.001); @@ -330,7 +330,7 @@ describe('grafanaGraph', function() { }); }); - it('should regenerate ticks so that if fits on the y-axis', function() { + it('should regenerate ticks so that if fits on the y-axis', () => { const axisAutoscale = ctx.plotOptions.yaxes[0]; expect(axisAutoscale.min).toBe(0.1); expect(axisAutoscale.ticks.length).toBe(8); @@ -339,7 +339,7 @@ describe('grafanaGraph', function() { expect(axisAutoscale.max).toBe(262144); }); - it('should set axis max to be max tick value', function() { + it('should set axis max to be max tick value', () => { expect(ctx.plotOptions.yaxes[0].max).toBe(262144); }); }); @@ -353,7 +353,7 @@ describe('grafanaGraph', function() { }); }); - it('should configure dashed plot with correct options', function() { + it('should configure dashed plot with correct options', () => { expect(ctx.plotOptions.series.lines.show).toBe(true); expect(ctx.plotOptions.series.dashes.lineWidth).toBe(2); expect(ctx.plotOptions.series.dashes.show).toBe(true); @@ -371,7 +371,7 @@ describe('grafanaGraph', function() { }); }); - it('should set barWidth', function() { + it('should set barWidth', () => { expect(ctx.plotOptions.series.bars.barWidth).toBe(1 / 1.5); }); }); @@ -388,7 +388,7 @@ describe('grafanaGraph', function() { }); }); - it('should match second series and fill zero, and enable points', function() { + it('should match second series and fill zero, and enable points', () => { expect(ctx.plotOptions.series.lines.fill).toBe(0.5); expect(ctx.plotData[1].lines.fill).toBe(0.001); expect(ctx.plotData[1].points.show).toBe(true); @@ -403,7 +403,7 @@ describe('grafanaGraph', function() { }); }); - it('should move zindex 2 last', function() { + it('should move zindex 2 last', () => { expect(ctx.plotData[0].alias).toBe('series2'); expect(ctx.plotData[1].alias).toBe('series1'); }); @@ -416,7 +416,7 @@ describe('grafanaGraph', function() { }); }); - it('should remove datapoints and disable stack', function() { + it('should remove datapoints and disable stack', () => { expect(ctx.plotData[0].alias).toBe('series1'); expect(ctx.plotData[1].data.length).toBe(0); expect(ctx.plotData[1].stack).toBe(false); @@ -431,7 +431,7 @@ describe('grafanaGraph', function() { }); }); - it('should show percentage', function() { + it('should show percentage', () => { const axis = ctx.plotOptions.yaxes[0]; expect(axis.tickFormatter(100, axis)).toBe('100%'); }); @@ -439,7 +439,7 @@ describe('grafanaGraph', function() { describe('when panel too narrow to show x-axis dates in same granularity as wide panels', () => { //Set width to 10px - describe('and the range is less than 24 hours', function() { + describe('and the range is less than 24 hours', () => { beforeEach(() => { setupCtx(() => { ctrl.range.from = moment([2015, 1, 1, 10]); @@ -447,13 +447,13 @@ describe('grafanaGraph', function() { }); }); - it('should format dates as hours minutes', function() { + it('should format dates as hours minutes', () => { const axis = ctx.plotOptions.xaxis; expect(axis.timeformat).toBe('%H:%M'); }); }); - describe('and the range is less than one year', function() { + describe('and the range is less than one year', () => { beforeEach(() => { setupCtx(() => { ctrl.range.from = moment([2015, 1, 1]); @@ -461,7 +461,7 @@ describe('grafanaGraph', function() { }); }); - it('should format dates as month days', function() { + it('should format dates as month days', () => { const axis = ctx.plotOptions.xaxis; expect(axis.timeformat).toBe('%m/%d'); }); @@ -485,7 +485,7 @@ describe('grafanaGraph', function() { }); }); - it('should calculate correct histogram', function() { + it('should calculate correct histogram', () => { expect(ctx.plotData[0].data[0][0]).toBe(100); expect(ctx.plotData[0].data[0][1]).toBe(2); expect(ctx.plotData[1].data[0][0]).toBe(100); @@ -510,7 +510,7 @@ describe('grafanaGraph', function() { }); }); - it('should calculate correct histogram', function() { + it('should calculate correct histogram', () => { expect(ctx.plotData[0].data[0][0]).toBe(100); expect(ctx.plotData[0].data[0][1]).toBe(2); }); diff --git a/public/app/plugins/panel/graph/specs/graph_tooltip.test.ts b/public/app/plugins/panel/graph/specs/graph_tooltip.test.ts index ecc6ce0fb21..00a3cf0dcf1 100644 --- a/public/app/plugins/panel/graph/specs/graph_tooltip.test.ts +++ b/public/app/plugins/panel/graph/specs/graph_tooltip.test.ts @@ -24,12 +24,12 @@ function describeSharedTooltip(desc, fn) { stack: false, }; - ctx.setup = function(setupFn) { + ctx.setup = setupFn => { ctx.setupFn = setupFn; }; - describe(desc, function() { - beforeEach(function() { + describe(desc, () => { + beforeEach(() => { ctx.setupFn(); const tooltip = new GraphTooltip(elem, dashboard, scope, getSeriesFn); ctx.results = tooltip.getMultiSeriesPlotHoverInfo(ctx.data, ctx.pos); @@ -39,35 +39,35 @@ function describeSharedTooltip(desc, fn) { }); } -describe('findHoverIndexFromData', function() { +describe('findHoverIndexFromData', () => { const tooltip = new GraphTooltip(elem, dashboard, scope, getSeriesFn); const series = { data: [[100, 0], [101, 0], [102, 0], [103, 0], [104, 0], [105, 0], [106, 0], [107, 0]], }; - it('should return 0 if posX out of lower bounds', function() { + it('should return 0 if posX out of lower bounds', () => { const posX = 99; expect(tooltip.findHoverIndexFromData(posX, series)).toBe(0); }); - it('should return n - 1 if posX out of upper bounds', function() { + it('should return n - 1 if posX out of upper bounds', () => { const posX = 108; expect(tooltip.findHoverIndexFromData(posX, series)).toBe(series.data.length - 1); }); - it('should return i if posX in series', function() { + it('should return i if posX in series', () => { const posX = 104; expect(tooltip.findHoverIndexFromData(posX, series)).toBe(4); }); - it('should return i if posX not in series and i + 1 > posX', function() { + it('should return i if posX not in series and i + 1 > posX', () => { const posX = 104.9; expect(tooltip.findHoverIndexFromData(posX, series)).toBe(4); }); }); -describeSharedTooltip('steppedLine false, stack false', function(ctx) { - ctx.setup(function() { +describeSharedTooltip('steppedLine false, stack false', ctx => { + ctx.setup(() => { ctx.data = [ { data: [[10, 15], [12, 20]], lines: {}, hideTooltip: false }, { data: [[10, 2], [12, 3]], lines: {}, hideTooltip: false }, @@ -75,30 +75,30 @@ describeSharedTooltip('steppedLine false, stack false', function(ctx) { ctx.pos = { x: 11 }; }); - it('should return 2 series', function() { + it('should return 2 series', () => { expect(ctx.results.length).toBe(2); }); - it('should add time to results array', function() { + it('should add time to results array', () => { expect(ctx.results.time).toBe(10); }); - it('should set value and hoverIndex', function() { + it('should set value and hoverIndex', () => { expect(ctx.results[0].value).toBe(15); expect(ctx.results[1].value).toBe(2); expect(ctx.results[0].hoverIndex).toBe(0); }); }); -describeSharedTooltip('one series is hidden', function(ctx) { - ctx.setup(function() { +describeSharedTooltip('one series is hidden', ctx => { + ctx.setup(() => { ctx.data = [{ data: [[10, 15], [12, 20]] }, { data: [] }]; ctx.pos = { x: 11 }; }); }); -describeSharedTooltip('steppedLine false, stack true, individual false', function(ctx) { - ctx.setup(function() { +describeSharedTooltip('steppedLine false, stack true, individual false', ctx => { + ctx.setup(() => { ctx.data = [ { data: [[10, 15], [12, 20]], @@ -125,13 +125,13 @@ describeSharedTooltip('steppedLine false, stack true, individual false', functio ctx.pos = { x: 11 }; }); - it('should show stacked value', function() { + it('should show stacked value', () => { expect(ctx.results[1].value).toBe(17); }); }); -describeSharedTooltip('steppedLine false, stack true, individual false, series stack false', function(ctx) { - ctx.setup(function() { +describeSharedTooltip('steppedLine false, stack true, individual false, series stack false', ctx => { + ctx.setup(() => { ctx.data = [ { data: [[10, 15], [12, 20]], @@ -158,13 +158,13 @@ describeSharedTooltip('steppedLine false, stack true, individual false, series s ctx.pos = { x: 11 }; }); - it('should not show stacked value', function() { + it('should not show stacked value', () => { expect(ctx.results[1].value).toBe(2); }); }); -describeSharedTooltip('steppedLine false, stack true, individual true', function(ctx) { - ctx.setup(function() { +describeSharedTooltip('steppedLine false, stack true, individual true', ctx => { + ctx.setup(() => { ctx.data = [ { data: [[10, 15], [12, 20]], @@ -192,7 +192,7 @@ describeSharedTooltip('steppedLine false, stack true, individual true', function ctx.pos = { x: 11 }; }); - it('should not show stacked value', function() { + it('should not show stacked value', () => { expect(ctx.results[1].value).toBe(2); }); }); diff --git a/public/app/plugins/panel/graph/specs/histogram.test.ts b/public/app/plugins/panel/graph/specs/histogram.test.ts index adbc0fcba68..d6b8527910b 100644 --- a/public/app/plugins/panel/graph/specs/histogram.test.ts +++ b/public/app/plugins/panel/graph/specs/histogram.test.ts @@ -1,6 +1,6 @@ import { convertValuesToHistogram, getSeriesValues } from '../histogram'; -describe('Graph Histogam Converter', function() { +describe('Graph Histogam Converter', () => { describe('Values to histogram converter', () => { let values; let bucketSize = 10; diff --git a/public/app/plugins/panel/graph/specs/threshold_manager.test.ts b/public/app/plugins/panel/graph/specs/threshold_manager.test.ts index ecbc382923e..bbeaf1b993a 100644 --- a/public/app/plugins/panel/graph/specs/threshold_manager.test.ts +++ b/public/app/plugins/panel/graph/specs/threshold_manager.test.ts @@ -2,9 +2,9 @@ import angular from 'angular'; import TimeSeries from 'app/core/time_series2'; import { ThresholdManager } from '../threshold_manager'; -describe('ThresholdManager', function() { +describe('ThresholdManager', () => { function plotOptionsScenario(desc, func) { - describe(desc, function() { + describe(desc, () => { const ctx: any = { panel: { thresholds: [], @@ -15,7 +15,7 @@ describe('ThresholdManager', function() { panelCtrl: {}, }; - ctx.setup = function(thresholds, data) { + ctx.setup = (thresholds, data) => { ctx.panel.thresholds = thresholds; const manager = new ThresholdManager(ctx.panelCtrl); if (data !== undefined) { @@ -33,7 +33,7 @@ describe('ThresholdManager', function() { plotOptionsScenario('for simple gt threshold', ctx => { ctx.setup([{ op: 'gt', value: 300, fill: true, line: true, colorMode: 'critical' }]); - it('should add fill for threshold with fill: true', function() { + it('should add fill for threshold with fill: true', () => { const markings = ctx.options.grid.markings; expect(markings[0].yaxis.from).toBe(300); @@ -41,7 +41,7 @@ describe('ThresholdManager', function() { expect(markings[0].color).toBe('rgba(234, 112, 112, 0.12)'); }); - it('should add line', function() { + it('should add line', () => { const markings = ctx.options.grid.markings; expect(markings[1].yaxis.from).toBe(300); expect(markings[1].yaxis.to).toBe(300); @@ -55,13 +55,13 @@ describe('ThresholdManager', function() { { op: 'gt', value: 300, fill: true, colorMode: 'critical' }, ]); - it('should add fill for first thresholds to next threshold', function() { + it('should add fill for first thresholds to next threshold', () => { const markings = ctx.options.grid.markings; expect(markings[0].yaxis.from).toBe(200); expect(markings[0].yaxis.to).toBe(300); }); - it('should add fill for last thresholds to infinity', function() { + it('should add fill for last thresholds to infinity', () => { const markings = ctx.options.grid.markings; expect(markings[1].yaxis.from).toBe(300); expect(markings[1].yaxis.to).toBe(Infinity); @@ -74,13 +74,13 @@ describe('ThresholdManager', function() { { op: 'gt', value: 200, fill: true, colorMode: 'critical' }, ]); - it('should add fill for first thresholds to next threshold', function() { + it('should add fill for first thresholds to next threshold', () => { const markings = ctx.options.grid.markings; expect(markings[0].yaxis.from).toBe(300); expect(markings[0].yaxis.to).toBe(200); }); - it('should add fill for last thresholds to itself', function() { + it('should add fill for last thresholds to itself', () => { const markings = ctx.options.grid.markings; expect(markings[1].yaxis.from).toBe(200); expect(markings[1].yaxis.to).toBe(200); @@ -93,13 +93,13 @@ describe('ThresholdManager', function() { { op: 'lt', value: 200, fill: true, colorMode: 'critical' }, ]); - it('should add fill for first thresholds to next threshold', function() { + it('should add fill for first thresholds to next threshold', () => { const markings = ctx.options.grid.markings; expect(markings[0].yaxis.from).toBe(300); expect(markings[0].yaxis.to).toBe(Infinity); }); - it('should add fill for last thresholds to itself', function() { + it('should add fill for last thresholds to itself', () => { const markings = ctx.options.grid.markings; expect(markings[1].yaxis.from).toBe(200); expect(markings[1].yaxis.to).toBe(-Infinity); @@ -126,12 +126,12 @@ describe('ThresholdManager', function() { data ); - it('should add first threshold for left axis', function() { + it('should add first threshold for left axis', () => { const markings = ctx.options.grid.markings; expect(markings[0].yaxis.from).toBe(100); }); - it('should add second threshold for right axis', function() { + it('should add second threshold for right axis', () => { const markings = ctx.options.grid.markings; expect(markings[1].y2axis.from).toBe(200); }); diff --git a/public/app/plugins/panel/heatmap/specs/heatmap_ctrl.test.ts b/public/app/plugins/panel/heatmap/specs/heatmap_ctrl.test.ts index 740d4045013..a51be8eb723 100644 --- a/public/app/plugins/panel/heatmap/specs/heatmap_ctrl.test.ts +++ b/public/app/plugins/panel/heatmap/specs/heatmap_ctrl.test.ts @@ -1,7 +1,7 @@ import moment from 'moment'; import { HeatmapCtrl } from '../heatmap_ctrl'; -describe('HeatmapCtrl', function() { +describe('HeatmapCtrl', () => { const ctx = {} as any; const $injector = { @@ -23,8 +23,8 @@ describe('HeatmapCtrl', function() { ctx.ctrl = new HeatmapCtrl($scope, $injector, {}); }); - describe('when time series are outside range', function() { - beforeEach(function() { + describe('when time series are outside range', () => { + beforeEach(() => { const data = [ { target: 'test.cpu1', @@ -36,13 +36,13 @@ describe('HeatmapCtrl', function() { ctx.ctrl.onDataReceived(data); }); - it('should set datapointsOutside', function() { + it('should set datapointsOutside', () => { expect(ctx.ctrl.dataWarning.title).toBe('Data points outside time range'); }); }); - describe('when time series are inside range', function() { - beforeEach(function() { + describe('when time series are inside range', () => { + beforeEach(() => { const range = { from: moment() .subtract(1, 'days') @@ -61,18 +61,18 @@ describe('HeatmapCtrl', function() { ctx.ctrl.onDataReceived(data); }); - it('should set datapointsOutside', function() { + it('should set datapointsOutside', () => { expect(ctx.ctrl.dataWarning).toBe(null); }); }); - describe('datapointsCount given 2 series', function() { - beforeEach(function() { + describe('datapointsCount given 2 series', () => { + beforeEach(() => { const data = [{ target: 'test.cpu1', datapoints: [] }, { target: 'test.cpu2', datapoints: [] }]; ctx.ctrl.onDataReceived(data); }); - it('should set datapointsCount warning', function() { + it('should set datapointsCount warning', () => { expect(ctx.ctrl.dataWarning.title).toBe('No data points'); }); }); diff --git a/public/app/plugins/panel/singlestat/specs/singlestat.test.ts b/public/app/plugins/panel/singlestat/specs/singlestat.test.ts index bacc972c058..6003acd89a6 100644 --- a/public/app/plugins/panel/singlestat/specs/singlestat.test.ts +++ b/public/app/plugins/panel/singlestat/specs/singlestat.test.ts @@ -1,7 +1,7 @@ import { SingleStatCtrl } from '../module'; import moment from 'moment'; -describe('SingleStatCtrl', function() { +describe('SingleStatCtrl', () => { const ctx = {} as any; const epoch = 1505826363746; Date.now = () => epoch; @@ -28,9 +28,9 @@ describe('SingleStatCtrl', function() { }; function singleStatScenario(desc, func) { - describe(desc, function() { - ctx.setup = function(setupFunc) { - beforeEach(function() { + describe(desc, () => { + ctx.setup = setupFunc => { + beforeEach(() => { ctx.ctrl = new SingleStatCtrl($scope, $injector, {}); setupFunc(); ctx.ctrl.onDataReceived(ctx.data); @@ -42,191 +42,189 @@ describe('SingleStatCtrl', function() { }); } - singleStatScenario('with defaults', function(ctx) { - ctx.setup(function() { + singleStatScenario('with defaults', ctx => { + ctx.setup(() => { ctx.data = [{ target: 'test.cpu1', datapoints: [[10, 1], [20, 2]] }]; }); - it('Should use series avg as default main value', function() { + it('Should use series avg as default main value', () => { expect(ctx.data.value).toBe(15); expect(ctx.data.valueRounded).toBe(15); }); - it('should set formatted falue', function() { + it('should set formatted falue', () => { expect(ctx.data.valueFormatted).toBe('15'); }); }); - singleStatScenario('showing serie name instead of value', function(ctx) { - ctx.setup(function() { + singleStatScenario('showing serie name instead of value', ctx => { + ctx.setup(() => { ctx.data = [{ target: 'test.cpu1', datapoints: [[10, 1], [20, 2]] }]; ctx.ctrl.panel.valueName = 'name'; }); - it('Should use series avg as default main value', function() { + it('Should use series avg as default main value', () => { expect(ctx.data.value).toBe(0); expect(ctx.data.valueRounded).toBe(0); }); - it('should set formatted value', function() { + it('should set formatted value', () => { expect(ctx.data.valueFormatted).toBe('test.cpu1'); }); }); - singleStatScenario('showing last iso time instead of value', function(ctx) { - ctx.setup(function() { + singleStatScenario('showing last iso time instead of value', ctx => { + ctx.setup(() => { ctx.data = [{ target: 'test.cpu1', datapoints: [[10, 12], [20, 1505634997920]] }]; ctx.ctrl.panel.valueName = 'last_time'; ctx.ctrl.panel.format = 'dateTimeAsIso'; ctx.ctrl.dashboard.isTimezoneUtc = () => false; }); - it('Should use time instead of value', function() { + it('Should use time instead of value', () => { expect(ctx.data.value).toBe(1505634997920); expect(ctx.data.valueRounded).toBe(1505634997920); }); - it('should set formatted value', function() { + it('should set formatted value', () => { expect(moment(ctx.data.valueFormatted).valueOf()).toBe(1505634997000); }); }); - singleStatScenario('showing last iso time instead of value (in UTC)', function(ctx) { - ctx.setup(function() { + singleStatScenario('showing last iso time instead of value (in UTC)', ctx => { + ctx.setup(() => { ctx.data = [{ target: 'test.cpu1', datapoints: [[10, 12], [20, 5000]] }]; ctx.ctrl.panel.valueName = 'last_time'; ctx.ctrl.panel.format = 'dateTimeAsIso'; ctx.ctrl.dashboard.isTimezoneUtc = () => true; }); - it('should set value', function() { + it('should set value', () => { expect(ctx.data.valueFormatted).toBe('1970-01-01 00:00:05'); }); }); - singleStatScenario('showing last us time instead of value', function(ctx) { - ctx.setup(function() { + singleStatScenario('showing last us time instead of value', ctx => { + ctx.setup(() => { ctx.data = [{ target: 'test.cpu1', datapoints: [[10, 12], [20, 1505634997920]] }]; ctx.ctrl.panel.valueName = 'last_time'; ctx.ctrl.panel.format = 'dateTimeAsUS'; ctx.ctrl.dashboard.isTimezoneUtc = () => false; }); - it('Should use time instead of value', function() { + it('Should use time instead of value', () => { expect(ctx.data.value).toBe(1505634997920); expect(ctx.data.valueRounded).toBe(1505634997920); }); - it('should set formatted value', function() { + it('should set formatted value', () => { expect(ctx.data.valueFormatted).toBe(moment(1505634997920).format('MM/DD/YYYY h:mm:ss a')); }); }); - singleStatScenario('showing last us time instead of value (in UTC)', function(ctx) { - ctx.setup(function() { + singleStatScenario('showing last us time instead of value (in UTC)', ctx => { + ctx.setup(() => { ctx.data = [{ target: 'test.cpu1', datapoints: [[10, 12], [20, 5000]] }]; ctx.ctrl.panel.valueName = 'last_time'; ctx.ctrl.panel.format = 'dateTimeAsUS'; ctx.ctrl.dashboard.isTimezoneUtc = () => true; }); - it('should set formatted value', function() { + it('should set formatted value', () => { expect(ctx.data.valueFormatted).toBe('01/01/1970 12:00:05 am'); }); }); - singleStatScenario('showing last time from now instead of value', function(ctx) { - ctx.setup(function() { + singleStatScenario('showing last time from now instead of value', ctx => { + ctx.setup(() => { ctx.data = [{ target: 'test.cpu1', datapoints: [[10, 12], [20, 1505634997920]] }]; ctx.ctrl.panel.valueName = 'last_time'; ctx.ctrl.panel.format = 'dateTimeFromNow'; }); - it('Should use time instead of value', function() { + it('Should use time instead of value', () => { expect(ctx.data.value).toBe(1505634997920); expect(ctx.data.valueRounded).toBe(1505634997920); }); - it('should set formatted value', function() { + it('should set formatted value', () => { expect(ctx.data.valueFormatted).toBe('2 days ago'); }); }); - singleStatScenario('showing last time from now instead of value (in UTC)', function(ctx) { - ctx.setup(function() { + singleStatScenario('showing last time from now instead of value (in UTC)', ctx => { + ctx.setup(() => { ctx.data = [{ target: 'test.cpu1', datapoints: [[10, 12], [20, 1505634997920]] }]; ctx.ctrl.panel.valueName = 'last_time'; ctx.ctrl.panel.format = 'dateTimeFromNow'; }); - it('should set formatted value', function() { + it('should set formatted value', () => { expect(ctx.data.valueFormatted).toBe('2 days ago'); }); }); - singleStatScenario('MainValue should use same number for decimals as displayed when checking thresholds', function( - ctx - ) { - ctx.setup(function() { + singleStatScenario('MainValue should use same number for decimals as displayed when checking thresholds', ctx => { + ctx.setup(() => { ctx.data = [{ target: 'test.cpu1', datapoints: [[99.999, 1], [99.99999, 2]] }]; ctx.ctrl.panel.valueName = 'avg'; ctx.ctrl.panel.format = 'none'; }); - it('Should be rounded', function() { + it('Should be rounded', () => { expect(ctx.data.value).toBe(99.999495); expect(ctx.data.valueRounded).toBe(100); }); - it('should set formatted value', function() { + it('should set formatted value', () => { expect(ctx.data.valueFormatted).toBe('100'); }); }); - singleStatScenario('When value to text mapping is specified', function(ctx) { - ctx.setup(function() { + singleStatScenario('When value to text mapping is specified', ctx => { + ctx.setup(() => { ctx.data = [{ target: 'test.cpu1', datapoints: [[9.9, 1]] }]; ctx.ctrl.panel.valueMaps = [{ value: '10', text: 'OK' }]; }); - it('value should remain', function() { + it('value should remain', () => { expect(ctx.data.value).toBe(9.9); }); - it('round should be rounded up', function() { + it('round should be rounded up', () => { expect(ctx.data.valueRounded).toBe(10); }); - it('Should replace value with text', function() { + it('Should replace value with text', () => { expect(ctx.data.valueFormatted).toBe('OK'); }); }); - singleStatScenario('When range to text mapping is specified for first range', function(ctx) { - ctx.setup(function() { + singleStatScenario('When range to text mapping is specified for first range', ctx => { + ctx.setup(() => { ctx.data = [{ target: 'test.cpu1', datapoints: [[41, 50]] }]; ctx.ctrl.panel.mappingType = 2; ctx.ctrl.panel.rangeMaps = [{ from: '10', to: '50', text: 'OK' }, { from: '51', to: '100', text: 'NOT OK' }]; }); - it('Should replace value with text OK', function() { + it('Should replace value with text OK', () => { expect(ctx.data.valueFormatted).toBe('OK'); }); }); - singleStatScenario('When range to text mapping is specified for other ranges', function(ctx) { - ctx.setup(function() { + singleStatScenario('When range to text mapping is specified for other ranges', ctx => { + ctx.setup(() => { ctx.data = [{ target: 'test.cpu1', datapoints: [[65, 75]] }]; ctx.ctrl.panel.mappingType = 2; ctx.ctrl.panel.rangeMaps = [{ from: '10', to: '50', text: 'OK' }, { from: '51', to: '100', text: 'NOT OK' }]; }); - it('Should replace value with text NOT OK', function() { + it('Should replace value with text NOT OK', () => { expect(ctx.data.valueFormatted).toBe('NOT OK'); }); }); - describe('When table data', function() { + describe('When table data', () => { const tableData = [ { columns: [{ text: 'Time', type: 'time' }, { text: 'test1' }, { text: 'mean' }, { text: 'test2' }], @@ -235,8 +233,8 @@ describe('SingleStatCtrl', function() { }, ]; - singleStatScenario('with default values', function(ctx) { - ctx.setup(function() { + singleStatScenario('with default values', ctx => { + ctx.setup(() => { ctx.data = tableData; ctx.ctrl.panel = { emit: () => {}, @@ -245,49 +243,47 @@ describe('SingleStatCtrl', function() { ctx.ctrl.panel.format = 'none'; }); - it('Should use first rows value as default main value', function() { + it('Should use first rows value as default main value', () => { expect(ctx.data.value).toBe(15); expect(ctx.data.valueRounded).toBe(15); }); - it('should set formatted value', function() { + it('should set formatted value', () => { expect(ctx.data.valueFormatted).toBe('15'); }); }); - singleStatScenario('When table data has multiple columns', function(ctx) { - ctx.setup(function() { + singleStatScenario('When table data has multiple columns', ctx => { + ctx.setup(() => { ctx.data = tableData; ctx.ctrl.panel.tableColumn = ''; }); - it('Should set column to first column that is not time', function() { + it('Should set column to first column that is not time', () => { expect(ctx.ctrl.panel.tableColumn).toBe('test1'); }); }); - singleStatScenario('MainValue should use same number for decimals as displayed when checking thresholds', function( - ctx - ) { - ctx.setup(function() { + singleStatScenario('MainValue should use same number for decimals as displayed when checking thresholds', ctx => { + ctx.setup(() => { ctx.data = tableData; ctx.data[0].rows[0] = [1492759673649, 'ignore1', 99.99999, 'ignore2']; ctx.ctrl.panel.mappingType = 0; ctx.ctrl.panel.tableColumn = 'mean'; }); - it('Should be rounded', function() { + it('Should be rounded', () => { expect(ctx.data.value).toBe(99.99999); expect(ctx.data.valueRounded).toBe(100); }); - it('should set formatted falue', function() { + it('should set formatted falue', () => { expect(ctx.data.valueFormatted).toBe('100'); }); }); - singleStatScenario('When value to text mapping is specified', function(ctx) { - ctx.setup(function() { + singleStatScenario('When value to text mapping is specified', ctx => { + ctx.setup(() => { ctx.data = tableData; ctx.data[0].rows[0] = [1492759673649, 'ignore1', 9.9, 'ignore2']; ctx.ctrl.panel.mappingType = 2; @@ -295,21 +291,21 @@ describe('SingleStatCtrl', function() { ctx.ctrl.panel.valueMaps = [{ value: '10', text: 'OK' }]; }); - it('value should remain', function() { + it('value should remain', () => { expect(ctx.data.value).toBe(9.9); }); - it('round should be rounded up', function() { + it('round should be rounded up', () => { expect(ctx.data.valueRounded).toBe(10); }); - it('Should replace value with text', function() { + it('Should replace value with text', () => { expect(ctx.data.valueFormatted).toBe('OK'); }); }); - singleStatScenario('When range to text mapping is specified for first range', function(ctx) { - ctx.setup(function() { + singleStatScenario('When range to text mapping is specified for first range', ctx => { + ctx.setup(() => { ctx.data = tableData; ctx.data[0].rows[0] = [1492759673649, 'ignore1', 41, 'ignore2']; ctx.ctrl.panel.tableColumn = 'mean'; @@ -317,13 +313,13 @@ describe('SingleStatCtrl', function() { ctx.ctrl.panel.rangeMaps = [{ from: '10', to: '50', text: 'OK' }, { from: '51', to: '100', text: 'NOT OK' }]; }); - it('Should replace value with text OK', function() { + it('Should replace value with text OK', () => { expect(ctx.data.valueFormatted).toBe('OK'); }); }); - singleStatScenario('When range to text mapping is specified for other ranges', function(ctx) { - ctx.setup(function() { + singleStatScenario('When range to text mapping is specified for other ranges', ctx => { + ctx.setup(() => { ctx.data = tableData; ctx.data[0].rows[0] = [1492759673649, 'ignore1', 65, 'ignore2']; ctx.ctrl.panel.tableColumn = 'mean'; @@ -331,31 +327,31 @@ describe('SingleStatCtrl', function() { ctx.ctrl.panel.rangeMaps = [{ from: '10', to: '50', text: 'OK' }, { from: '51', to: '100', text: 'NOT OK' }]; }); - it('Should replace value with text NOT OK', function() { + it('Should replace value with text NOT OK', () => { expect(ctx.data.valueFormatted).toBe('NOT OK'); }); }); - singleStatScenario('When value is string', function(ctx) { - ctx.setup(function() { + singleStatScenario('When value is string', ctx => { + ctx.setup(() => { ctx.data = tableData; ctx.data[0].rows[0] = [1492759673649, 'ignore1', 65, 'ignore2']; ctx.ctrl.panel.tableColumn = 'test1'; }); - it('Should replace value with text NOT OK', function() { + it('Should replace value with text NOT OK', () => { expect(ctx.data.valueFormatted).toBe('ignore1'); }); }); - singleStatScenario('When value is zero', function(ctx) { - ctx.setup(function() { + singleStatScenario('When value is zero', ctx => { + ctx.setup(() => { ctx.data = tableData; ctx.data[0].rows[0] = [1492759673649, 'ignore1', 0, 'ignore2']; ctx.ctrl.panel.tableColumn = 'mean'; }); - it('Should return zero', function() { + it('Should return zero', () => { expect(ctx.data.value).toBe(0); }); }); diff --git a/public/app/plugins/panel/singlestat/specs/singlestat_panel.test.ts b/public/app/plugins/panel/singlestat/specs/singlestat_panel.test.ts index 114cdf132e1..1a88c6bb770 100644 --- a/public/app/plugins/panel/singlestat/specs/singlestat_panel.test.ts +++ b/public/app/plugins/panel/singlestat/specs/singlestat_panel.test.ts @@ -1,6 +1,6 @@ import { getColorForValue } from '../module'; -describe('grafanaSingleStat', function() { +describe('grafanaSingleStat', () => { describe('legacy thresholds', () => { describe('positive thresholds', () => { const data: any = { diff --git a/public/app/plugins/panel/table/specs/renderer.test.ts b/public/app/plugins/panel/table/specs/renderer.test.ts index b66984ba223..f29c69e4acd 100644 --- a/public/app/plugins/panel/table/specs/renderer.test.ts +++ b/public/app/plugins/panel/table/specs/renderer.test.ts @@ -163,15 +163,15 @@ describe('when rendering table', () => { ], }; - const sanitize = function(value) { + const sanitize = value => { return 'sanitized'; }; const templateSrv = { - replace: function(value, scopedVars) { + replace: (value, scopedVars) => { if (scopedVars) { // For testing variables replacement in link - _.each(scopedVars, function(val, key) { + _.each(scopedVars, (val, key) => { value = value.replace('$' + key, val.value); }); } diff --git a/public/app/plugins/panel/table/specs/transformers.test.ts b/public/app/plugins/panel/table/specs/transformers.test.ts index 2425d98f26d..8d581b68842 100644 --- a/public/app/plugins/panel/table/specs/transformers.test.ts +++ b/public/app/plugins/panel/table/specs/transformers.test.ts @@ -161,15 +161,15 @@ describe('when transforming time series table', () => { }, ]; - describe('getColumns', function() { - it('should return data columns given a single query', function() { + describe('getColumns', () => { + it('should return data columns given a single query', () => { const columns = transformers[transform].getColumns(singleQueryData); expect(columns[0].text).toBe('Time'); expect(columns[1].text).toBe('Label Key 1'); expect(columns[2].text).toBe('Value'); }); - it('should return the union of data columns given a multiple queries', function() { + it('should return the union of data columns given a multiple queries', () => { const columns = transformers[transform].getColumns(multipleQueriesDataSameLabels); expect(columns[0].text).toBe('Time'); expect(columns[1].text).toBe('Label Key 1'); @@ -178,7 +178,7 @@ describe('when transforming time series table', () => { expect(columns[4].text).toBe('Value #B'); }); - it('should return the union of data columns given a multiple queries with different labels', function() { + it('should return the union of data columns given a multiple queries with different labels', () => { const columns = transformers[transform].getColumns(multipleQueriesDataDifferentLabels); expect(columns[0].text).toBe('Time'); expect(columns[1].text).toBe('Label Key 1'); @@ -189,7 +189,7 @@ describe('when transforming time series table', () => { }); }); - describe('transform', function() { + describe('transform', () => { it('should throw an error with non-table data', () => { expect(() => transformDataToTable(nonTableData, panel)).toThrow(); }); @@ -286,8 +286,8 @@ describe('when transforming time series table', () => { }, ]; - describe('getColumns', function() { - it('should return nested properties', function() { + describe('getColumns', () => { + it('should return nested properties', () => { const columns = transformers['json'].getColumns(rawData); expect(columns[0].text).toBe('timestamp'); expect(columns[1].text).toBe('message'); @@ -295,7 +295,7 @@ describe('when transforming time series table', () => { }); }); - describe('transform', function() { + describe('transform', () => { beforeEach(() => { table = transformDataToTable(rawData, panel); }); diff --git a/public/app/routes/dashboard_loaders.ts b/public/app/routes/dashboard_loaders.ts index 74c9fe2235a..a14efd3c508 100644 --- a/public/app/routes/dashboard_loaders.ts +++ b/public/app/routes/dashboard_loaders.ts @@ -9,7 +9,8 @@ export class LoadDashboardCtrl { if (!$routeParams.uid && !$routeParams.slug) { backendSrv.get('/api/dashboards/home').then(function(homeDash) { if (homeDash.redirectUri) { - $location.path(homeDash.redirectUri); + const newUrl = locationUtil.stripBaseFromUrl(homeDash.redirectUri); + $location.path(newUrl); } else { const meta = homeDash.meta; meta.canSave = meta.canShare = meta.canStar = false; diff --git a/public/test/specs/helpers.ts b/public/test/specs/helpers.ts index f585498d247..0345576d96c 100644 --- a/public/test/specs/helpers.ts +++ b/public/test/specs/helpers.ts @@ -15,10 +15,10 @@ export function ControllerTestContext(this: any) { this.timeSrv = new TimeSrvStub(); this.templateSrv = new TemplateSrvStub(); this.datasourceSrv = { - getMetricSources: function() {}, - get: function() { + getMetricSources: () => {}, + get: () => { return { - then: function(callback) { + then: callback => { callback(self.datasource); }, }; @@ -26,8 +26,8 @@ export function ControllerTestContext(this: any) { }; this.isUtc = false; - this.providePhase = function(mocks) { - return angularMocks.module(function($provide) { + this.providePhase = mocks => { + return angularMocks.module($provide => { $provide.value('contextSrv', self.contextSrv); $provide.value('datasourceSrv', self.datasourceSrv); $provide.value('annotationsSrv', self.annotationsSrv); @@ -35,14 +35,14 @@ export function ControllerTestContext(this: any) { $provide.value('templateSrv', self.templateSrv); $provide.value('$element', self.$element); $provide.value('$sanitize', self.$sanitize); - _.each(mocks, function(value, key) { + _.each(mocks, (value, key) => { $provide.value(key, value); }); }); }; - this.createPanelController = function(Ctrl) { - return angularMocks.inject(function($controller, $rootScope, $q, $location, $browser) { + this.createPanelController = Ctrl => { + return angularMocks.inject(($controller, $rootScope, $q, $location, $browser) => { self.scope = $rootScope.$new(); self.$location = $location; self.$browser = $browser; @@ -50,7 +50,7 @@ export function ControllerTestContext(this: any) { self.panel = new PanelModel({ type: 'test' }); self.dashboard = { meta: {} }; self.isUtc = false; - self.dashboard.isTimezoneUtc = function() { + self.dashboard.isTimezoneUtc = () => { return self.isUtc; }; @@ -74,8 +74,8 @@ export function ControllerTestContext(this: any) { }); }; - this.createControllerPhase = function(controllerName) { - return angularMocks.inject(function($controller, $rootScope, $q, $location, $browser) { + this.createControllerPhase = controllerName => { + return angularMocks.inject(($controller, $rootScope, $q, $location, $browser) => { self.scope = $rootScope.$new(); self.$location = $location; self.$browser = $browser; @@ -101,7 +101,7 @@ export function ControllerTestContext(this: any) { }); }; - this.setIsUtc = function(isUtc = false) { + this.setIsUtc = (isUtc = false) => { self.isUtc = isUtc; }; } @@ -114,23 +114,23 @@ export function ServiceTestContext(this: any) { self.backendSrv = {}; self.$routeParams = {}; - this.providePhase = function(mocks) { - return angularMocks.module(function($provide) { - _.each(mocks, function(key) { + this.providePhase = mocks => { + return angularMocks.module($provide => { + _.each(mocks, key => { $provide.value(key, self[key]); }); }); }; - this.createService = function(name) { - return angularMocks.inject(function($q, $rootScope, $httpBackend, $injector, $location, $timeout) { + this.createService = name => { + return angularMocks.inject(($q, $rootScope, $httpBackend, $injector, $location, $timeout) => { self.$q = $q; self.$rootScope = $rootScope; self.$httpBackend = $httpBackend; self.$location = $location; - self.$rootScope.onAppEvent = function() {}; - self.$rootScope.appEvent = function() {}; + self.$rootScope.onAppEvent = () => {}; + self.$rootScope.appEvent = () => {}; self.$timeout = $timeout; self.service = $injector.get(name); @@ -139,7 +139,7 @@ export function ServiceTestContext(this: any) { } export function DashboardViewStateStub(this: any) { - this.registerPanel = function() {}; + this.registerPanel = () => {}; } export function TimeSrvStub(this: any) { @@ -155,7 +155,7 @@ export function TimeSrvStub(this: any) { }; }; - this.replace = function(target) { + this.replace = target => { return target; }; @@ -165,7 +165,7 @@ export function TimeSrvStub(this: any) { } export function ContextSrvStub(this: any) { - this.hasRole = function() { + this.hasRole = () => { return true; }; } @@ -177,17 +177,17 @@ export function TemplateSrvStub(this: any) { this.replace = function(text) { return _.template(text, this.templateSettings)(this.data); }; - this.init = function() {}; - this.getAdhocFilters = function() { + this.init = () => {}; + this.getAdhocFilters = () => { return []; }; - this.fillVariableValuesForUrl = function() {}; - this.updateTemplateData = function() {}; - this.variableExists = function() { + this.fillVariableValuesForUrl = () => {}; + this.updateTemplateData = () => {}; + this.variableExists = () => { return false; }; - this.variableInitialized = function() {}; - this.highlightVariablesAsHtml = function(str) { + this.variableInitialized = () => {}; + this.highlightVariablesAsHtml = str => { return str; }; this.setGrafanaVariable = function(name, value) { diff --git a/tslint.json b/tslint.json index f53eb541643..0d525bea3f0 100644 --- a/tslint.json +++ b/tslint.json @@ -29,6 +29,7 @@ "label-position": true, "max-line-length": [true, 150], "member-access": [true, "no-public"], + "new-parens": true, "no-angle-bracket-type-assertion": true, "no-arg": true, "no-bitwise": false,