Implement plugin config validation

Refactor plugin management card
This commit is contained in:
grimsi
2024-12-19 13:23:19 +01:00
parent 774f904334
commit 5eb94180e7
12 changed files with 124 additions and 55 deletions
@@ -19,11 +19,19 @@ export default function PluginManagement() {
<div className="flex flex-row flex-grow justify-between mb-8"> <div className="flex flex-row flex-grow justify-between mb-8">
<h2 className="text-2xl font-bold">Plugins</h2> <h2 className="text-2xl font-bold">Plugins</h2>
</div> </div>
<Divider className="mb-4"/> <Divider className="mb-4"/>
<div className="flex flex-row flex-grow justify-between mb-8">
<h2 className="text-xl font-bold">Metadata</h2>
</div>
<div className="grid grid-cols-300px gap-4"> <div className="grid grid-cols-300px gap-4">
{plugins.map((plugin) => <PluginManagementCard plugin={plugin} key={plugin.name}/>)} {plugins.map((plugin) => <PluginManagementCard plugin={plugin} key={plugin.name}/>)}
</div> </div>
<div className="flex flex-row flex-grow justify-between my-8">
<h2 className="text-xl font-bold">Notifications</h2>
</div>
</div> </div>
); );
} }
@@ -6,14 +6,15 @@ import {PluginConfigEndpoint} from "Frontend/generated/endpoints";
import PluginDto from "Frontend/generated/de/grimsi/gameyfin/core/plugins/management/PluginDto"; import PluginDto from "Frontend/generated/de/grimsi/gameyfin/core/plugins/management/PluginDto";
import PluginConfigElement from "Frontend/generated/de/grimsi/gameyfin/pluginapi/core/PluginConfigElement"; import PluginConfigElement from "Frontend/generated/de/grimsi/gameyfin/pluginapi/core/PluginConfigElement";
import Input from "Frontend/components/general/Input"; import Input from "Frontend/components/general/Input";
import {PuzzlePiece} from "@phosphor-icons/react";
interface PluginConfigurationModalProps { interface PluginDetailsModalProps {
plugin: PluginDto; plugin: PluginDto;
isOpen: boolean; isOpen: boolean;
onOpenChange: () => void; onOpenChange: () => void;
} }
export default function PluginConfigurationModal({plugin, isOpen, onOpenChange}: PluginConfigurationModalProps) { export default function PluginDetailsModal({plugin, isOpen, onOpenChange}: PluginDetailsModalProps) {
const [pluginConfigMeta, setPluginConfigMeta] = useState<(PluginConfigElement)[]>(); const [pluginConfigMeta, setPluginConfigMeta] = useState<(PluginConfigElement)[]>();
const [pluginConfig, setPluginConfig] = useState<Record<string, string>>(); const [pluginConfig, setPluginConfig] = useState<Record<string, string>>();
@@ -46,24 +47,40 @@ export default function PluginConfigurationModal({plugin, isOpen, onOpenChange}:
> >
{(formik: { isSubmitting: any; }) => ( {(formik: { isSubmitting: any; }) => (
<Form> <Form>
<ModalHeader className="flex flex-col gap-1">{plugin.name} configuration</ModalHeader> <ModalHeader className="flex flex-col gap-1">
Plugin configuration for {plugin.name}</ModalHeader>
<ModalBody> <ModalBody>
{pluginConfigMeta && pluginConfigMeta.map((entry: any) => ( <h4 className="text-l font-bold">Details</h4>
<Input key={entry.key} name={entry.key} label={entry.name} type="text"/> <div className="flex flex-row gap-8">
))} <PuzzlePiece size={64} weight="fill"/>
<div className="grid grid-cols-2">
<p>Author: {plugin.author}</p>
<p>Version: {plugin.version}</p>
<p>Plugin ID: {plugin.id}</p>
<p>Status: {plugin.state?.toLowerCase()}</p>
</div>
</div>
<h4 className="text-l font-bold mt-6">Configuration</h4>
{(pluginConfigMeta && pluginConfigMeta.length > 0) ?
pluginConfigMeta.map((entry: any) => (
<Input key={entry.key} name={entry.key} label={entry.name} type="text"/>
)) : "This plugin has no configuration options."
}
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button variant="light" onPress={onClose}> <Button variant="light" onPress={onClose}>
Cancel Cancel
</Button> </Button>
<Button {(pluginConfigMeta && pluginConfigMeta?.length > 0) ?
color="primary" <Button
isLoading={formik.isSubmitting} color="primary"
disabled={formik.isSubmitting} isLoading={formik.isSubmitting}
type="submit" disabled={formik.isSubmitting}
> type="submit"
{formik.isSubmitting ? "" : "Save"} >
</Button> {formik.isSubmitting ? "" : "Save"}
</Button> : ""}
</ModalFooter> </ModalFooter>
</Form> </Form>
)} )}
@@ -1,14 +1,29 @@
import {Card, Chip, Tooltip, useDisclosure} from "@nextui-org/react"; import {Card, Chip, Skeleton, useDisclosure} from "@nextui-org/react";
import {PuzzlePiece} from "@phosphor-icons/react"; import {PuzzlePiece} from "@phosphor-icons/react";
import {PluginManagementEndpoint} from "Frontend/generated/endpoints";
import PluginDto from "Frontend/generated/de/grimsi/gameyfin/core/plugins/management/PluginDto"; import PluginDto from "Frontend/generated/de/grimsi/gameyfin/core/plugins/management/PluginDto";
import PluginState from "Frontend/generated/org/pf4j/PluginState"; import PluginState from "Frontend/generated/org/pf4j/PluginState";
import React from "react"; import React, {useEffect, useState} from "react";
import PluginConfigurationModal from "Frontend/components/general/PluginConfigurationModal"; import PluginDetailsModal from "Frontend/components/general/PluginDetailsModal";
export function PluginManagementCard({plugin}: { plugin: PluginDto }) { export function PluginManagementCard({plugin}: { plugin: PluginDto }) {
const pluginConfigurationModal = useDisclosure(); const pluginDetailsModal = useDisclosure();
const [configValid, setConfigValid] = useState<boolean | undefined>(undefined);
function stateToColor(state: PluginState | undefined): string { useEffect(() => {
PluginManagementEndpoint.validatePluginConfig(plugin.id).then((response: boolean) => {
if (response === undefined) return;
setConfigValid(response);
});
}, []);
function borderColor(state: PluginState | undefined): "success" | "warning" | "danger" | "default" {
if (configValid === undefined) return "default";
if (!configValid) return "danger";
return stateToColor(state);
}
function stateToColor(state: PluginState | undefined): "success" | "warning" | "danger" | "default" {
switch (state) { switch (state) {
case PluginState.STARTED: case PluginState.STARTED:
return "success"; return "success";
@@ -17,32 +32,33 @@ export function PluginManagementCard({plugin}: { plugin: PluginDto }) {
case PluginState.STOPPED: case PluginState.STOPPED:
return "danger"; return "danger";
default: default:
return ""; return "default";
} }
} }
return ( return (
<> <>
<Card className="flex flex-row justify-between p-2" <Card className={`flex flex-row justify-between p-2 border-2 border-${borderColor(plugin.state)}`}
isPressable={true} onPress={pluginConfigurationModal.onOpen}> isPressable={true} onPress={pluginDetailsModal.onOpen}>
<div className="flex flex-row items-center gap-4"> <div className="flex flex-1 flex-col items-center gap-1">
<Tooltip placement="right" content={`Plugin ${plugin.state!.toLowerCase()}`}> <PuzzlePiece size={64} weight="fill"/>
<PuzzlePiece size={64} weight="duotone" className={`text-${stateToColor(plugin.state)}`}/> <p className="font-semibold">{plugin.name}</p>
</Tooltip> <div className="flex flex-row gap-2">
<div className="flex flex-col items-start gap-1"> <Chip size="sm" radius="sm" className="text-xs">{plugin.version}</Chip>
<div className="flex flex-row gap-2"> <Chip size="sm" radius="sm" className="text-xs"
<p className="font-semibold">{plugin.name}</p> color={stateToColor(plugin.state)}>{plugin.state?.toLowerCase()}</Chip>
<div className="text-sm"> {configValid === undefined ?
<Chip size="sm" radius="sm" className="text-xs">{plugin.version}</Chip> <Skeleton className="rounded-md h-6 w-20"></Skeleton>
</div> : configValid ?
</div> <Chip size="sm" radius="sm" className="text-xs" color="success">config valid</Chip> :
<p className="text-sm">Author: {plugin.author}</p> <Chip size="sm" radius="sm" className="text-xs" color="danger">config invalid</Chip>
}
</div> </div>
</div> </div>
</Card> </Card>
<PluginConfigurationModal plugin={plugin} <PluginDetailsModal plugin={plugin}
isOpen={pluginConfigurationModal.isOpen} isOpen={pluginDetailsModal.isOpen}
onOpenChange={pluginConfigurationModal.onOpenChange} onOpenChange={pluginDetailsModal.onOpenChange}
/> />
</> </>
@@ -11,7 +11,6 @@ import TokenDto from "Frontend/generated/de/grimsi/gameyfin/shared/token/TokenDt
import UserInfoDto from "Frontend/generated/de/grimsi/gameyfin/users/dto/UserInfoDto"; import UserInfoDto from "Frontend/generated/de/grimsi/gameyfin/users/dto/UserInfoDto";
import RoleChip from "Frontend/components/general/RoleChip"; import RoleChip from "Frontend/components/general/RoleChip";
import AssignRolesModal from "Frontend/components/general/AssignRolesModal"; import AssignRolesModal from "Frontend/components/general/AssignRolesModal";
import Role from "Frontend/generated/de/grimsi/gameyfin/core/Role";
export function UserManagementCard({user}: { user: UserInfoDto }) { export function UserManagementCard({user}: { user: UserInfoDto }) {
const userDeletionConfirmationModal = useDisclosure(); const userDeletionConfirmationModal = useDisclosure();
@@ -123,7 +122,7 @@ export function UserManagementCard({user}: { user: UserInfoDto }) {
<p className="font-semibold">{user.username}</p> <p className="font-semibold">{user.username}</p>
<p className="text-sm">{user.email}</p> <p className="text-sm">{user.email}</p>
{user.roles?.map((role) => ( {user.roles?.map((role) => (
<RoleChip role={role as Role}/> <RoleChip role={role as string}/>
))} ))}
</div> </div>
</div> </div>
@@ -19,7 +19,7 @@ export default function MainLayout() {
const [isExploding, setIsExploding] = useState(false); const [isExploding, setIsExploding] = useState(false);
useEffect(() => { useEffect(() => {
let newTitle = `Gameyfin - ${routeMetadata?.title}` ?? 'Gameyfin'; let newTitle = `Gameyfin - ${routeMetadata?.title}`;
window.addEventListener('popstate', () => document.title = newTitle); window.addEventListener('popstate', () => document.title = newTitle);
loadUserTheme().catch(console.error); loadUserTheme().catch(console.error);
}, []); }, []);
@@ -1,7 +1,7 @@
import {Link} from "react-router-dom"; import {Link} from "react-router-dom";
import {Button, Input} from "@nextui-org/react"; import {Button, Input} from "@nextui-org/react";
import {toast} from "sonner"; import {toast} from "sonner";
import {LibraryEndpoint, SystemEndpoint} from "Frontend/generated/endpoints.js"; import {LibraryEndpoint, SystemEndpoint} from "Frontend/generated/endpoints";
import {useState} from "react"; import {useState} from "react";
import Game from "Frontend/generated/de/grimsi/gameyfin/games/Game"; import Game from "Frontend/generated/de/grimsi/gameyfin/games/Game";
@@ -59,6 +59,14 @@ class GameyfinPluginManager(
plugin.start() plugin.start()
} }
fun validatePluginConfig(pluginId: String): Boolean {
val plugin = getPlugin(pluginId)?.plugin ?: return false
if (plugin is GameyfinPlugin) {
return plugin.validateConfig()
}
return false
}
private fun configurePlugin(pluginWrapper: PluginWrapper) { private fun configurePlugin(pluginWrapper: PluginWrapper) {
val plugin = pluginWrapper.plugin val plugin = pluginWrapper.plugin
if (plugin is GameyfinPlugin) { if (plugin is GameyfinPlugin) {
@@ -20,4 +20,6 @@ class PluginManagementEndpoint(
fun enablePlugin(pluginId: String) = pluginManagementService.enablePlugin(pluginId) fun enablePlugin(pluginId: String) = pluginManagementService.enablePlugin(pluginId)
fun disablePlugin(pluginId: String) = pluginManagementService.disablePlugin(pluginId) fun disablePlugin(pluginId: String) = pluginManagementService.disablePlugin(pluginId)
fun validatePluginConfig(pluginId: String): Boolean = pluginManagementService.validatePluginConfig(pluginId)
} }
@@ -37,4 +37,8 @@ class PluginManagementService(
fun disablePlugin(pluginId: String) { fun disablePlugin(pluginId: String) {
pluginManager.disablePlugin(pluginId) pluginManager.disablePlugin(pluginId)
} }
fun validatePluginConfig(pluginId: String): Boolean {
return pluginManager.validatePluginConfig(pluginId)
}
} }
@@ -15,4 +15,10 @@ abstract class GameyfinPlugin(wrapper: PluginWrapper) : Plugin(wrapper) {
open fun loadConfig(config: Map<String, String?>) { open fun loadConfig(config: Map<String, String?>) {
this.config = config this.config = config
} }
open fun validateConfig(): Boolean {
return validateConfig(config)
}
abstract fun validateConfig(config: Map<String, String?>): Boolean
} }
@@ -13,7 +13,6 @@ import me.xdrop.fuzzywuzzy.FuzzySearch
import org.pf4j.Extension import org.pf4j.Extension
import org.pf4j.PluginWrapper import org.pf4j.PluginWrapper
import java.time.Instant import java.time.Instant
import kotlin.collections.filter
class IgdbPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) { class IgdbPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) {
@@ -22,6 +21,16 @@ class IgdbPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) {
PluginConfigElement("clientSecret", "Twitch client secret", "Your Twitch Client Secret") PluginConfigElement("clientSecret", "Twitch client secret", "Your Twitch Client Secret")
) )
override fun validateConfig(config: Map<String, String?>): Boolean {
try {
authenticate()
return true
} catch (e: PluginConfigError) {
log.error(e.message)
return false
}
}
override fun start() { override fun start() {
try { try {
authenticate() authenticate()
@@ -4,19 +4,15 @@ import de.grimsi.gameyfin.pluginapi.core.GameyfinPlugin
import de.grimsi.gameyfin.pluginapi.core.PluginConfigElement import de.grimsi.gameyfin.pluginapi.core.PluginConfigElement
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadata import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadata
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadataProvider import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadataProvider
import io.ktor.client.HttpClient import io.ktor.client.*
import io.ktor.client.call.body import io.ktor.client.call.*
import io.ktor.client.engine.cio.CIO import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.get import io.ktor.client.request.*
import io.ktor.serialization.kotlinx.json.json import io.ktor.serialization.kotlinx.json.*
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.*
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import me.xdrop.fuzzywuzzy.FuzzySearch import me.xdrop.fuzzywuzzy.FuzzySearch
import org.pf4j.Extension import org.pf4j.Extension
import org.pf4j.PluginWrapper import org.pf4j.PluginWrapper
@@ -26,7 +22,7 @@ import java.time.Instant
import java.time.LocalDate import java.time.LocalDate
import java.time.ZoneOffset import java.time.ZoneOffset
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.Locale import java.util.*
@Serializable @Serializable
data class SteamSearchResult( data class SteamSearchResult(
@@ -64,6 +60,10 @@ data class Platforms(
class SteamPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) { class SteamPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) {
override val configMetadata: List<PluginConfigElement> = emptyList() override val configMetadata: List<PluginConfigElement> = emptyList()
override fun validateConfig(config: Map<String, String?>): Boolean {
return true
}
@Extension @Extension
class SteamMetadataProvider : GameMetadataProvider { class SteamMetadataProvider : GameMetadataProvider {
val client = HttpClient(CIO) { val client = HttpClient(CIO) {