1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
|
{-# LANGUAGE OverloadedStrings #-}
{-# 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 http-types
-- : dep aeson
-- : 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.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 Network.Wai.Handler.Warp as Warp
import qualified Omni.Ava.Trace as Trace
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)
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 =
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>"
]
|