Merge pull request 'Statistics page' (#7) from statistics-page into main
Reviewed-on: #7
This commit was merged in pull request #7.
This commit is contained in:
commit
6caa2acafb
8 files changed
+261
-28
No files matched your search
+6
-6
@@ -2,9 +2,9 @@
|
|||||||
, cereal, cereal-conduit, cereal-text, conduit, containers
|
, cereal, cereal-conduit, cereal-text, conduit, containers
|
||||||
, data-default, directory, ekg-core, exceptions, filepath, hedgehog
|
, data-default, directory, ekg-core, exceptions, filepath, hedgehog
|
||||||
, hspec, hspec-hedgehog, http-media, http-types, katip, lens
|
, hspec, hspec-hedgehog, http-media, http-types, katip, lens
|
||||||
, lens-aeson, lib, libsystemd-journal, network, process, retry
|
, lens-aeson, lib, libsystemd-journal, lucid2, network, process
|
||||||
, servant, servant-server, stm, temporary, text, time, unliftio
|
, retry, servant, servant-server, stm, temporary, text, time
|
||||||
, unordered-containers, uuid, wai, warp, websockets
|
, unliftio, unordered-containers, uuid, wai, warp, websockets
|
||||||
}:
|
}:
|
||||||
mkDerivation {
|
mkDerivation {
|
||||||
pname = "home-assistant-controller";
|
pname = "home-assistant-controller";
|
||||||
@@ -16,9 +16,9 @@ mkDerivation {
|
|||||||
aeson annotated-exception async base bytestring cereal
|
aeson annotated-exception async base bytestring cereal
|
||||||
cereal-conduit cereal-text conduit containers data-default
|
cereal-conduit cereal-text conduit containers data-default
|
||||||
directory ekg-core exceptions filepath http-media http-types katip
|
directory ekg-core exceptions filepath http-media http-types katip
|
||||||
lens lens-aeson libsystemd-journal network process retry servant
|
lens lens-aeson libsystemd-journal lucid2 network process retry
|
||||||
servant-server stm text time unliftio unordered-containers uuid wai
|
servant servant-server stm text time unliftio unordered-containers
|
||||||
warp websockets
|
uuid wai warp websockets
|
||||||
];
|
];
|
||||||
executableHaskellDepends = [ base ];
|
executableHaskellDepends = [ base ];
|
||||||
testHaskellDepends = [
|
testHaskellDepends = [
|
||||||
|
|||||||
@@ -132,6 +132,8 @@ library
|
|||||||
, wai
|
, wai
|
||||||
, retry
|
, retry
|
||||||
, libsystemd-journal
|
, libsystemd-journal
|
||||||
|
, lucid2
|
||||||
|
, http-api-data
|
||||||
|
|
||||||
-- Directories containing source files.
|
-- Directories containing source files.
|
||||||
hs-source-dirs: src
|
hs-source-dirs: src
|
||||||
@@ -179,6 +181,7 @@ test-suite home-assistant-controller-test
|
|||||||
, ConnectionSpec
|
, ConnectionSpec
|
||||||
, FlagsSpec
|
, FlagsSpec
|
||||||
, GraphingSpec
|
, GraphingSpec
|
||||||
|
, HttpServerSpec
|
||||||
, KitchenSpec
|
, KitchenSpec
|
||||||
, LivingroomSpec
|
, LivingroomSpec
|
||||||
, MetricsSpec
|
, MetricsSpec
|
||||||
@@ -222,4 +225,5 @@ test-suite home-assistant-controller-test
|
|||||||
unordered-containers,
|
unordered-containers,
|
||||||
process,
|
process,
|
||||||
directory,
|
directory,
|
||||||
temporary
|
temporary,
|
||||||
|
lucid2
|
||||||
@@ -8,6 +8,7 @@ module AFRP
|
|||||||
, withEntities
|
, withEntities
|
||||||
, Event(..)
|
, Event(..)
|
||||||
, hold
|
, hold
|
||||||
|
, holdKnown
|
||||||
, events
|
, events
|
||||||
-- , switch
|
-- , switch
|
||||||
, preMapAccum
|
, preMapAccum
|
||||||
@@ -326,6 +327,11 @@ hold def = mapAccum step def id
|
|||||||
Tick -> prev
|
Tick -> prev
|
||||||
Event new -> new
|
Event new -> new
|
||||||
|
|
||||||
|
|
||||||
|
-- A variant of 'hold' which makes it explicit whether we have a previous value
|
||||||
|
holdKnown :: (Serialize a, Eq a) => Mealy m (Event a) (Maybe a)
|
||||||
|
holdKnown = arr (fmap Just) >>> hold Nothing
|
||||||
|
|
||||||
snapshot :: (Serialize a, Eq a) => a -> Mealy m (Event (), a) a
|
snapshot :: (Serialize a, Eq a) => a -> Mealy m (Event (), a) a
|
||||||
snapshot def = mapAccum step def id
|
snapshot def = mapAccum step def id
|
||||||
where
|
where
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import Data.Aeson.Types (ToJSON (..))
|
|||||||
import Data.Aeson.Lens (key, _String)
|
import Data.Aeson.Lens (key, _String)
|
||||||
import Control.Lens ((^?), to)
|
import Control.Lens ((^?), to)
|
||||||
import Data.Text (Text)
|
import Data.Text (Text)
|
||||||
|
import Control.Monad (join)
|
||||||
|
|
||||||
-- Let's see building some reasonable interface for utctime
|
-- Let's see building some reasonable interface for utctime
|
||||||
dow :: Day -> (WeekOfYear, Int)
|
dow :: Day -> (WeekOfYear, Int)
|
||||||
@@ -131,8 +132,8 @@ data HumidifierAction
|
|||||||
instance Serialize HumidifierAction
|
instance Serialize HumidifierAction
|
||||||
|
|
||||||
-- what is the humidifer doing right now?
|
-- what is the humidifer doing right now?
|
||||||
humidifierEntityAction :: HASS (Event Value) (Event (Maybe HumidifierAction))
|
humidifierOperatingState :: HASS (Event Value) (Event (Maybe HumidifierAction))
|
||||||
humidifierEntityAction = entityChangeEvent "humidifier.lapset_ilmankostutin"
|
humidifierOperatingState = entityChangeEvent "humidifier.lapset_ilmankostutin"
|
||||||
>>> arr (fmap entityAction)
|
>>> arr (fmap entityAction)
|
||||||
where
|
where
|
||||||
entityAction v = v ^? key "event" . key "variables" . key "trigger" . key "to_state" . key "attributes" . key "action" . _String . to humidifierAction
|
entityAction v = v ^? key "event" . key "variables" . key "trigger" . key "to_state" . key "attributes" . key "action" . _String . to humidifierAction
|
||||||
@@ -149,7 +150,7 @@ instance ToJSON Percentage where
|
|||||||
childrenAirFilterController :: HASS (Event Value) ()
|
childrenAirFilterController :: HASS (Event Value) ()
|
||||||
childrenAirFilterController = proc ev -> do
|
childrenAirFilterController = proc ev -> do
|
||||||
level <- pm25 >>> pm25Level >>> AFRP.changes >>> traceEvent -< ev
|
level <- pm25 >>> pm25Level >>> AFRP.changes >>> traceEvent -< ev
|
||||||
humidifier <- humidifierEntityAction >>> AFRP.hold Nothing -< ev
|
humidifier <- humidifierOperatingState >>> AFRP.holdKnown >>> arr join -< ev
|
||||||
case level of
|
case level of
|
||||||
Event PM25High ->
|
Event PM25High ->
|
||||||
AFRP.whenA (== Just Idle) (callService (fanOnTo (Percentage 80))) -< humidifier
|
AFRP.whenA (== Just Idle) (callService (fanOnTo (Percentage 80))) -< humidifier
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ module HomeAssistant.Runtime.Graphing
|
|||||||
, readProcessBytes
|
, readProcessBytes
|
||||||
, Graph(..)
|
, Graph(..)
|
||||||
, renderGraph
|
, renderGraph
|
||||||
|
, rangeSuffix
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import Control.Exception (evaluate)
|
import Control.Exception (evaluate)
|
||||||
|
|||||||
+173
-18
@@ -7,71 +7,225 @@ import Control.Monad.IO.Class (liftIO, MonadIO)
|
|||||||
import qualified Data.ByteString as BS
|
import qualified Data.ByteString as BS
|
||||||
import Data.Void (Void)
|
import Data.Void (Void)
|
||||||
import Network.Wai.Handler.Warp (run)
|
import Network.Wai.Handler.Warp (run)
|
||||||
import Servant.API ((:>), Capture, Get, QueryParam, (:-), MimeRender (..), Accept (..), NamedRoutes, JSON, ReqBody, Put)
|
import Servant.API ((:>), Capture, Get, QueryParam, (:-), MimeRender (..), Accept (..), NamedRoutes, JSON, ReqBody, Put, Post, FormUrlEncoded, Header)
|
||||||
import Data.ByteString (ByteString)
|
import Data.ByteString (ByteString)
|
||||||
import GHC.Generics (Generic)
|
import GHC.Generics (Generic)
|
||||||
import Servant (Handler, err404)
|
import Servant (Handler, err404, fieldLink, toUrlPiece)
|
||||||
import Control.Monad.Catch (throwM, catch, MonadCatch, MonadThrow, SomeException)
|
import Control.Monad.Catch (throwM, catch, MonadCatch, MonadThrow, SomeException)
|
||||||
import Servant.Server (err500, ServerT)
|
import Servant.Server (err500, ServerT)
|
||||||
import Data.Maybe (fromMaybe)
|
import Data.Maybe (fromMaybe)
|
||||||
import Data.Map.Strict (Map)
|
import Data.Map.Strict (Map)
|
||||||
import qualified Data.Set as S
|
import qualified Data.Set as S
|
||||||
import Data.Text (Text)
|
import Data.Text (Text)
|
||||||
import HomeAssistant.Runtime.Flags (Flags, allFlags, setEnabled, validNames)
|
import HomeAssistant.Runtime.Flags (Flags, allFlags, setEnabled, validNames, isEnabled)
|
||||||
import Control.Exception.Annotated (checkpoint, Annotation (Annotation))
|
import Control.Exception.Annotated (checkpoint, Annotation (Annotation))
|
||||||
import Servant.Server.Generic (genericServeT)
|
import Servant.Server.Generic (genericServeT)
|
||||||
import Network.Wai (Application)
|
import Network.Wai (Application)
|
||||||
import Network.HTTP.Media ((//))
|
import Network.HTTP.Media ((//))
|
||||||
import HomeAssistant.Runtime.Graphing (Graph, Range (..), lookupGraph, graphs, renderGraph)
|
import HomeAssistant.Runtime.Graphing (Graph (..), Range (..), lookupGraph, graphs, renderGraph, rangeSuffix)
|
||||||
import Katip (KatipContextT, Katip, runKatipContextT, LogEnv, KatipContext, ls, Severity (..), logFM)
|
import Katip (KatipContextT, Katip, runKatipContextT, LogEnv, KatipContext, ls, Severity (..), logFM, katipAddContext, sl)
|
||||||
|
import Data.Aeson (ToJSON (toJSON), FromJSON, object, (.=))
|
||||||
|
import Lucid (ToHtml, toHtml, renderBS, toHtmlRaw, HtmlT, Attributes)
|
||||||
|
import qualified Lucid.Html5 as H5
|
||||||
|
import qualified Data.Map.Strict as M
|
||||||
|
import Data.Foldable (for_)
|
||||||
|
import qualified Data.Text as T
|
||||||
|
import Web.FormUrlEncoded (FromForm, fromForm, parseUnique)
|
||||||
|
import Lucid.Base (makeAttributes)
|
||||||
|
import Data.Aeson.Key (fromText)
|
||||||
|
|
||||||
data ImagePng
|
data ImagePng
|
||||||
|
|
||||||
instance MimeRender ImagePng ByteString where
|
instance MimeRender ImagePng GraphPng where
|
||||||
mimeRender _ = BS.fromStrict
|
mimeRender _ (GraphPng bs) = BS.fromStrict bs
|
||||||
|
|
||||||
instance Accept ImagePng where
|
instance Accept ImagePng where
|
||||||
contentType _ = "image" // "png"
|
contentType _ = "image" // "png"
|
||||||
|
|
||||||
|
-- `servant-lucid` package would provide this and the instance for the original lucid(1) package
|
||||||
|
-- but we have lucid2
|
||||||
|
data HTML
|
||||||
|
|
||||||
|
instance Accept HTML where
|
||||||
|
contentType _ = "text" // "html"
|
||||||
|
|
||||||
|
instance ToHtml a => MimeRender HTML a where
|
||||||
|
mimeRender _ = renderBS . toHtml
|
||||||
|
|
||||||
|
newtype StatsPage = StatsPage (forall m. Monad m => HtmlT m ())
|
||||||
|
|
||||||
|
instance ToHtml StatsPage where
|
||||||
|
toHtml (StatsPage inner) = inner
|
||||||
|
toHtmlRaw = toHtml
|
||||||
|
|
||||||
|
page_ :: Monad m => HtmlT m () -> HtmlT m ()
|
||||||
|
page_ inner = H5.doctypehtml_ $ do
|
||||||
|
H5.head_ $ do
|
||||||
|
H5.script_
|
||||||
|
[ H5.src_ "https://cdn.jsdelivr.net/npm/htmx.org@2.0.11/dist/htmx.min.js"
|
||||||
|
, H5.integrity_ "sha384-2OatzQy1H+Zd/IIrjr1TcuDGqLXeHhbooAyJY1KdQMKnr4LZ22k31GBLdYKHmVjg"
|
||||||
|
, H5.crossorigin_ "anonymous"
|
||||||
|
]
|
||||||
|
("" :: Text)
|
||||||
|
H5.style_ css
|
||||||
|
H5.body_ inner
|
||||||
|
|
||||||
|
css :: Text
|
||||||
|
css = T.unlines
|
||||||
|
[ "body { font-family: system-ui, sans-serif; margin: 1rem auto; max-width: 40rem; padding: 0 1rem; }"
|
||||||
|
, "table { border-collapse: collapse; }"
|
||||||
|
, "th, td { padding: 0.4rem 0.75rem; border-bottom: 1px solid #ddd; text-align: left; }"
|
||||||
|
, "input[type=submit] { padding: 0.25rem 0.75rem; cursor: pointer; }"
|
||||||
|
, "img { max-width: 100%; }"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
type HxRequest = Header "HX-Request" Bool
|
||||||
|
|
||||||
data API mode = API
|
data API mode = API
|
||||||
{ getMetrics :: mode :- "metrics" :> Capture "graph" Graph :> QueryParam "range" Range :> Get '[ImagePng] ByteString
|
{ getMetrics :: mode :- "metrics" :> Capture "graph" Graph :> QueryParam "range" Range :> Get '[ImagePng] GraphPng
|
||||||
, getFlags :: mode :- "flags" :> Get '[JSON] (Map Text Bool)
|
, getStats :: mode :- "stats" :> HxRequest :> QueryParam "range" Range :> Get '[HTML] StatsPage
|
||||||
, putFlag :: mode :- "flags" :> Capture "name" Text :> ReqBody '[JSON] Bool :> Put '[JSON] (Map Text Bool)
|
, getFlags :: mode :- "flags" :> Get '[JSON, HTML] FlagsResponse
|
||||||
|
, putFlag :: mode :- "flags" :> Capture "name" Text :> ReqBody '[JSON, FormUrlEncoded] FlagValue :> Put '[HTML, JSON] FlagResponse
|
||||||
|
, postFlag :: mode :- "flags" :> Capture "name" Text :> ReqBody '[JSON, FormUrlEncoded] FlagValue :> Post '[JSON, HTML] FlagResponse
|
||||||
}
|
}
|
||||||
deriving Generic
|
deriving Generic
|
||||||
|
|
||||||
|
newtype GraphPng = GraphPng ByteString
|
||||||
|
|
||||||
getFlagsHandler :: Flags -> LoggingHandler (Map Text Bool)
|
newtype FlagValue = FlagValue Bool
|
||||||
getFlagsHandler = allFlags
|
deriving FromJSON via Bool
|
||||||
|
|
||||||
putFlagHandler :: Flags -> Text -> Bool -> LoggingHandler (Map Text Bool)
|
instance FromForm FlagValue where
|
||||||
putFlagHandler flags name val
|
fromForm f = FlagValue <$> parseUnique "flag-value" f
|
||||||
|
|
||||||
|
newtype FlagsResponse = FlagsResponse (Map Text Bool)
|
||||||
|
deriving (ToJSON) via (Map Text Bool)
|
||||||
|
|
||||||
|
data FlagResponse = FlagResponse Text Bool
|
||||||
|
|
||||||
|
instance ToHtml FlagResponse where
|
||||||
|
toHtml (FlagResponse name value) = flagRow name value
|
||||||
|
toHtmlRaw = toHtml
|
||||||
|
|
||||||
|
instance ToJSON FlagResponse where
|
||||||
|
toJSON (FlagResponse k v) = object [fromText k .= v]
|
||||||
|
|
||||||
|
flagsPage :: Monad m => FlagsResponse -> HtmlT m ()
|
||||||
|
flagsPage (FlagsResponse f) = page_ $
|
||||||
|
H5.table_ $ do
|
||||||
|
H5.tr_ $ do
|
||||||
|
H5.th_ "Name"
|
||||||
|
H5.th_ "Value"
|
||||||
|
H5.th_ ""
|
||||||
|
for_ (M.toList f) $ uncurry flagRow
|
||||||
|
|
||||||
|
flagRow :: Monad m => Text -> Bool -> HtmlT m ()
|
||||||
|
flagRow name value = H5.tr_ [H5.id_ (rowId name)] $ do
|
||||||
|
H5.td_ (toHtml name)
|
||||||
|
H5.td_ (toHtml (if value then "on" else "off" :: Text))
|
||||||
|
H5.td_ (toggle name value)
|
||||||
|
where
|
||||||
|
toggle :: Monad m => Text -> Bool -> HtmlT m ()
|
||||||
|
toggle key current = H5.form_ [H5.method_ "post", H5.action_ (postFlagsLink key)] $ do
|
||||||
|
H5.input_ [H5.name_ "flag-value", H5.type_ "hidden", H5.value_ (T.pack (show (not current)))]
|
||||||
|
H5.input_
|
||||||
|
[ H5.type_ "submit"
|
||||||
|
, H5.value_ (if current then "Disable" else "Enable")
|
||||||
|
, hxTrigger "click"
|
||||||
|
, hxTarget ("#" <> rowId key)
|
||||||
|
, hxPut (postFlagsLink key)
|
||||||
|
, hxSwap "outerHTML"
|
||||||
|
]
|
||||||
|
postFlagsLink k = "/" <> toUrlPiece (fieldLink putFlag k)
|
||||||
|
|
||||||
|
rowId :: Text -> Text
|
||||||
|
rowId = ("status-" <>)
|
||||||
|
|
||||||
|
hxTrigger :: Text -> Attributes
|
||||||
|
hxTrigger = makeAttributes "hx-trigger"
|
||||||
|
|
||||||
|
hxTarget :: Text -> Attributes
|
||||||
|
hxTarget = makeAttributes "hx-target"
|
||||||
|
|
||||||
|
hxPut :: Text -> Attributes
|
||||||
|
hxPut = makeAttributes "hx-put"
|
||||||
|
|
||||||
|
hxGet :: Text -> Attributes
|
||||||
|
hxGet = makeAttributes "hx-get"
|
||||||
|
|
||||||
|
hxPushUrl :: Text -> Attributes
|
||||||
|
hxPushUrl = makeAttributes "hx-push-url"
|
||||||
|
|
||||||
|
hxSwap :: Text -> Attributes
|
||||||
|
hxSwap = makeAttributes "hx-swap"
|
||||||
|
|
||||||
|
instance ToHtml FlagsResponse where
|
||||||
|
toHtml = flagsPage
|
||||||
|
toHtmlRaw = toHtml
|
||||||
|
|
||||||
|
getFlagsHandler :: Flags -> LoggingHandler FlagsResponse
|
||||||
|
getFlagsHandler = fmap FlagsResponse . allFlags
|
||||||
|
|
||||||
|
putFlagHandler :: Flags -> Text -> FlagValue -> LoggingHandler FlagResponse
|
||||||
|
putFlagHandler flags name (FlagValue val)
|
||||||
| name `S.notMember` validNames flags = throwM err404
|
| name `S.notMember` validNames flags = throwM err404
|
||||||
| otherwise = do
|
| otherwise = checkpoint (Annotation (name, val)) $ katipAddContext (sl "flag" (name, val)) $ do
|
||||||
setEnabled flags name val `catch` \e -> do
|
setEnabled flags name val `catch` \e -> do
|
||||||
logFM ErrorS (ls ("failed to persist handler flag: " <> show (e :: SomeException)))
|
logFM ErrorS (ls ("failed to persist handler flag: " <> show (e :: SomeException)))
|
||||||
throwM err500
|
throwM err500
|
||||||
allFlags flags
|
FlagResponse name <$> isEnabled flags name
|
||||||
|
|
||||||
|
|
||||||
getMetricsHandler :: FilePath -> FilePath -> Graph -> Maybe Range -> LoggingHandler ByteString
|
getMetricsHandler :: FilePath -> FilePath -> Graph -> Maybe Range -> LoggingHandler GraphPng
|
||||||
getMetricsHandler rrdPath rrdtool graphMode mRange = checkpoint (Annotation (graphMode, mRange)) $ do
|
getMetricsHandler rrdPath rrdtool graphMode mRange = checkpoint (Annotation (graphMode, mRange)) $ do
|
||||||
case lookupGraph graphs graphMode of
|
case lookupGraph graphs graphMode of
|
||||||
Nothing -> throwM err404
|
Nothing -> throwM err404
|
||||||
Just spec -> do
|
Just spec -> do
|
||||||
let range = fromMaybe OneDay mRange
|
let range = fromMaybe OneDay mRange
|
||||||
g <- renderGraph rrdPath rrdtool spec range
|
g <- renderGraph rrdPath rrdtool spec range
|
||||||
either (\e -> logFM ErrorS (ls e) >> throwM err500) pure g
|
either (\e -> logFM ErrorS (ls e) >> throwM err500) (pure . GraphPng) g
|
||||||
|
|
||||||
-- server :: FilePath -> FilePath -> API AsServer
|
-- server :: FilePath -> FilePath -> API AsServer
|
||||||
server :: FilePath -> FilePath -> Flags -> ServerT (NamedRoutes API) LoggingHandler
|
server :: FilePath -> FilePath -> Flags -> ServerT (NamedRoutes API) LoggingHandler
|
||||||
server rrdPath rrdtool flags = API
|
server rrdPath rrdtool flags = API
|
||||||
{ getMetrics = getMetricsHandler rrdPath rrdtool
|
{ getMetrics = getMetricsHandler rrdPath rrdtool
|
||||||
|
, getStats = getStatsHandler
|
||||||
, getFlags = getFlagsHandler flags
|
, getFlags = getFlagsHandler flags
|
||||||
, putFlag = putFlagHandler flags
|
, putFlag = putFlagHandler flags
|
||||||
|
, postFlag = putFlagHandler flags -- This exists only for html forms
|
||||||
}
|
}
|
||||||
|
|
||||||
|
statsPage :: Maybe Bool -> Maybe Range -> StatsPage
|
||||||
|
statsPage hx r = StatsPage $
|
||||||
|
if hx == Just True
|
||||||
|
then statsContent r
|
||||||
|
else page_ (statsContent r)
|
||||||
|
|
||||||
|
statsContent :: Monad m => Maybe Range -> HtmlT m ()
|
||||||
|
statsContent r = H5.div_ [H5.id_ "stats"] $ do
|
||||||
|
H5.ul_ $ for_ [minBound..maxBound] $ \range ->
|
||||||
|
H5.li_ $ H5.a_ (link range) $ toHtml $ rangeSuffix range
|
||||||
|
H5.h2_ "Traffic"
|
||||||
|
H5.img_ [H5.src_ (src Traffic)]
|
||||||
|
H5.h2_ "Memory"
|
||||||
|
H5.img_ [H5.src_ (src Memory)]
|
||||||
|
H5.h2_ "Ratelimit"
|
||||||
|
H5.img_ [H5.src_ (src RateLimit)]
|
||||||
|
where
|
||||||
|
src m = "/" <> toUrlPiece (fieldLink getMetrics m r)
|
||||||
|
link range =
|
||||||
|
let url = "/" <> toUrlPiece (fieldLink getStats (Just range))
|
||||||
|
in [ H5.href_ url
|
||||||
|
, hxGet url
|
||||||
|
, hxTarget "#stats"
|
||||||
|
, hxSwap "outerHTML"
|
||||||
|
, hxPushUrl "true"
|
||||||
|
]
|
||||||
|
|
||||||
|
getStatsHandler :: Maybe Bool -> Maybe Range -> LoggingHandler StatsPage
|
||||||
|
getStatsHandler hx r = pure (statsPage hx r)
|
||||||
|
|
||||||
servantApp :: LogEnv -> FilePath -> FilePath -> Flags -> Application
|
servantApp :: LogEnv -> FilePath -> FilePath -> Flags -> Application
|
||||||
servantApp le rrdPath rrdtool flags = genericServeT (toHandler le) (server rrdPath rrdtool flags)
|
servantApp le rrdPath rrdtool flags = genericServeT (toHandler le) (server rrdPath rrdtool flags)
|
||||||
|
|
||||||
@@ -85,3 +239,4 @@ toHandler :: LogEnv -> (forall a. LoggingHandler a -> Handler a)
|
|||||||
toHandler le (LoggingHandler k) = runKatipContextT le () "http" k
|
toHandler le (LoggingHandler k) = runKatipContextT le () "http" k
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
|
|
||||||
|
module HttpServerSpec (spec) where
|
||||||
|
|
||||||
|
import qualified Data.Map.Strict as M
|
||||||
|
import Data.Text (Text)
|
||||||
|
import qualified Data.Text as T
|
||||||
|
import Data.Text.Lazy.Encoding (decodeUtf8)
|
||||||
|
import qualified Data.Text.Lazy as TL
|
||||||
|
import Lucid (Html, renderBS, toHtml)
|
||||||
|
import Test.Hspec
|
||||||
|
|
||||||
|
import HomeAssistant.Runtime.Graphing (Range (..))
|
||||||
|
import HttpServer
|
||||||
|
|
||||||
|
render :: Html () -> String
|
||||||
|
render = TL.unpack . decodeUtf8 . renderBS
|
||||||
|
|
||||||
|
spec :: Spec
|
||||||
|
spec = describe "HttpServer" $ do
|
||||||
|
describe "flagRow" $ do
|
||||||
|
it "renders the whole row so htmx can outerHTML-swap it" $
|
||||||
|
render (flagRow "tea" True) `shouldContain` "<tr id=\"status-tea\">"
|
||||||
|
|
||||||
|
it "requests an outerHTML swap (htmx casing)" $
|
||||||
|
render (flagRow "tea" True) `shouldContain` "hx-swap=\"outerHTML\""
|
||||||
|
|
||||||
|
it "displays on with a Disable button when enabled" $ do
|
||||||
|
let html = render (flagRow "tea" True)
|
||||||
|
html `shouldContain` "<td>on</td>"
|
||||||
|
html `shouldContain` "value=\"Disable\""
|
||||||
|
|
||||||
|
it "displays off with an Enable button when disabled" $ do
|
||||||
|
let html = render (flagRow "tea" False)
|
||||||
|
html `shouldContain` "<td>off</td>"
|
||||||
|
html `shouldContain` "value=\"Enable\""
|
||||||
|
|
||||||
|
describe "flagsPage" $ do
|
||||||
|
let page = flagsPage (FlagsResponse (M.fromList [("tea" :: Text, True)]))
|
||||||
|
it "uses the shared page shell with a style block" $
|
||||||
|
render page `shouldContain` "<style"
|
||||||
|
|
||||||
|
it "has a header row" $ do
|
||||||
|
let html = render page
|
||||||
|
html `shouldContain` "<th>Name</th>"
|
||||||
|
html `shouldContain` "<th>Value</th>"
|
||||||
|
|
||||||
|
it "carries each row id exactly once" $
|
||||||
|
T.count "id=\"status-tea\"" (T.pack (render page)) `shouldBe` 1
|
||||||
|
|
||||||
|
describe "statsPage" $ do
|
||||||
|
it "uses the shared page shell with a style block (full page)" $
|
||||||
|
render (toHtml (statsPage Nothing Nothing)) `shouldContain` "<style"
|
||||||
|
|
||||||
|
it "serves a bare fragment for htmx requests" $ do
|
||||||
|
let html = render (toHtml (statsPage (Just True) (Just OneDay)))
|
||||||
|
html `shouldNotContain` "DOCTYPE"
|
||||||
|
html `shouldContain` "<div id=\"stats\">"
|
||||||
|
|
||||||
|
it "range links swap the stats fragment in place" $ do
|
||||||
|
let html = render (toHtml (statsPage Nothing (Just OneDay)))
|
||||||
|
html `shouldContain` "hx-get=\"/stats?range=24h\""
|
||||||
|
html `shouldContain` "hx-target=\"#stats\""
|
||||||
|
html `shouldContain` "hx-push-url=\"true\""
|
||||||
@@ -8,6 +8,7 @@ import qualified BusSpec
|
|||||||
import qualified ConnectionSpec
|
import qualified ConnectionSpec
|
||||||
import qualified FlagsSpec
|
import qualified FlagsSpec
|
||||||
import qualified GraphingSpec
|
import qualified GraphingSpec
|
||||||
|
import qualified HttpServerSpec
|
||||||
import qualified KitchenSpec
|
import qualified KitchenSpec
|
||||||
import qualified LivingroomSpec
|
import qualified LivingroomSpec
|
||||||
import qualified MetricsSpec
|
import qualified MetricsSpec
|
||||||
@@ -23,6 +24,7 @@ main = hspec $ do
|
|||||||
ConnectionSpec.spec
|
ConnectionSpec.spec
|
||||||
FlagsSpec.spec
|
FlagsSpec.spec
|
||||||
GraphingSpec.spec
|
GraphingSpec.spec
|
||||||
|
HttpServerSpec.spec
|
||||||
KitchenSpec.spec
|
KitchenSpec.spec
|
||||||
LivingroomSpec.spec
|
LivingroomSpec.spec
|
||||||
MetricsSpec.spec
|
MetricsSpec.spec
|
||||||
|
|||||||
Reference in new issue
Block a user