mirror of https://github.com/jetkvm/kvm.git
Implement pluginList RPC and associated UI
This commit is contained in:
parent
00fdbafeb7
commit
3853b58613
|
@ -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() {
|
||||
|
|
|
@ -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]
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
@ -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},
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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">
|
||||
|
|
|
@ -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 }),
|
||||
}));
|
||||
|
|
Loading…
Reference in New Issue