feat(server): implement simple log roller (#609)

Adds a pino destination that performs rudimentary log rotation based on
file size / schedule / both. The rotation can also be configured to only
keep a certain number of logs.

This needs some UI work so I haven't actually hooked it into the logger
yet.
This commit is contained in:
Christian Benincasa
2026-01-15 14:40:13 -05:00
committed by GitHub
parent 9e9b00540b
commit ab3624675c
22 changed files with 853 additions and 205 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

13
pnpm-lock.yaml generated
View File

@@ -298,6 +298,9 @@ importers:
retry:
specifier: ^0.13.1
version: 0.13.1
sonic-boom:
specifier: 4.2.0
version: 4.2.0
split2:
specifier: ^4.2.0
version: 4.2.0
@@ -8291,8 +8294,8 @@ packages:
sonic-boom@3.8.1:
resolution: {integrity: sha512-y4Z8LCDBuum+PBP3lSV7RHrXscqksve/bi0as7mhwVnBW+/wUqKT/2Kb7um8yqcFy0duYbbPxzt89Zy2nOCaxg==}
sonic-boom@4.1.0:
resolution: {integrity: sha512-NGipjjRicyJJ03rPiZCJYjwlsuP2d1/5QUviozRXC7S3WdVWNK5e3Ojieb9CCyfhq2UC+3+SRd9nG3I2lPRvUw==}
sonic-boom@4.2.0:
resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==}
sorted-array-functions@1.3.0:
resolution: {integrity: sha512-2sqgzeFlid6N4Z2fUQ1cvFmTOLRi/sEDzSQ0OKYchqgoPmQBVyM3959qYx3fpS6Esef80KjmpgPeEr028dP3OA==}
@@ -16854,7 +16857,7 @@ snapshots:
pump: 3.0.0
readable-stream: 4.4.2
secure-json-parse: 2.7.0
sonic-boom: 4.1.0
sonic-boom: 4.2.0
strip-json-comments: 3.1.1
pino-roll@1.3.0:
@@ -16874,7 +16877,7 @@ snapshots:
quick-format-unescaped: 4.0.4
real-require: 0.2.0
safe-stable-stringify: 2.4.3
sonic-boom: 4.1.0
sonic-boom: 4.2.0
thread-stream: 3.1.0
pirates@4.0.6: {}
@@ -17706,7 +17709,7 @@ snapshots:
dependencies:
atomic-sleep: 1.0.0
sonic-boom@4.1.0:
sonic-boom@4.2.0:
dependencies:
atomic-sleep: 1.0.0

View File

@@ -81,6 +81,7 @@
"random-js": "2.1.0",
"reflect-metadata": "^0.2.2",
"retry": "^0.13.1",
"sonic-boom": "4.2.0",
"split2": "^4.2.0",
"ts-pattern": "^5.8.0",
"tslib": "^2.8.1",

View File

@@ -189,9 +189,17 @@ export const systemApiRouter: RouterPluginAsyncCallback = async (
system.server = server;
});
ifDefined(req.body.logging?.logRollConfig, (logging) => {
system.logging.logRollConfig = logging;
});
return file;
});
if (req.body.logging?.logRollConfig) {
LoggerFactory.rollLogsNow();
}
const refreshedSettings = req.serverCtx.settings.systemSettings();
return res.send(getSystemSettingsResponse(refreshedSettings));

View File

