From 39ad08e3d860b8f135bb66ee40feb353b70e40f8 Mon Sep 17 00:00:00 2001 From: Sergii Kamenskyi Date: Fri, 16 Nov 2018 17:02:23 +0100 Subject: [PATCH] #29 Nakadi SQL Query tab and server support added (#30) * #29 Nakadi SQL Query tab and server support added * #29 Nakadi SQL feature flag * #29 Nakadi SQL feature flag usage * #29 Make SQL Query the seccond tab * #29 Adjast tabs height * #29 Create SQL Query menu item and the routing * #29 Create SQL Query Form * #29 Nakadi SQL. Status, Delete, Monitoring * #29 Nakadi SQL. Create help fix * #29 Nakadi SQL. #25 Integrate Ace editor * #29 cleanup * #29 Hide not supported authz option * #29 fix SQL create request * #29 fix. remove admins from autz * #29 fix. json format for POST * #29 Hide publishing tab, as it is useless for Query output * Improve deployment for docker * Fix Create menu height * Fix text --- .env.example | 4 + Dockerfile | 4 + INSTALL.md | 7 + client/Config.elm | 4 + client/Helpers/AccessEditor.elm | 10 +- client/Helpers/Ace.elm | 199 ++++++++++++++++ client/Helpers/Forms.elm | 66 +++--- client/Helpers/Header.elm | 51 ++-- client/Native/Ace.js | 225 ++++++++++++++++++ client/Pages/EventTypeCreate/Models.elm | 10 +- client/Pages/EventTypeCreate/Query.elm | 224 ++++++++++++++++++ client/Pages/EventTypeCreate/Update.elm | 16 ++ client/Pages/EventTypeCreate/View.elm | 69 +++--- client/Pages/EventTypeDetails/Messages.elm | 9 + client/Pages/EventTypeDetails/Models.elm | 13 ++ client/Pages/EventTypeDetails/PublishTab.elm | 22 +- client/Pages/EventTypeDetails/QueryTab.elm | 231 +++++++++++++++++++ client/Pages/EventTypeDetails/Update.elm | 73 +++++- client/Pages/EventTypeDetails/View.elm | 127 ++++++---- client/Pages/SubscriptionCreate/View.elm | 1 + client/Pages/SubscriptionDetails/View.elm | 1 + client/Routing/Models.elm | 11 + client/Routing/View.elm | 4 + client/Stores/Authorization.elm | 6 + client/Stores/Query.elm | 19 ++ client/Update.elm | 16 +- client/User/Commands.elm | 3 +- client/User/Models.elm | 4 + client/assets/styles.css | 59 +++-- client/index.js | 13 +- docker-compose.yml | 2 +- server/App.js | 2 + server/config.js | 6 +- server/nakadiSqlApi.js | 61 +++++ tests/mocks/data/appConf.json | 5 +- tests/mocks/data/sqlquery.json | 26 +++ tests/mocks/testNakadiSql.js | 22 ++ tests/mocks/testServer.js | 2 + tests/unit/config.spec.js | 5 +- 39 files changed, 1466 insertions(+), 166 deletions(-) create mode 100644 client/Helpers/Ace.elm create mode 100644 client/Native/Ace.js create mode 100644 client/Pages/EventTypeCreate/Query.elm create mode 100644 client/Pages/EventTypeDetails/QueryTab.elm create mode 100644 client/Stores/Query.elm create mode 100644 server/nakadiSqlApi.js create mode 100644 tests/mocks/data/sqlquery.json create mode 100644 tests/mocks/testNakadiSql.js diff --git a/.env.example b/.env.example index 5a0adec..1be6e1d 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,10 @@ SUPPORT_URL=https://hipchat.example.com/chat/room/12345 ALLOW_DELETE_EVENT_TYPE=yes FORBID_DELETE_URL=https://nakadi-faq.docs.example.com/#how-to-delete-et +NAKADI_SQL_API_URL="http://nakadi-sql.example.com" +QUERY_MONITORING_URL="https://zmon.example.com/grafana/dashboard/db/nakadi-et/?var-stack=live&var-$queryId={query}" +SHOW_NAKADI_SQL=no + MONITORING_URL="https://zmon.example.com/grafana/dashboard/db/nakadi-live" SLO_MONITORING_URL="https://zmon.example.com/grafana/dashboard/db/nakadi-slos" EVENT_TYPE_MONITORING_URL="https://zmon.example.com/grafana/dashboard/db/nakadi-et/?var-stack=nakadi-live&var-et={et}" diff --git a/Dockerfile b/Dockerfile index d2878cd..70fe7f9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,4 +43,8 @@ ENV CREDENTIALS_DIR="deploy/OAUTH" ENV HTTPS_ENABLE=0 ENV NODE_TLS_REJECT_UNAUTHORIZED=0 +ENV SHOW_NAKADI_SQL=yes +ENV NAKADI_SQL_API_URL="http://nakadi-sql.example.com" +ENV QUERY_MONITORING_URL="https://zmon.example.com/grafana/dashboard/db/nakadi-et/?var-stack=live&var-$queryId={query}" + ENTRYPOINT npm run start:prod diff --git a/INSTALL.md b/INSTALL.md index b393f9a..99f513c 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -126,6 +126,13 @@ AUTHORIZE_OPTIONS=myscope #ANALYTICS_OPTIONS_url="https://nakadi-staging.example.com" #ANALYTICS_OPTIONS_name="example-team.nakadi-ui.access-log" +# Nakadi SQL Support +# If "yes" shows create SQL Query menu item and the SQL Query tab +# for query output event types +SHOW_NAKADI_SQL=no +NAKADI_SQL_API_URL="http://nakadi-sql.example.com" +QUERY_MONITORING_URL="https://zmon.example.com/grafana/dashboard/db/nakadi-et/?var-stack=live&var-$queryId={query}" + # The key used to encode/decode user session. # Required COOKIE_SECRET="!!! CHANGE THIS!!! ksdgi98NNliuHHy^fdjy6b!khl_ig6%^#vGdsljhgl Bfdes&8yh3e" diff --git a/client/Config.elm b/client/Config.elm index c1ebc38..d0ed3fc 100644 --- a/client/Config.elm +++ b/client/Config.elm @@ -10,6 +10,10 @@ urlNakadiApi : String urlNakadiApi = urlBase ++ "api/nakadi/" +urlNakadiSqlApi : String +urlNakadiSqlApi = + urlBase ++ "api/nakadi-sql/" + urlValidationApi : String urlValidationApi = diff --git a/client/Helpers/AccessEditor.elm b/client/Helpers/AccessEditor.elm index 3b13eb4..be91165 100644 --- a/client/Helpers/AccessEditor.elm +++ b/client/Helpers/AccessEditor.elm @@ -39,7 +39,8 @@ type alias Config = { appsInfoUrl : String , usersInfoUrl : String , showWrite : Bool - , help: List (Html Msg) + , showAnyToken : Bool + , help : List (Html Msg) } @@ -395,7 +396,10 @@ accessTable config renderer records = in UI.grid header (List.concat - [ renderSection All emptyString + [ if config.showAnyToken then + renderSection All emptyString + else + [] , renderSection User "Users:" , renderSection Service "Services:" , renderSection Unknown "Unknown types:" @@ -496,5 +500,3 @@ hasPermission permissionType record = Admin -> record.permission.admin - - diff --git a/client/Helpers/Ace.elm b/client/Helpers/Ace.elm new file mode 100644 index 0000000..1e464a2 --- /dev/null +++ b/client/Helpers/Ace.elm @@ -0,0 +1,199 @@ +module Helpers.Ace exposing (..) + +{-| A library to use Ace editor with Elm. + + +# Editor + +@docs toHtml + + +# Ace's Attributes + +@docs theme, readOnly, mode, value, highlightActiveLine +@docs showPrintMargin, showCursor, showGutter, tabSize, useSoftTabs, useWrapMode +@docs enableBasicAutocompletion, enableLiveAutocompletion, enableSnippets, extensions + + +# Ace's Events + +@docs onSourceChange + +-} + +import Html exposing (Attribute, Html) +import Html.Attributes as Attributes +import Html.Events as Events +import Json.Decode as JD +import Json.Encode as JE +import Native.Ace + + +{-| Attribute to set the theme to Ace. + + Ace.toHtml [ Ace.theme "monokai" ] [] + +-} +theme : String -> Attribute msg +theme val = + Attributes.property "AceTheme" (JE.string val) + + +{-| Attribute to set editor in readonly. + + Ace.toHtml [ Ace.readOnly true ] [] + +-} +readOnly : Bool -> Attribute msg +readOnly val = + Attributes.property "AceReadOnly" (JE.bool val) + + +{-| Attribute to set the mode to Ace. + + Ace.toHtml [ Ace.mode "lua" ] [] + +-} +mode : String -> Attribute msg +mode val = + Attributes.property "AceMode" (JE.string val) + + +{-| Attribute to set initial value or to update current value of Ace. + + Ace.toHtml [ Ace.value "-- It's a source!\nlocal x = 1" ] [] + +-} +value : String -> Attribute msg +value val = + Attributes.property "AceValue" (JE.string val) + + +{-| Attribute to set whether to show the print margin or not. + + Ace.toHtml [ Ace.showPrintMargin false ] [] + +-} +showPrintMargin : Bool -> Attribute msg +showPrintMargin val = + Attributes.property "AceShowPrintMargin" (JE.bool val) + + +{-| Attribute to set whether show cursor or not + + Ace.toHtml [ Ace.showCursor false ] [] + +-} +showCursor : Bool -> Attribute msg +showCursor val = + Attributes.property "AceShowCursor" (JE.bool val) + + +{-| Attribute to set whether to show gutter or not. + + Ace.toHtml [ Ace.showGutter false ] [] + +-} +showGutter : Bool -> Attribute msg +showGutter val = + Attributes.property "AceShowGutter" (JE.bool val) + + +{-| Attribute to set whether to highlight the active line or not. + + Ace.toHtml [ Ace.highlightActiveLine false ] [] + +-} +highlightActiveLine : Bool -> Attribute msg +highlightActiveLine val = + Attributes.property "AceHighlightActiveLine" (JE.bool val) + + +{-| Attribute to set the tab size. + + Ace.toHtml [ Ace.tabSize 4 ] [] + +-} +tabSize : Int -> Attribute msg +tabSize val = + Attributes.property "AceTabSize" (JE.int val) + + +{-| Attribute to set whether to use soft tabs or not. + + Ace.toHtml [ Ace.useSoftTabs false ] [] + +-} +useSoftTabs : Bool -> Attribute msg +useSoftTabs val = + Attributes.property "AceUseSoftTabs" (JE.bool val) + + +{-| Attribute to set whether to use wrap mode. + + Ace.toHtml [ Ace.useWrapMode false ] [] + +-} +useWrapMode : Bool -> Attribute msg +useWrapMode val = + Attributes.property "AceUseWrapMode" (JE.bool val) + + +{-| Attribute to set autocompletion option. + + Ace.toHtml [ Ace.enableBasicAutocompletion true ] [] + +-} +enableBasicAutocompletion : Bool -> Attribute msg +enableBasicAutocompletion val = + Attributes.property "AceEnableBasicAutocompletion" (JE.bool val) + + +{-| Attribute to set live autocompletion option. + + Ace.toHtml [ Ace.enableLiveAutocompletion true ] [] + +-} +enableLiveAutocompletion : Bool -> Attribute msg +enableLiveAutocompletion val = + Attributes.property "AceEnableLiveAutocompletion" (JE.bool val) + + +{-| Attribute to activate snippets. + + Ace.toHtml [ Ace.enableSnippets true ] [] + +-} +enableSnippets : Bool -> Attribute msg +enableSnippets val = + Attributes.property "AceEnableSnippets" (JE.bool val) + + +{-| Set list of extensions for ace. + + Ace.toHtml [ Ace.extensions [ "language_tools" ] ] [] + +-} +extensions : List String -> Attribute msg +extensions exts = + Attributes.property "AceExtensions" (List.map JE.string exts |> JE.list) + + +{-| Values changes listener. It used to get notifications about changes made by user. + + Ace.toHtml [ Ace.onSourceChange model.data ] [] + +-} +onSourceChange : (String -> msg) -> Attribute msg +onSourceChange tagger = + Events.on "AceSourceChange" (JD.map tagger Events.targetValue) + + +{-| Creates `Html` instance with Ace attached to it. + + Ace.toHtml [] [] + +-} +toHtml : List (Attribute msg) -> List (Html msg) -> Html msg +toHtml = + Native.Ace.toHtml diff --git a/client/Helpers/Forms.elm b/client/Helpers/Forms.elm index 8f6dbaf..758b7c0 100644 --- a/client/Helpers/Forms.elm +++ b/client/Helpers/Forms.elm @@ -83,18 +83,21 @@ textInput : -> Locking -> Html msg textInput formModel field onInputMsg inputLabel inputPlaceholder hint help isRequired isDisabled = - inputFrame field inputLabel hint help isRequired formModel <| - input - [ onInput (onInputMsg field) - , value (getValue field formModel.values) - , type_ "text" - , validationClass field "dc-input" formModel - , id (inputId formModel.formId field) - , placeholder inputPlaceholder - , tabindex 1 - , disabled (isDisabled == Disabled) - ] - [] + if isDisabled == Disabled then + none + else + inputFrame field inputLabel hint help isRequired formModel <| + input + [ onInput (onInputMsg field) + , value (getValue field formModel.values) + , type_ "text" + , validationClass field "dc-input" formModel + , id (inputId formModel.formId field) + , placeholder inputPlaceholder + , tabindex 1 + , disabled (isDisabled == Disabled) + ] + [] selectInput : @@ -119,24 +122,27 @@ selectInput formModel field onInputMsg inputLabel hint help isRequired isDisable else (isDisabled == Disabled) in - inputFrame field inputLabel hint help isRequired formModel <| - select - [ onSelect (onInputMsg field) - , validationClass field "dc-select" formModel - , id (inputId formModel.formId field) - , tabindex 1 - , disabled isDisabledOrOne - ] - (options - |> List.map - (\optionName -> - option - [ selected (selectedValue == optionName) - , value optionName - ] - [ text optionName ] - ) - ) + if isDisabledOrOne then + none + else + inputFrame field inputLabel hint help isRequired formModel <| + select + [ onSelect (onInputMsg field) + , validationClass field "dc-select" formModel + , id (inputId formModel.formId field) + , tabindex 1 + , disabled isDisabledOrOne + ] + (options + |> List.map + (\optionName -> + option + [ selected (selectedValue == optionName) + , value optionName + ] + [ text optionName ] + ) + ) areaInput : diff --git a/client/Helpers/Header.elm b/client/Helpers/Header.elm index f27fda2..d2f248b 100644 --- a/client/Helpers/Header.elm +++ b/client/Helpers/Header.elm @@ -36,7 +36,7 @@ navLinks model = , tab (SubscriptionListRoute Pages.SubscriptionList.Models.emptyQuery) "Subscriptions" , span [ class "header__link" ] [ UI.externalLink "Documentation" model.userStore.user.settings.docsUrl ] - , buttonCreate + , buttonCreate model.userStore.user.settings.showNakadiSql ] ] @@ -58,23 +58,38 @@ rightPanel model = ] -buttonCreate : AppHtml -buttonCreate = - div [ class "dropdown-menu" ] - [ button [ class "dc-btn dc-btn--primary" ] - [ text "Create" - , span [ class "dc-btn-dropdown__arrow dc-btn-dropdown__arrow--down" ] [] - ] - , div [ class "dropdown-menu__popup" ] - [ a - [ class "dropdown-menu__item dc-link" - , href (routeToUrl EventTypeCreateRoute) +buttonCreate : Bool -> AppHtml +buttonCreate showNakadiSql = + let + className = + if showNakadiSql then + "show-sql-feature dropdown-menu" + else + "dropdown-menu" + in + div [ class className ] + [ button [ class "dc-btn dc-btn--primary" ] + [ text "Create" + , span [ class "dc-btn-dropdown__arrow dc-btn-dropdown__arrow--down" ] [] ] - [ text "Event Type" ] - , a - [ class "dropdown-menu__item dc-link" - , href (routeToUrl SubscriptionCreateRoute) + , div [ class "dropdown-menu__popup" ] + [ a + [ class "dropdown-menu__item dc-link" + , href (routeToUrl EventTypeCreateRoute) + ] + [ text "Event Type" ] + , if showNakadiSql then + a + [ class "dropdown-menu__item dc-link" + , href (routeToUrl QueryCreateRoute) + ] + [ text "SQL Query" ] + else + UI.none + , a + [ class "dropdown-menu__item dc-link" + , href (routeToUrl SubscriptionCreateRoute) + ] + [ text "Subscription" ] ] - [ text "Subscription" ] ] - ] diff --git a/client/Native/Ace.js b/client/Native/Ace.js new file mode 100644 index 0000000..b03ff71 --- /dev/null +++ b/client/Native/Ace.js @@ -0,0 +1,225 @@ +var _zalando_incubator$nakadi_ui$Native_Ace = function() { + +// TODO Check memory leaks +// TODO Set options directly to Ace, maybe... + +// Source: http://unscriptable.com/2009/03/20/debouncing-javascript-methods/ + var debounced = function (func, threshold, execAsap) { + + var timeout; + + return function debounced () { + var obj = this, args = arguments; + function delayed () { + if (!execAsap) + func.apply(obj, args); + timeout = null; + }; + + if (timeout) + clearTimeout(timeout); + else if (execAsap) + func.apply(obj, args); + + timeout = setTimeout(delayed, threshold || 100); + }; + + } + +// VIRTUAL-DOM WIDGETS + +// `toHtml` called everytime component appeared or model changed + function toHtml(factList, skipChildren) { + var model = extractModel(factList); + // Ace event's uses this facts to dispatch custom event + return _elm_lang$virtual_dom$Native_VirtualDom.custom(factList, model, implementation); + } + + function emptyModel() { + return { + theme: null, + mode: null, + value: null, + shared: null, + showPrintMargin: true, + highlightActiveLine: true, + tabSize: 4, + useSoftTabs: true, + useWrapMode: false, + readOnly: false, + showCursor: true, + showGutter: true, + extensions: [], + }; + } + + function extractModel(factList) { + var model = emptyModel(); + var current = factList; + while (current.ctor != "[]") { + var payload = current._0; + // TODO Consider to use map of functions instead of large switch/case + switch (payload.key) { + case "AceTheme": + model.theme = payload.value; + break; + case "AceMode": + model.mode = payload.value; + break; + case "AceValue": + model.value = payload.value; + break; + case "AceShowPrintMargin": + model.showPrintMargin = payload.value; + break; + case "AceHighlightActiveLine": + model.highlightActiveLine = payload.value; + break; + case "AceTabSize": + model.tabSize = payload.value; + break; + case "AceUseSoftTabs": + model.useSoftTabs = payload.value; + break; + case "AceUseWrapMode": + model.useWrapMode = payload.value; + break; + case "AceReadOnly": + model.readOnly = payload.value; + break; + case "AceShowCursor": + model.showCursor = payload.value; + break; + case "AceShowGutter": + model.showGutter = payload.value; + break; + case "AceEnableBasicAutocompletion": + model.enableBasicAutocompletion = payload.value; + break; + case "AceEnableLiveAutocompletion": + model.enableLiveAutocompletion = payload.value; + break; + case "AceEnableSnippets": + model.enableSnippets = payload.value; + break; + case "AceExtensions": + model.extensions = payload.value; + break; + + } + current = current._1; + } + return model; + } + +// WIDGET IMPLEMENTATION + + var implementation = { + render: render, + diff: diff + }; + +// +// `render` function calls everytime component appeared on th screen +// if you have tabs/pages and component hides it will be destroyed +// and new one created when you back to tab with this component +// `render` also be called +// +// It's impossible to detect when it destroyed, because it needs DOMNodeRemoved event +// which fires when component self-destroyed, but this API was deprecated +// and only MutationObserver available. The last needs an information about a parent +// which isn't available, because `div` created dynamically and will attached +// to tree later. Information about parent isn't available here. +// + function render(model) { + var shared = { + // Shared reference to an editor instance + editor: null, + // Skip next flag to prevent self-updates (much of them can drop typed symbols) + skipNext: false, + }; + var div = document.createElement('div'); + // TODO It replaces class + div.setAttribute("class", "elm-ace"); + + for (var ext in model.extensions) { + ace.require("ace/ext/" + ext); + } + var editor = ace.edit(div); + shared.editor = editor; + + editor.$blockScrolling = Infinity; // won't use deprecated + editor.setOptions({ + showPrintMargin: model.showPrintMargin, + highlightActiveLine: model.highlightActiveLine, + readOnly: model.readOnly, + showGutter: model.showGutter + }); + if (!model.showCursor) + editor.renderer.$cursorLayer.element.style.display = "none" + editor.getSession().setTabSize(model.tabSize); + editor.getSession().setUseSoftTabs(model.useSoftTabs); + editor.getSession().setUseWrapMode(model.useWrapMode); + editor.getSession().setValue(model.value || ""); + var dummy = emptyModel(); + dummy.shared = shared; + // It uses editor instance of prev and copy it to new + diff({ model: dummy }, { model: model }) + + // To resize automatically + editor.setAutoScrollEditorIntoView(true); + + var changer = function(_val) { + var new_source = editor.getSession().getValue(); + div.value = new_source; + var event = new Event('AceSourceChange'); + // Infinite loops are impossible, bacause Elm never calls `diff` inside handlers + shared.skipNext = true; + div.dispatchEvent(event); + div.value = null; + }; + + // Add debounce, because "change" event is extremelly often + // and value of Ace and model can be in different state + editor.on("change", debounced(changer, 150, false)); + + return div; + } + +// `diff` called everytime view updates, but you are still on the same page + function diff(prev, next) { + var pm = prev.model; + var nm = next.model; + var shared = pm.shared; + var editor = shared.editor; + var session = editor.getSession(); + + if (pm.theme != nm.theme) { + editor.setTheme("ace/theme/" + nm.theme); + } + + if (pm.mode != nm.mode) { + session.setMode("ace/mode/" + nm.mode); + } + + if (!shared.skipNext && nm.value != editor.getValue()) { + var pos = editor.getCursorPositionScreen(); + if (nm.value != null) { + editor.setValue(nm.value, pos); + } + } + + // Keep reference to shared state + shared.skipNext = false; + nm.shared = shared; + + // It's not necessary to use patches, because Ace do changes itself + // But It usesd to inform Ace about changes + return null; + } + + return { + toHtml: F2(toHtml), + }; + +}(); diff --git a/client/Pages/EventTypeCreate/Models.elm b/client/Pages/EventTypeCreate/Models.elm index d8fad95..81d8b3a 100644 --- a/client/Pages/EventTypeCreate/Models.elm +++ b/client/Pages/EventTypeCreate/Models.elm @@ -23,7 +23,7 @@ type Operation = Create | Update String | Clone String - + | CreateQuery type Field = FieldName @@ -39,6 +39,7 @@ type Field | FieldAccess | FieldAudience | FieldCleanupPolicy + | FieldSql type alias Model = @@ -84,6 +85,7 @@ defaultValues = , ( FieldOrderingKeyFields, emptyString ) , ( FieldRetentionTime, toString defaultRetentionDays ) , ( FieldSchema, defaultSchema ) + , ( FieldSql, defaultSql ) , ( FieldCompatibilityMode, compatibilityModes.forward ) , ( FieldAudience, "" ) , ( FieldCleanupPolicy, cleanupPolicies.delete ) @@ -155,3 +157,9 @@ defaultSchema = } } """ + +defaultSql : String +defaultSql = + """SELECT * + FROM `my-source-event-type` as payload +""" diff --git a/client/Pages/EventTypeCreate/Query.elm b/client/Pages/EventTypeCreate/Query.elm new file mode 100644 index 0000000..0018850 --- /dev/null +++ b/client/Pages/EventTypeCreate/Query.elm @@ -0,0 +1,224 @@ +module Pages.EventTypeCreate.Query exposing (..) + +import Pages.EventTypeCreate.Messages exposing (..) +import Pages.EventTypeCreate.Models exposing (..) +import Json.Encode as Json +import Http +import Config +import Helpers.Forms exposing (..) +import Helpers.AccessEditor as AccessEditor +import Helpers.Store as Store +import Stores.Authorization exposing (Authorization, emptyAuthorization) + + +{--------------- View -----------------} + +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (..) +import Helpers.AccessEditor as AccessEditor +import Config +import Helpers.Forms exposing (..) +import Pages.EventTypeDetails.Help as Help +import Helpers.Panel +import Stores.EventType exposing (allAudiences) +import Models exposing (AppModel) +import Helpers.UI exposing (..) +import Helpers.Ace as Ace +import Stores.EventType exposing (categories) + + +viewQueryForm : AppModel -> Html Msg +viewQueryForm model = + let + formModel = + model.eventTypeCreatePage + + { appsInfoUrl, usersInfoUrl, supportUrl } = + model.userStore.user.settings + + formTitle = + "Create SQL Query" + in + div [ class "dc-column form-create__form-container" ] + [ div [] + [ h4 [ class "dc-h4 dc--text-center" ] [ text formTitle ] + , textInput formModel + FieldName + OnInput + "Output Event Type Name" + "Example: bazar.price-updater.price_changed" + "Should be several words (with '_', '-') separated by dot." + Help.eventType + Required + Enabled + , textInput formModel + FieldOwningApplication + OnInput + "Owning Application" + "Example: stups_price-updater" + "App name registered in YourTurn with 'stups_' prefix" + Help.owningApplication + Required + Enabled + , selectInput + formModel + FieldCategory + OnInput + "Category" + "" + Help.category + Optional + Enabled + [ categories.business + , categories.data + ] + , textInput formModel + FieldOrderingKeyFields + OnInput + "Ordering Key Fields" + "Example: order.day, order.index" + "Comma-separated list of keys." + Help.orderingKeyFields + Optional + Enabled + , selectInput formModel + FieldAudience + OnInput + "Audience" + "" + Help.audience + Required + Enabled + ("" :: allAudiences) + , sqlEditor formModel + , hr [ class "dc-divider" ] [] + , sqlAccessEditor appsInfoUrl usersInfoUrl formModel + ] + , hr [ class "dc-divider" ] + [] + , div + [ class "dc-toast__content dc-toast__content--success" ] + [ text "Nakady SQL Query Created!" ] + |> Helpers.Panel.loadingStatus formModel + , buttonPanel formTitle Submit Reset FieldName formModel + ] + + +sqlEditor : Model -> Html Msg +sqlEditor formModel = + inputFrame FieldSql "SQL Query" "" helpSql Required formModel <| + div [] + [ div [ class "dc-btn-group" ] [] + , pre + [ class "ace-edit" ] + [ Ace.toHtml + [ Ace.value (getValue FieldSql formModel.values) + , Ace.onSourceChange (OnInput FieldSql) + , Ace.mode "sql" + , Ace.theme "dawn" + , Ace.tabSize 4 + , Ace.useSoftTabs False + , Ace.extensions [ "language_tools" ] + , Ace.enableLiveAutocompletion True + , Ace.enableBasicAutocompletion True + ] + [] + ] + ] + + +sqlAccessEditor : String -> String -> Model -> Html Msg +sqlAccessEditor appsInfoUrl usersInfoUrl formModel = + AccessEditor.view + { appsInfoUrl = appsInfoUrl + , usersInfoUrl = usersInfoUrl + , showWrite = False + , showAnyToken = False + , help = Help.authorization + } + AccessEditorMsg + formModel.accessEditor + + + +{-------------- Update ----------------} + + +submitQueryCreate : Model -> Cmd Msg +submitQueryCreate model = + let + orderingKeyFields = + model.values + |> getValue FieldOrderingKeyFields + |> stringToJsonList + + asString field = + model.values + |> getValue field + |> String.trim + |> Json.string + + auth = + AccessEditor.unflatten model.accessEditor.authorization + |> Stores.Authorization.encoderReadAdmin + + fields = + [ ( "output_event_type" + , Json.object + [ ( "name", asString FieldName ) + , ( "owning_application", asString FieldOwningApplication ) + , ( "category", asString FieldCategory ) + , ( "ordering_key_fields", orderingKeyFields ) + , ( "audience", asString FieldAudience ) + ] + ) + , ( "sql", asString FieldSql ) + , ( "authorization", auth ) + ] + + body = + Json.object (fields) + in + post body + + +post : Json.Value -> Cmd Msg +post body = + Http.request + { method = "POST" + , headers = [] + , url = Config.urlNakadiSqlApi ++ "queries" + , body = Http.jsonBody body + , expect = Http.expectStringResponse (always (Ok ())) + , timeout = Nothing + , withCredentials = False + } + |> Http.send SubmitResponse + + +stringToJsonList : String -> Json.Value +stringToJsonList str = + str + |> String.split "," + |> List.map String.trim + |> List.filter (String.isEmpty >> not) + |> List.map Json.string + |> Json.list + + +helpSql : List (Html msg) +helpSql = + [ text "The SQL query to be run by the executor." + , newline + , text "The SQL statements supported are a subset of ANSI SQL." + , newline + , text "The operations supported are joining two or more EventTypes and filtering" + , text " EventTypes to an output EventType. The EventTypes on which these queries are run MUST" + , text " be log-compacted EventTypes. The EventTypes that are used for join queries MUST have the" + , text " equal number of partitions and the EventTypes are joined on their compaction keys. Also," + , text " the join is done on per partition basis. The output EventType has the same number of" + , text " partitions as the input EventType(s)." + , newline + , link "More in the API Manual" "https://apis.zalando.net/apis/3d932e38-b9db-42cf-84bb-0898a72895fb/ui" + ] diff --git a/client/Pages/EventTypeCreate/Update.elm b/client/Pages/EventTypeCreate/Update.elm index 75b9fe8..7982eec 100644 --- a/client/Pages/EventTypeCreate/Update.elm +++ b/client/Pages/EventTypeCreate/Update.elm @@ -2,6 +2,7 @@ module Pages.EventTypeCreate.Update exposing (..) import Pages.EventTypeCreate.Messages exposing (..) import Pages.EventTypeCreate.Models exposing (..) +import Pages.EventTypeCreate.Query exposing (submitQueryCreate) import Http import Dict import Json.Encode as Json @@ -69,6 +70,9 @@ update message model eventTypeStore = Update name -> ( Store.onFetchStart model, submitUpdate model ) + CreateQuery -> + ( Store.onFetchStart model, submitQueryCreate model ) + Reset -> let newModel = @@ -90,6 +94,9 @@ update message model eventTypeStore = |> setValue FieldName ("clone_of_" ++ name) } + CreateQuery -> + { initialModel | operation = model.operation } + authorization = case model.operation of Create -> @@ -101,6 +108,9 @@ update message model eventTypeStore = Clone name -> authorizationFromEventType (Just name) eventTypeStore + CreateQuery -> + authorizationFromEventType Nothing eventTypeStore + loadPartitionsCmd = case model.operation of Create -> @@ -114,6 +124,9 @@ update message model eventTypeStore = |> PartitionsStoreMsg |> dispatch + CreateQuery -> + Cmd.none + cmd = Cmd.batch [ Dom.focus "eventTypeCreateFormFieldName" |> Task.attempt FocusResult @@ -213,6 +226,9 @@ validate model eventTypeStore = |> checkPartitionKeys model |> checkSchemaFormat model |> isNotEmpty FieldSchema model + + CreateQuery -> + checkAll in { model | validationErrors = errors } diff --git a/client/Pages/EventTypeCreate/View.elm b/client/Pages/EventTypeCreate/View.elm index 60c653c..745f7b2 100644 --- a/client/Pages/EventTypeCreate/View.elm +++ b/client/Pages/EventTypeCreate/View.elm @@ -5,6 +5,7 @@ import Html.Attributes exposing (..) import Html.Events exposing (..) import Pages.EventTypeCreate.Messages exposing (..) import Pages.EventTypeCreate.Models exposing (..) +import Pages.EventTypeCreate.Query exposing (viewQueryForm) import Helpers.UI exposing (helpIcon, PopupPosition(..), onSelect, none, externalLink) import Pages.EventTypeDetails.Help as Help import Models exposing (AppModel) @@ -27,6 +28,7 @@ import Stores.EventType import Helpers.AccessEditor as AccessEditor import Config import Helpers.Forms exposing (..) +import Helpers.Ace as Ace view : AppModel -> Html Msg @@ -63,17 +65,22 @@ view model = Helpers.Panel.loadingStatus formModel.partitionsStore <| findEventType name viewFormClone + CreateQuery -> + container <| + viewQueryForm model + viewFormCreate : AppModel -> Html Msg viewFormCreate model = viewForm model - { updateMode = False + { nameEditing = Enabled , formTitle = "Create Event Type" , successMessage = "Event Type Created!" , categoriesOptions = allCategories , compatibilityModeOptions = allModes , cleanupPoliciesOptions = allCleanupPolicies , partitionStrategyEditing = Enabled + , partitionNumberEditing = Enabled } @@ -114,44 +121,47 @@ viewFormUpdate model originalEventType = [ originalEventType.cleanup_policy ] in viewForm model - { updateMode = True + { nameEditing = Disabled , formTitle = "Update Event Type" , successMessage = "Event Type Updated!" , categoriesOptions = categoriesOptions , compatibilityModeOptions = compatibilityModeOptions , cleanupPoliciesOptions = cleanupPoliciesOptions , partitionStrategyEditing = partitionStrategyEditing + , partitionNumberEditing = Disabled } viewFormClone : AppModel -> EventType -> Html Msg viewFormClone model originalEventType = viewForm model - { updateMode = False + { nameEditing = Enabled , formTitle = "Clone Event Type" , successMessage = "Event Type Cloned!" , categoriesOptions = allCategories , compatibilityModeOptions = allModes , cleanupPoliciesOptions = allCleanupPolicies , partitionStrategyEditing = Enabled + , partitionNumberEditing = Enabled } type alias FormSetup = - { updateMode : Bool + { nameEditing : Locking , formTitle : String , successMessage : String , categoriesOptions : List String , compatibilityModeOptions : List String , cleanupPoliciesOptions : List String , partitionStrategyEditing : Locking + , partitionNumberEditing : Locking } viewForm : AppModel -> FormSetup -> Html Msg viewForm model setup = let - { updateMode, formTitle, successMessage, categoriesOptions, compatibilityModeOptions, cleanupPoliciesOptions, partitionStrategyEditing } = + { nameEditing, formTitle, successMessage, categoriesOptions, compatibilityModeOptions, cleanupPoliciesOptions, partitionStrategyEditing, partitionNumberEditing } = setup formModel = @@ -177,11 +187,7 @@ viewForm model setup = "Should be several words (with '_', '-') separated by dot." Help.eventType Required - (if updateMode then - Disabled - else - Enabled - ) + nameEditing , textInput formModel FieldOwningApplication OnInput @@ -230,18 +236,15 @@ viewForm model setup = ) ] ] - , if updateMode then - none - else - selectInput formModel - FieldPartitionsNumber - OnInput - "Number of Partitions" - "" - Help.defaultStatistic - Optional - Enabled - (List.range 1 Config.maxPartitionNumber |> List.map toString) + , selectInput formModel + FieldPartitionsNumber + OnInput + "Number of Partitions" + "" + Help.defaultStatistic + Optional + partitionNumberEditing + (List.range 1 Config.maxPartitionNumber |> List.map toString) , textInput formModel FieldOrderingKeyFields OnInput @@ -320,6 +323,7 @@ accessEditor appsInfoUrl usersInfoUrl formModel = { appsInfoUrl = appsInfoUrl , usersInfoUrl = usersInfoUrl , showWrite = True + , showAnyToken = True , help = Help.authorization } AccessEditorMsg @@ -342,12 +346,19 @@ schemaEditor formModel = ] [ text "Clear" ] ] - , textarea - [ onInput (OnInput FieldSchema) - , value (getValue FieldSchema formModel.values) - , id (inputId formModel.formId FieldSchema) - , validationClass FieldSchema "dc-textarea" formModel - , tabindex 2 + , pre + [ class "ace-edit" ] + [ Ace.toHtml + [ Ace.value (getValue FieldSchema formModel.values) + , Ace.onSourceChange (OnInput FieldSchema) + , Ace.mode "json" + , Ace.theme "dawn" + , Ace.tabSize 4 + , Ace.useSoftTabs False + , Ace.extensions [ "language_tools" ] + , Ace.enableLiveAutocompletion True + , Ace.enableBasicAutocompletion True + ] + [] ] - [] ] diff --git a/client/Pages/EventTypeDetails/Messages.elm b/client/Pages/EventTypeDetails/Messages.elm index 9e7d716..8f48147 100644 --- a/client/Pages/EventTypeDetails/Messages.elm +++ b/client/Pages/EventTypeDetails/Messages.elm @@ -9,9 +9,11 @@ import Stores.CursorDistance import Stores.Partition import Stores.EventTypeSchema import Stores.EventTypeValidation +import Stores.Query import Http import RemoteData exposing (WebData) + type Msg = OnRouteChange Route | Reload @@ -44,3 +46,10 @@ type Msg | SendEvent | SendEventResponse (WebData String) | SendEventReset + | LoadQuery String + | LoadQueryResponse (WebData Stores.Query.Query) + | OpenDeleteQueryPopup + | CloseDeleteQueryPopup + | ConfirmQueryDelete + | QueryDelete + | QueryDeleteResponse (WebData ()) diff --git a/client/Pages/EventTypeDetails/Models.elm b/client/Pages/EventTypeDetails/Models.elm index e884be7..44f8198 100644 --- a/client/Pages/EventTypeDetails/Models.elm +++ b/client/Pages/EventTypeDetails/Models.elm @@ -10,6 +10,7 @@ import Stores.Partition import Stores.CursorDistance import Stores.EventTypeSchema import Stores.EventTypeValidation +import Stores.Query exposing (Query) import Helpers.Store exposing (Status(Unknown), ErrorMessage) import Http import RemoteData exposing (WebData, RemoteData(NotAsked)) @@ -31,12 +32,16 @@ initialModel = , validationIssuesStore = Stores.EventTypeValidation.initialModel , editEvent = emptyString , sendEventResponse = NotAsked + , loadQueryResponse = NotAsked , deletePopup = { isOpen = False , deleteCheckbox = False , status = Unknown , error = Nothing } + , deleteQueryPopupOpen = False + , deleteQueryPopupCheck = False + , deleteQueryResponse = NotAsked } @@ -47,6 +52,7 @@ type Tabs | ConsumerTab | AuthTab | PublishTab + | QueryTab type alias Model = @@ -64,12 +70,16 @@ type alias Model = , validationIssuesStore : Stores.EventTypeValidation.Model , editEvent : String , sendEventResponse : WebData String + , loadQueryResponse : WebData Query , deletePopup : { isOpen : Bool , deleteCheckbox : Bool , status : Status , error : Maybe ErrorMessage } + , deleteQueryPopupOpen : Bool + , deleteQueryPopupCheck : Bool + , deleteQueryResponse : WebData () } @@ -142,5 +152,8 @@ stringToTabs str = "PublishTab" -> Just PublishTab + "QueryTab" -> + Just QueryTab + _ -> Nothing diff --git a/client/Pages/EventTypeDetails/PublishTab.elm b/client/Pages/EventTypeDetails/PublishTab.elm index 1837ea1..e503e6d 100644 --- a/client/Pages/EventTypeDetails/PublishTab.elm +++ b/client/Pages/EventTypeDetails/PublishTab.elm @@ -15,6 +15,7 @@ import Http import Json.Decode import Config import Result +import Helpers.Ace as Ace publishTab : Model -> Html Msg @@ -63,14 +64,21 @@ Example: [ h3 [ class "dc-h3" ] [ text "Publish event to this Event Type" ] , p [ class "dc--text-less-important" ] [ text "Expectd JSON array of events. Example: [{\"order_id\": \"1052\"}, {\"order_id\": \"8364\"}]" ] , div [] - [ textarea - [ onInput EditEvent - , placeholder aPlaceholder - , value pageState.editEvent - , rows 15 - , class "dc-textarea" + [ pre + [ class "ace-edit" ] + [ Ace.toHtml + [ Ace.value pageState.editEvent + , Ace.onSourceChange EditEvent + , Ace.mode "json" + , Ace.theme "dawn" + , Ace.tabSize 4 + , Ace.useSoftTabs False + , Ace.extensions [ "language_tools" ] + , Ace.enableLiveAutocompletion True + , Ace.enableBasicAutocompletion True + ] + [] ] - [] ] , div [ class "dc--text-error" ] [ text jsonError ] , showRemoteDataStatus pageState.sendEventResponse diff --git a/client/Pages/EventTypeDetails/QueryTab.elm b/client/Pages/EventTypeDetails/QueryTab.elm new file mode 100644 index 0000000..398295c --- /dev/null +++ b/client/Pages/EventTypeDetails/QueryTab.elm @@ -0,0 +1,231 @@ +module Pages.EventTypeDetails.QueryTab exposing (..) + +import Pages.EventTypeDetails.Models exposing (Model) +import Pages.EventTypeDetails.Messages exposing (..) +import Stores.Query exposing (Query, queryDecoder) +import RemoteData +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (..) +import Helpers.UI exposing (..) +import Helpers.Panel exposing (renderError, warningMessage) +import Helpers.Store exposing (errorToViewRecord) +import RemoteData exposing (WebData, isLoading) +import Http +import Json.Decode +import Config +import User.Models exposing (Settings) +import String.Extra exposing (replace) +import Helpers.Ace as Ace + + +queryTab : Settings -> Model -> Html Msg +queryTab setting pageState = + div [ class "dc-card" ] + [ showRemoteDataStatus + pageState.loadQueryResponse + (queryTabHeader setting pageState) + ] + + +queryTabHeader : Settings -> Model -> Query -> Html Msg +queryTabHeader settings model query = + let + statClass = + case query.status of + "active" -> + "schema-tab__value dc-status dc-status--active" + + "inactive" -> + "schema-tab__value dc-status dc-status--error" + + _ -> + "schema-tab__value dc-status dc-status--inactive" + + terminate = + if query.status == "active" then + span + [ onClick OpenDeleteQueryPopup + , class "icon-link dc-icon--trash dc-btn--destroy dc-icon dc-icon--interactive" + , title "Terminate Query" + ] + [] + else + none + in + div [] + [ span [] [ text "SQL Query" ] + , helpIcon "Nakadi SQL" queryHelp BottomRight + , label [ class "query-tab__label" ] [ text " Status: " ] + , span [ class (statClass) ] [ text query.status ] + , span [ class "query-tab__value toolbar" ] + [ a + [ title "View Query as raw JSON" + , class "icon-link dc-icon dc-icon--interactive" + , target "_blank" + , href <| Config.urlNakadiSqlApi ++ "queries/" ++ query.id + ] + [ i [ class "far fa-file-code" ] [] ] + , a + [ title "Query Monitoring Graphs" + , class "icon-link dc-icon dc-icon--interactive" + , target "_blank" + , href <| replace "{query}" query.id settings.queryMonitoringUrl + ] + [ i [ class "fas fa-chart-line" ] [] ] + , a + [ onClick (CopyToClipboard query.sql) + , class "icon-link dc-icon dc-icon--interactive" + , title "Copy To Clipboard" + ] + [ i [ class "far fa-clipboard" ] [] ] + , terminate + ] + , sqlView query.sql + , deleteQueryPopup model query + ] + + +queryHelp : List (Html msg) +queryHelp = + [ text "Nakadi SQL API provides a self-serviceable SQL interface for stream processing Nakadi event" + , text " types. By expressing transformations as SQL, this service enables a broader audience to analyse" + , text " and process streaming data in real-time. Nakadi SQL is scalable, elastic and fault-tolerant." + , text " It is planned to support a wide range of streaming operations, including data filtering," + , text " transformations, aggregations, joins, windowing, and sessionization." + , newline + , text "A query describes a set of operations to be performed on one or more EventTypes." + , newline + , text "The output events are written to an output EventType, which can be accessed via Nakadi." + , newline + , link "More in the API Manual" "https://apis.zalando.net/apis/3d932e38-b9db-42cf-84bb-0898a72895fb/ui" + ] + + +sqlView : String -> Html msg +sqlView sql = + pre [ class "sql-view" ] + [ Ace.toHtml + [ Ace.value sql + , Ace.mode "sql" + , Ace.theme "dawn" + , Ace.tabSize 4 + , Ace.useSoftTabs False + , Ace.extensions [ "language_tools" ] + , Ace.readOnly True + ] + [] + ] + + +loadQuery : (WebData Query -> msg) -> String -> Cmd msg +loadQuery tagger id = + Http.get (Config.urlNakadiSqlApi ++ "queries/" ++ (Http.encodeUri id)) queryDecoder + |> RemoteData.sendRequest + |> Cmd.map tagger + + +showRemoteDataStatus : WebData a -> (a -> Html Msg) -> Html Msg +showRemoteDataStatus state content = + case state of + RemoteData.NotAsked -> + div [] [ none ] + + RemoteData.Loading -> + div [] [ text "Loading..." ] + + RemoteData.Success resp -> + content resp + + RemoteData.Failure resp -> + resp |> errorToViewRecord |> renderError + + +deleteQueryPopup : Model -> Query -> Html Msg +deleteQueryPopup model query = + let + deleteButton = + if model.deleteQueryPopupCheck then + button + [ onClick QueryDelete + , class "dc-btn dc-btn--destroy" + ] + [ text "Delete Query" ] + else + button [ disabled True, class "dc-btn dc-btn--disabled" ] + [ text "Delete Query" ] + + dialog = + div [] + [ div [ class "dc-overlay" ] [] + , div [ class "dc-dialog" ] + [ div [ class "dc-dialog__content", style [ ( "min-width", "600px" ) ] ] + [ div [ class "dc-dialog__body" ] + [ div [ class "dc-dialog__close" ] + [ i + [ onClick CloseDeleteQueryPopup + , class "dc-icon dc-icon--close dc-icon--interactive dc-dialog__close__icon" + ] + [] + ] + , h3 [ class "dc-dialog__title" ] + [ text "Delete/Terminate Query" ] + , div [ class "dc-msg dc-msg--error" ] + [ div [ class "dc-msg__inner" ] + [ div [ class "dc-msg__icon-frame" ] + [ i [ class "dc-icon dc-msg__icon dc-icon--warning" ] [] + ] + , div [ class "dc-msg__bd" ] + [ h1 [ class "dc-msg__title blinking" ] [ text "Warning! Dangerous Action!" ] + , p [ class "dc-msg__text" ] + [ text "You are about to completely delete this query forever." + , text " This action cannot be undone." + ] + ] + ] + ] + , h1 [ class "dc-h1 dc--is-important" ] [ text query.id ] + , p [ class "dc-p" ] + [ text "Think twice, notify all consumers and producers." + ] + , showRemoteDataStatus model.deleteQueryResponse (always none) + ] + , div [ class "dc-dialog__actions" ] + [ input + [ onClick ConfirmQueryDelete + , type_ "checkbox" + , class "dc-checkbox" + , id "confirmDeleteQuery" + , checked model.deleteQueryPopupCheck + ] + [] + , label + [ for "confirmDeleteQuery", class "dc-label" ] + [ text "Yes, delete " + , b [] [ text query.id ] + ] + , deleteButton + ] + ] + ] + ] + in + if model.deleteQueryPopupOpen then + dialog + else + none + + +deleteQuery : (WebData () -> msg) -> String -> Cmd msg +deleteQuery tagger id = + Http.request + { method = "DELETE" + , headers = [] + , url = Config.urlNakadiSqlApi ++ "queries/" ++ (Http.encodeUri id) + , body = Http.emptyBody + , expect = Http.expectStringResponse (always (Ok ())) + , timeout = Nothing + , withCredentials = False + } + |> RemoteData.sendRequest + |> Cmd.map tagger diff --git a/client/Pages/EventTypeDetails/Update.elm b/client/Pages/EventTypeDetails/Update.elm index c1c11ba..79f3894 100644 --- a/client/Pages/EventTypeDetails/Update.elm +++ b/client/Pages/EventTypeDetails/Update.elm @@ -3,6 +3,7 @@ module Pages.EventTypeDetails.Update exposing (..) import Pages.EventTypeDetails.Messages exposing (Msg(..)) import Pages.EventTypeDetails.Models exposing (Model, initialModel, Tabs(..)) import Pages.EventTypeDetails.PublishTab exposing (sendEvent) +import Pages.EventTypeDetails.QueryTab exposing (loadQuery, deleteQuery) import Routing.Models exposing (Route(EventTypeDetailsRoute)) import Helpers.Task exposing (dispatch) import Helpers.JsonEditor @@ -19,11 +20,12 @@ import User.Commands exposing (logoutIfExpired) import Constants import Http import Config -import RemoteData exposing (RemoteData(NotAsked)) +import RemoteData exposing (isFailure, isSuccess, RemoteData(Loading, Failure, NotAsked)) +import User.Models exposing (Settings) -update : Msg -> Model -> ( Model, Cmd Msg, Route ) -update message model = +update : Settings -> Msg -> Model -> ( Model, Cmd Msg, Route ) +update settings message model = let deletePopup = model.deletePopup @@ -51,6 +53,7 @@ update message model = , Cmd.batch [ dispatch (TabChange model.tab) , dispatch (ValidationStoreMsg (loadSubStoreMsg model.name)) + , dispatch (LoadQuery model.name) ] ) @@ -69,6 +72,9 @@ update message model = TabChange tab -> ( { model | tab = tab } , case tab of + QueryTab -> + Cmd.none + SchemaTab -> dispatch (EventTypeSchemasStoreMsg (loadSubStoreMsg model.name)) @@ -122,6 +128,36 @@ update message model = in ( { model | eventTypeSchemasStore = subModel }, Cmd.map EventTypeSchemasStoreMsg msCmd ) + LoadQuery id -> + let + startLoadingQuery = + ( { model | loadQueryResponse = Loading } + , loadQuery LoadQueryResponse id + ) + + switchTab = + ( { model | loadQueryResponse = Failure Http.NetworkError } + , if model.tab == QueryTab then + dispatch (TabChange SchemaTab) + else + Cmd.none + ) + in + if settings.showNakadiSql then + startLoadingQuery + else + switchTab + + LoadQueryResponse resp -> + let + switchTabOnFailure = + if isFailure resp && model.tab == QueryTab then + dispatch (TabChange SchemaTab) + else + Cmd.none + in + ( { model | loadQueryResponse = resp }, switchTabOnFailure ) + LoadPublishers -> ( model, dispatch (PublishersStoreMsg (loadSubStoreMsg model.name)) ) @@ -244,6 +280,37 @@ update message model = Err error -> ( { model | deletePopup = Store.onFetchErr deletePopup error }, logoutIfExpired error ) + OpenDeleteQueryPopup -> + ( { model + | deleteQueryResponse = NotAsked + , deleteQueryPopupCheck = False + , deleteQueryPopupOpen = True + } + , Cmd.none + ) + + CloseDeleteQueryPopup -> + ( { model | deleteQueryPopupOpen = False }, Cmd.none ) + + ConfirmQueryDelete -> + ( { model | deleteQueryPopupCheck = not model.deleteQueryPopupCheck }, Cmd.none ) + + QueryDelete -> + ( model, deleteQuery QueryDeleteResponse model.name ) + + QueryDeleteResponse response -> + let + cmd = + if response |> isSuccess then + Cmd.batch + [ dispatch CloseDeleteQueryPopup + , dispatch (LoadQuery model.name) + ] + else + Cmd.none + in + ( { model | deleteQueryResponse = response }, cmd ) + OutOnEventTypeDeleted -> ( model, Cmd.none ) diff --git a/client/Pages/EventTypeDetails/View.elm b/client/Pages/EventTypeDetails/View.elm index 0ca40d1..dc991c7 100644 --- a/client/Pages/EventTypeDetails/View.elm +++ b/client/Pages/EventTypeDetails/View.elm @@ -3,6 +3,7 @@ module Pages.EventTypeDetails.View exposing (..) import Html exposing (..) import Html.Attributes exposing (..) import Html.Events exposing (..) +import RemoteData exposing (isSuccess) import String.Extra exposing (replace) import Models exposing (AppModel) import Helpers.Panel exposing (loadingStatus, warningMessage) @@ -30,6 +31,7 @@ import Pages.EventTypeDetails.Messages exposing (..) import Pages.EventTypeDetails.Models exposing (Tabs(..), Model) import Pages.EventTypeDetails.Help as Help import Pages.EventTypeDetails.PublishTab exposing (publishTab) +import Pages.EventTypeDetails.QueryTab exposing (queryTab) import Pages.EventTypeDetails.EffectiveSchema exposing (toEffective) import Pages.EventTypeList.Models import Pages.Partition.Models @@ -101,11 +103,20 @@ detailsLayout typeName eventType model = model.eventTypeDetailsPage.version |> Maybe.withDefault (eventType.schema.version |> Maybe.withDefault Constants.noneLabel) + settings = + model.userStore.user.settings + appsInfoUrl = - model.userStore.user.settings.appsInfoUrl + settings.appsInfoUrl usersInfoUrl = - model.userStore.user.settings.usersInfoUrl + settings.usersInfoUrl + + showNakadiSql = + settings.showNakadiSql + + isQueryOutput = + showNakadiSql && isSuccess pageState.loadQueryResponse tab = pageState.tab @@ -210,54 +221,69 @@ detailsLayout typeName eventType model = infoDateToText eventType.updated_at ] ] - , tabs tabOptions - (Just tab) - [ ( SchemaTab - , "Schema" - , schemaTab - jsonEditorState - pageState.eventTypeSchemasStore - selectedVersion - pageState.formatted - pageState.effective - eventType - ) - , ( PartitionsTab - , "Partitions" - , partitionsTab - eventType - pageState.partitionsStore - pageState.totalsStore - ) - , ( PublisherTab - , "Publishers" - , publisherTab - eventType - pageState.publishersStore - appsInfoUrl - usersInfoUrl - ) - , ( ConsumerTab - , "Consumers" - , consumersTab - eventType - pageState.consumersStore - model.subscriptionStore - appsInfoUrl - usersInfoUrl - ) - , ( AuthTab - , "Authorization" - , authTab - appsInfoUrl - usersInfoUrl - eventType - ) - , ( PublishTab - , "Publish Events" - , publishTab pageState - ) - ] + , tabs tabOptions (Just tab) <| + List.concat + [ [ ( SchemaTab + , "Schema" + , schemaTab + jsonEditorState + pageState.eventTypeSchemasStore + selectedVersion + pageState.formatted + pageState.effective + eventType + ) + ] + , (if isQueryOutput then + [ ( QueryTab + , "SQL Query" + , queryTab settings pageState + ) + ] + else + [] + ) + , [ ( PartitionsTab + , "Partitions" + , partitionsTab + eventType + pageState.partitionsStore + pageState.totalsStore + ) + , ( PublisherTab + , "Publishers" + , publisherTab + eventType + pageState.publishersStore + appsInfoUrl + usersInfoUrl + ) + , ( ConsumerTab + , "Consumers" + , consumersTab + eventType + pageState.consumersStore + model.subscriptionStore + appsInfoUrl + usersInfoUrl + ) + , ( AuthTab + , "Authorization" + , authTab + appsInfoUrl + usersInfoUrl + eventType + ) + ] + , if not isQueryOutput then + [ ( PublishTab + , "Publish Events" + , publishTab pageState + ) + ] + else + [] + ] ] , deletePopup model eventType @@ -707,6 +733,7 @@ authTab appsInfoUrl usersInfoUrl eventType = { appsInfoUrl = appsInfoUrl , usersInfoUrl = usersInfoUrl , showWrite = True + , showAnyToken = True , help = Help.authorization } (always Reload) diff --git a/client/Pages/SubscriptionCreate/View.elm b/client/Pages/SubscriptionCreate/View.elm index 16e8082..b99317d 100644 --- a/client/Pages/SubscriptionCreate/View.elm +++ b/client/Pages/SubscriptionCreate/View.elm @@ -201,6 +201,7 @@ accessEditor appsInfoUrl usersInfoUrl formModel = { appsInfoUrl = appsInfoUrl , usersInfoUrl = usersInfoUrl , showWrite = False + , showAnyToken = True , help = Help.authorization } AccessEditorMsg diff --git a/client/Pages/SubscriptionDetails/View.elm b/client/Pages/SubscriptionDetails/View.elm index 7ce5ac0..3038fb1 100644 --- a/client/Pages/SubscriptionDetails/View.elm +++ b/client/Pages/SubscriptionDetails/View.elm @@ -450,6 +450,7 @@ authTab appsInfoUrl usersInfoUrl subscription = { appsInfoUrl = appsInfoUrl , usersInfoUrl = usersInfoUrl , showWrite = False + , showAnyToken = True , help = Help.authorization } (always Refresh) diff --git a/client/Routing/Models.elm b/client/Routing/Models.elm index ac83709..9a2de56 100644 --- a/client/Routing/Models.elm +++ b/client/Routing/Models.elm @@ -27,6 +27,7 @@ type Route | SubscriptionCreateRoute | SubscriptionUpdateRoute SubscriptionDetails.UrlParams | SubscriptionCloneRoute SubscriptionDetails.UrlParams + | QueryCreateRoute | NotFoundRoute @@ -58,6 +59,10 @@ routingConfig = , \( params, query ) -> EventTypeCreateRoute ) + , ( "createquery" + , \( params, query ) -> + QueryCreateRoute + ) , ( "types/:name/update" , \( params, query ) -> EventTypeUpdateRoute (EventTypeDetails.dictToParams params) @@ -135,6 +140,9 @@ routeToUrl route = SubscriptionCloneRoute params -> "#subscriptions/" ++ (Http.encodeUri params.id) ++ "/clone" + QueryCreateRoute -> + "#createquery" + routeToTitle : Route -> String routeToTitle route = @@ -184,6 +192,9 @@ routeToTitle route = SubscriptionCloneRoute params -> " - Clone Subscription - " ++ params.id + QueryCreateRoute -> + " - Create SQL Query" + initialModel : Model initialModel = diff --git a/client/Routing/View.elm b/client/Routing/View.elm index fdf511f..dab707b 100644 --- a/client/Routing/View.elm +++ b/client/Routing/View.elm @@ -42,6 +42,10 @@ view model = Html.map EventTypeCreateMsg <| Pages.EventTypeCreate.View.view model + QueryCreateRoute -> + Html.map EventTypeCreateMsg <| + Pages.EventTypeCreate.View.view model + PartitionRoute param query -> Html.map PartitionMsg <| Pages.Partition.View.view model diff --git a/client/Stores/Authorization.elm b/client/Stores/Authorization.elm index 31b3f78..65ce0e1 100644 --- a/client/Stores/Authorization.elm +++ b/client/Stores/Authorization.elm @@ -116,6 +116,12 @@ encoder authorization = , ( "admins", Encode.list (authorization.admins |> List.map encodeAttribute) ) ] +encoderReadAdmin : Authorization -> Encode.Value +encoderReadAdmin authorization = + Encode.object + [ ( "readers", Encode.list (authorization.readers |> List.map encodeAttribute) ) + , ( "admins", Encode.list (authorization.admins |> List.map encodeAttribute) ) + ] encodeAttribute : AuthorizationAttribute -> Encode.Value encodeAttribute attr = diff --git a/client/Stores/Query.elm b/client/Stores/Query.elm new file mode 100644 index 0000000..2dd9fa7 --- /dev/null +++ b/client/Stores/Query.elm @@ -0,0 +1,19 @@ +module Stores.Query exposing (..) +import Json.Decode exposing (Decoder,string) +import Json.Decode.Pipeline exposing (decode, required) + + +type alias Query = + { + id : String, + sql : String, + status: String + } + + +queryDecoder : Decoder Query +queryDecoder = + decode Query + |> required "id" string + |> required "sql" string + |> required "status" string diff --git a/client/Update.elm b/client/Update.elm index 9827e06..19b5722 100644 --- a/client/Update.elm +++ b/client/Update.elm @@ -87,6 +87,9 @@ isInactivePageMsg message route = EventTypeCloneRoute _ -> False + QueryCreateRoute -> + False + _ -> True @@ -204,7 +207,7 @@ updateComponents message model = EventTypeDetailsMsg subMsg -> let ( newModel, subCmd, newRoute ) = - PageEventTypeDetails.update subMsg model.eventTypeDetailsPage + PageEventTypeDetails.update model.userStore.user.settings subMsg model.eventTypeDetailsPage in ( { model | eventTypeDetailsPage = newModel, newRoute = newRoute }, Cmd.map EventTypeDetailsMsg subCmd ) @@ -283,6 +286,9 @@ interComponentMessaging message ( model, cmd ) = EventTypeDetailsRoute params query -> [ EventTypeDetailsMsg EventTypeDetailsPageMessages.Reload ] + QueryCreateRoute -> + [ EventTypeCreateMsg Pages.EventTypeCreate.Messages.Reset ] + _ -> [] in @@ -300,7 +306,7 @@ interComponentMessaging message ( model, cmd ) = SubscriptionCloneRoute params -> [ SubscriptionCreateMsg (Pages.SubscriptionCreate.Messages.Reset) ] - SubscriptionDetailsRoute params query-> + SubscriptionDetailsRoute params query -> [ SubscriptionDetailsMsg SubscriptionDetailsPageMessages.Refresh ] _ -> @@ -461,6 +467,12 @@ interComponentMessaging message ( model, cmd ) = SubscriptionCloneRoute param -> send [ SubscriptionCreateMsg (Pages.SubscriptionCreate.Messages.OnRouteChange (Pages.SubscriptionCreate.Models.Clone param.id)) ] + QueryCreateRoute -> + send + [ EventTypeCreateMsg + (Pages.EventTypeCreate.Messages.OnRouteChange Pages.EventTypeCreate.Models.CreateQuery) + ] + _ -> pass diff --git a/client/User/Commands.elm b/client/User/Commands.elm index c9311e6..066c78b 100644 --- a/client/User/Commands.elm +++ b/client/User/Commands.elm @@ -38,7 +38,8 @@ settingsDecoder = |> optional "supportUrl" string emptyString |> optional "forbidDeleteUrl" string emptyString |> optional "allowDeleteEvenType" bool False - + |> optional "showNakadiSql" bool False + |> optional "queryMonitoringUrl" string emptyString {-| Redirect browser to logout This function never actually returns diff --git a/client/User/Models.elm b/client/User/Models.elm index e0b518a..54aa44f 100644 --- a/client/User/Models.elm +++ b/client/User/Models.elm @@ -53,6 +53,8 @@ type alias Settings = , supportUrl : String , forbidDeleteUrl : String , allowDeleteEvenType : Bool + , showNakadiSql : Bool + , queryMonitoringUrl : String } @@ -69,4 +71,6 @@ initialSettings = , supportUrl = emptyString , forbidDeleteUrl = emptyString , allowDeleteEvenType = False + , showNakadiSql = False + , queryMonitoringUrl = emptyString } diff --git a/client/assets/styles.css b/client/assets/styles.css index f4efca7..cfe9d35 100644 --- a/client/assets/styles.css +++ b/client/assets/styles.css @@ -88,6 +88,11 @@ body { height: 100px; } +.show-sql-feature.dropdown-menu:hover .dropdown-menu__popup { + transition: height .1s ease-out; + height: 150px; +} + .dropdown-menu__item { display: block; height: 50px; @@ -102,7 +107,7 @@ body { } .main-content { - height: calc(100vh - 70px); + height: calc(100vh - 140px); } .page { @@ -125,6 +130,10 @@ body { line-height: 2rem; } +.user-menu.dropdown-menu:hover .dropdown-menu__popup { + height: 100px; +} + .user-menu__popup { margin-top: 10px; margin-left: -170px; @@ -279,11 +288,6 @@ body { text-transform: none; } -.schema-box { - height: 500px; - overflow: auto; -} - .tabs__tab__btn { margin-right: 0; margin-bottom: 0; @@ -383,45 +387,70 @@ body { margin: 0 20px 0 5px; } -.schema-tab__label { +.schema-tab__label, +.query-tab__label { font-size: smaller; color: gray; } -.schema-tab__value { +.schema-tab__value, +.query-tab__value { margin: 0 20px 0 5px; } .event-type-details__info-form { - height: calc(100vh - 165px); + height: calc(100vh - 280px); max-width: 280px; overflow: auto; } .schema-box { width: calc(100vw - 490px); - height: calc(100vh - 280px); + height: calc(100vh - 400px); box-shadow: 0 0 10px rgba(0, 0, 0, 0.2) inset; - margin-bottom: -20px; + margin-bottom: -10px; +} + +.sql-view { + width: calc(100vw - 490px); + height: calc(100vh - 400px); + margin-bottom: -10px; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.2) inset; + background-color: #F9F9F9; + padding: 10px; +} + +.ace-edit { + height: 500px; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.2) inset; + background-color: #F9F9F9; + padding: 10px; +} + +.ace-edit > .elm-ace, +.sql-view > .elm-ace { + width: 100%; + height: 100%; + font-size: 18px; } .partitions-tab__list { - height: calc(100vh - 350px); + height: calc(100vh - 400px); overflow: auto; } .publisher-tab__list { - height: calc(100vh - 350px); + height: calc(100vh - 400px); overflow: auto; } .consumer-tab__list { - height: calc((100vh - 300px) / 2); + height: calc((100vh - 400px) / 2); overflow: auto; } .auth-tab { - min-height: calc(100vh - 200px) + min-height: calc(100vh - 400px) } .auth-tab__content { diff --git a/client/index.js b/client/index.js index cc6d7c9..f69f39a 100644 --- a/client/index.js +++ b/client/index.js @@ -1,9 +1,12 @@ require('./assets/styles.css'); require('./assets/fontawesome-all.js'); + +loadScript('https://cdnjs.cloudflare.com/ajax/libs/ace/1.2.6/ace.js'); +loadScript('https://cdnjs.cloudflare.com/ajax/libs/ace/1.2.6/ext-language_tools.js'); + const Elm = require('./Main'); const app = Elm.Main.fullscreen(); - app.ports.downloadAs.subscribe(function downloadAs([format, filename, data]) { const blob = new Blob([data], {type: format}); if (window.navigator.msSaveOrOpenBlob) { @@ -23,3 +26,11 @@ app.ports.downloadAs.subscribe(function downloadAs([format, filename, data]) { app.ports.title.subscribe(function(title) { document.title = title; }); + +function loadScript(src) { + const head= document.getElementsByTagName('head')[0]; + const script= document.createElement('script'); + script.type= 'text/javascript'; + script.src= src; + head.appendChild(script); +} diff --git a/docker-compose.yml b/docker-compose.yml index bd2ec2e..03d9a90 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ version: '2' services: nakadi-ui: - image: nakadi/nakadi-ui:latest + build: . ports: - "3000:3000" depends_on: diff --git a/server/App.js b/server/App.js index aa76998..4f1bbd1 100644 --- a/server/App.js +++ b/server/App.js @@ -11,6 +11,7 @@ const bodyParser = require('body-parser'); const logger = require('./logger'); const auth = require('./auth'); const nakadiApi = require('./nakadiApi'); +const nakadiSqlApi = require('./nakadiSqlApi'); const logsApi = require('./logsApi'); const validationApi = require('./validationApi'); const staticFiles = require('./staticFiles'); @@ -35,6 +36,7 @@ module.exports = function App(config) { .use(analytics(config.analytics, logger)) .use('/api/logs', authentication, logsApi(config.logsApi)) .use('/api/nakadi', authentication, nakadiApi(config)) + .use('/api/nakadi-sql', authentication, nakadiSqlApi(config)) .use('/api/validation', authentication,validationApi(config)) .use(staticFiles(config.productionMode)) .use(logger.errorHandler); diff --git a/server/config.js b/server/config.js index cfffaad..361ce9b 100644 --- a/server/config.js +++ b/server/config.js @@ -13,7 +13,7 @@ exports = module.exports = function createConfiguration(env) { port: env.HTTP_PORT || 3000, baseUrl: required('BASE_URL', env), nakadiApiUrl: required('NAKADI_API_URL', env), - + nakadiApiSqlUrl: optional('NAKADI_SQL_API_URL', env, ''), serverOptions: envToBool(env.HTTPS_ENABLE) ? { key: fs.readFileSync(required('HTTPS_PRIVATE_KEY_FILE', env), 'utf8'), cert: fs.readFileSync(required('HTTPS_PUBLIC_KEY_FILE', env), 'utf8') @@ -36,7 +36,9 @@ exports = module.exports = function createConfiguration(env) { docsUrl: optional('DOCS_URL', env, ''), supportUrl: optional('SUPPORT_URL', env, ''), allowDeleteEvenType: envToBool(env.ALLOW_DELETE_EVENT_TYPE), - forbidDeleteUrl: optional('FORBID_DELETE_URL', env, '') + forbidDeleteUrl: optional('FORBID_DELETE_URL', env, ''), + showNakadiSql: envToBool(env.SHOW_NAKADI_SQL), + queryMonitoringUrl: optional('QUERY_MONITORING_URL', env, '') }, authorize: { diff --git a/server/nakadiSqlApi.js b/server/nakadiSqlApi.js new file mode 100644 index 0000000..dd84582 --- /dev/null +++ b/server/nakadiSqlApi.js @@ -0,0 +1,61 @@ +/** + * @module + * Proxy all request to /api/nakadi-sql to real Nakadi SQL server endpoint + * adding authorization token + * + * @param {object} config + * @param {object} [config.authorize] authorization parameters + * @param {object} config.nakadiApiSqlUrl Nakadi API URL + * + * @returns {function[]} list of expressjs middleware functions + */ + +const proxy = require('express-http-proxy'); +const logger = require('./logger'); + +module.exports = function NakadiSqlProxyRoute(config) { + + if (!config.nakadiApiSqlUrl){ + return [] + } + + return [ + authorization(config.authorize), + proxy(config.nakadiApiSqlUrl, { + proxyReqOptDecorator: function(proxyReq, origReq) { + if (!origReq.authorizationToken) { + origReq.log('warn', 'No nakadi authorizationToken(server to server) in the request object.'); + return; + } + + proxyReq.headers['Authorization'] = 'Bearer ' + origReq.authorizationToken; + return proxyReq; + } + }) + ] +}; + +/** + * Select the authorization strategy + * @param {object} conf + * @param {string} [conf.strategy] Node module name, should export middleware function + * @param {object} [conf.options] Options object for module + * @returns {function(req, res, next)} + */ +function authorization(conf) { + return (conf && conf.strategy) ? + require(conf.strategy)(logger, conf.options) : + defaultAuthorization +} + +/** + * Default authorization just uses user accessToken as a Nakadi access token + * + * @param {Request} req + * @param {Response} res + * @param {function} next + */ +function defaultAuthorization(req, res, next) { + req.authorizationToken = req.user.accessToken; + next(); +} diff --git a/tests/mocks/data/appConf.json b/tests/mocks/data/appConf.json index 72040ab..8ed5f78 100644 --- a/tests/mocks/data/appConf.json +++ b/tests/mocks/data/appConf.json @@ -3,6 +3,7 @@ "port": "3000", "baseUrl": "http://localhost:3000", "nakadiApiUrl": "http://localhost:5341", + "nakadiApiSqlUrl": "http://localhost:6341", "serverOptions": {}, "auth": { "strategy": "../tests/mocks/testPassportStrategy", @@ -20,8 +21,10 @@ "monitoringUrl": "https://zmon.example.com/grafana/dashboard/db/nakadi-staging", "sloMonitoringUrl": "https://zmon.example.com/grafana/dashboard/db/nakadi-slos", "eventTypeMonitoringUrl": "https://zmon.example.com/grafana/dashboard/db/nakadi-et/?var-stack=nakadi-staging&var-et={et}", + "queryMonitoringUrl": "https://zmon.example.com/grafana/dashboard/db/nakadi-sql-query/?var-stack=live&var-queryId={query}", "subscriptionMonitoringUrl": "https://zmon.example.com/grafana/dashboard/db/nakadi-subscription/?var-stack=nakadi-staging&var-id={id}", - "allowDeleteEvenType": true + "allowDeleteEvenType": true, + "showNakadiSql": true }, "logsApi": { "scalyrUrl": "http://localhost:5342", diff --git a/tests/mocks/data/sqlquery.json b/tests/mocks/data/sqlquery.json new file mode 100644 index 0000000..63510c6 --- /dev/null +++ b/tests/mocks/data/sqlquery.json @@ -0,0 +1,26 @@ +{ + "authorization": { + "admins": [ + { + "data_type": "user", + "value": "lmontrieux" + } + ], + "readers": [ + { + "data_type": "user", + "value": "lmontrieux" + } + ] + }, + "created": "2018-10-31T09:41:21.274", + "id": "lm.sql.test1.output", + "output_event_type": { + "category": "data", + "name": "lm.sql.test1.output", + "owning_application": "stups_nakadi" + }, + "sql": "select * from \"lm.sql.test1\" t1", + "status": "active", + "updated": "2018-10-31T09:41:21.274" +} diff --git a/tests/mocks/testNakadiSql.js b/tests/mocks/testNakadiSql.js new file mode 100644 index 0000000..3776572 --- /dev/null +++ b/tests/mocks/testNakadiSql.js @@ -0,0 +1,22 @@ +const express = require('express'); +const http = require('http'); +const app = express(); +const query = require('./data/sqlquery'); + +app.get('/queries/ad.nakadi.sql.demo.et', (req, res, done) =>{ + res.status(404).json({}) +}); + +app.get('/queries/:id', (req, res, done) =>{ + res.json(query) +}); + +app.delete('/queries/:id', (req, res, done) =>{ + res.json({}) +}); + +app.post('/queries', (req, res, done) => { + res.json({}) +}); + +module.exports = http.createServer(app); diff --git a/tests/mocks/testServer.js b/tests/mocks/testServer.js index e7f46ac..299c113 100644 --- a/tests/mocks/testServer.js +++ b/tests/mocks/testServer.js @@ -1,11 +1,13 @@ const App = require('../../server/App'); const IPM = require('./testIPM'); const nakadi = require('./testNakadi'); +const nakadiSql = require('./testNakadiSql'); const conf = require('./data/appConf.json'); const app = App(conf); app.listen(3000); nakadi.listen(5341); +nakadiSql.listen(6341); IPM.listen(5000); diff --git a/tests/unit/config.spec.js b/tests/unit/config.spec.js index 8496f48..f90328e 100644 --- a/tests/unit/config.spec.js +++ b/tests/unit/config.spec.js @@ -43,6 +43,7 @@ describe('Config', function() { port: '3000', baseUrl: 'https://localhost:3000', nakadiApiUrl: 'https://nakadi-staging.example.com', + nakadiApiSqlUrl:'', serverOptions: { key: 'test fake private certificate', cert: 'test fake public certificate' @@ -67,7 +68,9 @@ describe('Config', function() { docsUrl: "https://nakadi-faq.docs.example.com/", supportUrl: "https://hipchat.example.com/chat/room/12345", forbidDeleteUrl: "https://nakadi-faq.docs.example.com/#how-to-delete-et", - allowDeleteEvenType: true + allowDeleteEvenType: true, + showNakadiSql: false, + queryMonitoringUrl: '' }, authorize: { strategy: 'myGoogleAdapter',