Files
home-assistant-controller/src/HomeAssistant/Runtime/Graphing.hs
T
2026-09-16 14:52:39 +03:00

294 lines
7.7 KiB
Haskell

{-# LANGUAGE OverloadedStrings #-}
module HomeAssistant.Runtime.Graphing
( Range (..)
, parseRange
, rangeStart
, Series (..)
, GraphSpec (..)
, graphs
, lookupGraph
, parseNameParam
, buildGraphArgs
, readProcessBytes
, graphAction
) where
import Control.Exception (evaluate)
import Control.Monad (forever)
import Control.Monad.IO.Class (liftIO, MonadIO)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Lazy as BL
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Lazy as TL
import Data.Void (Void)
import Network.HTTP.Types.Status (Status, status400, status404, status500)
import Network.Wai.Handler.Warp (defaultSettings, setHost, setPort)
import System.Exit (ExitCode (..))
import System.IO (hGetContents)
import System.Process (CreateProcess (..), StdStream (..), proc, waitForProcess, withCreateProcess)
import Web.Scotty
( ActionM
, ScottyM
, defaultOptions
, get
, pathParam
, queryParamMaybe
, raw
, scottyOpts
, setHeader
, settings
, status
, text
)
data Range = TenMinutes | OneHour | OneDay | OneWeek | OneMonth
deriving (Eq, Show, Bounded, Enum)
rangeSuffix :: Range -> String
rangeSuffix TenMinutes = "10m"
rangeSuffix OneHour = "1h"
rangeSuffix OneDay = "24h"
rangeSuffix OneWeek = "7d"
rangeSuffix OneMonth = "30d"
parseRange :: Text -> Maybe Range
parseRange t = lookup t [(T.pack (rangeSuffix r), r) | r <- [minBound .. maxBound]]
rangeStart :: Range -> String
rangeStart r = "end-" ++ rangeSuffix r
data CF = Average | Minimum | Maximum | Last
deriving (Eq, Show)
data Source = Source
{ srcDs :: String
, srcCF :: CF
}
deriving (Eq, Show)
data Transform
= Identity
| Multiply Double
deriving (Eq, Show)
data Series = Series
{ serSource :: Source
, serTransform :: Transform
, serAlias :: String
, serColor :: String
, serLabel :: String
}
deriving (Eq, Show)
data Plot
= Line Int Series
| Area Series
deriving (Eq, Show)
data GraphSpec = GraphSpec
{ gsName :: Text
, gsTitle :: String
, gsPlots :: [Plot]
}
deriving (Eq, Show)
average :: String -> Source
average ds = Source ds Average
maximumCF :: String -> Source
maximumCF ds = Source ds Maximum
line :: String -> String -> Source -> Plot
line alias color source =
Line 2 Series
{ serSource = source
, serTransform = Identity
, serAlias = alias
, serColor = color
, serLabel = alias
}
area :: String -> String -> Source -> Plot
area alias color source =
Area Series
{ serSource = source
, serTransform = Identity
, serAlias = alias
, serColor = color
, serLabel = alias
}
ratelimitGraph :: GraphSpec
ratelimitGraph =
GraphSpec
{ gsName = "ratelimit"
, gsTitle = "Rate limiting"
, gsPlots =
[ Line 2 Series
{ serSource = Source "ratelimit_retry" Average
, serTransform = Multiply 60
, serAlias = "retry"
, serColor = "#0066cc"
, serLabel = "Requests retried/min"
}
, Line 2 Series
{ serSource = Source "ratelimit_exhausted" Average
, serTransform = Multiply 60
, serAlias = "exhaust"
, serColor = "#cc0000"
, serLabel = "Requests exhausted/min"
}
]
}
trafficGraph :: GraphSpec
trafficGraph =
GraphSpec
{ gsName = "traffic"
, gsTitle = "Hass triggers in / services out"
, gsPlots =
[ Line 2 Series
{ serSource = Source "hass_trigger_in" Average
, serTransform = Multiply 60
, serAlias = "in"
, serColor = "#0066cc"
, serLabel = "Triggers in/min"
}
, Line 2 Series
{ serSource = Source "hass_service_out" Average
, serTransform = Multiply 60
, serAlias = "out"
, serColor = "#cc0000"
, serLabel = "Service calls out/min"
}
]
}
memoryGraph :: GraphSpec
memoryGraph =
GraphSpec
"memory"
"Memory use"
[ area "live" "#99ccff" (average "rts_gc_current__a7a")
, line "peak" "#cc0000" (maximumCF "rts_gc_current__a7a")
]
graphs :: [GraphSpec]
graphs = [memoryGraph, ratelimitGraph, trafficGraph]
lookupGraph :: [GraphSpec] -> Text -> Maybe GraphSpec
lookupGraph specs name = case [g | g <- specs, gsName g == name] of
(g : _) -> Just g
[] -> Nothing
parseNameParam :: [GraphSpec] -> Text -> Maybe GraphSpec
parseNameParam specs t = T.stripSuffix ".png" t >>= lookupGraph specs
cfName :: CF -> String
cfName = \case
Average -> "AVERAGE"
Minimum -> "MIN"
Maximum -> "MAX"
Last -> "LAST"
defArgs :: FilePath -> Series -> [String]
defArgs rrdPath series =
[ "DEF:" ++ rawF ++ "=" ++ rrdPath ++ ":"
++ srcDs source ++ ":" ++ cfName (srcCF source)
]
++ transformArgs
where
source = serSource series
alias = serAlias series
rawF = alias ++ "_raw"
transformArgs = case serTransform series of
Identity ->
[ "CDEF:" ++ alias ++ "=" ++ rawF ]
Multiply factor ->
[ "CDEF:" ++ alias ++ "=" ++ rawF
++ "," ++ show factor ++ ",*"
]
plotArg :: Plot -> String
plotArg = \case
Line width series ->
"LINE" ++ show width ++ ":" ++ serAlias series
++ serColor series ++ ":" ++ serLabel series
Area series ->
"AREA:" ++ serAlias series
++ serColor series ++ ":" ++ serLabel series
plotSeries :: Plot -> Series
plotSeries = \case
Line _ series -> series
Area series -> series
buildGraphArgs :: FilePath -> GraphSpec -> Range -> [String]
buildGraphArgs rrdPath spec range =
[ "graph"
, "-"
, "--start", rangeStart range
, "--title", gsTitle spec
, "--width", "800"
, "--height", "250"
, "--imgformat", "PNG"
]
++ concatMap (defArgs rrdPath . plotSeries) (gsPlots spec)
++ map plotArg (gsPlots spec)
readProcessBytes :: FilePath -> [String] -> IO (ExitCode, BS.ByteString, String)
readProcessBytes cmd args =
withCreateProcess (proc cmd args) { std_out = CreatePipe, std_err = CreatePipe } $
\_ mout merr ph ->
case (mout, merr) of
(Just hout, Just herr) -> do
out <- BS.hGetContents hout
err <- hGetContents herr
code <- waitForProcess ph
_ <- evaluate (length err)
pure (code, out, err)
_ -> pure (ExitFailure 1, BS.empty, "failed to create pipes")
respond :: Status -> TL.Text -> ActionM ()
respond st body = status st >> text body
serve :: FilePath -> FilePath -> GraphSpec -> Range -> ActionM ()
serve rrdPath rrdtool spec range = do
(code, out, err) <- liftIO (readProcessBytes rrdtool (buildGraphArgs rrdPath spec range))
case code of
ExitSuccess
| not (BS.null out) -> do
setHeader "Content-Type" "image/png"
raw (BL.fromStrict out)
| otherwise -> respond status500 "empty graph"
_ -> respond status500 (TL.pack (take 200 err))
handler :: FilePath -> FilePath -> ActionM ()
handler rrdPath rrdtool = do
name <- pathParam "name"
case parseNameParam graphs name of
Nothing -> respond status404 "unknown graph"
Just spec -> do
rangeArg <- queryParamMaybe "range"
case rangeArg of
Nothing -> serve rrdPath rrdtool spec OneDay
Just t -> case parseRange t of
Nothing -> respond status400 "invalid range"
Just range -> serve rrdPath rrdtool spec range
app :: FilePath -> FilePath -> ScottyM ()
app rrdPath rrdtool = get "/metrics/:name" (handler rrdPath rrdtool)
graphAction :: MonadIO m => FilePath -> FilePath -> Int -> m Void
graphAction rrdPath rrdtool port = liftIO $ forever $ scottyOpts opts (app rrdPath rrdtool)
where
opts = defaultOptions
{ settings = setHost "127.0.0.1" (setPort port defaultSettings)
}