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"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
const databaseFile = pluginsFolder + "/plugins.json"
|
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{}
|
var pluginDatabase = PluginDatabase{}
|
||||||
|
|
||||||
func init() {
|
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
|
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) {
|
func readManifest(extractFolder string) (*PluginManifest, error) {
|
||||||
manifestPath := path.Join(extractFolder, "manifest.json")
|
manifestPath := path.Join(extractFolder, "manifest.json")
|
||||||
manifestFile, err := os.Open(manifestPath)
|
manifestFile, err := os.Open(manifestPath)
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import "sync"
|
|
||||||
|
|
||||||
type PluginManifest struct {
|
type PluginManifest struct {
|
||||||
ManifestVersion string `json:"manifest_version"`
|
ManifestVersion string `json:"manifest_version"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
@ -12,19 +10,8 @@ type PluginManifest struct {
|
||||||
SystemMinVersion string `json:"system_min_version,omitempty"`
|
SystemMinVersion string `json:"system_min_version,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PluginInstall struct {
|
type PluginStatus struct {
|
||||||
Enabled bool `json:"enabled"`
|
PluginManifest
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
// Current active version of the plugin
|
Status string `json:"status"`
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -558,4 +558,5 @@ var rpcHandlers = map[string]RPCHandler{
|
||||||
"pluginStartUpload": {Func: plugin.RpcPluginStartUpload, Params: []string{"filename", "size"}},
|
"pluginStartUpload": {Func: plugin.RpcPluginStartUpload, Params: []string{"filename", "size"}},
|
||||||
"pluginExtract": {Func: plugin.RpcPluginExtract, Params: []string{"filename"}},
|
"pluginExtract": {Func: plugin.RpcPluginExtract, Params: []string{"filename"}},
|
||||||
"pluginInstall": {Func: plugin.RpcPluginInstall, Params: []string{"name", "version"}},
|
"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,
|
useSettingsStore,
|
||||||
useUiStore,
|
useUiStore,
|
||||||
useUpdateStore,
|
useUpdateStore,
|
||||||
usePluginStore,
|
|
||||||
} from "@/hooks/stores";
|
} from "@/hooks/stores";
|
||||||
import { Checkbox } from "@components/Checkbox";
|
import { Checkbox } from "@components/Checkbox";
|
||||||
import { Button, LinkButton } from "@components/Button";
|
import { Button, LinkButton } from "@components/Button";
|
||||||
|
@ -26,7 +25,7 @@ import LocalAuthPasswordDialog from "@/components/LocalAuthPasswordDialog";
|
||||||
import { LocalDevice } from "@routes/devices.$id";
|
import { LocalDevice } from "@routes/devices.$id";
|
||||||
import { useRevalidator } from "react-router-dom";
|
import { useRevalidator } from "react-router-dom";
|
||||||
import { ShieldCheckIcon } from "@heroicons/react/20/solid";
|
import { ShieldCheckIcon } from "@heroicons/react/20/solid";
|
||||||
import UploadPluginModal from "@components/UploadPluginDialog";
|
import PluginList from "@components/PluginList";
|
||||||
|
|
||||||
export function SettingsItem({
|
export function SettingsItem({
|
||||||
title,
|
title,
|
||||||
|
@ -253,8 +252,6 @@ export default function SettingsSidebar() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const {isPluginUploadModalOpen, setIsPluginUploadModalOpen, setPluginUploadModalView} = usePluginStore();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getCloudState();
|
getCloudState();
|
||||||
|
|
||||||
|
@ -752,40 +749,7 @@ export default function SettingsSidebar() {
|
||||||
title="Plugins"
|
title="Plugins"
|
||||||
description="Manage installed plugins and their settings"
|
description="Manage installed plugins and their settings"
|
||||||
/>
|
/>
|
||||||
<ul role="list" className="divide-y divide-gray-200 dark:divide-gray-700 w-full">
|
<PluginList />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="h-[1px] w-full bg-slate-800/10 dark:bg-slate-300/20" />
|
<div className="h-[1px] w-full bg-slate-800/10 dark:bg-slate-300/20" />
|
||||||
<div className="pb-2 space-y-4">
|
<div className="pb-2 space-y-4">
|
||||||
|
|
|
@ -537,6 +537,11 @@ export interface PluginManifest {
|
||||||
homepage: string;
|
homepage: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PluginStatus extends PluginManifest {
|
||||||
|
enabled: boolean;
|
||||||
|
status: "stopped" | "running";
|
||||||
|
}
|
||||||
|
|
||||||
interface PluginState {
|
interface PluginState {
|
||||||
isPluginUploadModalOpen: boolean;
|
isPluginUploadModalOpen: boolean;
|
||||||
setIsPluginUploadModalOpen: (isOpen: boolean) => void;
|
setIsPluginUploadModalOpen: (isOpen: boolean) => void;
|
||||||
|
@ -549,6 +554,9 @@ interface PluginState {
|
||||||
|
|
||||||
pluginUploadModalView: "upload" | "install";
|
pluginUploadModalView: "upload" | "install";
|
||||||
setPluginUploadModalView: (view: PluginState["pluginUploadModalView"]) => void;
|
setPluginUploadModalView: (view: PluginState["pluginUploadModalView"]) => void;
|
||||||
|
|
||||||
|
plugins: PluginStatus[];
|
||||||
|
setPlugins: (plugins: PluginStatus[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const usePluginStore = create<PluginState>(set => ({
|
export const usePluginStore = create<PluginState>(set => ({
|
||||||
|
@ -563,4 +571,7 @@ export const usePluginStore = create<PluginState>(set => ({
|
||||||
|
|
||||||
pluginUploadModalView: "upload",
|
pluginUploadModalView: "upload",
|
||||||
setPluginUploadModalView: view => set({ pluginUploadModalView: view }),
|
setPluginUploadModalView: view => set({ pluginUploadModalView: view }),
|
||||||
|
|
||||||
|
plugins: [],
|
||||||
|
setPlugins: plugins => set({ plugins }),
|
||||||
}));
|
}));
|
||||||
|
|
Loading…
Reference in New Issue