feat: add health check and restore frontend routes

Health check endpoint:
- Add /health endpoint that pings database with 2-second timeout
- Returns 200 when DB connected, 503 when unavailable
- Provides true end-to-end health verification

Frontend routes restoration (routes removed in c5f327b):
- Add public routes: /, /login, /register with smart auth detection
- Add redirect routes: /bookshelf, /dashboard
- Add admin routes: /admin, /admin/profile, /admin/library
- Add SSR routes: /api/devices-page, /api/conflicts-page
- Add 'FRONTEND ROUTES - DO NOT DELETE' comment block to prevent future removal

Docker Compose healthcheck:
- Update to use curl on /health endpoint (pg_isready not in Alpine)
- Add 10s start_period for app initialization
- Accurately reflects app + database health status

All changes maintain backward compatibility and existing API behavior.
This commit is contained in:
2026-02-06 10:52:54 -05:00
parent 79373dd225
commit 2a7338200c
2 changed files with 225 additions and 2 deletions
+222
View File
@@ -623,6 +623,228 @@ func main() {
return c.HTML(http.StatusOK, buf.String())
})
// ============================================================================
// FRONTEND ROUTES - DO NOT DELETE
// These routes serve Server-Side Rendered (SSR) HTML pages for the web UI.
// They are NOT API endpoints and should NOT be removed during refactors.
// All authenticated frontend routes use the jwtMiddleware to validate tokens.
// ============================================================================
// Public routes for login and registration pages (no auth required)
e.GET("/login", func(c echo.Context) error {
var buf bytes.Buffer
err := templates.Login().Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
e.GET("/register", func(c echo.Context) error {
var buf bytes.Buffer
err := templates.Register().Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
// Root route - landing page with smart login detection
e.GET("/", func(c echo.Context) error {
var buf bytes.Buffer
tokenString := c.Request().Header.Get("Authorization")
if tokenString != "" && len(tokenString) > 7 && tokenString[:7] == "Bearer " {
tokenString = tokenString[7:]
} else {
cookie, err := c.Cookie("token")
if err == nil {
tokenString = cookie.Value
}
}
loggedIn := false
if tokenString != "" {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
return []byte(cfg.JWTSecret), nil
})
loggedIn = err == nil && token.Valid
}
err = templates.Index(loggedIn).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
// Public redirect routes - convenience shortcuts to authenticated routes
e.GET("/bookshelf", func(c echo.Context) error {
return c.Redirect(http.StatusTemporaryRedirect, "/api/bookshelf")
})
e.GET("/dashboard", func(c echo.Context) error {
return c.Redirect(http.StatusTemporaryRedirect, "/api/bookshelf")
})
// Admin area routes (authenticated, admin role required, SSR)
e.GET("/admin", handlers.AdminMiddleware(func(c echo.Context) error {
user, err := getTemplateUserWithTheme(c, queries)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading user")
}
var buf bytes.Buffer
err = templates.Admin(user).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
}))
e.GET("/admin/", handlers.AdminMiddleware(func(c echo.Context) error {
user, err := getTemplateUserWithTheme(c, queries)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading user")
}
var buf bytes.Buffer
err = templates.Admin(user).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
}))
e.GET("/admin/profile", handlers.AdminMiddleware(func(c echo.Context) error {
user, err := getTemplateUserWithTheme(c, queries)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading user")
}
var buf bytes.Buffer
err = templates.AdminProfile(user).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
}))
e.GET("/admin/library", handlers.AdminMiddleware(func(c echo.Context) error {
user, err := getTemplateUserWithTheme(c, queries)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading user")
}
var buf bytes.Buffer
err = templates.AdminLibrary(user).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
}))
// Devices management page (authenticated SSR route)
protected.GET("/devices-page", func(c echo.Context) error {
user, err := getTemplateUserWithTheme(c, queries)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading user")
}
deviceData, err := deviceHandler.GetDevicesData(c)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading devices")
}
pendingData, err := deviceHandler.GetPendingRegistrationsData(c)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading pending registrations")
}
devicesList := make([]templates.DeviceData, len(deviceData))
for i, d := range deviceData {
lastSync := ""
if d.LastSync != nil {
lastSync = d.LastSync.Format("2006-01-02T15:04:05Z07:00")
}
lastSeen := ""
if d.LastSeen != nil {
lastSeen = d.LastSeen.Format("2006-01-02T15:04:05Z07:00")
}
devicesList[i] = templates.DeviceData{
ID: d.ID.String(),
DeviceName: d.DeviceName,
DeviceType: d.DeviceType,
SyncEnabled: d.SyncEnabled,
LastSync: lastSync,
LastSeen: lastSeen,
}
}
pendingList := make([]templates.PendingRegistrationData, len(pendingData))
for i, p := range pendingData {
pendingList[i] = templates.PendingRegistrationData{
RegistrationID: p["registration_id"].(string),
DeviceName: p["device_name"].(string),
DeviceType: p["device_type"].(string),
ExpiresAt: p["expires_at"].(string),
}
}
var buf bytes.Buffer
err = templates.Devices(user, devicesList, pendingList).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
// Conflicts management page (authenticated SSR route)
protected.GET("/conflicts-page", func(c echo.Context) error {
user, err := getTemplateUserWithTheme(c, queries)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading user")
}
conflictsData, total, unresolved, err := conflictHandler.GetConflictsData(c)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading conflicts")
}
var buf bytes.Buffer
err = templates.Conflicts(user, conflictsData, total, unresolved).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
// ============================================================================
// HEALTH CHECK (public - no authentication required)
// ============================================================================
e.GET("/health", func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 2*time.Second)
defer cancel()
if err := dbPool.Ping(ctx); err != nil {
return c.JSON(http.StatusServiceUnavailable, map[string]string{
"status": "unhealthy",
"error": "database unavailable",
})
}
return c.JSON(http.StatusOK, map[string]string{
"status": "healthy",
"database": "connected",
})
})
// ============================================================================
// DOCUMENTATION ROUTES (public - no authentication required)
// ============================================================================
// Documentation routes (no authentication required)
docsHandler := docs.NewHTTPHandler("docs")
e.GET("/docs", docsHandler.DocsHome)