diff --git a/ui/index.html b/ui/index.html index a75d48ce72..81c9ff2258 100644 --- a/ui/index.html +++ b/ui/index.html @@ -13,14 +13,8 @@ var app = Elm.Main.init({ node: document.getElementById('elm') }); - var ws = new WebSocket("wss://echo.websocket.org"); - // var ws = new WebSocket('wss://localhost/ws/channels/1dec73d1-2d74-456b-b1f3-0d4908e600cf/messages?authorization=9a6c7929-0213-40a9-9154-b8e43e01e103'); - ws.onmessage = function(message) - { - console.log(message); - app.ports.websocketIn.send(JSON.stringify({data:message.data,timestamp:message.timeStamp})); - }; - app.ports.websocketOut.subscribe(function(msg) { ws.send(msg); }); + var MF = {} + diff --git a/ui/src/Connection.elm b/ui/src/Connection.elm index 8ed3c0acac..1e1d9d282a 100644 --- a/ui/src/Connection.elm +++ b/ui/src/Connection.elm @@ -4,7 +4,7 @@ -- SPDX-License-Identifier: Apache-2.0 -module Connection exposing (Model, Msg(..), initial, update, view) +port module Connection exposing (Model, Msg(..), initial, update, view) import Bootstrap.Button as Button import Bootstrap.Card as Card @@ -13,6 +13,7 @@ import Bootstrap.Form as Form import Bootstrap.Form.Checkbox as Checkbox import Bootstrap.Form.Input as Input import Bootstrap.Grid as Grid +import Bootstrap.Grid.Col as Col import Bootstrap.Table as Table import Bootstrap.Text as Text import Bootstrap.Utilities.Spacing as Spacing @@ -25,7 +26,10 @@ import Html.Attributes exposing (..) import Html.Events exposing (onClick) import Http import HttpMF exposing (paths) +import Json.Decode as D +import Json.Encode as E import List.Extra +import Ports exposing (..) import Thing import Url.Builder as B @@ -36,6 +40,7 @@ type alias Model = , channels : Channel.Model , checkedThingsIds : List String , checkedChannelsIds : List String + , websocketIn : List String } @@ -46,6 +51,7 @@ initial = , channels = Channel.initial , checkedThingsIds = [] , checkedChannelsIds = [] + , websocketIn = [] } @@ -55,28 +61,38 @@ type Msg | ThingMsg Thing.Msg | ChannelMsg Channel.Msg | GotResponse (Result Http.Error String) - | CheckThing String + | CheckThing ( String, String ) | CheckChannel String +resetChecked : Model -> Model +resetChecked model = + { model | checkedThingsIds = [], checkedChannelsIds = [] } + + +isEmptyChecked : Model -> Bool +isEmptyChecked model = + List.isEmpty model.checkedThingsIds || List.isEmpty model.checkedChannelsIds + + update : Msg -> Model -> String -> ( Model, Cmd Msg ) update msg model token = case msg of Connect -> - if List.isEmpty model.checkedThingsIds || List.isEmpty model.checkedChannelsIds then + if isEmptyChecked model then ( model, Cmd.none ) else - ( { model | checkedThingsIds = [], checkedChannelsIds = [] } + ( resetChecked model , Cmd.batch (connect model.checkedThingsIds model.checkedChannelsIds "PUT" token) ) Disconnect -> - if List.isEmpty model.checkedThingsIds || List.isEmpty model.checkedChannelsIds then + if isEmptyChecked model then ( model, Cmd.none ) else - ( { model | checkedThingsIds = [], checkedChannelsIds = [] } + ( resetChecked model , Cmd.batch (connect model.checkedThingsIds model.checkedChannelsIds "DELETE" token) ) @@ -102,8 +118,12 @@ update msg model token = in ( { model | channels = updatedChannel }, Cmd.map ChannelMsg channelCmd ) - CheckThing id -> - ( { model | checkedThingsIds = Helpers.checkEntity id model.checkedThingsIds }, Cmd.none ) + CheckThing thing -> + ( { model + | checkedThingsIds = Helpers.checkEntity (Tuple.first thing) model.checkedThingsIds + } + , Cmd.none + ) CheckChannel id -> ( { model | checkedChannelsIds = Helpers.checkEntity id model.checkedChannelsIds }, Cmd.none ) @@ -129,7 +149,7 @@ view model = ) ] , Grid.row [] - [ Grid.col [] + [ Grid.col [ Col.attrs [ align "left" ] ] [ Form.form [] [ Button.button [ Button.success, Button.attrs [ Spacing.ml1 ], Button.onClick Connect ] [ text "Connect" ] , Button.button [ Button.danger, Button.attrs [ Spacing.ml1 ], Button.onClick Disconnect ] [ text "Disconnect" ] @@ -147,7 +167,7 @@ genThingRows checkedThingsIds things = Table.tr [] [ Table.td [] [ text (" " ++ Helpers.parseString thing.name) ] , Table.td [] [ text thing.id ] - , Table.td [] [ input [ type_ "checkbox", onClick (CheckThing thing.id), checked (Helpers.isChecked thing.id checkedThingsIds) ] [] ] + , Table.td [] [ input [ type_ "checkbox", onClick (CheckThing ( thing.id, thing.key )), checked (Helpers.isChecked thing.id checkedThingsIds) ] [] ] ] ) things diff --git a/ui/src/Helpers.elm b/ui/src/Helpers.elm index 016488f458..d74e092932 100644 --- a/ui/src/Helpers.elm +++ b/ui/src/Helpers.elm @@ -4,7 +4,7 @@ -- SPDX-License-Identifier: Apache-2.0 -module Helpers exposing (appendIf, buildQueryParamList, checkEntity, disableNext, faIcons, fontAwesome, genCardConfig, genPagination, isChecked, offsetToPage, pageToOffset, parseString, response, validateInt, validateOffset) +module Helpers exposing (appendIf, buildQueryParamList, checkEntity, disableNext, faIcons, fontAwesome, genCardConfig, genOrderedList, genPagination, isChecked, offsetToPage, pageToOffset, parseString, resetList, response, validateInt, validateOffset) import Bootstrap.Button as Button import Bootstrap.Card as Card @@ -94,6 +94,11 @@ validateOffset offset total limit = offset +disableNext : Int -> Int -> Bool +disableNext currPage total = + currPage == Basics.ceiling (Basics.toFloat total / 10) + + genPagination : Int -> Int -> (Int -> msg) -> Html msg genPagination total currPage msg = let @@ -159,6 +164,8 @@ faIcons = , messages = "far fa-paper-plane" , version = "fa fa-code-branch" , websocket = "fas fa-arrows-alt-v" + , send = "fas fa-arrow-up" + , receive = "fas fa-arrow-down" } @@ -193,11 +200,6 @@ appendIf flag list value = list -disableNext : Int -> Int -> Bool -disableNext currPage total = - currPage == Basics.ceiling (Basics.toFloat total / 10) - - genCardConfig : String -> String -> List (Table.Row msg) -> Html msg genCardConfig faClass title rows = Card.config @@ -217,3 +219,21 @@ genCardConfig faClass title rows = ) ] |> Card.view + + +genOrderedList : List String -> Html msg +genOrderedList strings = + Html.ol [] + (strings + |> List.map + (\string -> Html.li [] [ Html.text string ]) + ) + + +resetList : List String -> Int -> List String +resetList strings limit = + if List.length strings >= limit then + [] + + else + strings diff --git a/ui/src/Main.elm b/ui/src/Main.elm index 4e0b416527..dde4add828 100644 --- a/ui/src/Main.elm +++ b/ui/src/Main.elm @@ -280,6 +280,7 @@ subscriptions : Model -> Sub Msg subscriptions model = Sub.batch [ Sub.map UserMsg (User.subscriptions model.user) + , Sub.map MessageMsg (Message.subscriptions model.message) ] diff --git a/ui/src/Message.elm b/ui/src/Message.elm index b45e8b4b7e..945dadbcc2 100644 --- a/ui/src/Message.elm +++ b/ui/src/Message.elm @@ -4,7 +4,7 @@ -- SPDX-License-Identifier: Apache-2.0 -module Message exposing (Model, Msg(..), initial, update, view) +port module Message exposing (Model, Msg(..), initial, subscriptions, update, view) import Bootstrap.Button as Button import Bootstrap.Card as Card @@ -14,9 +14,11 @@ import Bootstrap.Form.Checkbox as Checkbox import Bootstrap.Form.Input as Input import Bootstrap.Form.Radio as Radio import Bootstrap.Grid as Grid +import Bootstrap.Grid.Col as Col import Bootstrap.Table as Table import Bootstrap.Utilities.Spacing as Spacing import Channel +import Debug exposing (log) import Error import Helpers exposing (faIcons, fontAwesome) import Html exposing (..) @@ -24,11 +26,20 @@ import Html.Attributes exposing (..) import Html.Events exposing (onClick) import Http import HttpMF exposing (paths) +import Json.Decode as D +import Json.Encode as E import List.Extra +import Ports exposing (..) import Thing import Url.Builder as B +type alias CheckedChannel = + { id : String + , ws : Int + } + + type alias Model = { message : String , thingkey : String @@ -37,6 +48,8 @@ type alias Model = , channels : Channel.Model , thingid : String , checkedChannelsIds : List String + , checkedChannelsIdsWs : List String + , websocketIn : List String } @@ -49,19 +62,30 @@ initial = , channels = Channel.initial , thingid = "" , checkedChannelsIds = [] + , checkedChannelsIdsWs = [] + , websocketIn = [] } type Msg = SubmitMessage String | SendMessage + | Listen + | Stop + | WebsocketIn String + | RetrievedWebsockets E.Value | SentMessage (Result Http.Error String) | ThingMsg Thing.Msg | ChannelMsg Channel.Msg - | SelectedThing String String Channel.Msg + | SelectThing String String Channel.Msg | CheckChannel String +resetSent : Model -> Model +resetSent model = + { model | message = "", thingkey = "", response = "", thingid = "" } + + update : Msg -> Model -> String -> ( Model, Cmd Msg ) update msg model token = case msg of @@ -69,7 +93,7 @@ update msg model token = ( { model | message = message }, Cmd.none ) SendMessage -> - ( { model | message = "", thingkey = "", response = "", thingid = "" } + ( model , Cmd.batch (List.map (\channelid -> send channelid model.thingkey model.message) @@ -77,6 +101,20 @@ update msg model token = ) ) + Listen -> + if List.isEmpty model.checkedChannelsIds then + ( model, Cmd.none ) + + else + ( model, Cmd.batch <| ws connectWebsocket model ) + + Stop -> + if List.isEmpty model.checkedChannelsIds then + ( model, Cmd.none ) + + else + ( model, Cmd.batch <| ws disconnectWebsocket model ) + SentMessage result -> case result of Ok statusCode -> @@ -85,26 +123,64 @@ update msg model token = Err error -> ( { model | response = Error.handle error }, Cmd.none ) + WebsocketIn data -> + ( { model | websocketIn = data :: model.websocketIn }, Cmd.none ) + + RetrievedWebsockets wssList -> + case D.decodeValue websocketsQueryDecoder wssList of + Ok wssL -> + if List.isEmpty wssL then + ( model, Cmd.none ) + + else + let + l = + List.map + (\wss -> + channelIdFromUrl wss.url + ) + wssL + in + ( { model | checkedChannelsIdsWs = l }, Cmd.none ) + + Err _ -> + ( model, Cmd.none ) + ThingMsg subMsg -> updateThing model subMsg token ChannelMsg subMsg -> updateChannel model subMsg token - SelectedThing thingid thingkey channelMsg -> - updateChannel { model | thingid = thingid, thingkey = thingkey, checkedChannelsIds = [] } (Channel.RetrieveChannelsForThing thingid) token + SelectThing thingid thingkey channelMsg -> + updateChannel { model | thingid = thingid, thingkey = thingkey, checkedChannelsIds = [], checkedChannelsIdsWs = [] } (Channel.RetrieveChannelsForThing thingid) token CheckChannel id -> ( { model | checkedChannelsIds = Helpers.checkEntity id model.checkedChannelsIds }, Cmd.none ) +retrieveWebsocketsForThing : List Channel.Channel -> String -> Cmd Msg +retrieveWebsocketsForThing channels thingkey = + let + wssList = + List.map + (\channel -> + Websocket channel.id thingkey "" + ) + channels + in + queryWebsockets (websocketsEncoder wssList) + + updateThing : Model -> Thing.Msg -> String -> ( Model, Cmd Msg ) updateThing model msg token = let ( updatedThing, thingCmd ) = Thing.update msg model.things token in - ( { model | things = updatedThing }, Cmd.map ThingMsg thingCmd ) + ( { model | things = updatedThing } + , Cmd.map ThingMsg thingCmd + ) updateChannel : Model -> Channel.Msg -> String -> ( Model, Cmd Msg ) @@ -112,8 +188,25 @@ updateChannel model msg token = let ( updatedChannel, channelCmd ) = Channel.update msg model.channels token + + checkedChannels = + updatedChannel.channels.list in - ( { model | channels = updatedChannel }, Cmd.map ChannelMsg channelCmd ) + ( { model | channels = updatedChannel } + , Cmd.map ChannelMsg channelCmd + ) + + + +-- SUBSCRIPTIONS + + +subscriptions : Model -> Sub Msg +subscriptions model = + Sub.batch + [ websocketIn WebsocketIn + , retrieveWebsockets RetrievedWebsockets + ] @@ -126,60 +219,106 @@ view model = [ Grid.row [] [ Grid.col [] (Helpers.appendIf (model.things.things.total > model.things.limit) - [ Helpers.genCardConfig faIcons.things "Things" (genThingRows model.things.things.list) ] + [ Helpers.genCardConfig faIcons.things "Things" (genThingRows model) ] (Html.map ThingMsg (Helpers.genPagination model.things.things.total (Helpers.offsetToPage model.things.offset model.things.limit) Thing.SubmitPage)) ) , Grid.col [] (Helpers.appendIf (model.channels.channels.total > model.channels.limit) - [ Helpers.genCardConfig faIcons.channels "Channels" (genChannelRows model.checkedChannelsIds model.channels.channels.list) ] + [ Helpers.genCardConfig faIcons.channels "Channels" (genChannelRows model) ] (Html.map ChannelMsg (Helpers.genPagination model.channels.channels.total (Helpers.offsetToPage model.channels.offset model.channels.limit) Channel.SubmitPage)) ) ] , Grid.row [] [ Grid.col [] [ Card.config [] - |> Card.headerH3 [] [ div [ class "table_header" ] [ i [ style "margin-right" "15px", class faIcons.messages ] [], text "Message" ] ] - |> Card.block [] - [ Block.custom - (Form.form [] + |> Card.headerH3 [] + [ Grid.row [] + [ Grid.col [] + [ div [ class "table_header" ] + [ i [ style "margin-right" "15px", class faIcons.send ] [] + , text "HTTP" + ] + ] + , Grid.col [ Col.attrs [ align "right" ] ] [ Form.group [] - [ Input.text [ Input.id "message", Input.onInput SubmitMessage ] + [ Button.button [ Button.secondary, Button.attrs [ Spacing.ml1 ], Button.onClick SendMessage ] [ text "Send" ] ] - , Button.button [ Button.secondary, Button.attrs [ Spacing.ml1 ], Button.onClick SendMessage ] [ text "Send" ] + ] + ] + ] + |> Card.block [] + [ Block.custom + (Grid.row [] + [ Grid.col [] [ Input.text [ Input.id "message", Input.onInput SubmitMessage ] ] ] ) ] |> Card.view ] + , Grid.col [] + [ Card.config [] + |> Card.headerH3 [] + [ Grid.row [] + [ Grid.col [] + [ div [ class "table_header" ] + [ i [ style "margin-right" "15px", class faIcons.receive ] [] + , text "WS" + ] + ] + , Grid.col [ Col.attrs [ align "right" ] ] + [ Form.form [] + [ Form.group [] + [ Button.button [ Button.secondary, Button.attrs [ Spacing.ml1 ], Button.onClick Listen ] [ text "Listen" ] + , Button.button [ Button.secondary, Button.attrs [ Spacing.ml1 ], Button.onClick Stop ] [ text "Stop" ] + ] + ] + ] + ] + ] + |> Card.block [] + [ Block.custom + (Helpers.genOrderedList model.websocketIn) + ] + |> Card.view + ] ] , Helpers.response model.response ] -genThingRows : List Thing.Thing -> List (Table.Row Msg) -genThingRows things = +genThingRows : Model -> List (Table.Row Msg) +genThingRows model = List.map (\thing -> Table.tr [] [ Table.td [] [ label [] [ text (Helpers.parseString thing.name) ] ] , Table.td [] [ text thing.id ] - , Table.td [] [ input [ type_ "radio", onClick (SelectedThing thing.id thing.key (Channel.RetrieveChannelsForThing thing.id)), name "things" ] [] ] + , Table.td [] [ input [ type_ "radio", onClick (SelectThing thing.id thing.key (Channel.RetrieveChannelsForThing thing.id)), name "things" ] [] ] ] ) - things + model.things.things.list -genChannelRows : List String -> List Channel.Channel -> List (Table.Row Msg) -genChannelRows checkedChannelsIds channels = +genChannelRows : Model -> List (Table.Row Msg) +genChannelRows model = List.map (\channel -> Table.tr [] [ Table.td [] [ text (" " ++ Helpers.parseString channel.name) ] - , Table.td [] [ text channel.id ] - , Table.td [] [ input [ type_ "checkbox", onClick (CheckChannel channel.id), checked (Helpers.isChecked channel.id checkedChannelsIds) ] [] ] + , Table.td [] [ text (channel.id ++ isInList channel.id model.checkedChannelsIdsWs) ] + , Table.td [] [ input [ type_ "checkbox", onClick (CheckChannel channel.id), checked (Helpers.isChecked channel.id model.checkedChannelsIds) ] [] ] ] ) - channels + model.channels.channels.list + + +isInList : String -> List String -> String +isInList id idList = + if List.member id idList then + " *WS*" + + else + "" @@ -194,3 +333,12 @@ send channelid thingkey message = thingkey (Http.stringBody "application/json" message) SentMessage + + +ws : (E.Value -> Cmd Msg) -> Model -> List (Cmd Msg) +ws command model = + List.map + (\channelid -> + command <| websocketEncoder (Websocket channelid model.thingkey "") + ) + model.checkedChannelsIds diff --git a/ui/src/Ports.elm b/ui/src/Ports.elm index 400cb568f2..8b7e92ac25 100644 --- a/ui/src/Ports.elm +++ b/ui/src/Ports.elm @@ -4,17 +4,87 @@ -- SPDX-License-Identifier: Apache-2.0 -port module Ports exposing (websocketIn, websocketOut) +port module Ports exposing (Websocket, WebsocketQuery, channelIdFromUrl, connectWebsocket, disconnectWebsocket, queryWebsocket, queryWebsockets, retrieveWebsocket, retrieveWebsockets, websocketEncoder, websocketIn, websocketOut, websocketQueryDecoder, websocketsEncoder, websocketsQueryDecoder) --- PORTS --- JavaScript usage: app.ports.websocketIn.send(response); +import Json.Decode as D +import Json.Encode as E + + +port connectWebsocket : E.Value -> Cmd msg + + +port disconnectWebsocket : E.Value -> Cmd msg port websocketIn : (String -> msg) -> Sub msg +port websocketOut : E.Value -> Cmd msg + + +port queryWebsocket : E.Value -> Cmd msg + + +port retrieveWebsocket : (E.Value -> msg) -> Sub msg + + +port queryWebsockets : E.Value -> Cmd msg + + +port retrieveWebsockets : (E.Value -> msg) -> Sub msg + + + +-- JSON + + +type alias WebsocketQuery = + { url : String + , readyState : Int + } + + +type alias Websocket = + { channelid : String + , thingkey : String + , message : String + } + + +websocketQueryDecoder : D.Decoder WebsocketQuery +websocketQueryDecoder = + D.map2 WebsocketQuery + (D.field "url" D.string) + (D.field "readyState" D.int) + + +websocketsQueryDecoder : D.Decoder (List WebsocketQuery) +websocketsQueryDecoder = + D.list websocketQueryDecoder + + +websocketEncoder : Websocket -> E.Value +websocketEncoder ws = + E.object + [ ( "channelid", E.string ws.channelid ) + , ( "thingkey", E.string ws.thingkey ) + , ( "message", E.string ws.message ) + ] + + +websocketsEncoder : List Websocket -> E.Value +websocketsEncoder wss = + E.list websocketEncoder wss --- JavaScript usage: app.ports.websocketOut.subscribe(handler); +channelIdFromUrl : String -> String +channelIdFromUrl url = + let + start = + String.length "wss://localhost/ws/channels/" -port websocketOut : String -> Cmd msg + end = + String.length "wss://localhost/ws/channels/" + + String.length "0522c54b-5b00-4aab-a2b0-6e3e54320995" + in + String.slice start end url diff --git a/ui/src/Websocket.js b/ui/src/Websocket.js new file mode 100644 index 0000000000..7f64e04a76 --- /dev/null +++ b/ui/src/Websocket.js @@ -0,0 +1,82 @@ +var wss = new Object(); + +MF.log = function(msg) { + console.log(msg); + app.ports.websocketIn.send(msg); +} + +MF.url = function(data) { + return 'wss://localhost/ws/channels/' + data.channelid + '/messages?authorization=' + data.thingkey +} + +app.ports.connectWebsocket.subscribe(function(data) { + var url = MF.url(data); + if (wss[url]) { + MF.log('Websocket already open. URL: ' + url ); + return; + } + + var ws = new WebSocket(url); + + ws.onopen = function (event) { + MF.log('Websocket opened. URL: ' + url); + wss[url] = ws; + } + + ws.onerror = function (event) { + console.log(event); + } + + ws.onmessage = function(message) { + app.ports.websocketIn.send(JSON.stringify({data: message.data, timestamp: message.timeStamp})); + }; + + ws.onclose = function (event) { + MF.log('Websocket closed. URL: ' + url); + delete wss[ws.url]; + }; +}); + +if (typeof app.ports.websocketOut !== 'undefined') { + app.ports.websocketOut.subscribe(function(data) { + var url = MF.url(data); + if (wss[url]) { + wss[url].send(data.message); + } else { + MF.log('Message not sent. Websocket is not open. URL: ' + url); + } + }) +} + +app.ports.disconnectWebsocket.subscribe(function(data) { + var url = MF.url(data); + if (wss[url]) { + wss[url].close(); + } else { + MF.log('Websocket not disconnected. Websocket is not open. URL: ' + url); + } +}) + +if (typeof app.ports.queryWebsocket !== 'undefined') { + app.ports.queryWebsocket.subscribe(function(data) { + var url = MF.url(data); + if (wss[url]) { + app.ports.retrieveWebsocket.send({url: url, readyState : wss[url].readyState}); + } else { + app.ports.retrieveWebsocket.send({url: '', readyState : -1}) + } + }) +} + +if (typeof app.ports.queryWebsockets !== 'undefined') { + app.ports.queryWebsockets.subscribe(function(data) { + var wssList = [] + data.forEach(function(item, index){ + var url = MF.url(item); + if (wss[url]) { + wssList.push({url: url, readyState : wss[url].readyState}) + } + }) + app.ports.retrieveWebsockets.send(wssList); + }) +}