194 lines
7.1 KiB
Go
194 lines
7.1 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/ponzischeme89/memby/server/internal/adminevents"
|
|
"github.com/ponzischeme89/memby/server/internal/buildinfo"
|
|
"github.com/ponzischeme89/memby/server/internal/store"
|
|
)
|
|
|
|
// adminGatewaySettingsResponse is deliberately three things at once: what the operator has
|
|
// chosen, the deployed or server defaults, and what is therefore in force. A settings page
|
|
// that showed only the first would leave every empty environment-backed field looking like
|
|
// a value of nothing, when it means "whatever .env says" — and the operator has no other
|
|
// way to see that without opening a file on the NAS.
|
|
type adminGatewaySettingsResponse struct {
|
|
Settings store.GatewaySettings `json:"settings"`
|
|
Deployed deployedGatewaySettings `json:"deployed"`
|
|
Effective deployedGatewaySettings `json:"effective"`
|
|
// LogLevels is the vocabulary rather than a list the console keeps its own copy of,
|
|
// for the reason the preference catalogue is served with the accounts page: a value
|
|
// the server would refuse must never be offerable.
|
|
LogLevels []string `json:"logLevels"`
|
|
NotificationDisplays []string `json:"notificationDisplays"`
|
|
// Version and Timezone name the process this page is about, so the page can identify
|
|
// the gateway it is changing without a second request.
|
|
Version string `json:"version"`
|
|
}
|
|
|
|
func (s *Server) gatewaySettingsResponse() adminGatewaySettingsResponse {
|
|
settings := s.gatewaySettings.get()
|
|
return adminGatewaySettingsResponse{
|
|
Settings: settings,
|
|
Deployed: s.deployedSettings(),
|
|
Effective: deployedGatewaySettings{
|
|
Timezone: s.householdTimezoneName(),
|
|
LogLevel: s.effectiveLogLevel(),
|
|
SessionIdleDays: int(s.sessionIdleExpiry() / (24 * time.Hour)),
|
|
SonarrAlertMinutes: int(s.sonarrAlertWindow() / time.Minute),
|
|
RadarrAlertMinutes: int(s.radarrAlertWindow() / time.Minute),
|
|
NotificationDisplay: s.notificationDisplay(),
|
|
EmbyHealthSeconds: int(s.embyHealthInterval() / time.Second),
|
|
SlowRequestMillis: effectiveSlowRequestMillis(s.slowRequestThreshold()),
|
|
LibrarySyncMinutes: int(s.LibrarySyncInterval() / time.Minute),
|
|
HomeTTLSeconds: int(s.homeTTL() / time.Second),
|
|
RecommendTTLHours: int(s.recommendTTL() / time.Hour),
|
|
ForYouRebuildHour: s.forYouRebuildHour(),
|
|
},
|
|
LogLevels: store.GatewayLogLevels,
|
|
NotificationDisplays: store.GatewayNotificationDisplays,
|
|
Version: buildinfo.Version(),
|
|
}
|
|
}
|
|
|
|
// effectiveSlowRequestMillis reports the switched-off threshold as zero rather than as
|
|
// the impossible duration the reader uses, because the console draws the effective value
|
|
// beside the field an operator cleared and "0" is what its own vocabulary means by off.
|
|
func effectiveSlowRequestMillis(threshold time.Duration) int {
|
|
if threshold <= 0 || threshold > time.Hour {
|
|
return 0
|
|
}
|
|
return int(threshold / time.Millisecond)
|
|
}
|
|
|
|
func (s *Server) effectiveLogLevel() string {
|
|
if s.logLevel != nil {
|
|
return levelName(s.logLevel.Level())
|
|
}
|
|
return levelName(s.deployedLogLevel)
|
|
}
|
|
|
|
// handleAdminGatewaySettings serves the page and accepts a write on the same path, which
|
|
// is the shape the *arr policy routes already take.
|
|
func (s *Server) handleAdminGatewaySettings(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodGet {
|
|
writeJSON(w, http.StatusOK, s.gatewaySettingsResponse())
|
|
return
|
|
}
|
|
var req store.GatewaySettings
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "malformed request body")
|
|
return
|
|
}
|
|
previous := s.gatewaySettings.get()
|
|
_, operator, _ := s.browserSession(r, adminSessionPurpose)
|
|
req.UpdatedBy = operator
|
|
stored, err := s.store.SetGatewaySettings(r.Context(), req)
|
|
if err != nil {
|
|
s.loggerFor(r.Context()).Error("gateway settings write failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "could not save gateway settings")
|
|
return
|
|
}
|
|
// Applied here rather than waited for: the watcher would pick this up within thirty
|
|
// seconds, and an operator who has just turned debug logging on and gone to look at
|
|
// the log would spend that half minute believing it had not worked.
|
|
s.gatewaySettings.set(stored)
|
|
s.applyLogLevel(stored.LogLevel)
|
|
|
|
s.loggerFor(r.Context()).Info("gateway settings changed",
|
|
"timezone", s.householdTimezoneName(), "log_level", s.effectiveLogLevel(),
|
|
"notification_display", s.notificationDisplay(),
|
|
"session_idle_days", int(s.sessionIdleExpiry()/(24*time.Hour)),
|
|
"operator", operator)
|
|
if summary := gatewaySettingsChanges(previous, stored); summary != "" {
|
|
s.publishAdmin(r.Context(), adminevents.Event{
|
|
Type: adminevents.TypeSettingsChanged,
|
|
Severity: adminevents.SeverityWarning,
|
|
Title: "Gateway settings changed",
|
|
Summary: summary,
|
|
Actor: operator,
|
|
Link: "/admin/settings",
|
|
})
|
|
}
|
|
writeJSON(w, http.StatusOK, s.gatewaySettingsResponse())
|
|
}
|
|
|
|
// gatewaySettingsChanges is what the notification says, and returns empty when nothing
|
|
// moved. The console re-posts the whole document, so a save that changed nothing is an
|
|
// ordinary event — the preferenceChanges rule, for the same reason: a feed that records
|
|
// every press of Save is one nobody reads.
|
|
func gatewaySettingsChanges(before, after store.GatewaySettings) string {
|
|
changes := []string{}
|
|
if before.Timezone != after.Timezone {
|
|
changes = append(changes, "timezone")
|
|
}
|
|
if before.LogLevel != after.LogLevel {
|
|
changes = append(changes, "log level")
|
|
}
|
|
if before.SessionIdleDays != after.SessionIdleDays {
|
|
changes = append(changes, "session expiry")
|
|
}
|
|
if before.SonarrAlertMinutes != after.SonarrAlertMinutes {
|
|
changes = append(changes, "episode alert window")
|
|
}
|
|
if before.RadarrAlertMinutes != after.RadarrAlertMinutes {
|
|
changes = append(changes, "film alert window")
|
|
}
|
|
if before.NotificationDisplay != after.NotificationDisplay {
|
|
changes = append(changes, "notification display")
|
|
}
|
|
if before.EmbyHealthSeconds != after.EmbyHealthSeconds {
|
|
changes = append(changes, "Emby health probe")
|
|
}
|
|
if before.SlowRequestMillis != after.SlowRequestMillis {
|
|
changes = append(changes, "slow-request threshold")
|
|
}
|
|
if before.LibrarySyncMinutes != after.LibrarySyncMinutes {
|
|
changes = append(changes, "library sweep interval")
|
|
}
|
|
if before.HomeTTLSeconds != after.HomeTTLSeconds {
|
|
changes = append(changes, "home cache lifetime")
|
|
}
|
|
if before.RecommendTTLHours != after.RecommendTTLHours {
|
|
changes = append(changes, "recommendation cache lifetime")
|
|
}
|
|
if !sameIntPointer(before.ForYouRebuildHour, after.ForYouRebuildHour) {
|
|
changes = append(changes, "For You rebuild hour")
|
|
}
|
|
switch len(changes) {
|
|
case 0:
|
|
return ""
|
|
case 1:
|
|
return "The gateway's " + changes[0] + " was changed"
|
|
default:
|
|
return "The gateway's " + joinPhrase(changes) + " were changed"
|
|
}
|
|
}
|
|
|
|
func sameIntPointer(a, b *int) bool {
|
|
if a == nil || b == nil {
|
|
return a == b
|
|
}
|
|
return *a == *b
|
|
}
|
|
|
|
func joinPhrase(values []string) string {
|
|
switch len(values) {
|
|
case 0:
|
|
return ""
|
|
case 1:
|
|
return values[0]
|
|
}
|
|
out := ""
|
|
for index, value := range values[:len(values)-1] {
|
|
if index > 0 {
|
|
out += ", "
|
|
}
|
|
out += value
|
|
}
|
|
return out + " and " + values[len(values)-1]
|
|
}
|