Files
home-assistant-controller/src/HomeAssistant/Runtime/Graphing.hs
T
2026-09-25 11:54:36 +03:00

282 lines
7.3 KiB
Haskell

{-# LANGUAGE OverloadedStrings #-}
module HomeAssistant.Runtime.Graphing
( Range (..)
, parseRange
, rangeStart
, Series (..)
, GraphSpec (..)
, graphs
, lookupGraph
, parseNameParam
, buildGraphArgs
, readProcessBytes
, Graph(..)
, renderGraph
) where
import Control.Exception (evaluate)
import Control.Monad.IO.Class (liftIO, MonadIO)
import qualified Data.ByteString as BS
import Data.Text (Text)
import qualified Data.Text as T
import System.Exit (ExitCode (..))
import System.IO (hGetContents)
import System.Process (CreateProcess (..), StdStream (..), proc, waitForProcess, withCreateProcess)
import Servant.API (FromHttpApiData (..), ToHttpApiData (..))
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]]
instance ToHttpApiData Range where
toQueryParam = T.pack . rangeSuffix
instance FromHttpApiData Range where
parseQueryParam x = maybe (Left ("Could not parse '" <> x <> "' as Range")) Right $ parseRange x
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]
, gsIdentifier :: Graph
}
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"
}
]
, gsIdentifier = RateLimit
}
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"
}
]
, gsIdentifier = Traffic
}
memoryGraph :: GraphSpec
memoryGraph =
GraphSpec
"memory"
"Memory use"
[ area "live" "#99ccff" (average "rts_gc_current__a7a")
, line "peak" "#cc0000" (maximumCF "rts_gc_current__a7a")
]
Memory
graphs :: [GraphSpec]
graphs = [memoryGraph, ratelimitGraph, trafficGraph]
-- XXX: Proper lookup..?
lookupGraph :: [GraphSpec] -> Graph -> Maybe GraphSpec
lookupGraph specs graphMode = case [g | g <- specs, gsIdentifier g == graphMode] of
(g : _) -> Just g
[] -> Nothing
parseNameParam :: [GraphSpec] -> Text -> Maybe GraphSpec
parseNameParam specs t = rightToMaybe (parseQueryParam t) >>= lookupGraph specs
where
rightToMaybe = either (const Nothing) Just
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")
renderGraph :: MonadIO m => FilePath -> FilePath -> GraphSpec -> Range -> m (Either Text BS.ByteString)
renderGraph rrdPath rrdtool spec range = do
(code, out, err) <- liftIO (readProcessBytes rrdtool (buildGraphArgs rrdPath spec range))
case code of
ExitSuccess
| not (BS.null out) -> pure $ Right out
| otherwise -> pure $ Left "empty graph"
_ -> pure $ Left (T.pack $ take 200 err)
data Graph
= Memory
| Traffic
| RateLimit
deriving (Show, Eq, Enum, Bounded)
instance ToHttpApiData Graph where
toQueryParam Memory = "memory.png"
toQueryParam Traffic = "traffic.png"
toQueryParam RateLimit = "ratelimit.png"
instance FromHttpApiData Graph where
parseQueryParam "memory.png" = Right Memory
parseQueryParam "traffic.png" = Right Traffic
parseQueryParam "ratelimit.png" = Right RateLimit
parseQueryParam x = Left ("Can't parse '" <> x <> "' as Graph")