diff --git a/conf/configuration.go b/conf/configuration.go
index 67f43294d..64a4e6a75 100644
--- a/conf/configuration.go
+++ b/conf/configuration.go
@@ -112,6 +112,7 @@ type configOptions struct {
DevActivityPanelUpdateRate time.Duration
DevSidebarPlaylists bool
DevShowArtistPage bool
+ DevUIShowConfig bool
DevOffsetOptimize int
DevArtworkMaxRequests int
DevArtworkThrottleBacklogLimit int
@@ -553,6 +554,7 @@ func setViperDefaults() {
viper.SetDefault("devactivitypanelupdaterate", 300*time.Millisecond)
viper.SetDefault("devsidebarplaylists", true)
viper.SetDefault("devshowartistpage", true)
+ viper.SetDefault("devuishowconfig", true)
viper.SetDefault("devoffsetoptimize", 50000)
viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/3))
viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit)
diff --git a/resources/i18n/pt-br.json b/resources/i18n/pt-br.json
index febdcf769..cc771e8fa 100644
--- a/resources/i18n/pt-br.json
+++ b/resources/i18n/pt-br.json
@@ -496,6 +496,21 @@
"disabled": "Desligado",
"waiting": "Aguardando"
}
+ },
+ "tabs": {
+ "about": "Sobre",
+ "config": "Configuração"
+ },
+ "config": {
+ "configName": "Nome da Configuração",
+ "environmentVariable": "Variável de Ambiente",
+ "currentValue": "Valor Atual",
+ "configurationFile": "Arquivo de Configuração",
+ "exportToml": "Exportar Configuração (TOML)",
+ "exportSuccess": "Configuração exportada para o clipboard em formato TOML",
+ "exportFailed": "Falha ao copiar configuração",
+ "devFlagsHeader": "Flags de Desenvolvimento (sujeitas a mudança/remoção)",
+ "devFlagsComment": "Estas são configurações experimentais e podem ser removidas em versões futuras"
}
},
"activity": {
@@ -523,4 +538,4 @@
"current_song": "Vai para música atual"
}
}
-}
\ No newline at end of file
+}
diff --git a/server/nativeapi/config.go b/server/nativeapi/config.go
new file mode 100644
index 000000000..500e9098f
--- /dev/null
+++ b/server/nativeapi/config.go
@@ -0,0 +1,133 @@
+package nativeapi
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "reflect"
+ "strings"
+
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model/request"
+)
+
+// sensitiveFieldsPartialMask contains configuration field names that should be redacted
+// using partial masking (first and last character visible, middle replaced with *).
+// For values with 7+ characters: "secretvalue123" becomes "s***********3"
+// For values with <7 characters: "short" becomes "****"
+// Add field paths using dot notation (e.g., "LastFM.ApiKey", "Spotify.Secret")
+var sensitiveFieldsPartialMask = []string{
+ "LastFM.ApiKey",
+ "LastFM.Secret",
+ "Prometheus.MetricsPath",
+ "Spotify.ID",
+ "Spotify.Secret",
+ "DevAutoLoginUsername",
+}
+
+// sensitiveFieldsFullMask contains configuration field names that should always be
+// completely masked with "****" regardless of their length.
+// Add field paths using dot notation for any fields that should never show any content.
+var sensitiveFieldsFullMask = []string{
+ "DevAutoCreateAdminPassword",
+ "PasswordEncryptionKey",
+ "Prometheus.Password",
+}
+
+type configEntry struct {
+ Key string `json:"key"`
+ EnvVar string `json:"envVar"`
+ Value interface{} `json:"value"`
+}
+
+type configResponse struct {
+ ID string `json:"id"`
+ ConfigFile string `json:"configFile"`
+ Config []configEntry `json:"config"`
+}
+
+func redactValue(key string, value string) string {
+ // Return empty values as-is
+ if len(value) == 0 {
+ return value
+ }
+
+ // Check if this field should be fully masked
+ for _, field := range sensitiveFieldsFullMask {
+ if field == key {
+ return "****"
+ }
+ }
+
+ // Check if this field should be partially masked
+ for _, field := range sensitiveFieldsPartialMask {
+ if field == key {
+ if len(value) < 7 {
+ return "****"
+ }
+ // Show first and last character with * in between
+ return string(value[0]) + strings.Repeat("*", len(value)-2) + string(value[len(value)-1])
+ }
+ }
+
+ // Return original value if not sensitive
+ return value
+}
+
+func flatten(ctx context.Context, entries *[]configEntry, prefix string, v reflect.Value) {
+ if v.Kind() == reflect.Struct && v.Type().PkgPath() != "time" {
+ t := v.Type()
+ for i := 0; i < v.NumField(); i++ {
+ if !t.Field(i).IsExported() {
+ continue
+ }
+ flatten(ctx, entries, prefix+"."+t.Field(i).Name, v.Field(i))
+ }
+ return
+ }
+
+ key := strings.TrimPrefix(prefix, ".")
+ envVar := "ND_" + strings.ToUpper(strings.ReplaceAll(key, ".", "_"))
+ var val interface{}
+ switch v.Kind() {
+ case reflect.Map, reflect.Slice, reflect.Array:
+ b, err := json.Marshal(v.Interface())
+ if err != nil {
+ log.Error(ctx, "Error marshalling config value", "key", key, err)
+ val = "error marshalling value"
+ } else {
+ val = string(b)
+ }
+ default:
+ originalValue := fmt.Sprint(v.Interface())
+ val = redactValue(key, originalValue)
+ }
+
+ *entries = append(*entries, configEntry{Key: key, EnvVar: envVar, Value: val})
+}
+
+func getConfig(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ user, _ := request.UserFrom(ctx)
+ if !user.IsAdmin {
+ http.Error(w, "Config endpoint is only available to admin users", http.StatusUnauthorized)
+ return
+ }
+
+ entries := make([]configEntry, 0)
+ v := reflect.ValueOf(*conf.Server)
+ t := reflect.TypeOf(*conf.Server)
+ for i := 0; i < v.NumField(); i++ {
+ fieldVal := v.Field(i)
+ fieldType := t.Field(i)
+ flatten(ctx, &entries, fieldType.Name, fieldVal)
+ }
+
+ resp := configResponse{ID: "config", ConfigFile: conf.Server.ConfigFile, Config: entries}
+ w.Header().Set("Content-Type", "application/json")
+ if err := json.NewEncoder(w).Encode(resp); err != nil {
+ log.Error(ctx, "Error encoding config response", err)
+ }
+}
diff --git a/server/nativeapi/config_test.go b/server/nativeapi/config_test.go
new file mode 100644
index 000000000..eef8a81a2
--- /dev/null
+++ b/server/nativeapi/config_test.go
@@ -0,0 +1,268 @@
+package nativeapi
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/conf/configtest"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/request"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("config endpoint", func() {
+ BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
+ })
+
+ It("rejects non admin users", func() {
+ req := httptest.NewRequest("GET", "/config", nil)
+ w := httptest.NewRecorder()
+ ctx := request.WithUser(req.Context(), model.User{IsAdmin: false})
+ getConfig(w, req.WithContext(ctx))
+ Expect(w.Code).To(Equal(http.StatusUnauthorized))
+ })
+
+ It("returns configuration entries", func() {
+ req := httptest.NewRequest("GET", "/config", nil)
+ w := httptest.NewRecorder()
+ ctx := request.WithUser(req.Context(), model.User{IsAdmin: true})
+ getConfig(w, req.WithContext(ctx))
+ Expect(w.Code).To(Equal(http.StatusOK))
+ var resp configResponse
+ Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
+ Expect(resp.ID).To(Equal("config"))
+
+ // Verify that we have both Dev and non-Dev fields
+ var hasDevFields = false
+ var hasNonDevFields = false
+ for _, e := range resp.Config {
+ if strings.HasPrefix(e.Key, "Dev") {
+ hasDevFields = true
+ } else {
+ hasNonDevFields = true
+ }
+ }
+
+ Expect(hasDevFields).To(BeTrue(), "Should have Dev* configuration fields")
+ Expect(hasNonDevFields).To(BeTrue(), "Should have non-Dev configuration fields")
+ Expect(len(resp.Config)).To(BeNumerically(">", 0), "Should return configuration entries")
+ })
+
+ It("includes flattened struct fields", func() {
+ req := httptest.NewRequest("GET", "/config", nil)
+ w := httptest.NewRecorder()
+ ctx := request.WithUser(req.Context(), model.User{IsAdmin: true})
+ getConfig(w, req.WithContext(ctx))
+ Expect(w.Code).To(Equal(http.StatusOK))
+ var resp configResponse
+ Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
+ values := map[string]string{}
+ for _, e := range resp.Config {
+ if s, ok := e.Value.(string); ok {
+ values[e.Key] = s
+ }
+ }
+ Expect(values).To(HaveKeyWithValue("Inspect.MaxRequests", "1"))
+ Expect(values).To(HaveKeyWithValue("HTTPSecurityHeaders.CustomFrameOptionsValue", "DENY"))
+ })
+
+ It("includes the config file path", func() {
+ req := httptest.NewRequest("GET", "/config", nil)
+ w := httptest.NewRecorder()
+ ctx := request.WithUser(req.Context(), model.User{IsAdmin: true})
+ getConfig(w, req.WithContext(ctx))
+ Expect(w.Code).To(Equal(http.StatusOK))
+ var resp configResponse
+ Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
+ Expect(resp.ConfigFile).To(Not(BeEmpty()))
+ })
+
+ It("includes environment variable names", func() {
+ req := httptest.NewRequest("GET", "/config", nil)
+ w := httptest.NewRecorder()
+ ctx := request.WithUser(req.Context(), model.User{IsAdmin: true})
+ getConfig(w, req.WithContext(ctx))
+ Expect(w.Code).To(Equal(http.StatusOK))
+ var resp configResponse
+ Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
+
+ // Create a map to check specific env var mappings
+ envVars := map[string]string{}
+ for _, e := range resp.Config {
+ envVars[e.Key] = e.EnvVar
+ }
+
+ Expect(envVars).To(HaveKeyWithValue("MusicFolder", "ND_MUSICFOLDER"))
+ Expect(envVars).To(HaveKeyWithValue("Scanner.Enabled", "ND_SCANNER_ENABLED"))
+ Expect(envVars).To(HaveKeyWithValue("HTTPSecurityHeaders.CustomFrameOptionsValue", "ND_HTTPSECURITYHEADERS_CUSTOMFRAMEOPTIONSVALUE"))
+ })
+
+ Context("redaction functionality", func() {
+ It("redacts sensitive values with partial masking for long values", func() {
+ // Set up test values
+ conf.Server.LastFM.ApiKey = "ba46f0e84a123456"
+ conf.Server.Spotify.Secret = "verylongsecret123"
+
+ req := httptest.NewRequest("GET", "/config", nil)
+ w := httptest.NewRecorder()
+ ctx := request.WithUser(req.Context(), model.User{IsAdmin: true})
+ getConfig(w, req.WithContext(ctx))
+
+ Expect(w.Code).To(Equal(http.StatusOK))
+ var resp configResponse
+ Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
+
+ values := map[string]string{}
+ for _, e := range resp.Config {
+ if s, ok := e.Value.(string); ok {
+ values[e.Key] = s
+ }
+ }
+
+ Expect(values).To(HaveKeyWithValue("LastFM.ApiKey", "b**************6"))
+ Expect(values).To(HaveKeyWithValue("Spotify.Secret", "v***************3"))
+ })
+
+ It("redacts sensitive values with full masking for short values", func() {
+ // Set up test values with short secrets
+ conf.Server.LastFM.Secret = "short"
+ conf.Server.Spotify.ID = "abc123"
+
+ req := httptest.NewRequest("GET", "/config", nil)
+ w := httptest.NewRecorder()
+ ctx := request.WithUser(req.Context(), model.User{IsAdmin: true})
+ getConfig(w, req.WithContext(ctx))
+
+ Expect(w.Code).To(Equal(http.StatusOK))
+ var resp configResponse
+ Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
+
+ values := map[string]string{}
+ for _, e := range resp.Config {
+ if s, ok := e.Value.(string); ok {
+ values[e.Key] = s
+ }
+ }
+
+ Expect(values).To(HaveKeyWithValue("LastFM.Secret", "****"))
+ Expect(values).To(HaveKeyWithValue("Spotify.ID", "****"))
+ })
+
+ It("fully masks password fields", func() {
+ // Set up test values for password fields
+ conf.Server.DevAutoCreateAdminPassword = "adminpass123"
+ conf.Server.Prometheus.Password = "prometheuspass"
+
+ req := httptest.NewRequest("GET", "/config", nil)
+ w := httptest.NewRecorder()
+ ctx := request.WithUser(req.Context(), model.User{IsAdmin: true})
+ getConfig(w, req.WithContext(ctx))
+
+ Expect(w.Code).To(Equal(http.StatusOK))
+ var resp configResponse
+ Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
+
+ values := map[string]string{}
+ for _, e := range resp.Config {
+ if s, ok := e.Value.(string); ok {
+ values[e.Key] = s
+ }
+ }
+
+ Expect(values).To(HaveKeyWithValue("DevAutoCreateAdminPassword", "****"))
+ Expect(values).To(HaveKeyWithValue("Prometheus.Password", "****"))
+ })
+
+ It("does not redact non-sensitive values", func() {
+ conf.Server.MusicFolder = "/path/to/music"
+ conf.Server.Port = 4533
+
+ req := httptest.NewRequest("GET", "/config", nil)
+ w := httptest.NewRecorder()
+ ctx := request.WithUser(req.Context(), model.User{IsAdmin: true})
+ getConfig(w, req.WithContext(ctx))
+
+ Expect(w.Code).To(Equal(http.StatusOK))
+ var resp configResponse
+ Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
+
+ values := map[string]string{}
+ for _, e := range resp.Config {
+ if s, ok := e.Value.(string); ok {
+ values[e.Key] = s
+ }
+ }
+
+ Expect(values).To(HaveKeyWithValue("MusicFolder", "/path/to/music"))
+ Expect(values).To(HaveKeyWithValue("Port", "4533"))
+ })
+
+ It("handles empty sensitive values", func() {
+ conf.Server.LastFM.ApiKey = ""
+ conf.Server.PasswordEncryptionKey = ""
+
+ req := httptest.NewRequest("GET", "/config", nil)
+ w := httptest.NewRecorder()
+ ctx := request.WithUser(req.Context(), model.User{IsAdmin: true})
+ getConfig(w, req.WithContext(ctx))
+
+ Expect(w.Code).To(Equal(http.StatusOK))
+ var resp configResponse
+ Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
+
+ values := map[string]string{}
+ for _, e := range resp.Config {
+ if s, ok := e.Value.(string); ok {
+ values[e.Key] = s
+ }
+ }
+
+ // Empty sensitive values should remain empty
+ Expect(values["LastFM.ApiKey"]).To(Equal(""))
+ Expect(values["PasswordEncryptionKey"]).To(Equal(""))
+ })
+ })
+})
+
+var _ = Describe("redactValue function", func() {
+ It("partially masks long sensitive values", func() {
+ Expect(redactValue("LastFM.ApiKey", "ba46f0e84a")).To(Equal("b********a"))
+ Expect(redactValue("Spotify.Secret", "verylongsecret123")).To(Equal("v***************3"))
+ })
+
+ It("fully masks long sensitive values that should be completely hidden", func() {
+ Expect(redactValue("PasswordEncryptionKey", "1234567890")).To(Equal("****"))
+ Expect(redactValue("DevAutoCreateAdminPassword", "1234567890")).To(Equal("****"))
+ Expect(redactValue("Prometheus.Password", "1234567890")).To(Equal("****"))
+ })
+
+ It("fully masks short sensitive values", func() {
+ Expect(redactValue("LastFM.Secret", "short")).To(Equal("****"))
+ Expect(redactValue("Spotify.ID", "abc")).To(Equal("****"))
+ Expect(redactValue("PasswordEncryptionKey", "12345")).To(Equal("****"))
+ Expect(redactValue("DevAutoCreateAdminPassword", "short")).To(Equal("****"))
+ Expect(redactValue("Prometheus.Password", "short")).To(Equal("****"))
+ })
+
+ It("does not mask non-sensitive values", func() {
+ Expect(redactValue("MusicFolder", "/path/to/music")).To(Equal("/path/to/music"))
+ Expect(redactValue("Port", "4533")).To(Equal("4533"))
+ Expect(redactValue("SomeOtherField", "secretvalue")).To(Equal("secretvalue"))
+ })
+
+ It("handles empty values", func() {
+ Expect(redactValue("LastFM.ApiKey", "")).To(Equal(""))
+ Expect(redactValue("NonSensitive", "")).To(Equal(""))
+ })
+
+ It("handles edge case values", func() {
+ Expect(redactValue("LastFM.ApiKey", "a")).To(Equal("****"))
+ Expect(redactValue("LastFM.ApiKey", "ab")).To(Equal("****"))
+ Expect(redactValue("LastFM.ApiKey", "abcdefg")).To(Equal("a*****g"))
+ })
+})
diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go
index ddf5df1c3..f2c13fa3a 100644
--- a/server/nativeapi/native_api.go
+++ b/server/nativeapi/native_api.go
@@ -61,21 +61,9 @@ func (n *Router) routes() http.Handler {
n.addPlaylistTrackRoute(r)
n.addMissingFilesRoute(r)
n.addInspectRoute(r)
-
- // Keepalive endpoint to be used to keep the session valid (ex: while playing songs)
- r.Get("/keepalive/*", func(w http.ResponseWriter, r *http.Request) {
- _, _ = w.Write([]byte(`{"response":"ok", "id":"keepalive"}`))
- })
-
- // Insights status endpoint
- r.Get("/insights/*", func(w http.ResponseWriter, r *http.Request) {
- last, success := n.insights.LastRun(r.Context())
- if conf.Server.EnableInsightsCollector {
- _, _ = w.Write([]byte(`{"id":"insights_status", "lastRun":"` + last.Format("2006-01-02 15:04:05") + `", "success":` + strconv.FormatBool(success) + `}`))
- } else {
- _, _ = w.Write([]byte(`{"id":"insights_status", "lastRun":"disabled", "success":false}`))
- }
- })
+ n.addConfigRoute(r)
+ n.addKeepAliveRoute(r)
+ n.addInsightsRoute(r)
})
return r
@@ -196,3 +184,26 @@ func (n *Router) addInspectRoute(r chi.Router) {
})
}
}
+
+func (n *Router) addConfigRoute(r chi.Router) {
+ if conf.Server.DevUIShowConfig {
+ r.Get("/config/*", getConfig)
+ }
+}
+
+func (n *Router) addKeepAliveRoute(r chi.Router) {
+ r.Get("/keepalive/*", func(w http.ResponseWriter, r *http.Request) {
+ _, _ = w.Write([]byte(`{"response":"ok", "id":"keepalive"}`))
+ })
+}
+
+func (n *Router) addInsightsRoute(r chi.Router) {
+ r.Get("/insights/*", func(w http.ResponseWriter, r *http.Request) {
+ last, success := n.insights.LastRun(r.Context())
+ if conf.Server.EnableInsightsCollector {
+ _, _ = w.Write([]byte(`{"id":"insights_status", "lastRun":"` + last.Format("2006-01-02 15:04:05") + `", "success":` + strconv.FormatBool(success) + `}`))
+ } else {
+ _, _ = w.Write([]byte(`{"id":"insights_status", "lastRun":"disabled", "success":false}`))
+ }
+ })
+}
diff --git a/server/serve_index.go b/server/serve_index.go
index 9a457ac20..1e55743f0 100644
--- a/server/serve_index.go
+++ b/server/serve_index.go
@@ -65,6 +65,7 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl
"devSidebarPlaylists": conf.Server.DevSidebarPlaylists,
"lastFMEnabled": conf.Server.LastFM.Enabled,
"devShowArtistPage": conf.Server.DevShowArtistPage,
+ "devUIShowConfig": conf.Server.DevUIShowConfig,
"listenBrainzEnabled": conf.Server.ListenBrainz.Enabled,
"enableExternalServices": conf.Server.EnableExternalServices,
"enableReplayGain": conf.Server.EnableReplayGain,
diff --git a/server/serve_index_test.go b/server/serve_index_test.go
index 0f02153fd..fd0d42193 100644
--- a/server/serve_index_test.go
+++ b/server/serve_index_test.go
@@ -304,6 +304,17 @@ var _ = Describe("serveIndex", func() {
Expect(config).To(HaveKeyWithValue("devShowArtistPage", true))
})
+ It("sets the devUIShowConfig", func() {
+ conf.Server.DevUIShowConfig = true
+ r := httptest.NewRequest("GET", "/index.html", nil)
+ w := httptest.NewRecorder()
+
+ serveIndex(ds, fs, nil)(w, r)
+
+ config := extractAppConfig(w.Body.String())
+ Expect(config).To(HaveKeyWithValue("devUIShowConfig", true))
+ })
+
It("sets the listenBrainzEnabled", func() {
conf.Server.ListenBrainz.Enabled = true
r := httptest.NewRequest("GET", "/index.html", nil)
diff --git a/ui/src/App.jsx b/ui/src/App.jsx
index 1b89f7b8c..4a38051b4 100644
--- a/ui/src/App.jsx
+++ b/ui/src/App.jsx
@@ -137,6 +137,9 @@ const Admin = (props) => {
,
,
,
+ permissions === 'admin' && config.devUIShowConfig ? (
+
+ ) : null,
,
]}
diff --git a/ui/src/common/SongInfo.jsx b/ui/src/common/SongInfo.jsx
index 77e91b653..9b9ca18cd 100644
--- a/ui/src/common/SongInfo.jsx
+++ b/ui/src/common/SongInfo.jsx
@@ -138,7 +138,7 @@ export const SongInfo = (props) => {
)}
diff --git a/ui/src/config.js b/ui/src/config.js
index 92ce07893..1a89019ba 100644
--- a/ui/src/config.js
+++ b/ui/src/config.js
@@ -30,6 +30,7 @@ const defaultConfig = {
enableExternalServices: true,
enableCoverAnimation: true,
devShowArtistPage: true,
+ devUIShowConfig: true,
enableReplayGain: true,
defaultDownsamplingFormat: 'opus',
publicBaseUrl: '/share',
diff --git a/ui/src/dialogs/AboutDialog.jsx b/ui/src/dialogs/AboutDialog.jsx
index c220784a8..e0d796691 100644
--- a/ui/src/dialogs/AboutDialog.jsx
+++ b/ui/src/dialogs/AboutDialog.jsx
@@ -10,14 +10,63 @@ import TableRow from '@material-ui/core/TableRow'
import TableCell from '@material-ui/core/TableCell'
import Paper from '@material-ui/core/Paper'
import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder'
+import FileCopyIcon from '@material-ui/icons/FileCopy'
+import Button from '@material-ui/core/Button'
import { humanize, underscore } from 'inflection'
-import { useGetOne, usePermissions, useTranslate } from 'react-admin'
+import { useGetOne, usePermissions, useTranslate, useNotify } from 'react-admin'
+import { Tabs, Tab } from '@material-ui/core'
+import { makeStyles } from '@material-ui/core/styles'
import config from '../config'
import { DialogTitle } from './DialogTitle'
import { DialogContent } from './DialogContent'
import { INSIGHTS_DOC_URL } from '../consts.js'
import subsonic from '../subsonic/index.js'
import { Typography } from '@material-ui/core'
+import TableHead from '@material-ui/core/TableHead'
+import { configToToml, separateAndSortConfigs } from '../utils/toml'
+
+const useStyles = makeStyles((theme) => ({
+ configNameColumn: {
+ maxWidth: '200px',
+ width: '200px',
+ wordWrap: 'break-word',
+ overflowWrap: 'break-word',
+ },
+ envVarColumn: {
+ maxWidth: '200px',
+ width: '200px',
+ fontFamily: 'monospace',
+ wordWrap: 'break-word',
+ overflowWrap: 'break-word',
+ },
+ configFileValue: {
+ maxWidth: '300px',
+ width: '300px',
+ fontFamily: 'monospace',
+ wordBreak: 'break-all',
+ },
+ copyButton: {
+ marginBottom: theme.spacing(2),
+ marginTop: theme.spacing(1),
+ },
+ devSectionHeader: {
+ '& td': {
+ paddingTop: theme.spacing(2),
+ paddingBottom: theme.spacing(2),
+ borderTop: `2px solid ${theme.palette.divider}`,
+ borderBottom: `1px solid ${theme.palette.divider}`,
+ textAlign: 'left',
+ fontWeight: 600,
+ },
+ },
+ configContainer: {
+ paddingTop: theme.spacing(1),
+ },
+ tableContainer: {
+ maxHeight: '60vh',
+ overflow: 'auto',
+ },
+}))
const links = {
homepage: 'navidrome.org',
@@ -54,7 +103,6 @@ const LinkToVersion = ({ version }) => {
const ShowVersion = ({ uiVersion, serverVersion }) => {
const translate = useTranslate()
-
const showRefresh = uiVersion !== serverVersion
return (
@@ -90,11 +138,286 @@ const ShowVersion = ({ uiVersion, serverVersion }) => {
)
}
-const AboutDialog = ({ open, onClose }) => {
+const AboutTabContent = ({
+ uiVersion,
+ serverVersion,
+ insightsData,
+ loading,
+ permissions,
+}) => {
const translate = useTranslate()
+
+ const lastRun = !loading && insightsData?.lastRun
+ let insightsStatus = 'N/A'
+ if (lastRun === 'disabled') {
+ insightsStatus = translate('about.links.insights.disabled')
+ } else if (lastRun && lastRun?.startsWith('1969-12-31')) {
+ insightsStatus = translate('about.links.insights.waiting')
+ } else if (lastRun) {
+ insightsStatus = lastRun
+ }
+
+ return (
+
+
+
+ {Object.keys(links).map((key) => {
+ return (
+
+
+ {translate(`about.links.${key}`, {
+ _: humanize(underscore(key)),
+ })}
+ :
+
+
+
+ {links[key]}
+
+
+
+ )
+ })}
+ {permissions === 'admin' ? (
+
+
+ {translate(`about.links.lastInsightsCollection`)}:
+
+
+ {insightsStatus}
+
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+ ko-fi.com/deluan
+
+
+
+
+
+ )
+}
+
+const ConfigTabContent = ({ configData }) => {
+ const classes = useStyles()
+ const translate = useTranslate()
+ const notify = useNotify()
+
+ if (!configData || !configData.config) {
+ return null
+ }
+
+ // Use the shared separation and sorting logic
+ const { regularConfigs, devConfigs } = separateAndSortConfigs(
+ configData.config,
+ )
+
+ const handleCopyToml = async () => {
+ try {
+ const tomlContent = configToToml(configData, translate)
+ await navigator.clipboard.writeText(tomlContent)
+ notify(translate('about.config.exportSuccess'), 'info')
+ } catch (err) {
+ // eslint-disable-next-line no-console
+ console.error('Failed to copy TOML:', err)
+ notify(translate('about.config.exportFailed'), 'error')
+ }
+ }
+
+ return (
+
+
}
+ onClick={handleCopyToml}
+ className={classes.copyButton}
+ disabled={!configData}
+ size="small"
+ >
+ {translate('about.config.exportToml')}
+
+
+
+
+
+
+ {translate('about.config.configName')}
+
+
+ {translate('about.config.environmentVariable')}
+
+
+ {translate('about.config.currentValue')}
+
+
+
+
+ {configData?.configFile && (
+
+
+ {translate('about.config.configurationFile')}
+
+
+ ND_CONFIGFILE
+
+
+ {configData.configFile}
+
+
+ )}
+ {regularConfigs.map(({ key, envVar, value }) => (
+
+
+ {key}
+
+
+ {envVar}
+
+ {String(value)}
+
+ ))}
+ {devConfigs.length > 0 && (
+
+
+
+ 🚧 {translate('about.config.devFlagsHeader')}
+
+
+
+ )}
+ {devConfigs.map(({ key, envVar, value }) => (
+
+
+ {key}
+
+
+ {envVar}
+
+ {String(value)}
+
+ ))}
+
+
+
+
+ )
+}
+
+const TabContent = ({
+ tab,
+ setTab,
+ showConfigTab,
+ uiVersion,
+ serverVersion,
+ insightsData,
+ loading,
+ permissions,
+ configData,
+}) => {
+ const translate = useTranslate()
+
+ return (
+
+ {showConfigTab && (
+ setTab(value)}>
+
+
+
+ )}
+
+ {showConfigTab && (
+
+
+
+ )}
+
+ )
+}
+
+const AboutDialog = ({ open, onClose }) => {
const { permissions } = usePermissions()
- const { data, loading } = useGetOne('insights', 'insights_status')
+ const { data: insightsData, loading } = useGetOne(
+ 'insights',
+ 'insights_status',
+ )
const [serverVersion, setServerVersion] = useState('')
+ const showConfigTab = permissions === 'admin' && config.devUIShowConfig
+ const [tab, setTab] = useState(0)
+ const { data: configData } = useGetOne('config', 'config', {
+ enabled: showConfigTab,
+ })
+ const expanded = showConfigTab && tab === 1
const uiVersion = config.version
useEffect(() => {
@@ -112,85 +435,30 @@ const AboutDialog = ({ open, onClose }) => {
})
}, [setServerVersion])
- const lastRun = !loading && data?.lastRun
- let insightsStatus = 'N/A'
- if (lastRun === 'disabled') {
- insightsStatus = translate('about.links.insights.disabled')
- } else if (lastRun && lastRun?.startsWith('1969-12-31')) {
- insightsStatus = translate('about.links.insights.waiting')
- } else if (lastRun) {
- insightsStatus = lastRun
- }
-
return (
-