294 lines
7.7 KiB
Haskell
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)
|
|
}
|