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:
MasseR committed 2026-10-12 16:06:16 +03:00
commit 6caa2acafb
8 files changed
+261 -28

No files matched your search

+6 -6
View File
@@ -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 = [
+5 -1
View File
@@ -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
+6
View File
@@ -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
+4 -3
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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
+64
View File
@@ -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\""
+2
View File
@@ -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