App v0.2.26 and gateway 0.1.20
Client: seek controls, Bazarr subtitle download and cast panel in the player; MDBList ratings strip; episode and schedule detail pages; series pace estimate; what's new panel; install-permission onboarding step; synced per-profile preferences; Emby outage banner. Gateway: rebuilt admin console (one fragment per page), preference history and restore, merged Continue Watching, Emby health probe, subtitle selection and Bazarr download, structured request logging with per-request identity, and embedded build version. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2675e6d82b
commit
4a4df7a73c
+147
-43
@@ -14,15 +14,18 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/appupdate"
|
||||
"github.com/ponzischeme89/memby/server/internal/buildinfo"
|
||||
"github.com/ponzischeme89/memby/server/internal/library"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
//go:embed admin.html
|
||||
var adminPage []byte
|
||||
|
||||
const adminCookieName = "memby_admin"
|
||||
|
||||
// adminActivityHeader is how the console distinguishes its own poll from a request an
|
||||
// operator caused. It is advice, not authority: a page that sets it always already holds
|
||||
// a valid session, so the only thing it can extend is its own sign-in.
|
||||
const adminActivityHeader = "X-Memby-Admin-Active"
|
||||
|
||||
// adminRoutes is the operator interface: library imports, the maintenance switch, and
|
||||
// row engagement. Disabled entirely when MEMBY_ADMIN_TOKEN is unset, so it cannot be
|
||||
// left exposed by accident.
|
||||
@@ -31,12 +34,25 @@ func (s *Server) adminRoutes() http.Handler {
|
||||
|
||||
mux.HandleFunc("GET /admin/{$}", s.handleAdminRoot)
|
||||
mux.HandleFunc("GET /admin/{page}", s.handleAdminPage)
|
||||
// One person's own page. It is a path rather than a query string so it can be linked,
|
||||
// bookmarked and returned to after a sign-in, like every other page here.
|
||||
mux.HandleFunc("GET /admin/accounts/{userID}", s.handleAdminAccountPage)
|
||||
// The settings history is its own page rather than a fifth card on the account: it is
|
||||
// a table with a row per change and an action per row, and it is read when something
|
||||
// has gone wrong rather than as part of ordinary account admin.
|
||||
mux.HandleFunc("GET /admin/accounts/{userID}/settings", s.handleAdminSettingsHistoryPage)
|
||||
mux.Handle("GET /admin/api/status", s.adminAuth(s.handleAdminStatus))
|
||||
mux.Handle("GET /admin/api/accounts", s.adminAuth(s.handleAdminAccounts))
|
||||
mux.Handle("PUT /admin/api/accounts/{userID}/devices/{deviceID}", s.adminAuth(s.handleAdminRenameDevice))
|
||||
mux.Handle("DELETE /admin/api/accounts/{userID}/devices/{deviceID}", s.adminAuth(s.handleAdminDeleteDevice))
|
||||
mux.Handle("DELETE /admin/api/accounts/{userID}/sessions", s.adminAuth(s.handleAdminDeleteAccount))
|
||||
mux.Handle("PUT /admin/api/accounts/{userID}/preferences", s.adminAuth(s.handleAdminPushPreferences))
|
||||
mux.Handle("DELETE /admin/api/accounts/{userID}/preferences", s.adminAuth(s.handleAdminResetPreferences))
|
||||
mux.Handle("GET /admin/api/accounts/{userID}/preferences/history", s.adminAuth(s.handleAdminPreferenceHistory))
|
||||
mux.Handle("POST /admin/api/accounts/{userID}/preferences/revisions/{revision}/restore",
|
||||
s.adminAuth(s.handleAdminRestorePreferences))
|
||||
mux.Handle("DELETE /admin/api/accounts/{userID}/recommendations", s.adminAuth(s.handleAdminResetRecommendations))
|
||||
mux.Handle("PUT /admin/api/accounts/{userID}/recommendations/prompt", s.adminAuth(s.handleAdminPromptRecommendations))
|
||||
mux.Handle("GET /admin/api/recommendations", s.adminAuth(s.handleAdminRecommendations))
|
||||
mux.Handle("GET /admin/api/analytics", s.adminAuth(s.handleAdminAnalytics))
|
||||
mux.Handle("GET /admin/api/events", s.adminAuth(s.handleAdminEvents))
|
||||
@@ -44,6 +60,7 @@ func (s *Server) adminRoutes() http.Handler {
|
||||
mux.Handle("POST /admin/api/sync", s.adminAuth(s.handleAdminSync))
|
||||
mux.Handle("POST /admin/api/for-you", s.adminAuth(s.handleAdminForYou))
|
||||
mux.Handle("POST /admin/api/maintenance", s.adminAuth(s.handleAdminMaintenance))
|
||||
mux.Handle("POST /admin/api/deployment-alert", s.adminAuth(s.handleAdminDeploymentAlert))
|
||||
mux.Handle("POST /admin/api/update-policy", s.adminAuth(s.handleAdminUpdatePolicy))
|
||||
mux.Handle("POST /admin/api/request-policy", s.adminAuth(s.handleAdminRequestPolicy))
|
||||
mux.Handle("POST /admin/api/playback-policy", s.adminAuth(s.handleAdminPlaybackPolicy))
|
||||
@@ -54,18 +71,12 @@ func (s *Server) adminRoutes() http.Handler {
|
||||
return mux
|
||||
}
|
||||
|
||||
var adminPages = map[string]bool{
|
||||
"accounts": true, "library": true, "recommendations": true, "requests": true,
|
||||
"features": true, "playback": true, "maintenance": true, "updates": true, "engagement": true,
|
||||
"ratings": true, "imports": true, "logs": true,
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminRoot(w http.ResponseWriter, r *http.Request) {
|
||||
if s.cfg.AdminToken == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin/features", http.StatusFound)
|
||||
http.Redirect(w, r, "/admin/overview", http.StatusFound)
|
||||
}
|
||||
|
||||
type adminRuntimeStatus struct {
|
||||
@@ -132,24 +143,73 @@ func (s *Server) adminAuth(h http.HandlerFunc) http.Handler {
|
||||
writeError(w, http.StatusUnauthorized, "invalid admin token")
|
||||
return
|
||||
}
|
||||
if browser && operatorPresent(r) {
|
||||
s.renewInstallerSession(w, r)
|
||||
}
|
||||
h(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// operatorPresent reports whether a request came from somebody at the keyboard rather
|
||||
// than from the console's own status poll. Only these extend the sign-in: the dashboard
|
||||
// refreshes itself on a timer, so renewing on any request at all would leave a tab
|
||||
// abandoned on a second monitor signed in forever. A mutation is a click by definition;
|
||||
// for reads the page says so itself, setting the header only while there has been recent
|
||||
// interaction with it.
|
||||
func operatorPresent(r *http.Request) bool {
|
||||
return r.Method != http.MethodGet ||
|
||||
strings.TrimSpace(r.Header.Get(adminActivityHeader)) == "1"
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminPage(w http.ResponseWriter, r *http.Request) {
|
||||
if s.cfg.AdminToken == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
// A hidden page is reachable only through the route that knows how to address it: an
|
||||
// account page with nobody to be about is not a page.
|
||||
page := strings.TrimSpace(r.PathValue("page"))
|
||||
if !adminPages[page] {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if !s.validInstallerSession(r) {
|
||||
s.renderAccessLogin(w, r, "", http.StatusOK, "/admin/"+page)
|
||||
s.serveAdminPage(w, r, page, "/admin/"+page)
|
||||
}
|
||||
|
||||
// The account page is served from its own route because the identity is in the path. The
|
||||
// sign-in returns to /admin/accounts rather than to this URL: cleanInstallerDestination only
|
||||
// admits the pages it can name, and a person's id is not one of them.
|
||||
func (s *Server) handleAdminAccountPage(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.TrimSpace(r.PathValue("userID")) == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
s.serveAdminPage(w, r, "account", "/admin/accounts")
|
||||
}
|
||||
|
||||
// Hidden for the same reason the account page is: a settings history with nobody to be
|
||||
// about is not a page, so the identity is in the path and a sign-in returns to the account
|
||||
// list rather than here.
|
||||
func (s *Server) handleAdminSettingsHistoryPage(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.TrimSpace(r.PathValue("userID")) == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
s.serveAdminPage(w, r, "settings-history", "/admin/accounts")
|
||||
}
|
||||
|
||||
func (s *Server) serveAdminPage(w http.ResponseWriter, r *http.Request, page, next string) {
|
||||
if s.cfg.AdminToken == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
body, ok := adminRendered[page]
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if !s.validInstallerSession(r) {
|
||||
s.renderAccessLogin(w, r, "", http.StatusOK, next)
|
||||
return
|
||||
}
|
||||
// Opening a page is somebody at the keyboard, so it starts the clock again.
|
||||
s.renewInstallerSession(w, r)
|
||||
secure := r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: adminCookieName,
|
||||
@@ -163,10 +223,13 @@ func (s *Server) handleAdminPage(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
preventDiscovery(w)
|
||||
_, _ = w.Write(adminPage)
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
type adminStatus struct {
|
||||
// ServerVersion is what the page's footer reports. An operator reading the live log
|
||||
// needs to know which build wrote it, and the page is the one place that is asked.
|
||||
ServerVersion string `json:"serverVersion"`
|
||||
Maintenance store.Maintenance `json:"maintenance"`
|
||||
UpdatePolicy appupdate.Policy `json:"updatePolicy"`
|
||||
Library store.LibraryStats `json:"library"`
|
||||
@@ -190,13 +253,13 @@ func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
stats, err := s.store.LibraryStats(ctx)
|
||||
if err != nil {
|
||||
s.log.Error("library stats failed", "error", err)
|
||||
s.loggerFor(ctx).Error("library stats failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not read library stats")
|
||||
return
|
||||
}
|
||||
runs, err := s.store.RecentSyncRuns(ctx, 10)
|
||||
if err != nil {
|
||||
s.log.Error("sync history failed", "error", err)
|
||||
s.loggerFor(ctx).Error("sync history failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not read sync history")
|
||||
return
|
||||
}
|
||||
@@ -206,23 +269,24 @@ func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if s.forYou != nil {
|
||||
forYouStats, err = s.forYou.Stats(ctx)
|
||||
if err != nil {
|
||||
s.log.Warn("For You stats failed", "error", err)
|
||||
s.loggerFor(ctx).Warn("For You stats failed", "error", err)
|
||||
}
|
||||
forYouRunning = s.forYou.Running()
|
||||
}
|
||||
requestPolicy, err := s.store.RequestPolicy(ctx)
|
||||
if err != nil {
|
||||
s.log.Warn("request policy read failed", "error", err)
|
||||
s.loggerFor(ctx).Warn("request policy read failed", "error", err)
|
||||
}
|
||||
requestUsers, err := s.store.KnownUsers(ctx)
|
||||
if err != nil {
|
||||
s.log.Warn("known users read failed", "error", err)
|
||||
s.loggerFor(ctx).Warn("known users read failed", "error", err)
|
||||
}
|
||||
clients, err := s.store.KnownClients(ctx)
|
||||
if err != nil {
|
||||
s.log.Warn("known clients read failed", "error", err)
|
||||
s.loggerFor(ctx).Warn("known clients read failed", "error", err)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, adminStatus{
|
||||
ServerVersion: buildinfo.Version(),
|
||||
Maintenance: s.maintenance.get(),
|
||||
UpdatePolicy: s.updatePolicy.get(),
|
||||
Library: stats,
|
||||
@@ -235,21 +299,29 @@ func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) {
|
||||
PlaybackPolicy: func() store.PlaybackPolicy {
|
||||
policy, policyErr := s.store.PlaybackPolicy(ctx)
|
||||
if policyErr != nil {
|
||||
s.log.Warn("playback policy read failed", "error", policyErr)
|
||||
s.loggerFor(ctx).Warn("playback policy read failed", "error", policyErr)
|
||||
return store.DefaultPlaybackPolicy()
|
||||
}
|
||||
return policy
|
||||
}(),
|
||||
Features: featurePayload(s.currentFeaturePolicy(ctx), membyProtocolVersion),
|
||||
Features: featurePayload(s.currentFeaturePolicy(ctx), ProtocolVersion),
|
||||
RequestUsers: requestUsers,
|
||||
Clients: clients,
|
||||
MDBList: func() mdblistAdminSettings {
|
||||
settings, settingsErr := s.store.MDBListSettings(ctx)
|
||||
if settingsErr != nil {
|
||||
s.log.Warn("MDBList settings read failed", "error", settingsErr)
|
||||
s.loggerFor(ctx).Warn("MDBList settings read failed", "error", settingsErr)
|
||||
settings = store.DefaultMDBListSettings()
|
||||
}
|
||||
return publicMDBListSettings(settings)
|
||||
public := publicMDBListSettings(settings)
|
||||
// How much of the catalogue is already stored is the only way to see whether
|
||||
// the integration is still spending the operator's daily allowance.
|
||||
if total, stale, statsErr := s.store.MediaRatingsStats(
|
||||
ctx, time.Now().Add(-ratingsRefreshInterval),
|
||||
); statsErr == nil {
|
||||
public.CachedTitles, public.StaleTitles = total, stale
|
||||
}
|
||||
return public
|
||||
}(),
|
||||
SonarrReady: s.sonarr != nil,
|
||||
RadarrReady: s.radarr != nil,
|
||||
@@ -261,6 +333,8 @@ type mdblistAdminSettings struct {
|
||||
APIKeyConfigured bool `json:"apiKeyConfigured"`
|
||||
Sources []string `json:"sources"`
|
||||
AvailableSources []string `json:"availableSources"`
|
||||
CachedTitles int `json:"cachedTitles"`
|
||||
StaleTitles int `json:"staleTitles"`
|
||||
}
|
||||
|
||||
type mdblistSettingsRequest struct {
|
||||
@@ -321,16 +395,17 @@ func (s *Server) handleAdminMDBListSettings(w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
next := store.MDBListSettings{Enabled: req.Enabled, APIKey: apiKey, Sources: sources}
|
||||
if err := s.store.SetMDBListSettings(r.Context(), next); err != nil {
|
||||
s.log.Error("MDBList settings write failed", "error", err)
|
||||
s.loggerFor(r.Context()).Error("MDBList settings write failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not save MDBList settings")
|
||||
return
|
||||
}
|
||||
s.forgetMDBListSettings()
|
||||
stored, err := s.store.MDBListSettings(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not reload MDBList settings")
|
||||
return
|
||||
}
|
||||
s.log.Info("MDBList settings changed", "enabled", stored.Enabled, "sources", len(stored.Sources),
|
||||
s.loggerFor(r.Context()).Info("MDBList settings changed", "enabled", stored.Enabled, "sources", len(stored.Sources),
|
||||
"api_key_configured", stored.APIKey != "")
|
||||
writeJSON(w, http.StatusOK, publicMDBListSettings(stored))
|
||||
}
|
||||
@@ -354,7 +429,7 @@ func (s *Server) handleAdminPlaybackPolicy(w http.ResponseWriter, r *http.Reques
|
||||
PrerollEnabled: req.PrerollEnabled, PrerollDurationMs: req.PrerollDurationMs,
|
||||
}
|
||||
if err := s.store.SetPlaybackPolicy(r.Context(), policy); err != nil {
|
||||
s.log.Error("playback policy write failed", "error", err)
|
||||
s.loggerFor(r.Context()).Error("playback policy write failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not save playback policy")
|
||||
return
|
||||
}
|
||||
@@ -363,7 +438,7 @@ func (s *Server) handleAdminPlaybackPolicy(w http.ResponseWriter, r *http.Reques
|
||||
writeError(w, http.StatusInternalServerError, "could not reload playback policy")
|
||||
return
|
||||
}
|
||||
s.log.Info("playback policy changed", "preroll_enabled", stored.PrerollEnabled,
|
||||
s.loggerFor(r.Context()).Info("playback policy changed", "preroll_enabled", stored.PrerollEnabled,
|
||||
"preroll_duration_ms", stored.PrerollDurationMs)
|
||||
writeJSON(w, http.StatusOK, stored)
|
||||
}
|
||||
@@ -403,11 +478,11 @@ func (s *Server) handleAdminRequestPolicy(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
policy := store.RequestPolicy{AllowedUserIDs: allowed}
|
||||
if err := s.store.SetRequestPolicy(r.Context(), policy); err != nil {
|
||||
s.log.Error("request policy write failed", "error", err)
|
||||
s.loggerFor(r.Context()).Error("request policy write failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not save request access")
|
||||
return
|
||||
}
|
||||
s.log.Info("media request access changed", "users", len(allowed))
|
||||
s.loggerFor(r.Context()).Info("media request access changed", "users", len(allowed))
|
||||
writeJSON(w, http.StatusOK, policy)
|
||||
}
|
||||
|
||||
@@ -464,15 +539,15 @@ func (s *Server) handleAdminUpdatePolicy(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
if err := s.store.SetUpdatePolicy(r.Context(), policy); err != nil {
|
||||
s.log.Error("update policy write failed", "error", err)
|
||||
s.loggerFor(r.Context()).Error("update policy write failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not save the update policy")
|
||||
return
|
||||
}
|
||||
if err := s.LoadUpdatePolicy(r.Context()); err != nil {
|
||||
s.log.Warn("update policy reload failed", "error", err)
|
||||
s.loggerFor(r.Context()).Warn("update policy reload failed", "error", err)
|
||||
}
|
||||
|
||||
s.log.Info("update policy changed",
|
||||
s.loggerFor(r.Context()).Info("update policy changed",
|
||||
"enabled", policy.Enabled, "latest", policy.LatestVersion, "minimum", policy.MinimumVersion)
|
||||
writeJSON(w, http.StatusOK, s.updatePolicy.get())
|
||||
}
|
||||
@@ -502,7 +577,10 @@ func (s *Server) handleAdminSync(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(context.WithoutCancel(context.Background()), s.cfg.SyncTimeout)
|
||||
defer cancel()
|
||||
if _, err := s.syncer.Sync(ctx, req.Kind, "manual"); err != nil {
|
||||
s.log.Error("manual sync failed", "kind", req.Kind, "error", err)
|
||||
// Detached from the request on purpose, so the identity in the request
|
||||
// context is gone by now; name the area explicitly instead.
|
||||
s.log.Error("manual sync failed",
|
||||
"component", "admin", "kind", req.Kind, "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -542,12 +620,14 @@ func (s *Server) handleAdminForYou(w http.ResponseWriter, r *http.Request) {
|
||||
if req.Action != "rebuild-all" {
|
||||
_, err := s.forYou.Import(ctx, req.Action == "full-import")
|
||||
if err != nil {
|
||||
s.log.Error("manual Tracearr import failed", "action", req.Action, "error", err)
|
||||
s.log.Error("manual Tracearr import failed",
|
||||
"component", "admin", "action", req.Action, "error", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := s.forYou.RebuildAll(ctx, true); err != nil {
|
||||
s.log.Error("manual For You rebuild failed", "action", req.Action, "error", err)
|
||||
s.log.Error("manual For You rebuild failed",
|
||||
"component", "admin", "action", req.Action, "error", err)
|
||||
}
|
||||
}()
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{
|
||||
@@ -555,6 +635,30 @@ func (s *Server) handleAdminForYou(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// handleAdminDeploymentAlert announces that this gateway is about to be replaced.
|
||||
//
|
||||
// It takes no body and says the same thing every time: the caller is `deploy-server.ps1`,
|
||||
// which runs before the old container is stopped, and a deployment script has no business
|
||||
// deciding what appears over somebody's film. It is deliberately outside the maintenance
|
||||
// switch — a deployment is not maintenance mode, and the point is to say so while the app
|
||||
// is still working.
|
||||
func (s *Server) handleAdminDeploymentAlert(w http.ResponseWriter, r *http.Request) {
|
||||
if s.cache == nil {
|
||||
// Alerts live in Redis; with none there is nowhere to publish to, and the deploy
|
||||
// must be told rather than believing every TV was warned.
|
||||
writeError(w, http.StatusServiceUnavailable, "alerts are unavailable without Redis")
|
||||
return
|
||||
}
|
||||
s.AnnounceDeployment(r.Context())
|
||||
s.loggerFor(r.Context()).Warn("deployment announced to clients",
|
||||
"component", "admin", "window", deploymentAlertWindow.String())
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{
|
||||
"status": "announced",
|
||||
"title": deploymentAlertTitle,
|
||||
"message": deploymentAlertMessage,
|
||||
})
|
||||
}
|
||||
|
||||
type maintenanceRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Message string `json:"message"`
|
||||
@@ -569,15 +673,15 @@ func (s *Server) handleAdminMaintenance(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
state := store.Maintenance{Enabled: req.Enabled, Message: strings.TrimSpace(req.Message)}
|
||||
if err := s.store.SetMaintenance(r.Context(), state); err != nil {
|
||||
s.log.Error("maintenance write failed", "error", err)
|
||||
s.loggerFor(r.Context()).Error("maintenance write failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not update maintenance mode")
|
||||
return
|
||||
}
|
||||
if err := s.LoadMaintenance(r.Context()); err != nil {
|
||||
s.log.Warn("maintenance reload failed", "error", err)
|
||||
s.loggerFor(r.Context()).Warn("maintenance reload failed", "error", err)
|
||||
}
|
||||
|
||||
s.log.Warn("maintenance mode changed", "enabled", state.Enabled, "message", state.Message)
|
||||
s.loggerFor(r.Context()).Warn("maintenance mode changed", "enabled", state.Enabled, "message", state.Message)
|
||||
writeJSON(w, http.StatusOK, s.maintenance.get())
|
||||
}
|
||||
|
||||
@@ -587,7 +691,7 @@ func (s *Server) handleAdminAnalytics(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
stats, err := s.store.RowStats(r.Context(), since)
|
||||
if err != nil {
|
||||
s.log.Error("row stats failed", "error", err)
|
||||
s.loggerFor(r.Context()).Error("row stats failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not read analytics")
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user