summaryrefslogtreecommitdiff
path: root/Omni/Ava/Web.hs
diff options
context:
space:
mode:
Diffstat (limited to 'Omni/Ava/Web.hs')
-rw-r--r--Omni/Ava/Web.hs245
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 "&" "&amp;"
- <. Text.replace "<" "&lt;"
- <. Text.replace ">" "&gt;"
- <. Text.replace "\"" "&quot;"
-
--- | 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>"
+ " });",
+ "});"
]