Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae676f2b6c | ||
|
|
6b4c8034f1 | ||
|
|
161737d4c7 | ||
|
|
5990b8d735 | ||
|
|
134ee27ca6 | ||
|
|
71918ee4fd | ||
|
|
a9b7874f36 | ||
|
|
32a1509482 | ||
|
|
6afaa228c3 | ||
|
|
5c1a080831 | ||
|
|
13a53a26d0 |
@@ -36,9 +36,9 @@ btcprice's docker-compose includes btcdata via `include: ../btcdata/docker-compo
|
||||
- **Framework:** Kit (Integrant-based)
|
||||
- **Server:** Undertow
|
||||
- **Routing:** Reitit
|
||||
- **Templates:** Hiccup + vanilla JS WebSocket
|
||||
- **Templates:** Hiccup + HTMX (WebSocket extension)
|
||||
- **Styling:** Tailwind CSS v4
|
||||
- **Data backend:** btcdata (separate service at BTCDATA_URL, default http://localhost:4100, WebSocket at ws://localhost:4100)
|
||||
- **Data backend:** btcdata (separate service at BTCDATA_URL, default http://localhost:4100)
|
||||
- **No database** — all data comes from btcdata
|
||||
|
||||
## Source Layout
|
||||
@@ -58,6 +58,7 @@ src/clj/pmagnus/btcprice/
|
||||
│ └── formats.clj # Content negotiation
|
||||
└── routes/
|
||||
├── api.clj # /api routes (JSON)
|
||||
├── ui.clj # UI routes (HTML + WebSocket)
|
||||
├── ui.clj # UI routes (HTML + HTMX WebSocket)
|
||||
├── ws_proxy.clj # WebSocket proxy to btcdata
|
||||
└── utils.clj # Route utilities
|
||||
```
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
include .env
|
||||
export
|
||||
|
||||
.PHONY: clean run repl tailwind test uberjar
|
||||
.PHONY: clean run run-docker repl tailwind test uberjar
|
||||
|
||||
clean:
|
||||
rm -rf target
|
||||
@@ -9,6 +9,9 @@ clean:
|
||||
run:
|
||||
clj -M:dev -e "(dev-prep!) (go)" -r
|
||||
|
||||
run-docker:
|
||||
BTCDATA_URL=http://localhost:4101 clj -M:dev -e "(dev-prep!) (go)" -r
|
||||
|
||||
repl:
|
||||
clj -M:dev:nrepl
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# btcprice
|
||||
|
||||
Bitcoin price tracker UI. Serves an HTML page with live price updates streamed from [btcdata](../btcdata) via Server-Sent Events.
|
||||
Bitcoin price tracker UI. Serves an HTML page with live price updates streamed from [btcdata](../btcdata) via WebSocket using HTMX.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -21,12 +21,12 @@ docker compose up -d # btcprice on 4041, btcdata on 4101
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `PORT` | `4000` | HTTP server port |
|
||||
| `BTCDATA_URL` | `http://localhost:4100` | btcdata service URL (injected into browser JS) |
|
||||
| `BTCDATA_URL` | `http://192.168.50.42:4100` | btcdata service URL (browser connects via WebSocket) |
|
||||
|
||||
## How It Works
|
||||
|
||||
The server renders an HTML page that includes a vanilla JavaScript `EventSource` connecting directly to btcdata's SSE endpoint (`BTCDATA_URL/api/price/stream`). Price updates are rendered client-side with color-coded changes (green up, red down).
|
||||
The server renders an HTML page with HTMX and the WebSocket extension (`hx-ext="ws"`). HTMX connects directly to btcdata's WebSocket endpoint (`BTCDATA_URL/api/price/ws`), which sends HTML fragments. HTMX swaps the received `<div id="price-display">` into the DOM automatically. Price changes are color-coded (green up, red down). Reconnection is handled by HTMX with exponential backoff.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
Clojure · Kit · Undertow · Reitit · Hiccup · Tailwind CSS v4
|
||||
Clojure · Kit · Undertow · Reitit · Hiccup · HTMX · Tailwind CSS v4
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
|
||||
;; Serialization
|
||||
metosin/muuntaja {:mvn/version "0.6.11"}
|
||||
luminus-transit/luminus-transit {:mvn/version "0.1.6"}}
|
||||
luminus-transit/luminus-transit {:mvn/version "0.1.6"}
|
||||
org.clojure/data.json {:mvn/version "2.5.1"}}
|
||||
|
||||
:aliases
|
||||
{:build {:deps {io.github.clojure/tools.build {:mvn/version "0.10.9"}}
|
||||
|
||||
+7
-3
@@ -1,11 +1,15 @@
|
||||
include:
|
||||
- path: ../btcdata/docker-compose.yml
|
||||
networks:
|
||||
btc-network:
|
||||
name: btc-network
|
||||
external: true
|
||||
|
||||
services:
|
||||
btcprice:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- btc-network
|
||||
ports:
|
||||
- "4041:4040"
|
||||
environment:
|
||||
BTCDATA_URL: "http://localhost:4101"
|
||||
BTCDATA_URL: "http://btcdata:${DOCKER_BTCDATA_PORT:-4102}"
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -36,5 +36,8 @@
|
||||
{:base-path "/api"}
|
||||
|
||||
:reitit.routes/ui
|
||||
{:base-path ""}
|
||||
|
||||
:reitit.routes/ws-proxy
|
||||
{:base-path ""
|
||||
:btcdata-url #or [#env BTCDATA_URL "http://localhost:4100"]}}
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
[pmagnus.btcprice.web.handler]
|
||||
;; Routes
|
||||
[pmagnus.btcprice.web.routes.api]
|
||||
[pmagnus.btcprice.web.routes.ui])
|
||||
[pmagnus.btcprice.web.routes.ui]
|
||||
[pmagnus.btcprice.web.routes.ws-proxy])
|
||||
(:gen-class))
|
||||
|
||||
(defonce system (atom nil))
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
[:meta {:name "apple-mobile-web-app-capable" :content "yes"}]
|
||||
[:meta {:name "apple-mobile-web-app-status-bar-style" :content "black-translucent"}]
|
||||
[:title (or (:title opts#) "BTC Price")]
|
||||
[:link {:rel "stylesheet" :href "/css/output.css"}]]
|
||||
[:link {:rel "stylesheet" :href "/css/output.css"}]
|
||||
[:script {:src "https://unpkg.com/htmx.org@2/dist/htmx.min.js"}]
|
||||
[:script {:src "https://unpkg.com/htmx-ext-ws@2/ws.js"}]]
|
||||
[:body
|
||||
[:div.mx-auto.max-w-lg.px-4.py-6
|
||||
~@content]]]))}))
|
||||
|
||||
@@ -2,8 +2,18 @@
|
||||
(:require
|
||||
[ring.middleware.defaults :as defaults]))
|
||||
|
||||
(defn- wrap-nil-guard
|
||||
"Discard responses with no :status — these are artifacts of ring-defaults
|
||||
middleware wrapping a nil response (e.g., from a raw WebSocket upgrade).
|
||||
Passes through :undertow/websocket responses for the adapter to handle."
|
||||
[handler]
|
||||
(fn [request]
|
||||
(let [resp (handler request)]
|
||||
(when (or (:status resp) (:undertow/websocket resp)) resp))))
|
||||
|
||||
(defn wrap-base [{:keys [site-defaults-config]}]
|
||||
(fn [handler]
|
||||
(cond-> handler
|
||||
true (defaults/wrap-defaults
|
||||
(or site-defaults-config defaults/site-defaults)))))
|
||||
(-> handler
|
||||
(defaults/wrap-defaults
|
||||
(or site-defaults-config defaults/site-defaults))
|
||||
(wrap-nil-guard))))
|
||||
|
||||
@@ -7,60 +7,25 @@
|
||||
[reitit.ring.middleware.muuntaja :as muuntaja]
|
||||
[reitit.ring.middleware.parameters :as parameters]))
|
||||
|
||||
(defn- home-page [{:keys [btcdata-url]} _req]
|
||||
(defn- home-page [_opts _req]
|
||||
(page {:title "BTC Price"}
|
||||
[:header.text-center.mb-8
|
||||
[:h1.text-3xl.font-bold.text-gray-900 "BTC Price"]
|
||||
[:p.text-sm.text-gray-500.mt-1 "Bitcoin price tracker"]]
|
||||
|
||||
[:div#price-panel
|
||||
[:div#price-panel {:hx-ext "ws" :ws-connect "/ws/price"}
|
||||
[:div#price-display
|
||||
[:div.bg-white.rounded-2xl.shadow-sm.border.border-gray-200.p-6
|
||||
[:p.text-center.text-gray-400.text-sm "Connecting..."]]]]
|
||||
|
||||
[:script
|
||||
(hiccup2.core/raw
|
||||
(str
|
||||
"document.addEventListener('DOMContentLoaded', function() {\n"
|
||||
" var btcdataUrl = '" btcdata-url "';\n"
|
||||
" var wsUrl = btcdataUrl.replace(/^http/, 'ws');\n"
|
||||
" var display = document.getElementById('price-display');\n"
|
||||
"\n"
|
||||
" function connect() {\n"
|
||||
" var ws = new WebSocket(wsUrl + '/api/price/ws');\n"
|
||||
"\n"
|
||||
" ws.onmessage = function(e) {\n"
|
||||
" var d = JSON.parse(e.data);\n"
|
||||
" if (d.ping) return;\n"
|
||||
" var price = parseFloat(d.price);\n"
|
||||
" var prevPrice = d.prev_price ? parseFloat(d.prev_price) : null;\n"
|
||||
" var color = 'text-gray-900';\n"
|
||||
" if (prevPrice !== null) {\n"
|
||||
" if (price > prevPrice) color = 'text-green-600';\n"
|
||||
" else if (price < prevPrice) color = 'text-red-600';\n"
|
||||
" }\n"
|
||||
" var fmt = '$' + price.toLocaleString('en-US', {minimumFractionDigits: 2, maximumFractionDigits: 2});\n"
|
||||
" var ts = new Date(d.recorded_at);\n"
|
||||
" var pad = function(n) { return n < 10 ? '0' + n : n; };\n"
|
||||
" var timeStr = pad(ts.getDate()) + '-' + pad(ts.getMonth()+1) + ' ' + pad(ts.getHours()) + ':' + pad(ts.getMinutes()) + ':' + pad(ts.getSeconds());\n"
|
||||
" display.innerHTML = '<div class=\"bg-white rounded-2xl shadow-sm border border-gray-200 p-6\">' +\n"
|
||||
" '<div class=\"text-center\">' +\n"
|
||||
" '<p class=\"text-4xl font-bold ' + color + '\">' + fmt + '</p>' +\n"
|
||||
" '<p class=\"text-xs text-gray-400 mt-2\">Updated ' + timeStr + '</p>' +\n"
|
||||
" '</div></div>';\n"
|
||||
" };\n"
|
||||
"\n"
|
||||
" ws.onclose = function() {\n"
|
||||
" display.innerHTML = '<div class=\"bg-white rounded-2xl shadow-sm border border-gray-200 p-6\">' +\n"
|
||||
" '<p class=\"text-center text-red-400 text-sm\">Connection lost. Reconnecting...</p></div>';\n"
|
||||
" setTimeout(connect, 3000);\n"
|
||||
" };\n"
|
||||
"\n"
|
||||
" ws.onerror = function() {};\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" connect();\n"
|
||||
"});\n"))]))
|
||||
[:div.mt-4 {:hx-ext "ws" :ws-connect "/ws/strike"}
|
||||
[:div#strike-display
|
||||
[:div.bg-white.rounded-2xl.shadow-sm.border.border-gray-200.p-6
|
||||
[:p.text-center.text-gray-400.text-sm "Loading Strike rates..."]]]]
|
||||
|
||||
;; Safelist classes used in btcdata HTML fragments
|
||||
[:div {:class "hidden text-4xl text-2xl font-bold text-green-600 text-red-600 text-gray-900 text-xs text-gray-400 mt-2 flex justify-center justify-between items-center gap-4 mt-1 mt-3 text-sm text-gray-600 text-gray-500 mb-3 py-1 grid grid-cols-2 gap-x-6 gap-y-1"
|
||||
:aria-hidden "true"}]))
|
||||
|
||||
(defn- ui-routes [opts]
|
||||
[["/"
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
(ns pmagnus.btcprice.web.routes.ws-proxy
|
||||
(:require
|
||||
[clojure.data.json :as json]
|
||||
[clojure.tools.logging :as log]
|
||||
[integrant.core :as ig])
|
||||
(:import
|
||||
[io.undertow.websockets.core WebSockets WebSocketChannel]
|
||||
[java.net URI]
|
||||
[java.net.http HttpClient WebSocket$Builder WebSocket$Listener]
|
||||
[java.time Instant ZoneId]
|
||||
[java.time.format DateTimeFormatter]
|
||||
[java.util Locale]
|
||||
[java.util.concurrent CompletableFuture]))
|
||||
|
||||
(defn- close-quietly [^WebSocketChannel ch]
|
||||
(when (and ch (.isOpen ch))
|
||||
(try (.close ch) (catch Exception _))))
|
||||
|
||||
(defn- close-upstream-quietly [^java.net.http.WebSocket ws]
|
||||
(when ws
|
||||
(try (.sendClose ws java.net.http.WebSocket/NORMAL_CLOSURE "") (catch Exception _))))
|
||||
|
||||
(defn- connect-upstream
|
||||
"Open a Java HttpClient WebSocket to btcdata, transform each text frame
|
||||
with xf, and send the result to the browser channel."
|
||||
[^String btcdata-ws-url ^WebSocketChannel browser-ch xf]
|
||||
(let [client (HttpClient/newHttpClient)
|
||||
listener (reify java.net.http.WebSocket$Listener
|
||||
(onOpen [_ ws]
|
||||
(.request ws 1))
|
||||
(onText [_ ws data last?]
|
||||
(let [text (str data)]
|
||||
(try
|
||||
(when (and (.isOpen browser-ch) (seq text))
|
||||
(WebSockets/sendTextBlocking (xf text) browser-ch))
|
||||
(catch Exception e
|
||||
(log/debug e "Error forwarding to browser")
|
||||
(close-upstream-quietly ws))))
|
||||
(.request ws 1)
|
||||
(CompletableFuture/completedFuture nil))
|
||||
(onClose [_ _ws status-code _reason]
|
||||
(log/debug "Upstream closed" status-code)
|
||||
(close-quietly browser-ch))
|
||||
(onError [_ _ws error]
|
||||
(log/debug error "Upstream error")
|
||||
(close-quietly browser-ch)))]
|
||||
(-> (.newWebSocketBuilder client)
|
||||
^WebSocket$Builder identity
|
||||
(.buildAsync (URI. btcdata-ws-url) listener)
|
||||
(.join))))
|
||||
|
||||
(defn- ws-handler [btcdata-ws-url xf _req]
|
||||
(let [upstream-atom (atom nil)]
|
||||
{:undertow/websocket
|
||||
{:on-open
|
||||
(fn [{:keys [^WebSocketChannel channel]}]
|
||||
(log/info "Browser connected, proxying to" btcdata-ws-url)
|
||||
(reset! upstream-atom (connect-upstream btcdata-ws-url channel xf)))
|
||||
:on-close-message
|
||||
(fn [_]
|
||||
(log/debug "Browser closed")
|
||||
(close-upstream-quietly @upstream-atom))}}))
|
||||
|
||||
;; --- Price rendering ---
|
||||
|
||||
(def ^:private ts-fmt (DateTimeFormatter/ofPattern "dd-MM HH:mm:ss"))
|
||||
(def ^:private da-locale (Locale. "da" "DK"))
|
||||
|
||||
(defn- da-fmt [fmt val]
|
||||
(String/format da-locale fmt (into-array Object [val])))
|
||||
|
||||
(defn- price-json->html [text]
|
||||
(let [{:strs [price prev_price recorded_at
|
||||
price_eur price_dkk]} (json/read-str text)
|
||||
p (double (bigdec price))
|
||||
pp (when prev_price (double (bigdec prev_price)))
|
||||
color (cond
|
||||
(nil? pp) "text-gray-900"
|
||||
(> p pp) "text-green-600"
|
||||
(< p pp) "text-red-600"
|
||||
:else "text-gray-900")
|
||||
ts (.format (.atZone (Instant/parse recorded_at) (ZoneId/systemDefault)) ts-fmt)]
|
||||
(str "<div id=\"price-display\">"
|
||||
"<div class=\"bg-white rounded-2xl shadow-sm border border-gray-200 p-6\">"
|
||||
"<div class=\"text-center\">"
|
||||
"<p class=\"text-4xl font-bold " color "\">$" (da-fmt "%,.2f" p) "</p>"
|
||||
(when price_eur
|
||||
(let [p-eur (double (bigdec price_eur))
|
||||
p-dkk (double (bigdec price_dkk))
|
||||
eur (/ p-eur p)
|
||||
dkk (/ p-dkk p)]
|
||||
(str "<div class=\"flex justify-center gap-4 mt-3\">"
|
||||
"<span class=\"text-2xl font-bold text-gray-600\">\u20AC" (da-fmt "%,.0f" p-eur) "</span>"
|
||||
"<span class=\"text-2xl font-bold text-gray-600\">" (da-fmt "%,.0f" p-dkk) " kr</span>"
|
||||
"</div>"
|
||||
"<div class=\"flex justify-center gap-4 mt-1\">"
|
||||
"<span class=\"text-xs text-gray-400\">EUR/USD " (da-fmt "%.4f" eur) "</span>"
|
||||
"<span class=\"text-xs text-gray-400\">DKK/USD " (da-fmt "%.4f" dkk) "</span>"
|
||||
"</div>")))
|
||||
"<p class=\"text-xs text-gray-400 mt-2\">Updated " ts "</p>"
|
||||
"</div></div></div>")))
|
||||
|
||||
;; --- Strike rendering ---
|
||||
|
||||
(def ^:private strike-pairs
|
||||
[["BTC/USDT" "$"]
|
||||
["BTC/EUR" "\u20AC"]
|
||||
["USDT/EUR" ""]
|
||||
["EUR/BTC" ""]])
|
||||
|
||||
(defn- format-strike-amount [prefix amount]
|
||||
(if amount
|
||||
(let [v (double (bigdec amount))]
|
||||
(case prefix
|
||||
"$" (str prefix (da-fmt "%,.2f" v))
|
||||
"\u20AC" (str prefix (da-fmt "%,.2f" v))
|
||||
amount))
|
||||
"\u2014"))
|
||||
|
||||
(defn- strike-json->html [text]
|
||||
(let [rates (json/read-str text)
|
||||
sats (get rates "quote-sats")]
|
||||
(str "<div id=\"strike-display\">"
|
||||
"<div class=\"rounded-2xl shadow-sm border border-gray-200 p-6\" style=\"background:#f3f4f6;\">"
|
||||
(if sats
|
||||
(str "<p class=\"text-center text-2xl font-bold mb-3\" style=\"color:#1e3a5f;\">"
|
||||
(da-fmt "%,d" sats) " <span style=\"color:#1e3a5f;\">sats</span></p>")
|
||||
"")
|
||||
"<div style=\"display:grid;grid-template-columns:1fr;row-gap:0.25rem;\">"
|
||||
(apply str
|
||||
(for [[pair prefix] strike-pairs
|
||||
:let [amount (get rates pair)]]
|
||||
(str "<div style=\"display:flex;justify-content:space-between;align-items:center;padding:0.25rem 0;\">"
|
||||
"<span class=\"text-sm text-gray-500\">" pair "</span>"
|
||||
"<span class=\"text-sm font-bold text-gray-900\">"
|
||||
(format-strike-amount prefix amount)
|
||||
"</span></div>")))
|
||||
"</div></div></div>")))
|
||||
|
||||
;; --- Routes ---
|
||||
|
||||
(defn- ws-proxy-routes [{:keys [btcdata-url]}]
|
||||
(let [ws-base (str (.replaceFirst ^String btcdata-url "^http" "ws"))]
|
||||
[["/ws/price"
|
||||
{:get (fn [req] (ws-handler (str ws-base "/api/price/ws") price-json->html req))
|
||||
:no-doc true
|
||||
:middleware []}]
|
||||
["/ws/strike"
|
||||
{:get (fn [req] (ws-handler (str ws-base "/api/strike/ws") strike-json->html req))
|
||||
:no-doc true
|
||||
:middleware []}]]))
|
||||
|
||||
(derive :reitit.routes/ws-proxy :reitit/routes)
|
||||
|
||||
(defmethod ig/init-key :reitit.routes/ws-proxy
|
||||
[_ {:keys [base-path]
|
||||
:or {base-path ""}
|
||||
:as opts}]
|
||||
[base-path {} (ws-proxy-routes opts)])
|
||||
Reference in New Issue
Block a user