@@ -4,7 +4,7 @@ import {
SettingsChangeEvents,
} from '@/db/interfaces/ISettingsDB.js';
import { TypedEventEmitter } from '@/types/eventEmitter.js';
import { isProduction } from '@/util/index.js';
import { deepCopy, isProduction } from '@/util/index.js';
import { type Logger, LoggerFactory } from '@/util/logging/LoggerFactory.js';
import {
DefaultServerSettings,
@@ -39,6 +39,7 @@ import { setImmediate } from 'node:timers';
import { DeepPartial, DeepReadonly } from 'ts-essentials';
import { v4 as uuidv4 } from 'uuid';
import { z } from 'zod/v4';
import { Maybe } from '../types/util.ts';
import {
getDefaultLogDirectory,
getDefaultLogLevel,
@@ -112,6 +113,11 @@ export const defaultSettings = (dbBasePath: string): SettingsFile => ({
logLevel: getDefaultLogLevel(),
logsDirectory: getDefaultLogDirectory(),
useEnvVarLevel: true,
logRollConfig: {
enabled: false,
rolledFileLimit: 3,
maxFileSizeBytes: Math.pow(2, 20),
},
},
cache: {
enablePlexRequestCache: false,
@@ -191,12 +197,18 @@ export class SettingsDB extends ITypedEventEmitter implements ISettingsDB {
}
async directUpdate(fn: (settings: SettingsFile) => SettingsFile | void) {
return await this.db.update(fn).then(() => {
this.logger?.debug(
'Detected change to settings DB file on disk. Reloading.',
);
this.emit('change');
});
let prevSettings: Maybe<SettingsFile>;
return await this.db
.update((prev) => {
prevSettings = deepCopy(prev);
fn(prev);
})
.then(() => {
this.logger?.debug(
'Detected change to settings DB file on disk. Reloading.',
);
this.emit('change', prevSettings);
});
}
async updateSettings<K extends keyof Settings>(
@@ -213,8 +225,8 @@ export class SettingsDB extends ITypedEventEmitter implements ISettingsDB {
key: K,
settings: Partial<SettingsFile[K]>,
) {
return await this.db.update((olDsettings) => {
olDsettings[key] = merge(olDsettings[key], settings);
return await this.db.update((oldSettings) => {
oldSettings[key] = merge(oldSettings[key], settings);
});
}

View File

@@ -62,5 +62,5 @@ export interface ISettingsDB extends TypedEventEmitter<SettingsChangeEvents> {
export type ReadableFfmpegSettings = DeepReadonly<FfmpegSettings>;
export type SettingsChangeEvents = {
change(): void;
change(prevSettings?: SettingsFile): void;
};

View File

@@ -7,7 +7,7 @@ import { ScheduledTask } from '@/tasks/ScheduledTask.js';
import type { Task, Task2, TaskId, TaskOutputType } from '@/tasks/Task.js';
import type { Maybe } from '@/types/util.js';
import { LoggerFactory } from '@/util/logging/LoggerFactory.js';
import { parseEveryScheduleRule } from '@/util/schedulingUtil.js';
import { scheduleRuleToCronString } from '@/util/schedulingUtil.js';
import type { BackupSettings } from '@tunarr/types/schemas';
import dayjs, { type Dayjs } from 'dayjs';
import type { interfaces } from 'inversify';
@@ -164,7 +164,7 @@ export function scheduleBackupJobs(
let cronSchedule: string;
switch (config.schedule.type) {
case 'every': {
cronSchedule = parseEveryScheduleRule(config.schedule);
cronSchedule = scheduleRuleToCronString(config.schedule);
break;
}
case 'cron': {

View File

@@ -0,0 +1,16 @@
import { LoggerFactory } from '../util/logging/LoggerFactory.ts';
import { SimpleTask } from './Task.ts';
import { simpleTaskDef } from './TaskRegistry.ts';
@simpleTaskDef()
export class RollLogFileTask extends SimpleTask {
public ID: string = RollLogFileTask.name;
protected runInternal(): Promise<void> {
try {
LoggerFactory.rollLogsNow();
} catch (e) {
this.logger.error(e, 'Error rolling logs');
}
return Promise.resolve();
}
}

View File

@@ -29,6 +29,7 @@ import type {
import { UpdatePlexPlayStatusScheduledTask } from './plex/UpdatePlexPlayStatusTask.ts';
import { RefreshMediaSourceLibraryTask } from './RefreshMediaSourceLibraryTask.ts';
import { RemoveDanglingProgramsFromSearchTask } from './RemoveDanglingProgramsFromSearchTask.ts';
import { RollLogFileTask } from './RollLogFileTask.ts';
import { ScanLibrariesTask } from './ScanLibrariesTask.ts';
import { SubtitleExtractorTask } from './SubtitleExtractorTask.ts';
@@ -85,6 +86,8 @@ const TasksModule = new ContainerModule((bind) => {
ArchiveDatabaseBackup,
);
bind(RollLogFileTask).toSelf();
bindFactoryFunc<BackupTaskFactory>(
bind,
BackupTask.KEY,

View File

@@ -5,6 +5,7 @@ import { isNonEmptyString, isProduction, isTest } from '@/util/index.js';
import {
forEach,
isEmpty,
isEqual,
isString,
isUndefined,
nth,
@@ -23,6 +24,7 @@ import pino, {
import type { PrettyOptions } from 'pino-pretty';
import pretty from 'pino-pretty';
import type ThreadStream from 'thread-stream';
import { RollingLogDestination } from './RollingDestination.ts';
export const LogConfigEnvVars = {
level: 'LOG_LEVEL',
@@ -99,6 +101,7 @@ class LoggerFactoryImpl {
private initialized = false;
private children: Record<string, Logger> = {};
private currentStreams: MultiStreamRes<LogLevels>;
private roller?: RollingLogDestination;
constructor() {
// This ensures we always have a logger with the default configuration.
@@ -114,14 +117,21 @@ class LoggerFactoryImpl {
// but it does seem to work with relative paths + the shim... so I'm
// going to keep them around for now.
this.rootLogger = this.createRootLogger();
this.settingsDB.on('change', () => {
this.settingsDB.on('change', (prevSettings) => {
if (!this.initialized) {
return;
}
const currentSettings =
this.settingsDB.systemSettings().logging.logRollConfig;
const { level: newLevel } = this.logLevel;
if (this.rootLogger[symbols.getLevelSym] !== newLevel) {
if (
this.rootLogger[symbols.getLevelSym] !== newLevel ||
!prevSettings ||
!isEqual(prevSettings.system.logging.logRollConfig, currentSettings)
) {
this.updateLevel(newLevel);
}
});
@@ -146,6 +156,10 @@ class LoggerFactoryImpl {
return this.initialized;
}
rollLogsNow() {
this.roller?.roll();
}
// HACK - but this is how we change transports without a restart:
// 1. Flush and close the existing transport
// 2. Update the transports to the new set
@@ -259,17 +273,43 @@ class LoggerFactoryImpl {
// We can only add these streams post-initialization because they
// require configuration.
if (!isUndefined(this.settingsDB) && !isTest) {
streams.push({
stream: pino.destination({
dest: join(
this.settingsDB.systemSettings().logging.logsDirectory,
'tunarr.log',
),
mkdir: true,
append: true,
}),
level: logLevel,
});
// TODO Expose this in the UI with configuration
const logConfig = this.settingsDB.systemSettings().logging;
const logFilePath = join(logConfig.logsDirectory, 'tunarr.log');
this.roller?.deinitialize();
this.roller = undefined;
if (logConfig.logRollConfig.enabled) {
this.roller = new RollingLogDestination({
fileName: logFilePath,
maxSizeBytes: logConfig.logRollConfig.maxFileSizeBytes,
rotateSchedule: logConfig.logRollConfig.schedule,
fileLimit: {
count: logConfig.logRollConfig.rolledFileLimit,
},
destinationOpts: {
mkdir: true,
append: true,
},
});
streams.push({
stream: this.roller.initDestination(),
level: logLevel,
});
} else {
streams.push({
stream: pino.destination({
dest: join(
this.settingsDB.systemSettings().logging.logsDirectory,
'tunarr.log',
),
mkdir: true,
append: true,
}),
level: logLevel,
});
}
}
return streams;

View File

@@ -0,0 +1,229 @@
import type { Schedule } from '@tunarr/types/schemas';
import {
forEach,
isError,
isNull,
isUndefined,
map,
nth,
uniq,
} from 'lodash-es';
import fs from 'node:fs';
import path from 'node:path';
import * as sb from 'sonic-boom';
import {
type SonicBoomOpts,
type SonicBoom as SonicBoomType,
} from 'sonic-boom';
import type { UnknownScheduledTask } from '../../tasks/ScheduledTask.ts';
import { ScheduledTask } from '../../tasks/ScheduledTask.ts';
import { SimpleTask } from '../../tasks/Task.ts';
import type { Maybe } from '../../types/util.ts';
import { attemptSync, isDefined } from '../index.ts';
import { scheduleRuleToCronString } from '../schedulingUtil.ts';
type Opts = {
fileName: string;
fileExt?: string;
maxSizeBytes?: number;
rotateSchedule?: Schedule;
extension?: string;
destinationOpts?: SonicBoomOpts;
fileLimit?: {
count?: number;
};
};
export class RollingLogDestination {
private initialized = false;
private scheduledTask: Maybe<UnknownScheduledTask>;
private destination: SonicBoomType;
private currentFileName: string;
private createdFileNames: string[] = [];
private rotatePattern: RegExp;
constructor(private opts: Opts) {
this.rotatePattern = new RegExp(`(\\d+)${this.opts.extension ?? ''}$`);
this.initState();
}
initDestination(): SonicBoomType {
if (this.initialized || this.destination) {
return this.destination;
}
if (this.opts.rotateSchedule) {
let schedule: string;
switch (this.opts.rotateSchedule.type) {
case 'cron':
schedule = this.opts.rotateSchedule.cron;
break;
case 'every':
schedule = scheduleRuleToCronString(this.opts.rotateSchedule);
break;
}
this.scheduledTask = new ScheduledTask(
RollLogFileTask,
schedule,
() => new RollLogFileTask(this),
undefined,
);
}
this.destination = new sb.default.SonicBoom({
...(this.opts.destinationOpts ?? {}),
dest: this.opts.fileName,
});
if (this.opts.maxSizeBytes && this.opts.maxSizeBytes > 0) {
let currentSize = getFileSize(this.currentFileName);
this.destination.on('write', (size) => {
currentSize += size;
if (
isDefined(this.opts.maxSizeBytes) &&
this.opts.maxSizeBytes > 0 &&
currentSize >= this.opts.maxSizeBytes
) {
currentSize = 0;
// Make sure the log flushes before we roll
setTimeout(() => {
const rollResult = attemptSync(() => this.roll());
if (isError(rollResult)) {
console.error('Error while rolling log files', rollResult);
}
}, 0);
}
});
}
if (this.scheduledTask) {
this.destination.on('close', () => {
this.scheduledTask?.cancel();
});
}
return this.destination;
}
deinitialize() {
if (!this.initialized) {
return;
}
this.scheduledTask?.cancel(false);
}
roll() {
if (!this.destination) {
return;
}
this.destination.flushSync();
const tmpFile = `${this.opts.fileName}.tmp`;
fs.copyFileSync(this.opts.fileName, tmpFile);
fs.truncateSync(this.opts.fileName);
const numFiles = this.createdFileNames.length;
const dirname = path.dirname(this.opts.fileName);
const addedFiles: string[] = [];
for (let i = numFiles; i > 0; i--) {
const f = nth(this.createdFileNames, i - 1);
if (isUndefined(f)) {
continue;
}
const rotateMatches = f.match(this.rotatePattern);
// This really shouldn't happen since the file shouldn't
// make it into the array in the first place if it doesn't
// match..
if (isNull(rotateMatches)) {
continue;
}
const rotateNum = parseInt(rotateMatches[1]!);
// Again shouldn't happen since we've already matched
// that this part of the file is a number...
if (isNaN(rotateNum)) {
continue;
}
const nextNum = rotateNum + 1;
const nextFile = f.replace(this.rotatePattern, `${nextNum}`);
const result = attemptSync(() =>
fs.renameSync(path.join(dirname, f), path.join(dirname, nextFile)),
);
if (isError(result)) {
console.warn(`Error rotating ${path.join(dirname, f)}`);
}
addedFiles.push(nextFile);
}
const nextFile = this.buildFileName(1);
fs.renameSync(tmpFile, nextFile);
this.createdFileNames = uniq([
path.basename(nextFile),
...this.createdFileNames,
...addedFiles.slice(0, 1),
]);
if (this.opts.fileLimit) {
this.checkFileRemoval();
}
}
private initState() {
for (const file of fs.readdirSync(path.dirname(this.opts.fileName))) {
if (file.match(this.rotatePattern)) {
this.createdFileNames.push(file);
}
}
}
private checkFileRemoval() {
const count = this.opts.fileLimit?.count;
if (count && count >= 1 && this.createdFileNames.length > count) {
// We start removing at the first file to delete and take the rest of the
// array. In general this will be just one file.
const filesToRemove = this.createdFileNames.splice(count);
forEach(
map(filesToRemove, (file) =>
path.join(path.dirname(this.opts.fileName), file),
),
(file) => {
const res = attemptSync(() => fs.unlinkSync(file));
if (isError(res)) {
console.warn(`Error while deleting log file ${file}`, res);
}
},
);
}
return;
}
private buildFileName(num: number) {
return `${this.opts.fileName}.${num}${this.opts.fileExt ?? ''}`;
}
}
class RollLogFileTask extends SimpleTask {
constructor(private dest: RollingLogDestination) {
super();
}
protected runInternal(): Promise<void> {
return Promise.resolve(this.dest.roll());
}
}
function getFileSize(path: string) {
const result = attemptSync(() => fs.statSync(path));
return isError(result) ? 0 : result.size;
}

View File

@@ -1,6 +1,6 @@
import { EverySchedule } from '@tunarr/types/schemas';
import dayjs from './dayjs.ts';
import { parseEveryScheduleRule } from './schedulingUtil.ts';
import { scheduleRuleToCronString } from './schedulingUtil.ts';
test('should parse every schedules', () => {
const schedule: EverySchedule = {
type: 'every',
@@ -9,5 +9,5 @@ test('should parse every schedules', () => {
unit: 'hour',
};
expect(parseEveryScheduleRule(schedule)).toEqual('0 4-23 * * *');
expect(scheduleRuleToCronString(schedule)).toEqual('0 4-23 * * *');
});

View File

@@ -53,7 +53,7 @@ const defaultCronFields: CronFields = run(() => {
) as Required<CronFields>;
});
export function parseEveryScheduleRule(schedule: EverySchedule) {
export function scheduleRuleToCronString(schedule: EverySchedule) {
const offset = dayjs.duration(schedule.offsetMs);
function getRange(

View File

@@ -1,5 +1,6 @@
import { z } from 'zod/v4';
import { BackupSettingsSchema } from './schemas/settingsSchemas.js';
import { ScheduleSchema } from './schemas/utilSchemas.js';
import { type TupleToUnion } from './util.js';
export const LogLevelsSchema = z.union([
@@ -28,10 +29,22 @@ export const LogLevels = [
export type LogLevel = TupleToUnion<typeof LogLevels>;
export const LogRollConfigSchema = z.object({
enabled: z.boolean().default(false),
maxFileSizeBytes: z.number().positive().optional(),
rolledFileLimit: z.number().positive(),
schedule: ScheduleSchema.optional(),
});
export const LoggingSettingsSchema = z.object({
logLevel: LogLevelsSchema,
logsDirectory: z.string(),
useEnvVarLevel: z.boolean().default(true),
logRollConfig: LogRollConfigSchema.optional().default({
enabled: false,
maxFileSizeBytes: Math.pow(2, 20), // 1MB => 1,048,576 bytes
rolledFileLimit: 3,
}),
});
export type LoggingSettings = z.infer<typeof LoggingSettingsSchema>;

View File

@@ -261,7 +261,11 @@ export type SystemSettingsResponse = z.infer<
>;
export const UpdateSystemSettingsRequestSchema = z.object({
logging: LoggingSettingsSchema.pick({ logLevel: true, useEnvVarLevel: true })
logging: LoggingSettingsSchema.pick({
logLevel: true,
useEnvVarLevel: true,
logRollConfig: true,
})
.partial()
.optional(),
backup: BackupSettingsSchema.optional(),

View File

@@ -139,3 +139,5 @@ export const ContentProgramTypeSchema = z.enum([
'music_video',
'other_video',
]);
export type Schedule = z.infer<typeof ScheduleSchema>;

View File

@@ -12,6 +12,7 @@ import { CloudDoneOutlined, CloudOff } from '@mui/icons-material';
import {
Box,
Checkbox,
Divider,
FormControl,
FormControlLabel,
FormHelperText,
@@ -27,6 +28,7 @@ import {
import Button from '@mui/material/Button';
import Grid from '@mui/material/Grid';
import Stack from '@mui/material/Stack';
import type { LoggingSettings } from '@tunarr/types';
import {
LogLevels,
type CacheSettings,
@@ -46,7 +48,9 @@ import {
useFieldArray,
useForm,
} from 'react-hook-form';
import type { SettingsStateInternal } from '../../../store/settings/store.ts';
import { BackupForm } from './BackupForm.tsx';
import { LogRollForm } from './LogRollForm.tsx';
const LogLevelChoices = [
{
@@ -59,6 +63,37 @@ const LogLevelChoices = [
})),
];
const getBaseFormValues = (
settings: SettingsStateInternal,
systemSettings: SystemSettings,
): GeneralSettingsFormData => ({
backendUri: settings.backendUri,
logLevel: systemSettings.logging.useEnvVarLevel
? 'env'
: systemSettings.logging.logLevel,
backup: systemSettings.backup,
cache: systemSettings.cache ?? {
enablePlexRequestCache: false,
},
server: systemSettings.server,
logging: systemSettings.logging,
});
function getDefaultFormValues(
settings: SettingsStateInternal,
systemSettings: SystemSettings,
) {
const base = getBaseFormValues(settings, systemSettings);
base.logging.logRollConfig.maxFileSizeBytes ??= 0;
base.logging.logRollConfig.schedule ??= {
type: 'every',
unit: 'day',
increment: 0,
offsetMs: 0,
};
return base;
}
export function GeneralSettingsForm({
systemSettings,
}: GeneralSetingsFormProps) {
@@ -74,22 +109,8 @@ export function GeneralSettingsForm({
const updateSystemSettings = useUpdateSystemSettings();
const getBaseFormValues = (
systemSettings: SystemSettings,
): GeneralSettingsFormData => ({
backendUri: settings.backendUri,
logLevel: systemSettings.logging.useEnvVarLevel
? 'env'
: systemSettings.logging.logLevel,
backup: systemSettings.backup,
cache: systemSettings.cache ?? {
enablePlexRequestCache: false,
},
server: systemSettings.server,
});
const settingsForm = useForm<GeneralSettingsFormData>({
defaultValues: getBaseFormValues(systemSettings),
defaultValues: getDefaultFormValues(settings, systemSettings),
});
const {
@@ -105,17 +126,30 @@ export function GeneralSettingsForm({
name: 'backup.configurations',
});
const backupsValue = watch('backup');
// eslint-disable-next-line react-hooks/incompatible-library
const [backupsValue] = watch(['backup']);
const backupsEnabled = backupsValue.configurations.length > 0;
const onSave = (data: GeneralSettingsFormData) => {
const newBackendUri = trimEnd(trim(data.backendUri), '/');
setBackendUri(newBackendUri);
const rollSchedule =
data.logging.logRollConfig.schedule?.type === 'every' &&
data.logging.logRollConfig.schedule.increment >= 0
? data.logging.logRollConfig.schedule
: undefined;
const updateReq: UpdateSystemSettingsRequest = {
logging: {
logLevel: data.logLevel === 'env' ? undefined : data.logLevel,
useEnvVarLevel: data.logLevel === 'env',
logRollConfig: {
...data.logging.logRollConfig,
maxFileSizeBytes: data.logging.logRollConfig.maxFileSizeBytes
? Math.max(0, data.logging.logRollConfig.maxFileSizeBytes)
: undefined,
schedule: rollSchedule,
},
},
backup: data.backup,
cache: data.cache,
@@ -126,7 +160,7 @@ export function GeneralSettingsForm({
{ body: updateReq },
{
onSuccess(data) {
reset(getBaseFormValues(data), { keepDirty: false });
reset(getBaseFormValues(settings, data), { keepDirty: false });
snackbar.enqueueSnackbar('Settings Saved!', {
variant: 'success',
});
@@ -187,173 +221,188 @@ export function GeneralSettingsForm({
</FormHelperText>
</FormControl>
</Grid>
{backupsEnabled && (
<FormProvider {...settingsForm}>
<BackupForm />
</FormProvider>
)}
{backupsEnabled && <BackupForm />}
</Grid>
);
}
return (
<Box component="form" onSubmit={handleSubmit(onSave, console.error)}>
<Stack gap={2} spacing={2}>
<Typography variant="h5" sx={{ mb: 1 }}>
Server Settings
</Typography>
{!systemState.data.isInContainer && (
<NumericFormControllerText
control={control}
name="server.port"
TextFieldProps={{
label: 'Server Listen Port',
sx: {
width: ['100%', '50%'],
},
helperText:
'Select the port the Tunarr server will listen on. This requires a server restart to take effect.',
}}
/>
)}
<Box>
<Controller
control={control}
name="backendUri"
rules={{ validate: { isValidUrl: (s) => isValidUrl(s, true) } }}
render={({ field, fieldState: { error } }) => (
<TextField
<FormProvider {...settingsForm}>
<Stack gap={2} divider={<Divider />}>
<Stack gap={2}>
<Typography variant="h5" sx={{ mb: 1 }}>
Server Settings
</Typography>
<Stack direction={{ sm: 'column', md: 'row' }} gap={2}>
{!systemState.data.isInContainer && (
<NumericFormControllerText
control={control}
name="server.port"
TextFieldProps={{
label: 'Server Listen Port',
fullWidth: true,
sx: {
width: { sm: '100%', md: '50%' },
},
helperText:
'Select the port the Tunarr server will listen on. This requires a server restart to take effect.',
}}
/>
)}
<Controller
control={control}
name="backendUri"
rules={{ validate: { isValidUrl: (s) => isValidUrl(s, true) } }}
render={({ field, fieldState: { error } }) => (
<TextField
sx={{
width: { sm: '100%', md: '50%' },
}}
fullWidth
label="Tunarr Backend URL"
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
{isLoading ? (
<RotatingLoopIcon />
) : !isError ? (
<CloudDoneOutlined color="success" />
) : (
<CloudOff color="error" />
)}
</InputAdornment>
),
},
}}
{...field}
helperText={
error?.type === 'isValidUrl'
? 'Must use a valid URL, or empty.'
: 'Set the host of your Tunarr backend. When empty, the web UI will use the current host/port to communicate with the backend.'
}
/>
)}
/>
</Stack>
</Stack>
<Stack gap={1}>
<Typography variant="h6" sx={{ mb: 1 }}>
Logging
</Typography>
<Box>
<FormControl
sx={{
width: ['100%', '50%'],
}}
label="Tunarr Backend URL"
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
{isLoading ? (
<RotatingLoopIcon />
) : !isError ? (
<CloudDoneOutlined color="success" />
) : (
<CloudOff color="error" />
)}
</InputAdornment>
),
},
}}
{...field}
helperText={
error?.type === 'isValidUrl'
? 'Must use a valid URL, or empty.'
: 'Set the host of your Tunarr backend. When empty, the web UI will use the current host/port to communicate with the backend.'
}
/>
)}
/>
</Box>
<Box>
<FormControl
sx={{
width: ['100%', '50%'],
}}
>
<InputLabel id="log-level-label">Log Level</InputLabel>
<Controller
name="logLevel"
control={control}
render={({ field }) => (
<Select
labelId="log-level-label"
id="log-level"
label="Log Level"
{...field}
>
{map(LogLevelChoices, ({ value, description }) => (
<MenuItem key={value} value={value}>
{description}
</MenuItem>
))}
</Select>
)}
/>
<FormHelperText>
Set the log level for the Tunarr server.
<br />
Selecting <strong>"Use environment settings"</strong> will
instruct the server to use the <code>LOG_LEVEL</code> environment
variable, if set, or system default "info".
</FormHelperText>
</FormControl>
</Box>
<Box>
<Typography variant="h6" sx={{ mb: 1 }}>
Backups
</Typography>
{renderBackupsForm()}
</Box>
<Box>
<Typography variant="h6" sx={{ mb: 1 }}>
Caching
</Typography>
<Box>
<FormControl
sx={{
width: ['100%', '50%'],
}}
>
<FormControlLabel
control={
<Controller
control={control}
name="cache.enablePlexRequestCache"
render={({ field }) => (
<Checkbox checked={field.value} {...field} />
)}
/>
}
label={
<span>
<strong>Experimental:</strong> Enable Plex Request Cache{' '}
<Tooltip
title="Temporarily caches responses from Plex based by request path. Could potentially speed up channel editing."
placement="top"
>
<InputLabel id="log-level-label">Log Level</InputLabel>
<Controller
name="logLevel"
control={control}
render={({ field }) => (
<Select
labelId="log-level-label"
id="log-level"
label="Log Level"
{...field}
>
<sup style={{ color: theme.palette.primary.main }}>
[?]
</sup>
</Tooltip>
</span>
}
/>
<FormHelperText>
This feature is currently experimental. Proceed with caution and
if you experience an issue, try disabling caching.
</FormHelperText>
</FormControl>
{map(LogLevelChoices, ({ value, description }) => (
<MenuItem key={value} value={value}>
{description}
</MenuItem>
))}
</Select>
)}
/>
<FormHelperText>
Set the log level for the Tunarr server.
<br />
Selecting <strong>"Use environment settings"</strong> will
instruct the server to use the <code>LOG_LEVEL</code>{' '}
environment variable, if set, or system default "info".
</FormHelperText>
</FormControl>
</Box>
<Box>
<LogRollForm />
</Box>
</Stack>
<Box>
<Typography variant="h6" sx={{ mb: 1 }}>
Backups
</Typography>
{renderBackupsForm()}
</Box>
</Box>
</Stack>
<Stack spacing={2} direction="row" justifyContent="right" sx={{ mt: 2 }}>
{isDirty && (
<Box>
<Typography variant="h6" sx={{ mb: 1 }}>
Caching
</Typography>
<Box>
<FormControl
sx={{
width: ['100%', '50%'],
}}
>
<FormControlLabel
control={
<Controller
control={control}
name="cache.enablePlexRequestCache"
render={({ field }) => (
<Checkbox checked={field.value} {...field} />
)}
/>
}
label={
<span>
<strong>Experimental:</strong> Enable Plex Request Cache{' '}
<Tooltip
title="Temporarily caches responses from Plex based by request path. Could potentially speed up channel editing."
placement="top"
>
<sup style={{ color: theme.palette.primary.main }}>
[?]
</sup>
</Tooltip>
</span>
}
/>
<FormHelperText>
This feature is currently experimental. Proceed with caution
and if you experience an issue, try disabling caching.
</FormHelperText>
</FormControl>
</Box>
</Box>
</Stack>
<Stack
spacing={2}
direction="row"
justifyContent="right"
sx={{ mt: 2 }}
>
{isDirty && (
<Button
variant="outlined"
onClick={() => {
reset(getBaseFormValues(settings, systemSettings));
}}
disabled={!isValid || isSubmitting || !isDirty}
>
Reset Options
</Button>
)}
<Button
variant="outlined"
onClick={() => {
reset(getBaseFormValues(systemSettings));
}}
variant="contained"
type="submit"
disabled={!isValid || isSubmitting || !isDirty}
>
Reset Options
Save
</Button>
)}
<Button
variant="contained"
type="submit"
disabled={!isValid || isSubmitting || !isDirty}
>
Save
</Button>
</Stack>
</Stack>
</FormProvider>
</Box>
);
}
@@ -363,7 +412,9 @@ export type GeneralSettingsFormData = {
backup: BackupSettings;
cache: CacheSettings;
server: ServerSettings;
logging: LoggingSettings;
};
export type GeneralSetingsFormProps = {
systemSettings: SystemSettings;
};

View File

@@ -0,0 +1,202 @@
import {
Checkbox,
FormControl,
FormControlLabel,
FormHelperText,
Grid,
MenuItem,
Select,
Stack,
Typography,
} from '@mui/material';
import { TimePicker } from '@mui/x-date-pickers';
import type { EverySchedule } from '@tunarr/types/schemas';
import dayjs from 'dayjs';
import pluralize from 'pluralize';
import { useCallback } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { formatBytes } from '../../../helpers/util.ts';
import {
CheckboxFormController,
NumericFormControllerText,
} from '../../util/TypedController.tsx';
import type { GeneralSettingsFormData } from './GeneralSettingsForm.tsx';
export const LogRollForm = () => {
const { control, watch, setValue } =
useFormContext<GeneralSettingsFormData>();
const [enabled, schedule] = watch([
'logging.logRollConfig.enabled',
'logging.logRollConfig.schedule',
]);
const currentBackupSchedule = schedule as EverySchedule | undefined;
const handleBackupTimeChange = useCallback(
(
value: dayjs.Dayjs | null,
originalOnChange: (...args: unknown[]) => void,
) => {
if (!value) {
originalOnChange(0);
return;
}
const h = value.hour();
const m = value.minute();
const millis = dayjs.duration({ hours: h, minutes: m }).asMilliseconds();
originalOnChange(millis);
},
[],
);
return (
<Stack gap={2}>
<FormControl>
<FormControlLabel
label="Enable Log File Rolling"
control={
<CheckboxFormController
control={control}
name="logging.logRollConfig.enabled"
/>
}
/>
<FormHelperText>
Enable rolling log files using time and/or size based criteria
</FormHelperText>
</FormControl>
{enabled && (
<>
<Grid size={{ xs: 6 }}>
<FormControl>
<FormControlLabel
label="Roll on Schedule"
control={
<Controller
control={control}
name="logging.logRollConfig.schedule.increment"
render={({ field }) => (
<Checkbox
checked={(field.value ?? 0) > 0}
onChange={(_, checked) => {
field.onChange(checked ? 1 : 0);
setValue(
'logging.logRollConfig.schedule.unit',
'day',
{ shouldDirty: true },
);
setValue(
'logging.logRollConfig.schedule.offsetMs',
0,
{ shouldDirty: true },
);
}}
/>
)}
/>
}
/>
<FormHelperText>
Roll the log file on a fixed schedule, regardless of file size.
</FormHelperText>
</FormControl>
{(currentBackupSchedule?.increment ?? 0) > 0 && (
<Stack direction="row" alignItems="center" spacing={2}>
<Typography>Every</Typography>
<NumericFormControllerText
control={control}
name="logging.logRollConfig.schedule.increment"
prettyFieldName="Max Backups"
rules={{ min: 1 }}
TextFieldProps={{
sx: { width: '30%' },
}}
/>
<Controller
control={control}
name="logging.logRollConfig.schedule.unit"
render={({ field }) => (
<Select
{...field}
value={
field.value === 'day' || field.value === 'hour'
? field.value
: 'day'
}
sx={{ minWidth: '25%' }}
>
<MenuItem value="hour">
{pluralize('Hour', currentBackupSchedule!.increment)}
</MenuItem>
<MenuItem value="day">
{pluralize('Day', currentBackupSchedule!.increment)}
</MenuItem>
</Select>
)}
/>
{currentBackupSchedule!.unit === 'day' && (
<Controller
control={control}
name="logging.logRollConfig.schedule.offsetMs"
render={({ field }) => (
<TimePicker
value={dayjs()
.startOf('day')
.add(currentBackupSchedule!.offsetMs)}
onChange={(value) =>
handleBackupTimeChange(value, field.onChange)
}
/>
)}
/>
)}
</Stack>
)}
</Grid>
<Stack direction={'row'}>
<FormControl fullWidth>
<FormControlLabel
label="Roll based on size"
control={
<Controller
control={control}
name="logging.logRollConfig.maxFileSizeBytes"
render={({ field }) => (
<Checkbox
checked={(field.value ?? 0) > 0}
onChange={(_, checked) => {
field.onChange(checked ? Math.pow(2, 20) : 0);
setValue(
'logging.logRollConfig.schedule.unit',
'day',
{
shouldDirty: true,
},
);
}}
/>
)}
/>
}
/>
<FormHelperText>
Roll the log file on a fixed schedule, regardless of file size.
</FormHelperText>
</FormControl>
<NumericFormControllerText
control={control}
name="logging.logRollConfig.maxFileSizeBytes"
TextFieldProps={{
fullWidth: true,
label: 'Max file size (bytes)',
helperText: ({ field }) =>
field.value ? `${formatBytes(field.value)}` : '',
}}
/>
</Stack>
</>
)}
</Stack>
);
};

View File

@@ -16244,6 +16244,20 @@ export type GetApiSystemSettingsResponses = {
logLevel: 'silent' | 'fatal' | 'error' | 'warn' | 'info' | 'http' | 'debug' | 'http_out' | 'trace';
logsDirectory: string;
useEnvVarLevel: boolean;
logRollConfig: {
enabled: boolean;
maxFileSizeBytes?: number;
rolledFileLimit: number;
schedule?: {
type: 'cron';
cron: string;
} | {
type: 'every';
increment: number;
unit: 'second' | 'minute' | 'hour' | 'day' | 'week';
offsetMs: number;
};
};
environmentLogLevel?: 'silent' | 'fatal' | 'error' | 'warn' | 'info' | 'http' | 'debug' | 'http_out' | 'trace';
};
cache?: {
@@ -16268,6 +16282,20 @@ export type PutApiSystemSettingsData = {
logging?: {
logLevel?: 'silent' | 'fatal' | 'error' | 'warn' | 'info' | 'http' | 'debug' | 'http_out' | 'trace';
useEnvVarLevel?: boolean;
logRollConfig?: {
enabled?: boolean;
maxFileSizeBytes?: number;
rolledFileLimit: number;
schedule?: {
type: 'cron';
cron: string;
} | {
type: 'every';
increment: number;
unit: 'second' | 'minute' | 'hour' | 'day' | 'week';
offsetMs?: number;
};
};
};
backup?: {
configurations: Array<{
@@ -16338,6 +16366,20 @@ export type PutApiSystemSettingsResponses = {
logLevel: 'silent' | 'fatal' | 'error' | 'warn' | 'info' | 'http' | 'debug' | 'http_out' | 'trace';
logsDirectory: string;
useEnvVarLevel: boolean;
logRollConfig: {
enabled: boolean;
maxFileSizeBytes?: number;
rolledFileLimit: number;
schedule?: {
type: 'cron';
cron: string;
} | {
type: 'every';
increment: number;
unit: 'second' | 'minute' | 'hour' | 'day' | 'week';
offsetMs: number;
};
};
environmentLogLevel?: 'silent' | 'fatal' | 'error' | 'warn' | 'info' | 'http' | 'debug' | 'http_out' | 'trace';
};
cache?: {

View File

@@ -331,3 +331,20 @@ export function difference<T>(
}
export const noop = () => {};
const k = 1024;
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB'];
export function formatBytes(bytes: number, decimals: number = 2): string {
if (bytes === 0) return '0 B';
const isNegative = bytes < 0;
bytes = Math.abs(bytes);
const i = Math.floor(Math.log(bytes) / Math.log(k));
const value = bytes / Math.pow(k, i);
const formatted = value.toFixed(decimals);
return `${isNegative ? '-' : ''}${formatted} ${units[i]}`;
}

View File

@@ -4,7 +4,7 @@ import type { StateCreator } from 'zustand';
// Only these 2 are supported currently
export type SupportedLocales = 'en' | 'en-gb';
interface SettingsStateInternal {
export interface SettingsStateInternal {
backendUri: string;
ui: {
channelTablePagination: PaginationState;