Implement pluginList RPC and associated UI

This commit is contained in:
tutman96 2025-01-04 16:43:09 +00:00
parent 00fdbafeb7
commit 3853b58613
8 changed files with 186 additions and 55 deletions

View File

@ -4,10 +4,18 @@ import (
"encoding/json"
"fmt"
"os"
"sync"
)
const databaseFile = pluginsFolder + "/plugins.json"
type PluginDatabase struct {
// Map with the plugin name as the key
Plugins map[string]PluginInstall `json:"plugins"`
saveMutex sync.Mutex
}
var pluginDatabase = PluginDatabase{}
func init() {

View File

@ -0,0 +1,31 @@
package plugin
type PluginInstall struct {
Enabled bool `json:"enabled"`
// Current active version of the plugin
Version string `json:"version"`
// Map of a plugin version to the extracted directory
ExtractedVersions map[string]string `json:"extracted_versions"`
manifest *PluginManifest
}
func (p *PluginInstall) GetManifest() (*PluginManifest, error) {
if p.manifest != nil {
return p.manifest, nil
}
manifest, err := readManifest(p.GetExtractedFolder())
if err != nil {
return nil, err
}
p.manifest = manifest
return manifest, nil
}
func (p *PluginInstall) GetExtractedFolder() string {
return p.ExtractedVersions[p.Version]
}

View File

@ -132,6 +132,28 @@ func RpcPluginInstall(name string, version string) error {
return nil
}
func RpcPluginList() ([]PluginStatus, error) {
plugins := make([]PluginStatus, 0, len(pluginDatabase.Plugins))
for pluginName, plugin := range pluginDatabase.Plugins {
manifest, err := plugin.GetManifest()
if err != nil {
return nil, fmt.Errorf("failed to get plugin manifest for %s: %v", pluginName, err)
}
status := "stopped"
if plugin.Enabled {
status = "running"
}
plugins = append(plugins, PluginStatus{
PluginManifest: *manifest,
Enabled: plugin.Enabled,
Status: status,
})
}
return plugins, nil
}
func readManifest(extractFolder string) (*PluginManifest, error) {
manifestPath := path.Join(extractFolder, "manifest.json")
manifestFile, err := os.Open(manifestPath)

View File

@ -1,7 +1,5 @@
package plugin
import "sync"
type PluginManifest struct {
ManifestVersion string `json:"manifest_version"`
Name string `json:"name"`
@ -12,19 +10,8 @@ type PluginManifest struct {
SystemMinVersion string `json:"system_min_version,omitempty"`
}
type PluginInstall struct {
Enabled bool `json:"enabled"`
// Current active version of the plugin
Version string `json:"version"`
// Map of a plugin version to the extracted directory
ExtractedVersions map[string]string `json:"extracted_versions"`
}
type PluginDatabase struct {
// Map with the plugin name as the key
Plugins map[string]PluginInstall `json:"plugins"`
saveMutex sync.Mutex
type PluginStatus struct {
PluginManifest
Enabled bool `json:"enabled"`
Status string `json:"status"`
}

View File

@ -558,4 +558,5 @@ var rpcHandlers = map[string]RPCHandler{
"pluginStartUpload": {Func: plugin.RpcPluginStartUpload, Params: []string{"filename", "size"}},
"pluginExtract": {Func: plugin.RpcPluginExtract, Params: []string{"filename"}},
"pluginInstall": {Func: plugin.RpcPluginInstall, Params: []string{"name", "version"}},
"pluginList": {Func: plugin.RpcPluginList},
}

View File

@ -0,0 +1,107 @@
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { Button } from "@components/Button";
import { PluginStatus, usePluginStore, useUiStore } from "@/hooks/stores";
import { useCallback, useEffect, useState } from "react";
import { cx } from "@/cva.config";
import UploadPluginModal from "@components/UploadPluginDialog";
function PluginListStatusIcon({ plugin }: { plugin: PluginStatus }) {
let classNames = "bg-slate-500 border-slate-600";
if (plugin.enabled && plugin.status === "running") {
classNames = "bg-green-500 border-green-600";
} else if (plugin.enabled && plugin.status === "stopped") {
classNames = "bg-red-500 border-red-600";
}
return (
<div className="flex items-center px-2">
<div className={cx("h-2 w-2 rounded-full border transition", classNames)} />
</div>
)
}
export default function PluginList() {
const [send] = useJsonRpc();
const [error, setError] = useState<string | null>(null);
const {
isPluginUploadModalOpen,
setIsPluginUploadModalOpen,
setPluginUploadModalView,
plugins,
setPlugins
} = usePluginStore();
const sidebarView = useUiStore(state => state.sidebarView);
const updatePlugins = useCallback(() => {
send("pluginList", {}, resp => {
if ("error" in resp) {
setError(resp.error.message);
return
}
setPlugins(resp.result as PluginStatus[]);
});
}, [send, setPlugins])
useEffect(() => {
// Only update plugins when the sidebar view is the settings view
if (sidebarView !== "system") return;
updatePlugins();
const updateInterval = setInterval(() => {
updatePlugins();
}, 10_000);
return () => clearInterval(updateInterval);
}, [updatePlugins, sidebarView])
return (
<>
<div className="overflow-auto max-h-40 border border-gray-200 dark:border-gray-700 rounded-md">
<ul role="list" className="divide-y divide-gray-200 dark:divide-gray-700 w-full">
{error && <li className="text-red-500 dark:text-red-400">{error}</li>}
{plugins.length === 0 && <li className="text-sm text-center text-gray-500 dark:text-gray-400 py-5">No plugins installed</li>}
{plugins.map(plugin => (
<li key={plugin.name} className="flex items-center justify-between pa-2 py-2 gap-x-2">
<PluginListStatusIcon plugin={plugin} />
<div className="overflow-hidden flex grow flex-col">
<p className="text-base font-semibold text-black dark:text-white">{plugin.name}</p>
<p className="text-xs text-slate-700 dark:text-slate-300 line-clamp-1">
<a href={plugin.homepage} target="_blank" rel="noopener noreferrer" className="font-medium text-blue-600 hover:text-blue-800 dark:text-blue-500 dark:hover:text-blue-400">{plugin.homepage}</a>
</p>
</div>
<div className="flex items-center w-20">
<Button
size="SM"
theme="light"
text="Settings"
onClick={() => console.log("Settings clicked")}
/>
</div>
</li>
))}
</ul>
</div>
<div className="flex items-center gap-x-2">
<Button
size="SM"
theme="primary"
text="Upload Plugin"
onClick={() => {
setPluginUploadModalView("upload");
setIsPluginUploadModalOpen(true)
}}
/>
<UploadPluginModal
open={isPluginUploadModalOpen}
setOpen={(open) => {
setIsPluginUploadModalOpen(open);
if (!open) {
updatePlugins();
}
}}
/>
</div>
</>
);
}

View File

@ -4,7 +4,6 @@ import {
useSettingsStore,
useUiStore,
useUpdateStore,
usePluginStore,
} from "@/hooks/stores";
import { Checkbox } from "@components/Checkbox";
import { Button, LinkButton } from "@components/Button";
@ -26,7 +25,7 @@ import LocalAuthPasswordDialog from "@/components/LocalAuthPasswordDialog";
import { LocalDevice } from "@routes/devices.$id";
import { useRevalidator } from "react-router-dom";
import { ShieldCheckIcon } from "@heroicons/react/20/solid";
import UploadPluginModal from "@components/UploadPluginDialog";
import PluginList from "@components/PluginList";
export function SettingsItem({
title,
@ -253,8 +252,6 @@ export default function SettingsSidebar() {
}
};
const {isPluginUploadModalOpen, setIsPluginUploadModalOpen, setPluginUploadModalView} = usePluginStore();
useEffect(() => {
getCloudState();
@ -752,40 +749,7 @@ export default function SettingsSidebar() {
title="Plugins"
description="Manage installed plugins and their settings"
/>
<ul role="list" className="divide-y divide-gray-200 dark:divide-gray-700 w-full">
<li className="flex items-center justify-between pa-2 gap-x-2">
<div className="flex items-center px-2">
<div className="h-2 w-2 rounded-full border transition bg-green-500 border-green-600" />
</div>
<div className="overflow-hidden flex grow flex-col space-y-1">
<p className="text-base font-semibold text-black dark:text-white">Tailscale</p>
<p className="text-xs text-slate-700 dark:text-slate-300 line-clamp-1">https://github.com/tutman96/jetkvm-plugin-tailscale</p>
</div>
<div className="flex items-center w-20">
<Button
size="SM"
theme="light"
text="Settings"
onClick={() => console.log("Settings clicked")}
/>
</div>
</li>
</ul>
<div className="flex items-center gap-x-2">
<Button
size="SM"
theme="primary"
text="Upload Plugin"
onClick={() => {
setPluginUploadModalView("upload");
setIsPluginUploadModalOpen(true)
}}
/>
<UploadPluginModal
open={isPluginUploadModalOpen}
setOpen={setIsPluginUploadModalOpen}
/>
</div>
<PluginList />
</div>
<div className="h-[1px] w-full bg-slate-800/10 dark:bg-slate-300/20" />
<div className="pb-2 space-y-4">

View File

@ -537,6 +537,11 @@ export interface PluginManifest {
homepage: string;
}
export interface PluginStatus extends PluginManifest {
enabled: boolean;
status: "stopped" | "running";
}
interface PluginState {
isPluginUploadModalOpen: boolean;
setIsPluginUploadModalOpen: (isOpen: boolean) => void;
@ -549,6 +554,9 @@ interface PluginState {
pluginUploadModalView: "upload" | "install";
setPluginUploadModalView: (view: PluginState["pluginUploadModalView"]) => void;
plugins: PluginStatus[];
setPlugins: (plugins: PluginStatus[]) => void;
}
export const usePluginStore = create<PluginState>(set => ({
@ -563,4 +571,7 @@ export const usePluginStore = create<PluginState>(set => ({
pluginUploadModalView: "upload",
setPluginUploadModalView: view => set({ pluginUploadModalView: view }),
plugins: [],
setPlugins: plugins => set({ plugins }),
}));