mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-16 00:30:02 +00:00
Implement push-based log view
Various layout fixes
This commit is contained in:
@@ -47,3 +47,4 @@ out/
|
|||||||
/.gameyfin/
|
/.gameyfin/
|
||||||
/src/main/frontend/generated/
|
/src/main/frontend/generated/
|
||||||
/db/
|
/db/
|
||||||
|
/logs/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<component name="ProjectRunConfigurationManager">
|
<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" />
|
<method v="2" />
|
||||||
</configuration>
|
</configuration>
|
||||||
</component>
|
</component>
|
||||||
+8
-2
@@ -41,6 +41,11 @@ dependencies {
|
|||||||
implementation("jakarta.validation:jakarta.validation-api:3.0.2")
|
implementation("jakarta.validation:jakarta.validation-api:3.0.2")
|
||||||
implementation("org.jetbrains.kotlin:kotlin-reflect")
|
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
|
// Vaadin Hilla
|
||||||
implementation("com.vaadin:vaadin-core") {
|
implementation("com.vaadin:vaadin-core") {
|
||||||
exclude("com.vaadin:flow-react")
|
exclude("com.vaadin:flow-react")
|
||||||
@@ -50,10 +55,11 @@ dependencies {
|
|||||||
// Logging
|
// Logging
|
||||||
implementation("io.github.oshai:kotlin-logging-jvm:6.0.3")
|
implementation("io.github.oshai:kotlin-logging-jvm:6.0.3")
|
||||||
|
|
||||||
// Persistence
|
// Persistence & I/O
|
||||||
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
|
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
|
||||||
implementation("com.github.paulcwarren:spring-content-fs-boot-starter:3.0.14")
|
implementation("com.github.paulcwarren:spring-content-fs-boot-starter:3.0.14")
|
||||||
implementation("org.springframework.cloud:spring-cloud-starter")
|
implementation("org.springframework.cloud:spring-cloud-starter")
|
||||||
|
implementation("commons-io:commons-io:2.16.1")
|
||||||
|
|
||||||
// SSO
|
// SSO
|
||||||
implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
|
implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
|
||||||
@@ -62,7 +68,7 @@ dependencies {
|
|||||||
|
|
||||||
// Notifications
|
// Notifications
|
||||||
implementation("org.springframework.boot:spring-boot-starter-mail")
|
implementation("org.springframework.boot:spring-boot-starter-mail")
|
||||||
|
|
||||||
// Development
|
// Development
|
||||||
developmentOnly("org.springframework.boot:spring-boot-devtools")
|
developmentOnly("org.springframework.boot:spring-boot-devtools")
|
||||||
annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
|
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 isInitialized = useRef(false);
|
||||||
const [configSaved, setConfigSaved] = useState(false);
|
const [configSaved, setConfigSaved] = useState(false);
|
||||||
const [configDtos, setConfigDtos] = useState<ConfigEntryDto[]>([]);
|
const [configDtos, setConfigDtos] = useState<ConfigEntryDto[]>([]);
|
||||||
|
const [nestedConfigDtos, setNestedConfigDtos] = useState<NestedConfig>({});
|
||||||
const [saveMessage, setSaveMessage] = useState<string>();
|
const [saveMessage, setSaveMessage] = useState<string>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
ConfigEndpoint.getAll(configPrefix).then((response: any) => {
|
ConfigEndpoint.getAll(configPrefix).then((response: any) => {
|
||||||
setConfigDtos(response as ConfigEntryDto[]);
|
setConfigDtos(response as ConfigEntryDto[]);
|
||||||
|
setNestedConfigDtos(toNestedConfig(response as ConfigEntryDto[]));
|
||||||
isInitialized.current = true;
|
isInitialized.current = true;
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
@@ -34,6 +36,7 @@ export default function withConfigPage(WrappedComponent: React.ComponentType<any
|
|||||||
async function handleSubmit(values: NestedConfig) {
|
async function handleSubmit(values: NestedConfig) {
|
||||||
const configValues = toConfigValuePair(values);
|
const configValues = toConfigValuePair(values);
|
||||||
await ConfigEndpoint.setAll(configValues);
|
await ConfigEndpoint.setAll(configValues);
|
||||||
|
setNestedConfigDtos(values);
|
||||||
setConfigSaved(true);
|
setConfigSaved(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,11 +117,12 @@ export default function withConfigPage(WrappedComponent: React.ComponentType<any
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={toNestedConfig(configDtos)}
|
initialValues={nestedConfigDtos}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
validationSchema={validationSchema}
|
validationSchema={validationSchema}
|
||||||
|
enableReinitialize={true}
|
||||||
>
|
>
|
||||||
{(formik: { values: any; isSubmitting: any; }) => (
|
{(formik) => (
|
||||||
<Form>
|
<Form>
|
||||||
<div className="flex flex-row flex-grow justify-between mb-8">
|
<div className="flex flex-row flex-grow justify-between mb-8">
|
||||||
<h1 className="text-2xl font-bold">{title}</h1>
|
<h1 className="text-2xl font-bold">{title}</h1>
|
||||||
@@ -131,7 +135,7 @@ export default function withConfigPage(WrappedComponent: React.ComponentType<any
|
|||||||
<Button
|
<Button
|
||||||
color="primary"
|
color="primary"
|
||||||
isLoading={formik.isSubmitting}
|
isLoading={formik.isSubmitting}
|
||||||
disabled={formik.isSubmitting || configSaved}
|
disabled={formik.isSubmitting || configSaved || !formik.dirty}
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
{formik.isSubmitting ? "" : configSaved ? <Check/> : "Save"}
|
{formik.isSubmitting ? "" : configSaved ? <Check/> : "Save"}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const CheckboxInput = ({label, ...props}) => {
|
|||||||
const [field] = useField(props);
|
const [field] = useField(props);
|
||||||
|
|
||||||
return (
|
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
|
<Checkbox
|
||||||
{...field}
|
{...field}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const Input = ({label, ...props}) => {
|
|||||||
const [field, meta] = useField(props);
|
const [field, meta] = useField(props);
|
||||||
|
|
||||||
return (
|
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
|
<NextUiInput
|
||||||
{...props}
|
{...props}
|
||||||
{...field}
|
{...field}
|
||||||
|
|||||||
@@ -7,17 +7,18 @@ const SelectInput = ({label, values, ...props}) => {
|
|||||||
const [field] = useField(props);
|
const [field] = useField(props);
|
||||||
|
|
||||||
return (
|
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
|
<Select
|
||||||
{...field}
|
{...field}
|
||||||
{...props}
|
{...props}
|
||||||
id={field.name}
|
id={field.name}
|
||||||
label={label}
|
label={label}
|
||||||
defaultSelectedKeys={[field.value]}
|
defaultSelectedKeys={[field.value]}
|
||||||
|
disallowEmptySelection
|
||||||
>
|
>
|
||||||
{values.map((value: string) => (
|
{values.map((value: string) => (
|
||||||
<SelectItem key={value} value={value}>
|
<SelectItem key={value} value={value}>
|
||||||
{value.toLowerCase()}
|
{value}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export default function withSideMenu(menuItems: MenuItem[]) {
|
|||||||
))}
|
))}
|
||||||
</Listbox>
|
</Listbox>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col flex-grow">
|
<div className="flex-1 overflow-auto">
|
||||||
<Outlet/>
|
<Outlet/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8"/>
|
<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>
|
<title>Gameyfin</title>
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {SsoManagement} from "Frontend/components/administration/SsoManagement";
|
|||||||
import {AdministrationView} from "Frontend/views/AdministrationView";
|
import {AdministrationView} from "Frontend/views/AdministrationView";
|
||||||
import {ProfileView} from "Frontend/views/ProfileView";
|
import {ProfileView} from "Frontend/views/ProfileView";
|
||||||
import {NotificationManagement} from "Frontend/components/administration/NotificationManagement";
|
import {NotificationManagement} from "Frontend/components/administration/NotificationManagement";
|
||||||
|
import {LogManagement} from "Frontend/components/administration/LogManagement";
|
||||||
|
|
||||||
export const routes = protectRoutes([
|
export const routes = protectRoutes([
|
||||||
{
|
{
|
||||||
@@ -41,7 +42,8 @@ export const routes = protectRoutes([
|
|||||||
{path: 'libraries', element: <LibraryManagement/>},
|
{path: 'libraries', element: <LibraryManagement/>},
|
||||||
{path: 'users', element: <UserManagement/>},
|
{path: 'users', element: <UserManagement/>},
|
||||||
{path: 'sso', element: <SsoManagement/>},
|
{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";
|
import withSideMenu, {MenuItem} from "Frontend/components/general/withSideMenu";
|
||||||
|
|
||||||
const menuItems: MenuItem[] = [
|
const menuItems: MenuItem[] = [
|
||||||
@@ -21,6 +21,11 @@ const menuItems: MenuItem[] = [
|
|||||||
title: "Notifications",
|
title: "Notifications",
|
||||||
url: "notifications",
|
url: "notifications",
|
||||||
icon: <Envelope/>
|
icon: <Envelope/>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Logs",
|
||||||
|
url: "logs",
|
||||||
|
icon: <Log/>
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -15,25 +15,24 @@ export default function MainLayout() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-svh">
|
<div className="flex flex-col min-h-svh">
|
||||||
<Navbar maxWidth="2xl" className="shadow">
|
<div className="flex flex-col flex-grow w-full 2xl:w-3/4 m-auto">
|
||||||
<NavbarBrand as="button" onClick={() => navigate('/')}>
|
<Navbar maxWidth="full">
|
||||||
<GameyfinLogo className="h-10 fill-foreground"/>
|
<NavbarBrand as="button" onClick={() => navigate('/')}>
|
||||||
</NavbarBrand>
|
<GameyfinLogo className="h-10 fill-foreground"/>
|
||||||
<NavbarContent justify="end">
|
</NavbarBrand>
|
||||||
<NavbarItem>
|
<NavbarContent justify="end">
|
||||||
<ProfileMenu/>
|
<NavbarItem>
|
||||||
</NavbarItem>
|
<ProfileMenu/>
|
||||||
</NavbarContent>
|
</NavbarItem>
|
||||||
</Navbar>
|
</NavbarContent>
|
||||||
|
</Navbar>
|
||||||
|
|
||||||
<div className="flex-1">
|
<div className="w-full overflow-hidden ml-2 pr-8 mt-4">
|
||||||
<div className="flex-row relative m-auto max-w-[1536px] align-self-center px-2 pt-4">
|
|
||||||
<Outlet/>
|
<Outlet/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Divider/>
|
<Divider/>
|
||||||
|
|
||||||
<footer className="flex flex-row items-center justify-between py-4 px-12">
|
<footer className="flex flex-row items-center justify-between py-4 px-12">
|
||||||
<p>Gameyfin {PackageJson.version}</p>
|
<p>Gameyfin {PackageJson.version}</p>
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.grimsi.gameyfin.config
|
package de.grimsi.gameyfin.config
|
||||||
|
|
||||||
|
import org.springframework.boot.logging.LogLevel
|
||||||
import java.io.Serializable
|
import java.io.Serializable
|
||||||
import kotlin.reflect.KClass
|
import kotlin.reflect.KClass
|
||||||
|
|
||||||
@@ -115,7 +116,7 @@ sealed class ConfigProperties<T : Serializable>(
|
|||||||
MatchUsersBy::class,
|
MatchUsersBy::class,
|
||||||
"sso.oidc.match-existing-users-by",
|
"sso.oidc.match-existing-users-by",
|
||||||
"Match existing users by",
|
"Match existing users by",
|
||||||
MatchUsersBy.USERNAME,
|
MatchUsersBy.username,
|
||||||
MatchUsersBy.entries
|
MatchUsersBy.entries
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -158,8 +159,31 @@ sealed class ConfigProperties<T : Serializable>(
|
|||||||
"notifications.email.password",
|
"notifications.email.password",
|
||||||
"Password for the email account"
|
"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 {
|
enum class MatchUsersBy {
|
||||||
USERNAME, EMAIL
|
username, email
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.grimsi.gameyfin.core
|
package de.grimsi.gameyfin.core
|
||||||
|
|
||||||
|
import de.grimsi.gameyfin.setup.SetupService
|
||||||
import de.grimsi.gameyfin.users.UserService
|
import de.grimsi.gameyfin.users.UserService
|
||||||
import de.grimsi.gameyfin.users.entities.Role
|
import de.grimsi.gameyfin.users.entities.Role
|
||||||
import de.grimsi.gameyfin.users.entities.User
|
import de.grimsi.gameyfin.users.entities.User
|
||||||
@@ -18,12 +19,15 @@ import java.net.InetAddress
|
|||||||
class SetupDataLoader(
|
class SetupDataLoader(
|
||||||
private val roleRepository: RoleRepository,
|
private val roleRepository: RoleRepository,
|
||||||
private val userService: UserService,
|
private val userService: UserService,
|
||||||
|
private val setupService: SetupService,
|
||||||
private val env: Environment
|
private val env: Environment
|
||||||
) {
|
) {
|
||||||
private val log = KotlinLogging.logger {}
|
private val log = KotlinLogging.logger {}
|
||||||
|
|
||||||
@EventListener(ApplicationReadyEvent::class)
|
@EventListener(ApplicationReadyEvent::class)
|
||||||
fun initialSetup() {
|
fun initialSetup() {
|
||||||
|
if (setupService.isSetupCompleted()) return
|
||||||
|
|
||||||
log.info { "Looks like this is the first time you're starting Gameyfin." }
|
log.info { "Looks like this is the first time you're starting Gameyfin." }
|
||||||
log.info { "We will now set up some data..." }
|
log.info { "We will now set up some data..." }
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ class SecurityConfig(
|
|||||||
// Prevent unnecessary redirects
|
// Prevent unnecessary redirects
|
||||||
http.logout { logout -> logout.logoutSuccessHandler((HttpStatusReturningLogoutSuccessHandler(HttpStatus.OK))) }
|
http.logout { logout -> logout.logoutSuccessHandler((HttpStatusReturningLogoutSuccessHandler(HttpStatus.OK))) }
|
||||||
} else {
|
} else {
|
||||||
setLoginView(http, "/login")
|
setLoginView(http, "/login", "/")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ class SsoAuthenticationSuccessHandler(
|
|||||||
// This is meant to map existing users to SSO users
|
// This is meant to map existing users to SSO users
|
||||||
if (matchedUser == null) {
|
if (matchedUser == null) {
|
||||||
matchedUser = when (config.get(ConfigProperties.SsoMatchExistingUsersBy)) {
|
matchedUser = when (config.get(ConfigProperties.SsoMatchExistingUsersBy)) {
|
||||||
MatchUsersBy.USERNAME -> userService.getByUsername(oidcUser.preferredUsername)
|
MatchUsersBy.username -> userService.getByUsername(oidcUser.preferredUsername)
|
||||||
MatchUsersBy.EMAIL -> userService.getByEmail(oidcUser.email)
|
MatchUsersBy.email -> userService.getByEmail(oidcUser.email)
|
||||||
else -> throw IllegalStateException("Unknown 'match users by' configuration")
|
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(
|
class SystemService(
|
||||||
private val restartEndpoint: RestartEndpoint,
|
private val restartEndpoint: RestartEndpoint,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
private var restartRequired = false;
|
||||||
|
|
||||||
fun restart() {
|
fun restart() {
|
||||||
restartEndpoint.restart()
|
restartEndpoint.restart()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setRestartRequired() {
|
||||||
|
restartRequired = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isRestartRequired(): Boolean {
|
||||||
|
return restartRequired
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -8,7 +8,6 @@ server:
|
|||||||
tracking-modes: cookie
|
tracking-modes: cookie
|
||||||
|
|
||||||
management:
|
management:
|
||||||
endpoints.web.exposure.include: '*'
|
|
||||||
endpoint:
|
endpoint:
|
||||||
pause:
|
pause:
|
||||||
enabled: false
|
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