Watch
1
0
Fork
You've already forked RedFlag
0

feat: embed web UI in server binary + local trigger-scan write endpoint

Server becomes self-contained: web/dist embedded via go:embed
(server/internal/webui), SPA served from the binary with JSON-404 guard on
/api paths, nginx web container removed from compose (31336 now maps to the
server). Clean checkouts without the UI copy build API-only.

Agent local API gains its first write endpoint, POST /v1/actions/trigger-scan
(FEAT-002 write path): group-ACL authorized, single-flight, 202/409/503
semantics. Registered agents run the same HandleScanUpdates path as a signed
scan command (empty command_id, no ack tracking); standalone agents scan
through the orchestrator into the local read model only. Also repairs
localapi tests left uncompilable by the desktop-provider parameter.
This commit is contained in:
Fimeg 2026-06-10 08:38:18 -04:00
commit 1241c1ef01
14 changed files with 476 additions and 38 deletions

4
.gitignore vendored
View file

@ -153,6 +153,10 @@ out
.nuxt
dist
web/dist-desktop/
# embedded web UI build target — keep the dir, never its contents
!server/internal/webui/dist/
server/internal/webui/dist/*
!server/internal/webui/dist/.gitkeep
desktop/target/
# vuepress build output

View file

@ -110,15 +110,10 @@ Before a package is installed: the agent fetches the expected SHA-256 from the s
## Architecture
```
┌─────────────────┐
│ Web Dashboard │ React + TypeScript
│ Port: 31336 │
└────────┬────────┘
│ HTTPS + JWT + Machine Binding
┌────────▼────────┐
│ Server (Go) │ PostgreSQL · Ed25519 Signing Service
│ Port: 31337 │
└────────┬────────┘
┌─────────────────────────────┐
│ Server (Go) │ PostgreSQL · Ed25519 Signing Service
│ Embedded React dashboard │ Dashboard: 31336 · Agent API: 31337
└────────┬────────────────────┘
│ Pull-based (agents check in, not the reverse)
├──────────────────┐
┌────────▼────────┐ ┌──────▼──────────┐

View file

@ -7,6 +7,7 @@ import (
"log"
"math/rand"
"os"
"sync/atomic"
"time"
"github.com/Fimeg/RedFlag/agent/internal/acknowledgment"
@ -183,9 +184,28 @@ func RunPollingLoop(loopCtx *LoopContext) error {
ctx := loopCtx
// FEAT-002 write path: single-flight scan trigger for the local API.
// Authorization is the socket/pipe group ACL; the scan itself runs through
// the same handler primitives a signed server scan command uses.
var scanInFlight atomic.Bool
triggerScan := func(source string) error {
if !scanInFlight.CompareAndSwap(false, true) {
return localapi.ErrScanInFlight
}
go func() {
defer recovery.Recover("local_triggered_scan")
defer scanInFlight.Store(false)
if err := handlers.HandleLocalTriggeredScan(ctx.APIClient, ctx.Cfg, ctx.AckTracker, ctx.ScanOrchestrator, source); err != nil {
log.Printf("[ERROR] [agent] [localapi] local_scan_failed source=%s error=%v", source, err)
}
}()
return nil
}
localAPIServer, err := localapi.Start(localapi.Options{
Config: ctx.Cfg,
DesktopProvider: ctx.DesktopManager,
TriggerScan: triggerScan,
})
if err != nil {
log.Printf("[ERROR] [agent] [localapi] start_failed error=%v", err)

View file

@ -0,0 +1,83 @@
package handlers
import (
"context"
"fmt"
"log"
"runtime"
"strings"
"github.com/Fimeg/RedFlag/agent/internal/acknowledgment"
"github.com/Fimeg/RedFlag/agent/internal/client"
"github.com/Fimeg/RedFlag/agent/internal/config"
"github.com/Fimeg/RedFlag/agent/internal/orchestrator"
"github.com/Fimeg/RedFlag/agent/internal/scanner"
)
// HandleLocalTriggeredScan runs the full package-update scan on behalf of the
// local API write path (FEAT-002). There is no signed server command behind
// it, so no command ID exists and nothing is ack-tracked.
//
// Registered agents run the same HandleScanUpdates path a server scan command
// uses — results are reported so the server ingests and reconciles them
// (RECONCILE-001 close-by-absence included). Unregistered (standalone) agents
// run the scanners through the orchestrator and record the local read model
// only; no doomed HTTP calls are attempted.
func HandleLocalTriggeredScan(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, orch *orchestrator.Orchestrator, source string) error {
log.Printf("[INFO] [agent] [localapi] local_scan_started source=%s registered=%v", source, cfg.IsRegistered())
if cfg.IsRegistered() {
return HandleScanUpdates(apiClient, cfg, ackTracker, orch, "")
}
return runStandaloneUpdateScan(cfg, orch)
}
// runStandaloneUpdateScan mirrors HandleScanUpdates's scanner set (the virtual
// "updates" subsystem) without any server reporting: orchestrator-managed
// scans (circuit breakers, timeouts) feeding the local read model.
func runStandaloneUpdateScan(cfg *config.Config, orch *orchestrator.Orchestrator) error {
ctx := context.Background()
var results []orchestrator.ScanResult
var errs []string
type updateScanner struct {
name string
available func() bool
}
var candidates []updateScanner
switch runtime.GOOS {
case "linux":
candidates = []updateScanner{
{"apt", scanner.NewAPTScanner().IsAvailable},
{"dnf", scanner.NewDNFScanner().IsAvailable},
}
case "windows":
candidates = []updateScanner{
{"windows", scanner.NewWindowsUpdateScanner().IsAvailable},
{"winget", scanner.NewWingetScanner().IsAvailable},
}
}
ran := 0
for _, cand := range candidates {
if !cand.available() {
continue
}
ran++
result, err := orch.ScanSingle(ctx, cand.name)
results = append(results, result)
if err != nil {
errs = append(errs, fmt.Sprintf("%s: %v", cand.name, err))
}
}
recordLocalScanResults(cfg, results, true)
if len(errs) > 0 {
err := fmt.Errorf("standalone scan errors: %s", strings.Join(errs, "; "))
log.Printf("[ERROR] [agent] [localapi] standalone_scan_failed scanners_run=%d error=%v", ran, err)
return err
}
log.Printf("[INFO] [agent] [localapi] standalone_scan_completed scanners_run=%d", ran)
return nil
}

View file

@ -22,8 +22,14 @@ func mapAny[T any](v interface{}) T {
return r
}
// reportLogWithAck reports a command log to the server and tracks it for acknowledgment
// reportLogWithAck reports a command log to the server and tracks it for acknowledgment.
// A log with no command ID (locally triggered scan — no signed server command behind it)
// is reported without ack tracking: there is no command completion to guarantee.
func reportLogWithAck(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, logReport client.LogReport) error {
if logReport.CommandID == "" {
return apiClient.ReportLog(cfg.AgentID, logReport)
}
// Track this command result as pending acknowledgment
ackTracker.Add(logReport.CommandID)

View file

@ -23,6 +23,10 @@ const (
DefaultWindowsPipeName = `\\.\pipe\RedFlagAgentLocal`
)
// ErrScanInFlight is returned by a TriggerScan callback while a previously
// triggered scan is still running. The handler maps it to 409 Conflict.
var ErrScanInFlight = errors.New("localapi: scan already in flight")
// Options configures the local read-only API. Group, socket, and pipe defaults
// are platform-specific and enforced by the listener implementation.
type Options struct {
@ -34,6 +38,11 @@ type Options struct {
RequestLog func(format string, args ...interface{})
ListenerOverride net.Listener
DesktopProvider DesktopStatusProvider
// TriggerScan enqueues a package-update scan through the agent's existing
// scanner primitives (FEAT-002 write path). Authorization is the OS-local
// group boundary on the socket/pipe — anyone who can connect may trigger.
// Nil disables the endpoint (503). Return ErrScanInFlight to signal 409.
TriggerScan func(source string) error
}
// Server owns the local API listener and HTTP server.
@ -57,7 +66,7 @@ func Start(opts Options) (*Server, error) {
opts.RequestLog = log.Printf
}
handler := newHandler(opts.Config, opts.LoadCache, opts.DesktopProvider)
handler := newHandler(opts.Config, opts.LoadCache, opts.DesktopProvider, opts.TriggerScan)
httpServer := &http.Server{Handler: handler}
listener := opts.ListenerOverride
@ -100,9 +109,10 @@ func (s *Server) Stop() {
}
type handler struct {
cfg *config.Config
loadCache func() (*cache.LocalCache, error)
desktop DesktopStatusProvider
cfg *config.Config
loadCache func() (*cache.LocalCache, error)
desktop DesktopStatusProvider
triggerScan func(source string) error
}
// DesktopStatusProvider allows the desktop manager to report its status.
@ -162,8 +172,8 @@ type TokenResponse struct {
Capabilities cache.CapabilityTokenState `json:"capabilities"`
}
func newHandler(cfg *config.Config, loadCache func() (*cache.LocalCache, error), desktop DesktopStatusProvider) http.Handler {
h := &handler{cfg: cfg, loadCache: loadCache, desktop: desktop}
func newHandler(cfg *config.Config, loadCache func() (*cache.LocalCache, error), desktop DesktopStatusProvider, triggerScan func(source string) error) http.Handler {
h := &handler{cfg: cfg, loadCache: loadCache, desktop: desktop, triggerScan: triggerScan}
mux := http.NewServeMux()
mux.HandleFunc("/v1/identity", h.identity)
mux.HandleFunc("/v1/status", h.status)
@ -171,6 +181,7 @@ func newHandler(cfg *config.Config, loadCache func() (*cache.LocalCache, error),
mux.HandleFunc("/v1/packages", h.scansLatest)
mux.HandleFunc("/v1/tokens/active", h.tokensActive)
mux.HandleFunc("/v1/desktop", h.desktopHealth)
mux.HandleFunc("/v1/actions/trigger-scan", h.triggerScanAction)
return mux
}
@ -293,6 +304,43 @@ func (h *handler) desktopHealth(w http.ResponseWriter, r *http.Request) {
})
}
// triggerScanAction handles POST /v1/actions/trigger-scan — the first local
// write endpoint (FEAT-002). The OS-local group ACL on the socket/pipe is the
// authorization boundary. The scan runs through the agent's existing scanner
// primitives; this never bypasses server command-signing or package
// authorization paths because it cannot install anything.
func (h *handler) triggerScanAction(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if h.triggerScan == nil {
log.Printf("[WARNING] [agent] [localapi] scan_trigger_unavailable")
http.Error(w, "scan trigger unavailable", http.StatusServiceUnavailable)
return
}
err := h.triggerScan("localapi")
if errors.Is(err, ErrScanInFlight) {
log.Printf("[INFO] [agent] [localapi] scan_trigger_rejected reason=in_flight")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusConflict)
writeJSONBody(w, map[string]interface{}{"accepted": false, "error": "scan already in flight"})
return
}
if err != nil {
log.Printf("[ERROR] [agent] [localapi] scan_trigger_failed error=%v", err)
http.Error(w, "scan trigger failed", http.StatusInternalServerError)
return
}
log.Printf("[INFO] [agent] [localapi] scan_trigger_accepted source=localapi")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
writeJSONBody(w, map[string]interface{}{"accepted": true})
}
func (h *handler) load(w http.ResponseWriter) (*cache.LocalCache, bool) {
localCache, err := h.loadCache()
if err != nil {
@ -314,6 +362,12 @@ func requireGet(w http.ResponseWriter, r *http.Request) bool {
func writeJSON(w http.ResponseWriter, value interface{}) {
w.Header().Set("Content-Type", "application/json")
writeJSONBody(w, value)
}
// writeJSONBody encodes without touching headers — for callers that already
// wrote a non-200 status code.
func writeJSONBody(w http.ResponseWriter, value interface{}) {
if err := json.NewEncoder(w).Encode(value); err != nil {
log.Printf("[WARNING] [agent] [localapi] response_encode_failed error=%v", err)
}

View file

@ -19,7 +19,7 @@ func TestIdentityRedactsSecrets(t *testing.T) {
cfg := testConfig(t)
handler := newHandler(cfg, func() (*cache.LocalCache, error) {
return &cache.LocalCache{}, nil
})
}, nil, nil)
req := httptest.NewRequest(http.MethodGet, "/v1/identity", nil)
rec := httptest.NewRecorder()
@ -88,7 +88,7 @@ func TestStatusAndPackagesReadLocalCache(t *testing.T) {
}
handler := newHandler(testConfig(t), func() (*cache.LocalCache, error) {
return localCache, nil
})
}, nil, nil)
var status StatusResponse
requestJSON(t, handler, http.MethodGet, "/v1/status", &status)
@ -118,7 +118,7 @@ func TestStatusAndPackagesReadLocalCache(t *testing.T) {
func TestCacheLoadFailureReturnsServiceUnavailable(t *testing.T) {
handler := newHandler(testConfig(t), func() (*cache.LocalCache, error) {
return nil, errors.New("cannot read cache")
})
}, nil, nil)
req := httptest.NewRequest(http.MethodGet, "/v1/status", nil)
rec := httptest.NewRecorder()
@ -132,7 +132,7 @@ func TestCacheLoadFailureReturnsServiceUnavailable(t *testing.T) {
func TestOnlyGetMethodsAllowed(t *testing.T) {
handler := newHandler(testConfig(t), func() (*cache.LocalCache, error) {
return &cache.LocalCache{}, nil
})
}, nil, nil)
req := httptest.NewRequest(http.MethodPost, "/v1/status", nil)
rec := httptest.NewRecorder()

View file

@ -0,0 +1,88 @@
package localapi
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/Fimeg/RedFlag/agent/internal/cache"
)
func triggerHandler(t *testing.T, trigger func(source string) error) http.Handler {
t.Helper()
return newHandler(testConfig(t), func() (*cache.LocalCache, error) {
return &cache.LocalCache{}, nil
}, nil, trigger)
}
func postTrigger(t *testing.T, handler http.Handler) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/v1/actions/trigger-scan", nil))
return rec
}
func TestTriggerScanAccepted(t *testing.T) {
var gotSource string
handler := triggerHandler(t, func(source string) error {
gotSource = source
return nil
})
rec := postTrigger(t, handler)
if rec.Code != http.StatusAccepted {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusAccepted, rec.Body.String())
}
if gotSource != "localapi" {
t.Fatalf("source = %q, want localapi", gotSource)
}
var resp map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp["accepted"] != true {
t.Fatalf("accepted = %v, want true", resp["accepted"])
}
}
func TestTriggerScanInFlightIsConflict(t *testing.T) {
handler := triggerHandler(t, func(string) error { return ErrScanInFlight })
rec := postTrigger(t, handler)
if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusConflict)
}
}
func TestTriggerScanUnavailableWithoutCallback(t *testing.T) {
handler := triggerHandler(t, nil)
rec := postTrigger(t, handler)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusServiceUnavailable)
}
}
func TestTriggerScanFailureIs500(t *testing.T) {
handler := triggerHandler(t, func(string) error { return errors.New("boom") })
rec := postTrigger(t, handler)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusInternalServerError)
}
}
func TestTriggerScanRejectsGet(t *testing.T) {
handler := triggerHandler(t, func(string) error { return nil })
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/actions/trigger-scan", nil))
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusMethodNotAllowed)
}
if rec.Header().Get("Allow") != http.MethodPost {
t.Fatalf("Allow = %q, want POST", rec.Header().Get("Allow"))
}
}

View file

@ -37,30 +37,15 @@ services:
start_period: 15s
retries: 3
ports:
# 31337 = agent/API endpoint, 31336 = dashboard. Same process now —
# the UI is embedded in the server binary; the nginx web container is gone.
- "31337:8080"
- "31336:8080"
command: ["./redflag-server"]
restart: unless-stopped
env_file:
- ./config/.env
web:
build:
context: ./web
dockerfile: Dockerfile
container_name: redflag-web
ports:
- "31336:80"
depends_on:
server:
condition: service_started
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:80/"]
interval: 30s
timeout: 5s
start_period: 10s
retries: 3
restart: unless-stopped
volumes:
postgres-data:
server-data:

View file

@ -1,3 +1,14 @@
# Stage 0: Build the web dashboard for embedding into the server binary
FROM node:20-alpine AS web-builder
WORKDIR /web
COPY web/package.json web/package-lock.json ./
RUN npm ci --ignore-scripts
COPY web/ ./
RUN npx vite build
# Stage 1: Build server binary
FROM golang:1.25-alpine AS server-builder
@ -12,6 +23,9 @@ RUN go mod download
# Copy server contents to /app
COPY server/ ./
# Embed the dashboard build — server/internal/webui picks this up via go:embed
COPY --from=web-builder /web/dist ./internal/webui/dist
# Build server with version injection
RUN echo "Building server version: $BUILD_VERSION" && \
CGO_ENABLED=0 go build \

View file

@ -7,8 +7,11 @@ import (
"encoding/hex"
"flag"
"fmt"
"io/fs"
"log"
"net/http"
"os"
"path"
"path/filepath"
"runtime"
"strconv"
@ -31,6 +34,7 @@ import (
"github.com/Fimeg/RedFlag/server/internal/services/upstream"
"github.com/Fimeg/RedFlag/server/internal/taskrunner"
"github.com/Fimeg/RedFlag/server/internal/version"
"github.com/Fimeg/RedFlag/server/internal/webui"
"github.com/gin-gonic/gin"
"github.com/gofrs/uuid/v5"
)
@ -1001,6 +1005,8 @@ func main() {
gin.WrapH(metricsExporter),
)
registerWebUI(router)
// Add graceful shutdown for services
defer func() {
log.Println("Shutting down services...")
@ -1030,6 +1036,61 @@ func main() {
}
}
// registerWebUI serves the embedded dashboard build (server/internal/webui)
// from the server binary itself — no nginx, no separate web container. The
// frontend calls relative /api/v1 paths, so same-origin serving needs no
// frontend changes. Binaries built without the UI copy step run API-only.
func registerWebUI(router *gin.Engine) {
if !webui.Present() {
log.Printf("[INFO] [server] [webui] no embedded UI build in this binary — serving API only")
return
}
uiFS, err := webui.FS()
if err != nil {
log.Printf("[ERROR] [server] [webui] embedded UI unavailable: %v", err)
return
}
fileServer := http.FileServer(http.FS(uiFS))
router.NoRoute(func(c *gin.Context) {
p := c.Request.URL.Path
// Unmatched API/infra paths stay JSON 404s — never fall through to HTML.
if strings.HasPrefix(p, "/api/") || p == "/metrics" || p == "/health" {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
clean := strings.TrimPrefix(path.Clean(p), "/")
if clean == "" {
clean = "index.html"
}
if _, statErr := fs.Stat(uiFS, clean); statErr == nil {
// Vite asset filenames are content-hashed — safe to cache hard.
if strings.HasPrefix(clean, "assets/") {
c.Header("Cache-Control", "public, max-age=31536000, immutable")
}
fileServer.ServeHTTP(c.Writer, c.Request)
return
}
// Client-side route (e.g. /agents/123) — serve the SPA shell.
index, readErr := fs.ReadFile(uiFS, "index.html")
if readErr != nil {
log.Printf("[ERROR] [server] [webui] index.html read failed: %v", readErr)
c.JSON(http.StatusInternalServerError, gin.H{"error": "ui unavailable"})
return
}
c.Header("Cache-Control", "no-cache")
c.Data(http.StatusOK, "text/html; charset=utf-8", index)
})
log.Printf("[INFO] [server] [webui] embedded dashboard enabled")
}
// getOperationalSetting reads a timeout value from the DB settings with fallback to a default.
func getOperationalSetting(svc *services.SecuritySettingsService, key string, defaultVal int) int {
if svc == nil {

View file

@ -0,0 +1,94 @@
package main
import (
"io/fs"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Fimeg/RedFlag/server/internal/webui"
"github.com/gin-gonic/gin"
)
// These tests only run when a UI build is embedded (dist populated before
// compile). A clean checkout builds API-only and skips.
func newWebUIRouter(t *testing.T) *gin.Engine {
t.Helper()
if !webui.Present() {
t.Skip("no embedded UI build in this binary")
}
gin.SetMode(gin.TestMode)
router := gin.New()
registerWebUI(router)
return router
}
func TestWebUIServesIndexAtRoot(t *testing.T) {
router := newWebUIRouter(t)
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/", nil))
if w.Code != http.StatusOK {
t.Fatalf("GET / = %d, want 200", w.Code)
}
if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") {
t.Fatalf("GET / content-type = %q, want html", ct)
}
}
func TestWebUISPAFallbackForClientRoutes(t *testing.T) {
router := newWebUIRouter(t)
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/agents/123", nil))
if w.Code != http.StatusOK {
t.Fatalf("GET /agents/123 = %d, want 200 (SPA fallback)", w.Code)
}
if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") {
t.Fatalf("SPA fallback content-type = %q, want html", ct)
}
if cc := w.Header().Get("Cache-Control"); cc != "no-cache" {
t.Fatalf("SPA fallback cache-control = %q, want no-cache", cc)
}
}
func TestWebUIUnmatchedAPIPathStaysJSON404(t *testing.T) {
router := newWebUIRouter(t)
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/does-not-exist", nil))
if w.Code != http.StatusNotFound {
t.Fatalf("GET /api/v1/does-not-exist = %d, want 404", w.Code)
}
if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") {
t.Fatalf("API 404 content-type = %q, want json (must not leak SPA html)", ct)
}
}
func TestWebUINonGetIs404(t *testing.T) {
router := newWebUIRouter(t)
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/agents/123", nil))
if w.Code != http.StatusNotFound {
t.Fatalf("POST /agents/123 = %d, want 404", w.Code)
}
}
func TestWebUIHashedAssetsCacheHard(t *testing.T) {
router := newWebUIRouter(t)
uiFS, err := webui.FS()
if err != nil {
t.Fatalf("webui.FS: %v", err)
}
entries, err := fs.ReadDir(uiFS, "assets")
if err != nil || len(entries) == 0 {
t.Skip("no assets directory in embedded build")
}
asset := "/assets/" + entries[0].Name()
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, asset, nil))
if w.Code != http.StatusOK {
t.Fatalf("GET %s = %d, want 200", asset, w.Code)
}
if cc := w.Header().Get("Cache-Control"); !strings.Contains(cc, "immutable") {
t.Fatalf("asset cache-control = %q, want immutable", cc)
}
}

0
server/internal/webui/dist/.gitkeep vendored Normal file
View file

View file

@ -0,0 +1,34 @@
// Package webui embeds the production web dashboard build into the server
// binary so a native install needs no nginx and no separate web container.
//
// The build pipeline copies web/dist into this package's dist/ directory
// before `go build`. When that copy has not happened (plain `go build` from
// a clean checkout), the embed holds only the .gitkeep placeholder and the
// server runs API-only — Present() reports false and the caller logs it.
package webui
import (
"embed"
"io/fs"
)
//go:embed all:dist
var distFS embed.FS
// FS returns the embedded UI file tree rooted at the dist directory.
func FS() (fs.FS, error) {
return fs.Sub(distFS, "dist")
}
// Present reports whether a real UI build is embedded (index.html exists),
// as opposed to the empty placeholder tree from a UI-less build.
func Present() bool {
sub, err := FS()
if err != nil {
return false
}
if _, err := fs.Stat(sub, "index.html"); err != nil {
return false
}
return true
}