1
0
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:
James Hillyerd
2018-06-02 12:44:15 -07:00
parent 8b5a05eb40
commit c5b5321be3
24 changed files with 3027 additions and 1 deletions

44
ui/src/Data/Message.elm Normal file
View 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

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;
}

View 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();
});
}
}