diff --git a/.gitignore b/.gitignore index 5f4ed8d..3644275 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ # Build artifacts build/ dist/ +# ...but the Go embed target inside the api module keeps a tracked +# placeholder index.html, so un-ignore that subtree. Its own nested +# .gitignore re-excludes any build artifacts the Dockerfile drops in. +!api/internal/webui/dist/ *.o *.a *.so diff --git a/Makefile b/Makefile index 707151e..ad0010f 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,7 @@ ckpool: docker build -t kamado/ckpool:dev ./ckpool api: - docker build -t kamado/api:dev ./api + docker build -t kamado/api:dev -f api/Dockerfile . api-test: cd api && go test ./... -race diff --git a/README.md b/README.md index cbe00a1..a0e26d3 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ Three main components: - [ ] Phase 2b.5: ZMQ block notifier, SQLite persistence (deferred until s9pk repo exists — need real Go build env for new deps) - [x] ckpool patch 0001: expose `bestever` in runtime socket JSON so the UI can show "this round" and "all-time" best share side by side - [~] **Phase 3** — Svelte UI dashboard (skeleton: header, pool overview, miners table, blocks, best shares leaderboard; live WS updates) -- [ ] **Phase 4** — Monorepo Docker build, full stack integration +- [x] **Phase 4** — Monorepo Docker build: `kamado-api` embeds `ui/dist` via `//go:embed` and serves it at `/`. The api Dockerfile has a node stage that builds the UI before the Go stage embeds and builds the binary; docker-compose uses the repo root as build context so both `api/` and `ui/` are visible. - [ ] **Phase 5** — Testing (regtest, testnet4), polish ## Quick start (dev) diff --git a/api/Dockerfile b/api/Dockerfile index 4377fcc..12620f1 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -1,24 +1,57 @@ # ============================================================================ -# kamado-api build stage +# kamado-api build +# +# Stage 1 (ui): Build the Svelte dashboard to ui/dist. +# Stage 2 (go): Build the Go binary, embedding the UI via //go:embed. +# Stage 3 (run): Minimal debian with tini and the static binary. +# +# The build context for this Dockerfile is the ./api directory by +# default, which is fine for stage 2. But stage 1 needs the ui/ +# directory from the repo root. docker-compose sets `context: .` at +# the repo root and `dockerfile: api/Dockerfile` to make both visible; +# for plain `docker build ./api`, set --build-context repo=.. # ============================================================================ + +# ---------------------------------------------------------------------------- +# Stage 1: build the Svelte dashboard +# ---------------------------------------------------------------------------- +FROM node:22-bookworm-slim AS ui + +WORKDIR /ui +# Copy just the manifest first so npm install is cached across code +# edits. The build context must be the repo root — docker-compose +# uses `context: .`, and `make api` invokes docker build with the +# repo root as context. +COPY ui/package.json ./ +RUN npm install --no-audit --no-fund + +COPY ui/ ./ +RUN npm run build + +# ---------------------------------------------------------------------------- +# Stage 2: build the Go binary with the UI embedded +# ---------------------------------------------------------------------------- FROM golang:1.22-bookworm AS build WORKDIR /src -# Cache deps first -COPY go.mod ./ +COPY api/go.mod ./ RUN go mod download 2>/dev/null || true -COPY . . +COPY api/ . + +# Drop the built dashboard into the embed target before `go build` so +# //go:embed picks it up. We delete the committed placeholder first. +RUN rm -rf internal/webui/dist && mkdir -p internal/webui/dist +COPY --from=ui /ui/dist/ internal/webui/dist/ -# Static build — CGO off, stripped, reproducible-ish RUN CGO_ENABLED=0 GOOS=linux go build \ -trimpath \ -ldflags="-s -w" \ -o /out/kamado-api \ ./cmd/kamado-api -# ============================================================================ -# Runtime: distroless-ish minimal -# ============================================================================ +# ---------------------------------------------------------------------------- +# Stage 3: runtime +# ---------------------------------------------------------------------------- FROM debian:bookworm-slim AS runtime RUN apt-get update && apt-get install --no-install-recommends -y \ diff --git a/api/internal/httpapi/server.go b/api/internal/httpapi/server.go index 9b1e50a..76437e6 100644 --- a/api/internal/httpapi/server.go +++ b/api/internal/httpapi/server.go @@ -4,10 +4,14 @@ package httpapi import ( "encoding/json" + "errors" + "io/fs" "log/slog" "net/http" + "strings" "github.com/kamadopool/kamado-api/internal/state" + "github.com/kamadopool/kamado-api/internal/webui" ) type Server struct { @@ -20,7 +24,9 @@ func New(agg *state.Aggregator, log *slog.Logger) *Server { return &Server{Agg: agg, Hub: NewHub(), Log: log} } -// Handler returns an http.Handler with all kamado routes mounted under /api. +// Handler returns an http.Handler with all kamado routes mounted under +// /api and the embedded Svelte dashboard served under /. Unknown +// non-/api paths fall back to index.html for SPA-style routing. func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /api/health", s.health) @@ -31,9 +37,47 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/blocks", s.blocks) mux.HandleFunc("GET /api/snapshot", s.snapshot) mux.HandleFunc("GET /api/ws", s.handleWS) + mux.Handle("/", spaHandler(webui.FS())) return mux } +// spaHandler serves static files from the embedded dist tree and +// falls back to index.html on 404 so client-side routing works. It +// refuses anything under /api to keep the contract with mux patterns +// explicit (those routes register their own handlers above). +func spaHandler(root fs.FS) http.Handler { + fileServer := http.FileServerFS(root) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Defence-in-depth — /api routes are matched by the mux first + // with their GET patterns, but a bare POST /api/... would fall + // through here. Return 404 so we don't accidentally shadow + // API semantics with HTML. + if strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/api" { + http.NotFound(w, r) + return + } + + // Fast path: exact file exists in the embed. + clean := strings.TrimPrefix(r.URL.Path, "/") + if clean == "" { + clean = "index.html" + } + if _, err := fs.Stat(root, clean); err == nil { + fileServer.ServeHTTP(w, r) + return + } else if !errors.Is(err, fs.ErrNotExist) { + http.Error(w, "webui: "+err.Error(), http.StatusInternalServerError) + return + } + + // SPA fallback: serve index.html with a 200 so reloads on a + // client-side route don't 404. + r2 := r.Clone(r.Context()) + r2.URL.Path = "/" + fileServer.ServeHTTP(w, r2) + }) +} + func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Cache-Control", "no-store") diff --git a/api/internal/webui/dist/.gitignore b/api/internal/webui/dist/.gitignore new file mode 100644 index 0000000..6e95f3b --- /dev/null +++ b/api/internal/webui/dist/.gitignore @@ -0,0 +1,7 @@ +# Build artifacts from `make ui` / the Dockerfile's node stage land +# here during `go build`. Only the placeholder index.html (tracked) +# and this .gitignore are kept in source control; everything else is +# regenerated per build. +* +!.gitignore +!index.html diff --git a/api/internal/webui/dist/index.html b/api/internal/webui/dist/index.html new file mode 100644 index 0000000..da9a3d8 --- /dev/null +++ b/api/internal/webui/dist/index.html @@ -0,0 +1,55 @@ + + + + + Kamado Pool — UI not built + + + + +
+

