summaryrefslogtreecommitdiff
path: root/Omni/Ava/Web.hs
blob: c1eaa48177c6ff32ba5d51cdf12c82980f52ecb9 (plain)
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);",
      "    });",
      "  });",
      "});"
    ]