diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,5 @@
+# Revision history for grafana-dashboarding
+
+## 0.1.0.0 -- YYYY-mm-dd
+
+* First version. Released on an unsuspecting world.
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,30 @@
+Copyright (c) 2019, Ethan Jones
+
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+    * Redistributions of source code must retain the above copyright
+      notice, this list of conditions and the following disclaimer.
+
+    * Redistributions in binary form must reproduce the above
+      copyright notice, this list of conditions and the following
+      disclaimer in the documentation and/or other materials provided
+      with the distribution.
+
+    * Neither the name of chessai nor the names of other
+      contributors may be used to endorse or promote products derived
+      from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/Setup.hs b/Setup.hs
new file mode 100644
--- /dev/null
+++ b/Setup.hs
@@ -0,0 +1,2 @@
+import Distribution.Simple
+main = defaultMain
diff --git a/grafana.cabal b/grafana.cabal
new file mode 100644
--- /dev/null
+++ b/grafana.cabal
@@ -0,0 +1,47 @@
+cabal-version: 2.2
+name:
+  grafana
+version:
+  0.1
+synopsis:
+  grafana datatypes for dashboards
+description:
+  'grafana' provides datatypes that correspond to components of
+  grafana dashboards, and combinators for constructing dashboards.
+  It should be suitable for a UI that allows users to construct
+  grafana dashboards.
+license:
+  BSD-3-Clause
+license-file:
+  LICENSE
+author:
+  Ethan Jones
+maintainer:
+  Ethan Jones
+category:
+  Graphics, Tooling
+extra-source-files:
+  CHANGELOG.md
+
+library
+  exposed-modules:
+    Grafana
+  build-depends:
+    , base >= 4.10 && < 4.13
+    , bytestring >= 0.10 && < 0.11
+    , aeson >= 1.3 && < 1.5
+    , aeson-pretty >= 0.8 && < 0.9
+    , text >= 1.2 && < 1.3
+  hs-source-dirs:
+    src
+  default-language:
+    Haskell2010
+  ghc-options:
+    -Wall
+    -O2
+
+source-repository head
+  type:
+    git
+  location:
+    git://github.com/chessai/grafana.git
diff --git a/src/Grafana.hs b/src/Grafana.hs
new file mode 100644
--- /dev/null
+++ b/src/Grafana.hs
@@ -0,0 +1,601 @@
+{-# language
+        BangPatterns
+      , BlockArguments
+      , DeriveFoldable
+      , DeriveFunctor
+      , DeriveGeneric
+      , DeriveTraversable
+      , DerivingStrategies
+      , GeneralizedNewtypeDeriving
+      , LambdaCase
+      , OverloadedStrings
+  #-}
+
+module Grafana
+  ( ColumnSort(..)
+  , Dashboard(..)
+  , GraphiteQuery(..)
+  , Gauge(..)
+  , Sparkline(..)
+  , UnitFormat(..)
+  , Panel
+  , PanelStyles(..)
+  , PathComponent(..)
+  , RGBA(..)
+  , SortOrder(..)
+  , StyleThresholds(..)
+  , Templating(..)
+  , TimeAmount(..)
+  , TimeRange(..)
+  , TimeUnit(..)
+
+  , columns
+  , defaultDashboard
+  , defaultStyles
+  , defaultGauge
+  , defaultSparkline
+  , getDashboardJSON
+  , graph
+  , layoutUniformPanels
+  , maxDashboardWidth
+  , move
+  , panelAt
+  , row
+  , singlestatQuery
+  , table
+  , htmlPanel
+  ) where
+
+import Data.Aeson (ToJSON(..), FromJSON(..))
+import Data.Aeson (Value(..), (.=), object)
+import Data.Aeson.Encode.Pretty (encodePretty)
+import Data.ByteString (ByteString)
+import Data.Char (isAlphaNum)
+import Data.Coerce (coerce)
+import Data.List.NonEmpty (NonEmpty)
+import Data.Maybe (isJust, maybeToList)
+import Data.Text (Text)
+import Data.Word (Word8)
+import GHC.Generics (Generic)
+
+import qualified Data.Aeson as AE
+import qualified Data.ByteString.Lazy as BL
+import qualified Data.List.NonEmpty as NE
+import qualified Data.Text as T
+
+tshow :: Show a => a -> Text
+tshow = T.pack . show
+{-# inlineable tshow #-} -- not exported, should inline anyway
+
+optionalField :: ToJSON a => Text -> Maybe a -> [(Text,AE.Value)]
+optionalField key = \case
+  Nothing -> []
+  Just x -> [ key .= x ]
+
+data Templating = Templating
+  { templatingOptions :: NonEmpty Text
+  , templatingName :: !Text
+  , templatingLabel :: !Text
+  , templatingQuery :: !GraphiteQuery
+  , templatingAllValue :: !(Maybe Text)
+  } deriving stock (Eq, Read, Show)
+
+instance ToJSON Templating where
+  toJSON t = object $
+    [ "datasource" .= String "Graphite"
+    , "includeAll" .= isJust (templatingAllValue t)
+    , "type" .= String "query"
+    , "regex" .= optionsRegex (templatingOptions t)
+    , "name" .= templatingName t
+    , "label" .= Null
+    , "current" .= object
+        [ "text" .= firstOption
+        , "value" .= firstOption
+        ]
+    , "definition" .= query
+    , "query" .= query
+    , "options" .= Array mempty
+    , "tagsQuery" .= String ""
+    , "tags" .= Array mempty
+    , "useTags" .= False
+    , "hide" .= (0 :: Int)
+    , "multi" .= False
+    , "refresh" .= (2 :: Int)
+    , "tagValuesQuery" .= String ""
+    ]
+    <> optionalField "allValue" (templatingAllValue t)
+    where
+      query = serializeQuery (templatingQuery t)
+      firstOption = NE.head (templatingOptions t)
+
+optionsRegex :: NonEmpty Text -> Text
+optionsRegex options = "/"
+  <> T.intercalate "|" (stripInvalidChars <$> NE.toList options)
+  <> "/"
+
+data UnitFormat
+  = PercentUnitFormat
+  | PercentFormat
+  | DBmFormat
+  | SecondsFormat
+  | MillisecondsFormat
+  deriving stock (Eq, Generic, Read, Show)
+
+instance ToJSON UnitFormat where
+  toJSON = \case
+    PercentFormat -> "percent"
+    PercentUnitFormat -> "percentunit"
+    DBmFormat -> "dBm"
+    SecondsFormat -> "s"
+    MillisecondsFormat -> "ms"
+
+data PanelType
+  = GraphPanel
+  | SinglestatPanel
+  | TablePanel
+  | HeatmapPanel
+  | AlertListPanel
+  | DashboardListPanel
+  | TextPanel
+  | RowPanel
+  deriving stock (Eq, Generic, Read, Show)
+
+instance ToJSON PanelType where
+  toJSON = \case
+    GraphPanel -> "graph"
+    SinglestatPanel -> "singlestat"
+    TablePanel -> "table"
+    HeatmapPanel -> "heatmap"
+    AlertListPanel -> "alertlist"
+    DashboardListPanel -> "dashboardlist"
+    TextPanel -> "text"
+    RowPanel -> "row"
+
+data GridPos = GridPos
+  { panelWidth :: !Int
+  , panelHeight :: !Int
+  , panelXPosition :: !Int
+  , panelYPosition :: !Int
+  } deriving stock (Eq, Generic, Read, Show)
+
+instance ToJSON GridPos where
+  toJSON (GridPos w h x y) =
+    object
+      [ "w" .= w
+      , "h" .= h
+      , "x" .= x
+      , "y" .= y
+      ]
+
+data Column = Column
+  { columnLabel :: !Text
+  , columnValue :: !Text
+  } deriving stock (Eq, Generic, Read, Show)
+
+instance ToJSON Column where
+  toJSON (Column label value) =
+    object
+      [ "text" .= String label
+      , "value" .= String value
+      ]
+
+columns :: [Text] -> [Column]
+columns = fmap (\name -> Column (T.toTitle name) (T.toLower name))
+
+newtype PanelColumns = PanelColumns (Maybe [Column])
+  deriving stock (Eq, Generic, Read, Show)
+
+instance ToJSON PanelColumns where
+  toJSON (PanelColumns x) = case x of
+    Nothing -> Array mempty
+    Just xs -> toJSON xs
+
+data Panel = Panel
+  { panelQueryPanel :: !QueryPanel
+  , panelGridPos :: !GridPos
+  } deriving stock (Eq, Generic, Read, Show)
+
+data RGBA
+  = RGBA !Word8 !Word8 !Word8 !Double
+  | RGB !Word8 !Word8 !Word8
+  deriving stock (Eq, Generic, Read, Show)
+
+instance ToJSON RGBA where
+  toJSON (RGBA r g b a) = String $
+    "rgba(" <>
+      tshow r <> ", " <>
+      tshow g <> ", " <>
+      tshow b <> ", " <>
+      tshow a <> ")"
+  toJSON (RGB r g b) = String $
+    "rgb(" <>
+      tshow r <> ", " <>
+      tshow g <> ", " <>
+      tshow b <> ")"
+
+newtype StyleThresholds a = StyleThresholds [a]
+  deriving stock (Generic, Read, Show)
+  deriving newtype (Eq, ToJSON, FromJSON)
+  deriving newtype (Functor, Foldable)
+
+instance Traversable StyleThresholds where
+  traverse f = fmap StyleThresholds . traverse f . coerce
+  {-# inline traverse #-}
+
+data SortOrder
+  = Ascending
+  | Descending
+  deriving stock (Eq, Generic, Read, Show)
+
+data ColumnSort = ColumnSort !Int !SortOrder
+  deriving stock (Eq, Generic, Read, Show)
+
+data Gauge = Gauge
+  { minValue :: !Int
+  , maxValue :: !Int
+  , thresholdMarkers :: !Bool
+  , thresholdLabels :: !Bool
+  } deriving stock (Eq, Generic, Read, Show)
+
+defaultGauge :: Gauge
+defaultGauge = Gauge
+  { minValue = 0
+  , maxValue = 100
+  , thresholdMarkers = True
+  , thresholdLabels = False
+  }
+
+instance ToJSON Gauge where
+  toJSON g = object
+    [ "minValue" .= minValue g
+    , "maxValue" .= maxValue g
+    , "thresholdMarkers" .= thresholdMarkers g
+    , "thresholdLabels" .= thresholdLabels g
+    , "show" .= True
+    ]
+
+data Sparkline = Sparkline
+  { fillColor :: !RGBA
+  , full :: !Bool
+  , lineColor :: !RGBA
+  } deriving stock (Eq, Generic, Read, Show)
+
+defaultSparkline :: Sparkline
+defaultSparkline = Sparkline
+  { fillColor = RGBA 31 118 189 0.18
+  , full = False
+  , lineColor = RGB 31 120 193
+  }
+
+instance ToJSON Sparkline where
+  toJSON s = object
+    [ "fillColor" .= fillColor s
+    , "full" .= full s
+    , "lineColor" .= lineColor s
+    , "show" .= True
+    ]
+
+data PanelStyles = PanelStyles
+  { alias :: !Text
+  , colorMode :: !Text
+  , colors :: [RGBA]
+  , columnsSort :: !(Maybe ColumnSort)
+  , decimals :: !Int
+  , fontsize :: !Int
+  , styleThresholds :: !(StyleThresholds Double)
+  , styleUnit :: !(Maybe UnitFormat)
+  , transparent :: !Bool
+  , gauge :: !(Maybe Gauge)
+  , colorBackground :: !Bool
+  , colorValue :: !Bool
+  , sparkline :: !(Maybe Sparkline)
+  } deriving stock (Eq, Generic, Read, Show)
+
+defaultStyles :: PanelStyles
+defaultStyles = PanelStyles
+  { alias = ""
+  , colorMode = "cell"
+  , colors = []
+  , columnsSort = Nothing
+  , decimals = 2
+  , fontsize = 80
+  , styleThresholds = StyleThresholds []
+  , styleUnit = Nothing
+  , transparent = False
+  , gauge = Nothing
+  , colorBackground = True
+  , colorValue = False
+  , sparkline = Nothing
+  }
+
+instance ToJSON PanelStyles where
+  toJSON o = object
+    [ "alias" .= alias o
+    , "colorMode" .= colorMode o
+    , "colors" .= colors o
+    , "decimals" .= decimals o
+    , "unit" .= String "short"
+    , "type" .= String "number"
+    , "pattern" .= String "/.*/"
+    , "thresholds" .= fmap tshow (styleThresholds o)
+    , "unit" .= styleUnit o
+    ]
+
+data QueryPanel = QueryPanel
+  { queryPanelType :: !PanelType
+  , queryPanelTitle :: !Text
+  , queryPanelQueries :: [GraphiteQuery]
+  , queryPanelColumns :: !PanelColumns
+  , queryPanelStyles :: !PanelStyles
+  , queryPanelContent :: !Text
+  } deriving stock (Eq, Generic, Read, Show)
+
+instance ToJSON QueryPanel where
+  toJSON = object . queryPanelToPairs
+
+instance ToJSON ColumnSort where
+  toJSON (ColumnSort n sortOrder) = object
+    [ "col" .= n
+    , "desc" .= (sortOrder == Descending)
+    ]
+
+queryPanelToPairs :: QueryPanel -> [(Text,AE.Value)]
+queryPanelToPairs p =
+  [ "type" .= queryPanelType p
+  , "title" .= queryPanelTitle p
+  , "targets" .= makeTargets (queryPanelQueries p)
+  , "columns" .= queryPanelColumns p
+  , "valueFontSize" .= (String $ tshow (fontsize (queryPanelStyles p)) <> "%")
+  , "content" .= queryPanelContent p
+  , "transparent" .= transparent (queryPanelStyles p)
+  , "mode" .= String "html"
+  ] <>
+    case queryPanelType p of
+      SinglestatPanel ->
+        [ "format" .= styleUnit (queryPanelStyles p)
+        , "thresholds" .=
+            let (StyleThresholds xs) = styleThresholds (queryPanelStyles p)
+            in  T.intercalate "," (tshow <$> xs)
+        , "colorBackground" .= colorBackground (queryPanelStyles p)
+        , "colorValue" .= colorValue (queryPanelStyles p)
+        , "colors" .= colors (queryPanelStyles p)
+        ]
+        <> optionalField "gauge" (gauge (queryPanelStyles p))
+        <> optionalField "sparkline" (sparkline (queryPanelStyles p))
+      TablePanel ->
+        [ "styles" .= [queryPanelStyles p]
+        , "transform" .= ("timeseries_aggregations" :: Text)
+        ]
+        <> optionalField "sort" (columnsSort (queryPanelStyles p))
+      GraphPanel ->
+        [ "nullPointMode" .= String "connected"
+        ]
+      _ -> []
+
+instance ToJSON Panel where
+  toJSON p = object $
+    ( "gridPos" .= panelGridPos p ) : queryPanelToPairs (panelQueryPanel p)
+
+data TimeUnit
+  = Seconds
+  | Minutes
+  | Hours
+  | Days
+  deriving stock (Eq, Generic, Read, Show)
+
+data TimeAmount = Interval !Int !TimeUnit
+  deriving stock (Eq, Generic, Read, Show)
+
+displayTimeAmount :: TimeAmount -> Text
+displayTimeAmount (Interval n units) =
+  case units of
+    Seconds -> (tshow n <> "s")
+    Minutes -> (tshow n <> "m")
+    Hours -> (tshow n <> "h")
+    Days -> (tshow n <> "d")
+
+instance ToJSON TimeAmount where
+  toJSON = String . displayTimeAmount
+
+data TimeRange = TimeRange
+  { rangeFrom :: !TimeAmount
+  , rangeTo :: !(Maybe TimeAmount)
+  } deriving stock (Eq, Generic, Read, Show)
+
+instance ToJSON TimeRange where
+  toJSON range =
+    object
+      [ "from" .= ("now-" <> displayTimeAmount (rangeFrom range))
+      , "to" .=
+          case rangeTo range of
+            Nothing -> "now"
+            Just r -> "now-" <> displayTimeAmount r
+      ]
+
+maxDashboardWidth :: Int
+maxDashboardWidth = 24
+
+data Dashboard = Dashboard
+  { dashboardIdentifier :: !(Maybe Int)
+  , dashboardUid :: !(Maybe Text)
+  , dashboardTitle :: !Text
+  , dashboardTime :: !TimeRange
+  , dashboardRefresh :: !TimeAmount
+  , dashboardVersion :: !Int
+  , dashboardPanels :: [Panel]
+  , dashboardTemplating :: [Templating]
+  , dashboardLinks :: [Link]
+  , dashboardTags :: [Text]
+  } deriving stock (Eq, Generic, Read, Show)
+
+instance ToJSON Dashboard where
+  toJSON g = object $
+    [ "panels" .= dashboardPanels g
+    , "title" .= dashboardTitle g
+    , "time" .= dashboardTime g
+    , "templating" .= object
+        [ "list" .= dashboardTemplating g
+        ]
+    , "links" .= dashboardLinks g
+    , "tags" .= dashboardTags g
+    ] <> maybeToList (("uid" .=) <$> dashboardUid g)
+
+defaultDashboard :: Dashboard
+defaultDashboard = Dashboard
+  { dashboardIdentifier = Nothing
+  , dashboardUid = Nothing
+  , dashboardTitle = "New dashboard"
+  , dashboardPanels = []
+  , dashboardTime = TimeRange (Interval 1 Hours) Nothing
+  , dashboardRefresh = Interval 5 Seconds
+  , dashboardVersion = 1
+  , dashboardTemplating = []
+  , dashboardLinks = []
+  , dashboardTags = []
+  }
+
+data Target = Target
+  { refId :: !Text
+  , targetVal :: !Text
+  } deriving (Eq, Generic, Read, Show)
+
+instance ToJSON Target where
+  toJSON g =
+    object
+      [ "refId" .= refId g
+      , "target" .= targetVal g
+      ]
+
+data Link = Link
+  { tags :: [Text]
+  , title :: !Text
+  } deriving (Eq, Generic, Read, Show)
+
+instance ToJSON Link where
+  toJSON g =
+    object
+      [ "asDropdown" .= True
+      , "icon" .= String "external link"
+      , "tags" .= tags g
+      , "title" .= title g
+      , "type" .= String "dashboards"
+      ]
+
+makeTargets :: [GraphiteQuery] -> [Target]
+makeTargets = zipWith
+  (\refid query -> Target refid (serializeQuery query))
+  refids
+  where
+    refids = T.singleton <$> (repeat 'A')
+
+table :: PanelStyles -> Text -> [Column] -> ColumnSort -> [GraphiteQuery] -> QueryPanel
+table style label cols colSort queries = QueryPanel
+  TablePanel
+  label
+  queries
+  (PanelColumns $ Just cols)
+  (style { columnsSort = Just colSort })
+  ""
+
+graph :: Text -> [GraphiteQuery] -> QueryPanel
+graph label queries = QueryPanel
+  GraphPanel
+  label
+  queries
+  (PanelColumns Nothing)
+  defaultStyles
+  ""
+
+row :: Text -> QueryPanel
+row label = QueryPanel
+  RowPanel
+  label
+  []
+  (PanelColumns Nothing)
+  defaultStyles
+  ""
+
+data GraphiteQuery
+  = HighestCurrent GraphiteQuery !Int
+  | AverageSeriesWithWildcards GraphiteQuery !Int
+  | AliasSub GraphiteQuery !Text !Text
+  | Avg GraphiteQuery
+  | Metric [PathComponent Text]
+  | LiteralQuery !Text
+  deriving stock (Eq, Read, Show)
+
+serializeQuery :: GraphiteQuery -> Text
+serializeQuery (HighestCurrent q n) =
+  "highestCurrent(" <> serializeQuery q <> "," <> tshow n <> ")"
+serializeQuery (AverageSeriesWithWildcards q n) =
+  "averageSeriesWithWildcards(" <> serializeQuery q <> "," <> tshow n <> ")"
+serializeQuery (AliasSub q a b) =
+  "aliasSub(" <> serializeQuery q <> ",'" <> a <> "','" <> b <> "')"
+serializeQuery (Avg q) = "avg(" <> serializeQuery q <> ")"
+serializeQuery (Metric xs) = T.intercalate "."
+  (serializePathComponent . fmap stripInvalidChars <$> xs)
+serializeQuery (LiteralQuery t) = t
+
+stripInvalidChars :: Text -> Text
+stripInvalidChars = T.filter (\c -> isAlphaNum c || c == '-' || c == '_')
+
+data PathComponent a
+  = Anything
+  | Variable !Text
+  | Literal a
+  | OneOf [a]
+  deriving stock (Eq, Ord, Read, Show)
+  deriving stock (Functor, Foldable, Traversable)
+
+serializePathComponent :: PathComponent Text -> Text
+serializePathComponent = \case
+  Anything -> "*"
+  Variable v -> "$" <> v
+  Literal name -> name
+  OneOf xs -> "{" <> T.intercalate "," xs <> "}"
+
+getDashboardJSON :: Dashboard -> ByteString
+getDashboardJSON = BL.toStrict . encodePretty
+
+htmlPanel :: Text -> QueryPanel
+htmlPanel content = QueryPanel
+  TextPanel
+  ""
+  []
+  (PanelColumns Nothing)
+  defaultStyles { transparent = True }
+  content
+
+singlestatQuery :: PanelStyles -> Text -> [GraphiteQuery] -> QueryPanel
+singlestatQuery style label queries = QueryPanel
+  SinglestatPanel
+  label
+  queries
+  (PanelColumns Nothing)
+  style
+  ""
+
+panelAt :: Int -> Int -> Int -> Int -> QueryPanel -> [Panel]
+panelAt left top width height queryPanel =
+  [ Panel
+      queryPanel
+      (GridPos width height left top)
+  ]
+
+move :: Int -> Int -> Panel -> Panel
+move dx dy panel =
+  let
+    GridPos w h x y = panelGridPos panel
+    newPos = GridPos w h (x + dx) (y + dy)
+  in
+    panel { panelGridPos = newPos }
+
+layoutUniformPanels :: Int -> Int -> Int -> Int -> [QueryPanel] -> [Panel]
+layoutUniformPanels left top width height queryPanels =
+  zipWith
+    Panel
+    queryPanels
+    [ GridPos width height x y
+    | y <- [top, height..]
+    , x <- [left, width..maxDashboardWidth-1]
+    ]
