mirror of
https://github.com/chrisbenincasa/tunarr.git
synced 2026-04-18 09:03:35 -04:00
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:
committed by
GitHub
parent
9e9b00540b
commit
ab3624675c
File diff suppressed because one or more lines are too long
1
docs/generated/tunarr-v1.1.0-dev.3-openapi.json
Normal file
1
docs/generated/tunarr-v1.1.0-dev.3-openapi.json
Normal file
File diff suppressed because one or more lines are too long
13
pnpm-lock.yaml
generated
13
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -62,5 +62,5 @@ export interface ISettingsDB extends TypedEventEmitter<SettingsChangeEvents> {
|
||||
|
||||
export type ReadableFfmpegSettings = DeepReadonly<FfmpegSettings>;
|
||||
export type SettingsChangeEvents = {
|
||||
change(): void;
|
||||
change(prevSettings?: SettingsFile): void;
|
||||
};
|
||||
|
||||
@@ -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': {
|
||||
|
||||
16
server/src/tasks/RollLogFileTask.ts
Normal file
16
server/src/tasks/RollLogFileTask.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
229
server/src/util/logging/RollingDestination.ts
Normal file
229
server/src/util/logging/RollingDestination.ts
Normal 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;
|
||||
}
|
||||
@@ -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 * * *');
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -139,3 +139,5 @@ export const ContentProgramTypeSchema = z.enum([
|
||||
'music_video',
|
||||
'other_video',
|
||||
]);
|
||||
|
||||
export type Schedule = z.infer<typeof ScheduleSchema>;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
202
web/src/components/settings/general/LogRollForm.tsx
Normal file
202
web/src/components/settings/general/LogRollForm.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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]}`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user