{-# LANGUAGE TypeOperators, DeriveGeneric, NamedFieldPuns, DataKinds, StandaloneDeriving, FlexibleContexts #-}
module OddJobs.Endpoints where
import OddJobs.Web as Web
import OddJobs.Job as Job
import OddJobs.Types
import GHC.Generics
import Servant
import Servant.API.Generic
import Servant.Server.Generic
import Servant.HTML.Lucid
import Lucid
import Lucid.Html5
import Lucid.Base
import Data.Text as T
import Network.Wai.Handler.Warp (run)
import Servant.Server.StaticFiles (serveDirectoryFileServer)
import UnliftIO hiding (Handler)
import Database.PostgreSQL.Simple as PGS
import Data.Pool as Pool
import Control.Monad.Reader
import Data.String.Conv (toS)
import Control.Monad.Except
import Data.Time as Time
import Data.Time.Format.Human (humanReadableTime')
import Data.Aeson as Aeson
import qualified Data.HashMap.Strict as HM
import GHC.Exts (toList)
import Data.Maybe (fromMaybe)
import Data.Text.Conversions (fromText, toText)
import Control.Applicative ((<|>))
import Data.Time.Convenience (timeSince, Unit(..), Direction(..))
import qualified OddJobs.Links as Links
import Data.List ((\\))
import qualified System.Log.FastLogger as FLogger
import qualified System.Log.FastLogger.Date as FLogger
import Control.Monad.Logger as MLogger
import qualified Data.ByteString.Lazy as BSL
tname :: TableName
tname = "jobs_aqgrqtaowi"
startApp :: IO ()
startApp = do
let connInfo = ConnectInfo
{ connectHost = "localhost"
, connectPort = fromIntegral 5432
, connectUser = "jobs_test"
, connectPassword = "jobs_test"
, connectDatabase = "jobs_test"
}
dbPool <- createPool
(PGS.connect connInfo)
(PGS.close)
1
(fromRational 10)
5
tcache <- FLogger.newTimeCache FLogger.simpleTimeFormat'
(tlogger, cleanup) <- FLogger.newTimedFastLogger tcache (FLogger.LogStdout FLogger.defaultBufSize)
let flogger = Job.defaultTimedLogger tlogger (Job.defaultLogStr (Job.defaultJobToText Job.defaultJobType))
jm = Job.defaultConfig flogger tname dbPool Job.UnlimitedConcurrentJobs (const $ pure ())
let nt :: ReaderT Job.Config IO a -> Servant.Handler a
nt action = (liftIO $ try $ runReaderT action jm) >>= \case
Left (e :: SomeException) -> Servant.Handler $ ExceptT $ pure $ Left $ err500 { errBody = toS $ show e }
Right a -> Servant.Handler $ ExceptT $ pure $ Right a
appProxy = (Proxy :: Proxy (ToServant Routes AsApi))
finally
(run 8080 $ genericServe (server dbPool))
(cleanup >> (Pool.destroyAllResources dbPool))
stopApp :: IO ()
stopApp = pure ()
server :: Pool Connection
-> Routes AsServer
server dbPool = Routes
{ rFilterResults = (\mFilter -> filterResults dbPool mFilter)
, rStaticAssets = serveDirectoryFileServer "assets"
}
filterResults :: Pool Connection
-> Maybe Filter
-> Handler (Html ())
filterResults dbPool mFilter = do
let filters = fromMaybe mempty mFilter
(jobs, runningCount) <- liftIO $ Pool.withResource dbPool $ \conn -> (,)
<$> (filterJobs conn tname filters)
<*> (countJobs conn tname filters{ filterStatuses = [Job.Locked] })
t <- liftIO getCurrentTime
pure $ pageLayout $ do
searchBar t filters
resultsPanel t filters jobs runningCount
pageNav :: Html ()
pageNav = do
div_ $ nav_ [ class_ "navbar navbar-default navigation-clean" ] $ div_ [ class_ "container" ] $ do
div_ [ class_ "navbar-header" ] $ do
a_ [ class_ "navbar-brand navbar-link", href_ "#", style_ "padding: 0px;" ] $ img_ [ src_ "/assets/odd-jobs-color-logo.png", title_ "Odd Jobs Logo" ]
button_ [ class_ "navbar-toggle collapsed", data_ "toggle" "collapse", data_ "target" "#navcol-1" ] $ do
span_ [ class_ "sr-only" ] $ "Toggle navigation"
span_ [ class_ "icon-bar" ] $ ""
span_ [ class_ "icon-bar" ] $ ""
span_ [ class_ "icon-bar" ] $ ""
pageLayout :: Html () -> Html ()
pageLayout inner = do
doctype_
html_ $ do
head_ $ do
meta_ [ charset_ "utf-8" ]
meta_ [ name_ "viewport", content_ "width=device-width, initial-scale=1.0" ]
title_ "haskell-pg-queue"
link_ [ rel_ "stylesheet", href_ "assets/bootstrap/css/bootstrap.min.css" ]
link_ [ rel_ "stylesheet", href_ "https://fonts.googleapis.com/css?family=Lato:100i,300,300i,400,700,900" ]
link_ [ rel_ "stylesheet", href_ "assets/css/logo-slider.css" ]
link_ [ rel_ "stylesheet", href_ "assets/css/Navigation-Clean1.css" ]
link_ [ rel_ "stylesheet", href_ "assets/css/styles.css" ]
body_ $ do
pageNav
div_ $ div_ [ class_ "container", style_ "/*background-color:#f2f2f2;*/" ] $ div_ [ class_ "row" ] $ div_ [ class_ "col-md-12" ] $ do
inner
script_ [ src_ "assets/js/jquery.min.js" ] $ ("" :: Text)
script_ [ src_ "assets/bootstrap/js/bootstrap.min.js" ] $ ("" :: Text)
script_ [ src_ "https://cdnjs.cloudflare.com/ajax/libs/slick-carousel/1.6.0/slick.js" ] $ ("" :: Text)
script_ [ src_ "assets/js/logo-slider.js" ] $ ("" :: Text)
searchBar :: UTCTime -> Filter -> Html ()
searchBar t filter@Filter{filterStatuses, filterCreatedAfter, filterCreatedBefore, filterUpdatedAfter, filterUpdatedBefore, filterJobTypes, filterRunAfter} = do
form_ [ style_ "padding-top: 2em;" ] $ do
div_ [ class_ "form-group" ] $ do
div_ [ class_ "search-container" ] $ do
ul_ [ class_ "list-inline search-bar" ] $ do
forM_ filterStatuses $ \s -> renderFilter "Status" (toText s) (Links.rFilterResults $ Just filter{filterStatuses = filterStatuses \\ [s]})
maybe mempty (\x -> renderFilter "Created after" (showText x) (Links.rFilterResults $ Just filter{filterCreatedAfter = Nothing})) filterCreatedAfter
maybe mempty (\x -> renderFilter "Created before" (showText x) (Links.rFilterResults $ Just filter{filterCreatedBefore = Nothing})) filterCreatedBefore
maybe mempty (\x -> renderFilter "Updated after" (showText x) (Links.rFilterResults $ Just filter{filterUpdatedAfter = Nothing})) filterUpdatedAfter
maybe mempty (\x -> renderFilter "Updated before" (showText x) (Links.rFilterResults $ Just filter{filterUpdatedBefore = Nothing})) filterUpdatedBefore
maybe mempty (\x -> renderFilter "Run after" (showText x) (Links.rFilterResults $ Just filter{filterRunAfter = Nothing})) filterRunAfter
forM_ filterJobTypes $ \x -> renderFilter "Job type" x (Links.rFilterResults $ Just filter{filterJobTypes = filterJobTypes \\ [x]})
button_ [ class_ "btn btn-default search-button", type_ "button" ] $ "Search"
ul_ [ class_ "list-inline" ] $ do
li_ $ span_ $ strong_ "Common searches:"
li_ $ a_ [ href_ (Links.rFilterResults $ Just mempty) ] $ "All jobs"
li_ $ a_ [ href_ (Links.rFilterResults $ Just $ filter{ filterStatuses = [Job.Locked] }) ] $ "Currently running"
li_ $ a_ [ href_ (Links.rFilterResults $ Just $ filter{ filterStatuses = [Job.Success] }) ] $ "Successful"
li_ $ a_ [ href_ (Links.rFilterResults $ Just $ filter{ filterStatuses = [Job.Failed] }) ] $ "Failed"
li_ $ a_ [ href_ (Links.rFilterResults $ Just $ filter{ filterRunAfter = Just t }) ] $ "Future"
li_ $ a_ [ href_ (Links.rFilterResults $ Just $ filter{ filterStatuses = [Job.Queued] }) ] $ "Queued"
li_ $ a_ [ href_ (Links.rFilterResults $ Just $ filter{ filterUpdatedAfter = Just $ timeSince t 10 Minutes Ago }) ] $ "Last 10 mins"
li_ $ a_ [ href_ (Links.rFilterResults $ Just $ filter{ filterCreatedAfter = Just $ timeSince t 10 Minutes Ago }) ] $ "Recently created"
where
renderFilter :: Text -> Text -> Text -> Html ()
renderFilter k v u = do
li_ [ class_ "search-filter" ] $ do
span_ [ class_ "filter-name" ] $ toHtml k
span_ [ class_ "filter-value" ] $ do
toHtml v
a_ [ href_ u, class_ "text-danger" ] $ i_ [ class_ "glyphicon glyphicon-remove" ] $ ""
timeDuration :: UTCTime -> UTCTime -> (Int, String)
timeDuration from to = (diff, str)
where
str = if diff <= 0
then "under 1s"
else (if d>0 then (show d) <> "d" else "") <>
(if m>0 then (show m) <> "m" else "") <>
(if s>0 then (show s) <> "s" else "")
diff = (abs $ round $ diffUTCTime from to)
(m', s) = diff `divMod` 60
(h', m) = m' `divMod` 60
(d, h) = h' `divMod` 24
showText :: (Show a) => a -> Text
showText a = toS $ show a
jobContent :: Value -> Value
jobContent v = case v of
Aeson.Object o -> case HM.lookup "contents" o of
Nothing -> v
Just c -> c
_ -> v
rowSuccess :: UTCTime -> Job -> Html ()
rowSuccess t job@Job{jobStatus, jobCreatedAt, jobUpdatedAt, jobPayload, jobAttempts, jobRunAt} = do
tr_ $ do
td_ [ class_ "job-type" ] $ case jobStatus of
Job.Success -> statusSuccess
Job.Failed -> statusFailed
Job.Queued -> if jobRunAt > t
then statusFuture
else statusWaiting
Job.Retry -> statusRetry
Job.Locked -> statusLocked
td_ $ toHtml $ Job.defaultJobType job
td_ $ div_ [ class_ "job-payload" ] $ payloadToHtml $ jobContent jobPayload
td_ "Text"
td_ $ case jobStatus of
Job.Success -> mempty
Job.Failed -> actionsFailed
Job.Queued -> if jobRunAt > t
then actionsFuture
else mempty
Job.Retry -> actionsRetry
Job.Locked -> mempty
where
actionsFailed = do
button_ [ class_ "btn btn-default", type_ "button" ] $ "Retry again"
actionsRetry = do
button_ [ class_ "btn btn-default", type_ "button" ] $ "Retry now"
actionsFuture = do
button_ [ class_ "btn btn-default", type_ "button" ] $ "Run now"
payloadToHtml :: Value -> Html ()
payloadToHtml v = case v of
Aeson.Object o -> do
toHtml ("{ " :: Text)
forM_ (HM.toList o) $ \(k, v) -> do
span_ [ class_ " key-value-pair " ] $ do
span_ [ class_ "key" ] $ toHtml $ k <> ":"
span_ [ class_ "value" ] $ payloadToHtml v
toHtml (" }" :: Text)
Aeson.Array a -> do
toHtml ("[" :: Text)
forM_ (toList a) $ \x -> do
payloadToHtml x
toHtml (", " :: Text)
toHtml ("]" :: Text)
Aeson.String t -> toHtml t
Aeson.Number n -> toHtml $ show n
Aeson.Bool b -> toHtml $ show b
Aeson.Null -> toHtml ("null" :: Text)
statusSuccess = do
span_ [ class_ "label label-success" ] $ "Success"
span_ [ class_ "job-run-time" ] $ do
let (d, s) = timeDuration jobCreatedAt jobUpdatedAt
abbr_ [ title_ (showText jobUpdatedAt) ] $ toHtml $ "Completed " <> humanReadableTime' t jobUpdatedAt <> ". "
abbr_ [ title_ (showText d <> " seconds")] $ toHtml $ "Took " <> s
statusFailed = do
span_ [ class_ "label label-danger" ] $ "Failed"
span_ [ class_ "job-run-time" ] $ do
abbr_ [ title_ (showText jobUpdatedAt) ] $ toHtml $ "Failed " <> humanReadableTime' t jobUpdatedAt <> " after " <> show jobAttempts <> " attempts"
statusFuture = do
span_ [ class_ "label" ] $ "Future"
span_ [ class_ "job-run-time" ] $ do
abbr_ [ title_ (showText jobRunAt) ] $ toHtml $ humanReadableTime' t jobRunAt
statusWaiting = do
span_ [ class_ "label label-warning" ] $ "Waiting"
statusRetry = do
span_ [ class_ "label label-info" ] $ toHtml $ "Retries (" <> show jobAttempts <> ")"
span_ [ class_ "job-run-time" ] $ do
abbr_ [ title_ (showText jobUpdatedAt) ] $ toHtml $ "Retried " <> humanReadableTime' t jobUpdatedAt <> ". "
abbr_ [ title_ (showText jobRunAt)] $ toHtml $ "Next retry in " <> humanReadableTime' t jobRunAt
statusLocked = do
span_ [ class_ "label label-warning" ] $ toHtml ("Locked" :: Text)
rowRetry :: Html ()
rowRetry = do
tr_ $ do
td_ [ class_ "job-type" ] $ do
span_ [ class_ "label label-info" ] $ "Retried (5)"
span_ [ class_ "job-run-time" ] $ "23 mins ago. Next retry in 90 min."
td_ "Queued Mail"
td_ $ div_ [ class_ "job-payload" ] $ do
span_ [ class_ "key-value-pair" ] $ do
span_ [ class_ "key" ] $ "client_id"
span_ [ class_ "value" ] $ "456"
span_ [ class_ "key-value-pair" ] $ do
span_ [ class_ "key" ] $ "user_id"
span_ [ class_ "value" ] $ "123"
span_ [ class_ "key-value-pair" ] $ do
span_ [ class_ "key" ] $ "args"
span_ [ class_ "value" ] $ do
"[\"flexi_payment_reminder\", 3432423,"
a_ [ href_ "#", class_ "json-ellipsis" ] $ "\8230"
"]"
td_ "Text"
td_ $ div_ [ class_ "btn-group" ] $ do
button_ [ class_ "btn btn-default", type_ "button" ] $ "Retry now"
button_ [ class_ "btn btn-default dropdown-toggle", data_ "toggle" "dropdown", ariaExpanded_ "false", type_ "button" ] $ span_ [ class_ "caret" ] $ ""
ul_ [ class_ "dropdown-menu", role_ "menu" ] $ do
li_ [ role_ "presentation" ] $ a_ [ href_ "#" ] $ "First Item"
li_ [ role_ "presentation" ] $ a_ [ href_ "#" ] $ "Second Item"
li_ [ role_ "presentation" ] $ a_ [ href_ "#" ] $ "Third Item"
rowFailed :: Html ()
rowFailed = do
tr_ $ do
td_ [ class_ "job-type" ] $ do
span_ [ class_ "label label-danger" ] $ "Failed"
span_ [ class_ "job-run-time" ] $ "23 mins ago. After 25 attempts."
td_ "Queued Mail"
td_ $ div_ [ class_ "job-payload" ] $ do
span_ [ class_ "key-value-pair" ] $ do
span_ [ class_ "key" ] $ "client_id"
span_ [ class_ "value" ] $ "456"
span_ [ class_ "key-value-pair" ] $ do
span_ [ class_ "key" ] $ "user_id"
span_ [ class_ "value" ] $ "123"
span_ [ class_ "key-value-pair" ] $ do
span_ [ class_ "key" ] $ "args"
span_ [ class_ "value" ] $ do
"[\"flexi_payment_reminder\", 3432423,"
a_ [ href_ "#", class_ "json-ellipsis" ] $ "\8230"
"]"
td_ "Text"
td_ $ button_ [ class_ "btn btn-default", type_ "button" ] $ "Retry again"
rowFuture :: Html ()
rowFuture = do
tr_ $ do
td_ [ class_ "job-type" ] $ do
span_ [ class_ "label label-default" ] $ "Future"
span_ [ class_ "job-run-time" ] $ "37 mins from now"
td_ "Queued Mail"
td_ $ div_ [ class_ "job-payload" ] $ do
span_ [ class_ "key-value-pair" ] $ do
span_ [ class_ "key" ] $ "client_id"
span_ [ class_ "value" ] $ "456"
span_ [ class_ "key-value-pair" ] $ do
span_ [ class_ "key" ] $ "user_id"
span_ [ class_ "value" ] $ "123"
span_ [ class_ "key-value-pair" ] $ do
span_ [ class_ "key" ] $ "args"
span_ [ class_ "value" ] $ do
"[\"flexi_payment_reminder\", 3432423,"
a_ [ href_ "#", class_ "json-ellipsis" ] $ "\8230"
"]"
td_ "Text"
td_ $ div_ [ class_ "btn-group" ] $ do
button_ [ class_ "btn btn-default", type_ "button" ] $ "Run now"
button_ [ class_ "btn btn-default dropdown-toggle", data_ "toggle" "dropdown", ariaExpanded_ "false", type_ "button" ] $ span_ [ class_ "caret" ] $ ""
ul_ [ class_ "dropdown-menu", role_ "menu" ] $ do
li_ [ role_ "presentation" ] $ a_ [ href_ "#" ] $ "First Item"
li_ [ role_ "presentation" ] $ a_ [ href_ "#" ] $ "Second Item"
li_ [ role_ "presentation" ] $ a_ [ href_ "#" ] $ "Third Item"
rowLocked :: Html ()
rowLocked = do
tr_ $ do
td_ [ class_ "job-type" ] $ do
span_ [ class_ "label label-warning" ] $ "Locked"
span_ [ class_ "job-run-time" ] $ "Since 2min by"
span_ [ class_ "job-runner-name" ] $ "hostname:3242"
td_ "Queued Mail"
td_ $ div_ [ class_ "job-payload" ] $ do
span_ [ class_ "key-value-pair" ] $ do
span_ [ class_ "key" ] $ "client_id"
span_ [ class_ "value" ] $ "456"
span_ [ class_ "key-value-pair" ] $ do
span_ [ class_ "key" ] $ "user_id"
span_ [ class_ "value" ] $ "123"
span_ [ class_ "key-value-pair" ] $ do
span_ [ class_ "key" ] $ "args"
span_ [ class_ "value" ] $ do
"[\"flexi_payment_reminder\", 3432423,"
a_ [ href_ "#", class_ "json-ellipsis" ] $ "\8230"
"]"
td_ "Text"
td_ $ button_ [ class_ "btn btn-default", type_ "button" ] $ "Unlock"
resultsPanel :: UTCTime -> Filter -> [Job] -> Int -> Html ()
resultsPanel t filter@Filter{filterPage} jobs runningCount = do
div_ [ class_ "panel panel-default" ] $ do
div_ [ class_ "panel-heading" ] $ h3_ [ class_ "panel-title" ] $ do
"Currently running "
span_ [ class_ "badge" ] $ toHtml (show runningCount)
div_ [ class_ "panel-body" ] $ div_ [ class_ "currently-running" ] $ div_ [ class_ "table-responsive" ] $ table_ [ class_ "table" ] $ do
thead_ $ tr_ $ do
th_ "Job status"
th_ "Job type"
th_ "Job payload"
th_ "Last error"
th_ "Actions"
tbody_ $ do
forM_ jobs $ \j -> case jobStatus j of
Job.Success -> rowSuccess t j
_ -> rowSuccess t j
div_ [ class_ "panel-footer" ] $ nav_ $ ul_ [ class_ "pager", style_ "margin:0px;" ] $ do
li_ [ class_ "previous" ] $ case filterPage of
Nothing -> a_ [ disabled_ "disabled" ] $ "Prev"
Just (l, 0) -> a_ [ disabled_ "disabled" ] $ "Prev"
Just (l, o) -> a_ [ href_ (Links.rFilterResults $ Just $ filter {filterPage = Just (l, max 0 $ o - l)}) ] $ "Prev"
li_ [ class_ "next" ] $ case filterPage of
Nothing -> a_ [ href_ (Links.rFilterResults $ Just $ filter {filterPage = Just (10, 10)}) ] $ "Next"
Just (l, o) -> a_ [ href_ (Links.rFilterResults $ Just $ filter {filterPage = Just (l, o + l)}) ] $ "Next"
ariaExpanded_ :: Text -> Attribute
ariaExpanded_ v = makeAttribute "aria-expanded" v