diff options
| author | Ben Sima <ben@bensima.com> | 2025-12-19 23:35:21 -0500 |
|---|---|---|
| committer | Ben Sima <ben@bensima.com> | 2025-12-19 23:35:21 -0500 |
| commit | f10b5fda7f24f72ea51672f64c2d838a58c92b50 (patch) | |
| tree | b3aa15402acaeb97f515c50e120bbb5ee995840f /Omni | |
| parent | e77f8a82cbd6aa5381af8fd711bc9522357a3518 (diff) | |
Omni/Ava/Web: refactor to use Servant and Lucid
Matches the patterns used in Omni/Jr/Web.
Diffstat (limited to 'Omni')
| -rw-r--r-- | Omni/Ava/Web.hs | 245 |
1 files changed, 122 insertions, 123 deletions
diff --git a/Omni/Ava/Web.hs b/Omni/Ava/Web.hs index f9dd7cd..c1eaa48 100644 --- a/Omni/Ava/Web.hs +++ b/Omni/Ava/Web.hs @@ -1,4 +1,6 @@ +{-# LANGUAGE DataKinds #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TypeOperators #-} {-# LANGUAGE NoImplicitPrelude #-} -- | Web server for Ava trace viewer. @@ -8,6 +10,9 @@ -- : out omni-ava-web -- : dep warp -- : dep wai +-- : dep servant-server +-- : dep servant-lucid +-- : dep lucid -- : dep http-types -- : dep aeson -- : dep text @@ -23,14 +28,13 @@ where import Alpha import qualified Data.Aeson as Aeson -import qualified Data.ByteString.Lazy as LBS import qualified Data.Text as Text -import qualified Data.Text.Encoding as Encoding import qualified Database.SQLite.Simple as SQL -import qualified Network.HTTP.Types as HTTP -import qualified Network.Wai as Wai +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" @@ -44,126 +48,121 @@ startWebServer port dbPath = do SQL.withConnection dbPath Trace.initTraceDb Warp.run port (app dbPath) -app :: FilePath -> Wai.Application -app dbPath request respond = do - case Wai.pathInfo request of - ["trace", tid] -> serveTracePage dbPath tid request respond - ["api", "trace", tid] -> serveTraceJson dbPath tid request respond - ["health"] -> respond <| Wai.responseLBS HTTP.status200 [(HTTP.hContentType, "text/plain")] "ok" - _ -> respond <| Wai.responseLBS HTTP.status404 [(HTTP.hContentType, "text/plain")] "Not found" - -serveTracePage :: FilePath -> Text -> Wai.Request -> (Wai.Response -> IO Wai.ResponseReceived) -> IO Wai.ResponseReceived -serveTracePage dbPath tid _req respond = do - SQL.withConnection dbPath <| \conn -> do - maybeRec <- Trace.getTrace conn tid - case maybeRec of - Nothing -> respond <| Wai.responseLBS HTTP.status404 [(HTTP.hContentType, "text/plain")] "Trace not found" - Just rec -> do - html <- renderTraceHtml rec - respond <| Wai.responseLBS HTTP.status200 [(HTTP.hContentType, "text/html; charset=utf-8")] (LBS.fromStrict <| Encoding.encodeUtf8 html) - -serveTraceJson :: FilePath -> Text -> Wai.Request -> (Wai.Response -> IO Wai.ResponseReceived) -> IO Wai.ResponseReceived -serveTraceJson dbPath tid _req respond = do - SQL.withConnection dbPath <| \conn -> do - maybeRec <- Trace.getTrace conn tid - case maybeRec of - Nothing -> respond <| Wai.responseLBS HTTP.status404 [(HTTP.hContentType, "application/json")] "{\"error\":\"not found\"}" - Just rec -> do - let json = Aeson.encode rec - respond <| Wai.responseLBS HTTP.status200 [(HTTP.hContentType, "application/json")] json - -renderTraceHtml :: Trace.TraceRecord -> IO Text -renderTraceHtml rec = - pure - <| Text.replace "{{trace_id}}" (Trace.trcId rec) - <| Text.replace "{{tool_name}}" (Trace.trcToolName rec) - <| Text.replace "{{created_at}}" (Trace.trcCreatedAt rec) - <| Text.replace "{{duration_ms}}" (tshow <| Trace.trcDurationMs rec) - <| Text.replace "{{input_json}}" (escapeHtml <| Trace.trcInput rec) - <| Text.replace "{{output_json}}" (escapeHtml <| Trace.trcOutput rec) - <| traceTemplate - --- | Escape HTML special characters -escapeHtml :: Text -> Text -escapeHtml = - Text.replace "&" "&" - <. Text.replace "<" "<" - <. Text.replace ">" ">" - <. Text.replace "\"" """ - --- | Embedded trace HTML template -traceTemplate :: Text -traceTemplate = +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 (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 (Trace.trcOutput rec) + + Lucid.p_ [Lucid.class_ "footer"] <| Lucid.toHtml ("Trace ID: " <> Trace.trcId rec) + + Lucid.script_ traceScript + +traceStyles :: Text +traceStyles = Text.unlines - [ "<!DOCTYPE html>", - "<html lang=\"en\">", - "<head>", - " <meta charset=\"utf-8\">", - " <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">", - " <title>Trace: {{tool_name}}</title>", - " <style>", - " :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; }", - " </style>", - "</head>", - "<body>", - " <h1>{{tool_name}}</h1>", - " <p class=\"meta\">{{created_at}} · {{duration_ms}}ms</p>", - "", - " <div class=\"section\" id=\"input-section\">", - " <div class=\"section-header\">", - " Input", - " <button class=\"copy-btn\" data-target=\"input-content\">Copy</button>", - " </div>", - " <div class=\"section-content\" id=\"input-content\"><pre>{{input_json}}</pre></div>", - " </div>", - "", - " <div class=\"section\" id=\"output-section\">", - " <div class=\"section-header\">", - " Output", - " <button class=\"copy-btn\" data-target=\"output-content\">Copy</button>", - " </div>", - " <div class=\"section-content\" id=\"output-content\"><pre>{{output_json}}</pre></div>", - " </div>", - "", - " <p class=\"footer\">Trace ID: {{trace_id}}</p>", - "", - " <script>", - " document.querySelectorAll('.section-header').forEach(header => {", - " header.addEventListener('click', (e) => {", - " if (e.target.classList.contains('copy-btn')) return;", - " header.parentElement.classList.toggle('collapsed');", - " });", - " });", + [ ":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);", - " });", - " });", + "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);", " });", - " </script>", - "</body>", - "</html>" + " });", + "});" ] |
