{-# LANGUAGE DataKinds #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TypeOperators #-} {-# LANGUAGE NoImplicitPrelude #-} -- | Web server for Ava trace viewer. -- -- Serves the trace viewer UI for debugging tool executions. -- -- : out omni-ava-web -- : dep warp -- : dep wai -- : dep servant-server -- : dep servant-lucid -- : dep lucid -- : dep http-types -- : dep aeson -- : dep aeson-pretty -- : dep text -- : dep bytestring -- : dep sqlite-simple module Omni.Ava.Web ( startWebServer, app, defaultPort, main, ) where import Alpha import qualified Data.Aeson as Aeson import qualified Data.Aeson.Encode.Pretty as AesonPretty import qualified Data.ByteString.Lazy as BL import qualified Data.Text as Text import qualified Data.Text.Encoding as TE import qualified Database.SQLite.Simple as SQL import qualified Lucid import qualified Network.Wai.Handler.Warp as Warp import qualified Omni.Ava.Trace as Trace import Servant import qualified Servant.HTML.Lucid as Lucid main :: IO () main = putText "Use Omni.Ava for the main entry point" defaultPort :: Int defaultPort = 8079 startWebServer :: Int -> FilePath -> IO () startWebServer port dbPath = do putText <| "Starting Ava web server on port " <> tshow port SQL.withConnection dbPath Trace.initTraceDb Warp.run port (app dbPath) type API = "trace" :> Capture "id" Text :> Get '[Lucid.HTML] TracePage :<|> "api" :> "trace" :> Capture "id" Text :> Get '[JSON] Aeson.Value :<|> "health" :> Get '[PlainText] Text api :: Proxy API api = Proxy app :: FilePath -> Application app dbPath = serve api (server dbPath) server :: FilePath -> Server API server dbPath = tracePageHandler dbPath :<|> traceJsonHandler dbPath :<|> healthHandler healthHandler :: Servant.Handler Text healthHandler = pure "ok" tracePageHandler :: FilePath -> Text -> Servant.Handler TracePage tracePageHandler dbPath tid = do maybeRec <- liftIO <| SQL.withConnection dbPath <| \conn -> Trace.getTrace conn tid case maybeRec of Nothing -> throwError err404 Just rec -> pure (TracePage rec) traceJsonHandler :: FilePath -> Text -> Servant.Handler Aeson.Value traceJsonHandler dbPath tid = do maybeRec <- liftIO <| SQL.withConnection dbPath <| \conn -> Trace.getTrace conn tid case maybeRec of Nothing -> throwError err404 Just rec -> pure (Aeson.toJSON rec) -- | Wrapper for trace page rendering newtype TracePage = TracePage Trace.TraceRecord instance Lucid.ToHtml TracePage where toHtmlRaw = Lucid.toHtml toHtml (TracePage rec) = Lucid.doctypehtml_ <| do Lucid.head_ <| do Lucid.meta_ [Lucid.charset_ "utf-8"] Lucid.meta_ [Lucid.name_ "viewport", Lucid.content_ "width=device-width, initial-scale=1"] Lucid.title_ <| Lucid.toHtml ("Trace: " <> Trace.trcToolName rec) Lucid.style_ traceStyles Lucid.body_ <| do Lucid.h1_ <| Lucid.toHtml (Trace.trcToolName rec) Lucid.p_ [Lucid.class_ "meta"] <| do Lucid.toHtml (Trace.trcCreatedAt rec) " · " Lucid.toHtml (tshow (Trace.trcDurationMs rec) <> "ms") Lucid.div_ [Lucid.class_ "section", Lucid.id_ "input-section"] <| do Lucid.div_ [Lucid.class_ "section-header"] <| do "Input" Lucid.button_ [Lucid.class_ "copy-btn", Lucid.data_ "target" "input-content"] "Copy" Lucid.div_ [Lucid.class_ "section-content", Lucid.id_ "input-content"] <| Lucid.pre_ <| Lucid.toHtml (prettyJson (Trace.trcInput rec)) Lucid.div_ [Lucid.class_ "section", Lucid.id_ "output-section"] <| do Lucid.div_ [Lucid.class_ "section-header"] <| do "Output" Lucid.button_ [Lucid.class_ "copy-btn", Lucid.data_ "target" "output-content"] "Copy" Lucid.div_ [Lucid.class_ "section-content", Lucid.id_ "output-content"] <| Lucid.pre_ <| Lucid.toHtml (prettyJson (Trace.trcOutput rec)) Lucid.p_ [Lucid.class_ "footer"] <| Lucid.toHtml ("Trace ID: " <> Trace.trcId rec) Lucid.script_ traceScript traceStyles :: Text traceStyles = Text.unlines [ ":root { --bg: #1a1a2e; --fg: #eee; --accent: #4a9eff; --code-bg: #0d0d1a; }", "@media (prefers-color-scheme: light) {", " :root { --bg: #fff; --fg: #222; --accent: #0066cc; --code-bg: #f5f5f5; }", "}", "* { box-sizing: border-box; }", "body { font-family: system-ui, sans-serif; background: var(--bg); color: var(--fg); padding: 1rem; margin: 0; max-width: 100%; }", "h1 { font-size: 1.25rem; margin: 0 0 0.5rem; }", ".meta { font-size: 0.875rem; opacity: 0.7; margin-bottom: 1rem; }", ".section { margin: 1rem 0; border: 1px solid var(--accent); border-radius: 8px; overflow: hidden; }", ".section-header { padding: 0.75rem 1rem; cursor: pointer; display: flex; justify-content: space-between; align-items: center; background: var(--code-bg); }", ".section-header::before { content: '▼'; margin-right: 0.5rem; transition: transform 0.2s; }", ".section.collapsed .section-header::before { transform: rotate(-90deg); }", ".section-content { padding: 1rem; display: block; overflow-x: auto; background: var(--code-bg); }", ".section.collapsed .section-content { display: none; }", "pre { margin: 0; white-space: pre-wrap; word-break: break-word; font-size: 0.875rem; font-family: ui-monospace, monospace; }", ".copy-btn { background: var(--accent); color: white; border: none; padding: 0.25rem 0.5rem; border-radius: 4px; cursor: pointer; font-size: 0.75rem; }", ".copy-btn:hover { opacity: 0.8; }", ".footer { font-size: 0.75rem; opacity: 0.5; margin-top: 2rem; text-align: center; }" ] traceScript :: Text traceScript = Text.unlines [ "document.querySelectorAll('.section-header').forEach(header => {", " header.addEventListener('click', (e) => {", " if (e.target.classList.contains('copy-btn')) return;", " header.parentElement.classList.toggle('collapsed');", " });", "});", "", "document.querySelectorAll('.copy-btn').forEach(btn => {", " btn.addEventListener('click', () => {", " const targetId = btn.getAttribute('data-target');", " const text = document.getElementById(targetId).querySelector('pre').textContent;", " navigator.clipboard.writeText(text).then(() => {", " const orig = btn.textContent;", " btn.textContent = 'Copied!';", " setTimeout(() => btn.textContent = orig, 1500);", " });", " });", "});" ] prettyJson :: Text -> Text prettyJson t = case Aeson.decode (BL.fromStrict (TE.encodeUtf8 t)) :: Maybe Aeson.Value of Nothing -> t Just v -> TE.decodeUtf8 (BL.toStrict (AesonPretty.encodePretty v))