diff --git a/CHANGELOG.md b/CHANGELOG.md index 30fa0cd0b94..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,7 +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-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/app.ts b/public/app/app.ts index 77f56264504..8e30747072e 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -21,7 +21,7 @@ import _ from 'lodash'; import moment from 'moment'; // add move to lodash for backward compatabiltiy -_.move = function(array, fromIndex, toIndex) { +_.move = (array, fromIndex, toIndex) => { array.splice(toIndex, 0, array.splice(fromIndex, 1)[0]); return array; }; @@ -76,9 +76,9 @@ export class GrafanaApp { $provide.decorator('$http', [ '$delegate', '$templateCache', - function($delegate, $templateCache) { + ($delegate, $templateCache) => { const get = $delegate.get; - $delegate.get = function(url, config) { + $delegate.get = (url, config) => { if (url.match(/\.html$/)) { // some template's already exist in the cache if (!$templateCache.get(url)) { @@ -135,7 +135,7 @@ export class GrafanaApp { this.preBootModules = null; }); }) - .catch(function(err) { + .catch(err => { console.log('Application boot failed:', err); }); } diff --git a/public/app/containers/Explore/utils/debounce.ts b/public/app/containers/Explore/utils/debounce.ts index 5fda5a05f5f..a7c9450a6c1 100644 --- a/public/app/containers/Explore/utils/debounce.ts +++ b/public/app/containers/Explore/utils/debounce.ts @@ -4,7 +4,7 @@ export default function debounce(func, wait) { return function(this: any) { const context = this; const args = arguments; - const later = function() { + const later = () => { timeout = null; func.apply(context, args); }; 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/gf_page.ts b/public/app/core/components/gf_page.ts index ad0770940ec..057a307f205 100644 --- a/public/app/core/components/gf_page.ts +++ b/public/app/core/components/gf_page.ts @@ -31,7 +31,7 @@ export function gfPageDirective() { header: '?gfPageHeader', body: 'gfPageBody', }, - link: function(scope, elem, attrs) { + link: (scope, elem, attrs) => { console.log(scope); }, }; diff --git a/public/app/core/components/scroll/page_scroll.ts b/public/app/core/components/scroll/page_scroll.ts index b6603f06175..2d6e27f8b22 100644 --- a/public/app/core/components/scroll/page_scroll.ts +++ b/public/app/core/components/scroll/page_scroll.ts @@ -4,7 +4,7 @@ import appEvents from 'app/core/app_events'; export function pageScrollbar() { return { restrict: 'A', - link: function(scope, elem, attrs) { + link: (scope, elem, attrs) => { let lastPos = 0; appEvents.on( diff --git a/public/app/core/components/scroll/scroll.ts b/public/app/core/components/scroll/scroll.ts index 2d60825e739..bd355817f92 100644 --- a/public/app/core/components/scroll/scroll.ts +++ b/public/app/core/components/scroll/scroll.ts @@ -14,7 +14,7 @@ const scrollerClass = 'baron__scroller'; export function geminiScrollbar() { return { restrict: 'A', - link: function(scope, elem, attrs) { + link: (scope, elem, attrs) => { let scrollRoot = elem.parent(); const scroller = elem; 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 8ee687543cc..49541028caf 100644 --- a/public/app/plugins/datasource/postgres/specs/datasource.test.ts +++ b/public/app/plugins/datasource/postgres/specs/datasource.test.ts @@ -9,12 +9,23 @@ describe('PostgreSQLDatasource', () => { 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', () => { @@ -219,6 +230,7 @@ describe('PostgreSQLDatasource', () => { 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/routes/ReactContainer.tsx b/public/app/routes/ReactContainer.tsx index b161a5e7a87..438cdaa9137 100644 --- a/public/app/routes/ReactContainer.tsx +++ b/public/app/routes/ReactContainer.tsx @@ -50,7 +50,7 @@ export function reactContainer( ReactDOM.render(WrapInProvider(store, component, props), elem[0]); - scope.$on('$destroy', function() { + scope.$on('$destroy', () => { ReactDOM.unmountComponentAtNode(elem[0]); }); }, diff --git a/public/app/routes/dashboard_loaders.ts b/public/app/routes/dashboard_loaders.ts index 74c9fe2235a..b3e329bd4f2 100644 --- a/public/app/routes/dashboard_loaders.ts +++ b/public/app/routes/dashboard_loaders.ts @@ -7,9 +7,10 @@ export class LoadDashboardCtrl { $scope.appEvent('dashboard-fetch-start'); if (!$routeParams.uid && !$routeParams.slug) { - backendSrv.get('/api/dashboards/home').then(function(homeDash) { + backendSrv.get('/api/dashboards/home').then(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; @@ -29,7 +30,7 @@ export class LoadDashboardCtrl { return; } - dashboardLoaderSrv.loadDashboard($routeParams.type, $routeParams.slug, $routeParams.uid).then(function(result) { + dashboardLoaderSrv.loadDashboard($routeParams.type, $routeParams.slug, $routeParams.uid).then(result => { if (result.meta.url) { const url = locationUtil.stripBaseFromUrl(result.meta.url);