mirror of
https://github.com/jhillyerd/inbucket.git
synced 2025-12-18 18:17:03 +00:00
ui: Initial Elm UI import
Merged from https://github.com/jhillyerd/inbucket-elm Uses https://github.com/halfzebra/create-elm-app
This commit is contained in:
44
ui/src/Data/Message.elm
Normal file
44
ui/src/Data/Message.elm
Normal file
@@ -0,0 +1,44 @@
|
||||
module Data.Message exposing (..)
|
||||
|
||||
import Json.Decode as Decode exposing (..)
|
||||
import Json.Decode.Pipeline exposing (..)
|
||||
|
||||
|
||||
type alias Message =
|
||||
{ mailbox : String
|
||||
, id : String
|
||||
, from : String
|
||||
, to : List String
|
||||
, subject : String
|
||||
, date : String
|
||||
, size : Int
|
||||
, seen : Bool
|
||||
, body : Body
|
||||
}
|
||||
|
||||
|
||||
type alias Body =
|
||||
{ text : String
|
||||
, html : String
|
||||
}
|
||||
|
||||
|
||||
decoder : Decoder Message
|
||||
decoder =
|
||||
decode Message
|
||||
|> required "mailbox" string
|
||||
|> required "id" string
|
||||
|> optional "from" string ""
|
||||
|> required "to" (list string)
|
||||
|> optional "subject" string ""
|
||||
|> required "date" string
|
||||
|> required "size" int
|
||||
|> required "seen" bool
|
||||
|> required "body" bodyDecoder
|
||||
|
||||
|
||||
bodyDecoder : Decoder Body
|
||||
bodyDecoder =
|
||||
decode Body
|
||||
|> required "text" string
|
||||
|> required "html" string
|
||||
29
ui/src/Data/MessageHeader.elm
Normal file
29
ui/src/Data/MessageHeader.elm
Normal file
@@ -0,0 +1,29 @@
|
||||
module Data.MessageHeader exposing (..)
|
||||
|
||||
import Json.Decode as Decode exposing (..)
|
||||
import Json.Decode.Pipeline exposing (..)
|
||||
|
||||
|
||||
type alias MessageHeader =
|
||||
{ mailbox : String
|
||||
, id : String
|
||||
, from : String
|
||||
, to : List String
|
||||
, subject : String
|
||||
, date : String
|
||||
, size : Int
|
||||
, seen : Bool
|
||||
}
|
||||
|
||||
|
||||
decoder : Decoder MessageHeader
|
||||
decoder =
|
||||
decode MessageHeader
|
||||
|> required "mailbox" string
|
||||
|> required "id" string
|
||||
|> optional "from" string ""
|
||||
|> required "to" (list string)
|
||||
|> optional "subject" string ""
|
||||
|> required "date" string
|
||||
|> required "size" int
|
||||
|> required "seen" bool
|
||||
62
ui/src/Data/Metrics.elm
Normal file
62
ui/src/Data/Metrics.elm
Normal file
@@ -0,0 +1,62 @@
|
||||
module Data.Metrics exposing (..)
|
||||
|
||||
import Json.Decode as Decode exposing (..)
|
||||
import Json.Decode.Pipeline exposing (..)
|
||||
|
||||
|
||||
type alias Metrics =
|
||||
{ sysMem : Int
|
||||
, heapSize : Int
|
||||
, heapUsed : Int
|
||||
, heapObjects : Int
|
||||
, goRoutines : Int
|
||||
, webSockets : Int
|
||||
, smtpConnOpen : Int
|
||||
, smtpConnTotal : Int
|
||||
, smtpConnHist : List Int
|
||||
, smtpReceivedTotal : Int
|
||||
, smtpReceivedHist : List Int
|
||||
, smtpErrorsTotal : Int
|
||||
, smtpErrorsHist : List Int
|
||||
, smtpWarnsTotal : Int
|
||||
, smtpWarnsHist : List Int
|
||||
, retentionDeletesTotal : Int
|
||||
, retentionDeletesHist : List Int
|
||||
, retainedCount : Int
|
||||
, retainedCountHist : List Int
|
||||
, retainedSize : Int
|
||||
, retainedSizeHist : List Int
|
||||
}
|
||||
|
||||
|
||||
decoder : Decoder Metrics
|
||||
decoder =
|
||||
decode Metrics
|
||||
|> requiredAt [ "memstats", "Sys" ] int
|
||||
|> requiredAt [ "memstats", "HeapSys" ] int
|
||||
|> requiredAt [ "memstats", "HeapAlloc" ] int
|
||||
|> requiredAt [ "memstats", "HeapObjects" ] int
|
||||
|> requiredAt [ "goroutines" ] int
|
||||
|> requiredAt [ "http", "WebSocketConnectsCurrent" ] int
|
||||
|> requiredAt [ "smtp", "ConnectsCurrent" ] int
|
||||
|> requiredAt [ "smtp", "ConnectsTotal" ] int
|
||||
|> requiredAt [ "smtp", "ConnectsHist" ] decodeIntList
|
||||
|> requiredAt [ "smtp", "ReceivedTotal" ] int
|
||||
|> requiredAt [ "smtp", "ReceivedHist" ] decodeIntList
|
||||
|> requiredAt [ "smtp", "ErrorsTotal" ] int
|
||||
|> requiredAt [ "smtp", "ErrorsHist" ] decodeIntList
|
||||
|> requiredAt [ "smtp", "WarnsTotal" ] int
|
||||
|> requiredAt [ "smtp", "WarnsHist" ] decodeIntList
|
||||
|> requiredAt [ "retention", "DeletesTotal" ] int
|
||||
|> requiredAt [ "retention", "DeletesHist" ] decodeIntList
|
||||
|> requiredAt [ "retention", "RetainedCurrent" ] int
|
||||
|> requiredAt [ "retention", "RetainedHist" ] decodeIntList
|
||||
|> requiredAt [ "retention", "RetainedSize" ] int
|
||||
|> requiredAt [ "retention", "SizeHist" ] decodeIntList
|
||||
|
||||
|
||||
{-| Decodes Inbuckets hacky comma-separated-int JSON strings.
|
||||
-}
|
||||
decodeIntList : Decoder (List Int)
|
||||
decodeIntList =
|
||||
map (String.split "," >> List.map (String.toInt >> Result.withDefault 0)) string
|
||||
91
ui/src/Data/Session.elm
Normal file
91
ui/src/Data/Session.elm
Normal file
@@ -0,0 +1,91 @@
|
||||
module Data.Session
|
||||
exposing
|
||||
( Session
|
||||
, Persistent
|
||||
, Msg(..)
|
||||
, decoder
|
||||
, decodeValueWithDefault
|
||||
, init
|
||||
, none
|
||||
, update
|
||||
)
|
||||
|
||||
import Json.Decode as Decode exposing (..)
|
||||
import Json.Decode.Pipeline exposing (..)
|
||||
|
||||
|
||||
type alias Session =
|
||||
{ flash : String
|
||||
, routing : Bool
|
||||
, persistent : Persistent
|
||||
}
|
||||
|
||||
|
||||
type alias Persistent =
|
||||
{ recentMailboxes : List String
|
||||
}
|
||||
|
||||
|
||||
type Msg
|
||||
= None
|
||||
| SetFlash String
|
||||
| ClearFlash
|
||||
| DisableRouting
|
||||
| EnableRouting
|
||||
| AddRecent String
|
||||
|
||||
|
||||
init : Persistent -> Session
|
||||
init persistent =
|
||||
Session "" True persistent
|
||||
|
||||
|
||||
update : Msg -> Session -> Session
|
||||
update msg session =
|
||||
case msg of
|
||||
None ->
|
||||
session
|
||||
|
||||
SetFlash flash ->
|
||||
{ session | flash = flash }
|
||||
|
||||
ClearFlash ->
|
||||
{ session | flash = "" }
|
||||
|
||||
DisableRouting ->
|
||||
{ session | routing = False }
|
||||
|
||||
EnableRouting ->
|
||||
{ session | routing = True }
|
||||
|
||||
AddRecent mailbox ->
|
||||
if List.head session.persistent.recentMailboxes == Just mailbox then
|
||||
session
|
||||
else
|
||||
let
|
||||
recent =
|
||||
session.persistent.recentMailboxes
|
||||
|> List.filter ((/=) mailbox)
|
||||
|> List.take 7
|
||||
|> (::) mailbox
|
||||
|
||||
persistent =
|
||||
session.persistent
|
||||
in
|
||||
{ session | persistent = { persistent | recentMailboxes = recent } }
|
||||
|
||||
|
||||
none : Msg
|
||||
none =
|
||||
None
|
||||
|
||||
|
||||
decoder : Decoder Persistent
|
||||
decoder =
|
||||
decode Persistent
|
||||
|> optional "recentMailboxes" (list string) []
|
||||
|
||||
|
||||
decodeValueWithDefault : Value -> Persistent
|
||||
decodeValueWithDefault =
|
||||
Decode.decodeValue decoder >> Result.withDefault { recentMailboxes = [] }
|
||||
39
ui/src/HttpUtil.elm
Normal file
39
ui/src/HttpUtil.elm
Normal file
@@ -0,0 +1,39 @@
|
||||
module HttpUtil exposing (..)
|
||||
|
||||
import Http
|
||||
|
||||
|
||||
delete : String -> Http.Request ()
|
||||
delete url =
|
||||
Http.request
|
||||
{ method = "DELETE"
|
||||
, headers = []
|
||||
, url = url
|
||||
, body = Http.emptyBody
|
||||
, expect = Http.expectStringResponse (\_ -> Ok ())
|
||||
, timeout = Nothing
|
||||
, withCredentials = False
|
||||
}
|
||||
|
||||
|
||||
errorString : Http.Error -> String
|
||||
errorString error =
|
||||
case error of
|
||||
Http.BadUrl str ->
|
||||
"Bad URL: " ++ str
|
||||
|
||||
Http.Timeout ->
|
||||
"HTTP timeout"
|
||||
|
||||
Http.NetworkError ->
|
||||
"HTTP Network error"
|
||||
|
||||
Http.BadStatus res ->
|
||||
"Bad HTTP status: " ++ toString res.status.code
|
||||
|
||||
Http.BadPayload msg res ->
|
||||
"Bad HTTP payload: "
|
||||
++ msg
|
||||
++ " ("
|
||||
++ toString res.status.code
|
||||
++ ")"
|
||||
288
ui/src/Main.elm
Normal file
288
ui/src/Main.elm
Normal file
@@ -0,0 +1,288 @@
|
||||
module Main exposing (..)
|
||||
|
||||
import Data.Session as Session exposing (Session, decoder)
|
||||
import Json.Decode as Decode exposing (Value)
|
||||
import Html exposing (..)
|
||||
import Navigation exposing (Location)
|
||||
import Page.Home as Home
|
||||
import Page.Mailbox as Mailbox
|
||||
import Page.Monitor as Monitor
|
||||
import Page.Status as Status
|
||||
import Ports
|
||||
import Route exposing (Route)
|
||||
import Views.Page as Page exposing (ActivePage(..), frame)
|
||||
|
||||
|
||||
-- MODEL
|
||||
|
||||
|
||||
type Page
|
||||
= Home Home.Model
|
||||
| Mailbox Mailbox.Model
|
||||
| Monitor Monitor.Model
|
||||
| Status Status.Model
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ page : Page
|
||||
, session : Session
|
||||
, mailboxName : String
|
||||
}
|
||||
|
||||
|
||||
init : Value -> Location -> ( Model, Cmd Msg )
|
||||
init sessionValue location =
|
||||
let
|
||||
session =
|
||||
Session.init (Session.decodeValueWithDefault sessionValue)
|
||||
|
||||
model =
|
||||
{ page = Home Home.init
|
||||
, session = session
|
||||
, mailboxName = ""
|
||||
}
|
||||
|
||||
route =
|
||||
Route.fromLocation location
|
||||
in
|
||||
applySession (setRoute route model)
|
||||
|
||||
|
||||
type Msg
|
||||
= SetRoute Route
|
||||
| NewRoute Route
|
||||
| UpdateSession (Result String Session.Persistent)
|
||||
| MailboxNameInput String
|
||||
| ViewMailbox String
|
||||
| HomeMsg Home.Msg
|
||||
| MailboxMsg Mailbox.Msg
|
||||
| MonitorMsg Monitor.Msg
|
||||
| StatusMsg Status.Msg
|
||||
|
||||
|
||||
|
||||
-- SUBSCRIPTIONS
|
||||
|
||||
|
||||
subscriptions : Model -> Sub Msg
|
||||
subscriptions model =
|
||||
Sub.batch
|
||||
[ pageSubscriptions model.page
|
||||
, Sub.map UpdateSession sessionChange
|
||||
]
|
||||
|
||||
|
||||
sessionChange : Sub (Result String Session.Persistent)
|
||||
sessionChange =
|
||||
Ports.onSessionChange (Decode.decodeValue Session.decoder)
|
||||
|
||||
|
||||
pageSubscriptions : Page -> Sub Msg
|
||||
pageSubscriptions page =
|
||||
case page of
|
||||
Monitor subModel ->
|
||||
Sub.map MonitorMsg (Monitor.subscriptions subModel)
|
||||
|
||||
Status subModel ->
|
||||
Sub.map StatusMsg (Status.subscriptions subModel)
|
||||
|
||||
_ ->
|
||||
Sub.none
|
||||
|
||||
|
||||
|
||||
-- UPDATE
|
||||
|
||||
|
||||
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||
update msg model =
|
||||
applySession <|
|
||||
case msg of
|
||||
SetRoute route ->
|
||||
-- Updates broser URL to requested route.
|
||||
( model, Route.newUrl route, Session.none )
|
||||
|
||||
NewRoute route ->
|
||||
-- Responds to new browser URL.
|
||||
if model.session.routing then
|
||||
setRoute route model
|
||||
else
|
||||
-- Skip once, but re-enable routing.
|
||||
( model, Cmd.none, Session.EnableRouting )
|
||||
|
||||
UpdateSession (Ok persistent) ->
|
||||
let
|
||||
session =
|
||||
model.session
|
||||
in
|
||||
( { model | session = { session | persistent = persistent } }
|
||||
, Cmd.none
|
||||
, Session.none
|
||||
)
|
||||
|
||||
UpdateSession (Err error) ->
|
||||
let
|
||||
_ =
|
||||
Debug.log "Error decoding session" error
|
||||
in
|
||||
( model, Cmd.none, Session.none )
|
||||
|
||||
MailboxNameInput name ->
|
||||
( { model | mailboxName = name }, Cmd.none, Session.none )
|
||||
|
||||
ViewMailbox name ->
|
||||
( { model | mailboxName = "" }
|
||||
, Route.newUrl (Route.Mailbox name)
|
||||
, Session.none
|
||||
)
|
||||
|
||||
_ ->
|
||||
updatePage msg model
|
||||
|
||||
|
||||
{-| Delegates incoming messages to their respective sub-pages.
|
||||
-}
|
||||
updatePage : Msg -> Model -> ( Model, Cmd Msg, Session.Msg )
|
||||
updatePage msg model =
|
||||
let
|
||||
-- Handles sub-model update by calling toUpdate with subMsg & subModel, then packing the
|
||||
-- updated sub-model back into model.page.
|
||||
modelUpdate toPage toMsg subUpdate subMsg subModel =
|
||||
let
|
||||
( newModel, subCmd, sessionMsg ) =
|
||||
subUpdate model.session subMsg subModel
|
||||
in
|
||||
( { model | page = toPage newModel }, Cmd.map toMsg subCmd, sessionMsg )
|
||||
in
|
||||
case ( msg, model.page ) of
|
||||
( HomeMsg subMsg, Home subModel ) ->
|
||||
modelUpdate Home HomeMsg Home.update subMsg subModel
|
||||
|
||||
( MailboxMsg subMsg, Mailbox subModel ) ->
|
||||
modelUpdate Mailbox MailboxMsg Mailbox.update subMsg subModel
|
||||
|
||||
( MonitorMsg subMsg, Monitor subModel ) ->
|
||||
modelUpdate Monitor MonitorMsg Monitor.update subMsg subModel
|
||||
|
||||
( StatusMsg subMsg, Status subModel ) ->
|
||||
modelUpdate Status StatusMsg Status.update subMsg subModel
|
||||
|
||||
( _, _ ) ->
|
||||
-- Disregard messages destined for the wrong page.
|
||||
( model, Cmd.none, Session.none )
|
||||
|
||||
|
||||
setRoute : Route -> Model -> ( Model, Cmd Msg, Session.Msg )
|
||||
setRoute route model =
|
||||
case route of
|
||||
Route.Unknown hash ->
|
||||
( model, Cmd.none, Session.SetFlash ("Unknown route requested: " ++ hash) )
|
||||
|
||||
Route.Home ->
|
||||
( { model | page = Home Home.init }
|
||||
, Ports.windowTitle "Inbucket"
|
||||
, Session.none
|
||||
)
|
||||
|
||||
Route.Mailbox name ->
|
||||
( { model | page = Mailbox (Mailbox.init name Nothing) }
|
||||
, Cmd.map MailboxMsg (Mailbox.load name)
|
||||
, Session.none
|
||||
)
|
||||
|
||||
Route.Message mailbox id ->
|
||||
( { model | page = Mailbox (Mailbox.init mailbox (Just id)) }
|
||||
, Cmd.map MailboxMsg (Mailbox.load mailbox)
|
||||
, Session.none
|
||||
)
|
||||
|
||||
Route.Monitor ->
|
||||
( { model | page = Monitor Monitor.init }
|
||||
, Ports.windowTitle "Inbucket Monitor"
|
||||
, Session.none
|
||||
)
|
||||
|
||||
Route.Status ->
|
||||
( { model | page = Status Status.init }
|
||||
, Cmd.batch
|
||||
[ Ports.windowTitle "Inbucket Status"
|
||||
, Cmd.map StatusMsg (Status.load)
|
||||
]
|
||||
, Session.none
|
||||
)
|
||||
|
||||
|
||||
applySession : ( Model, Cmd Msg, Session.Msg ) -> ( Model, Cmd Msg )
|
||||
applySession ( model, cmd, sessionMsg ) =
|
||||
let
|
||||
session =
|
||||
Session.update sessionMsg model.session
|
||||
|
||||
newModel =
|
||||
{ model | session = session }
|
||||
in
|
||||
if session.persistent == model.session.persistent then
|
||||
-- No change
|
||||
( newModel, cmd )
|
||||
else
|
||||
( newModel
|
||||
, Cmd.batch [ cmd, Ports.storeSession session.persistent ]
|
||||
)
|
||||
|
||||
|
||||
|
||||
-- VIEW
|
||||
|
||||
|
||||
view : Model -> Html Msg
|
||||
view model =
|
||||
let
|
||||
mailbox =
|
||||
case model.page of
|
||||
Mailbox subModel ->
|
||||
subModel.name
|
||||
|
||||
_ ->
|
||||
""
|
||||
|
||||
controls =
|
||||
{ viewMailbox = ViewMailbox
|
||||
, mailboxOnInput = MailboxNameInput
|
||||
, mailboxValue = model.mailboxName
|
||||
, recentOptions = model.session.persistent.recentMailboxes
|
||||
, recentActive = mailbox
|
||||
}
|
||||
|
||||
frame =
|
||||
Page.frame controls model.session
|
||||
in
|
||||
case model.page of
|
||||
Home subModel ->
|
||||
Html.map HomeMsg (Home.view model.session subModel)
|
||||
|> frame Page.Other
|
||||
|
||||
Mailbox subModel ->
|
||||
Html.map MailboxMsg (Mailbox.view model.session subModel)
|
||||
|> frame Page.Mailbox
|
||||
|
||||
Monitor subModel ->
|
||||
Html.map MonitorMsg (Monitor.view model.session subModel)
|
||||
|> frame Page.Monitor
|
||||
|
||||
Status subModel ->
|
||||
Html.map StatusMsg (Status.view model.session subModel)
|
||||
|> frame Page.Status
|
||||
|
||||
|
||||
|
||||
-- MAIN
|
||||
|
||||
|
||||
main : Program Value Model Msg
|
||||
main =
|
||||
Navigation.programWithFlags (Route.fromLocation >> NewRoute)
|
||||
{ init = init
|
||||
, view = view
|
||||
, update = update
|
||||
, subscriptions = subscriptions
|
||||
}
|
||||
42
ui/src/Page/Home.elm
Normal file
42
ui/src/Page/Home.elm
Normal file
@@ -0,0 +1,42 @@
|
||||
module Page.Home exposing (Model, Msg, init, update, view)
|
||||
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (..)
|
||||
import Data.Session as Session exposing (Session)
|
||||
|
||||
|
||||
-- MODEL --
|
||||
|
||||
|
||||
type alias Model =
|
||||
{}
|
||||
|
||||
|
||||
init : Model
|
||||
init =
|
||||
{}
|
||||
|
||||
|
||||
|
||||
-- UPDATE --
|
||||
|
||||
|
||||
type Msg
|
||||
= Msg
|
||||
|
||||
|
||||
update : Session -> Msg -> Model -> ( Model, Cmd Msg, Session.Msg )
|
||||
update session msg model =
|
||||
( model, Cmd.none, Session.none )
|
||||
|
||||
|
||||
|
||||
-- VIEW --
|
||||
|
||||
|
||||
view : Session -> Model -> Html Msg
|
||||
view session model =
|
||||
div [ id "page" ]
|
||||
[ h1 [] [ text "Inbucket" ]
|
||||
, text "This is the home page"
|
||||
]
|
||||
216
ui/src/Page/Mailbox.elm
Normal file
216
ui/src/Page/Mailbox.elm
Normal file
@@ -0,0 +1,216 @@
|
||||
module Page.Mailbox exposing (Model, Msg, init, load, update, view)
|
||||
|
||||
import Data.Message as Message exposing (Message)
|
||||
import Data.MessageHeader as MessageHeader exposing (MessageHeader)
|
||||
import Data.Session as Session exposing (Session)
|
||||
import Json.Decode as Decode exposing (Decoder)
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (class, classList, href, id, placeholder, target)
|
||||
import Html.Events exposing (..)
|
||||
import Http exposing (Error)
|
||||
import HttpUtil
|
||||
import Ports
|
||||
import Route exposing (Route)
|
||||
|
||||
|
||||
inbucketBase : String
|
||||
inbucketBase =
|
||||
""
|
||||
|
||||
|
||||
|
||||
-- MODEL --
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ name : String
|
||||
, selected : Maybe String
|
||||
, headers : List MessageHeader
|
||||
, message : Maybe Message
|
||||
}
|
||||
|
||||
|
||||
init : String -> Maybe String -> Model
|
||||
init name id =
|
||||
Model name id [] Nothing
|
||||
|
||||
|
||||
load : String -> Cmd Msg
|
||||
load name =
|
||||
Cmd.batch
|
||||
[ Ports.windowTitle (name ++ " - Inbucket")
|
||||
, getMailbox name
|
||||
]
|
||||
|
||||
|
||||
|
||||
-- UPDATE --
|
||||
|
||||
|
||||
type Msg
|
||||
= ClickMessage String
|
||||
| ViewMessage String
|
||||
| DeleteMessage Message
|
||||
| DeleteMessageResult (Result Http.Error ())
|
||||
| NewMailbox (Result Http.Error (List MessageHeader))
|
||||
| NewMessage (Result Http.Error Message)
|
||||
|
||||
|
||||
update : Session -> Msg -> Model -> ( Model, Cmd Msg, Session.Msg )
|
||||
update session msg model =
|
||||
case msg of
|
||||
ClickMessage id ->
|
||||
( { model | selected = Just id }
|
||||
, Cmd.batch
|
||||
[ Route.newUrl (Route.Message model.name id)
|
||||
, getMessage model.name id
|
||||
]
|
||||
, Session.DisableRouting
|
||||
)
|
||||
|
||||
ViewMessage id ->
|
||||
( { model | selected = Just id }
|
||||
, getMessage model.name id
|
||||
, Session.AddRecent model.name
|
||||
)
|
||||
|
||||
DeleteMessage msg ->
|
||||
deleteMessage model msg
|
||||
|
||||
DeleteMessageResult (Ok _) ->
|
||||
( model, Cmd.none, Session.none )
|
||||
|
||||
DeleteMessageResult (Err err) ->
|
||||
( model, Cmd.none, Session.SetFlash (HttpUtil.errorString err) )
|
||||
|
||||
NewMailbox (Ok headers) ->
|
||||
let
|
||||
newModel =
|
||||
{ model | headers = headers }
|
||||
in
|
||||
case model.selected of
|
||||
Nothing ->
|
||||
( newModel, Cmd.none, Session.AddRecent model.name )
|
||||
|
||||
Just id ->
|
||||
-- Recurse to select message id.
|
||||
update session (ViewMessage id) newModel
|
||||
|
||||
NewMailbox (Err err) ->
|
||||
( model, Cmd.none, Session.SetFlash (HttpUtil.errorString err) )
|
||||
|
||||
NewMessage (Ok msg) ->
|
||||
( { model | message = Just msg }, Cmd.none, Session.none )
|
||||
|
||||
NewMessage (Err err) ->
|
||||
( model, Cmd.none, Session.SetFlash (HttpUtil.errorString err) )
|
||||
|
||||
|
||||
getMailbox : String -> Cmd Msg
|
||||
getMailbox name =
|
||||
let
|
||||
url =
|
||||
inbucketBase ++ "/api/v1/mailbox/" ++ name
|
||||
in
|
||||
Http.get url (Decode.list MessageHeader.decoder)
|
||||
|> Http.send NewMailbox
|
||||
|
||||
|
||||
deleteMessage : Model -> Message -> ( Model, Cmd Msg, Session.Msg )
|
||||
deleteMessage model msg =
|
||||
let
|
||||
url =
|
||||
inbucketBase ++ "/api/v1/mailbox/" ++ msg.mailbox ++ "/" ++ msg.id
|
||||
|
||||
cmd =
|
||||
HttpUtil.delete url
|
||||
|> Http.send DeleteMessageResult
|
||||
in
|
||||
( { model
|
||||
| message = Nothing
|
||||
, selected = Nothing
|
||||
, headers = List.filter (\x -> x.id /= msg.id) model.headers
|
||||
}
|
||||
, cmd
|
||||
, Session.none
|
||||
)
|
||||
|
||||
|
||||
getMessage : String -> String -> Cmd Msg
|
||||
getMessage mailbox id =
|
||||
let
|
||||
url =
|
||||
inbucketBase ++ "/api/v1/mailbox/" ++ mailbox ++ "/" ++ id
|
||||
in
|
||||
Http.get url Message.decoder
|
||||
|> Http.send NewMessage
|
||||
|
||||
|
||||
|
||||
-- VIEW --
|
||||
|
||||
|
||||
view : Session -> Model -> Html Msg
|
||||
view session model =
|
||||
div [ id "page", class "mailbox" ]
|
||||
[ aside [ id "message-list" ] [ viewMailbox model ]
|
||||
, main_ [ id "message" ] [ viewMessage model ]
|
||||
]
|
||||
|
||||
|
||||
viewMailbox : Model -> Html Msg
|
||||
viewMailbox model =
|
||||
div [] (List.map (viewHeader model) (List.reverse model.headers))
|
||||
|
||||
|
||||
viewHeader : Model -> MessageHeader -> Html Msg
|
||||
viewHeader mailbox msg =
|
||||
div
|
||||
[ classList
|
||||
[ ( "message-list-entry", True )
|
||||
, ( "selected", mailbox.selected == Just msg.id )
|
||||
, ( "unseen", not msg.seen )
|
||||
]
|
||||
, onClick (ClickMessage msg.id)
|
||||
]
|
||||
[ div [ class "subject" ] [ text msg.subject ]
|
||||
, div [ class "from" ] [ text msg.from ]
|
||||
, div [ class "date" ] [ text msg.date ]
|
||||
]
|
||||
|
||||
|
||||
viewMessage : Model -> Html Msg
|
||||
viewMessage model =
|
||||
case model.message of
|
||||
Just message ->
|
||||
div []
|
||||
[ div [ class "button-bar" ]
|
||||
[ button [ class "danger", onClick (DeleteMessage message) ] [ text "Delete" ]
|
||||
, a
|
||||
[ href
|
||||
(inbucketBase
|
||||
++ "/mailbox/"
|
||||
++ message.mailbox
|
||||
++ "/"
|
||||
++ message.id
|
||||
++ "/source"
|
||||
)
|
||||
, target "_blank"
|
||||
]
|
||||
[ button [] [ text "Source" ] ]
|
||||
]
|
||||
, dl [ id "message-header" ]
|
||||
[ dt [] [ text "From:" ]
|
||||
, dd [] [ text message.from ]
|
||||
, dt [] [ text "To:" ]
|
||||
, dd [] (List.map text message.to)
|
||||
, dt [] [ text "Date:" ]
|
||||
, dd [] [ text message.date ]
|
||||
, dt [] [ text "Subject:" ]
|
||||
, dd [] [ text message.subject ]
|
||||
]
|
||||
, article [] [ text message.body.text ]
|
||||
]
|
||||
|
||||
Nothing ->
|
||||
text ""
|
||||
88
ui/src/Page/Monitor.elm
Normal file
88
ui/src/Page/Monitor.elm
Normal file
@@ -0,0 +1,88 @@
|
||||
module Page.Monitor exposing (Model, Msg, init, subscriptions, update, view)
|
||||
|
||||
import Data.MessageHeader as MessageHeader exposing (MessageHeader)
|
||||
import Data.Session as Session exposing (Session)
|
||||
import Json.Decode exposing (decodeString)
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (..)
|
||||
import Html.Events as Events
|
||||
import Route
|
||||
import WebSocket
|
||||
|
||||
|
||||
-- MODEL --
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ messages : List MessageHeader }
|
||||
|
||||
|
||||
init : Model
|
||||
init =
|
||||
{ messages = [] }
|
||||
|
||||
|
||||
|
||||
-- SUBSCRIPTIONS --
|
||||
|
||||
|
||||
subscriptions : Model -> Sub Msg
|
||||
subscriptions model =
|
||||
WebSocket.listen "ws://192.168.1.10:3000/api/v1/monitor/messages"
|
||||
(decodeString MessageHeader.decoder >> NewMessage)
|
||||
|
||||
|
||||
|
||||
-- UPDATE --
|
||||
|
||||
|
||||
type Msg
|
||||
= NewMessage (Result String MessageHeader)
|
||||
| OpenMessage MessageHeader
|
||||
|
||||
|
||||
update : Session -> Msg -> Model -> ( Model, Cmd Msg, Session.Msg )
|
||||
update session msg model =
|
||||
case msg of
|
||||
NewMessage (Ok msg) ->
|
||||
( { model | messages = msg :: model.messages }, Cmd.none, Session.none )
|
||||
|
||||
NewMessage (Err err) ->
|
||||
( model, Cmd.none, Session.SetFlash err )
|
||||
|
||||
OpenMessage msg ->
|
||||
( model
|
||||
, Route.newUrl (Route.Message msg.mailbox msg.id)
|
||||
, Session.none
|
||||
)
|
||||
|
||||
|
||||
|
||||
-- VIEW --
|
||||
|
||||
|
||||
view : Session -> Model -> Html Msg
|
||||
view session model =
|
||||
div [ id "page" ]
|
||||
[ h1 [] [ text "Monitor" ]
|
||||
, p [] [ text "Messages will be listed here shortly after delivery." ]
|
||||
, table [ id "monitor" ]
|
||||
[ thead []
|
||||
[ th [] [ text "Date" ]
|
||||
, th [ class "desktop" ] [ text "From" ]
|
||||
, th [] [ text "Mailbox" ]
|
||||
, th [] [ text "Subject" ]
|
||||
]
|
||||
, tbody [] (List.map viewMessage model.messages)
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
viewMessage : MessageHeader -> Html Msg
|
||||
viewMessage message =
|
||||
tr [ Events.onClick (OpenMessage message) ]
|
||||
[ td [] [ text message.date ]
|
||||
, td [ class "desktop" ] [ text message.from ]
|
||||
, td [] [ text message.mailbox ]
|
||||
, td [] [ text message.subject ]
|
||||
]
|
||||
400
ui/src/Page/Status.elm
Normal file
400
ui/src/Page/Status.elm
Normal file
@@ -0,0 +1,400 @@
|
||||
module Page.Status exposing (Model, Msg, init, load, subscriptions, update, view)
|
||||
|
||||
import Data.Metrics as Metrics exposing (Metrics)
|
||||
import Data.Session as Session exposing (Session)
|
||||
import Filesize
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (..)
|
||||
import Http exposing (Error)
|
||||
import HttpUtil
|
||||
import Sparkline exposing (sparkline, Point, DataSet, Size)
|
||||
import Svg.Attributes as SvgAttrib
|
||||
import Time exposing (Time)
|
||||
|
||||
|
||||
-- MODEL --
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ metrics : Maybe Metrics
|
||||
, xCounter : Float
|
||||
, sysMem : Metric
|
||||
, heapSize : Metric
|
||||
, heapUsed : Metric
|
||||
, heapObjects : Metric
|
||||
, goRoutines : Metric
|
||||
, webSockets : Metric
|
||||
, smtpConnOpen : Metric
|
||||
, smtpConnTotal : Metric
|
||||
, smtpReceivedTotal : Metric
|
||||
, smtpErrorsTotal : Metric
|
||||
, smtpWarnsTotal : Metric
|
||||
, retentionDeletesTotal : Metric
|
||||
, retainedCount : Metric
|
||||
, retainedSize : Metric
|
||||
}
|
||||
|
||||
|
||||
type alias Metric =
|
||||
{ label : String
|
||||
, value : Int
|
||||
, formatter : Int -> String
|
||||
, graph : DataSet -> Html Msg
|
||||
, history : DataSet
|
||||
, minutes : Int
|
||||
}
|
||||
|
||||
|
||||
init : Model
|
||||
init =
|
||||
{ metrics = Nothing
|
||||
, xCounter = 60
|
||||
, sysMem = Metric "System Memory" 0 Filesize.format graphZero initDataSet 10
|
||||
, heapSize = Metric "Heap Size" 0 Filesize.format graphZero initDataSet 10
|
||||
, heapUsed = Metric "Heap Used" 0 Filesize.format graphZero initDataSet 10
|
||||
, heapObjects = Metric "Heap # Objects" 0 fmtInt graphZero initDataSet 10
|
||||
, goRoutines = Metric "Goroutines" 0 fmtInt graphZero initDataSet 10
|
||||
, webSockets = Metric "Open WebSockets" 0 fmtInt graphZero initDataSet 10
|
||||
, smtpConnOpen = Metric "Open Connections" 0 fmtInt graphZero initDataSet 10
|
||||
, smtpConnTotal = Metric "Total Connections" 0 fmtInt graphChange initDataSet 60
|
||||
, smtpReceivedTotal = Metric "Messages Received" 0 fmtInt graphChange initDataSet 60
|
||||
, smtpErrorsTotal = Metric "Messages Errors" 0 fmtInt graphChange initDataSet 60
|
||||
, smtpWarnsTotal = Metric "Messages Warns" 0 fmtInt graphChange initDataSet 60
|
||||
, retentionDeletesTotal = Metric "Retention Deletes" 0 fmtInt graphChange initDataSet 60
|
||||
, retainedCount = Metric "Stored Messages" 0 fmtInt graphZero initDataSet 60
|
||||
, retainedSize = Metric "Store Size" 0 Filesize.format graphZero initDataSet 60
|
||||
}
|
||||
|
||||
|
||||
initDataSet : DataSet
|
||||
initDataSet =
|
||||
List.range 0 59
|
||||
|> List.map (\x -> ( toFloat (x), 0 ))
|
||||
|
||||
|
||||
load : Cmd Msg
|
||||
load =
|
||||
getMetrics
|
||||
|
||||
|
||||
|
||||
-- SUBSCRIPTIONS --
|
||||
|
||||
|
||||
subscriptions : Model -> Sub Msg
|
||||
subscriptions model =
|
||||
Time.every (10 * Time.second) Tick
|
||||
|
||||
|
||||
|
||||
-- UPDATE --
|
||||
|
||||
|
||||
type Msg
|
||||
= NewMetrics (Result Http.Error Metrics)
|
||||
| Tick Time
|
||||
|
||||
|
||||
update : Session -> Msg -> Model -> ( Model, Cmd Msg, Session.Msg )
|
||||
update session msg model =
|
||||
case msg of
|
||||
NewMetrics (Ok metrics) ->
|
||||
( updateMetrics metrics model, Cmd.none, Session.none )
|
||||
|
||||
NewMetrics (Err err) ->
|
||||
( model, Cmd.none, Session.SetFlash (HttpUtil.errorString err) )
|
||||
|
||||
Tick time ->
|
||||
( model, getMetrics, Session.ClearFlash )
|
||||
|
||||
|
||||
{-| Update all metrics in Model; increment xCounter.
|
||||
-}
|
||||
updateMetrics : Metrics -> Model -> Model
|
||||
updateMetrics metrics model =
|
||||
let
|
||||
x =
|
||||
model.xCounter
|
||||
in
|
||||
{ model
|
||||
| metrics = Just metrics
|
||||
, xCounter = x + 1
|
||||
, sysMem = updateLocalMetric model.sysMem x metrics.sysMem
|
||||
, heapSize = updateLocalMetric model.heapSize x metrics.heapSize
|
||||
, heapUsed = updateLocalMetric model.heapUsed x metrics.heapUsed
|
||||
, heapObjects = updateLocalMetric model.heapObjects x metrics.heapObjects
|
||||
, goRoutines = updateLocalMetric model.goRoutines x metrics.goRoutines
|
||||
, webSockets = updateLocalMetric model.webSockets x metrics.webSockets
|
||||
, smtpConnOpen = updateLocalMetric model.smtpConnOpen x metrics.smtpConnOpen
|
||||
, smtpConnTotal =
|
||||
updateRemoteTotal
|
||||
model.smtpConnTotal
|
||||
metrics.smtpConnTotal
|
||||
metrics.smtpConnHist
|
||||
, smtpReceivedTotal =
|
||||
updateRemoteTotal
|
||||
model.smtpReceivedTotal
|
||||
metrics.smtpReceivedTotal
|
||||
metrics.smtpReceivedHist
|
||||
, smtpErrorsTotal =
|
||||
updateRemoteTotal
|
||||
model.smtpErrorsTotal
|
||||
metrics.smtpErrorsTotal
|
||||
metrics.smtpErrorsHist
|
||||
, smtpWarnsTotal =
|
||||
updateRemoteTotal
|
||||
model.smtpWarnsTotal
|
||||
metrics.smtpWarnsTotal
|
||||
metrics.smtpWarnsHist
|
||||
, retentionDeletesTotal =
|
||||
updateRemoteTotal
|
||||
model.retentionDeletesTotal
|
||||
metrics.retentionDeletesTotal
|
||||
metrics.retentionDeletesHist
|
||||
, retainedCount =
|
||||
updateRemoteMetric
|
||||
model.retainedCount
|
||||
metrics.retainedCount
|
||||
metrics.retainedCountHist
|
||||
, retainedSize =
|
||||
updateRemoteMetric
|
||||
model.retainedSize
|
||||
metrics.retainedSize
|
||||
metrics.retainedSizeHist
|
||||
}
|
||||
|
||||
|
||||
{-| Update a single Metric, with history tracked locally.
|
||||
-}
|
||||
updateLocalMetric : Metric -> Float -> Int -> Metric
|
||||
updateLocalMetric metric x value =
|
||||
{ metric
|
||||
| value = value
|
||||
, history =
|
||||
(Maybe.withDefault [] (List.tail metric.history))
|
||||
++ [ ( x, (toFloat value) ) ]
|
||||
}
|
||||
|
||||
|
||||
{-| Update a single Metric, with history tracked on server.
|
||||
-}
|
||||
updateRemoteMetric : Metric -> Int -> List Int -> Metric
|
||||
updateRemoteMetric metric value history =
|
||||
{ metric
|
||||
| value = value
|
||||
, history =
|
||||
history
|
||||
|> zeroPadList
|
||||
|> List.indexedMap (\x y -> ( toFloat x, toFloat y ))
|
||||
}
|
||||
|
||||
|
||||
{-| Update a single Metric, with history tracked on server. Sparkline will chart changes to the
|
||||
total instead of its absolute value.
|
||||
-}
|
||||
updateRemoteTotal : Metric -> Int -> List Int -> Metric
|
||||
updateRemoteTotal metric value history =
|
||||
{ metric
|
||||
| value = value
|
||||
, history =
|
||||
history
|
||||
|> zeroPadList
|
||||
|> changeList
|
||||
|> List.indexedMap (\x y -> ( toFloat x, toFloat y ))
|
||||
}
|
||||
|
||||
|
||||
getMetrics : Cmd Msg
|
||||
getMetrics =
|
||||
Http.get "/debug/vars" Metrics.decoder
|
||||
|> Http.send NewMetrics
|
||||
|
||||
|
||||
|
||||
-- VIEW --
|
||||
|
||||
|
||||
view : Session -> Model -> Html Msg
|
||||
view session model =
|
||||
div [ id "page" ]
|
||||
[ h1 [] [ text "Status" ]
|
||||
, case model.metrics of
|
||||
Nothing ->
|
||||
div [] [ text "Loading metrics..." ]
|
||||
|
||||
Just metrics ->
|
||||
div []
|
||||
[ framePanel "General Metrics"
|
||||
[ viewMetric model.sysMem
|
||||
, viewMetric model.heapSize
|
||||
, viewMetric model.heapUsed
|
||||
, viewMetric model.heapObjects
|
||||
, viewMetric model.goRoutines
|
||||
, viewMetric model.webSockets
|
||||
]
|
||||
, framePanel "SMTP Metrics"
|
||||
[ viewMetric model.smtpConnOpen
|
||||
, viewMetric model.smtpConnTotal
|
||||
, viewMetric model.smtpReceivedTotal
|
||||
, viewMetric model.smtpErrorsTotal
|
||||
, viewMetric model.smtpWarnsTotal
|
||||
]
|
||||
, framePanel "Storage Metrics"
|
||||
[ viewMetric model.retentionDeletesTotal
|
||||
, viewMetric model.retainedCount
|
||||
, viewMetric model.retainedSize
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
viewMetric : Metric -> Html Msg
|
||||
viewMetric metric =
|
||||
div [ class "metric" ]
|
||||
[ div [ class "label" ] [ text metric.label ]
|
||||
, div [ class "value" ] [ text (metric.formatter metric.value) ]
|
||||
, div [ class "graph" ]
|
||||
[ metric.graph metric.history
|
||||
, text ("(" ++ toString metric.minutes ++ "min)")
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
viewLiveMetric : String -> (Int -> String) -> Int -> Html a -> Html a
|
||||
viewLiveMetric label formatter value graph =
|
||||
div [ class "metric" ]
|
||||
[ div [ class "label" ] [ text label ]
|
||||
, div [ class "value" ] [ text (formatter value) ]
|
||||
, div [ class "graph" ]
|
||||
[ graph
|
||||
, text "(10min)"
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
graphNull : Html a
|
||||
graphNull =
|
||||
div [] []
|
||||
|
||||
|
||||
graphSize : Size
|
||||
graphSize =
|
||||
( 180, 16, 0, 0 )
|
||||
|
||||
|
||||
areaStyle : Sparkline.Param a -> Sparkline.Param a
|
||||
areaStyle =
|
||||
Sparkline.Style
|
||||
[ SvgAttrib.fill "rgba(50,100,255,0.3)"
|
||||
, SvgAttrib.stroke "rgba(50,100,255,1.0)"
|
||||
, SvgAttrib.strokeWidth "1.0"
|
||||
]
|
||||
|
||||
|
||||
barStyle : Sparkline.Param a -> Sparkline.Param a
|
||||
barStyle =
|
||||
Sparkline.Style
|
||||
[ SvgAttrib.fill "rgba(50,200,50,0.7)"
|
||||
]
|
||||
|
||||
|
||||
zeroStyle : Sparkline.Param a -> Sparkline.Param a
|
||||
zeroStyle =
|
||||
Sparkline.Style
|
||||
[ SvgAttrib.stroke "rgba(0,0,0,0.2)"
|
||||
, SvgAttrib.strokeWidth "1.0"
|
||||
]
|
||||
|
||||
|
||||
{-| Bar graph to be used with updateRemoteTotal metrics (change instead of absolute values).
|
||||
-}
|
||||
graphChange : DataSet -> Html a
|
||||
graphChange data =
|
||||
let
|
||||
-- Used with Domain to stop sparkline forgetting about zero; continue scrolling graph.
|
||||
x =
|
||||
case List.head data of
|
||||
Nothing ->
|
||||
0
|
||||
|
||||
Just point ->
|
||||
Tuple.first point
|
||||
in
|
||||
sparkline graphSize
|
||||
[ Sparkline.Bar 2.5 data |> barStyle
|
||||
, Sparkline.ZeroLine |> zeroStyle
|
||||
, Sparkline.Domain [ ( x, 0 ), ( x, 1 ) ]
|
||||
]
|
||||
|
||||
|
||||
{-| Zero based area graph, for charting absolute values relative to 0.
|
||||
-}
|
||||
graphZero : DataSet -> Html a
|
||||
graphZero data =
|
||||
let
|
||||
-- Used with Domain to stop sparkline forgetting about zero; continue scrolling graph.
|
||||
x =
|
||||
case List.head data of
|
||||
Nothing ->
|
||||
0
|
||||
|
||||
Just point ->
|
||||
Tuple.first point
|
||||
in
|
||||
sparkline graphSize
|
||||
[ Sparkline.Area data |> areaStyle
|
||||
, Sparkline.ZeroLine |> zeroStyle
|
||||
, Sparkline.Domain [ ( x, 0 ), ( x, 1 ) ]
|
||||
]
|
||||
|
||||
|
||||
framePanel : String -> List (Html a) -> Html a
|
||||
framePanel name html =
|
||||
div [ class "metric-panel" ]
|
||||
[ h2 [] [ text name ]
|
||||
, div [ class "metrics" ] html
|
||||
]
|
||||
|
||||
|
||||
|
||||
-- UTILS --
|
||||
|
||||
|
||||
{-| Compute difference between each Int in numbers.
|
||||
-}
|
||||
changeList : List Int -> List Int
|
||||
changeList numbers =
|
||||
let
|
||||
tail =
|
||||
List.tail numbers |> Maybe.withDefault []
|
||||
in
|
||||
List.map2 (-) tail numbers
|
||||
|
||||
|
||||
{-| Pad the front of a list with 0s to make it at least 60 elements long.
|
||||
-}
|
||||
zeroPadList : List Int -> List Int
|
||||
zeroPadList numbers =
|
||||
let
|
||||
needed =
|
||||
60 - List.length numbers
|
||||
in
|
||||
if needed > 0 then
|
||||
(List.repeat needed 0) ++ numbers
|
||||
else
|
||||
numbers
|
||||
|
||||
|
||||
{-| Format an Int with thousands separators.
|
||||
-}
|
||||
fmtInt : Int -> String
|
||||
fmtInt n =
|
||||
let
|
||||
-- thousands recursively inserts thousands separators.
|
||||
thousands str =
|
||||
if String.length str <= 3 then
|
||||
str
|
||||
else
|
||||
(thousands (String.slice 0 -3 str)) ++ "," ++ (String.right 3 str)
|
||||
in
|
||||
thousands (toString n)
|
||||
13
ui/src/Ports.elm
Normal file
13
ui/src/Ports.elm
Normal file
@@ -0,0 +1,13 @@
|
||||
port module Ports exposing (onSessionChange, storeSession, windowTitle)
|
||||
|
||||
import Data.Session exposing (Persistent)
|
||||
import Json.Encode exposing (Value)
|
||||
|
||||
|
||||
port onSessionChange : (Value -> msg) -> Sub msg
|
||||
|
||||
|
||||
port storeSession : Persistent -> Cmd msg
|
||||
|
||||
|
||||
port windowTitle : String -> Cmd msg
|
||||
84
ui/src/Route.elm
Normal file
84
ui/src/Route.elm
Normal file
@@ -0,0 +1,84 @@
|
||||
module Route exposing (Route(..), fromLocation, href, modifyUrl, newUrl)
|
||||
|
||||
import Html exposing (Attribute)
|
||||
import Html.Attributes as Attr
|
||||
import Navigation exposing (Location)
|
||||
import UrlParser as Url exposing ((</>), Parser, parseHash, s, string)
|
||||
|
||||
|
||||
type Route
|
||||
= Unknown String
|
||||
| Home
|
||||
| Mailbox String
|
||||
| Message String String
|
||||
| Monitor
|
||||
| Status
|
||||
|
||||
|
||||
matcher : Parser (Route -> a) a
|
||||
matcher =
|
||||
Url.oneOf
|
||||
[ Url.map Home (s "")
|
||||
, Url.map Message (s "m" </> string </> string)
|
||||
, Url.map Mailbox (s "m" </> string)
|
||||
, Url.map Monitor (s "monitor")
|
||||
, Url.map Status (s "status")
|
||||
]
|
||||
|
||||
|
||||
routeToString : Route -> String
|
||||
routeToString page =
|
||||
let
|
||||
pieces =
|
||||
case page of
|
||||
Unknown _ ->
|
||||
[]
|
||||
|
||||
Home ->
|
||||
[]
|
||||
|
||||
Mailbox name ->
|
||||
[ "m", name ]
|
||||
|
||||
Message mailbox id ->
|
||||
[ "m", mailbox, id ]
|
||||
|
||||
Monitor ->
|
||||
[ "monitor" ]
|
||||
|
||||
Status ->
|
||||
[ "status" ]
|
||||
in
|
||||
"/#/" ++ String.join "/" pieces
|
||||
|
||||
|
||||
|
||||
-- PUBLIC HELPERS
|
||||
|
||||
|
||||
href : Route -> Attribute msg
|
||||
href route =
|
||||
Attr.href (routeToString route)
|
||||
|
||||
|
||||
modifyUrl : Route -> Cmd msg
|
||||
modifyUrl =
|
||||
routeToString >> Navigation.modifyUrl
|
||||
|
||||
|
||||
newUrl : Route -> Cmd msg
|
||||
newUrl =
|
||||
routeToString >> Navigation.newUrl
|
||||
|
||||
|
||||
fromLocation : Location -> Route
|
||||
fromLocation location =
|
||||
if String.isEmpty location.hash then
|
||||
Home
|
||||
else
|
||||
case parseHash matcher location of
|
||||
Nothing ->
|
||||
Unknown location.hash
|
||||
|
||||
Just route ->
|
||||
route
|
||||
123
ui/src/Views/Page.elm
Normal file
123
ui/src/Views/Page.elm
Normal file
@@ -0,0 +1,123 @@
|
||||
module Views.Page exposing (ActivePage(..), frame)
|
||||
|
||||
import Data.Session as Session exposing (Session)
|
||||
import Html exposing (..)
|
||||
import Html.Attributes
|
||||
exposing
|
||||
( attribute
|
||||
, class
|
||||
, classList
|
||||
, href
|
||||
, id
|
||||
, placeholder
|
||||
, type_
|
||||
, selected
|
||||
, value
|
||||
)
|
||||
import Html.Events as Events
|
||||
import Route exposing (Route)
|
||||
|
||||
|
||||
type ActivePage
|
||||
= Other
|
||||
| Mailbox
|
||||
| Monitor
|
||||
| Status
|
||||
|
||||
|
||||
type alias FrameControls msg =
|
||||
{ viewMailbox : String -> msg
|
||||
, mailboxOnInput : String -> msg
|
||||
, mailboxValue : String
|
||||
, recentOptions : List String
|
||||
, recentActive : String
|
||||
}
|
||||
|
||||
|
||||
frame : FrameControls msg -> Session -> ActivePage -> Html msg -> Html msg
|
||||
frame controls session page content =
|
||||
div [ id "app" ]
|
||||
[ header []
|
||||
[ ul [ class "navbar", attribute "role" "navigation" ]
|
||||
[ li [ id "navbar-brand" ] [ a [ Route.href Route.Home ] [ text "@ inbucket" ] ]
|
||||
, navbarLink page Route.Monitor [ text "Monitor" ]
|
||||
, navbarLink page Route.Status [ text "Status" ]
|
||||
, navbarRecent page controls
|
||||
, li [ id "navbar-mailbox" ]
|
||||
[ form [ Events.onSubmit (controls.viewMailbox controls.mailboxValue) ]
|
||||
[ input
|
||||
[ type_ "text"
|
||||
, placeholder "mailbox"
|
||||
, value controls.mailboxValue
|
||||
, Events.onInput controls.mailboxOnInput
|
||||
]
|
||||
[]
|
||||
]
|
||||
]
|
||||
]
|
||||
, div [] [ text ("Status: " ++ session.flash) ]
|
||||
]
|
||||
, div [ id "navbg" ] [ text "" ]
|
||||
, content
|
||||
, footer []
|
||||
[ div [ id "footer" ]
|
||||
[ a [ href "https://www.inbucket.org" ] [ text "Inbucket" ]
|
||||
, text " is an open source projected hosted at "
|
||||
, a [ href "https://github.com/jhillyerd/inbucket" ] [ text "GitHub" ]
|
||||
, text "."
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
navbarLink : ActivePage -> Route -> List (Html a) -> Html a
|
||||
navbarLink page route linkContent =
|
||||
li [ classList [ ( "navbar-active", isActive page route ) ] ]
|
||||
[ a [ Route.href route ] linkContent ]
|
||||
|
||||
|
||||
{-| Renders list of recent mailboxes, selecting the currently active mailbox.
|
||||
-}
|
||||
navbarRecent : ActivePage -> FrameControls msg -> Html msg
|
||||
navbarRecent page controls =
|
||||
let
|
||||
recentItemLink mailbox =
|
||||
a [ Route.href (Route.Mailbox mailbox) ] [ text mailbox ]
|
||||
|
||||
active =
|
||||
page == Mailbox
|
||||
|
||||
-- Navbar tab title, is current mailbox when active.
|
||||
title =
|
||||
if active then
|
||||
controls.recentActive
|
||||
else
|
||||
"Recent Mailboxes"
|
||||
|
||||
-- Items to show in recent list, doesn't include active mailbox.
|
||||
items =
|
||||
if active then
|
||||
List.tail controls.recentOptions |> Maybe.withDefault []
|
||||
else
|
||||
controls.recentOptions
|
||||
in
|
||||
li
|
||||
[ id "navbar-recent"
|
||||
, classList [ ( "navbar-dropdown", True ), ( "navbar-active", active ) ]
|
||||
]
|
||||
[ span [] [ text title ]
|
||||
, div [ class "navbar-dropdown-content" ] (List.map recentItemLink items)
|
||||
]
|
||||
|
||||
|
||||
isActive : ActivePage -> Route -> Bool
|
||||
isActive page route =
|
||||
case ( page, route ) of
|
||||
( Monitor, Route.Monitor ) ->
|
||||
True
|
||||
|
||||
( Status, Route.Status ) ->
|
||||
True
|
||||
|
||||
_ ->
|
||||
False
|
||||
33
ui/src/index.js
Normal file
33
ui/src/index.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import './main.css';
|
||||
import { Main } from './Main.elm';
|
||||
import registerServiceWorker from './registerServiceWorker';
|
||||
|
||||
var app = Main.embed(document.getElementById('root'), sessionObject());
|
||||
|
||||
app.ports.storeSession.subscribe(function (session) {
|
||||
localStorage.session = JSON.stringify(session);
|
||||
});
|
||||
|
||||
app.ports.windowTitle.subscribe(function (title) {
|
||||
document.title = title;
|
||||
});
|
||||
|
||||
window.addEventListener("storage", function (event) {
|
||||
if (event.storageArea === localStorage && event.key === "session") {
|
||||
app.ports.onSessionChange.send(sessionObject());
|
||||
}
|
||||
}, false);
|
||||
|
||||
function sessionObject() {
|
||||
var s = localStorage.session;
|
||||
try {
|
||||
if (s) {
|
||||
return JSON.parse(s);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
registerServiceWorker();
|
||||
377
ui/src/main.css
Normal file
377
ui/src/main.css
Normal file
@@ -0,0 +1,377 @@
|
||||
/** GLOBAL */
|
||||
|
||||
:root {
|
||||
--bg-color: #fff;
|
||||
--primary-color: #333;
|
||||
--low-color: #666;
|
||||
--high-color: #337ab7;
|
||||
--border-color: #ddd;
|
||||
--placeholder-color: #9f9f9f;
|
||||
--selected-color: #eee;
|
||||
}
|
||||
|
||||
html, body, div, span, applet, object, iframe,
|
||||
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
|
||||
a, abbr, acronym, address, big, cite, code,
|
||||
del, dfn, em, img, ins, kbd, q, s, samp,
|
||||
small, strike, strong, sub, sup, tt, var,
|
||||
b, u, i, center,
|
||||
dl, dt, dd, ol, ul, li,
|
||||
fieldset, form, label, legend,
|
||||
table, caption, tbody, tfoot, thead, tr, th, td,
|
||||
article, aside, canvas, details, embed,
|
||||
figure, figcaption, footer, header, hgroup,
|
||||
menu, nav, output, ruby, section, summary,
|
||||
time, mark, audio, video {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
font-size: 100%;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-color);
|
||||
}
|
||||
|
||||
body, input, table {
|
||||
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.43;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
::placeholder {
|
||||
color: var(--placeholder-color);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/** APP */
|
||||
|
||||
#app {
|
||||
display: grid;
|
||||
justify-content: center;
|
||||
grid-gap: 20px;
|
||||
grid-template:
|
||||
"lpad head rpad" auto
|
||||
"lpad page rpad" 1fr
|
||||
"foot foot foot" auto / minmax(20px, auto) 1fr minmax(20px, auto);
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
@media (max-width: 999px) {
|
||||
#app {
|
||||
grid-template:
|
||||
"head head head" auto
|
||||
"lpad page rpad" 1fr
|
||||
"foot foot foot" auto / 1px 1fr 1px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.desktop {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
header {
|
||||
grid-area: head;
|
||||
}
|
||||
|
||||
#page {
|
||||
grid-area: page;
|
||||
}
|
||||
|
||||
footer {
|
||||
background-color: var(--selected-color);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
grid-area: foot;
|
||||
}
|
||||
|
||||
#footer {
|
||||
margin: 10px auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 30px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/** NAV BAR */
|
||||
|
||||
.navbar,
|
||||
#navbg {
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
display: flex;
|
||||
line-height: 20px;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
text-shadow: 0 -1px 0 rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.navbar li {
|
||||
color: #9d9d9d;
|
||||
}
|
||||
|
||||
.navbar a,
|
||||
.navbar-dropdown span {
|
||||
color: #9d9d9d;
|
||||
display: inline-block;
|
||||
padding: 15px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
li.navbar-active {
|
||||
background-color: #080808;
|
||||
}
|
||||
|
||||
li.navbar-active a,
|
||||
li.navbar-active span,
|
||||
.navbar a:hover {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
#navbar-brand {
|
||||
font-size: 18px;
|
||||
margin-left: -15px;
|
||||
}
|
||||
|
||||
#navbar-recent {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
#navbar-mailbox {
|
||||
padding: 8px 0 !important;
|
||||
}
|
||||
|
||||
#navbar-mailbox input {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: 5px 10px;
|
||||
margin-top: 1px;
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
.navbar-dropdown-content {
|
||||
background-color: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,.05);
|
||||
display: none;
|
||||
min-width: 160px;
|
||||
position: absolute;
|
||||
text-shadow: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.navbar-dropdown:hover .navbar-dropdown-content {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.navbar-dropdown-content a {
|
||||
color: var(--primary-color) !important;
|
||||
display: block;
|
||||
padding: 5px 15px;
|
||||
}
|
||||
|
||||
.navbar-dropdown-content a:hover {
|
||||
background-color: var(--selected-color);
|
||||
}
|
||||
|
||||
#navbg {
|
||||
background-color: #222;
|
||||
background-image: linear-gradient(to bottom, #3c3c3c 0, #222 100%);
|
||||
grid-column: 1 / 4;
|
||||
grid-row: 1;
|
||||
width: 100%;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
/** BUTTONS */
|
||||
|
||||
.button-bar button {
|
||||
background-color: #337ab7;
|
||||
background-image: linear-gradient(to bottom, #337ab7 0, #265a88 100%);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
height: 30px;
|
||||
margin: 0 4px 0 0;
|
||||
padding: 5px;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
text-shadow: 0 -1px 0 rgba(0,0,0,0.2);
|
||||
width: 8em;
|
||||
}
|
||||
|
||||
.button-bar button.danger {
|
||||
background-color: #d9534f;
|
||||
background-image: linear-gradient(to bottom, #d9534f 0, #c12e2a 100%);
|
||||
}
|
||||
|
||||
/** MAILBOX */
|
||||
|
||||
.mailbox {
|
||||
display: grid;
|
||||
grid-area: page;
|
||||
grid-gap: 20px;
|
||||
grid-template-areas:
|
||||
"list"
|
||||
"mesg";
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
@media (min-width: 1000px) {
|
||||
.mailbox {
|
||||
grid-template-columns:
|
||||
minmax(200px, 300px)
|
||||
minmax(650px, 1000px);
|
||||
grid-template-areas:
|
||||
"list mesg";
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
#message-list {
|
||||
grid-area: list;
|
||||
}
|
||||
|
||||
.message-list-entry {
|
||||
border-color: var(--border-color);
|
||||
border-width: 1px;
|
||||
border-style: none solid solid solid;
|
||||
cursor: pointer;
|
||||
padding: 5px 8px;
|
||||
}
|
||||
|
||||
.message-list-entry.selected {
|
||||
background-color: var(--selected-color);
|
||||
}
|
||||
|
||||
.message-list-entry:first-child {
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.message-list-entry .subject {
|
||||
color: var(--high-color);
|
||||
}
|
||||
|
||||
.message-list-entry.unseen .subject {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.message-list-entry .from,
|
||||
.message-list-entry .date {
|
||||
color: var(--low-color);
|
||||
font-size: 85%;
|
||||
}
|
||||
|
||||
|
||||
/** MESSAGE */
|
||||
|
||||
#message {
|
||||
grid-area: mesg;
|
||||
}
|
||||
|
||||
#message-header {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,.05);
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
#message-header dt {
|
||||
color: var(--low-color);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#message-header dd {
|
||||
color: var(--low-color);
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
@media (min-width: 1000px) {
|
||||
#message-header {
|
||||
display: grid;
|
||||
grid-template: auto / 5em 1fr;
|
||||
}
|
||||
|
||||
#message-header dt {
|
||||
grid-column: 1;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
#message-header dd {
|
||||
grid-column: 2;
|
||||
}
|
||||
}
|
||||
|
||||
/** STATUS */
|
||||
|
||||
.metric-panel {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,.05);
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.metric-panel h2 {
|
||||
background-image: linear-gradient(to bottom,#f5f5f5 0,#e8e8e8 100%);
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.metric-panel .metrics {
|
||||
padding: 7px 10px;
|
||||
}
|
||||
|
||||
.metric-panel .metric {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin: 3px 0;
|
||||
}
|
||||
|
||||
.metric .label {
|
||||
flex-basis: 15em;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.metric .value {
|
||||
flex-basis: 15em;
|
||||
}
|
||||
|
||||
.metric .graph {
|
||||
flex-basis: 25em;
|
||||
}
|
||||
|
||||
/** MONITOR **/
|
||||
|
||||
#monitor {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#monitor th {
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
text-align: left;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
#monitor td {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 12px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
#monitor tr:hover {
|
||||
background-color: var(--selected-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
108
ui/src/registerServiceWorker.js
Normal file
108
ui/src/registerServiceWorker.js
Normal file
@@ -0,0 +1,108 @@
|
||||
// In production, we register a service worker to serve assets from local cache.
|
||||
|
||||
// This lets the app load faster on subsequent visits in production, and gives
|
||||
// it offline capabilities. However, it also means that developers (and users)
|
||||
// will only see deployed updates on the "N+1" visit to a page, since previously
|
||||
// cached resources are updated in the background.
|
||||
|
||||
// To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
|
||||
// This link also includes instructions on opting out of this behavior.
|
||||
|
||||
const isLocalhost = Boolean(
|
||||
window.location.hostname === 'localhost' ||
|
||||
// [::1] is the IPv6 localhost address.
|
||||
window.location.hostname === '[::1]' ||
|
||||
// 127.0.0.1/8 is considered localhost for IPv4.
|
||||
window.location.hostname.match(
|
||||
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
|
||||
)
|
||||
);
|
||||
|
||||
export default function register() {
|
||||
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
|
||||
// The URL constructor is available in all browsers that support SW.
|
||||
const publicUrl = new URL(process.env.PUBLIC_URL, window.location);
|
||||
if (publicUrl.origin !== window.location.origin) {
|
||||
// Our service worker won't work if PUBLIC_URL is on a different origin
|
||||
// from what our page is served on. This might happen if a CDN is used to
|
||||
// serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374
|
||||
return;
|
||||
}
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
|
||||
|
||||
if (!isLocalhost) {
|
||||
// Is not local host. Just register service worker
|
||||
registerValidSW(swUrl);
|
||||
} else {
|
||||
// This is running on localhost. Lets check if a service worker still exists or not.
|
||||
checkValidServiceWorker(swUrl);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function registerValidSW(swUrl) {
|
||||
navigator.serviceWorker
|
||||
.register(swUrl)
|
||||
.then(registration => {
|
||||
registration.onupdatefound = () => {
|
||||
const installingWorker = registration.installing;
|
||||
installingWorker.onstatechange = () => {
|
||||
if (installingWorker.state === 'installed') {
|
||||
if (navigator.serviceWorker.controller) {
|
||||
// At this point, the old content will have been purged and
|
||||
// the fresh content will have been added to the cache.
|
||||
// It's the perfect time to display a "New content is
|
||||
// available; please refresh." message in your web app.
|
||||
console.log('New content is available; please refresh.');
|
||||
} else {
|
||||
// At this point, everything has been precached.
|
||||
// It's the perfect time to display a
|
||||
// "Content is cached for offline use." message.
|
||||
console.log('Content is cached for offline use.');
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error during service worker registration:', error);
|
||||
});
|
||||
}
|
||||
|
||||
function checkValidServiceWorker(swUrl) {
|
||||
// Check if the service worker can be found. If it can't reload the page.
|
||||
fetch(swUrl)
|
||||
.then(response => {
|
||||
// Ensure service worker exists, and that we really are getting a JS file.
|
||||
if (
|
||||
response.status === 404 ||
|
||||
response.headers.get('content-type').indexOf('javascript') === -1
|
||||
) {
|
||||
// No service worker found. Probably a different app. Reload the page.
|
||||
navigator.serviceWorker.ready.then(registration => {
|
||||
registration.unregister().then(() => {
|
||||
window.location.reload();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// Service worker found. Proceed as normal.
|
||||
registerValidSW(swUrl);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
console.log(
|
||||
'No internet connection found. App is running in offline mode.'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function unregister() {
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.ready.then(registration => {
|
||||
registration.unregister();
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user