mirror of https://github.com/jetkvm/kvm.git
				
				
				
			wip: Plugin RPC with status reporting to the UI
This commit is contained in:
		
							parent
							
								
									0b3cd59e36
								
							
						
					
					
						commit
						e61decfb33
					
				|  | @ -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, | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
|  | @ -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"` | ||||||
|  |  | ||||||
|  | @ -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 |  | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | @ -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"` | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -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={{ | ||||||
|  |  | ||||||
|  | @ -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"> | ||||||
|  |  | ||||||
|  | @ -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> | ||||||
|  |   ); | ||||||
|  | } | ||||||
|  | @ -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 { | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue