Added UI for plugin system and upnp example

- Added wip UI for plugin tag system
- Added upnp port forwarder plugin
- Added error and fatal printout for plugins
- Optimized UI flow for plugin context window
- Added dev web server for plugin development purpose
This commit is contained in:
Toby Chui
2025-03-15 21:02:44 +08:00
parent 4a99afa2f0
commit f8270e46c2
34 changed files with 3143 additions and 14 deletions

View File

@ -29,6 +29,7 @@ func RegisterHTTPProxyAPIs(authRouter *auth.RouterDef) {
authRouter.HandleFunc("/api/proxy/status", ReverseProxyStatus)
authRouter.HandleFunc("/api/proxy/toggle", ReverseProxyToggleRuleSet)
authRouter.HandleFunc("/api/proxy/list", ReverseProxyList)
authRouter.HandleFunc("/api/proxy/listTags", ReverseProxyListTags)
authRouter.HandleFunc("/api/proxy/detail", ReverseProxyListDetail)
authRouter.HandleFunc("/api/proxy/edit", ReverseProxyHandleEditEndpoint)
authRouter.HandleFunc("/api/proxy/setAlias", ReverseProxyHandleAlias)
@ -225,6 +226,11 @@ func RegisterPluginAPIs(authRouter *auth.RouterDef) {
authRouter.HandleFunc("/api/plugins/enable", pluginManager.HandleEnablePlugin)
authRouter.HandleFunc("/api/plugins/disable", pluginManager.HandleDisablePlugin)
authRouter.HandleFunc("/api/plugins/icon", pluginManager.HandleLoadPluginIcon)
authRouter.HandleFunc("/api/plugins/groups/list", pluginManager.HandleListPluginGroups)
authRouter.HandleFunc("/api/plugins/groups/add", pluginManager.HandleAddPluginToGroup)
authRouter.HandleFunc("/api/plugins/groups/remove", pluginManager.HandleRemovePluginFromGroup)
authRouter.HandleFunc("/api/plugins/groups/deleteTag", pluginManager.HandleRemovePluginGroup)
}
// Register the APIs for Auth functions, due to scoping issue some functions are defined here

101
src/mod/plugins/groups.go Normal file
View File

@ -0,0 +1,101 @@
package plugins
import (
"encoding/json"
"errors"
"os"
"imuslab.com/zoraxy/mod/plugins/zoraxy_plugin"
)
// ListPluginGroups returns a map of plugin groups
func (m *Manager) ListPluginGroups() map[string][]string {
pluginGroup := map[string][]string{}
m.Options.pluginGroupsMutex.RLock()
for k, v := range m.Options.PluginGroups {
pluginGroup[k] = append([]string{}, v...)
}
m.Options.pluginGroupsMutex.RUnlock()
return pluginGroup
}
// AddPluginToGroup adds a plugin to a group
func (m *Manager) AddPluginToGroup(tag, pluginID string) error {
//Check if the plugin exists
plugin, ok := m.LoadedPlugins[pluginID]
if !ok {
return errors.New("plugin not found")
}
//Check if the plugin is a router type plugin
if plugin.Spec.Type != zoraxy_plugin.PluginType_Router {
return errors.New("plugin is not a router type plugin")
}
m.Options.pluginGroupsMutex.Lock()
//Check if the tag exists
_, ok = m.Options.PluginGroups[tag]
if !ok {
m.Options.PluginGroups[tag] = []string{pluginID}
m.Options.pluginGroupsMutex.Unlock()
return nil
}
//Add the plugin to the group
m.Options.PluginGroups[tag] = append(m.Options.PluginGroups[tag], pluginID)
m.Options.pluginGroupsMutex.Unlock()
return nil
}
// RemovePluginFromGroup removes a plugin from a group
func (m *Manager) RemovePluginFromGroup(tag, pluginID string) error {
m.Options.pluginGroupsMutex.Lock()
defer m.Options.pluginGroupsMutex.Unlock()
//Check if the tag exists
_, ok := m.Options.PluginGroups[tag]
if !ok {
return errors.New("tag not found")
}
//Remove the plugin from the group
pluginList := m.Options.PluginGroups[tag]
for i, id := range pluginList {
if id == pluginID {
pluginList = append(pluginList[:i], pluginList[i+1:]...)
m.Options.PluginGroups[tag] = pluginList
return nil
}
}
return errors.New("plugin not found")
}
// RemovePluginGroup removes a plugin group
func (m *Manager) RemovePluginGroup(tag string) error {
m.Options.pluginGroupsMutex.Lock()
defer m.Options.pluginGroupsMutex.Unlock()
_, ok := m.Options.PluginGroups[tag]
if !ok {
return errors.New("tag not found")
}
delete(m.Options.PluginGroups, tag)
return nil
}
// SavePluginGroupsFromFile loads plugin groups from a file
func (m *Manager) SavePluginGroupsToFile() error {
m.Options.pluginGroupsMutex.RLock()
pluginGroupsCopy := make(map[string][]string)
for k, v := range m.Options.PluginGroups {
pluginGroupsCopy[k] = append([]string{}, v...)
}
m.Options.pluginGroupsMutex.RUnlock()
//Write to file
js, _ := json.Marshal(pluginGroupsCopy)
err := os.WriteFile(m.Options.PluginGroupsConfig, js, 0644)
if err != nil {
return err
}
return nil
}

View File

@ -11,6 +11,146 @@ import (
"imuslab.com/zoraxy/mod/utils"
)
/* Plugin Groups */
// HandleListPluginGroups handles the request to list all plugin groups
func (m *Manager) HandleListPluginGroups(w http.ResponseWriter, r *http.Request) {
targetTag, err := utils.GetPara(r, "tag")
if err != nil {
//List all tags
pluginGroups := m.ListPluginGroups()
js, _ := json.Marshal(pluginGroups)
utils.SendJSONResponse(w, string(js))
} else {
//List the plugins under the tag
m.tagPluginListMutex.RLock()
plugins, ok := m.tagPluginList[targetTag]
m.tagPluginListMutex.RUnlock()
if !ok {
//Return empty array
js, _ := json.Marshal([]string{})
utils.SendJSONResponse(w, string(js))
return
}
//Sort the plugin by its name
sort.Slice(plugins, func(i, j int) bool {
return plugins[i].Spec.Name < plugins[j].Spec.Name
})
js, err := json.Marshal(plugins)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
utils.SendJSONResponse(w, string(js))
}
}
// HandleAddPluginToGroup handles the request to add a plugin to a group
func (m *Manager) HandleAddPluginToGroup(w http.ResponseWriter, r *http.Request) {
tag, err := utils.PostPara(r, "tag")
if err != nil {
utils.SendErrorResponse(w, "tag not found")
return
}
pluginID, err := utils.PostPara(r, "plugin_id")
if err != nil {
utils.SendErrorResponse(w, "plugin_id not found")
return
}
//Check if plugin exists
_, err = m.GetPluginByID(pluginID)
if err != nil {
utils.SendErrorResponse(w, err.Error())
return
}
//Add the plugin to the group
err = m.AddPluginToGroup(tag, pluginID)
if err != nil {
utils.SendErrorResponse(w, err.Error())
return
}
//Save the plugin groups to file
err = m.SavePluginGroupsToFile()
if err != nil {
utils.SendErrorResponse(w, err.Error())
return
}
//Update the radix tree mapping
m.UpdateTagsToPluginMaps()
utils.SendOK(w)
}
// HandleRemovePluginFromGroup handles the request to remove a plugin from a group
func (m *Manager) HandleRemovePluginFromGroup(w http.ResponseWriter, r *http.Request) {
tag, err := utils.PostPara(r, "tag")
if err != nil {
utils.SendErrorResponse(w, "tag not found")
return
}
pluginID, err := utils.PostPara(r, "plugin_id")
if err != nil {
utils.SendErrorResponse(w, "plugin_id not found")
return
}
//Remove the plugin from the group
err = m.RemovePluginFromGroup(tag, pluginID)
if err != nil {
utils.SendErrorResponse(w, err.Error())
return
}
//Save the plugin groups to file
err = m.SavePluginGroupsToFile()
if err != nil {
utils.SendErrorResponse(w, err.Error())
return
}
//Update the radix tree mapping
m.UpdateTagsToPluginMaps()
utils.SendOK(w)
}
// HandleRemovePluginGroup handles the request to remove a plugin group
func (m *Manager) HandleRemovePluginGroup(w http.ResponseWriter, r *http.Request) {
tag, err := utils.PostPara(r, "tag")
if err != nil {
utils.SendErrorResponse(w, "tag not found")
return
}
//Remove the plugin group
err = m.RemovePluginGroup(tag)
if err != nil {
utils.SendErrorResponse(w, err.Error())
return
}
//Save the plugin groups to file
err = m.SavePluginGroupsToFile()
if err != nil {
utils.SendErrorResponse(w, err.Error())
return
}
//Update the radix tree mapping
m.UpdateTagsToPluginMaps()
utils.SendOK(w)
}
/* Plugin APIs */
// HandleListPlugins handles the request to list all loaded plugins
func (m *Manager) HandleListPlugins(w http.ResponseWriter, r *http.Request) {
plugins, err := m.ListLoadedPlugins()

View File

@ -2,7 +2,6 @@ package plugins
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
@ -53,10 +52,16 @@ func (m *Manager) StartPlugin(pluginID string) error {
return err
}
stdErrPipe, err := cmd.StderrPipe()
if err != nil {
return err
}
if err := cmd.Start(); err != nil {
return err
}
//Create a goroutine to handle the STDOUT of the plugin
go func() {
buf := make([]byte, 1)
lineBuf := ""
@ -82,6 +87,48 @@ func (m *Manager) StartPlugin(pluginID string) error {
}
}()
//Create a goroutine to handle the STDERR of the plugin
go func() {
buf := make([]byte, 1)
lineBuf := ""
for {
n, err := stdErrPipe.Read(buf)
if n > 0 {
lineBuf += string(buf[:n])
for {
if idx := strings.IndexByte(lineBuf, '\n'); idx != -1 {
m.handlePluginSTDERR(pluginID, lineBuf[:idx])
lineBuf = lineBuf[idx+1:]
} else {
break
}
}
}
if err != nil {
if err != io.EOF {
m.handlePluginSTDERR(pluginID, lineBuf) // handle any remaining data
}
break
}
}
}()
//Create a goroutine to wait for the plugin to exit
go func() {
err := cmd.Wait()
if err != nil {
//In theory this should not happen except for a crash
m.Log("plugin "+thisPlugin.Spec.ID+" encounted a fatal error. Disabling plugin...", err)
//Set the plugin state to disabled
thisPlugin.Enabled = false
//Generate a new static forwarder radix tree
m.UpdateTagsToPluginMaps()
return
}
}()
//Create a UI forwarder if the plugin has UI
err = m.StartUIHandlerForPlugin(thisPlugin, pluginConfiguration.Port)
if err != nil {
@ -119,8 +166,6 @@ func (m *Manager) StartUIHandlerForPlugin(targetPlugin *Plugin, pluginListeningP
return err
}
fmt.Println("DEBUG: Requesting Plugin UI URL: ", pluginUIURL)
// Generate the plugin subpath to be trimmed
pluginMatchingPath := filepath.ToSlash(filepath.Join("/plugin.ui/"+targetPlugin.Spec.ID+"/")) + "/"
if targetPlugin.Spec.UIPath != "" {
@ -148,6 +193,19 @@ func (m *Manager) handlePluginSTDOUT(pluginID string, line string) {
m.Log("["+thisPlugin.Spec.Name+":"+strconv.Itoa(processID)+"] "+line, nil)
}
func (m *Manager) handlePluginSTDERR(pluginID string, line string) {
thisPlugin, err := m.GetPluginByID(pluginID)
if err != nil {
return
}
processID := -1
if thisPlugin.process != nil && thisPlugin.process.Process != nil {
// Get the process ID of the plugin
processID = thisPlugin.process.Process.Pid
}
m.Log("["+thisPlugin.Spec.Name+":"+strconv.Itoa(processID)+"] "+line, nil)
}
// StopPlugin stops a plugin, it is garanteed that the plugin is stopped after this function
func (m *Manager) StopPlugin(pluginID string) error {
thisPlugin, err := m.GetPluginByID(pluginID)

View File

@ -82,6 +82,7 @@ func (m *Manager) LoadPluginsFromDisk() error {
m.Log("Loaded plugin: "+thisPlugin.Spec.Name, nil)
// If the plugin was enabled, start it now
fmt.Println("Plugin enabled state", m.GetPluginPreviousEnableState(thisPlugin.Spec.ID))
if m.GetPluginPreviousEnableState(thisPlugin.Spec.ID) {
err = m.StartPlugin(thisPlugin.Spec.ID)
if err != nil {
@ -118,11 +119,11 @@ func (m *Manager) GetPluginByID(pluginID string) (*Plugin, error) {
// EnablePlugin enables a plugin
func (m *Manager) EnablePlugin(pluginID string) error {
m.Options.Database.Write("plugins", pluginID, true)
err := m.StartPlugin(pluginID)
if err != nil {
return err
}
m.Options.Database.Write("plugins", pluginID, true)
//Generate the static forwarder radix tree
m.UpdateTagsToPluginMaps()
return nil

View File

@ -13,8 +13,6 @@ func (m *Manager) HandleRoute(w http.ResponseWriter, r *http.Request, tags []str
return false
}
return false
//For each tag, check if the request path matches the static capture path //Wait group for the goroutines
var staticRoutehandlers []*Plugin //The handler for the request, can be multiple plugins
var longestPrefixAcrossAlltags string = "" //The longest prefix across all tags

View File

@ -0,0 +1,145 @@
package zoraxy_plugin
import (
"fmt"
"net/http"
"os"
"strings"
"time"
)
type PluginUiDebugRouter struct {
PluginID string //The ID of the plugin
TargetDir string //The directory where the UI files are stored
HandlerPrefix string //The prefix of the handler used to route this router, e.g. /ui
EnableDebug bool //Enable debug mode
terminateHandler func() //The handler to be called when the plugin is terminated
}
// NewPluginFileSystemUIRouter creates a new PluginUiRouter with file system
// The targetDir is the directory where the UI files are stored (e.g. ./www)
// The handlerPrefix is the prefix of the handler used to route this router
// The handlerPrefix should start with a slash (e.g. /ui) that matches the http.Handle path
// All prefix should not end with a slash
func NewPluginFileSystemUIRouter(pluginID string, targetDir string, handlerPrefix string) *PluginUiDebugRouter {
//Make sure all prefix are in /prefix format
if !strings.HasPrefix(handlerPrefix, "/") {
handlerPrefix = "/" + handlerPrefix
}
handlerPrefix = strings.TrimSuffix(handlerPrefix, "/")
//Return the PluginUiRouter
return &PluginUiDebugRouter{
PluginID: pluginID,
TargetDir: targetDir,
HandlerPrefix: handlerPrefix,
}
}
func (p *PluginUiDebugRouter) populateCSRFToken(r *http.Request, fsHandler http.Handler) http.Handler {
//Get the CSRF token from header
csrfToken := r.Header.Get("X-Zoraxy-Csrf")
if csrfToken == "" {
csrfToken = "missing-csrf-token"
}
//Return the middleware
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check if the request is for an HTML file
if strings.HasSuffix(r.URL.Path, ".html") {
//Read the target file from file system
targetFilePath := strings.TrimPrefix(r.URL.Path, "/")
targetFilePath = p.TargetDir + "/" + targetFilePath
targetFilePath = strings.TrimPrefix(targetFilePath, "/")
targetFileContent, err := os.ReadFile(targetFilePath)
if err != nil {
http.Error(w, "File not found", http.StatusNotFound)
return
}
body := string(targetFileContent)
body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken)
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
w.Write([]byte(body))
return
} else if strings.HasSuffix(r.URL.Path, "/") {
//Check if the request is for a directory
//Check if the directory has an index.html file
targetFilePath := strings.TrimPrefix(r.URL.Path, "/")
targetFilePath = p.TargetDir + "/" + targetFilePath + "index.html"
targetFilePath = strings.TrimPrefix(targetFilePath, "/")
if _, err := os.Stat(targetFilePath); err == nil {
//Serve the index.html file
targetFileContent, err := os.ReadFile(targetFilePath)
if err != nil {
http.Error(w, "File not found", http.StatusNotFound)
return
}
body := string(targetFileContent)
body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken)
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
w.Write([]byte(body))
return
}
}
//Call the next handler
fsHandler.ServeHTTP(w, r)
})
}
// GetHttpHandler returns the http.Handler for the PluginUiRouter
func (p *PluginUiDebugRouter) Handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
//Remove the plugin UI handler path prefix
if p.EnableDebug {
fmt.Print("Request URL:", r.URL.Path, " rewriting to ")
}
rewrittenURL := r.RequestURI
rewrittenURL = strings.TrimPrefix(rewrittenURL, p.HandlerPrefix)
rewrittenURL = strings.ReplaceAll(rewrittenURL, "//", "/")
r.URL.Path = rewrittenURL
r.RequestURI = rewrittenURL
if p.EnableDebug {
fmt.Println(r.URL.Path)
}
//Serve the file from the file system
fsHandler := http.FileServer(http.Dir(p.TargetDir))
// Replace {{csrf_token}} with the actual CSRF token and serve the file
p.populateCSRFToken(r, fsHandler).ServeHTTP(w, r)
})
}
// RegisterTerminateHandler registers the terminate handler for the PluginUiRouter
// The terminate handler will be called when the plugin is terminated from Zoraxy plugin manager
// if mux is nil, the handler will be registered to http.DefaultServeMux
func (p *PluginUiDebugRouter) RegisterTerminateHandler(termFunc func(), mux *http.ServeMux) {
p.terminateHandler = termFunc
if mux == nil {
mux = http.DefaultServeMux
}
mux.HandleFunc(p.HandlerPrefix+"/term", func(w http.ResponseWriter, r *http.Request) {
p.terminateHandler()
w.WriteHeader(http.StatusOK)
go func() {
//Make sure the response is sent before the plugin is terminated
time.Sleep(100 * time.Millisecond)
os.Exit(0)
}()
})
}
// Attach the file system UI handler to the target http.ServeMux
func (p *PluginUiDebugRouter) AttachHandlerToMux(mux *http.ServeMux) {
if mux == nil {
mux = http.DefaultServeMux
}
p.HandlerPrefix = strings.TrimSuffix(p.HandlerPrefix, "/")
mux.Handle(p.HandlerPrefix+"/", p.Handler())
}

View File

@ -74,6 +74,20 @@ func (p *PluginUiRouter) populateCSRFToken(r *http.Request, fsHandler http.Handl
w.WriteHeader(http.StatusOK)
w.Write([]byte(body))
return
} else if strings.HasSuffix(r.URL.Path, "/") {
// Check if the directory has an index.html file
indexFilePath := strings.TrimPrefix(r.URL.Path, "/") + "index.html"
indexFilePath = p.TargetFsPrefix + "/" + indexFilePath
indexFilePath = strings.TrimPrefix(indexFilePath, "/")
indexFileContent, err := fs.ReadFile(*p.TargetFs, indexFilePath)
if err == nil {
body := string(indexFileContent)
body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken)
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
w.Write([]byte(body))
return
}
}
//Call the next handler
@ -130,3 +144,13 @@ func (p *PluginUiRouter) RegisterTerminateHandler(termFunc func(), mux *http.Ser
}()
})
}
// Attach the embed UI handler to the target http.ServeMux
func (p *PluginUiRouter) AttachHandlerToMux(mux *http.ServeMux) {
if mux == nil {
mux = http.DefaultServeMux
}
p.HandlerPrefix = strings.TrimSuffix(p.HandlerPrefix, "/")
mux.Handle(p.HandlerPrefix+"/", p.Handler())
}

