Enable real-time UI starting with config pages

Implement client side state caching and updating via websocket
Simplify config REST endpoints
This commit is contained in:
grimsi
2025-05-17 18:55:17 +02:00
parent 9a467fd1ce
commit 03d635a997
14 changed files with 276 additions and 188 deletions
+31
View File
@@ -53,6 +53,7 @@
"react-router": "7.5.2",
"remark-breaks": "^4.0.0",
"swiper": "^11.2.6",
"valtio": "^2.1.5",
"yup": "^1.6.1"
},
"devDependencies": {
@@ -15816,6 +15817,12 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/proxy-compare": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-3.0.1.tgz",
"integrity": "sha512-V9plBAt3qjMlS1+nC8771KNf6oJ12gExvaxnNzN/9yVRLdTv/lc+oJlnSzrdYDAvBfTStPCoiaCOTmTs0adv7Q==",
"license": "MIT"
},
"node_modules/pump": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz",
@@ -18159,6 +18166,30 @@
"node": ">= 0.10"
}
},
"node_modules/valtio": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/valtio/-/valtio-2.1.5.tgz",
"integrity": "sha512-vsh1Ixu5mT0pJFZm+Jspvhga5GzHUTYv0/+Th203pLfh3/wbHwxhu/Z2OkZDXIgHfjnjBns7SN9HNcbDvPmaGw==",
"license": "MIT",
"dependencies": {
"proxy-compare": "^3.0.1"
},
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"react": ">=18.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"react": {
"optional": true
}
}
},
"node_modules/vfile": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
+4 -2
View File
@@ -48,6 +48,7 @@
"react-router": "7.5.2",
"remark-breaks": "^4.0.0",
"swiper": "^11.2.6",
"valtio": "^2.1.5",
"yup": "^1.6.1"
},
"devDependencies": {
@@ -136,7 +137,8 @@
"swiper": "$swiper",
"react-player": "$react-player",
"react-markdown": "$react-markdown",
"remark-breaks": "$remark-breaks"
"remark-breaks": "$remark-breaks",
"valtio": "$valtio"
},
"vaadin": {
"dependencies": {
@@ -197,6 +199,6 @@
"workbox-core": "7.3.0",
"workbox-precaching": "7.3.0"
},
"hash": "f66bddf01ef8dd09a431102050ddd561b4587fdd43bcbff127b93872febbee92"
"hash": "c57af53043f6c6a9f0b03c75c28c3fde0bbfd828f9ce0e179263959c61ec888d"
}
}
@@ -119,4 +119,4 @@ const validationSchema = Yup.object({
})
});
export const LibraryManagement = withConfigPage(LibraryManagementLayout, "Library Management", "library", validationSchema);
export const LibraryManagement = withConfigPage(LibraryManagementLayout, "Library Management", validationSchema);
@@ -94,4 +94,4 @@ const validationSchema = Yup.object({
})
});
export const LogManagement = withConfigPage(LogManagementLayout, "Logging", "logs", validationSchema);
export const LogManagement = withConfigPage(LogManagementLayout, "Logging", validationSchema);
@@ -9,7 +9,7 @@ import MessageTemplateDto from "Frontend/generated/de/grimsi/gameyfin/messages/t
import SendTestNotificationModal from "Frontend/components/administration/messages/SendTestNotificationModal";
import EditTemplateModal from "Frontend/components/administration/messages/EditTemplateModel";
function MessageManagementLayout({getConfig, getConfigs, formik}: any) {
function MessageManagementLayout({getConfig, formik}: any) {
const editorModal = useDisclosure();
const testNotificationModal = useDisclosure();
@@ -121,4 +121,4 @@ const validationSchema = Yup.object({
})
});
export const SsoManagement = withConfigPage(SsoManagementLayout, "Single Sign-On", "sso", validationSchema);
export const SsoManagement = withConfigPage(SsoManagementLayout, "Single Sign-On", validationSchema);
@@ -26,4 +26,4 @@ function SystemManagementLayout({getConfig, formik, setSaveMessage}: any) {
);
}
export const SystemManagement = withConfigPage(SystemManagementLayout, "System", "system", null);
export const SystemManagement = withConfigPage(SystemManagementLayout, "System");
@@ -2,27 +2,25 @@ import React, {useEffect, useState} from "react";
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
import withConfigPage from "Frontend/components/administration/withConfigPage";
import Section from "Frontend/components/general/Section";
import {ConfigEndpoint, UserEndpoint} from "Frontend/generated/endpoints";
import {UserEndpoint} from "Frontend/generated/endpoints";
import UserInfoDto from "Frontend/generated/de/grimsi/gameyfin/users/dto/UserInfoDto";
import {UserManagementCard} from "Frontend/components/general/cards/UserManagementCard";
import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
import {Info, UserPlus} from "@phosphor-icons/react";
import {Button, Divider, Tooltip, useDisclosure} from "@heroui/react";
import InviteUserModal from "Frontend/components/general/modals/InviteUserModal";
import {useSnapshot} from "valtio/react";
import {configState} from "Frontend/state/ConfigState";
function UserManagementLayout({getConfig, formik}: any) {
const inviteUserModal = useDisclosure();
const [users, setUsers] = useState<UserInfoDto[]>([]);
const [autoRegisterNewUsers, setAutoRegisterNewUsers] = useState(true);
const autoRegisterNewUsers = useSnapshot(configState);
useEffect(() => {
UserEndpoint.getAllUsers().then(
(response) => setUsers(response)
);
ConfigEndpoint.get("sso.oidc.auto-register-new-users").then(
(response) => setAutoRegisterNewUsers(response as boolean)
);
}, []);
return (
@@ -56,4 +54,4 @@ function UserManagementLayout({getConfig, formik}: any) {
);
}
export const UserManagement = withConfigPage(UserManagementLayout, "User Management", "users");
export const UserManagement = withConfigPage(UserManagementLayout, "User Management");
@@ -1,30 +1,22 @@
import React, {useEffect, useRef, useState} from "react";
import React, {useEffect, useState} from "react";
import {ConfigEndpoint} from "Frontend/generated/endpoints";
import ConfigEntryDto from "Frontend/generated/de/grimsi/gameyfin/config/dto/ConfigEntryDto";
import {Form, Formik} from "formik";
import {Button, Skeleton} from "@heroui/react";
import {Check, Info} from "@phosphor-icons/react";
import ConfigValuePairDto from "Frontend/generated/de/grimsi/gameyfin/config/dto/ConfigValuePairDto";
import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
import {configState, initializeConfig, NestedConfig} from "Frontend/state/ConfigState";
import {useSnapshot} from "valtio/react";
type NestedConfig = {
[field: string]: any;
}
export default function withConfigPage(WrappedComponent: React.ComponentType<any>, title: String, configPrefix: string, validationSchema?: any) {
export default function withConfigPage(WrappedComponent: React.ComponentType<any>, title: String, validationSchema?: any) {
return function ConfigPage(props: 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>();
const state = useSnapshot(configState);
useEffect(() => {
ConfigEndpoint.getAll(configPrefix).then((response: any) => {
setConfigDtos(response as ConfigEntryDto[]);
setNestedConfigDtos(toNestedConfig(response as ConfigEntryDto[]));
isInitialized.current = true;
});
initializeConfig();
}, []);
useEffect(() => {
@@ -34,149 +26,111 @@ export default function withConfigPage(WrappedComponent: React.ComponentType<any
}, [configSaved])
async function handleSubmit(values: NestedConfig): Promise<void> {
const configValues = toConfigValuePair(values);
await ConfigEndpoint.setAll(configValues);
setNestedConfigDtos(values);
const changed = getChangedValues(state.configNested, values);
await ConfigEndpoint.update({updates: changed});
setConfigSaved(true);
}
function getConfig(key: string): ConfigEntryDto | undefined {
return configDtos.find((configDto: ConfigEntryDto) => configDto.key === key);
// @ts-ignore
return state.configEntries[key];
}
function getConfigs(prefix: string): ConfigEntryDto[] {
return configDtos.filter((configDto: ConfigEntryDto) => configDto.key?.startsWith(prefix));
}
function toNestedConfig(configArray: ConfigEntryDto[]): NestedConfig {
const nestedConfig: NestedConfig = {};
configArray.forEach(item => {
const keys = item.key!.split('.');
let currentLevel = nestedConfig;
// Traverse the nested structure and create objects as needed
keys.forEach((key, index) => {
if (index === keys.length - 1) {
// Convert value to the appropriate type
let value: any;
switch (item.type) {
case 'Boolean':
value = typeof item.value == 'boolean' ? item.value : item.value === 'true';
break;
case 'Int':
value = typeof item.value == 'number' ? item.value : 0;
break;
case 'Float':
value = typeof item.value == 'number' ? item.value : 0.0;
break;
case 'Array':
if (Array.isArray(item.value)) {
switch (item.elementType) {
case 'Boolean':
value = item.value.map(v => typeof v === 'boolean' ? v : v === 'true');
break;
case 'Int':
case 'Integer':
value = item.value.map(v => typeof v == 'number' ? v : 0);
break;
case 'Float':
value = item.value.map(v => typeof v == 'number' ? v : 0.0);
break;
case 'String':
default:
value = item.value.map(v => v.toString());
break;
}
} else {
value = [];
}
break;
case 'String':
default:
value = item.value;
break;
function getChangedValues(initial: NestedConfig, current: NestedConfig): Record<string, any> {
const flatten = (obj: NestedConfig, parentKey = ''): Record<string, any> => {
let result: Record<string, any> = {};
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
const newKey = parentKey ? `${parentKey}.${key}` : key;
if (typeof obj[key] === 'object' && obj[key] !== null && !Array.isArray(obj[key])) {
Object.assign(result, flatten(obj[key], newKey));
} else {
result[newKey] = obj[key];
}
currentLevel[key] = value;
} else {
if (!currentLevel[key]) {
currentLevel[key] = {};
}
currentLevel = currentLevel[key];
}
});
});
return nestedConfig;
}
function toConfigValuePair(obj: NestedConfig, parentKey: string = ''): ConfigValuePairDto[] {
let result: ConfigValuePairDto[] = [];
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
const newKey = parentKey ? `${parentKey}.${key}` : key;
if (typeof obj[key] === 'object' && !Array.isArray(obj[key])) {
result = result.concat(toConfigValuePair(obj[key], newKey));
} else {
result.push({key: newKey, value: obj[key]});
}
}
return result;
};
const arraysEqual = (a: any[], b: any[]): boolean => {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (Array.isArray(a[i]) && Array.isArray(b[i])) {
if (!arraysEqual(a[i], b[i])) return false;
} else if (a[i] !== b[i]) {
return false;
}
}
return true;
};
const flatInitial = flatten(initial);
const flatCurrent = flatten(current);
const changed: Record<string, any> = {};
for (const key in flatCurrent) {
const valA = flatCurrent[key];
const valB = flatInitial[key];
if (Array.isArray(valA) && Array.isArray(valB)) {
if (!arraysEqual(valA, valB)) {
changed[key] = valA;
}
} else if (valA !== valB) {
changed[key] = valA;
}
}
return result;
}
if (!isInitialized.current) {
return (
[...Array(4)].map((_e, i) =>
<div className="flex flex-col flex-grow gap-8 mb-12" key={i}>
<Skeleton className="h-10 w-full rounded-md"/>
<Skeleton className="h-12 flex w-1/3 rounded-md"/>
<div className="flex flex-row gap-8">
<Skeleton className="h-12 flex w-1/3 rounded-md"/>
<Skeleton className="h-12 flex w-1/3 rounded-md"/>
</div>
</div>
)
)
return changed;
}
return (
<Formik
initialValues={nestedConfigDtos}
onSubmit={handleSubmit}
validationSchema={validationSchema}
enableReinitialize={true}
>
{(formik) => (
<Form>
<div className="flex flex-row flex-grow justify-between">
<h1 className="text-2xl font-bold">{title}</h1>
<>
{state.isLoaded ?
<Formik
initialValues={state.configNested}
onSubmit={handleSubmit}
validationSchema={validationSchema}
enableReinitialize={true}
>
{(formik) => (
<Form>
<div className="flex flex-row flex-grow justify-between">
<h1 className="text-2xl font-bold">{title}</h1>
<div className="flex flex-row items-center gap-4">
{saveMessage && <SmallInfoField icon={Info}
message={saveMessage}
className="text-warning"/>}
<div className="flex flex-row items-center gap-4">
{saveMessage && <SmallInfoField icon={Info}
message={saveMessage}
className="text-warning"/>}
<Button
color="primary"
isLoading={formik.isSubmitting}
isDisabled={formik.isSubmitting || configSaved || !formik.dirty}
type="submit"
>
{formik.isSubmitting ? "" : configSaved ? <Check/> : "Save"}
</Button>
<Button
color="primary"
isLoading={formik.isSubmitting}
isDisabled={formik.isSubmitting || configSaved || !formik.dirty}
type="submit"
>
{formik.isSubmitting ? "" : configSaved ? <Check/> : "Save"}
</Button>
</div>
</div>
<WrappedComponent {...props}
getConfig={getConfig}
formik={formik}
setSaveMessage={setSaveMessage}/>
</Form>
)}
</Formik> :
[...Array(4)].map((_e, i) =>
<div className="flex flex-col flex-grow gap-8 mb-12" key={i}>
<Skeleton className="h-10 w-full rounded-md"/>
<Skeleton className="h-12 flex w-1/3 rounded-md"/>
<div className="flex flex-row gap-8">
<Skeleton className="h-12 flex w-1/3 rounded-md"/>
<Skeleton className="h-12 flex w-1/3 rounded-md"/>
</div>
</div>
<WrappedComponent {...props}
getConfig={getConfig}
getConfigs={getConfigs}
formik={formik}
setSaveMessage={setSaveMessage}/>
</Form>
)}
</Formik>
)
}
</>
);
}
}
@@ -13,7 +13,7 @@ const SelectInput = ({label, values, ...props}) => {
{...props}
id={field.name}
label={label}
defaultSelectedKeys={[field.value]}
selectedKeys={[field.value]}
disallowEmptySelection
>
{values.map((value: string) => (
@@ -0,0 +1,110 @@
import {proxy} from 'valtio';
import ConfigEntryDto from "Frontend/generated/de/grimsi/gameyfin/config/dto/ConfigEntryDto";
import {ConfigEndpoint} from "Frontend/generated/endpoints";
import ConfigUpdateDto from "Frontend/generated/de/grimsi/gameyfin/config/dto/ConfigUpdateDto";
type ConfigState = {
isLoaded: boolean;
configEntries: Record<string, ConfigEntryDto>;
configNested: NestedConfig;
autoRegisterNewUsers: boolean;
};
export const configState = proxy<ConfigState>({
configEntries: {},
isLoaded: false,
get configNested() {
return toNestedConfig(Object.values(this.configEntries));
},
get autoRegisterNewUsers() {
return this.configNested["sso.oidc.auto-register-new-users"] as boolean;
}
});
/** Subscribe to and process state updates from backend **/
export async function initializeConfig() {
if (configState.isLoaded) return;
// Fetch initial configuration data
const initialEntries = await ConfigEndpoint.getAll();
initialEntries.forEach((entry) => {
configState.configEntries[entry.key] = entry;
});
configState.isLoaded = true;
// Subscribe to real-time updates
ConfigEndpoint.subscribe().onNext((updateDto: ConfigUpdateDto) => {
Object.entries(updateDto.updates).forEach(([key, value]) => {
if (configState.configEntries[key]) {
configState.configEntries[key].value = value;
}
});
});
}
/** Computed properties **/
export type NestedConfig = {
[field: string]: any;
}
function toNestedConfig(configArray: ConfigEntryDto[]): NestedConfig {
const nestedConfig: NestedConfig = {};
configArray.forEach(item => {
const keys = item.key!.split('.');
let currentLevel = nestedConfig;
// Traverse the nested structure and create objects as needed
keys.forEach((key, index) => {
if (index === keys.length - 1) {
// Convert value to the appropriate type
let value: any;
switch (item.type) {
case 'Boolean':
value = typeof item.value == 'boolean' ? item.value : item.value === 'true';
break;
case 'Int':
value = typeof item.value == 'number' ? item.value : 0;
break;
case 'Float':
value = typeof item.value == 'number' ? item.value : 0.0;
break;
case 'Array':
if (Array.isArray(item.value)) {
switch (item.elementType) {
case 'Boolean':
value = item.value.map(v => typeof v === 'boolean' ? v : v === 'true');
break;
case 'Int':
case 'Integer':
value = item.value.map(v => typeof v == 'number' ? v : 0);
break;
case 'Float':
value = item.value.map(v => typeof v == 'number' ? v : 0.0);
break;
case 'String':
default:
value = item.value.map(v => v.toString());
break;
}
} else {
value = [];
}
break;
case 'String':
default:
value = item.value;
break;
}
currentLevel[key] = value;
} else {
if (!currentLevel[key]) {
currentLevel[key] = {};
}
currentLevel = currentLevel[key];
}
});
});
return nestedConfig;
}
@@ -1,12 +1,14 @@
package de.grimsi.gameyfin.config
import com.vaadin.flow.server.auth.AnonymousAllowed
import com.vaadin.hilla.Endpoint
import de.grimsi.gameyfin.config.dto.ConfigEntryDto
import de.grimsi.gameyfin.config.dto.ConfigValuePairDto
import de.grimsi.gameyfin.config.dto.ConfigUpdateDto
import de.grimsi.gameyfin.core.Role
import jakarta.annotation.security.PermitAll
import jakarta.annotation.security.RolesAllowed
import java.io.Serializable
import reactor.core.publisher.Flux
import reactor.core.publisher.Sinks
@Endpoint
@RolesAllowed(Role.Names.ADMIN)
@@ -15,29 +17,19 @@ class ConfigEndpoint(
) {
/** CRUD endpoints for admins **/
private val configUpdates = Sinks.many().multicast().onBackpressureBuffer<ConfigUpdateDto>()
fun getAll(prefix: String?): List<ConfigEntryDto> {
return config.getAll(prefix)
fun getAll(): List<ConfigEntryDto> {
return config.getAll(null)
}
fun get(key: String): Serializable? {
return config.get(key)
}
// FIXME
@AnonymousAllowed
fun subscribe(): Flux<ConfigUpdateDto> = configUpdates.asFlux()
fun set(key: String, value: String) {
config.set(key, value)
}
fun setAll(configs: List<ConfigValuePairDto>) {
config.setAll(configs)
}
fun resetConfig(key: String) {
config.deleteConfig(key)
}
fun deleteConfig(key: String) {
config.deleteConfig(key)
fun update(update: ConfigUpdateDto) {
config.update(update.updates)
configUpdates.tryEmitNext(update)
}
/** Specific read-only endpoint for all users **/
@@ -1,7 +1,6 @@
package de.grimsi.gameyfin.config
import de.grimsi.gameyfin.config.dto.ConfigEntryDto
import de.grimsi.gameyfin.config.dto.ConfigValuePairDto
import de.grimsi.gameyfin.config.entities.ConfigEntry
import de.grimsi.gameyfin.config.persistence.ConfigRepository
import io.github.oshai.kotlinlogging.KotlinLogging
@@ -116,11 +115,15 @@ class ConfigService(
* Set multiple config values at once.
* Configs with a null value will be deleted.
*
* @param configs: A map of key-value pairs to set
* @param updates: A map of key-value pairs to set
*/
fun setAll(configs: List<ConfigValuePairDto>) {
configs.forEach {
it.value?.let { value -> set(it.key, value) } ?: deleteConfig(it.key)
fun update(updates: Map<String, Serializable?>) {
updates.forEach { (key, value) ->
if (value == null) {
delete(key)
} else {
set(key, value)
}
}
}
@@ -141,7 +144,7 @@ class ConfigService(
*
* @param key: Key of the config property
*/
fun deleteConfig(key: String) {
fun delete(key: String) {
log.debug { "Delete config value '$key'" }
@@ -4,9 +4,7 @@ import com.fasterxml.jackson.databind.annotation.JsonDeserialize
import de.grimsi.gameyfin.core.serialization.ArrayDeserializer
import java.io.Serializable
data class ConfigValuePairDto(
val key: String,
@field:JsonDeserialize(using = ArrayDeserializer::class)
val value: Serializable?
data class ConfigUpdateDto(
@get:JsonDeserialize(contentUsing = ArrayDeserializer::class)
val updates: Map<String, Serializable?>
)