From d0a2765fdc52a2214bd11c2d3b901ea16d548acb Mon Sep 17 00:00:00 2001 From: Christian Benincasa Date: Thu, 30 May 2024 07:26:34 -0400 Subject: [PATCH] Ability to set log level from UI. Fixes #448 (#458) * Checkpoint * Set logging level from frontend --- pnpm-lock.yaml | 27 ++-- server/package.json | 1 + server/src/api/index.ts | 2 + server/src/api/systemSettingsApi.ts | 70 ++++++++++ server/src/dao/settings.ts | 64 ++++----- server/src/server.ts | 1 - server/src/util/index.ts | 1 - server/src/util/logging/LoggerFactory.ts | 52 +++---- types/src/SystemSettings.ts | 38 +++++ types/src/api/index.ts | 20 +++ types/src/index.ts | 1 + types/src/util.ts | 2 + web/src/external/api.ts | 30 ++-- web/src/external/settingsApi.ts | 19 +++ web/src/hooks/useSystemSettings.ts | 28 ++++ .../pages/settings/GeneralSettingsPage.tsx | 132 +++++++++++++++--- 16 files changed, 388 insertions(+), 100 deletions(-) create mode 100644 server/src/api/systemSettingsApi.ts create mode 100644 types/src/SystemSettings.ts create mode 100644 web/src/hooks/useSystemSettings.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4db09e88b..a1b08f7e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -121,6 +121,9 @@ importers: chalk: specifier: ^5.3.0 version: 5.3.0 + chokidar: + specifier: ^3.6.0 + version: 3.6.0 cors: specifier: ^2.8.5 version: 2.8.5 @@ -3923,7 +3926,6 @@ packages: dependencies: normalize-path: 3.0.0 picomatch: 2.3.1 - dev: true /app-builder@7.0.4: resolution: {integrity: sha512-QCmWZnoNN2uItlRV+gj4J6OONaFcJPyFoIuP1RkoILcuq19MlkynYB+wtH8uGv/umyynMWHI1HxnH1jGa1hNKQ==} @@ -4227,7 +4229,6 @@ packages: /binary-extensions@2.2.0: resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} engines: {node: '>=8'} - dev: true /bindings@1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} @@ -4633,6 +4634,20 @@ packages: fsevents: 2.3.3 dev: true + /chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + dependencies: + anymatch: 3.1.3 + braces: 3.0.2 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + /chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} @@ -5560,7 +5575,7 @@ packages: esbuild: '>= 0.14.0' dependencies: chalk: 4.1.2 - chokidar: 3.5.3 + chokidar: 3.6.0 esbuild: 0.19.5 fs-extra: 10.1.0 globby: 11.1.0 @@ -6313,7 +6328,6 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] requiresBuild: true - dev: true optional: true /function-bind@1.1.2: @@ -6959,7 +6973,6 @@ packages: engines: {node: '>=8'} dependencies: binary-extensions: 2.2.0 - dev: true /is-boolean-object@1.1.2: resolution: {integrity: sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==} @@ -8193,7 +8206,6 @@ packages: /normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} - dev: true /normalize-url@2.0.1: resolution: {integrity: sha512-D6MUW4K/VzoJ4rJ01JFKxDrtY1v9wrgzCX5f2qj/lzH1m/lW6MhUZFKerVsnyjOhOsYzI9Kqqak+10l4LvLpMw==} @@ -9298,7 +9310,6 @@ packages: engines: {node: '>=8.10.0'} dependencies: picomatch: 2.3.1 - dev: true /real-require@0.2.0: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} @@ -10661,7 +10672,7 @@ packages: dependencies: bundle-require: 4.0.2(esbuild@0.19.12) cac: 6.7.14 - chokidar: 3.5.3 + chokidar: 3.6.0 debug: 4.3.4(supports-color@5.5.0) esbuild: 0.19.12 execa: 5.1.1 diff --git a/server/package.json b/server/package.json index 51f49e2e5..f1c55a5de 100644 --- a/server/package.json +++ b/server/package.json @@ -44,6 +44,7 @@ "axios": "^1.6.0", "better-sqlite3": "^9.1.1", "chalk": "^5.3.0", + "chokidar": "^3.6.0", "cors": "^2.8.5", "dayjs": "^1.11.10", "fast-json-stringify": "^5.9.1", diff --git a/server/src/api/index.ts b/server/src/api/index.ts index f064dd513..1276f60ff 100644 --- a/server/src/api/index.ts +++ b/server/src/api/index.ts @@ -27,6 +27,7 @@ import { hdhrSettingsRouter } from './hdhrSettingsApi.js'; import { plexServersRouter } from './plexServersApi.js'; import { plexSettingsRouter } from './plexSettingsApi.js'; import { xmlTvSettingsRouter } from './xmltvSettingsApi.js'; +import { systemSettingsRouter } from './systemSettingsApi.js'; export const apiRouter: RouterPluginAsyncCallback = async (fastify) => { const logger = LoggerFactory.child({ caller: import.meta }); @@ -53,6 +54,7 @@ export const apiRouter: RouterPluginAsyncCallback = async (fastify) => { .register(plexSettingsRouter) .register(xmlTvSettingsRouter) .register(hdhrSettingsRouter) + .register(systemSettingsRouter) .register(guideRouter); fastify.get( diff --git a/server/src/api/systemSettingsApi.ts b/server/src/api/systemSettingsApi.ts new file mode 100644 index 000000000..4f8bf78d4 --- /dev/null +++ b/server/src/api/systemSettingsApi.ts @@ -0,0 +1,70 @@ +import { + SystemSettingsResponseSchema, + UpdateSystemSettingsRequestSchema, +} from '@tunarr/types/api'; +import { RouterPluginAsyncCallback } from '../types/serverType'; +import { + getDefaultLogLevel, + getEnvironmentLogLevel, +} from '../util/logging/LoggerFactory'; +import { SystemSettings } from '@tunarr/types'; + +export const systemSettingsRouter: RouterPluginAsyncCallback = async ( + fastify, + // eslint-disable-next-line @typescript-eslint/require-await +) => { + fastify.get( + '/system/settings', + { + schema: { + response: { + 200: SystemSettingsResponseSchema, + }, + }, + }, + async (req, res) => { + const settings = req.serverCtx.settings.systemSettings(); + return res.send(getSystemSettingsResponse(settings)); + }, + ); + + fastify.put( + '/system/settings', + { + schema: { + body: UpdateSystemSettingsRequestSchema, + response: { + 200: SystemSettingsResponseSchema, + }, + }, + }, + async (req, res) => { + await req.serverCtx.settings.directUpdate((file) => { + const { system } = file; + system.logging.useEnvVarLevel = req.body.useEnvVarLevel ?? true; + if (system.logging.useEnvVarLevel) { + system.logging.logLevel = getDefaultLogLevel(false); + } else { + system.logging.logLevel = + req.body.logLevel ?? getDefaultLogLevel(false); + } + return file; + }); + + return res.send( + getSystemSettingsResponse(req.serverCtx.settings.systemSettings()), + ); + }, + ); + + function getSystemSettingsResponse(settings: SystemSettings) { + const envLogLevel = getEnvironmentLogLevel(); + return { + ...settings, + logging: { + ...settings.logging, + environmentLogLevel: envLogLevel, + }, + }; + } +}; diff --git a/server/src/dao/settings.ts b/server/src/dao/settings.ts index b3081eb19..f0b95a01d 100644 --- a/server/src/dao/settings.ts +++ b/server/src/dao/settings.ts @@ -1,7 +1,10 @@ import { FfmpegSettings, HdhrSettings, + LoggingSettingsSchema, PlexStreamSettings, + SystemSettings, + SystemSettingsSchema, XmlTvSettings, defaultFfmpegSettings, defaultHdhrSettings, @@ -11,7 +14,7 @@ import { import { isUndefined, merge, once } from 'lodash-es'; import { Low, LowSync } from 'lowdb'; import path from 'path'; -import fs from 'node:fs/promises'; +import chokidar from 'chokidar'; import { DeepReadonly } from 'ts-essentials'; import { v4 as uuidv4 } from 'uuid'; import { globalOptions } from '../globals.js'; @@ -42,28 +45,6 @@ export const defaultXmlTvSettings = (dbBasePath: string): XmlTvSettings => ({ outputPath: path.resolve(dbBasePath, 'xmltv.xml'), }); -const LoggingSettingsSchema = z.object({ - logLevel: z - .union([ - z.literal('silent'), - z.literal('fatal'), - z.literal('error'), - z.literal('warn'), - z.literal('info'), - z.literal('http'), - z.literal('debug'), - z.literal('trace'), - ]) - .default(() => (isProduction ? 'info' : 'debug')), - logsDirectory: z.string(), -}); - -const SystemSettingsSchema = z.object({ - logging: LoggingSettingsSchema, -}); - -type SystemSettings = z.infer; - export const SettingsSchema = z.object({ clientId: z.string(), hdhr: HdhrSettingsSchema, @@ -82,7 +63,13 @@ export const SettingsFileSchema = z.object({ version: z.number(), migration: MigrationStateSchema, settings: SettingsSchema, - system: SystemSettingsSchema, + system: SystemSettingsSchema.extend({ + logging: LoggingSettingsSchema.extend({ + logLevel: SystemSettingsSchema.shape.logging.shape.logLevel.default(() => + isProduction ? 'info' : 'debug', + ), + }), + }), }); export type SettingsFile = z.infer; @@ -103,6 +90,7 @@ export const defaultSchema = (dbBasePath: string): SettingsFile => ({ logging: { logLevel: getDefaultLogLevel(), logsDirectory: getDefaultLogDirectory(), + useEnvVarLevel: true, }, }, }); @@ -116,12 +104,11 @@ abstract class ITypedEventEmitter extends (events.EventEmitter as new () => Type export class SettingsDB extends ITypedEventEmitter { private logger: Logger; private db: Low; - private watcherController = new AbortController(); constructor(dbPath: string, db: Low) { super(); this.db = db; - this.handleFileChanges(dbPath).catch(console.error); + this.handleFileChanges(dbPath); setImmediate(() => { this.logger = LoggerFactory.child(import.meta); }); @@ -184,15 +171,22 @@ export class SettingsDB extends ITypedEventEmitter { return this.db.write(); } - private async handleFileChanges(path: string) { - const watcher = fs.watch(path, { signal: this.watcherController.signal }); - for await (const event of watcher) { - if (event.eventType === 'change') { - this.logger.debug('detected settings DB change on disk, reloading.'); - await this.db.read(); - this.emit('change'); - } - } + private handleFileChanges(path: string) { + const watcher = chokidar.watch(path, { + persistent: false, + awaitWriteFinish: true, + }); + + watcher.on('change', () => { + this.logger.debug( + 'Detected change to settings DB file %s on disk. Reloading.', + path, + ); + this.db + .read() + .then(() => this.emit('change')) + .catch(console.error); + }); } } diff --git a/server/src/server.ts b/server/src/server.ts index 601080d20..edf6ed78f 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -269,7 +269,6 @@ export async function initServer(opts: ServerOptions) { decorateReply: false, serve: false, // Use the interceptor }); - // f.addHook('onRequest', async (req, res) => ctx.cacheImageService.routerInterceptor(req, res)); f.get<{ Params: { hash: string } }>( '/cache/images/:hash', { diff --git a/server/src/util/index.ts b/server/src/util/index.ts index 1b292d772..5be4a11f7 100644 --- a/server/src/util/index.ts +++ b/server/src/util/index.ts @@ -410,7 +410,6 @@ export function flipMap( } export const filename = (path: string) => fileURLToPath(path); -// const dirname = (path: string) => dirname(filename(path)); export const currentEnv = once(() => { const env = process.env['NODE_ENV']; diff --git a/server/src/util/logging/LoggerFactory.ts b/server/src/util/logging/LoggerFactory.ts index 854d397db..d70ac4d40 100644 --- a/server/src/util/logging/LoggerFactory.ts +++ b/server/src/util/logging/LoggerFactory.ts @@ -14,7 +14,7 @@ import pretty from 'pino-pretty'; import type ThreadStream from 'thread-stream'; import { isNonEmptyString, isProduction } from '..'; import { SettingsDB, getSettings } from '../../dao/settings'; -import { TupleToUnion } from '../../types/util'; +import { Maybe, TupleToUnion } from '../../types/util'; import { isDocker } from '../isDocker'; export const LogConfigEnvVars = { @@ -22,10 +22,27 @@ export const LogConfigEnvVars = { directory: 'LOG_DIRECTORY', } as const; -export function getDefaultLogLevel(): LevelWithSilent | ExtraLogLevels { - const env = process.env[LogConfigEnvVars.level]; - if (isNonEmptyString(env) && ValidLogLevels.includes(env)) { - return env as LevelWithSilent; +export function getEnvironmentLogLevel(): Maybe { + const envLevel = trim(toLower(process.env[LogConfigEnvVars.level])); + if (isNonEmptyString(envLevel)) { + if (ValidLogLevels.includes(envLevel)) { + return envLevel as LogLevels; + } else { + console.warn( + `Invalid log level provided in env var: %s. Ignoring`, + envLevel, + ); + } + } + return; +} + +export function getDefaultLogLevel(useEnvVar: boolean = true): LogLevels { + if (useEnvVar) { + const level = getEnvironmentLogLevel(); + if (!isUndefined(level)) { + return level; + } } return isProduction ? 'info' : 'debug'; @@ -90,15 +107,7 @@ class LoggerFactoryImpl { return; } - const newLevel = this.settingsDB.systemSettings().logging.logLevel; - const { source } = this.logLevel; - if (source === 'env') { - this.rootLogger.debug( - 'Ignoring log level change because it is set as environment variable', - ); - // Ignore settings file changes if the level is set on the env - return; - } + const { level: newLevel } = this.logLevel; if (this.rootLogger[symbols.getLevelSym] !== newLevel) { this.updateLevel(newLevel); @@ -178,15 +187,10 @@ class LoggerFactoryImpl { level: LogLevels; source: 'env' | 'settings'; } { - const envLevel = trim(toLower(process.env[LogConfigEnvVars.level])); - if (isNonEmptyString(envLevel)) { - if (ValidLogLevels.includes(envLevel)) { - return { source: 'env', level: envLevel as LogLevels }; - } else { - console.warn( - `Invalid log level provided in env var: %s. Ignoring`, - envLevel, - ); + if (this.settingsDB?.systemSettings().logging.useEnvVarLevel) { + const envLevel = getEnvironmentLogLevel(); + if (!isUndefined(envLevel)) { + return { source: 'env', level: envLevel }; } } @@ -194,7 +198,7 @@ class LoggerFactoryImpl { } private get systemSettingsLogLevel() { - if (this.initialized && !isUndefined(this.settingsDB)) { + if (!isUndefined(this.settingsDB)) { return this.settingsDB.systemSettings().logging .logLevel as LevelWithSilent; } else { diff --git a/types/src/SystemSettings.ts b/types/src/SystemSettings.ts new file mode 100644 index 000000000..230246a87 --- /dev/null +++ b/types/src/SystemSettings.ts @@ -0,0 +1,38 @@ +import { z } from 'zod'; +import { TupleToUnion } from './util.js'; + +export const LogLevelsSchema = z.union([ + z.literal('silent'), + z.literal('fatal'), + z.literal('error'), + z.literal('warn'), + z.literal('info'), + z.literal('http'), + z.literal('debug'), + z.literal('trace'), +]); + +export const LogLevels = [ + 'silent', + 'fatal', + 'error', + 'warn', + 'info', + 'http', + 'debug', + 'trace', +] as const; + +export type LogLevel = TupleToUnion; + +export const LoggingSettingsSchema = z.object({ + logLevel: LogLevelsSchema, + logsDirectory: z.string(), + useEnvVarLevel: z.boolean().default(true), +}); + +export const SystemSettingsSchema = z.object({ + logging: LoggingSettingsSchema, +}); + +export type SystemSettings = z.infer; diff --git a/types/src/api/index.ts b/types/src/api/index.ts index b0b33f925..b40380b6f 100644 --- a/types/src/api/index.ts +++ b/types/src/api/index.ts @@ -9,6 +9,11 @@ import { RandomSlotScheduleSchema, TimeSlotScheduleSchema, } from './Scheduling.js'; +import { + LogLevelsSchema, + LoggingSettingsSchema, + SystemSettingsSchema, +} from '../SystemSettings.js'; export * from './Scheduling.js'; export * from './plexSearch.js'; @@ -150,3 +155,18 @@ export type VersionApiResponse = z.infer; export const BaseErrorSchema = z.object({ message: z.string(), }); + +export const SystemSettingsResponseSchema = SystemSettingsSchema.extend({ + logging: LoggingSettingsSchema.extend({ + environmentLogLevel: LogLevelsSchema.optional(), + }), +}); + +export const UpdateSystemSettingsRequestSchema = + SystemSettingsSchema.shape.logging + .pick({ logLevel: true, useEnvVarLevel: true }) + .partial(); + +export type UpdateSystemSettingsRequest = z.infer< + typeof UpdateSystemSettingsRequestSchema +>; diff --git a/types/src/index.ts b/types/src/index.ts index ef83409fd..dd9096c9a 100644 --- a/types/src/index.ts +++ b/types/src/index.ts @@ -12,3 +12,4 @@ export * from './Theme.js'; export * from './XmlTvSettings.js'; export * from './misc.js'; export * from './util.js'; +export * from './SystemSettings.js'; diff --git a/types/src/util.ts b/types/src/util.ts index 4dc323ef4..f94f96f5f 100644 --- a/types/src/util.ts +++ b/types/src/util.ts @@ -12,3 +12,5 @@ export const tag = < >( x: Base, ): FinalTagType => x as unknown as FinalTagType; + +export type TupleToUnion> = T[number]; diff --git a/web/src/external/api.ts b/web/src/external/api.ts index c6d99ade3..3d70dddcf 100644 --- a/web/src/external/api.ts +++ b/web/src/external/api.ts @@ -35,11 +35,13 @@ import { getPlexBackendStatus, getPlexServersEndpoint, getPlexStreamSettings, + getSystemSettings, getXmlTvSettings, updateFfmpegSettings, updateHdhrSettings, updatePlexServerEndpoint, updatePlexStreamSettings, + updateSystemSettings, updateXmlTvSettings, } from './settingsApi.ts'; import { isEmpty } from 'lodash-es'; @@ -324,19 +326,6 @@ export const api = makeApi([ status: 200, response: z.object({ streamPath: z.string() }), }, - getPlexServersEndpoint, - createPlexServerEndpoint, - updatePlexServerEndpoint, - deletePlexServerEndpoint, - getPlexBackendStatus, - getXmlTvSettings, - updateXmlTvSettings, - getHdhrSettings, - updateHdhrSettings, - getPlexStreamSettings, - updatePlexStreamSettings, - getFffmpegSettings, - updateFfmpegSettings, { method: 'post', path: '/api/upload/image', @@ -354,6 +343,21 @@ export const api = makeApi([ }), }), }, + getPlexServersEndpoint, + createPlexServerEndpoint, + updatePlexServerEndpoint, + deletePlexServerEndpoint, + getPlexBackendStatus, + getXmlTvSettings, + updateXmlTvSettings, + getHdhrSettings, + updateHdhrSettings, + getPlexStreamSettings, + updatePlexStreamSettings, + getFffmpegSettings, + updateFfmpegSettings, + getSystemSettings, + updateSystemSettings, ]); export type ApiClient = ZodiosInstance; diff --git a/web/src/external/settingsApi.ts b/web/src/external/settingsApi.ts index fcdb570cc..6f90f8730 100644 --- a/web/src/external/settingsApi.ts +++ b/web/src/external/settingsApi.ts @@ -1,6 +1,8 @@ +import { SystemSettingsSchema } from '@tunarr/types'; import { InsertPlexServerRequestSchema, UpdatePlexServerRequestSchema, + UpdateSystemSettingsRequestSchema, } from '@tunarr/types/api'; import { FfmpegSettingsSchema, @@ -130,3 +132,20 @@ export const updatePlexStreamSettings = makeEndpoint({ parameters: parametersBuilder().addBody(PlexStreamSettingsSchema).build(), alias: 'updatePlexStreamSettings', }); + +export const getSystemSettings = makeEndpoint({ + method: 'get', + path: '/api/system/settings', + response: SystemSettingsSchema, + alias: 'getSystemSettings', +}); + +export const updateSystemSettings = makeEndpoint({ + method: 'put', + path: '/api/system/settings', + parameters: parametersBuilder() + .addBody(UpdateSystemSettingsRequestSchema) + .build(), + alias: 'updateSystemSettings', + response: SystemSettingsSchema, +}); diff --git a/web/src/hooks/useSystemSettings.ts b/web/src/hooks/useSystemSettings.ts new file mode 100644 index 000000000..eedea79b2 --- /dev/null +++ b/web/src/hooks/useSystemSettings.ts @@ -0,0 +1,28 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useApiQuery } from './useApiQuery.ts'; +import { useTunarrApi } from './useTunarrApi.ts'; +import { LogLevel } from '@tunarr/types'; + +export const useSystemSettings = () => + useApiQuery({ + queryFn(api) { + return api.getSystemSettings(); + }, + queryKey: ['system', 'settings'], + }); + +type UpdateSystemSettingsArgs = { + logLevel?: LogLevel; +}; + +export const useUpdateSystemSettings = () => { + const apiClient = useTunarrApi(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (payload: UpdateSystemSettingsArgs) => + apiClient.updateSystemSettings(payload), + onSuccess: (response) => + queryClient.setQueryData(['system', 'settings'], response), + }); +}; diff --git a/web/src/pages/settings/GeneralSettingsPage.tsx b/web/src/pages/settings/GeneralSettingsPage.tsx index a5e8ef752..8fa8753da 100644 --- a/web/src/pages/settings/GeneralSettingsPage.tsx +++ b/web/src/pages/settings/GeneralSettingsPage.tsx @@ -2,32 +2,59 @@ import { CloudDoneOutlined, CloudOff } from '@mui/icons-material'; import { Box, Divider, + FormControl, + FormHelperText, InputAdornment, + InputLabel, + MenuItem, + Select, Snackbar, TextField, Typography, } from '@mui/material'; import Button from '@mui/material/Button'; import Stack from '@mui/material/Stack'; -import { attempt, isEmpty, isError, trim, trimEnd } from 'lodash-es'; +import { LogLevel, LogLevels, SystemSettings } from '@tunarr/types'; +import { attempt, isEmpty, isError, map, trim, trimEnd } from 'lodash-es'; import { useState } from 'react'; import { Controller, useForm } from 'react-hook-form'; import { RotatingLoopIcon } from '../../components/base/LoadingIcon.tsx'; import DarkModeButton from '../../components/settings/DarkModeButton.tsx'; +import { + useSystemSettings, + useUpdateSystemSettings, +} from '../../hooks/useSystemSettings.ts'; import { useVersion } from '../../hooks/useVersion.ts'; import { setBackendUri } from '../../store/settings/actions.ts'; import { useSettings } from '../../store/settings/selectors.ts'; +import { UpdateSystemSettingsRequest } from '@tunarr/types/api'; -type GeneralSettingsForm = { +type GeneralSettingsFormData = { backendUri: string; + logLevel: LogLevel | 'env'; +}; + +type GeneralSetingsFormProps = { + systemSettings: SystemSettings; }; +const LogLevelChoices = [ + { + description: 'Use environment settings', + value: 'env', + }, + ...map(LogLevels, (level) => ({ + description: level, + value: level, + })), +]; + function isValidUrl(url: string) { const sanitized = trim(url); return isEmpty(sanitized) || !isError(attempt(() => new URL(sanitized))); } -export default function GeneralSettingsPage() { +function GeneralSettingsForm({ systemSettings }: GeneralSetingsFormProps) { const settings = useSettings(); const [snackStatus, setSnackStatus] = useState(false); const versionInfo = useVersion({ @@ -36,19 +63,40 @@ export default function GeneralSettingsPage() { const { isLoading, isError } = versionInfo; + const updateSystemSettings = useUpdateSystemSettings(); + + const getBaseFormValues = ( + systemSettings: SystemSettings, + ): GeneralSettingsFormData => ({ + backendUri: settings.backendUri, + logLevel: systemSettings.logging.useEnvVarLevel + ? 'env' + : systemSettings.logging.logLevel, + }); + const { control, handleSubmit, reset, formState: { isDirty, isValid, isSubmitting }, - } = useForm({ + } = useForm({ reValidateMode: 'onBlur', - defaultValues: settings, + defaultValues: getBaseFormValues(systemSettings), }); - const onSave = (data: GeneralSettingsForm) => { - setBackendUri(trimEnd(trim(data.backendUri), '/')); + const onSave = (data: GeneralSettingsFormData) => { + const newBackendUri = trimEnd(trim(data.backendUri), '/'); + setBackendUri(newBackendUri); setSnackStatus(true); + const updateReq: UpdateSystemSettingsRequest = { + logLevel: data.logLevel === 'env' ? undefined : data.logLevel, + useEnvVarLevel: data.logLevel === 'env', + }; + updateSystemSettings.mutate(updateReq, { + onSuccess(data) { + reset(getBaseFormValues(data), { keepDirty: false }); + }, + }); }; return ( @@ -60,18 +108,11 @@ export default function GeneralSettingsPage() { onClose={() => setSnackStatus(false)} message="Settings Saved!" /> - - - - Theme Settings - - - - + + + Server Settings + - - Server Settings - + + + Log Level + ( + + )} + /> + + Set the log level for the Tunarr server. +
+ Selecting "Use environment settings" will + instruct the server to use the LOG_LEVEL environment + variable, if set, or system default "info". +
+
+
{isDirty && ( @@ -127,3 +196,30 @@ export default function GeneralSettingsPage() { ); } + +export default function GeneralSettingsPage() { + const systemSettings = useSystemSettings(); + + // TODO: Handle loading and error states. + + return ( + systemSettings.data && ( + + + + + Theme Settings + + + + This setting is stored in your browser and is saved automatically + when changed. + + + + + + + ) + ); +}