packages feed

hs-tango-2.0.0: app/WebAstor.hs

{-# LANGUAGE BlockArguments #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE EmptyDataDecls #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE ImportQualifiedPost #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeOperators #-}

module Main where

import Control.Applicative (pure)
import Control.Exception (handle)
import Control.Monad (Monad, forM_, (>>=))
import Control.Monad.IO.Class (liftIO)
import Data.Bool (not)
import Data.Either (Either (Left, Right))
import Data.Function (flip, ($), (.))
import Data.Functor (void, (<$>))
import Data.Int (Int)
import Data.List (sortOn)
import Data.List.NonEmpty qualified as NE
import Data.Maybe (Maybe (Just, Nothing), fromMaybe)
import Data.Monoid (mempty)
import Data.Semigroup ((<>))
import Data.Text (Text, breakOn, breakOnAll, drop, isPrefixOf, lines, pack, strip, unlines, words)
import Data.Text.Read (decimal)
import Data.Traversable (for, traverse)
import Data.Typeable (Typeable)
import Lucid (ToHtml (toHtml, toHtmlRaw))
import Lucid qualified as L
import Lucid.Base (renderBS)
import Lucid.Html5 (h1_)
import Network.HTTP.Media qualified as M
import Network.Wai.Handler.Warp (run)
import Servant (Get, Handler, Proxy (Proxy), QueryParam, (:>))
import Servant.API (Accept (..), JSON, MimeRender (..), Post, ReqBody, (:<|>) ((:<|>)))
import Servant.API.Generic (Generic)
import Servant.Server (Application, Server, serve)
import System.Environment (getArgs)
import System.IO (IO, putStrLn)
import Tango.Client (AttributeName (AttributeName), CommandData (CommandString), CommandName (CommandName), TangoException (TangoException), commandInOutGeneric, parseTangoUrl, readStringSpectrumAttribute, tangoValueRead, withDeviceProxy)
import Text.Show (show)
import Prelude (undefined)

-- | HTML type which uses lucid2 to output the bytes. lucid1 would have been simpler, but lucid2 is cleaner.
data HTML deriving stock (Typeable)

instance Accept HTML where
  contentTypes _ =
    "text" M.// "html" M./: ("charset", "utf-8")
      NE.:| ["text" M.// "html"]

instance (ToHtml a) => MimeRender HTML a where
  mimeRender _ = renderBS . toHtml

-- | A Tango device server description that is parsed from the Astor server output
data AstorServer = AstorServer
  { serverDevice :: Text,
    serverStatus :: Text,
    serverControlled :: Int,
    serverLevel :: Int,
    serverNbInstances :: Int
  }
  deriving (Generic)

-- | Message to display after an action (start server, stop server)
data Message = MessageError Text | MessageSuccess Text

-- | Main type holding information about what to output
data RefreshOutput = RefreshOutput
  { refreshOutputServers :: Either Text [AstorServer],
    refreshOutputHost :: Text,
    refreshOutputMessage :: Maybe Message
  }
  deriving (Generic)

viewServerStatus :: (Monad m) => Text -> L.HtmlT m ()
viewServerStatus "ON" = L.span_ [L.class_ "badge text-bg-success"] "ON"
viewServerStatus "MOVING" = L.span_ [L.class_ "spinner-border spinner-border-sm text-secondary"] mempty
viewServerStatus "FAULT" = L.span_ [L.class_ "badge text-bg-danger"] "FAULT"
viewServerStatus t = L.span_ [L.class_ "badge text-bg-secondary"] (L.toHtml t)

instance ToHtml RefreshOutput where
  toHtml (RefreshOutput servers host message) = viewHtmlSkeleton do
    L.form_ [L.action_ "servers"] do
      L.div_ [L.class_ "form-floating mb-3"] do
        L.input_
          [ L.type_ "text",
            L.class_ "form-control",
            L.id_ "host",
            L.placeholder_ "localhost:10000",
            L.name_ "host",
            L.value_ host
          ]
        L.label_ [L.for_ "host"] "Tango Host:Port"
      L.button_ [L.class_ "btn btn-primary mb-3", L.type_ "submit"] "Refresh"
    case servers of
      Left e ->
        L.div_ [L.class_ "alert alert-danger"] (L.toHtml ("Something went wrong: " <> e))
      Right serverList -> do
        case message of
          Just (MessageError e) -> L.div_ [L.class_ "alert alert-danger"] (L.toHtml ("Something went wrong: " <> e))
          Just (MessageSuccess s) -> L.div_ [L.class_ "alert alert-success"] (L.toHtml s)
          Nothing -> mempty
        L.table_ [L.class_ "table"] do
          L.thead_ do
            L.tr_ do
              L.th_ "Lvl"
              L.th_ "Device"
              L.th_ "Status"
              L.th_ "Actions"
          L.tbody_
            ( forM_ (sortOn serverLevel serverList) \(AstorServer {serverDevice, serverStatus, serverLevel}) ->
                L.tr_ do
                  L.td_ (L.toHtml (pack (show serverLevel)))
                  L.td_ (L.code_ (L.toHtml serverDevice))
                  L.td_ do
                    viewServerStatus serverStatus
                  L.td_ do
                    L.div_ [L.class_ "hstack gap-1"] do
                      L.a_
                        [ L.href_ ("servers?host=" <> host <> "&action=stop&device=" <> serverDevice),
                          L.class_ "btn btn-sm btn-primary rounded-circle"
                        ]
                        do
                          L.i_ [L.class_ "bi-stop"] mempty
                      L.a_
                        [ L.href_ ("servers?host=" <> host <> "&action=start&device=" <> serverDevice),
                          L.class_ "btn btn-sm btn-primary rounded-circle"
                        ]
                        do
                          L.i_ [L.class_ "bi-play"] mempty
            )
  toHtmlRaw = toHtml

type AstorAPI =
  "servers"
    :> QueryParam "host" Text
    :> QueryParam "action" Text
    :> QueryParam "device" Text
    :> Get '[HTML] RefreshOutput

parseServersOutput :: Text -> Either Text [AstorServer]
parseServersOutput l = for (lines l) \line -> case words line of
  [device, status, controlledStr, levelStr, nbInstancesStr] ->
    case (decimal controlledStr, decimal levelStr, decimal nbInstancesStr) of
      (Right (controlled, _), Right (level, _), Right (nbInstances, _)) ->
        Right (AstorServer {serverDevice = device, serverStatus = status, serverControlled = controlled, serverLevel = level, serverNbInstances = nbInstances})
      _ -> Left ("one of controlled, level, nbInstances is not a number: " <> line)
  _ -> Left ("expected whitespace-separated list of 5 values, got: " <> line)

data HostWithPort = HostWithPort Text Int

parseHostWithPort :: Text -> Maybe HostWithPort
parseHostWithPort hostAndPort =
  case breakOn ":" hostAndPort of
    (host, maybePort) ->
      if not (":" `isPrefixOf` maybePort)
        then Nothing
        else case decimal (drop 1 maybePort) of
          Right (port, _) -> Just (HostWithPort host port)
          _ -> Nothing

astorServer :: Server AstorAPI
astorServer = astorEndpointServers

astorEndpointServers :: Maybe Text -> Maybe Text -> Maybe Text -> Handler RefreshOutput
astorEndpointServers inputHost action devicename =
  case inputHost >>= parseHostWithPort of
    Nothing ->
      pure
        ( RefreshOutput
            { refreshOutputHost = "",
              refreshOutputServers = Right [],
              refreshOutputMessage = Nothing
            }
        )
    Just (HostWithPort inputHost' inputPort) -> do
      let inputHostAndPortStr = inputHost' <> ":" <> pack (show inputPort)
       in case parseTangoUrl ("tango://" <> inputHostAndPortStr <> "/tango/admin/" <> inputHost') of
            Left e ->
              pure
                ( RefreshOutput
                    { refreshOutputHost = inputHostAndPortStr,
                      refreshOutputServers = Left ("error parsing host: " <> e),
                      refreshOutputMessage = Nothing
                    }
                )
            Right deviceAddress -> liftIO
              $ handle
                ( \e@(TangoException _) ->
                    pure
                      ( RefreshOutput
                          { refreshOutputHost = inputHostAndPortStr,
                            refreshOutputServers = Left ("error in tango: " <> pack (show e)),
                            refreshOutputMessage = Nothing
                          }
                      )
                )
              $ withDeviceProxy deviceAddress \proxy -> do
                outputMessage <- case (action, devicename) of
                  (Just "stop", Just deviceName) -> do
                    void (commandInOutGeneric proxy (CommandName "DevStop") (CommandString deviceName))
                    pure (Just (MessageSuccess "stopping!"))
                  (Just "start", Just deviceName) -> do
                    void (commandInOutGeneric proxy (CommandName "DevStart") (CommandString deviceName))
                    pure (Just (MessageSuccess "starting!"))
                  _ -> pure Nothing

                servers <- readStringSpectrumAttribute proxy (AttributeName "Servers")
                case parseServersOutput (unlines (tangoValueRead servers)) of
                  Left e ->
                    pure
                      ( RefreshOutput
                          { refreshOutputHost = inputHostAndPortStr,
                            refreshOutputServers = Left ("error parsing servers output: " <> e),
                            refreshOutputMessage = outputMessage
                          }
                      )
                  Right serversParsed ->
                    pure
                      ( RefreshOutput
                          { refreshOutputHost = inputHostAndPortStr,
                            refreshOutputServers = Right serversParsed,
                            refreshOutputMessage = outputMessage
                          }
                      )

astorAPI :: Proxy AstorAPI
astorAPI = Proxy

app :: Application
app = serve astorAPI astorServer

viewHtmlSkeleton content = do
  L.doctypehtml_ do
    L.head_ do
      L.meta_ [L.charset_ "utf-8"]
      L.meta_ [L.name_ "viewport", L.content_ "width=device-width, initial-scale=1"]
      L.title_ "Astor on the Web"
      L.link_ [L.href_ "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css", L.rel_ "stylesheet"]
      L.link_ [L.href_ "https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css", L.rel_ "stylesheet"]
    L.body_ do
      L.div_ [L.class_ "container"] do
        L.header_ do
          L.h1_ "Astor on the Web"
        L.main_ do
          content
      -- Not sure if we need the bootstrap JS, and it must save some bandwidth, so leave it out maybe
      L.script_ [L.src_ "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"] ("" :: Text)

main :: IO ()
main = do
  putStrLn "listening on port 8081..."
  run 8081 app