wip: Plugin RPC with status reporting to the UI

This commit is contained in:
tutman96 2025-01-05 23:38:54 +00:00
parent 0b3cd59e36
commit e61decfb33
9 changed files with 412 additions and 75 deletions

View File

@ -5,31 +5,152 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"log"
"reflect" "reflect"
"sync"
"sync/atomic"
"time"
) )
type JSONRPCServer struct { type JSONRPCServer struct {
writer io.Writer writer io.Writer
handlers map[string]*RPCHandler handlers map[string]*RPCHandler
nextId atomic.Int64
responseChannelsMutex sync.Mutex
responseChannels map[int64]chan JSONRPCResponse
} }
func NewJSONRPCServer(writer io.Writer, handlers map[string]*RPCHandler) *JSONRPCServer { func NewJSONRPCServer(writer io.Writer, handlers map[string]*RPCHandler) *JSONRPCServer {
return &JSONRPCServer{ return &JSONRPCServer{
writer: writer, writer: writer,
handlers: handlers, handlers: handlers,
responseChannels: make(map[int64]chan JSONRPCResponse),
nextId: atomic.Int64{},
} }
} }
func (s *JSONRPCServer) Request(method string, params map[string]interface{}, result interface{}) *JSONRPCResponseError {
id := s.nextId.Add(1)
request := JSONRPCRequest{
JSONRPC: "2.0",
Method: method,
Params: params,
ID: id,
}
requestBytes, err := json.Marshal(request)
if err != nil {
return &JSONRPCResponseError{
Code: -32700,
Message: "Parse error",
Data: err,
}
}
// log.Printf("Sending RPC request: Method=%s, Params=%v, ID=%d", method, params, id)
responseChan := make(chan JSONRPCResponse, 1)
s.responseChannelsMutex.Lock()
s.responseChannels[id] = responseChan
s.responseChannelsMutex.Unlock()
defer func() {
s.responseChannelsMutex.Lock()
delete(s.responseChannels, id)
s.responseChannelsMutex.Unlock()
}()
_, err = s.writer.Write(requestBytes)
if err != nil {
return &JSONRPCResponseError{
Code: -32603,
Message: "Internal error",
Data: err,
}
}
timeout := time.After(5 * time.Second)
select {
case response := <-responseChan:
if response.Error != nil {
return response.Error
}
rawResult, err := json.Marshal(response.Result)
if err != nil {
return &JSONRPCResponseError{
Code: -32603,
Message: "Internal error",
Data: err,
}
}
if err := json.Unmarshal(rawResult, result); err != nil {
return &JSONRPCResponseError{
Code: -32603,
Message: "Internal error",
Data: err,
}
}
return nil
case <-timeout:
return &JSONRPCResponseError{
Code: -32603,
Message: "Internal error",
Data: "timeout waiting for response",
}
}
}
type JSONRPCMessage struct {
Method *string `json:"method,omitempty"`
ID *int64 `json:"id,omitempty"`
}
func (s *JSONRPCServer) HandleMessage(data []byte) error { func (s *JSONRPCServer) HandleMessage(data []byte) error {
var request JSONRPCRequest // Data will either be a JSONRPCRequest or JSONRPCResponse object
err := json.Unmarshal(data, &request) // We need to determine which one it is
var raw JSONRPCMessage
err := json.Unmarshal(data, &raw)
if err != nil { if err != nil {
errorResponse := JSONRPCResponse{ errorResponse := JSONRPCResponse{
JSONRPC: "2.0", JSONRPC: "2.0",
Error: map[string]interface{}{ Error: &JSONRPCResponseError{
"code": -32700, Code: -32700,
"message": "Parse error", Message: "Parse error",
},
ID: 0,
}
return s.writeResponse(errorResponse)
}
if raw.Method == nil && raw.ID != nil {
var resp JSONRPCResponse
if err := json.Unmarshal(data, &resp); err != nil {
fmt.Println("error unmarshalling response", err)
return err
}
s.responseChannelsMutex.Lock()
responseChan, ok := s.responseChannels[*raw.ID]
s.responseChannelsMutex.Unlock()
if ok {
responseChan <- resp
} else {
log.Println("No response channel found for ID", resp.ID)
}
return nil
}
var request JSONRPCRequest
err = json.Unmarshal(data, &request)
if err != nil {
errorResponse := JSONRPCResponse{
JSONRPC: "2.0",
Error: &JSONRPCResponseError{
Code: -32700,
Message: "Parse error",
}, },
ID: 0, ID: 0,
} }
@ -41,9 +162,9 @@ func (s *JSONRPCServer) HandleMessage(data []byte) error {
if !ok { if !ok {
errorResponse := JSONRPCResponse{ errorResponse := JSONRPCResponse{
JSONRPC: "2.0", JSONRPC: "2.0",
Error: map[string]interface{}{ Error: &JSONRPCResponseError{
"code": -32601, Code: -32601,
"message": "Method not found", Message: "Method not found",
}, },
ID: request.ID, ID: request.ID,
} }
@ -54,10 +175,10 @@ func (s *JSONRPCServer) HandleMessage(data []byte) error {
if err != nil { if err != nil {
errorResponse := JSONRPCResponse{ errorResponse := JSONRPCResponse{
JSONRPC: "2.0", JSONRPC: "2.0",
Error: map[string]interface{}{ Error: &JSONRPCResponseError{
"code": -32603, Code: -32603,
"message": "Internal error", Message: "Internal error",
"data": err.Error(), Data: err.Error(),
}, },
ID: request.ID, ID: request.ID,
} }

View File

@ -10,10 +10,16 @@ type JSONRPCRequest struct {
type JSONRPCResponse struct { type JSONRPCResponse struct {
JSONRPC string `json:"jsonrpc"` JSONRPC string `json:"jsonrpc"`
Result interface{} `json:"result,omitempty"` Result interface{} `json:"result,omitempty"`
Error interface{} `json:"error,omitempty"` Error *JSONRPCResponseError `json:"error,omitempty"`
ID interface{} `json:"id"` ID interface{} `json:"id"`
} }
type JSONRPCResponseError struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}
type JSONRPCEvent struct { type JSONRPCEvent struct {
JSONRPC string `json:"jsonrpc"` JSONRPC string `json:"jsonrpc"`
Method string `json:"method"` Method string `json:"method"`

View File

@ -3,7 +3,6 @@ package plugin
import ( import (
"fmt" "fmt"
"log" "log"
"net"
"os" "os"
"os/exec" "os/exec"
"path" "path"
@ -22,7 +21,7 @@ type PluginInstall struct {
manifest *PluginManifest manifest *PluginManifest
runningVersion *string runningVersion *string
processManager *ProcessManager processManager *ProcessManager
rpcListener net.Listener rpcServer *PluginRpcServer
} }
func (p *PluginInstall) GetManifest() (*PluginManifest, error) { func (p *PluginInstall) GetManifest() (*PluginManifest, error) {
@ -54,14 +53,25 @@ func (p *PluginInstall) GetStatus() (*PluginStatus, error) {
Enabled: p.Enabled, Enabled: p.Enabled,
} }
if p.rpcServer != nil && p.rpcServer.status.Status != "disconnected" {
log.Printf("Status from RPC: %v", p.rpcServer.status)
status.Status = p.rpcServer.status.Status
status.Message = p.rpcServer.status.Message
if status.Status == "error" {
status.Message = p.rpcServer.status.Message
}
} else {
status.Status = "stopped" status.Status = "stopped"
if p.processManager != nil { if p.processManager != nil {
status.Status = "running" status.Status = "running"
if p.processManager.LastError != nil { if p.processManager.LastError != nil {
status.Status = "errored" status.Status = "errored"
status.Error = p.processManager.LastError.Error() status.Message = p.processManager.LastError.Error()
} }
} }
log.Printf("Status from process manager: %v", status.Status)
}
return &status, nil return &status, nil
} }
@ -94,8 +104,10 @@ func (p *PluginInstall) ReconcileSubprocess() error {
p.processManager.Disable() p.processManager.Disable()
p.processManager = nil p.processManager = nil
p.runningVersion = nil p.runningVersion = nil
p.rpcListener.Close() err = p.rpcServer.Stop()
p.rpcListener = nil if err != nil {
return fmt.Errorf("failed to stop rpc server: %v", err)
}
} }
if versionShouldBeRunning == "" { if versionShouldBeRunning == "" {
@ -103,25 +115,22 @@ func (p *PluginInstall) ReconcileSubprocess() error {
} }
workingDir := path.Join(pluginsFolder, "working_dirs", p.manifest.Name) workingDir := path.Join(pluginsFolder, "working_dirs", p.manifest.Name)
socketPath := path.Join(workingDir, "plugin.sock")
os.Remove(socketPath)
err = os.MkdirAll(workingDir, 0755) err = os.MkdirAll(workingDir, 0755)
if err != nil { if err != nil {
return fmt.Errorf("failed to create working directory: %v", err) return fmt.Errorf("failed to create working directory: %v", err)
} }
listener, err := net.Listen("unix", socketPath) p.rpcServer = NewPluginRpcServer(p, workingDir)
err = p.rpcServer.Start()
if err != nil { if err != nil {
return fmt.Errorf("failed to listen on socket: %v", err) return fmt.Errorf("failed to start rpc server: %v", err)
} }
p.rpcListener = listener
p.processManager = NewProcessManager(func() *exec.Cmd { p.processManager = NewProcessManager(func() *exec.Cmd {
cmd := exec.Command(manifest.BinaryPath) cmd := exec.Command(manifest.BinaryPath)
cmd.Dir = p.GetExtractedFolder() cmd.Dir = p.GetExtractedFolder()
cmd.Env = append(cmd.Env, cmd.Env = append(cmd.Env,
"JETKVM_PLUGIN_SOCK="+socketPath, "JETKVM_PLUGIN_SOCK="+p.rpcServer.SocketPath(),
"JETKVM_PLUGIN_WORKING_DIR="+workingDir, "JETKVM_PLUGIN_WORKING_DIR="+workingDir,
) )
cmd.Stdout = os.Stdout cmd.Stdout = os.Stdout
@ -147,8 +156,7 @@ func (p *PluginInstall) Shutdown() {
p.runningVersion = nil p.runningVersion = nil
} }
if p.rpcListener != nil { if p.rpcServer != nil {
p.rpcListener.Close() p.rpcServer.Stop()
p.rpcListener = nil
} }
} }

166
internal/plugin/rpc.go Normal file
View File

@ -0,0 +1,166 @@
package plugin
import (
"context"
"errors"
"fmt"
"kvm/internal/jsonrpc"
"log"
"net"
"os"
"path"
"time"
)
type PluginRpcStatus struct {
Status string `json:"status"`
Message string `json:"message,omitempty"`
}
var (
PluginRpcStatusDisconnected = PluginRpcStatus{"disconnected", ""}
PluginRpcStatusLoading = PluginRpcStatus{"loading", ""}
PluginRpcStatusPendingConfiguration = PluginRpcStatus{"pending-configuration", ""}
PluginRpcStatusRunning = PluginRpcStatus{"running", ""}
PluginRpcStatusError = PluginRpcStatus{"error", ""}
)
type PluginRpcServer struct {
install *PluginInstall
workingDir string
listener net.Listener
status PluginRpcStatus
}
func NewPluginRpcServer(install *PluginInstall, workingDir string) *PluginRpcServer {
return &PluginRpcServer{
install: install,
workingDir: workingDir,
status: PluginRpcStatusDisconnected,
}
}
func (s *PluginRpcServer) Start() error {
socketPath := s.SocketPath()
_ = os.Remove(socketPath)
listener, err := net.Listen("unix", socketPath)
if err != nil {
return fmt.Errorf("failed to listen on socket: %v", err)
}
s.listener = listener
s.status = PluginRpcStatusDisconnected
go func() {
for {
conn, err := listener.Accept()
if err != nil {
// If the error indicates the listener is closed, break out
if opErr, ok := err.(*net.OpError); ok && opErr.Err.Error() == "use of closed network connection" {
log.Println("Listener closed, exiting accept loop.")
return
}
log.Printf("Failed to accept connection: %v", err)
continue
}
log.Printf("Accepted plugin rpc connection from %v", conn.RemoteAddr())
go s.handleConnection(conn)
}
}()
return nil
}
func (s *PluginRpcServer) Stop() error {
if s.listener != nil {
s.status = PluginRpcStatusDisconnected
return s.listener.Close()
}
return nil
}
func (s *PluginRpcServer) Status() PluginRpcStatus {
return s.status
}
func (s *PluginRpcServer) SocketPath() string {
return path.Join(s.workingDir, "plugin.sock")
}
func (s *PluginRpcServer) handleConnection(conn net.Conn) {
rpcserver := jsonrpc.NewJSONRPCServer(conn, map[string]*jsonrpc.RPCHandler{})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go s.handleRpcStatus(ctx, rpcserver)
// Read from the conn and write into rpcserver.HandleMessage
buf := make([]byte, 65*1024)
for {
// TODO: if read 65k bytes, then likey there is more data to read... figure out how to handle this
n, err := conn.Read(buf)
if err != nil {
log.Printf("Failed to read message: %v", err)
if errors.Is(err, net.ErrClosed) {
s.status = PluginRpcStatusDisconnected
} else {
s.status = PluginRpcStatusError
s.status.Message = fmt.Errorf("failed to read message: %v", err).Error()
}
break
}
err = rpcserver.HandleMessage(buf[:n])
if err != nil {
log.Printf("Failed to handle message: %v", err)
s.status = PluginRpcStatusError
s.status.Message = fmt.Errorf("failed to handle message: %v", err).Error()
continue
}
}
}
func (s *PluginRpcServer) handleRpcStatus(ctx context.Context, rpcserver *jsonrpc.JSONRPCServer) {
// log.Printf("Plugin rpc server started. Getting supported methods...")
// supportedMethodsResponse, err := rpcserver.Request("getPluginSupportedMethods", map[string]interface{}{})
// if err != nil {
// log.Printf("Failed to get supported methods: %v", err)
// s.status = PluginRpcStatusError
// s.status.Message = fmt.Errorf("error getting supported methods: %v", err).Error()
// }
// if supportedMethodsResponse.Error != nil {
// log.Printf("Failed to get supported methods: %v", supportedMethodsResponse.Error)
// s.status = PluginRpcStatusError
// s.status.Message = fmt.Errorf("error getting supported methods: %v", supportedMethodsResponse.Error).Error()
// }
// log.Printf("Plugin has supported methods: %v", supportedMethodsResponse.Result)
ticker := time.NewTicker(1 * time.Second)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
var statusResponse PluginRpcStatus
err := rpcserver.Request("getPluginStatus", map[string]interface{}{}, &statusResponse)
if err != nil {
log.Printf("Failed to get status: %v", err)
if err, ok := err.Data.(error); ok && errors.Is(err, net.ErrClosed) {
s.status = PluginRpcStatusDisconnected
break
}
s.status = PluginRpcStatusError
s.status.Message = fmt.Errorf("error getting status: %v", err).Error()
continue
}
s.status = statusResponse
}
}
}

View File

@ -14,5 +14,5 @@ type PluginStatus struct {
PluginManifest PluginManifest
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Status string `json:"status"` Status string `json:"status"`
Error string `json:"error,omitempty"` Message string `json:"message,omitempty"`
} }

View File

@ -1,13 +1,15 @@
import { PluginStatus } from "@/hooks/stores"; import { PluginStatus } from "@/hooks/stores";
import Modal from "@components/Modal"; import Modal from "@components/Modal";
import AutoHeight from "@components/AutoHeight"; import AutoHeight from "@components/AutoHeight";
import { GridCard } from "@components/Card"; import Card, { GridCard } from "@components/Card";
import LogoBlueIcon from "@/assets/logo-blue.svg"; import LogoBlueIcon from "@/assets/logo-blue.svg";
import LogoWhiteIcon from "@/assets/logo-white.svg"; import LogoWhiteIcon from "@/assets/logo-white.svg";
import { ViewHeader } from "./MountMediaDialog"; import { ViewHeader } from "./MountMediaDialog";
import { Button } from "./Button"; import { Button } from "./Button";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { PluginStatusIcon } from "./PluginStatusIcon";
import { cx } from "@/cva.config";
export default function PluginConfigureModal({ export default function PluginConfigureModal({
plugin, plugin,
@ -77,6 +79,8 @@ function Dialog({ plugin, setOpen }: { plugin: PluginStatus | null, setOpen: (op
<GridCard cardClassName="relative w-full text-left pointer-events-auto"> <GridCard cardClassName="relative w-full text-left pointer-events-auto">
<div className="p-4"> <div className="p-4">
<div className="flex flex-col items-start justify-start space-y-4 text-left"> <div className="flex flex-col items-start justify-start space-y-4 text-left">
<div className="flex justify-between w-full">
<div>
<img <img
src={LogoBlueIcon} src={LogoBlueIcon}
alt="JetKVM Logo" alt="JetKVM Logo"
@ -87,6 +91,16 @@ function Dialog({ plugin, setOpen }: { plugin: PluginStatus | null, setOpen: (op
alt="JetKVM Logo" alt="JetKVM Logo"
className="h-[24px] dark:block hidden dark:!mt-0" className="h-[24px] dark:block hidden dark:!mt-0"
/> />
</div>
<div className="flex items-center">
{plugin && <>
<p className="text-sm text-gray-500 dark:text-gray-400 inline-block">
{plugin.status}
</p>
<PluginStatusIcon plugin={plugin} />
</>}
</div>
</div>
<div className="w-full space-y-4"> <div className="w-full space-y-4">
<div className="flex items-center justify-between w-full"> <div className="flex items-center justify-between w-full">
<ViewHeader title="Plugin Configuration" description={`Configure the ${plugin?.name} plugin`} /> <ViewHeader title="Plugin Configuration" description={`Configure the ${plugin?.name} plugin`} />
@ -104,6 +118,8 @@ function Dialog({ plugin, setOpen }: { plugin: PluginStatus | null, setOpen: (op
</div> </div>
</div> </div>
<div className="h-[1px] w-full bg-slate-800/10 dark:bg-slate-300/20" />
<div <div
className="space-y-2 opacity-0 animate-fadeIn" className="space-y-2 opacity-0 animate-fadeIn"
style={{ style={{
@ -111,10 +127,25 @@ function Dialog({ plugin, setOpen }: { plugin: PluginStatus | null, setOpen: (op
}} }}
> >
{error && <p className="text-red-500 dark:text-red-400">{error}</p>} {error && <p className="text-red-500 dark:text-red-400">{error}</p>}
{plugin?.message && (
<>
<p className="text-sm text-gray-500 dark:text-gray-400">
Plugin message:
</p>
<Card className={cx(
"text-gray-500 dark:text-gray-400 p-4 border",
plugin.status === "errored" && "border-red-200 bg-red-50 text-red-800 dark:text-red-400",
)}>
{plugin.message}
</Card>
</>
)}
<p className="text-sm text-gray-500 dark:text-gray-400 py-10"> <p className="text-sm text-gray-500 dark:text-gray-400 py-10">
TODO: Plugin configuration goes here TODO: Plugin configuration goes here
</p> </p>
<div className="h-[1px] w-full bg-slate-800/10 dark:bg-slate-300/20" />
<div <div
className="flex items-end w-full opacity-0 animate-fadeIn" className="flex items-end w-full opacity-0 animate-fadeIn"
style={{ style={{

View File

@ -2,24 +2,9 @@ import { useJsonRpc } from "@/hooks/useJsonRpc";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import { PluginStatus, usePluginStore, useUiStore } from "@/hooks/stores"; import { PluginStatus, usePluginStore, useUiStore } from "@/hooks/stores";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { cx } from "@/cva.config";
import UploadPluginModal from "@components/UploadPluginDialog"; import UploadPluginModal from "@components/UploadPluginDialog";
import PluginConfigureModal from "@components/PluginConfigureDialog"; import PluginConfigureModal from "@components/PluginConfigureDialog";
import { PluginStatusIcon } from "./PluginStatusIcon";
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 === "errored") {
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() { export default function PluginList() {
const [send] = useJsonRpc(); const [send] = useJsonRpc();
@ -45,20 +30,21 @@ export default function PluginList() {
setError(resp.error.message); setError(resp.error.message);
return return
} }
console.log('pluginList', resp.result);
setPlugins(resp.result as PluginStatus[]); setPlugins(resp.result as PluginStatus[]);
}); });
}, [send, setPlugins]) }, [send, setPlugins])
useEffect(() => { useEffect(() => {
// Only update plugins when the sidebar view is the settings view // Only update plugins when the sidebar view is the settings view
if (sidebarView !== "system") return; if (sidebarView !== "system" && !pluginConfigureModalOpen) return;
updatePlugins(); updatePlugins();
const updateInterval = setInterval(() => { const updateInterval = setInterval(() => {
updatePlugins(); updatePlugins();
}, 10_000); }, 10_000);
return () => clearInterval(updateInterval); return () => clearInterval(updateInterval);
}, [updatePlugins, sidebarView]) }, [updatePlugins, sidebarView, pluginConfigureModalOpen])
return ( return (
<> <>
@ -68,7 +54,7 @@ export default function PluginList() {
{plugins.length === 0 && <li className="text-sm text-center text-gray-500 dark:text-gray-400 py-5">No plugins installed</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 => ( {plugins.map(plugin => (
<li key={plugin.name} className="flex items-center justify-between pa-2 py-2 gap-x-2"> <li key={plugin.name} className="flex items-center justify-between pa-2 py-2 gap-x-2">
<PluginListStatusIcon plugin={plugin} /> <PluginStatusIcon plugin={plugin} />
<div className="overflow-hidden flex grow flex-col"> <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-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"> <p className="text-xs text-slate-700 dark:text-slate-300 line-clamp-1">
@ -99,7 +85,7 @@ export default function PluginList() {
updatePlugins(); updatePlugins();
} }
}} }}
plugin={configuringPlugin} plugin={plugins.find(p => p.name == configuringPlugin?.name) ?? null}
/> />
<div className="flex items-center gap-x-2"> <div className="flex items-center gap-x-2">

View File

@ -0,0 +1,19 @@
import { cx } from "@/cva.config";
import { PluginStatus } from "@/hooks/stores";
export function PluginStatusIcon({ 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 === "pending-configuration") {
classNames = "bg-yellow-500 border-yellow-600";
} else if (plugin.enabled && plugin.status === "errored") {
classNames = "bg-red-500 border-red-600";
}
return (
<div className="flex items-center px-2" title={plugin.status}>
<div className={cx("h-2 w-2 rounded-full border transition", classNames)} />
</div>
);
}

View File

@ -539,8 +539,8 @@ export interface PluginManifest {
export interface PluginStatus extends PluginManifest { export interface PluginStatus extends PluginManifest {
enabled: boolean; enabled: boolean;
status: "stopped" | "running" | "errored"; status: "stopped" | "running" | "loading" | "pending-configuration" | "errored";
error?: string; message?: string;
} }
interface PluginState { interface PluginState {