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 @@ + + +
+ +
+ 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. +
+