From d793e16f336189d38a9d43310713dd13e3b7c438 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Sat, 7 Feb 2026 15:42:07 -1000 Subject: Add build version footer, deploy ldflags, template test helper, and logs script - Display build commit hash in unobtrusive footer overlay - Inject buildCommit/buildTime via ldflags in deploy.sh - Add assertTemplateContains test helper, refactor existing template tests - Add scripts/logs for fetching production journalctl via SSH Co-Authored-By: Claude Opus 4.6 --- cmd/dashboard/main.go | 9 +++- deploy.sh | 5 ++- internal/handlers/handlers.go | 6 ++- internal/handlers/handlers_test.go | 87 ++++++++++++++++++++++++++++---------- scripts/logs | 11 +++++ test/acceptance_test.go | 2 +- web/templates/index.html | 4 ++ 7 files changed, 98 insertions(+), 26 deletions(-) create mode 100755 scripts/logs diff --git a/cmd/dashboard/main.go b/cmd/dashboard/main.go index 0553041..073364f 100644 --- a/cmd/dashboard/main.go +++ b/cmd/dashboard/main.go @@ -27,7 +27,14 @@ import ( "github.com/go-webauthn/webauthn/webauthn" ) +// Set via -ldflags at build time +var ( + buildCommit = "dev" + buildTime = "unknown" +) + func main() { + log.Printf("task-dashboard build=%s time=%s", buildCommit, buildTime) // Load .env file (ignore error if file doesn't exist - env vars might be set directly) _ = godotenv.Load() @@ -139,7 +146,7 @@ func main() { } // Initialize handlers - h := handlers.New(db, todoistClient, trelloClient, planToEatClient, googleCalendarClient, googleTasksClient, cfg) + h := handlers.New(db, todoistClient, trelloClient, planToEatClient, googleCalendarClient, googleTasksClient, cfg, buildCommit) // Set up router r := chi.NewRouter() diff --git a/deploy.sh b/deploy.sh index abd9cfc..a201d82 100755 --- a/deploy.sh +++ b/deploy.sh @@ -11,7 +11,10 @@ echo "==> Building CSS..." npm run css:build echo "==> Building binary (linux/amd64)..." -GOOS=linux GOARCH=amd64 go build -o app cmd/dashboard/main.go +BUILD_COMMIT=$(git rev-parse --short HEAD) +BUILD_TIME=$(date -u '+%Y-%m-%dT%H:%M:%SZ') +LDFLAGS="-X main.buildCommit=${BUILD_COMMIT} -X main.buildTime=${BUILD_TIME}" +GOOS=linux GOARCH=amd64 go build -ldflags "$LDFLAGS" -o app cmd/dashboard/main.go echo "==> Pushing code to ${REMOTE}..." git push ${REMOTE} master diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 650eeaa..226f117 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -29,10 +29,11 @@ type Handler struct { googleTasksClient api.GoogleTasksAPI config *config.Config renderer Renderer + BuildVersion string } // New creates a new Handler instance -func New(s *store.Store, todoist api.TodoistAPI, trello api.TrelloAPI, planToEat api.PlanToEatAPI, googleCalendar api.GoogleCalendarAPI, googleTasks api.GoogleTasksAPI, cfg *config.Config) *Handler { +func New(s *store.Store, todoist api.TodoistAPI, trello api.TrelloAPI, planToEat api.PlanToEatAPI, googleCalendar api.GoogleCalendarAPI, googleTasks api.GoogleTasksAPI, cfg *config.Config, buildVersion string) *Handler { // Template functions funcMap := template.FuncMap{ "subtract": func(a, b int) int { return a - b }, @@ -59,6 +60,7 @@ func New(s *store.Store, todoist api.TodoistAPI, trello api.TrelloAPI, planToEat googleTasksClient: googleTasks, config: cfg, renderer: NewTemplateRenderer(tmpl), + BuildVersion: buildVersion, } } @@ -96,11 +98,13 @@ func (h *Handler) HandleDashboard(w http.ResponseWriter, r *http.Request) { ActiveTab string CSRFToken string BackgroundURL string + BuildVersion string }{ DashboardData: dashboardData, ActiveTab: tab, CSRFToken: auth.GetCSRFTokenFromContext(ctx), BackgroundURL: backgroundURL, + BuildVersion: h.BuildVersion, } if err := h.renderer.Render(w, "index.html", data); err != nil { diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go index 1842c38..0ad36b2 100644 --- a/internal/handlers/handlers_test.go +++ b/internal/handlers/handlers_test.go @@ -1901,6 +1901,18 @@ func TestHandleTimeline_RendersDataToTemplate(t *testing.T) { } } +// assertTemplateContains reads a template file and asserts it contains the expected string. +func assertTemplateContains(t *testing.T, templatePath, expected, errMsg string) { + t.Helper() + content, err := os.ReadFile(templatePath) + if err != nil { + t.Skipf("Cannot read template file: %v", err) + } + if !strings.Contains(string(content), expected) { + t.Error(errMsg) + } +} + // ============================================================================= // Dashboard Content Verification Tests // ============================================================================= @@ -1945,34 +1957,18 @@ func TestHandleDashboard_ContainsSettingsLink(t *testing.T) { // contains a link to /settings. This catches regressions where the settings // button is accidentally removed. func TestDashboardTemplate_HasSettingsLink(t *testing.T) { - // Read the actual template file and verify it contains a settings link - content, err := os.ReadFile("../../web/templates/index.html") - if err != nil { - t.Skipf("Cannot read template file (running from unexpected directory): %v", err) - } - - templateStr := string(content) - - if !strings.Contains(templateStr, `href="/settings"`) { - t.Error("index.html must contain a link to /settings (settings button missing from UI)") - } + assertTemplateContains(t, "../../web/templates/index.html", + `href="/settings"`, + "index.html must contain a link to /settings (settings button missing from UI)") } // TestSettingsTemplate_HasCSRFToken verifies the settings page exposes a CSRF // token in a meta tag so that JavaScript (e.g. passkey registration) can include // it in POST requests. Without this, passkey registration fails with 403. func TestSettingsTemplate_HasCSRFToken(t *testing.T) { - content, err := os.ReadFile("../../web/templates/settings.html") - if err != nil { - t.Skipf("Cannot read template file: %v", err) - } - tmpl := string(content) - - // The template must include a meta tag or hidden input with the Go template - // variable .CSRFToken so JS can retrieve it - if !strings.Contains(tmpl, ".CSRFToken") { - t.Error("settings.html must expose .CSRFToken (e.g. via meta tag) for JavaScript passkey registration") - } + assertTemplateContains(t, "../../web/templates/settings.html", + ".CSRFToken", + "settings.html must expose .CSRFToken (e.g. via meta tag) for JavaScript passkey registration") } // TestHandleSettingsPage_PassesCSRFToken verifies the settings handler includes @@ -2007,6 +2003,53 @@ func TestHandleSettingsPage_PassesCSRFToken(t *testing.T) { } } +// TestHandleDashboard_PassesBuildVersion verifies the dashboard handler includes +// BuildVersion in the template data. +func TestHandleDashboard_PassesBuildVersion(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + mockTodoist := &mockTodoistClient{} + mockTrello := &mockTrelloClient{} + renderer := NewMockRenderer() + + h := &Handler{ + store: db, + todoistClient: mockTodoist, + trelloClient: mockTrello, + config: &config.Config{CacheTTLMinutes: 5}, + renderer: renderer, + BuildVersion: "abc123", + } + + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + + h.HandleDashboard(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("Expected status 200, got %d", w.Code) + } + + if len(renderer.Calls) == 0 { + t.Fatal("Expected renderer to be called") + } + + lastCall := renderer.Calls[len(renderer.Calls)-1] + dataStr := fmt.Sprintf("%+v", lastCall.Data) + if !strings.Contains(dataStr, "abc123") { + t.Error("Dashboard template data must include BuildVersion value") + } +} + +// TestDashboardTemplate_HasBuildVersion verifies that index.html contains +// a build version placeholder so users can see the deployed version. +func TestDashboardTemplate_HasBuildVersion(t *testing.T) { + assertTemplateContains(t, "../../web/templates/index.html", + ".BuildVersion", + "index.html must contain .BuildVersion to display the build version in the footer") +} + // TestTimelineTemplate_CheckboxesTargetSelf verifies that completion checkboxes // in the timeline calendar grid target their parent item, not #tab-content. func TestTimelineTemplate_CheckboxesTargetSelf(t *testing.T) { diff --git a/scripts/logs b/scripts/logs new file mode 100755 index 0000000..7cec0c6 --- /dev/null +++ b/scripts/logs @@ -0,0 +1,11 @@ +#!/bin/bash +# Fetch logs from the production task-dashboard service +# +# Usage: +# scripts/logs # last 100 lines +# scripts/logs -f # follow (tail -f) +# scripts/logs -n 500 # last 500 lines +# scripts/logs --since "1 hour ago" +# scripts/logs --grep "error" + +ssh titanium journalctl -u task-dashboard@doot.terst.org --no-pager "$@" diff --git a/test/acceptance_test.go b/test/acceptance_test.go index fb55be4..0bcf974 100644 --- a/test/acceptance_test.go +++ b/test/acceptance_test.go @@ -82,7 +82,7 @@ func setupTestServer(t *testing.T) (*httptest.Server, *store.Store, *http.Client } // Initialize handlers - h := handlers.New(db, todoistClient, trelloClient, nil, nil, nil, cfg) + h := handlers.New(db, todoistClient, trelloClient, nil, nil, nil, cfg, "test") // Set up router (same as main.go) r := chi.NewRouter() diff --git a/web/templates/index.html b/web/templates/index.html index 9c90570..2a07a32 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -308,6 +308,10 @@ +
+ {{.BuildVersion}} +
+ -- cgit v1.2.3