View File

@ -1005,6 +1005,23 @@ func ReverseProxyListDetail(w http.ResponseWriter, r *http.Request) {
}
}
// List all tags used in the proxy rules
func ReverseProxyListTags(w http.ResponseWriter, r *http.Request) {
results := []string{}
dynamicProxyRouter.ProxyEndpoints.Range(func(key, value interface{}) bool {
thisEndpoint := value.(*dynamicproxy.ProxyEndpoint)
for _, tag := range thisEndpoint.Tags {
if !utils.StringInArray(results, tag) {
results = append(results, tag)
}
}
return true
})
js, _ := json.Marshal(results)
utils.SendJSONResponse(w, string(js))
}
func ReverseProxyList(w http.ResponseWriter, r *http.Request) {
eptype, err := utils.PostPara(r, "type") //Support root and host
if err != nil {

View File

@ -45,6 +45,9 @@
function resizeIframe() {
let iframe = document.getElementById('pluginContextLoader');
let mainMenuHeight = document.getElementById('mainmenu').offsetHeight;
if (mainMenuHeight == 0){
mainMenuHeight = window.innerHeight - 198; //Fallback to window height
}
iframe.style.height = mainMenuHeight + 'px';
}
@ -57,6 +60,4 @@
//On switch over to this page, load info
resizeIframe();
}
initPluginUIView();
</script>

View File

@ -1,3 +1,69 @@
<style>
#selectablePluginList{
max-height: 300px;
overflow-y: auto;
border-radius: 0.4em;
}
#selectablePluginList .item {
cursor: pointer;
padding: 1em;
}
#selectablePluginList .item:hover {
background-color: var(--theme_bg_active);
}
#selectedTagPluginList{
max-height: 300px;
overflow-y: auto;
border-radius: 0.4em;
}
#selectedTagPluginList .item {
padding: 1em;
cursor: pointer;
}
#selectedTagPluginList .item:hover {
background-color: var(--theme_bg_active);
}
.selectablePluginItem{
position: relative;
}
.selectablePluginItem.active{
background-color: var(--theme_bg_active);
}
.selectablePluginItem .selectedIcon{
position: absolute;
right: 0.2em;
bottom: 0.2em;
display:none;
}
.selectablePluginItem.active .selectedIcon{
display: block;
}
.selectedPluginItem{
position: relative;
}
.selectedPluginItem.active{
background-color: var(--theme_bg_active);
}
.selectedPluginItem .selectedIcon{
position: absolute;
right: 0.2em;
bottom: 0.2em;
display:none;
}
.selectedPluginItem.active .selectedIcon{
display: block;
}
</style>
<div class="standardContainer">
<div class="ui basic segment">
<h2>Plugins</h2>
@ -7,6 +73,55 @@
<div class="header">Experimental Feature</div>
<p>This feature is experimental and may not work as expected. Use with caution.</p>
</div>
<h4 class="ui header">
Plugin Map
<div class="sub header">Assigning a plugin to a tag will make the plugin available to the HTTP Proxy rule with the same tag.</div>
</h4>
<div class="ui stackable grid">
<div class="seven wide column">
<!-- Selectable plugin list -->
<div id="selectablePluginList" class="ui relaxed divided list" style="border: 1px solid var(--divider_color);">
<div class="item" style="pointer-events: none; user-select: none; opacity: 0.6;">
<i class="ui arrow up icon"></i> Select a tag to view available plugins
</div>
</div>
</div>
<div class="two wide column" style="display: flex; align-items: center; justify-content: center;">
<!-- Add and Remove button -->
<div>
<button id="removeSelectedPluginFromTagBtn" class="ui basic red icon button" title="Remove selected plugin from tag">
<i class="left arrow icon"></i>
</button>
<br>
<button id="addSelectedPluginTotagBtn" class="ui basic green icon button" title="Add selected plugin to tag" style="margin-top: 0.4em;">
<i class="right arrow icon"></i>
</button>
</div>
</div>
<div class="seven wide column">
<!-- Tag / Plugin List -->
<div class="ui fluid selection dropdown" id="pluginTagList">
<input type="hidden" name="tag">
<i class="dropdown icon"></i>
<div class="default text">Select Tag</div>
<div class="menu">
<!-- <div class="item" data-value="tag1">Tag 1</div> -->
</div>
</div>
<button class="ui basic fluid button" onclick="loadTags();" style="margin-top: 0.4em;"><i class="ui green refresh icon"></i> Refresh Tag List</button>
<div class="ui divider"></div>
<div id="selectedTagPluginList" class="ui relaxed divided list" style="border: 1px solid var(--divider_color);">
<div class="item" style="pointer-events: none; user-select: none; opacity: 0.6;">
<i class="ui arrow up icon"></i> Select a tag to view assigned plugins
</div>
</div>
</div>
</div>
<div class="ui divider"></div>
<h4 class="ui header">
Plugin List
<div class="sub header">A list of installed plugins and their enable state</div>
</h4>
<table class="ui basic celled table">
<thead>
<tr>
@ -22,11 +137,220 @@
</div>
<script>
var plugin_list = [];
/* Plugin Tag Assignment */
$('#pluginTagList').dropdown();
$('#pluginTagList').on('change', function() {
const selectedTag = $(this).dropdown('get value');
loadPluginsForTag(selectedTag);
});
function loadPluginsForTag(tag) {
$.get(`/api/plugins/groups/list?tag=${tag}`, function(data) {
$("#selectedTagPluginList").html("");
let selectedPluginIDs = [];
data.forEach(plugin => {
$("#selectedTagPluginList").append(`
<div class="item selectedPluginItem" pluginid="${plugin.Spec.id}">
<img class="ui avatar image" src="/api/plugins/icon?plugin_id=${plugin.Spec.id}">
<div class="content">
<a class="header">${plugin.Spec.name}</a>
<div class="description">${plugin.Spec.description}</div>
</div>
<div class="selectedIcon">
<i class="ui large green circle check icon"></i>
</div>
</div>
`);
selectedPluginIDs.push(plugin.Spec.id);
});
if (data.length == 0){
$("#selectedTagPluginList").append(`
<div class="item" style="pointer-events: none; user-select: none; opacity: 0.6;">
<i class="ui green circle check icon"></i> No plugins assigned to this tag
</div>
`);
}
//Load the remaining plugins to the selectable list
$("#selectablePluginList").html("");
let selectablePluginCount = 0;
plugin_list.forEach(plugin => {
if (plugin.Spec.type != 0) {
//This is not a router plugin, skip
return;
}
if (!selectedPluginIDs.includes(plugin.Spec.id)) {
$("#selectablePluginList").append(`
<div class="item selectablePluginItem" pluginid="${plugin.Spec.id}">
<img class="ui avatar image" src="/api/plugins/icon?plugin_id=${plugin.Spec.id}">
<div class="content">
<a class="header">${plugin.Spec.name}</a>
<div class="description">${plugin.Spec.description}</div>
</div>
<div class="selectedIcon">
<i class="ui large green circle check icon"></i>
</div>
</div>
`);
selectablePluginCount++;
}
});
if (selectablePluginCount == 0){
$("#selectablePluginList").append(`
<div class="item" style="pointer-events: none; user-select: none; opacity: 0.6;">
<i class="ui green circle check icon"></i> No plugins available to assign
</div>
`);
}
bindEventsToSelectableItems();
});
}
//Load all the tags from the server
function loadTags(){
$.get(`/api/proxy/listTags`, function(data){
$("#pluginTagList").find(".menu").html("");
if (data.error != undefined){
msgbox(data.error, false);
return;
}
$("#pluginTagList").find(".menu").html("");
data.forEach(tag => {
$("#pluginTagList").find(".menu").append(`
<div class="item" data-value="${tag}">${tag}</div>
`);
});
});
}
loadTags();
//This is used as a dummy function to initialize the selectable plugin list
function initSelectablePluginList(){
$("#selectablePluginList").html("");
$.get(`/api/plugins/list`, function(data){
data.forEach(plugin => {
if (plugin.Spec.type != 0) {
//This is not a router plugin, skip
return;
}
$("#selectablePluginList").append(`
<div class="item" style="pointer-events: none; user-select: none; opacity: 0.6;">
<img class="ui avatar image" src="/api/plugins/icon?plugin_id=${plugin.Spec.id}">
<div class="content">
<a class="header">${plugin.Spec.name}</a>
<div class="description">${plugin.Spec.description}</div>
</div>
</div>
`);
});
if (data.length == 0){
$("#selectablePluginList").append(`
<div class="item" style="pointer-events: none; user-select: none; opacity: 0.6;">
<p><i class="ui green circle check icon"></i> No plugins available to assign</p>
<p>Plugins can be installed to Zoraxy by placing the plugin files in the <code>./plugins/{plugin_name}/</code> directory.</p>
</div>
`);
}
});
}
initSelectablePluginList();
function bindEventsToSelectableItems(){
$(".selectablePluginItem").on("click", function(){
$(".selectablePluginItem.active").removeClass("active");
$(this).addClass("active");
});
$(".selectedPluginItem").on("click", function(){
$(".selectedPluginItem.active").removeClass("active");
$(this).addClass("active");
});
}
//Bind events for the buttons
function bindTagAssignButtonEvents(){
$("#addSelectedPluginTotagBtn").on("click", function(){
const selectedPlugin = $(".selectablePluginItem.active");
const selectedTag = $("#pluginTagList").dropdown("get value");
if (selectedPlugin.length == 0){
msgbox("Please select a plugin to add", false);
return;
}
if (selectedTag == ""){
msgbox("Please select a tag to add the plugin to", false);
return;
}
const pluginId = selectedPlugin.attr("pluginid");
addPluginToTag(pluginId, selectedTag);
});
$("#removeSelectedPluginFromTagBtn").on("click", function(){
const selectedPlugin = $(".selectedPluginItem.active");
const selectedTag = $("#pluginTagList").dropdown("get value");
if (selectedPlugin.length == 0){
msgbox("Please select a plugin to remove", false);
return;
}
if (selectedTag == ""){
msgbox("Please select a tag to remove the plugin from", false);
return;
}
const pluginId = selectedPlugin.attr("pluginid");
removePluginFromTag(pluginId, selectedTag);
});
}
bindTagAssignButtonEvents();
function addPluginToTag(pluginId, tag){
$.cjax({
url: '/api/plugins/groups/add',
type: 'POST',
data: {plugin_id: pluginId, tag: tag},
success: function(data){
if (data.error != undefined){
msgbox(data.error, false);
}else{
msgbox("Plugin added to tag", true);
}
loadPluginsForTag(tag);
}
});
}
function removePluginFromTag(pluginId, tag){
$.cjax({
url: '/api/plugins/groups/remove',
type: 'POST',
data: {plugin_id: pluginId, tag: tag},
success: function(data){
if (data.error != undefined){
msgbox(data.error, false);
}else{
msgbox("Plugin removed from tag", true);
}
loadPluginsForTag(tag);
}
});
}
/* Plugin List */
//Render the plugin list to Zoraxy homepage side menu
function initPluginSideMenu(){
$.get(`/api/plugins/list`, function(data){
$("#pluginMenu").html("");
let enabledPluginCount = 0;
plugin_list = data;
data.forEach(plugin => {
if (!plugin.Enabled){
return;
@ -55,6 +379,20 @@ function initPluginSideMenu(){
});
});
/* Handling Plugin Manager State, see index.html */
//Callback to be called when the plugin list is updated
if (plugin_manager_state && !plugin_manager_state.initated){
plugin_manager_state.initated = true;
if (plugin_manager_state.initCallback){
plugin_manager_state.initCallback();
}
}
//Callback to be called when the plugin list is updated
if (plugin_manager_state && plugin_manager_state.listUpdateCallback){
plugin_manager_state.listUpdateCallback();
}
});
}
initPluginSideMenu();
@ -119,6 +457,12 @@ function initiatePluginList(){
initiatePluginList();
/* Tag Assignment */
/* Plugin Lifecycle */
function startPlugin(pluginId, btn=undefined){
if (btn) {
$(btn).html('<i class="spinner loading icon"></i> Starting');

View File

@ -209,7 +209,20 @@
<br><br>
<script>
$(".year").text(new Date().getFullYear());
/*
Plugin Manager State
As some initiation must be done before the plugin manager
loaded up the plugin list, this state here tells the plugin
manager to do some actions after the plugin list is initiated
*/
var plugin_manager_state = {
initated: false, //Whether the plugin manager has been initiated
initCallback: undefined, //Callback to be called when the plugin manager is initiated
listUpdateCallback: undefined //Callback to be called when the plugin list is updated
}
/*
Loader function
@ -258,13 +271,22 @@
try {
let parsedData = JSON.parse(tabID);
tabID = parsedData.tabID;
//Open the plugin context window
if (tabID == "pluginContextWindow"){
let pluginID = parsedData.pluginID;
let button = $("#pluginMenu").find(`[pluginid="${pluginID}"]`);
openTabById(tabID, button);
loadPluginUIContextIfAvailable();
if (pluginID == undefined){
//Do not swap page
return;
}
if (!openPluginTabByID(pluginID)){
//Let the plugin manager to load the plugin list first
plugin_manager_state.initCallback = function(){
let pid = pluginID;
openPluginTabByID(pid);
}
}
}
} catch (e) {
console.error("Invalid JSON data:", e);
@ -290,6 +312,17 @@
$('table').tablesort();
});
//Function to open a plugin tab by its plugin id
function openPluginTabByID(pluginID, tabID="pluginContextWindow"){
let button = $("#pluginMenu").find(`[pluginid="${pluginID}"]`);
if (button.length == 0){
return false;
}
openTabById(tabID, button);
loadPluginUIContextIfAvailable();
return true;
}
function logout() {
$.get("/api/auth/logout", function(response) {
if (response === "OK") {
@ -495,6 +528,9 @@
});
$("body").css("overflow", "auto");
}
/* Utilities */
$(".year").text(new Date().getFullYear());
</script>
</body>
</html>