mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-13 16:40:01 +00:00
Implement push-based log view
Various layout fixes
This commit is contained in:
@@ -47,3 +47,4 @@ out/
|
||||
/.gameyfin/
|
||||
/src/main/frontend/generated/
|
||||
/db/
|
||||
/logs/
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="UI debug" type="JavascriptDebugType" engineId="37cae5b9-e8b2-4949-9172-aafa37fbc09c" uri="http://localhost:8080">
|
||||
<configuration default="false" name="UI debug" type="JavascriptDebugType" uri="http://localhost:8080">
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
+8
-2
@@ -41,6 +41,11 @@ dependencies {
|
||||
implementation("jakarta.validation:jakarta.validation-api:3.0.2")
|
||||
implementation("org.jetbrains.kotlin:kotlin-reflect")
|
||||
|
||||
// Reactive
|
||||
implementation("org.springframework.boot:spring-boot-starter-webflux")
|
||||
implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
|
||||
|
||||
// Vaadin Hilla
|
||||
implementation("com.vaadin:vaadin-core") {
|
||||
exclude("com.vaadin:flow-react")
|
||||
@@ -50,10 +55,11 @@ dependencies {
|
||||
// Logging
|
||||
implementation("io.github.oshai:kotlin-logging-jvm:6.0.3")
|
||||
|
||||
// Persistence
|
||||
// Persistence & I/O
|
||||
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
|
||||
implementation("com.github.paulcwarren:spring-content-fs-boot-starter:3.0.14")
|
||||
implementation("org.springframework.cloud:spring-cloud-starter")
|
||||
implementation("commons-io:commons-io:2.16.1")
|
||||
|
||||
// SSO
|
||||
implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
|
||||
@@ -62,7 +68,7 @@ dependencies {
|
||||
|
||||
// Notifications
|
||||
implementation("org.springframework.boot:spring-boot-starter-mail")
|
||||
|
||||
|
||||
// Development
|
||||
developmentOnly("org.springframework.boot:spring-boot-devtools")
|
||||
annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import React, {useEffect, useRef, useState} from "react";
|
||||
import {LogEndpoint} from "Frontend/generated/endpoints";
|
||||
import withConfigPage from "Frontend/components/administration/withConfigPage";
|
||||
import * as Yup from 'yup';
|
||||
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
|
||||
import {toast} from "sonner";
|
||||
import {Button, Code, Divider, Tooltip} from "@nextui-org/react";
|
||||
import {ArrowUDownLeft, SortAscending} from "@phosphor-icons/react";
|
||||
|
||||
function LogManagementLayout({getConfig, formik}: any) {
|
||||
|
||||
const [logEntries, setLogEntries] = useState<string[]>([]);
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const [softWrap, setSoftWrap] = useState(false);
|
||||
const subscribed = useRef(false);
|
||||
const logEndRef = useRef<null | HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (subscribed.current) return;
|
||||
LogEndpoint.getApplicationLogs().onNext((newEntry: string | undefined) =>
|
||||
setLogEntries((currentEntries) => [...currentEntries, newEntry as string])
|
||||
);
|
||||
subscribed.current = true;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (formik.isSubmitting == false && formik.submitCount > 0) {
|
||||
LogEndpoint.reloadLogConfig()
|
||||
.catch(() => toast.error("Failed to apply log configuration"));
|
||||
}
|
||||
}, [formik.isSubmitting]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoScroll) {
|
||||
scrollToBottom();
|
||||
}
|
||||
}, [logEntries, autoScroll, softWrap]);
|
||||
|
||||
function scrollToBottom() {
|
||||
logEndRef.current?.scrollIntoView();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-row gap-4">
|
||||
<ConfigFormField configElement={getConfig("logs.folder")}/>
|
||||
<ConfigFormField configElement={getConfig("logs.max-history-days")}/>
|
||||
<ConfigFormField configElement={getConfig("logs.level")}/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-row flex-grow justify-between items-baseline">
|
||||
<h2 className={"text-xl font-bold mt-8 mb-1"}>Application logs</h2>
|
||||
<div className="flex flex-row gap-1">
|
||||
<Tooltip content="Soft-wrap" placement="bottom">
|
||||
<Button isIconOnly
|
||||
onPress={() => setSoftWrap(!softWrap)}
|
||||
variant={softWrap ? "solid" : "ghost"}
|
||||
>
|
||||
<ArrowUDownLeft/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Auto-scroll" placement="bottom">
|
||||
<Button isIconOnly
|
||||
onPress={() => setAutoScroll(!autoScroll)}
|
||||
variant={autoScroll ? "solid" : "ghost"}
|
||||
>
|
||||
<SortAscending/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<Divider className="mb-4"/>
|
||||
</div>
|
||||
<Code size="sm" radius="none"
|
||||
className={`flex flex-col h-[50vh] max-h-[50vh] text-sm overflow-auto ${softWrap ? "whitespace-normal" : "whitespace-nowrap"}`}>
|
||||
{logEntries.map((entry, index) => <p key={index}>{entry}</p>)}
|
||||
<div ref={logEndRef}/>
|
||||
</Code>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
logs: Yup.object({
|
||||
folder: Yup.string().required("Required"),
|
||||
"max-history-days": Yup.number().required("Required"),
|
||||
level: Yup.string().required("Required")
|
||||
})
|
||||
});
|
||||
|
||||
export const LogManagement = withConfigPage(LogManagementLayout, "Logging", "logs", validationSchema);
|
||||
@@ -16,11 +16,13 @@ export default function withConfigPage(WrappedComponent: React.ComponentType<any
|
||||
const isInitialized = useRef(false);
|
||||
const [configSaved, setConfigSaved] = useState(false);
|
||||
const [configDtos, setConfigDtos] = useState<ConfigEntryDto[]>([]);
|
||||
const [nestedConfigDtos, setNestedConfigDtos] = useState<NestedConfig>({});
|
||||
const [saveMessage, setSaveMessage] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
ConfigEndpoint.getAll(configPrefix).then((response: any) => {
|
||||
setConfigDtos(response as ConfigEntryDto[]);
|
||||
setNestedConfigDtos(toNestedConfig(response as ConfigEntryDto[]));
|
||||
isInitialized.current = true;
|
||||
});
|
||||
}, []);
|
||||
@@ -34,6 +36,7 @@ export default function withConfigPage(WrappedComponent: React.ComponentType<any
|
||||
async function handleSubmit(values: NestedConfig) {
|
||||
const configValues = toConfigValuePair(values);
|
||||
await ConfigEndpoint.setAll(configValues);
|
||||
setNestedConfigDtos(values);
|
||||
setConfigSaved(true);
|
||||
}
|
||||
|
||||
@@ -114,11 +117,12 @@ export default function withConfigPage(WrappedComponent: React.ComponentType<any
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={toNestedConfig(configDtos)}
|
||||
initialValues={nestedConfigDtos}
|
||||
onSubmit={handleSubmit}
|
||||
validationSchema={validationSchema}
|
||||
enableReinitialize={true}
|
||||
>
|
||||
{(formik: { values: any; isSubmitting: any; }) => (
|
||||
{(formik) => (
|
||||
<Form>
|
||||
<div className="flex flex-row flex-grow justify-between mb-8">
|
||||
<h1 className="text-2xl font-bold">{title}</h1>
|
||||
@@ -131,7 +135,7 @@ export default function withConfigPage(WrappedComponent: React.ComponentType<any
|
||||
<Button
|
||||
color="primary"
|
||||
isLoading={formik.isSubmitting}
|
||||
disabled={formik.isSubmitting || configSaved}
|
||||
disabled={formik.isSubmitting || configSaved || !formik.dirty}
|
||||
type="submit"
|
||||
>
|
||||
{formik.isSubmitting ? "" : configSaved ? <Check/> : "Save"}
|
||||
|
||||
@@ -7,7 +7,7 @@ const CheckboxInput = ({label, ...props}) => {
|
||||
const [field] = useField(props);
|
||||
|
||||
return (
|
||||
<div className="flex flex-row flex-grow items-center gap-2 my-2">
|
||||
<div className="flex flex-row flex-1 items-center gap-2 my-2">
|
||||
<Checkbox
|
||||
{...field}
|
||||
{...props}
|
||||
|
||||
@@ -9,7 +9,7 @@ const Input = ({label, ...props}) => {
|
||||
const [field, meta] = useField(props);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full items-start gap-2 my-2">
|
||||
<div className="flex flex-col flex-1 items-start gap-2 my-2">
|
||||
<NextUiInput
|
||||
{...props}
|
||||
{...field}
|
||||
|
||||
@@ -7,17 +7,18 @@ const SelectInput = ({label, values, ...props}) => {
|
||||
const [field] = useField(props);
|
||||
|
||||
return (
|
||||
<div className="flex flex-row flex-1 items-center gap-2 my-2">
|
||||
<div className="flex flex-row flex-1 justify-center gap-2 my-2">
|
||||
<Select
|
||||
{...field}
|
||||
{...props}
|
||||
id={field.name}
|
||||
label={label}
|
||||
defaultSelectedKeys={[field.value]}
|
||||
disallowEmptySelection
|
||||
>
|
||||
{values.map((value: string) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{value.toLowerCase()}
|
||||
{value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
@@ -51,7 +51,7 @@ export default function withSideMenu(menuItems: MenuItem[]) {
|
||||
))}
|
||||
</Listbox>
|
||||
</div>
|
||||
<div className="flex flex-col flex-grow">
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Outlet/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Gameyfin</title>
|
||||
<style>
|
||||
body {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {SsoManagement} from "Frontend/components/administration/SsoManagement";
|
||||
import {AdministrationView} from "Frontend/views/AdministrationView";
|
||||
import {ProfileView} from "Frontend/views/ProfileView";
|
||||
import {NotificationManagement} from "Frontend/components/administration/NotificationManagement";
|
||||
import {LogManagement} from "Frontend/components/administration/LogManagement";
|
||||
|
||||
export const routes = protectRoutes([
|
||||
{
|
||||
@@ -41,7 +42,8 @@ export const routes = protectRoutes([
|
||||
{path: 'libraries', element: <LibraryManagement/>},
|
||||
{path: 'users', element: <UserManagement/>},
|
||||
{path: 'sso', element: <SsoManagement/>},
|
||||
{path: 'notifications', element: <NotificationManagement/>}
|
||||
{path: 'notifications', element: <NotificationManagement/>},
|
||||
{path: 'logs', element: <LogManagement/>}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {Envelope, GameController, LockKey, Users} from "@phosphor-icons/react";
|
||||
import {Envelope, GameController, LockKey, Log, Users} from "@phosphor-icons/react";
|
||||
import withSideMenu, {MenuItem} from "Frontend/components/general/withSideMenu";
|
||||
|
||||
const menuItems: MenuItem[] = [
|
||||
@@ -21,6 +21,11 @@ const menuItems: MenuItem[] = [
|
||||
title: "Notifications",
|
||||
url: "notifications",
|
||||
icon: <Envelope/>
|
||||
},
|
||||
{
|
||||
title: "Logs",
|
||||
url: "logs",
|
||||
icon: <Log/>
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -15,25 +15,24 @@ export default function MainLayout() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-svh">
|
||||
<Navbar maxWidth="2xl" className="shadow">
|
||||
<NavbarBrand as="button" onClick={() => navigate('/')}>
|
||||
<GameyfinLogo className="h-10 fill-foreground"/>
|
||||
</NavbarBrand>
|
||||
<NavbarContent justify="end">
|
||||
<NavbarItem>
|
||||
<ProfileMenu/>
|
||||
</NavbarItem>
|
||||
</NavbarContent>
|
||||
</Navbar>
|
||||
<div className="flex flex-col flex-grow w-full 2xl:w-3/4 m-auto">
|
||||
<Navbar maxWidth="full">
|
||||
<NavbarBrand as="button" onClick={() => navigate('/')}>
|
||||
<GameyfinLogo className="h-10 fill-foreground"/>
|
||||
</NavbarBrand>
|
||||
<NavbarContent justify="end">
|
||||
<NavbarItem>
|
||||
<ProfileMenu/>
|
||||
</NavbarItem>
|
||||
</NavbarContent>
|
||||
</Navbar>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex-row relative m-auto max-w-[1536px] align-self-center px-2 pt-4">
|
||||
<div className="w-full overflow-hidden ml-2 pr-8 mt-4">
|
||||
<Outlet/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider/>
|
||||
|
||||
<footer className="flex flex-row items-center justify-between py-4 px-12">
|
||||
<p>Gameyfin {PackageJson.version}</p>
|
||||
<p>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.grimsi.gameyfin.config
|
||||
|
||||
import org.springframework.boot.logging.LogLevel
|
||||
import java.io.Serializable
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
@@ -115,7 +116,7 @@ sealed class ConfigProperties<T : Serializable>(
|
||||
MatchUsersBy::class,
|
||||
"sso.oidc.match-existing-users-by",
|
||||
"Match existing users by",
|
||||
MatchUsersBy.USERNAME,
|
||||
MatchUsersBy.username,
|
||||
MatchUsersBy.entries
|
||||
)
|
||||
|
||||
@@ -158,8 +159,31 @@ sealed class ConfigProperties<T : Serializable>(
|
||||
"notifications.email.password",
|
||||
"Password for the email account"
|
||||
)
|
||||
|
||||
/** Logs */
|
||||
data object LogsFolder : ConfigProperties<String>(
|
||||
String::class,
|
||||
"logs.folder",
|
||||
"Storage folder for log files",
|
||||
"./logs"
|
||||
)
|
||||
|
||||
data object LogsMaxHistoryDays : ConfigProperties<Int>(
|
||||
Int::class,
|
||||
"logs.max-history-days",
|
||||
"Log retention in days",
|
||||
30
|
||||
)
|
||||
|
||||
data object LogsLevel : ConfigProperties<LogLevel>(
|
||||
LogLevel::class,
|
||||
"logs.level",
|
||||
"Log level",
|
||||
LogLevel.INFO,
|
||||
LogLevel.entries
|
||||
)
|
||||
}
|
||||
|
||||
enum class MatchUsersBy {
|
||||
USERNAME, EMAIL
|
||||
username, email
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.grimsi.gameyfin.core
|
||||
|
||||
import de.grimsi.gameyfin.setup.SetupService
|
||||
import de.grimsi.gameyfin.users.UserService
|
||||
import de.grimsi.gameyfin.users.entities.Role
|
||||
import de.grimsi.gameyfin.users.entities.User
|
||||
@@ -18,12 +19,15 @@ import java.net.InetAddress
|
||||
class SetupDataLoader(
|
||||
private val roleRepository: RoleRepository,
|
||||
private val userService: UserService,
|
||||
private val setupService: SetupService,
|
||||
private val env: Environment
|
||||
) {
|
||||
private val log = KotlinLogging.logger {}
|
||||
|
||||
@EventListener(ApplicationReadyEvent::class)
|
||||
fun initialSetup() {
|
||||
if (setupService.isSetupCompleted()) return
|
||||
|
||||
log.info { "Looks like this is the first time you're starting Gameyfin." }
|
||||
log.info { "We will now set up some data..." }
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ class SecurityConfig(
|
||||
// Prevent unnecessary redirects
|
||||
http.logout { logout -> logout.logoutSuccessHandler((HttpStatusReturningLogoutSuccessHandler(HttpStatus.OK))) }
|
||||
} else {
|
||||
setLoginView(http, "/login")
|
||||
setLoginView(http, "/login", "/")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,8 +34,8 @@ class SsoAuthenticationSuccessHandler(
|
||||
// This is meant to map existing users to SSO users
|
||||
if (matchedUser == null) {
|
||||
matchedUser = when (config.get(ConfigProperties.SsoMatchExistingUsersBy)) {
|
||||
MatchUsersBy.USERNAME -> userService.getByUsername(oidcUser.preferredUsername)
|
||||
MatchUsersBy.EMAIL -> userService.getByEmail(oidcUser.email)
|
||||
MatchUsersBy.username -> userService.getByUsername(oidcUser.preferredUsername)
|
||||
MatchUsersBy.email -> userService.getByEmail(oidcUser.email)
|
||||
else -> throw IllegalStateException("Unknown 'match users by' configuration")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package de.grimsi.gameyfin.logs
|
||||
|
||||
import com.vaadin.flow.server.auth.AnonymousAllowed
|
||||
import com.vaadin.hilla.Endpoint
|
||||
import de.grimsi.gameyfin.core.Roles
|
||||
import jakarta.annotation.security.RolesAllowed
|
||||
import reactor.core.publisher.Flux
|
||||
|
||||
@Endpoint
|
||||
@RolesAllowed(Roles.Names.SUPERADMIN, Roles.Names.ADMIN)
|
||||
class LogEndpoint(
|
||||
private val logService: LogService
|
||||
) {
|
||||
|
||||
fun reloadLogConfig() {
|
||||
logService.configureFileLogging()
|
||||
}
|
||||
|
||||
// FIXME: see https://vaadin.com/forum/t/can-only-access-flux-endpoint-with-anonymousallowed/167117
|
||||
@AnonymousAllowed
|
||||
fun getApplicationLogs(): Flux<String> {
|
||||
return logService.getInitialLogs()
|
||||
.concatWith(logService.streamLogs())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package de.grimsi.gameyfin.logs
|
||||
|
||||
import ch.qos.logback.classic.LoggerContext
|
||||
import ch.qos.logback.classic.joran.JoranConfigurator
|
||||
import de.grimsi.gameyfin.config.ConfigProperties
|
||||
import de.grimsi.gameyfin.config.ConfigService
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.apache.commons.io.input.Tailer
|
||||
import org.apache.commons.io.input.TailerListenerAdapter
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.boot.context.event.ApplicationStartedEvent
|
||||
import org.springframework.boot.logging.LogLevel
|
||||
import org.springframework.context.event.EventListener
|
||||
import org.springframework.stereotype.Service
|
||||
import reactor.core.publisher.Flux
|
||||
import reactor.core.publisher.Sinks
|
||||
import java.io.InputStream
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
import java.time.Duration
|
||||
|
||||
@Service
|
||||
class LogService(
|
||||
private val config: ConfigService
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private const val LOG_CONFIG_TEMPLATE = "log-config-template.xml"
|
||||
private const val LOG_FILE_NAME = "gameyfin"
|
||||
private val LOG_REFRESH_INTERVAL = Duration.ofSeconds(5)
|
||||
}
|
||||
|
||||
private val log = KotlinLogging.logger {}
|
||||
|
||||
private var logFilePath: Path = Paths.get(config.get(ConfigProperties.LogsFolder)!!, "$LOG_FILE_NAME.log")
|
||||
|
||||
private val sink: Sinks.Many<String> = Sinks.many().multicast().onBackpressureBuffer()
|
||||
|
||||
@EventListener(ApplicationStartedEvent::class)
|
||||
fun configureFileLogging() {
|
||||
return configureFileLogging(
|
||||
config.get(ConfigProperties.LogsFolder)!!,
|
||||
config.get(ConfigProperties.LogsMaxHistoryDays)!!,
|
||||
config.get(ConfigProperties.LogsLevel)!!
|
||||
)
|
||||
}
|
||||
|
||||
init {
|
||||
val tailer = Tailer.builder()
|
||||
.setFile(logFilePath.toFile())
|
||||
.setTailerListener(object : TailerListenerAdapter() {
|
||||
override fun handle(line: String) {
|
||||
sink.tryEmitNext(line)
|
||||
}
|
||||
})
|
||||
.setDelayDuration(LOG_REFRESH_INTERVAL)
|
||||
.setTailFromEnd(true)
|
||||
.get()
|
||||
|
||||
Thread(tailer).start()
|
||||
}
|
||||
|
||||
fun configureFileLogging(folder: String, maxHistoryDays: Int, level: LogLevel) {
|
||||
val context = LoggerFactory.getILoggerFactory() as LoggerContext
|
||||
val configurator = JoranConfigurator()
|
||||
configurator.context = context
|
||||
context.reset()
|
||||
|
||||
generateLogConfigXml(folder.removeSuffix("/"), maxHistoryDays, level).use {
|
||||
log.info { "Setting log level to $level" }
|
||||
log.info { "Setting log retention to $maxHistoryDays days" }
|
||||
configurator.doConfigure(it)
|
||||
logFilePath = Paths.get(config.get(ConfigProperties.LogsFolder)!!, "$LOG_FILE_NAME.log")
|
||||
}
|
||||
}
|
||||
|
||||
fun streamLogs(): Flux<String> {
|
||||
return sink.asFlux()
|
||||
}
|
||||
|
||||
fun getInitialLogs(): Flux<String> {
|
||||
return Flux.fromStream(Files.lines(logFilePath))
|
||||
}
|
||||
|
||||
private fun generateLogConfigXml(
|
||||
folder: String,
|
||||
maxHistoryDays: Int,
|
||||
level: LogLevel
|
||||
): InputStream {
|
||||
val template = javaClass.classLoader.getResourceAsStream(LOG_CONFIG_TEMPLATE)
|
||||
|
||||
if (template == null) {
|
||||
throw IllegalStateException("Log config template not found")
|
||||
}
|
||||
|
||||
val templateString = template.bufferedReader().use { it.readText() }
|
||||
return templateString
|
||||
.replace("{LOG_FOLDER}", folder)
|
||||
.replace("{LOG_FILE_NAME}", LOG_FILE_NAME)
|
||||
.replace("{LOG_MAX_HISTORY_DAYS}", maxHistoryDays.toString())
|
||||
.replace("{LOG_LEVEL}", level.toString())
|
||||
.byteInputStream()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package de.grimsi.gameyfin.logs.dto
|
||||
|
||||
import org.springframework.boot.logging.LogLevel
|
||||
|
||||
data class LogConfigDto(
|
||||
val logFolder: String,
|
||||
val maxHistoryDays: Int,
|
||||
val logLevel: LogLevel
|
||||
)
|
||||
@@ -7,7 +7,18 @@ import org.springframework.stereotype.Service
|
||||
class SystemService(
|
||||
private val restartEndpoint: RestartEndpoint,
|
||||
) {
|
||||
|
||||
private var restartRequired = false;
|
||||
|
||||
fun restart() {
|
||||
restartEndpoint.restart()
|
||||
}
|
||||
|
||||
fun setRestartRequired() {
|
||||
restartRequired = true
|
||||
}
|
||||
|
||||
fun isRestartRequired(): Boolean {
|
||||
return restartRequired
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ server:
|
||||
tracking-modes: cookie
|
||||
|
||||
management:
|
||||
endpoints.web.exposure.include: '*'
|
||||
endpoint:
|
||||
pause:
|
||||
enabled: false
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<configuration>
|
||||
<include resource="org/springframework/boot/logging/logback/defaults.xml"/>
|
||||
<include resource="org/springframework/boot/logging/logback/console-appender.xml"/>
|
||||
|
||||
<!-- Custom File Appender -->
|
||||
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<file>{LOG_FOLDER}/{LOG_FILE_NAME}.log</file>
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
|
||||
<fileNamePattern>{LOG_FOLDER}/{LOG_FILE_NAME}.%d{yyyy-MM-dd}.log</fileNamePattern>
|
||||
<maxHistory>{LOG_MAX_HISTORY_DAYS}</maxHistory>
|
||||
</rollingPolicy>
|
||||
<encoder>
|
||||
<pattern>%d{yyy-MM-dd'T'HH:mm:ss.SSS} %-5level %-40.40logger{39} : %m%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
<root level="{LOG_LEVEL}">
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
<appender-ref ref="FILE"/>
|
||||
</root>
|
||||
</configuration>
|
||||
Reference in New Issue
Block a user