Implement push-based log view

Various layout fixes
This commit is contained in:
grimsi
2024-09-20 12:23:34 +02:00
parent e28c21a2c9
commit 75feb614e6
23 changed files with 341 additions and 33 deletions
+1
View File
@@ -47,3 +47,4 @@ out/
/.gameyfin/
/src/main/frontend/generated/
/db/
/logs/
+1 -1
View File
@@ -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
View File
@@ -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>
+1 -1
View File
@@ -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 {
+3 -1
View File
@@ -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/>
}
]
+12 -13
View File
@@ -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
}
}
-1
View File
@@ -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>