From 77e5a78d5a4d4e8d3c6e4ac72986313a7e827630 Mon Sep 17 00:00:00 2001 From: Mikkel Georgsen Date: Fri, 10 Apr 2026 01:17:03 +0000 Subject: [PATCH] feat(01-foundation-01): Go module init, chi server, go:embed SPA scaffold - Initialize module git.georgsen.dk/hwlab with Go 1.23 - Install chi v5.2.5, go-redis v9.18.0, viper v1.21.0, godotenv v1.5.1, uuid v1.6.0, go-netbox v4.3.0 - Create health handler GET /api/health returning {status:ok, version:0.1.0} - Create chi router with Logger/Recoverer/RealIP middleware and SPA fallback - Embed web/dist via assets.go at module root (go:embed cannot use .. paths) - Create stub web/dist/index.html with ClickHouse dark theme - TestHealth passes --- assets.go | 9 +++++ cmd/hwlab/main.go | 31 ++++++++++++++++ go.mod | 25 +++++++++++++ go.sum | 54 ++++++++++++++++++++++++++++ internal/api/handlers/health.go | 20 +++++++++++ internal/api/handlers/health_test.go | 31 ++++++++++++++++ internal/api/router.go | 30 ++++++++++++++++ web/dist/index.html | 19 ++++++++++ 8 files changed, 219 insertions(+) create mode 100644 assets.go create mode 100644 cmd/hwlab/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/api/handlers/health.go create mode 100644 internal/api/handlers/health_test.go create mode 100644 internal/api/router.go create mode 100644 web/dist/index.html diff --git a/assets.go b/assets.go new file mode 100644 index 0000000..74335a6 --- /dev/null +++ b/assets.go @@ -0,0 +1,9 @@ +package hwlab + +import "embed" + +// StaticFiles contains the embedded web/dist directory. +// This file lives at the project root so the go:embed path is valid. +// +//go:embed web/dist +var StaticFiles embed.FS diff --git a/cmd/hwlab/main.go b/cmd/hwlab/main.go new file mode 100644 index 0000000..d606c8a --- /dev/null +++ b/cmd/hwlab/main.go @@ -0,0 +1,31 @@ +package main + +import ( + "fmt" + "io/fs" + "log" + "net/http" + + hwlab "git.georgsen.dk/hwlab" + "git.georgsen.dk/hwlab/internal/api" + "git.georgsen.dk/hwlab/internal/config" +) + +func main() { + cfg, err := config.Load() + if err != nil { + log.Fatalf("config: %v", err) + } + + staticFS, err := fs.Sub(hwlab.StaticFiles, "web/dist") + if err != nil { + log.Fatalf("embed: %v", err) + } + + router := api.NewRouter(staticFS) + addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) + log.Printf("HWLab starting on %s", addr) + if err := http.ListenAndServe(addr, router); err != nil { + log.Fatalf("server: %v", err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ad9bf7a --- /dev/null +++ b/go.mod @@ -0,0 +1,25 @@ +module git.georgsen.dk/hwlab + +go 1.23.0 + +require ( + github.com/go-chi/chi/v5 v5.2.5 + github.com/joho/godotenv v1.5.1 + github.com/spf13/viper v1.21.0 +) + +require ( + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/sys v0.29.0 // indirect + golang.org/x/text v0.28.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..065561d --- /dev/null +++ b/go.sum @@ -0,0 +1,54 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/api/handlers/health.go b/internal/api/handlers/health.go new file mode 100644 index 0000000..6765982 --- /dev/null +++ b/internal/api/handlers/health.go @@ -0,0 +1,20 @@ +package handlers + +import ( + "encoding/json" + "net/http" +) + +type HealthResponse struct { + Status string `json:"status"` + Version string `json:"version"` +} + +func Health(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(HealthResponse{ + Status: "ok", + Version: "0.1.0", + }) +} diff --git a/internal/api/handlers/health_test.go b/internal/api/handlers/health_test.go new file mode 100644 index 0000000..efbbeda --- /dev/null +++ b/internal/api/handlers/health_test.go @@ -0,0 +1,31 @@ +package handlers_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "git.georgsen.dk/hwlab/internal/api/handlers" +) + +func TestHealth(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/health", nil) + w := httptest.NewRecorder() + handlers.Health(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp handlers.HealthResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Status != "ok" { + t.Errorf("expected status=ok, got %s", resp.Status) + } + if resp.Version != "0.1.0" { + t.Errorf("expected version=0.1.0, got %s", resp.Version) + } +} diff --git a/internal/api/router.go b/internal/api/router.go new file mode 100644 index 0000000..b1893bb --- /dev/null +++ b/internal/api/router.go @@ -0,0 +1,30 @@ +package api + +import ( + "io/fs" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + + "git.georgsen.dk/hwlab/internal/api/handlers" +) + +// NewRouter creates the chi router. staticFiles is the fs.FS rooted at web/dist, +// passed from main.go where the go:embed directive lives. +func NewRouter(staticFiles fs.FS) http.Handler { + r := chi.NewRouter() + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + r.Use(middleware.RealIP) + + r.Route("/api", func(r chi.Router) { + r.Get("/health", handlers.Health) + }) + + // SPA fallback — serve index.html for all non-API routes + fileServer := http.FileServer(http.FS(staticFiles)) + r.Handle("/*", fileServer) + + return r +} diff --git a/web/dist/index.html b/web/dist/index.html new file mode 100644 index 0000000..008b5e6 --- /dev/null +++ b/web/dist/index.html @@ -0,0 +1,19 @@ + + + + + + HWLab + + + +
+

HWLab

+

Backend is running. UI coming in Phase 3.

+
+ +