diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bb6ab78 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.psci_modules +.stack-work +bower_components +output +result diff --git a/backend/Setup.hs b/backend/Setup.hs new file mode 100644 index 0000000..9a994af --- /dev/null +++ b/backend/Setup.hs @@ -0,0 +1,2 @@ +import Distribution.Simple +main = defaultMain diff --git a/backend/default.nix b/backend/default.nix new file mode 100644 index 0000000..78eca1b --- /dev/null +++ b/backend/default.nix @@ -0,0 +1,51 @@ +{ pkgs ? import {}}: +let + haskellPackages = pkgs.haskell.packages.ghc822; + + # Haskell Ide Engine + # Pass pkgs to the function since + # hie it must use the same ghc + hies = import (pkgs.fetchFromGitHub { + owner = "domenkozar"; + repo = "hie-nix"; + rev = "8f04568aa8c3215f543250eb7a1acfa0cf2d24ed"; + sha256 = "06ygnywfnp6da0mcy4hq0xcvaaap1w3di2midv1w9b9miam8hdrn"; + }) { + inherit pkgs; + }; + + # hfmt does not pass its own unit tests right now :( + # I'll fix and update this bit later + hfmt = haskellPackages.hfmt.overrideAttrs ( + oldAttrs: { + doCheck = false; + } + ); + + devInputs = [ + hies.hie82 + hfmt + ]; +in + with pkgs; + pkgs.haskell.lib.buildStackProject { + name = "nix-deps"; + src = lib.sourceFilesBySuffices ./. [ + ".hs" + "grafanix.cabal" + "stack.yaml" + ]; + configurePhase = '' + export STACK_ROOT=$NIX_BUILD_TOP/.stack + for pkg in ''${pkgsHostHost[@]} ''${pkgsHostBuild[@]} ''${pkgsHostTarget[@]} + do + [ -d "$pkg/lib" ] && \ + export STACK_IN_NIX_EXTRA_ARGS+=" --extra-lib-dirs=$pkg/lib" + [ -d "$pkg/include" ] && \ + export STACK_IN_NIX_EXTRA_ARGS+=" --extra-include-dirs=$pkg/include" + done + true + ''; + buildInputs = [ re2 zlib ] ++ + lib.optionals lib.inNixShell devInputs; + } diff --git a/backend/grafanix.cabal b/backend/grafanix.cabal new file mode 100644 index 0000000..6b5fe0e --- /dev/null +++ b/backend/grafanix.cabal @@ -0,0 +1,45 @@ +name: grafanix +version: 0.1.0.0 +author: Oleh Stolyar +maintainer: stolyar.oleh@gmail.com +copyright: 2018 Oleh Stolyar +category: Web +build-type: Simple +cabal-version: >=1.10 + +executable main + hs-source-dirs: src + main-is: Main.hs + default-language: Haskell2010 + other-modules: Config + , Nix + , Parser + , Types + build-depends: base >= 4.7 && < 5 + , aeson + , attoparsec + , bytestring + , containers + , dhall + , errors + , hashable + , lrucaching + , protolude + , re2 + , scotty + , text + , typed-process + , wai-middleware-static + default-extensions: DuplicateRecordFields + , DeriveGeneric + , FlexibleContexts + , GeneralizedNewtypeDeriving + , NoImplicitPrelude + , MultiParamTypeClasses + , OverloadedStrings + , RecordWildCards + , ScopedTypeVariables + , StandaloneDeriving + , TupleSections + , TypeApplications + ghc-options: -threaded -rtsopts -with-rtsopts=-N -O2 -Wall diff --git a/backend/src/Config.hs b/backend/src/Config.hs new file mode 100644 index 0000000..5ad1d6f --- /dev/null +++ b/backend/src/Config.hs @@ -0,0 +1,19 @@ +module Config + (Config(..), readConfig) +where + +import Dhall (Interpret, auto, detailed, input) +import GHC.Generics +import Protolude + +data Config = Config + { nixpkgsPath :: Text + , staticPath :: Text + , duCacheSize :: Integer + , whyCacheSize :: Integer + } deriving (Show, Generic) + +instance Interpret Config + +readConfig :: FilePath -> IO Config +readConfig file = detailed (input auto (toS file)) diff --git a/backend/src/Main.hs b/backend/src/Main.hs new file mode 100644 index 0000000..4d8bbaa --- /dev/null +++ b/backend/src/Main.hs @@ -0,0 +1,38 @@ +module Main where + +import Control.Error ( runExceptT ) +import Data.LruCache.IO ( newLruHandle ) +import Protolude hiding ( get ) +import Web.Scotty +import Network.Wai.Middleware.Static (addBase, staticPolicy) + +import Config (Config(..), readConfig) +import Nix +import Types (Env(..), runApp) + +safeIO :: Script a -> ActionM a +safeIO script = do + result <- liftIO $ runExceptT script + case result of + Right a -> return a + Left err -> do + putText err + raise . toS $ err + +main :: IO () +main = do + config <- readConfig "./config.dhall" + duCache <- newLruHandle (fromIntegral . duCacheSize $ config) + whyCache <- newLruHandle (fromIntegral . whyCacheSize $ config) + let env = Env {..} + scotty 3000 $ do + middleware $ staticPolicy (addBase . toS $ staticPath config) + get "/" $ file . toS $ staticPath config <> "/index.html" + get "/deps/:packageName/" $ do + pkgName <- param "packageName" + deps <- safeIO $ runApp env $ depTree =<< pkgPath pkgName + json deps + get "/build-deps/:packageName" $ do + pkgName <- param "packageName" + deps <- safeIO $ runApp env $ depTree =<< drvPath pkgName + json deps diff --git a/backend/src/Nix.hs b/backend/src/Nix.hs new file mode 100644 index 0000000..889d146 --- /dev/null +++ b/backend/src/Nix.hs @@ -0,0 +1,126 @@ +module Nix + ( Script + , drvPath + , pkgPath + , depTree + ) +where + +import Control.Error ( Script + , scriptIO + ) +import Data.Attoparsec.Text ( Parser + , parseOnly + ) +import qualified Data.ByteString.Lazy as L +import Data.Hashable ( Hashable ) +import Data.IORef ( atomicModifyIORef ) +import Data.LruCache ( insert + , lookup + ) +import Data.LruCache.IO ( LruHandle(..) ) +import Data.Tree ( Tree(..) ) +import Data.Text ( isSuffixOf + , strip + , unwords + ) +import Regex.RE2 ( compile + , replaceAll + ) + +import System.Environment ( getEnv ) +import System.Process.Typed +import Protolude + +import Config +import qualified Parser +import Types + +decolor :: ByteString -> ByteString +decolor str = fst $ replaceAll colors str "" + where Right colors = compile "\\033\\[(\\d+;)*\\d+m" + +run :: Text -> [Text] -> Script ByteString +run cmd args = do + putText cmdline + let + procConfig = setStdin closed + $ setStdout byteStringOutput + $ setStderr closed + $ proc (toS cmd) (map toS args) + (exitCode, out, err) <- readProcess procConfig + if exitCode == ExitSuccess + then do + let decolored = decolor . L.toStrict $ out + return decolored + else + let message = "Command '" <> cmdline <> "' failed with:\n" <> toS err + in throwError message + where cmdline = cmd <> " " <> unwords args + +cached + :: (Hashable k, Ord k) => LruHandle k v -> (k -> Script v) -> k -> Script v +cached (LruHandle ref) script k = do + cachedValue <- + scriptIO $ atomicModifyIORef ref $ \cache -> case lookup k cache of + Nothing -> (cache, Nothing) + Just (v, cache') -> (cache', Just v) + case cachedValue of + Just v -> return v + Nothing -> do + v <- script k + scriptIO $ atomicModifyIORef ref $ \cache -> (insert k v cache, ()) + return v + +parse :: Parser a -> Text -> Script a +parse parser text = case parseOnly parser text of + Right a -> return a + Left err -> throwError . toS $ err + +drvPath :: Text -> App Text +drvPath pkgName = do + nixpkgs <- asks (nixpkgsPath . config) + out <- lift $ run "nix-instantiate" [nixpkgs, "--attr", pkgName] + lift $ parse Parser.nixPath (toS out) + +pkgPath :: Text -> App Text +pkgPath pkgName = do + nixpkgs <- asks (nixpkgsPath . config) + out <- lift $ run "nix-build" [nixpkgs, "--attr", pkgName, "--no-out-link"] + lift $ parse Parser.nixPath (toS out) + +sizeBytes :: Text -> Script Int +sizeBytes path = do + out <- run "du" ["-bs", path] + parse Parser.size (toS out) + +whyDepends :: (Text, Text) -> Script [Why] +whyDepends (src, dest) = do + out <- run "nix" ["why-depends", toS src, toS dest] + parse Parser.whyDepends (toS out) + +depTree + :: Text + -> App DepTree +depTree path = do + out <- lift $ run "nix-store" ["--query", "--tree", toS path] + pathTree <- lift $ parse Parser.depTree (toS out) + DepTree <$> mkNodes Nothing pathTree + where + buildDeps :: Bool + buildDeps = ".drv" `isSuffixOf` toS path + + mkNodes :: Maybe Text -> Tree Text -> App (Tree Dep) + mkNodes parent Node {..} = do + duCache <- asks duCache + whyCache <- asks whyCache + size <- lift $ cached duCache sizeBytes rootLabel + (sha, name) <- lift $ parse Parser.hashAndName (toS rootLabel) + why <- if buildDeps + then return [] + else case parent of + Nothing -> return [] + Just p -> lift $ cached whyCache whyDepends (p, rootLabel) + + children <- mapM (mkNodes (Just rootLabel)) subForest + return Node {rootLabel = Dep {..}, subForest = children} diff --git a/backend/src/Parser.hs b/backend/src/Parser.hs new file mode 100644 index 0000000..c8f790c --- /dev/null +++ b/backend/src/Parser.hs @@ -0,0 +1,91 @@ +module Parser where + +import Data.Attoparsec.Text +import Data.Char +import Data.Tree +import qualified Data.Text as T +import Protolude hiding ( hash + ) +import Types + + +parseEither :: Parser a -> Text -> Either Text a +parseEither parser text = case parseOnly parser text of + Right a -> Right a + Left err -> Left $ toS err + + +restOfLine :: Parser () +restOfLine = takeTill isEndOfLine *> endOfLine + + +nixPath :: Parser Text +nixPath = do + store <- string "/nix/store" + rest <- takeTill isSpace + return $ store <> rest + + +-- "/nix/store/2kcrj1ksd2a14bm5sky182fv2xwfhfap-glibc-2.26-131" +-- -> ("2kcrj1ksd2a14bm5sky182fv2xwfhfap", "glibc-2.26-131") +hashAndName :: Parser (Text, Text) +hashAndName = do + _ <- string "/nix/store/" + hash <- takeTill (== '-') + _ <- char '-' + name <- takeTill isSpace + return (hash, name) + + +-- Given the output of `nix-store --query --tree`, +-- produce a tree of Nix store paths +depTree :: Parser (Tree Text) +depTree = do + (_, rootLabel) <- node + subForest <- makeForest <$> many node + return Node {..} + where + node :: Parser (Int, Text) + node = do + indent <- takeTill (== '/') + label <- takeWhile1 (not . isSpace) <* restOfLine + let level = T.length indent `div` 4 + return (level, label) + + makeForest :: [(Int, Text)] -> [Tree Text] + makeForest [] = [] + makeForest ((level, label) : xs) = + let + (children, rest) = break (\(x, _) -> x <= level) xs + in + case children of + [] -> [] + _ -> [ Node {rootLabel = label, subForest = makeForest children} ] + ++ makeForest rest + + +-- Given the output of `nix why-depends --all $from $to`, +-- produce a list of reasons why `from` directly depends on `to`. +-- The output of `why-depends` will print the shortest paths first, +-- which is why we only need to parse the first level of indentation until +-- the first "=> " +whyDepends :: Parser [Why] +whyDepends = do + restOfLine + many why + where + -- `filepath:…reason…` => Why + why :: Parser Why + why = do + skipWhile isIndent + file <- takeTill (== ':') <* takeTill (== '…') <* char '…' + reason <- takeTill (== '…') + restOfLine + return Why {..} + + isIndent :: Char -> Bool + isIndent c = c == ' ' || c == '║' || c == '╠' || c == '╚' || c == '═' + + +size :: Parser Int +size = decimal diff --git a/backend/src/Types.hs b/backend/src/Types.hs new file mode 100644 index 0000000..a240abc --- /dev/null +++ b/backend/src/Types.hs @@ -0,0 +1,67 @@ +module Types + ( App + , runApp + , Env(..) + , Dep(..) + , Why(..) + , DepTree(..) + ) +where + +import Control.Error (Script) +import Data.Aeson (ToJSON, object, toJSON) +import Data.Tree (Tree(..)) +import Data.LruCache.IO (LruHandle) +import GHC.Generics +import Protolude + +import Config + +type App = ReaderT Env Script + +runApp :: Env -> App a -> Script a +runApp = flip runReaderT + +-- Global application state +-- Since we are only interested in /nix/store and the store is immutable, +-- it is safe to cache information about store paths. +data Env = Env + { config :: Config + -- Cache storing filesystem size lookups. + , duCache :: LruHandle Text Int + -- Cache storing reasons why there is a dependency between two store paths (src, dest). + , whyCache :: LruHandle (Text, Text) [Why] + } + +-- A node in a dependency tree +data Dep = Dep + { name :: Text + , sha :: Text + , size :: Int + , why :: [Why] + } deriving (Eq, Show, Generic) + +instance ToJSON Dep + +-- A reason why a node depends on its parent +data Why = Why + { file :: Text + , reason :: Text + } deriving (Eq, Show, Generic) + +instance ToJSON Why + +newtype DepTree = DepTree (Tree Dep) + deriving (Eq, Show) + +instance ToJSON DepTree where + toJSON (DepTree node) = serialize node + where + serialize Node {rootLabel=Dep{..}, ..} = + object + [ ("name", toJSON name) + , ("sha", toJSON sha) + , ("size", toJSON size) + , ("why", toJSON why) + , ("children", toJSON $ map serialize subForest) + ] diff --git a/backend/stack.yaml b/backend/stack.yaml new file mode 100644 index 0000000..d4aedf9 --- /dev/null +++ b/backend/stack.yaml @@ -0,0 +1,7 @@ +resolver: lts-11.16 +packages: +- . +extra-deps: [] +nix: + enable: true + shell-file: default.nix \ No newline at end of file diff --git a/default.nix b/default.nix new file mode 100644 index 0000000..1510f91 --- /dev/null +++ b/default.nix @@ -0,0 +1,18 @@ +{ pkgs ? import {} }: +let + backend = import ./backend { inherit pkgs; }; + frontend = import ./frontend { inherit pkgs; }; + static = import ./static { inherit pkgs; }; +in + with pkgs; + runCommand "grafanix" {} '' + mkdir -p $out/static + cp -r ${static}/* $out/static + cp ${frontend}/main.js $out/static + cp ${backend}/bin/main $out + echo '{ nixpkgsPath = "${}"' >> $out/config.dhall + echo ", staticPath = \"$out/static\"" >> $out/config.dhall + echo ', duCacheSize = 2048' >> $out/config.dhall + echo ', whyCacheSize = 512' >> $out/config.dhall + echo '}' >> $out/config.dhall + '' diff --git a/frontend/bower.json b/frontend/bower.json new file mode 100644 index 0000000..e4e7b73 --- /dev/null +++ b/frontend/bower.json @@ -0,0 +1,19 @@ +{ + "name": "grafanix", + "ignore": [ + "**/.*", + "bower_components", + "output" + ], + "dependencies": { + "purescript-affjax": "latest", + "purescript-console": "latest", + "purescript-effect": "latest", + "purescript-halogen": "latest", + "purescript-prelude": "latest", + "purescript-simple-json": "latest" + }, + "devDependencies": { + "purescript-psci-support": "^4.0.0" + } +} diff --git a/frontend/default.nix b/frontend/default.nix new file mode 100644 index 0000000..41ce426 --- /dev/null +++ b/frontend/default.nix @@ -0,0 +1,23 @@ +{ pkgs ? import {}}: +with pkgs; +stdenv.mkDerivation { + name = "nix-deps-frontend"; + src = lib.sourceFilesBySuffices ./. [ + ".purs" + ".json" + ".js" + ]; + buildInputs = [ + git + purescript + nodePackages.bower + nodePackages.pulp + ]; + phases = [ "unpackPhase" "buildPhase" ]; + buildPhase = '' + export HOME=`pwd` + bower install + mkdir -p $out + pulp build -O --to $out/main.js + ''; +} diff --git a/frontend/src/Controls.purs b/frontend/src/Controls.purs new file mode 100644 index 0000000..650e236 --- /dev/null +++ b/frontend/src/Controls.purs @@ -0,0 +1,127 @@ +module Controls where + +import Prelude (type (~>), Unit, Void, bind, const, discard, pure, ($), (<$>), (<>), (==)) +import Types (ClosureType(..), UIState) + +import D3 (drawSunburst) +import Data.Either (Either(..)) +import Data.Maybe (Maybe(..)) +import Effect.Aff (Aff) +import Halogen as H +import Halogen.HTML (ClassName(..), HTML, div, form, input, label, span, text) +import Halogen.HTML.Events as HE +import Halogen.HTML.Properties (InputType(..), checked, class_, for, id_, name, placeholder, type_, value) +import Network.HTTP.Affjax (get) as AX +import Network.HTTP.Affjax.Response (string) as AX +import SessionStorage as SessionStorage +import Simple.JSON (readJSON, writeJSON) +import Web.Event.Event (preventDefault, Event) + +data Query a = + PreventDefault Event (Query a) | + PackageValueInput String a | + ClosureInput ClosureType a | + RestoreState a | + Submit a + +controls :: H.Component HTML Query Unit Void Aff +controls = + H.component + { initialState: const + { packageName: "" + , closureType: Runtime + } + , render + , eval + , receiver: const Nothing + } + where + render :: UIState -> H.ComponentHTML Query + render state = + div + [ class_ $ ClassName "parent" ] + [ div + [ class_ $ ClassName "header" ] + [ form + [ HE.onSubmit $ \e -> Just $ PreventDefault e (H.action Submit) ] + [ div + [ class_ $ ClassName "controls" ] + [ span + [ class_ $ ClassName "name" ] + [] + , input + [ placeholder "Package" + , type_ InputText + , value state.packageName + , HE.onValueInput $ HE.input PackageValueInput + ] + , input + [ type_ InputSubmit + , value "Go" + , id_ "submit" + ] + , span + [ class_ $ ClassName "name" ] + [] + , input + [ type_ InputRadio + , name "closure" + , checked (state.closureType == Runtime) + , HE.onClick $ HE.input_ (ClosureInput Runtime) + ] + , label + [ for "closure" ] + [ text "Runtime" ] + , input + [ type_ InputRadio + , name "closure" + , checked (state.closureType == Build) + , HE.onClick $ HE.input_ (ClosureInput Build) + ] + , label + [ for "closure" ] + [ text "Build" ] + ] + ] + ] + , div + [ id_ "vis" ] + [] + ] + + eval :: Query ~> H.ComponentDSL UIState Query Void Aff + eval = case _ of + PreventDefault event query -> do + H.liftEffect $ preventDefault event + eval query + PackageValueInput value next -> do + state <- H.get + H.put $ state { packageName = value } + pure next + ClosureInput value next -> do + state <- H.get + H.put $ state { closureType = value } + pure next + RestoreState next -> do + item <- H.liftEffect $ SessionStorage.getItem "ui_state" + case readJSON <$> item of + Just (Right state) -> do + H.put state + eval (Submit next) + foo -> do + pure next + Submit next -> do + state <- H.get + H.liftEffect $ SessionStorage.setItem "ui_state" (writeJSON state) + let + base = + case state.closureType of + Build -> "/build-deps/" + Runtime -> "/deps/" + url = + base <> state.packageName + res <- H.liftAff $ AX.get AX.string url + H.liftEffect do + SessionStorage.setItem "data" (res.response) + drawSunburst "vis" + pure next diff --git a/frontend/src/D3.js b/frontend/src/D3.js new file mode 100644 index 0000000..091ebbe --- /dev/null +++ b/frontend/src/D3.js @@ -0,0 +1,5 @@ +"use strict"; + +exports.drawSunburstImpl = function (divId) { + drawSunburst(divId); +}; \ No newline at end of file diff --git a/frontend/src/D3.purs b/frontend/src/D3.purs new file mode 100644 index 0000000..a59ba6e --- /dev/null +++ b/frontend/src/D3.purs @@ -0,0 +1,10 @@ +module D3 where + +import Data.Unit (Unit) +import Effect (Effect) +import Effect.Uncurried (EffectFn1, runEffectFn1) + +foreign import drawSunburstImpl :: EffectFn1 String Unit + +drawSunburst :: String -> Effect Unit +drawSunburst = runEffectFn1 drawSunburstImpl diff --git a/frontend/src/Main.purs b/frontend/src/Main.purs new file mode 100644 index 0000000..7ab2317 --- /dev/null +++ b/frontend/src/Main.purs @@ -0,0 +1,15 @@ +module Main where + +import Controls (Query(..), controls) +import Effect (Effect) +import Halogen as H +import Halogen.Aff (awaitBody, runHalogenAff) +import Halogen.VDom.Driver (runUI) +import Prelude (Unit, bind, unit, ($)) + +main :: Effect Unit +main = + runHalogenAff do + body <- awaitBody + io <- runUI controls unit body + io.query $ H.action RestoreState diff --git a/frontend/src/SessionStorage.js b/frontend/src/SessionStorage.js new file mode 100644 index 0000000..5a39155 --- /dev/null +++ b/frontend/src/SessionStorage.js @@ -0,0 +1,15 @@ +"use strict"; + +exports.getItemImpl = function (just, nothing, key) { + var item = window.sessionStorage.getItem(key); + if (item === null) { + return nothing; + } + return just(item); +}; +exports.setItemImpl = function (key, item) { + window.sessionStorage.setItem(key, item); +} +exports.removeItemImpl = function (key) { + window.sessionStorage.removeItem(key); +} diff --git a/frontend/src/SessionStorage.purs b/frontend/src/SessionStorage.purs new file mode 100644 index 0000000..9776803 --- /dev/null +++ b/frontend/src/SessionStorage.purs @@ -0,0 +1,30 @@ +module SessionStorage + ( getItem + , setItem + , removeItem + , updateItem + ) where + +import Data.Maybe (Maybe(..)) +import Data.Unit (Unit) +import Effect (Effect) +import Effect.Uncurried (EffectFn1, EffectFn2, EffectFn3, runEffectFn1, runEffectFn2, runEffectFn3) + +foreign import getItemImpl :: EffectFn3 (String -> Maybe String) (Maybe String) String (Maybe String) +foreign import setItemImpl :: EffectFn2 String String Unit +foreign import removeItemImpl :: EffectFn1 String Unit + +getItem :: String -> Effect (Maybe String) +getItem = runEffectFn3 getItemImpl Just Nothing + +setItem :: String -> String -> Effect Unit +setItem = runEffectFn2 setItemImpl + +removeItem :: String -> Effect Unit +removeItem = runEffectFn1 removeItemImpl + +updateItem :: String -> Maybe String -> Effect Unit +updateItem key = + case _ of + Just x -> setItem key x + Nothing -> removeItem key diff --git a/frontend/src/Types.purs b/frontend/src/Types.purs new file mode 100644 index 0000000..1f813d0 --- /dev/null +++ b/frontend/src/Types.purs @@ -0,0 +1,30 @@ +module Types where + +import Prelude + +import Foreign (ForeignError(..), fail, readString) +import Simple.JSON (class ReadForeign, class WriteForeign, writeImpl) + +data ClosureType = Build | Runtime + +derive instance eCT :: Eq ClosureType + +instance sCT :: Show ClosureType where + show Build = "Build" + show Runtime = "Runtime" + +instance rfCT :: ReadForeign ClosureType where + readImpl f = do + s <- readString f + case s of + "Build" -> pure Build + "Runtime" -> pure Runtime + _ -> fail $ ForeignError "Can't read ClosureType" + +instance wfCT :: WriteForeign ClosureType where + writeImpl = writeImpl <<< show + +type UIState = + { packageName :: String + , closureType :: ClosureType + } diff --git a/static/d3.js b/static/d3.js new file mode 100644 index 0000000..e4df5d7 --- /dev/null +++ b/static/d3.js @@ -0,0 +1,174 @@ +function drawSunburst(divId) { + const arcRadius = 100; + const layers = 4; + const height = width = arcRadius * layers * 2; + const labelChars = 20; + + function arcVisible(d) { + return d.y1 <= layers && d.y0 >= 1 && d.x1 > d.x0; + } + + function labelVisible(d) { + return arcVisible(d) && (d.y1 - d.y0) * (d.x1 - d.x0) > 0.03; + } + + function labelTransform(d) { + const x = (d.x0 + d.x1) / 2 * 180 / Math.PI; + const y = (d.y0 + d.y1) / 2 * arcRadius; + return `rotate(${x - 90}) translate(${y},0) rotate(${x < 180 ? 0 : 180})`; + } + + function prettySize(bytes) { + if (bytes == 0) { + return '0 B'; + } + var i = Math.floor(Math.log(bytes) / Math.log(1024)); + return (bytes / Math.pow(1024, i)).toFixed(2) * 1 + ' ' + ['B', 'kB', 'MB', 'GB', 'TB'][i]; + } + + function prettyWhy(why) { + return ( + `${why.file} contains:\n` + + `"${why.reason}"\n` + ); + } + + function title(d) { + if (d.parent === null) { + return ""; + } + return ( + `${d.parent.data.name} depends on:\n\n` + + `${d.data.name} (${prettySize(d.data.size)})\n\n` + + `${d.data.why.map(prettyWhy).join()}` + ); + } + + function truncate(d) { + const name = d.data.name; + if (name.length < labelChars) { + return name; + } + return name.substr(0, labelChars - 3) + '...'; + } + + const partition = data => { + const root = d3.hierarchy(data) + .sum(d => d.children.length ? 0 : 1) + .sort((a, b) => b.value - a.value); + return d3.partition() + .size([2 * Math.PI, root.height + 1]) + (root); + } + + const arc = d3.arc() + .startAngle(d => d.x0) + .endAngle(d => d.x1) + .padAngle(d => Math.min((d.x1 - d.x0) / 2, 0.005)) + .padRadius(arcRadius * 1.5) + .innerRadius(d => d.y0 * arcRadius) + .outerRadius(d => Math.max(d.y0 * arcRadius, d.y1 * arcRadius - 1)); + + function draw(data, svg) { + const root = partition(data); + root.each(d => d.current = d); + + const color = d3.scaleOrdinal().range(d3.quantize(d3.interpolateRainbow, data.children.length + 1)); + + const g = svg.append("g") + .attr("transform", `translate(${width / 2},${width / 2})`); + + const path = g.append("g") + .selectAll("path") + .data(root.descendants()) + .enter().append("path") + .attr("fill", d => { while (d.depth > 1) d = d.parent; return color(d.data.name); }) + .attr("fill-opacity", d => arcVisible(d.current) ? (d.children ? 0.6 : 0.4) : 0) + .attr("d", d => arc(d.current)); + + path.append("title") + .style("white-space", "nowrap") + .text(title); + + path.filter(d => d.children) + .style("cursor", "pointer") + .on("click", clicked); + + const label = g.append("g") + .attr("pointer-events", "none") + .attr("text-anchor", "middle") + .style("user-select", "none") + .selectAll("text") + .data(root.descendants()) + .enter().append("text") + .attr("dy", "0.35em") + .attr("fill-opacity", d => +labelVisible(d.current)) + .attr("transform", d => labelTransform(d.current)) + .text(truncate); + + const parent = g.append("circle") + .datum(root) + .attr("r", arcRadius) + .attr("fill", "none") + .attr("pointer-events", "all") + .on("click", clicked); + + const parentLabel = g.append("text") + .attr("dy", "0.35em") + .attr("text-anchor", "middle") + .style("user-select", "none") + .text(truncate(root)); + + function clicked(p) { + parent.datum(p.parent || root); + parentLabel.text(truncate(p)); + + root.each(d => d.target = { + x0: Math.max(0, Math.min(1, (d.x0 - p.x0) / (p.x1 - p.x0))) * 2 * Math.PI, + x1: Math.max(0, Math.min(1, (d.x1 - p.x0) / (p.x1 - p.x0))) * 2 * Math.PI, + y0: Math.max(0, d.y0 - p.depth), + y1: Math.max(0, d.y1 - p.depth) + }); + + const t = g.transition().duration(750); + + // Transition the data on all arcs, even the ones that aren’t visible, + // so that if this transition is interrupted, entering arcs will start + // the next transition from the desired position. + path.transition(t) + .tween("data", d => { + const i = d3.interpolate(d.current, d.target); + return t => d.current = i(t); + }) + .filter(function(d) { + return +this.getAttribute("fill-opacity") || arcVisible(d.target); + }) + .attr("fill-opacity", d => arcVisible(d.target) ? (d.children ? 0.6 : 0.4) : 0) + .attrTween("d", d => () => arc(d.current)); + + label.filter(function(d) { + return +this.getAttribute("fill-opacity") || labelVisible(d.target); + }).transition(t) + .attr("fill-opacity", d => +labelVisible(d.target)) + .attrTween("transform", d => () => labelTransform(d.current)); + } + } + + var vis = d3.select("#" + divId); + vis.selectAll("*").remove(); + + const svg = vis.append("svg") + .attr("viewBox", `0 0 ${width} ${height}`) + .attr("preserveAspectRatio", "xMidYMid meet"); + + var data = window.sessionStorage.getItem("data"); + if (data === null) { + return; + } + + try { + draw(JSON.parse(data), svg); + } catch (err) { + console.log(err) + } +} diff --git a/static/default.nix b/static/default.nix new file mode 100644 index 0000000..6d0de5c --- /dev/null +++ b/static/default.nix @@ -0,0 +1,15 @@ +{ pkgs ? import {}}: +with pkgs; +stdenv.mkDerivation { + name = "grafanix-static"; + src = lib.sourceFilesBySuffices ./. [ + "index.html" + "main.css" + "parrot.gif" + "d3.js" + ]; + installPhase = '' + mkdir -p $out + cp * $out + ''; +} \ No newline at end of file diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..908eb50 --- /dev/null +++ b/static/index.html @@ -0,0 +1,17 @@ + + + + + Grafanix + + + + + + + + + + + + \ No newline at end of file diff --git a/static/main.css b/static/main.css new file mode 100644 index 0000000..5dd08c7 --- /dev/null +++ b/static/main.css @@ -0,0 +1,71 @@ +body { + margin: 0; + padding: 0; + font-size: 16px; +} + +.parent { + display: flex; + flex-direction: column; + height: 100vh; +} + +.header { + background-color: #FAFAFA; + border-bottom: 1px solid rgba(136, 136, 136, 0.2); + padding: 20px 30px; +} + +.controls { + display: flex; + align-items: center; +} + +.controls .name { + margin-right: 10px; +} + +.controls input { + height: 20px; + outline: none; +} + +.controls input[type="text"] { + border-color: rgba(136, 136, 136, 0.2); + border-width: 1px; + padding: 3px; + margin: 0; +} + +.controls input[type="radio"] { + padding: 0; + margin: 8px; +} + +.controls #submit { + height: 28px; + background-color: #999999; + color: white; + border-color: rgba(136, 136, 136, 0.2); + border-width: 1px 1px 1px 0; + border-style: solid; + padding: 0 8px; + outline: none; +} + +.controls #submit:hover, .controls #submit:active { + background-color: #777777; + cursor: pointer; +} + +#vis { + display: flex; + flex: 1 1 auto; + font: 10px sans-serif; + margin: 30px; +} + +svg { + display: flex; + flex: 1 1 auto; +} diff --git a/static/parrot.gif b/static/parrot.gif new file mode 100644 index 0000000..458ad85 Binary files /dev/null and b/static/parrot.gif differ