Compare commits

..
13 Commits
Author SHA1 Message Date
magnus 7c68220404 Merge branch 'fastapi' 2026-03-13 12:34:10 +01:00
magnusandClaude Opus 4.6 c999d831e7 Rewrite btcprice from Clojure to Python FastAPI
Replace the Clojure Kit application with a Python FastAPI app that
produces identical HTML output. WebSocket proxy, Danish number
formatting, and HTMX integration all preserved.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 12:33:03 +01:00
magnusandClaude Opus 4.6 ae676f2b6c Use quote-sats from btcdata instead of local EUR/BTC calculation
Sats value now comes from btcdata's Strike quote endpoint which
includes the actual fee/spread, matching the Strike app.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 11:42:51 +01:00
magnusandClaude Opus 4.6 6b4c8034f1 Make sats EUR amount configurable, default 54.46
Configurable via SATS_EUR_AMOUNT env var.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:43:48 +01:00
magnusandClaude Opus 4.6 161737d4c7 Style Strike ticker: single column, currency symbols, Danish formatting, sats display
Show 54€ equivalent in sats with dark blue styling, light gray background,
and Danish number formatting for BTC/USDT and BTC/EUR values.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:39:26 +01:00
magnusandClaude Opus 4.6 5990b8d735 Render price and strike HTML in btcprice from btcdata JSON WebSockets
Receive raw JSON from btcdata, transform to HTML in ws_proxy.clj.
Added data.json dependency, price and strike rendering, safelist updates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:18:56 +01:00
magnusandClaude Opus 4.6 134ee27ca6 Rename shared network to btc-network
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 09:10:57 +01:00
magnusandClaude Opus 4.6 71918ee4fd Use shared Docker network to connect to btcdata independently
Remove btcdata compose include, use external shared network instead
so both projects can run as separate Docker stacks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 08:51:47 +01:00
magnusandClaude Opus 4.6 a9b7874f36 Use idiomatic undertow/websocket maps instead of raw WebSocket API
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 07:50:53 +01:00
magnusandClaude Opus 4.6 32a1509482 Proxy btcdata WebSocket through btcprice for remote access
Browser can't reach the LAN-only btcdata WebSocket when accessing
btcprice remotely (Tailscale/Cloudflare Tunnel). Add a /ws/price
proxy route that relays btcdata frames to the browser so only
btcprice needs to be reachable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 13:43:05 +01:00
magnusandClaude Opus 4.6 6afaa228c3 Safelist text-2xl and mt-1 Tailwind classes for larger currency prices
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 14:52:10 +01:00
magnusandClaude Opus 4.6 5c1a080831 Add EUR/DKK rate display support and run-docker Makefile target
Safelist Tailwind classes for currency rate HTML fragments from btcdata
WebSocket. Add make run-docker target that connects to Docker btcdata
on port 4101.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 14:28:17 +01:00
magnusandClaude Opus 4.6 13a53a26d0 Switch to HTMX WebSocket, use LAN IP for btcdata URL
- Replace vanilla JS WebSocket with HTMX ws extension (hx-ext="ws")
- Add HTMX + ws extension CDN scripts to page head
- Zero client-side JavaScript — HTMX handles connection, reconnect, and
  DOM swap of HTML fragments from btcdata
