Compare commits
10
Commits
a50256a042
...
02cc607e73
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02cc607e73 | ||
|
|
ce343dfcf6 | ||
|
|
397d9f7b5c | ||
|
|
c688846a26 | ||
|
|
30aa52cdc7 | ||
|
|
553dac3069 | ||
|
|
2c576aff4e | ||
|
|
201155c0b2 | ||
|
|
8169796eb9 | ||
|
|
08793c5099 |
@@ -0,0 +1,5 @@
|
||||
target/
|
||||
node_modules/
|
||||
.env
|
||||
.cpcache/
|
||||
.nrepl-port
|
||||
@@ -1,15 +1,26 @@
|
||||
# btcprice
|
||||
|
||||
Bitcoin price tracker - Clojure Kit web application.
|
||||
Bitcoin price tracker UI — Clojure Kit web application. Serves the HTML/Tailwind UI. Connects to btcdata for live price data via WebSocket.
|
||||
|
||||
## Build & Development Commands
|
||||
|
||||
- `make run` — Start dev server on port 3000
|
||||
- `make repl` — Start nREPL on port 7888
|
||||
- `make run` — Start dev server on port 4000
|
||||
- `make repl` — Start nREPL
|
||||
- `make test` — Run tests
|
||||
- `make tailwind` — Watch Tailwind CSS for changes
|
||||
- `make uberjar` — Build production JAR
|
||||
- `docker compose up -d` — Start PostgreSQL
|
||||
- `docker compose up -d` — Start btcprice + btcdata containers
|
||||
|
||||
## Docker
|
||||
|
||||
Docker runs alongside local dev on different ports:
|
||||
|
||||
| Service | Local dev | Docker |
|
||||
|----------|----------------|----------------|
|
||||
| btcprice | localhost:4000 | localhost:4041 |
|
||||
| btcdata | localhost:4100 | localhost:4101 |
|
||||
|
||||
btcprice's docker-compose includes btcdata via `include: ../btcdata/docker-compose.yml`.
|
||||
|
||||
## REPL Commands
|
||||
|
||||
@@ -18,10 +29,6 @@ Bitcoin price tracker - Clojure Kit web application.
|
||||
(go) ;; Start system
|
||||
(reset) ;; Reload code & restart
|
||||
(halt) ;; Stop system
|
||||
(reset-db) ;; Drop & re-migrate database
|
||||
(migrate) ;; Run pending migrations
|
||||
(rollback) ;; Rollback last migration
|
||||
(query-fn) ;; Get database query function
|
||||
```
|
||||
|
||||
## Architecture
|
||||
@@ -29,9 +36,10 @@ Bitcoin price tracker - Clojure Kit web application.
|
||||
- **Framework:** Kit (Integrant-based)
|
||||
- **Server:** Undertow
|
||||
- **Routing:** Reitit
|
||||
- **Templates:** Hiccup + HTMX
|
||||
- **Templates:** Hiccup + vanilla JS WebSocket
|
||||
- **Styling:** Tailwind CSS v4
|
||||
- **Database:** PostgreSQL + conman + Migratus
|
||||
- **Data backend:** btcdata (separate service at BTCDATA_URL, default http://localhost:4100, WebSocket at ws://localhost:4100)
|
||||
- **No database** — all data comes from btcdata
|
||||
|
||||
## Source Layout
|
||||
|
||||
@@ -50,6 +58,6 @@ src/clj/pmagnus/btcprice/
|
||||
│ └── formats.clj # Content negotiation
|
||||
└── routes/
|
||||
├── api.clj # /api routes (JSON)
|
||||
├── ui.clj # UI routes (HTML)
|
||||
├── ui.clj # UI routes (HTML + WebSocket)
|
||||
└── utils.clj # Route utilities
|
||||
```
|
||||
|
||||
+18
-4
@@ -1,14 +1,28 @@
|
||||
FROM clojure:openjdk-25 AS build
|
||||
FROM node:22-alpine AS css
|
||||
|
||||
WORKDIR /build
|
||||
COPY . .
|
||||
RUN clj -Sforce -T:build all
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY resources/css/ resources/css/
|
||||
COPY src/ src/
|
||||
COPY tailwind.config.js ./
|
||||
RUN npm run css:build
|
||||
|
||||
FROM azul/zulu-openjdk-alpine:25
|
||||
FROM clojure:temurin-21-tools-deps-alpine AS build
|
||||
|
||||
WORKDIR /build
|
||||
COPY deps.edn build.clj ./
|
||||
RUN clj -Sforce -P
|
||||
COPY . .
|
||||
COPY --from=css /build/resources/public/css/output.css resources/public/css/output.css
|
||||
RUN clj -T:build all
|
||||
|
||||
FROM eclipse-temurin:21-jre-alpine
|
||||
|
||||
COPY --from=build /build/target/btcprice-standalone.jar /btcprice/btcprice-standalone.jar
|
||||
|
||||
EXPOSE 4040
|
||||
ENV PORT=4040
|
||||
ENV BTCDATA_URL=http://btcdata:4101
|
||||
|
||||
CMD ["java", "-jar", "/btcprice/btcprice-standalone.jar"]
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# btcprice
|
||||
|
||||
Bitcoin price tracker UI. Serves an HTML page with live price updates streamed from [btcdata](../btcdata) via Server-Sent Events.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Start btcdata first (provides price data)
|
||||
cd ../btcdata && make run
|
||||
|
||||
# Local dev
|
||||
make run # starts on port 4000
|
||||
make tailwind # watch Tailwind CSS (separate terminal)
|
||||
|
||||
# Docker (starts both btcprice + btcdata)
|
||||
docker compose up -d # btcprice on 4041, btcdata on 4101
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `PORT` | `4000` | HTTP server port |
|
||||
| `BTCDATA_URL` | `http://localhost:4100` | btcdata service URL (injected into browser JS) |
|
||||
|
||||
## 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).
|
||||
|
||||
## Tech Stack
|
||||
|
||||
Clojure · Kit · Undertow · Reitit · Hiccup · Tailwind CSS v4
|
||||
@@ -1,7 +1,5 @@
|
||||
{:paths ["src/clj" "resources"]
|
||||
:deps {org.clojure/clojure {:mvn/version "1.12.3"}
|
||||
org.clojure/data.json {:mvn/version "2.5.1"}
|
||||
|
||||
;; Kit
|
||||
io.github.kit-clj/kit-core {:mvn/version "1.0.6"}
|
||||
|
||||
@@ -27,12 +25,7 @@
|
||||
|
||||
;; Serialization
|
||||
metosin/muuntaja {:mvn/version "0.6.11"}
|
||||
luminus-transit/luminus-transit {:mvn/version "0.1.6"}
|
||||
|
||||
;; Database
|
||||
io.github.kit-clj/kit-postgres {:mvn/version "1.0.7"}
|
||||
io.github.kit-clj/kit-sql-conman {:mvn/version "1.10.5"}
|
||||
io.github.kit-clj/kit-sql-migratus {:mvn/version "1.0.5"}}
|
||||
luminus-transit/luminus-transit {:mvn/version "0.1.6"}}
|
||||
|
||||
:aliases
|
||||
{:build {:deps {io.github.clojure/tools.build {:mvn/version "0.10.9"}}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
include:
|
||||
- path: ../btcdata/docker-compose.yml
|
||||
|
||||
services:
|
||||
btcprice:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "4041:4040"
|
||||
environment:
|
||||
BTCDATA_URL: "http://localhost:4101"
|
||||
Vendored
-25
@@ -1,6 +1,5 @@
|
||||
(ns user
|
||||
(:require
|
||||
[clojure.tools.logging :as log]
|
||||
[integrant.core :as ig]
|
||||
[integrant.repl :refer [go halt reset reset-all set-prep!]]
|
||||
[integrant.repl.state :as state]
|
||||
@@ -19,30 +18,6 @@
|
||||
(-> (config/system-config {:profile :test})
|
||||
(ig/expand)))))
|
||||
|
||||
(defn reset-db []
|
||||
(let [sys (or @pmagnus.btcprice.core/system
|
||||
integrant.repl.state/system)]
|
||||
(when-let [mig (:db.sql/migrations sys)]
|
||||
(migratus.core/reset mig)
|
||||
(log/info "Database reset complete"))))
|
||||
|
||||
(defn rollback []
|
||||
(let [sys (or @pmagnus.btcprice.core/system
|
||||
integrant.repl.state/system)]
|
||||
(when-let [mig (:db.sql/migrations sys)]
|
||||
(migratus.core/rollback mig))))
|
||||
|
||||
(defn migrate []
|
||||
(let [sys (or @pmagnus.btcprice.core/system
|
||||
integrant.repl.state/system)]
|
||||
(when-let [mig (:db.sql/migrations sys)]
|
||||
(migratus.core/migrate mig))))
|
||||
|
||||
(defn query-fn []
|
||||
(let [sys (or @pmagnus.btcprice.core/system
|
||||
integrant.repl.state/system)]
|
||||
(:db.sql/query-fn sys)))
|
||||
|
||||
(comment
|
||||
(dev-prep!)
|
||||
(go)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
DROP TABLE binance_price;
|
||||
@@ -1,5 +0,0 @@
|
||||
CREATE TABLE binance_price (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
price NUMERIC(18,8) NOT NULL,
|
||||
recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
@@ -1 +0,0 @@
|
||||
Migration files go here.
|
||||
File diff suppressed because one or more lines are too long
@@ -1,9 +0,0 @@
|
||||
-- queries for btcprice
|
||||
|
||||
-- :name insert-binance-price! :! :n
|
||||
-- :doc Insert a Binance BTC price
|
||||
INSERT INTO binance_price (price) VALUES (:price)
|
||||
|
||||
-- :name get-latest-binance-price :? :1
|
||||
-- :doc Get the most recent Binance BTC price
|
||||
SELECT price, recorded_at FROM binance_price ORDER BY id DESC LIMIT 1
|
||||
+2
-20
@@ -32,27 +32,9 @@
|
||||
{:routes #ig/ref :router/routes
|
||||
:env #ig/ref :system/env}
|
||||
|
||||
:db.sql/connection
|
||||
{:jdbc-url #env JDBC_URL}
|
||||
|
||||
:db.sql/query-fn
|
||||
{:conn #ig/ref :db.sql/connection
|
||||
:options {}
|
||||
:filename "queries.sql"}
|
||||
|
||||
:db.sql/migrations
|
||||
{:store :database
|
||||
:db {:datasource #ig/ref :db.sql/connection}
|
||||
:migrate-on-init? true}
|
||||
|
||||
:reitit.routes/api
|
||||
{:base-path "/api"
|
||||
:query-fn #ig/ref :db.sql/query-fn}
|
||||
{:base-path "/api"}
|
||||
|
||||
:reitit.routes/ui
|
||||
{:base-path ""
|
||||
:query-fn #ig/ref :db.sql/query-fn}
|
||||
|
||||
:ws/binance
|
||||
{:query-fn #ig/ref :db.sql/query-fn
|
||||
:uri "wss://stream.binance.com:9443/ws/btcusdt@trade"}}
|
||||
:btcdata-url #or [#env BTCDATA_URL "http://localhost:4100"]}}
|
||||
|
||||
@@ -6,16 +6,11 @@
|
||||
[pmagnus.btcprice.env :refer [defaults]]
|
||||
|
||||
;; Edges
|
||||
[kit.edge.db.postgres]
|
||||
[kit.edge.db.sql.conman]
|
||||
[kit.edge.db.sql.migratus]
|
||||
[kit.edge.server.undertow]
|
||||
[pmagnus.btcprice.web.handler]
|
||||
;; Routes
|
||||
[pmagnus.btcprice.web.routes.api]
|
||||
[pmagnus.btcprice.web.routes.ui]
|
||||
;; WebSocket clients
|
||||
[pmagnus.btcprice.ws.binance])
|
||||
[pmagnus.btcprice.web.routes.ui])
|
||||
(:gen-class))
|
||||
|
||||
(defonce system (atom nil))
|
||||
|
||||
@@ -17,8 +17,7 @@
|
||||
[: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"}]
|
||||
[:script {:src "https://unpkg.com/htmx.org@2.0.8"}]]
|
||||
[:link {:rel "stylesheet" :href "/css/output.css"}]]
|
||||
[:body
|
||||
[:div.mx-auto.max-w-lg.px-4.py-6
|
||||
~@content]]]))}))
|
||||
|
||||
@@ -8,8 +8,7 @@
|
||||
[reitit.ring.coercion :as coercion]
|
||||
[reitit.ring.middleware.muuntaja :as muuntaja]
|
||||
[reitit.ring.middleware.parameters :as parameters]
|
||||
[reitit.swagger :as swagger]
|
||||
[reitit.swagger-ui :as swagger-ui]))
|
||||
[reitit.swagger :as swagger]))
|
||||
|
||||
(defn- api-routes [_opts]
|
||||
[["/swagger.json"
|
||||
|
||||
@@ -1,40 +1,70 @@
|
||||
(ns pmagnus.btcprice.web.routes.ui
|
||||
(:require
|
||||
[integrant.core :as ig]
|
||||
[pmagnus.btcprice.web.htmx :refer [page fragment]]
|
||||
[pmagnus.btcprice.web.htmx :refer [page]]
|
||||
[pmagnus.btcprice.web.middleware.exception :as exception]
|
||||
[pmagnus.btcprice.web.middleware.formats :as formats]
|
||||
[reitit.ring.middleware.muuntaja :as muuntaja]
|
||||
[reitit.ring.middleware.parameters :as parameters]))
|
||||
|
||||
(defn home-page [_req]
|
||||
(defn- home-page [{:keys [btcdata-url]} _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.space-y-4
|
||||
{:hx-get "/price" :hx-trigger "every 2s" :hx-swap "innerHTML"}
|
||||
[:div#price-panel
|
||||
[:div#price-display
|
||||
[:div.bg-white.rounded-2xl.shadow-sm.border.border-gray-200.p-6
|
||||
[:p.text-center.text-gray-400.text-sm "Loading..."]]]))
|
||||
[:p.text-center.text-gray-400.text-sm "Connecting..."]]]]
|
||||
|
||||
(defn price-fragment [{:keys [query-fn]}]
|
||||
(let [row (query-fn :get-latest-binance-price {})]
|
||||
(fragment {}
|
||||
[:div.bg-white.rounded-2xl.shadow-sm.border.border-gray-200.p-6
|
||||
(if row
|
||||
[:div.text-center
|
||||
[:p.text-4xl.font-bold.text-gray-900
|
||||
(str "$" (format "%,.2f" (double (:price row))))]
|
||||
[:p.text-xs.text-gray-400.mt-2
|
||||
(str "Updated " (:recorded_at row))]]
|
||||
[:p.text-center.text-gray-400.text-sm "Waiting for data..."])])))
|
||||
[: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"))]))
|
||||
|
||||
(defn- ui-routes [opts]
|
||||
[["/"
|
||||
{:get home-page}]
|
||||
["/price"
|
||||
{:get (fn [_req] (price-fragment opts))}]])
|
||||
{:get (fn [req] (home-page opts req))}]])
|
||||
|
||||
(defn route-data [opts]
|
||||
(merge
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
(ns pmagnus.btcprice.ws.binance
|
||||
(:require
|
||||
[clojure.data.json :as json]
|
||||
[clojure.tools.logging :as log]
|
||||
[integrant.core :as ig])
|
||||
(:import
|
||||
[java.net URI]
|
||||
[java.net.http HttpClient WebSocket WebSocket$Listener]
|
||||
[java.util.concurrent CompletableFuture CompletionStage]))
|
||||
|
||||
(defn- save-price! [query-fn price]
|
||||
(try
|
||||
(query-fn :insert-binance-price! {:price price})
|
||||
(catch Exception e
|
||||
(log/error e "Failed to save BTC price"))))
|
||||
|
||||
(defn- connect!
|
||||
"Opens a WebSocket to Binance trade stream and returns the WebSocket instance.
|
||||
`state` is an atom with keys :running?, :last-write, :buffer."
|
||||
[uri query-fn state]
|
||||
(let [client (HttpClient/newHttpClient)
|
||||
listener (reify WebSocket$Listener
|
||||
(onOpen [_ ws]
|
||||
(log/info "Binance WebSocket connected")
|
||||
(.request ws 1))
|
||||
|
||||
(onText [_ ws data last?]
|
||||
(let [buf (:buffer @state)]
|
||||
(.append buf data)
|
||||
(when last?
|
||||
(let [text (str buf)]
|
||||
(.setLength buf 0)
|
||||
(try
|
||||
(let [msg (json/read-str text :key-fn keyword)
|
||||
price (some-> (:p msg) bigdec)]
|
||||
(when price
|
||||
(let [now (System/currentTimeMillis)]
|
||||
(when (> (- now (:last-write @state)) 5000)
|
||||
(swap! state assoc :last-write now)
|
||||
(log/info "BTC price:" (str price))
|
||||
(save-price! query-fn price)))))
|
||||
(catch Exception e
|
||||
(log/error e "Failed to parse Binance message")))))
|
||||
(let [^CompletionStage cf (CompletableFuture/completedFuture nil)]
|
||||
(.request ws 1)
|
||||
cf)))
|
||||
|
||||
(onClose [_ _ws code reason]
|
||||
(log/warn "Binance WebSocket closed:" code reason)
|
||||
(when (:running? @state)
|
||||
(future
|
||||
(Thread/sleep 3000)
|
||||
(when (:running? @state)
|
||||
(log/info "Reconnecting to Binance...")
|
||||
(try
|
||||
(let [ws (connect! uri query-fn state)]
|
||||
(swap! state assoc :ws ws))
|
||||
(catch Exception e
|
||||
(log/error e "Binance reconnect failed")))))))
|
||||
|
||||
(onError [_ _ws error]
|
||||
(log/error error "Binance WebSocket error")))]
|
||||
(-> (.newWebSocketBuilder client)
|
||||
(.buildAsync (URI. uri) listener)
|
||||
(.join))))
|
||||
|
||||
(defmethod ig/init-key :ws/binance
|
||||
[_ {:keys [query-fn uri]}]
|
||||
(log/info "Starting Binance WebSocket listener:" uri)
|
||||
(let [state (atom {:running? true
|
||||
:last-write 0
|
||||
:buffer (StringBuilder.)
|
||||
:ws nil})
|
||||
ws (connect! uri query-fn state)]
|
||||
(swap! state assoc :ws ws)
|
||||
state))
|
||||
|
||||
(defmethod ig/halt-key! :ws/binance
|
||||
[_ state]
|
||||
(log/info "Stopping Binance WebSocket listener")
|
||||
(swap! state assoc :running? false)
|
||||
(when-let [ws (:ws @state)]
|
||||
(try
|
||||
(.sendClose ws WebSocket/NORMAL_CLOSURE "shutting down")
|
||||
(catch Exception _))))
|
||||
Reference in New Issue
Block a user