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">
<h2 className="text-2xl font-bold">Plugins</h2>
</div>
<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">
{plugins.map((plugin) => <PluginManagementCard plugin={plugin} key={plugin.name}/>)}
</div>
<div className="flex flex-row flex-grow justify-between my-8">
<h2 className="text-xl font-bold">Notifications</h2>
</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 PluginConfigElement from "Frontend/generated/de/grimsi/gameyfin/pluginapi/core/PluginConfigElement";
import Input from "Frontend/components/general/Input";
import {PuzzlePiece} from "@phosphor-icons/react";
interface PluginConfigurationModalProps {
interface PluginDetailsModalProps {
plugin: PluginDto;
isOpen: boolean;
onOpenChange: () => void;
}
export default function PluginConfigurationModal({plugin, isOpen, onOpenChange}: PluginConfigurationModalProps) {
export default function PluginDetailsModal({plugin, isOpen, onOpenChange}: PluginDetailsModalProps) {
const [pluginConfigMeta, setPluginConfigMeta] = useState<(PluginConfigElement)[]>();
const [pluginConfig, setPluginConfig] = useState<Record<string, string>>();
@@ -46,24 +47,40 @@ export default function PluginConfigurationModal({plugin, isOpen, onOpenChange}:
>
{(formik: { isSubmitting: any; }) => (
<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>
{pluginConfigMeta && pluginConfigMeta.map((entry: any) => (
<Input key={entry.key} name={entry.key} label={entry.name} type="text"/>
))}
<h4 className="text-l font-bold">Details</h4>
<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>
<ModalFooter>
<Button variant="light" onPress={onClose}>
Cancel
</Button>
<Button
color="primary"
isLoading={formik.isSubmitting}
disabled={formik.isSubmitting}
type="submit"
>
{formik.isSubmitting ? "" : "Save"}
</Button>
{(pluginConfigMeta && pluginConfigMeta?.length > 0) ?
<Button
color="primary"
isLoading={formik.isSubmitting}
disabled={formik.isSubmitting}
type="submit"
>
{formik.isSubmitting ? "" : "Save"}
</Button> : ""}
</ModalFooter>
</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 {PluginManagementEndpoint} from "Frontend/generated/endpoints";
import PluginDto from "Frontend/generated/de/grimsi/gameyfin/core/plugins/management/PluginDto";
import PluginState from "Frontend/generated/org/pf4j/PluginState";
import React from "react";
import PluginConfigurationModal from "Frontend/components/general/PluginConfigurationModal";
import React, {useEffect, useState} from "react";
import PluginDetailsModal from "Frontend/components/general/PluginDetailsModal";
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) {
case PluginState.STARTED:
return "success";
@@ -17,32 +32,33 @@ export function PluginManagementCard({plugin}: { plugin: PluginDto }) {
case PluginState.STOPPED:
return "danger";
default:
return "";
return "default";
}
}
return (
<>
<Card className="flex flex-row justify-between p-2"
isPressable={true} onPress={pluginConfigurationModal.onOpen}>
<div className="flex flex-row items-center gap-4">
<Tooltip placement="right" content={`Plugin ${plugin.state!.toLowerCase()}`}>
<PuzzlePiece size={64} weight="duotone" className={`text-${stateToColor(plugin.state)}`}/>
</Tooltip>
<div className="flex flex-col items-start gap-1">
<div className="flex flex-row gap-2">
<p className="font-semibold">{plugin.name}</p>
<div className="text-sm">
<Chip size="sm" radius="sm" className="text-xs">{plugin.version}</Chip>
</div>
</div>
<p className="text-sm">Author: {plugin.author}</p>
<Card className={`flex flex-row justify-between p-2 border-2 border-${borderColor(plugin.state)}`}
isPressable={true} onPress={pluginDetailsModal.onOpen}>
<div className="flex flex-1 flex-col items-center gap-1">
<PuzzlePiece size={64} weight="fill"/>
<p className="font-semibold">{plugin.name}</p>
<div className="flex flex-row gap-2">
<Chip size="sm" radius="sm" className="text-xs">{plugin.version}</Chip>
<Chip size="sm" radius="sm" className="text-xs"
color={stateToColor(plugin.state)}>{plugin.state?.toLowerCase()}</Chip>
{configValid === undefined ?
<Skeleton className="rounded-md h-6 w-20"></Skeleton>
: configValid ?
<Chip size="sm" radius="sm" className="text-xs" color="success">config valid</Chip> :
<Chip size="sm" radius="sm" className="text-xs" color="danger">config invalid</Chip>
}
</div>
</div>
</Card>
<PluginConfigurationModal plugin={plugin}
isOpen={pluginConfigurationModal.isOpen}
onOpenChange={pluginConfigurationModal.onOpenChange}
<PluginDetailsModal plugin={plugin}
isOpen={pluginDetailsModal.isOpen}
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 RoleChip from "Frontend/components/general/RoleChip";
import AssignRolesModal from "Frontend/components/general/AssignRolesModal";
import Role from "Frontend/generated/de/grimsi/gameyfin/core/Role";
export function UserManagementCard({user}: { user: UserInfoDto }) {
const userDeletionConfirmationModal = useDisclosure();
@@ -123,7 +122,7 @@ export function UserManagementCard({user}: { user: UserInfoDto }) {
<p className="font-semibold">{user.username}</p>
<p className="text-sm">{user.email}</p>
{user.roles?.map((role) => (
<RoleChip role={role as Role}/>
<RoleChip role={role as string}/>
))}
</div>
</div>
@@ -19,7 +19,7 @@ export default function MainLayout() {
const [isExploding, setIsExploding] = useState(false);
useEffect(() => {
let newTitle = `Gameyfin - ${routeMetadata?.title}` ?? 'Gameyfin';
let newTitle = `Gameyfin - ${routeMetadata?.title}`;
window.addEventListener('popstate', () => document.title = newTitle);
loadUserTheme().catch(console.error);
}, []);
@@ -1,7 +1,7 @@
import {Link} from "react-router-dom";
import {Button, Input} from "@nextui-org/react";
import {toast} from "sonner";
import {LibraryEndpoint, SystemEndpoint} from "Frontend/generated/endpoints.js";
import {LibraryEndpoint, SystemEndpoint} from "Frontend/generated/endpoints";
import {useState} from "react";
import Game from "Frontend/generated/de/grimsi/gameyfin/games/Game";
@@ -59,6 +59,14 @@ class GameyfinPluginManager(
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) {
val plugin = pluginWrapper.plugin
if (plugin is GameyfinPlugin) {
@@ -20,4 +20,6 @@ class PluginManagementEndpoint(
fun enablePlugin(pluginId: String) = pluginManagementService.enablePlugin(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) {
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?>) {
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.PluginWrapper
import java.time.Instant
import kotlin.collections.filter
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")
)
override fun validateConfig(config: Map<String, String?>): Boolean {
try {
authenticate()
return true
} catch (e: PluginConfigError) {
log.error(e.message)
return false
}
}
override fun start() {
try {
authenticate()
@@ -4,19 +4,15 @@ import de.grimsi.gameyfin.pluginapi.core.GameyfinPlugin
import de.grimsi.gameyfin.pluginapi.core.PluginConfigElement
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadata
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadataProvider
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.serialization.kotlinx.json.json
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.*
import me.xdrop.fuzzywuzzy.FuzzySearch
import org.pf4j.Extension
import org.pf4j.PluginWrapper
@@ -26,7 +22,7 @@ import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.util.Locale
import java.util.*
@Serializable
data class SteamSearchResult(
@@ -64,6 +60,10 @@ data class Platforms(
class SteamPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) {
override val configMetadata: List<PluginConfigElement> = emptyList()
override fun validateConfig(config: Map<String, String?>): Boolean {
return true
}
@Extension
class SteamMetadataProvider : GameMetadataProvider {
val client = HttpClient(CIO) {