feat: ship Barkstack UI plugin
All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 6m32s
Test and Release PawSQL / test (push) Successful in 50s
Test and Release PawSQL / release (push) Successful in 8s

This commit is contained in:
2026-09-16 13:47:07 -04:00
parent cae9419cd5
commit 7d7133d6cc
16 changed files with 4034 additions and 19 deletions

3203
internal/adminui/dist/assets/entry.js vendored Normal file

File diff suppressed because one or more lines are too long

118
internal/adminui/handler.go Normal file
View File

@@ -0,0 +1,118 @@
// Package adminui serves PawSQL's Barkstack UI plugin and small read-only status API.
package adminui
import (
"embed"
"encoding/json"
"io/fs"
"mime"
"net/http"
"path"
"strings"
)
//go:embed dist
var embeddedUI embed.FS
const entryPath = "/barkstack/ui/assets/entry.js"
type manifest struct {
APIVersion string `json:"apiVersion"`
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Icon string `json:"icon"`
Mount string `json:"mount"`
Entry string `json:"entry"`
Element string `json:"element"`
Navigation []navigationItem `json:"navigation"`
}
type navigationItem struct {
Label string `json:"label"`
Path string `json:"path"`
Icon string `json:"icon"`
}
type RouteSummary struct {
Name string `json:"name"`
Hostname string `json:"hostname,omitempty"`
Managed bool `json:"managed"`
}
type statusResponse struct {
Status string `json:"status"`
Routes []RouteSummary `json:"routes"`
Endpoint string `json:"endpoint"`
}
type handler struct {
assets fs.FS
routes []RouteSummary
}
func Handler(routes []RouteSummary) http.Handler {
assets, err := fs.Sub(embeddedUI, "dist")
if err != nil {
panic(err)
}
copyRoutes := append([]RouteSummary(nil), routes...)
return &handler{assets: assets, routes: copyRoutes}
}
func (h *handler) ServeHTTP(response http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodGet && request.Method != http.MethodHead {
response.Header().Set("Allow", "GET, HEAD")
http.Error(response, "method not allowed", http.StatusMethodNotAllowed)
return
}
switch request.URL.Path {
case "/barkstack/ui/manifest.json":
h.serveJSON(response, request, manifest{
APIVersion: "barkstack.dev/ui/v1", ID: "pawsql", Name: "PawSQL",
Description: "PostgreSQL routing for Barkstack", Icon: "database", Mount: "/pawsql",
Entry: entryPath, Element: "barkstack-pawsql",
Navigation: []navigationItem{{Label: "PawSQL", Path: "/pawsql", Icon: "database"}},
})
case "/barkstack/ui/status.json":
h.serveJSON(response, request, statusResponse{Status: "running", Routes: h.routes, Endpoint: "*.pawsql.barkstack.dev:5432"})
default:
h.serveAsset(response, request)
}
}
func (h *handler) serveJSON(response http.ResponseWriter, request *http.Request, value any) {
response.Header().Set("Content-Type", "application/json")
response.Header().Set("Cache-Control", "no-store")
if request.Method == http.MethodHead {
return
}
_ = json.NewEncoder(response).Encode(value)
}
func (h *handler) serveAsset(response http.ResponseWriter, request *http.Request) {
const prefix = "/barkstack/ui/assets/"
if !strings.HasPrefix(request.URL.Path, prefix) {
http.NotFound(response, request)
return
}
assetName := strings.TrimPrefix(request.URL.Path, "/barkstack/ui/")
if assetName == "" || !fs.ValidPath(assetName) || path.Clean(assetName) != assetName || strings.Contains(assetName, "\\") {
http.NotFound(response, request)
return
}
contents, err := fs.ReadFile(h.assets, assetName)
if err != nil {
http.NotFound(response, request)
return
}
if contentType := mime.TypeByExtension(path.Ext(assetName)); contentType != "" {
response.Header().Set("Content-Type", contentType)
}
response.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
response.Header().Set("X-Content-Type-Options", "nosniff")
if request.Method == http.MethodHead {
return
}
_, _ = response.Write(contents)
}

View File

@@ -0,0 +1,62 @@
package adminui
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestManifestEndpointDescribesPawSQLPlugin(t *testing.T) {
response := httptest.NewRecorder()
Handler(nil).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/barkstack/ui/manifest.json", nil))
if response.Code != http.StatusOK {
t.Fatalf("status = %d", response.Code)
}
if got := response.Header().Get("Content-Type"); got != "application/json" {
t.Fatalf("content type = %q", got)
}
var got manifest
if err := json.Unmarshal(response.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.APIVersion != "barkstack.dev/ui/v1" || got.ID != "pawsql" || got.Entry != entryPath || got.Element != "barkstack-pawsql" {
t.Fatalf("manifest = %+v", got)
}
}
func TestEmbeddedEntryAssetIsServedWithJavaScriptContentType(t *testing.T) {
response := httptest.NewRecorder()
Handler(nil).ServeHTTP(response, httptest.NewRequest(http.MethodGet, entryPath, nil))
if response.Code != http.StatusOK {
t.Fatalf("status = %d", response.Code)
}
if got := response.Header().Get("Content-Type"); !strings.Contains(got, "javascript") {
t.Fatalf("content type = %q", got)
}
if response.Body.Len() == 0 {
t.Fatal("embedded entry asset is empty")
}
}
func TestStatusEndpointReportsConfiguredRoutes(t *testing.T) {
routes := []RouteSummary{{Name: "reporting", Hostname: "reports.example.com", Managed: true}}
response := httptest.NewRecorder()
Handler(routes).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/barkstack/ui/status.json", nil))
var got statusResponse
if err := json.Unmarshal(response.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.Status != "running" || len(got.Routes) != 1 || got.Routes[0] != routes[0] {
t.Fatalf("status response = %+v", got)
}
}
func TestUnknownAssetReturnsNotFound(t *testing.T) {
response := httptest.NewRecorder()
Handler(nil).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/barkstack/ui/assets/missing.js", nil))
if response.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", response.Code)
}
}