- Hidden div safelists Tailwind classes used in btcdata HTML fragments
- Update BTCDATA_URL to LAN IP (192.168.50.42) for cross-machine access
- Update CLAUDE.md and README.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:28:29 +01:00
39 changed files with 290 additions and 644 deletions
+3 -12
View File
@@ -1,15 +1,6 @@
/target __pycache__/
/classes *.pyc
/checkouts .venv/
profiles.clj
pom.xml
pom.xml.asc
*.jar
*.class
/.lein-*
/.nrepl-port
/.cpcache
/.clj-kondo
/node_modules /node_modules
/log /log
.DS_Store .DS_Store
+8 -35
View File
@@ -1,15 +1,12 @@
# btcprice # btcprice
Bitcoin price tracker UI — Clojure Kit web application. Serves the HTML/Tailwind UI. Connects to btcdata for live price data via WebSocket. Bitcoin price tracker UI — Python FastAPI web application. Serves the HTML/Tailwind UI. Connects to btcdata for live price data via WebSocket.
## Build & Development Commands ## Build & Development Commands
- `make run` — Start dev server on port 4000 - `make run` — Start dev server on port 4000
- `make repl` — Start nREPL
- `make test` — Run tests
- `make tailwind` — Watch Tailwind CSS for changes - `make tailwind` — Watch Tailwind CSS for changes
- `make uberjar` — Build production JAR - `make test` — Run tests
- `docker compose up -d` — Start btcprice + btcdata containers
## Docker ## Docker
@@ -22,42 +19,18 @@ Docker runs alongside local dev on different ports:
btcprice's docker-compose includes btcdata via `include: ../btcdata/docker-compose.yml`. btcprice's docker-compose includes btcdata via `include: ../btcdata/docker-compose.yml`.
## REPL Commands
```clojure
(dev-prep!) ;; Prepare dev system
(go) ;; Start system
(reset) ;; Reload code & restart
(halt) ;; Stop system
```
## Architecture ## Architecture
- **Framework:** Kit (Integrant-based) - **Framework:** FastAPI
- **Server:** Undertow - **Server:** Uvicorn
- **Routing:** Reitit - **Templates:** Inline HTML strings + HTMX (WebSocket extension)
- **Templates:** Hiccup + vanilla JS WebSocket
- **Styling:** Tailwind CSS v4 - **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 - **No database** — all data comes from btcdata
## Source Layout ## Source Layout
``` ```
src/clj/pmagnus/btcprice/ app/
── core.clj # App entry point ── main.py # FastAPI app: routes, WebSocket proxy, HTML rendering
├── config.clj # System config loader
└── web/
├── handler.clj # Ring handler + routes
├── htmx.clj # page/fragment macros
├── controllers/
│ └── health.clj # Health check
├── middleware/
│ ├── core.clj # Base middleware
│ ├── exception.clj # Exception handling
│ └── formats.clj # Content negotiation
└── routes/
├── api.clj # /api routes (JSON)
├── ui.clj # UI routes (HTML + WebSocket)
└── utils.clj # Route utilities
``` ```
+9 -12
View File
@@ -4,25 +4,22 @@ WORKDIR /build
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci RUN npm ci
COPY resources/css/ resources/css/ COPY resources/css/ resources/css/
COPY src/ src/ COPY app/ app/
COPY tailwind.config.js ./ COPY tailwind.config.js ./
RUN npm run css:build RUN npm run css:build
FROM clojure:temurin-21-tools-deps-alpine AS build FROM python:3.13-slim
WORKDIR /build WORKDIR /app
COPY deps.edn build.clj ./ COPY requirements.txt .
RUN clj -Sforce -P RUN pip install --no-cache-dir -r requirements.txt
COPY . .
COPY app/ app/
COPY resources/public/ resources/public/
COPY --from=css /build/resources/public/css/output.css resources/public/css/output.css 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 EXPOSE 4040
ENV PORT=4040 ENV PORT=4040
ENV BTCDATA_URL=http://btcdata:4101 ENV BTCDATA_URL=http://btcdata:4101
CMD ["java", "-jar", "/btcprice/btcprice-standalone.jar"] CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "4040"]
+6 -9
View File
@@ -1,22 +1,19 @@
include .env include .env
export export
.PHONY: clean run repl tailwind test uberjar .PHONY: clean run run-docker tailwind test
clean: clean:
rm -rf target rm -rf __pycache__ app/__pycache__
run: run:
clj -M:dev -e "(dev-prep!) (go)" -r .venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 4000 --reload
repl: run-local:
clj -M:dev:nrepl BTCDATA_URL=$(BTCDATA_LOCAL_URL) .venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 4000 --reload
tailwind: tailwind:
npm run css:watch npm run css:watch
test: test:
clj -M:test .venv/bin/python -m pytest tests/
uberjar:
clj -T:build all
+4 -4
View File
@@ -1,6 +1,6 @@
# btcprice # 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 ## Quick Start
@@ -21,12 +21,12 @@ docker compose up -d # btcprice on 4041, btcdata on 4101
| Variable | Default | Description | | Variable | Default | Description |
|---|---|---| |---|---|---|
| `PORT` | `4000` | HTTP server port | | `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 ## 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 ## Tech Stack
Clojure · Kit · Undertow · Reitit · Hiccup · Tailwind CSS v4 Clojure · Kit · Undertow · Reitit · Hiccup · HTMX · Tailwind CSS v4
+246
View File
@@ -0,0 +1,246 @@
import json
import logging
import os
from datetime import datetime
import websockets
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse, HTMLResponse
from fastapi.staticfiles import StaticFiles
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("btcprice")
BTCDATA_URL = os.environ.get("BTCDATA_URL", "http://localhost:4100")
app = FastAPI()
app.mount("/css", StaticFiles(directory="resources/public/css"), name="css")
@app.get("/favicon.ico", include_in_schema=False)
async def favicon():
return FileResponse("resources/public/favicon.svg", media_type="image/svg+xml")
# --- HTML page ---
HOME_HTML = """\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="icon" href="/favicon.ico" type="image/svg+xml">
<title>BTC Price</title>
<link rel="stylesheet" href="/css/output.css">
<script src="https://unpkg.com/htmx.org@2/dist/htmx.min.js"></script>
<script src="https://unpkg.com/htmx-ext-ws@2/ws.js"></script>
</head>
<body>
<div class="mx-auto max-w-lg px-4 py-6">
<header class="text-center mb-8">
<h1 class="text-3xl font-bold text-gray-900">BTC Price</h1>
</header>
<div id="price-panel" hx-ext="ws" ws-connect="/ws/price">
<div id="price-display">
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 p-6">
<p class="text-center text-gray-400 text-sm">Connecting...</p>
</div>
</div>
</div>
<div class="mt-4" hx-ext="ws" ws-connect="/ws/strike">
<div id="strike-display">
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 p-6">
<p class="text-center text-gray-400 text-sm">Loading Strike rates...</p>
</div>
</div>
</div>
<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"></div>
</div>
</body>
</html>"""
@app.get("/", response_class=HTMLResponse)
async def home():
return HOME_HTML
# --- Health endpoint ---
@app.get("/api/health")
async def health():
return {"status": "up"}
# --- Formatting helpers (Danish locale) ---
def da_fmt(fmt: str, val: float) -> str:
"""Format a number using Danish locale conventions."""
if fmt == ",.2f":
# e.g. 12.345,67
int_part = int(val)
frac = abs(val - int_part)
int_str = f"{int_part:,}".replace(",", ".")
return f"{int_str},{frac:.2f}"[:-1].split(",")[0] + "," + f"{frac:.2f}"[2:]
if fmt == ",.0f":
return f"{int(round(val)):,}".replace(",", ".")
if fmt == ".4f":
return f"{val:.4f}".replace(".", ",")
if fmt == ",d":
return f"{int(val):,}".replace(",", ".")
return str(val)
# --- Price transform ---
def price_json_to_html(text: str) -> str:
data = json.loads(text)
p = float(data["price"])
pp = float(data["prev_price"]) if data.get("prev_price") is not None else None
if pp is None:
color = "text-gray-900"
elif p > pp:
color = "text-green-600"
elif p < pp:
color = "text-red-600"
else:
color = "text-gray-900"
recorded_at = data.get("recorded_at", "")
if recorded_at:
dt = datetime.fromisoformat(recorded_at.replace("Z", "+00:00"))
local_dt = dt.astimezone()
ts = local_dt.strftime("%d-%m %H:%M:%S")
else:
ts = ""
html = (
'<div id="price-display">'
'<div class="bg-white rounded-2xl shadow-sm border border-gray-200 p-6">'
'<div class="text-center">'
f'<p class="text-4xl font-bold {color}">${da_fmt(",.2f", p)}</p>'
)
if data.get("price_eur") is not None:
p_eur = float(data["price_eur"])
p_dkk = float(data["price_dkk"])
eur_rate = p_eur / p
dkk_rate = p_dkk / p
html += (
'<div class="flex justify-center gap-4 mt-3">'
f'<span class="text-2xl font-bold text-gray-600">\u20ac{da_fmt(",.0f", p_eur)}</span>'
f'<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">'
f'<span class="text-xs text-gray-400">EUR/USD {da_fmt(".4f", eur_rate)}</span>'
f'<span class="text-xs text-gray-400">DKK/USD {da_fmt(".4f", dkk_rate)}</span>'
"</div>"
)
html += f'<p class="text-xs text-gray-400 mt-2">Updated {ts}</p>'
html += "</div></div></div>"
return html
# --- Strike transform ---
STRIKE_PAIRS = [
("BTC/USDT", "$"),
("BTC/EUR", "\u20ac"),
("USDT/EUR", ""),
("EUR/BTC", ""),
]
def format_strike_amount(prefix: str, amount) -> str:
if amount is None:
return "\u2014"
v = float(amount)
if prefix in ("$", "\u20ac"):
return f'{prefix}{da_fmt(",.2f", v)}'
return str(amount)
def strike_json_to_html(text: str) -> str:
rates = json.loads(text)
sats = rates.get("quote-sats")
html = (
'<div id="strike-display">'
'<div class="rounded-2xl shadow-sm border border-gray-200 p-6" style="background:#f3f4f6;">'
)
if sats is not None:
html += (
'<p class="text-center text-2xl font-bold mb-3" style="color:#1e3a5f;">'
f'{da_fmt(",d", sats)} <span style="color:#1e3a5f;">sats</span></p>'
)
html += '<div style="display:grid;grid-template-columns:1fr;row-gap:0.25rem;">'
for pair, prefix in STRIKE_PAIRS:
amount = rates.get(pair)
html += (
'<div style="display:flex;justify-content:space-between;align-items:center;padding:0.25rem 0;">'
f'<span class="text-sm text-gray-500">{pair}</span>'
f'<span class="text-sm font-bold text-gray-900">{format_strike_amount(prefix, amount)}</span>'
"</div>"
)
html += "</div></div></div>"
return html
# --- WebSocket proxy ---
def _btcdata_ws_url(path: str) -> str:
return BTCDATA_URL.replace("http", "ws", 1) + path
async def _ws_proxy(browser_ws: WebSocket, upstream_path: str, transform):
await browser_ws.accept()
upstream_url = _btcdata_ws_url(upstream_path)
logger.info("Browser connected, proxying to %s", upstream_url)
try:
async with websockets.connect(upstream_url, ping_interval=None) as upstream:
async for message in upstream:
if not message:
continue
try:
html = transform(message)
await browser_ws.send_text(html)
except WebSocketDisconnect:
break
except Exception:
logger.debug("Error forwarding to browser", exc_info=True)
break
except WebSocketDisconnect:
pass
except Exception:
logger.debug("Upstream connection error", exc_info=True)
finally:
try:
await browser_ws.close()
except Exception:
pass
@app.websocket("/ws/price")
async def ws_price(websocket: WebSocket):
await _ws_proxy(websocket, "/api/price/ws", price_json_to_html)
@app.websocket("/ws/strike")
async def ws_strike(websocket: WebSocket):
await _ws_proxy(websocket, "/api/strike/ws", strike_json_to_html)
-13
View File
@@ -1,13 +0,0 @@
{:tasks
{:run {:doc "Start dev server"
:task (shell "clj -M:dev")}
:nrepl {:doc "Start nREPL"
:task (shell "clj -M:dev:nrepl")}
:cider {:doc "Start CIDER"
:task (shell "clj -M:dev:cider")}
:test {:doc "Run tests"
:task (shell "clj -M:test")}
:uberjar {:doc "Build uberjar"
:task (shell "clj -T:build all")}
:format {:doc "Format code"
:task (shell "clj -M:dev -m cljstyle.main fix src test")}}}
-28
View File
@@ -1,28 +0,0 @@
(ns build
(:require [clojure.tools.build.api :as b]))
(def lib 'pmagnus/btcprice)
(def version "0.0.1-SNAPSHOT")
(def target-dir "target")
(def class-dir (str target-dir "/classes"))
(def basis (delay (b/create-basis {:project "deps.edn"})))
(def uber-file (str target-dir "/btcprice-standalone.jar"))
(defn clean [_]
(b/delete {:path target-dir}))
(defn uber [_]
(clean nil)
(b/copy-dir {:src-dirs ["src/clj" "resources" "env/prod/resources" "env/prod/clj"]
:target-dir class-dir})
(b/compile-clj {:basis @basis
:ns-compile '[pmagnus.btcprice.core]
:class-dir class-dir})
(b/uber {:class-dir class-dir
:uber-file uber-file
:basis @basis
:main 'pmagnus.btcprice.core}))
(defn all [_]
(uber nil))
-54
View File
@@ -1,54 +0,0 @@
{:paths ["src/clj" "resources"]
:deps {org.clojure/clojure {:mvn/version "1.12.3"}
;; Kit
io.github.kit-clj/kit-core {:mvn/version "1.0.6"}
;; Logging
ch.qos.logback/logback-classic {:mvn/version "1.5.20"}
;; HTTP Server
io.github.kit-clj/kit-undertow {:mvn/version "1.0.10"}
;; Routing
metosin/reitit {:mvn/version "0.9.2"}
metosin/reitit-ring {:mvn/version "0.9.2"}
metosin/reitit-middleware {:mvn/version "0.9.2"}
metosin/reitit-swagger {:mvn/version "0.9.2"}
metosin/reitit-swagger-ui {:mvn/version "0.9.2"}
metosin/reitit-malli {:mvn/version "0.9.2"}
;; Web
ring/ring-core {:mvn/version "1.15.3"}
ring/ring-defaults {:mvn/version "0.7.0"}
metosin/ring-http-response {:mvn/version "0.9.5"}
hiccup/hiccup {:mvn/version "2.0.0"}
;; Serialization
metosin/muuntaja {:mvn/version "0.6.11"}
luminus-transit/luminus-transit {:mvn/version "0.1.6"}}
:aliases
{:build {:deps {io.github.clojure/tools.build {:mvn/version "0.10.9"}}
:ns-default build}
:dev {:extra-paths ["env/dev/clj" "env/dev/resources" "test/clj"]
:extra-deps {integrant/repl {:mvn/version "0.5.0"}
criterium/criterium {:mvn/version "0.4.6"}
expound/expound {:mvn/version "0.9.0"}
com.lambdaisland/classpath {:mvn/version "0.4.44"}
ring/ring-devel {:mvn/version "1.15.3"}}}
:test {:extra-paths ["env/test/resources"]
:extra-deps {io.github.cognitect-labs/test-runner {:git/tag "v0.5.1" :git/sha "dfb30dd"}
peridot/peridot {:mvn/version "0.5.4"}
clj-commons/byte-streams {:mvn/version "0.3.4"}}
:main-opts ["-m" "cognitect.test-runner"]
:exec-fn cognitect.test-runner.api/test}
:nrepl {:extra-deps {nrepl/nrepl {:mvn/version "1.3.1"}}
:main-opts ["-m" "nrepl.cmdline"
"--middleware" "[integrant.repl/middleware]"]}
:cider {:extra-deps {cider/cider-nrepl {:mvn/version "0.55.7"}}
:main-opts ["-m" "nrepl.cmdline"
"--middleware" "[cider.nrepl/cider-middleware]"]}}}
+7 -3
View File
@@ -1,11 +1,15 @@
include: networks:
- path: ../btcdata/docker-compose.yml btc-network:
name: btc-network
external: true
services: services:
btcprice: btcprice:
build: . build: .
restart: unless-stopped restart: unless-stopped
networks:
- btc-network
ports: ports:
- "4041:4040" - "4041:4040"
environment: environment:
BTCDATA_URL: "http://localhost:4101" BTCDATA_URL: "http://btcdata:${DOCKER_BTCDATA_PORT:-4102}"
-4
View File
@@ -1,4 +0,0 @@
(ns pmagnus.btcprice.dev-middleware)
(defn wrap-dev [handler _opts]
handler)
-11
View File
@@ -1,11 +0,0 @@
(ns pmagnus.btcprice.env
(:require
[clojure.tools.logging :as log]
[pmagnus.btcprice.dev-middleware :refer [wrap-dev]]))
(def defaults
{:init (fn [] (log/info "\n-=[btcprice starting...dev/test profile]=-"))
:start (fn [] (log/info "\n-=[btcprice started...dev/test profile]=-"))
:stop (fn [] (log/info "\n-=[btcprice has shut down]=-"))
:middleware wrap-dev
:opts {:profile :dev}})
-25
View File
@@ -1,25 +0,0 @@
(ns user
(:require
[integrant.core :as ig]
[integrant.repl :refer [go halt reset reset-all set-prep!]]
[integrant.repl.state :as state]
[pmagnus.btcprice.config :as config]
[pmagnus.btcprice.core]))
(defn dev-prep! []
(set-prep!
(fn []
(-> (config/system-config {:profile :dev})
(ig/expand)))))
(defn test-prep! []
(set-prep!
(fn []
(-> (config/system-config {:profile :test})
(ig/expand)))))
(comment
(dev-prep!)
(go)
(reset)
(halt))
-15
View File
@@ -1,15 +0,0 @@
<configuration>
<statusListener class="ch.qos.logback.core.status.NopStatusListener" />
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="STDOUT" />
</root>
<logger name="pmagnus.btcprice" level="DEBUG" />
<logger name="org.eclipse.jetty" level="WARN" />
<logger name="io.undertow" level="WARN" />
<logger name="org.xnio" level="WARN" />
</configuration>
-10
View File
@@ -1,10 +0,0 @@
(ns pmagnus.btcprice.env
(:require
[clojure.tools.logging :as log]))
(def defaults
{:init (fn [] (log/info "\n-=[btcprice starting]=-"))
:start (fn [] (log/info "\n-=[btcprice started successfully]=-"))
:stop (fn [] (log/info "\n-=[btcprice has shut down successfully]=-"))
:middleware (fn [handler _] handler)
:opts {:profile :prod}})
-14
View File
@@ -1,14 +0,0 @@
<configuration>
<statusListener class="ch.qos.logback.core.status.NopStatusListener" />
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="STDOUT" />
</root>
<logger name="org.eclipse.jetty" level="WARN" />
<logger name="io.undertow" level="WARN" />
<logger name="org.xnio" level="WARN" />
</configuration>
-11
View File
@@ -1,11 +0,0 @@
<configuration>
<statusListener class="ch.qos.logback.core.status.NopStatusListener" />
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="ERROR">
<appender-ref ref="STDOUT" />
</root>
</configuration>
-8
View File
@@ -1,8 +0,0 @@
{:full-name "pmagnus/btcprice"
:ns-name "pmagnus.btcprice"
:sanitized "pmagnus/btcprice"
:name "btcprice"
:modules {:root "modules"
:repositories [{:url "https://github.com/kit-clj/modules.git"
:tag "master"
:name "kit-modules"}]}}
-1
View File
@@ -1 +0,0 @@
{}
+3
View File
@@ -0,0 +1,3 @@
fastapi>=0.115
uvicorn[standard]>=0.34
websockets>=14.0
+1 -1
View File
@@ -1,2 +1,2 @@
@import "tailwindcss"; @import "tailwindcss";
@source "../../src/**/*.clj"; @source "../../app/**/*.py";
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90"></text></svg>

After

Width:  |  Height:  |  Size: 109 B

-40
View File
@@ -1,40 +0,0 @@
{:system/env #profile {:dev :dev :test :test :prod :prod}
:server/http
{:port #long #or [#env PORT 4000]
:host #or [#env HTTP_HOST "0.0.0.0"]
:handler #ig/ref :handler/ring}
:handler/ring
{:router #ig/ref :router/core
:api-path "/api"
:site-defaults-config
{:params {:keywordize true
:multipart true
:nested true}
:cookies true
:session {:flash true
:cookie-attrs {:http-only true
:same-site :strict}}
:security {:anti-forgery false
:xss-protection {:enable? true :mode :block}
:frame-options :sameorigin}
:static {:resources "public"}
:responses {:not-modified-responses true
:absolute-redirects true
:content-types true
:default-charset "utf-8"}}}
:router/routes
{:routes #ig/refset :reitit/routes}
:router/core
{:routes #ig/ref :router/routes
:env #ig/ref :system/env}
:reitit.routes/api
{:base-path "/api"}
:reitit.routes/ui
{:base-path ""
:btcdata-url #or [#env BTCDATA_URL "http://localhost:4100"]}}
-8
View File
@@ -1,8 +0,0 @@
(ns pmagnus.btcprice.config
(:require
[kit.config :as config]))
(def ^:const system-filename "system.edn")
(defn system-config [options]
(config/read-config system-filename options))
-37
View File
@@ -1,37 +0,0 @@
(ns pmagnus.btcprice.core
(:require
[clojure.tools.logging :as log]
[integrant.core :as ig]
[pmagnus.btcprice.config :as config]
[pmagnus.btcprice.env :refer [defaults]]
;; Edges
[kit.edge.server.undertow]
[pmagnus.btcprice.web.handler]
;; Routes
[pmagnus.btcprice.web.routes.api]
[pmagnus.btcprice.web.routes.ui])
(:gen-class))
(defonce system (atom nil))
(defn stop-app []
((or (:stop defaults) (fn [])))
(some-> (deref system) (ig/halt!))
(shutdown-agents))
(defn start-app [& [params]]
((or (:init defaults) (fn [])))
(->> (config/system-config (or params {}))
(ig/expand)
(ig/init)
(reset! system))
((or (:start defaults) (fn []))))
(defn -main [& _]
(Thread/setDefaultUncaughtExceptionHandler
(reify Thread$UncaughtExceptionHandler
(uncaughtException [_ _thread ex]
(log/error ex "Uncaught exception"))))
(start-app)
(.addShutdownHook (Runtime/getRuntime) (Thread. stop-app)))
@@ -1,11 +0,0 @@
(ns pmagnus.btcprice.web.controllers.health
(:require
[ring.util.http-response :as response])
(:import
[java.lang.management ManagementFactory]))
(defn healthcheck! [_req]
(response/ok
{:time (str (java.time.Instant/now))
:up-time (.. ManagementFactory getRuntimeMXBean getUptime)
:app {:status "up"}}))
-31
View File
@@ -1,31 +0,0 @@
(ns pmagnus.btcprice.web.handler
(:require
[integrant.core :as ig]
[reitit.ring :as ring]
[pmagnus.btcprice.web.middleware.core :as middleware]))
(defmethod ig/init-key :handler/ring
[_ {:keys [router api-path] :as opts}]
(ring/ring-handler
(router)
(ring/routes
(ring/create-resource-handler {:path "/"})
(when (some? api-path)
(reitit.ring/create-default-handler))
(ring/create-default-handler
{:not-found (constantly {:status 404 :body "Page not found"})
:method-not-allowed (constantly {:status 405 :body "Not allowed"})
:not-acceptable (constantly {:status 406 :body "Not acceptable"})}))
{:middleware [(middleware/wrap-base opts)]}))
(defmethod ig/init-key :router/routes
[_ {:keys [routes]}]
(mapv (fn [route]
(if (fn? route) (route) route))
routes))
(defmethod ig/init-key :router/core
[_ {:keys [routes env]}]
(if (= env :dev)
(fn [] (ring/router routes))
(constantly (ring/router routes))))
-30
View File
@@ -1,30 +0,0 @@
(ns pmagnus.btcprice.web.htmx
(:require
[hiccup2.core :as h]))
(defmacro page
[opts & content]
`(let [opts# ~opts]
{:status 200
:headers {"Content-Type" "text/html; charset=utf-8"}
:body (str
(h/html
(hiccup2.core/raw "<!DOCTYPE html>")
[:html {:lang (or (:lang opts#) "en")}
[:head
[:meta {:charset "utf-8"}]
[:meta {:name "viewport" :content "width=device-width, initial-scale=1"}]
[: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"}]]
[:body
[:div.mx-auto.max-w-lg.px-4.py-6
~@content]]]))}))
(defmacro fragment
[opts & content]
`(let [_opts# ~opts]
{:status 200
:headers {"Content-Type" "text/html; charset=utf-8"}
:body (str (h/html ~@content))}))
@@ -1,9 +0,0 @@
(ns pmagnus.btcprice.web.middleware.core
(:require
[ring.middleware.defaults :as defaults]))
(defn wrap-base [{:keys [site-defaults-config]}]
(fn [handler]
(cond-> handler
true (defaults/wrap-defaults
(or site-defaults-config defaults/site-defaults)))))
@@ -1,21 +0,0 @@
(ns pmagnus.btcprice.web.middleware.exception
(:require
[clojure.tools.logging :as log]
[reitit.ring.middleware.exception :as exception]))
(defn- handler [message exception request]
(let [uri (:uri request)]
(log/error exception (str message " at " uri))
{:status 500
:body {:message message
:exception (.getClass exception)
:data (ex-data exception)
:uri uri}}))
(def exception-middleware
(exception/create-exception-middleware
(merge
exception/default-handlers
{::exception/default (partial handler "Internal error")
::exception/wrap (fn [handler e request]
(handler e request))})))
@@ -1,12 +0,0 @@
(ns pmagnus.btcprice.web.middleware.formats
(:require
[luminus-transit.time :as time]
[muuntaja.core :as m]))
(def instance
(m/create
(-> m/default-options
(update-in [:formats "application/transit+json" :decoder-opts]
(partial merge time/time-deserialization-handlers))
(update-in [:formats "application/transit+json" :encoder-opts]
(partial merge time/time-serialization-handlers)))))
@@ -1,41 +0,0 @@
(ns pmagnus.btcprice.web.routes.api
(:require
[integrant.core :as ig]
[pmagnus.btcprice.web.controllers.health :as health]
[pmagnus.btcprice.web.middleware.exception :as exception]
[pmagnus.btcprice.web.middleware.formats :as formats]
[reitit.coercion.malli :as malli]
[reitit.ring.coercion :as coercion]
[reitit.ring.middleware.muuntaja :as muuntaja]
[reitit.ring.middleware.parameters :as parameters]
[reitit.swagger :as swagger]))
(defn- api-routes [_opts]
[["/swagger.json"
{:get {:no-doc true
:swagger {:info {:title "btcprice API"}}
:handler (swagger/create-swagger-handler)}}]
["/health"
{:get health/healthcheck!}]])
(defn route-data [opts]
(merge
opts
{:coercion malli/coercion
:muuntaja formats/instance
:swagger {:id ::api}
:middleware [parameters/parameters-middleware
muuntaja/format-negotiate-middleware
muuntaja/format-response-middleware
exception/exception-middleware
muuntaja/format-request-middleware
coercion/coerce-request-middleware
coercion/coerce-response-middleware]}))
(derive :reitit.routes/api :reitit/routes)
(defmethod ig/init-key :reitit.routes/api
[_ {:keys [base-path]
:or {base-path ""}
:as opts}]
[base-path (route-data opts) (api-routes opts)])
@@ -1,83 +0,0 @@
(ns pmagnus.btcprice.web.routes.ui
(:require
[integrant.core :as ig]
[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 [{: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
[: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"))]))
(defn- ui-routes [opts]
[["/"
{:get (fn [req] (home-page opts req))}]])
(defn route-data [opts]
(merge
opts
{:muuntaja formats/instance
:middleware [parameters/parameters-middleware
muuntaja/format-response-middleware
exception/exception-middleware]}))
(derive :reitit.routes/ui :reitit/routes)
(defmethod ig/init-key :reitit.routes/ui
[_ {:keys [base-path]
:or {base-path ""}
:as opts}]
[base-path (route-data opts) (ui-routes opts)])
@@ -1,9 +0,0 @@
(ns pmagnus.btcprice.web.routes.utils)
(def route-data-path [:reitit.core/match :data])
(defn route-data [req]
(get-in req route-data-path))
(defn route-data-key [req k]
(get (route-data req) k))
+1 -1
View File
@@ -1,5 +1,5 @@
module.exports = { module.exports = {
content: ["./src/**/*.clj", "./resources/**/*.html"], content: ["./app/**/*.py", "./resources/**/*.html"],
theme: { extend: {} }, theme: { extend: {} },
plugins: [] plugins: []
} }
-6
View File
@@ -1,6 +0,0 @@
(ns pmagnus.btcprice.core-test
(:require
[clojure.test :refer [deftest is]]))
(deftest app-loads-test
(is (= 1 1)))
-21
View File
@@ -1,21 +0,0 @@
(ns pmagnus.btcprice.test-utils
(:require
[integrant.core :as ig]
[integrant.repl.state :as state]
[pmagnus.btcprice.config :as config]
[pmagnus.btcprice.core :as core]))
(defn system-state []
(or @core/system state/system))
(defn system-fixture []
(fn [f]
(let [system (-> (config/system-config {:profile :test})
(ig/prep)
(ig/init))]
(reset! core/system system)
(try
(f)
(finally
(ig/halt! system)
(reset! core/system nil))))))
@@ -1,13 +0,0 @@
(ns pmagnus.btcprice.web.request-test
(:require
[clojure.test :refer [deftest is use-fixtures]]
[pmagnus.btcprice.test-utils :as tu]))
(use-fixtures :once (tu/system-fixture))
(deftest health-request-test
(let [sys (tu/system-state)
app (get sys :handler/ring)]
(when app
(let [resp (app {:request-method :get :uri "/api/health"})]
(is (= 200 (:status resp)))))))