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", "react-router": "7.5.2",
"remark-breaks": "^4.0.0", "remark-breaks": "^4.0.0",
"swiper": "^11.2.6", "swiper": "^11.2.6",
"valtio": "^2.1.5",
"yup": "^1.6.1" "yup": "^1.6.1"
}, },
"devDependencies": { "devDependencies": {
@@ -15816,6 +15817,12 @@
"url": "https://github.com/sponsors/wooorm" "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": { "node_modules/pump": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz",
@@ -18159,6 +18166,30 @@
"node": ">= 0.10" "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": { "node_modules/vfile": {
"version": "6.0.3", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
+4 -2
View File
@@ -48,6 +48,7 @@
"react-router": "7.5.2", "react-router": "7.5.2",
"remark-breaks": "^4.0.0", "remark-breaks": "^4.0.0",
"swiper": "^11.2.6", "swiper": "^11.2.6",
"valtio": "^2.1.5",
"yup": "^1.6.1" "yup": "^1.6.1"
}, },
"devDependencies": { "devDependencies": {
@@ -136,7 +137,8 @@
"swiper": "$swiper", "swiper": "$swiper",
"react-player": "$react-player", "react-player": "$react-player",
"react-markdown": "$react-markdown", "react-markdown": "$react-markdown",
"remark-breaks": "$remark-breaks" "remark-breaks": "$remark-breaks",
"valtio": "$valtio"
}, },
"vaadin": { "vaadin": {
"dependencies": { "dependencies": {
@@ -197,6 +199,6 @@
"workbox-core": "7.3.0", "workbox-core": "7.3.0",
"workbox-precaching": "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 SendTestNotificationModal from "Frontend/components/administration/messages/SendTestNotificationModal";
import EditTemplateModal from "Frontend/components/administration/messages/EditTemplateModel"; import EditTemplateModal from "Frontend/components/administration/messages/EditTemplateModel";
function MessageManagementLayout({getConfig, getConfigs, formik}: any) { function MessageManagementLayout({getConfig, formik}: any) {
const editorModal = useDisclosure(); const editorModal = useDisclosure();
const testNotificationModal = 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 ConfigFormField from "Frontend/components/administration/ConfigFormField";
import withConfigPage from "Frontend/components/administration/withConfigPage"; import withConfigPage from "Frontend/components/administration/withConfigPage";
import Section from "Frontend/components/general/Section"; 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 UserInfoDto from "Frontend/generated/de/grimsi/gameyfin/users/dto/UserInfoDto";
import {UserManagementCard} from "Frontend/components/general/cards/UserManagementCard"; import {UserManagementCard} from "Frontend/components/general/cards/UserManagementCard";
import {SmallInfoField} from "Frontend/components/general/SmallInfoField"; import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
import {Info, UserPlus} from "@phosphor-icons/react"; import {Info, UserPlus} from "@phosphor-icons/react";
import {Button, Divider, Tooltip, useDisclosure} from "@heroui/react"; import {Button, Divider, Tooltip, useDisclosure} from "@heroui/react";
import InviteUserModal from "Frontend/components/general/modals/InviteUserModal"; import InviteUserModal from "Frontend/components/general/modals/InviteUserModal";
import {useSnapshot} from "valtio/react";
import {configState} from "Frontend/state/ConfigState";
function UserManagementLayout({getConfig, formik}: any) { function UserManagementLayout({getConfig, formik}: any) {
const inviteUserModal = useDisclosure(); const inviteUserModal = useDisclosure();
const [users, setUsers] = useState<UserInfoDto[]>([]); const [users, setUsers] = useState<UserInfoDto[]>([]);
const [autoRegisterNewUsers, setAutoRegisterNewUsers] = useState(true); const autoRegisterNewUsers = useSnapshot(configState);
useEffect(() => { useEffect(() => {
UserEndpoint.getAllUsers().then( UserEndpoint.getAllUsers().then(
(response) => setUsers(response) (response) => setUsers(response)
); );
ConfigEndpoint.get("sso.oidc.auto-register-new-users").then(
(response) => setAutoRegisterNewUsers(response as boolean)
);
}, []); }, []);
return ( 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 {ConfigEndpoint} from "Frontend/generated/endpoints";
import ConfigEntryDto from "Frontend/generated/de/grimsi/gameyfin/config/dto/ConfigEntryDto"; import ConfigEntryDto from "Frontend/generated/de/grimsi/gameyfin/config/dto/ConfigEntryDto";
import {Form, Formik} from "formik"; import {Form, Formik} from "formik";
import {Button, Skeleton} from "@heroui/react"; import {Button, Skeleton} from "@heroui/react";
import {Check, Info} from "@phosphor-icons/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 {SmallInfoField} from "Frontend/components/general/SmallInfoField";
import {configState, initializeConfig, NestedConfig} from "Frontend/state/ConfigState";
import {useSnapshot} from "valtio/react";
type NestedConfig = { export default function withConfigPage(WrappedComponent: React.ComponentType<any>, title: String, validationSchema?: any) {
[field: string]: any;
}
export default function withConfigPage(WrappedComponent: React.ComponentType<any>, title: String, configPrefix: string, validationSchema?: any) {
return function ConfigPage(props: any) { return function ConfigPage(props: any) {
const isInitialized = useRef(false);
const [configSaved, setConfigSaved] = useState(false); const [configSaved, setConfigSaved] = useState(false);
const [configDtos, setConfigDtos] = useState<ConfigEntryDto[]>([]);
const [nestedConfigDtos, setNestedConfigDtos] = useState<NestedConfig>({});
const [saveMessage, setSaveMessage] = useState<string>(); const [saveMessage, setSaveMessage] = useState<string>();
const state = useSnapshot(configState);
useEffect(() => { useEffect(() => {
ConfigEndpoint.getAll(configPrefix).then((response: any) => { initializeConfig();
setConfigDtos(response as ConfigEntryDto[]);
setNestedConfigDtos(toNestedConfig(response as ConfigEntryDto[]));
isInitialized.current = true;
});
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -34,116 +26,67 @@ export default function withConfigPage(WrappedComponent: React.ComponentType<any
}, [configSaved]) }, [configSaved])
async function handleSubmit(values: NestedConfig): Promise<void> { async function handleSubmit(values: NestedConfig): Promise<void> {
const configValues = toConfigValuePair(values); const changed = getChangedValues(state.configNested, values);
await ConfigEndpoint.setAll(configValues); await ConfigEndpoint.update({updates: changed});
setNestedConfigDtos(values);
setConfigSaved(true); setConfigSaved(true);
} }
function getConfig(key: string): ConfigEntryDto | undefined { 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[] { function getChangedValues(initial: NestedConfig, current: NestedConfig): Record<string, any> {
return configDtos.filter((configDto: ConfigEntryDto) => configDto.key?.startsWith(prefix)); const flatten = (obj: NestedConfig, parentKey = ''): Record<string, any> => {
} let result: Record<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;
}
function toConfigValuePair(obj: NestedConfig, parentKey: string = ''): ConfigValuePairDto[] {
let result: ConfigValuePairDto[] = [];
for (const key in obj) { for (const key in obj) {
if (obj.hasOwnProperty(key)) { if (obj.hasOwnProperty(key)) {
const newKey = parentKey ? `${parentKey}.${key}` : key; const newKey = parentKey ? `${parentKey}.${key}` : key;
if (typeof obj[key] === 'object' && !Array.isArray(obj[key])) { if (typeof obj[key] === 'object' && obj[key] !== null && !Array.isArray(obj[key])) {
result = result.concat(toConfigValuePair(obj[key], newKey)); Object.assign(result, flatten(obj[key], newKey));
} else { } else {
result.push({key: newKey, value: obj[key]}); result[newKey] = obj[key];
} }
} }
} }
return result; return result;
} };
if (!isInitialized.current) { const arraysEqual = (a: any[], b: any[]): boolean => {
return ( if (a.length !== b.length) return false;
[...Array(4)].map((_e, i) => for (let i = 0; i < a.length; i++) {
<div className="flex flex-col flex-grow gap-8 mb-12" key={i}> if (Array.isArray(a[i]) && Array.isArray(b[i])) {
<Skeleton className="h-10 w-full rounded-md"/> if (!arraysEqual(a[i], b[i])) return false;
<Skeleton className="h-12 flex w-1/3 rounded-md"/> } else if (a[i] !== b[i]) {
<div className="flex flex-row gap-8"> return false;
<Skeleton className="h-12 flex w-1/3 rounded-md"/> }
<Skeleton className="h-12 flex w-1/3 rounded-md"/> }
</div> return true;
</div> };
)
) 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 changed;
} }
return ( return (
<>
{state.isLoaded ?
<Formik <Formik
initialValues={nestedConfigDtos} initialValues={state.configNested}
onSubmit={handleSubmit} onSubmit={handleSubmit}
validationSchema={validationSchema} validationSchema={validationSchema}
enableReinitialize={true} enableReinitialize={true}
@@ -171,12 +114,23 @@ export default function withConfigPage(WrappedComponent: React.ComponentType<any
<WrappedComponent {...props} <WrappedComponent {...props}
getConfig={getConfig} getConfig={getConfig}
getConfigs={getConfigs}
formik={formik} formik={formik}
setSaveMessage={setSaveMessage}/> setSaveMessage={setSaveMessage}/>
</Form> </Form>
)} )}
</Formik> </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>
)
}
</>
); );
} }
} }
@@ -13,7 +13,7 @@ const SelectInput = ({label, values, ...props}) => {
{...props} {...props}
id={field.name} id={field.name}
label={label} label={label}
defaultSelectedKeys={[field.value]} selectedKeys={[field.value]}
disallowEmptySelection disallowEmptySelection
> >
{values.map((value: string) => ( {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 package de.grimsi.gameyfin.config
import com.vaadin.flow.server.auth.AnonymousAllowed
import com.vaadin.hilla.Endpoint import com.vaadin.hilla.Endpoint
import de.grimsi.gameyfin.config.dto.ConfigEntryDto 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 de.grimsi.gameyfin.core.Role
import jakarta.annotation.security.PermitAll import jakarta.annotation.security.PermitAll
import jakarta.annotation.security.RolesAllowed import jakarta.annotation.security.RolesAllowed
import java.io.Serializable import reactor.core.publisher.Flux
import reactor.core.publisher.Sinks
@Endpoint @Endpoint
@RolesAllowed(Role.Names.ADMIN) @RolesAllowed(Role.Names.ADMIN)
@@ -15,29 +17,19 @@ class ConfigEndpoint(
) { ) {
/** CRUD endpoints for admins **/ /** CRUD endpoints for admins **/
private val configUpdates = Sinks.many().multicast().onBackpressureBuffer<ConfigUpdateDto>()
fun getAll(prefix: String?): List<ConfigEntryDto> { fun getAll(): List<ConfigEntryDto> {
return config.getAll(prefix) return config.getAll(null)
} }
fun get(key: String): Serializable? { // FIXME
return config.get(key) @AnonymousAllowed
} fun subscribe(): Flux<ConfigUpdateDto> = configUpdates.asFlux()
fun set(key: String, value: String) { fun update(update: ConfigUpdateDto) {
config.set(key, value) config.update(update.updates)
} configUpdates.tryEmitNext(update)
fun setAll(configs: List<ConfigValuePairDto>) {
config.setAll(configs)
}
fun resetConfig(key: String) {
config.deleteConfig(key)
}
fun deleteConfig(key: String) {
config.deleteConfig(key)
} }
/** Specific read-only endpoint for all users **/ /** Specific read-only endpoint for all users **/
@@ -1,7 +1,6 @@
package de.grimsi.gameyfin.config package de.grimsi.gameyfin.config
import de.grimsi.gameyfin.config.dto.ConfigEntryDto 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.entities.ConfigEntry
import de.grimsi.gameyfin.config.persistence.ConfigRepository import de.grimsi.gameyfin.config.persistence.ConfigRepository
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
@@ -116,11 +115,15 @@ class ConfigService(
* Set multiple config values at once. * Set multiple config values at once.
* Configs with a null value will be deleted. * 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>) { fun update(updates: Map<String, Serializable?>) {
configs.forEach { updates.forEach { (key, value) ->
it.value?.let { value -> set(it.key, value) } ?: deleteConfig(it.key) if (value == null) {
delete(key)
} else {
set(key, value)
}
} }
} }
@@ -141,7 +144,7 @@ class ConfigService(
* *
* @param key: Key of the config property * @param key: Key of the config property
*/ */
fun deleteConfig(key: String) { fun delete(key: String) {
log.debug { "Delete config value '$key'" } 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 de.grimsi.gameyfin.core.serialization.ArrayDeserializer
import java.io.Serializable import java.io.Serializable
data class ConfigValuePairDto( data class ConfigUpdateDto(
val key: String, @get:JsonDeserialize(contentUsing = ArrayDeserializer::class)
val updates: Map<String, Serializable?>
@field:JsonDeserialize(using = ArrayDeserializer::class)
val value: Serializable?
) )