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
|
{-# 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 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.Text as Text
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 (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
[ ":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);",
" });",
" });",
"});"
]
|