🔥 Kamado Pool — UI not built

+

+ This is the placeholder shipped inside kamado-api when + the Svelte dashboard hasn't been built yet. Run + make ui && make api (or build the Docker image, + which does it automatically) to embed the real dashboard. +

+

+ The JSON API is still fully available at + /api/snapshot, + /api/health, etc. +

+
+ + diff --git a/api/internal/webui/embed.go b/api/internal/webui/embed.go new file mode 100644 index 0000000..e8f0e21 --- /dev/null +++ b/api/internal/webui/embed.go @@ -0,0 +1,39 @@ +// Package webui embeds the built Svelte dashboard into the kamado-api +// binary so the production image is a single Go binary plus ckpool. +// +// The `dist/` subdirectory is populated at build time: +// +// - `make api` (local) runs `make ui` first and copies ui/dist/* +// into api/internal/webui/dist/ before `go build`. +// - The api Dockerfile has a node builder stage that does the same +// inside the image build. +// +// A placeholder index.html is committed so `go build` succeeds on a +// fresh checkout without anyone having run `make ui` — it just shows +// a "UI not built" notice instead of the real dashboard. +package webui + +import ( + "embed" + "io/fs" +) + +// `//go:embed dist` (without `all:`) skips dot-prefixed files, which +// means the .gitignore we use to untrack build artifacts in this +// directory doesn't end up baked into the binary. +// +//go:embed dist +var distFS embed.FS + +// FS returns the embedded dist directory as a sub-filesystem so +// callers can pass it directly to http.FileServerFS. +func FS() fs.FS { + sub, err := fs.Sub(distFS, "dist") + if err != nil { + // Only possible if the "dist" directory literally does not + // exist in the embed, which is a build-time error we'd see + // before the binary ran. + panic("webui: embedded dist missing: " + err.Error()) + } + return sub +} diff --git a/docker-compose.yml b/docker-compose.yml index 1996f50..22c0334 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,8 +50,9 @@ services: api: build: - context: ./api - dockerfile: Dockerfile + # Repo root so the Dockerfile can pull in both api/ and ui/. + context: . + dockerfile: api/Dockerfile container_name: kamado-api restart: unless-stopped depends_on: