diff options
Diffstat (limited to 'Omni')
| -rw-r--r-- | Omni/Ava/Web.hs | 98 |
1 files changed, 89 insertions, 9 deletions
diff --git a/Omni/Ava/Web.hs b/Omni/Ava/Web.hs index 4d4ece6..f9dd7cd 100644 --- a/Omni/Ava/Web.hs +++ b/Omni/Ava/Web.hs @@ -26,13 +26,11 @@ 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 Data.Text.IO as TextIO import qualified Database.SQLite.Simple as SQL import qualified Network.HTTP.Types as HTTP import qualified Network.Wai as Wai import qualified Network.Wai.Handler.Warp as Warp import qualified Omni.Ava.Trace as Trace -import qualified System.Environment as Environment main :: IO () main = putText "Use Omni.Ava for the main entry point" @@ -75,15 +73,97 @@ serveTraceJson dbPath tid _req respond = do respond <| Wai.responseLBS HTTP.status200 [(HTTP.hContentType, "application/json")] json renderTraceHtml :: Trace.TraceRecord -> IO Text -renderTraceHtml rec = do - coderoot <- Environment.getEnv "CODEROOT" - let templatePath = coderoot <> "/Omni/Ava/Web/trace.html" - template <- TextIO.readFile templatePath +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}}" (Trace.trcInput rec) - <| Text.replace "{{output_json}}" (Trace.trcOutput rec) - <| template + <| 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 = + 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');", + " });", + " });", + "", + " 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>" + ] |
