mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +00:00
Updated Plugins
Added Import + Delete Added Modal confirmations Safer Defaults
This commit is contained in:
parent
e9a11588c4
commit
5b31440018
8 changed files with 533 additions and 44 deletions
30
Plugins.md
30
Plugins.md
|
|
@ -118,13 +118,21 @@ Each action is a dict:
|
||||||
|
|
||||||
Clicking an action calls your plugin’s `run(action, params, context)` and shows a notification with the result or error.
|
Clicking an action calls your plugin’s `run(action, params, context)` and shows a notification with the result or error.
|
||||||
|
|
||||||
### Confirmation Prompt
|
### Action Confirmation (Modal)
|
||||||
If you want the UI to ask for confirmation before executing an action, include a boolean field with id `confirm` set to `True` (or default `True`). The UI checks it and prompts using a confirm dialog.
|
Developers can request a confirmation modal per action using the `confirm` key on the action. Options:
|
||||||
|
|
||||||
|
- Boolean: `confirm: true` will show a default confirmation modal.
|
||||||
|
- Object: `confirm: { required: true, title: '...', message: '...' }` to customize the modal title and message.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
```
|
```
|
||||||
fields = [
|
actions = [
|
||||||
{"id": "confirm", "label": "Require confirmation", "type": "boolean", "default": True},
|
{
|
||||||
|
"id": "danger_run",
|
||||||
|
"label": "Do Something Risky",
|
||||||
|
"description": "Runs a job that affects many records.",
|
||||||
|
"confirm": { "required": true, "title": "Proceed?", "message": "This will modify many records." },
|
||||||
|
}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -181,6 +189,7 @@ Prefer Celery tasks (`.delay()`) to keep `run` fast and non-blocking.
|
||||||
- List plugins: `GET /api/plugins/plugins/`
|
- List plugins: `GET /api/plugins/plugins/`
|
||||||
- Response: `{ "plugins": [{ key, name, version, description, enabled, fields, settings, actions }, ...] }`
|
- Response: `{ "plugins": [{ key, name, version, description, enabled, fields, settings, actions }, ...] }`
|
||||||
- Reload discovery: `POST /api/plugins/plugins/reload/`
|
- Reload discovery: `POST /api/plugins/plugins/reload/`
|
||||||
|
- Import plugin: `POST /api/plugins/plugins/import/` with form-data file field `file`
|
||||||
- Update settings: `POST /api/plugins/plugins/<key>/settings/` with `{"settings": {...}}`
|
- Update settings: `POST /api/plugins/plugins/<key>/settings/` with `{"settings": {...}}`
|
||||||
- Run action: `POST /api/plugins/plugins/<key>/run/` with `{"action": "id", "params": {...}}`
|
- Run action: `POST /api/plugins/plugins/<key>/run/` with `{"action": "id", "params": {...}}`
|
||||||
- Enable/disable: `POST /api/plugins/plugins/<key>/enabled/` with `{"enabled": true|false}`
|
- Enable/disable: `POST /api/plugins/plugins/<key>/enabled/` with `{"enabled": true|false}`
|
||||||
|
|
@ -190,9 +199,19 @@ Notes:
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Importing Plugins
|
||||||
|
|
||||||
|
- In the UI, click the Import button on the Plugins page and upload a `.zip` containing a plugin folder.
|
||||||
|
- The archive should contain either `plugin.py` or a Python package (`__init__.py`).
|
||||||
|
- On success, the UI shows the plugin name/description and lets you enable it immediately (plugins are disabled by default).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Enabling / Disabling Plugins
|
## Enabling / Disabling Plugins
|
||||||
|
|
||||||
- Each plugin has a persisted `enabled` flag in the DB (`apps/plugins/models.py`).
|
- Each plugin has a persisted `enabled` flag (default: disabled) and `ever_enabled` flag in the DB (`apps/plugins/models.py`).
|
||||||
|
- New plugins are disabled by default and require an explicit enable.
|
||||||
|
- The first time a plugin is enabled, the UI shows a trust warning modal explaining that plugins can run arbitrary server-side code.
|
||||||
- The Plugins page shows a toggle in the card header. Turning it off dims the card and disables the Run button.
|
- The Plugins page shows a toggle in the card header. Turning it off dims the card and disables the Run button.
|
||||||
- Backend enforcement: Attempts to run an action for a disabled plugin return HTTP 403.
|
- Backend enforcement: Attempts to run an action for a disabled plugin return HTTP 403.
|
||||||
|
|
||||||
|
|
@ -265,4 +284,3 @@ class Plugin:
|
||||||
- Model: `apps/plugins/models.py`
|
- Model: `apps/plugins/models.py`
|
||||||
- Frontend page: `frontend/src/pages/Plugins.jsx`
|
- Frontend page: `frontend/src/pages/Plugins.jsx`
|
||||||
- Sidebar entry: `frontend/src/components/Sidebar.jsx`
|
- Sidebar entry: `frontend/src/components/Sidebar.jsx`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ from .api_views import (
|
||||||
PluginSettingsAPIView,
|
PluginSettingsAPIView,
|
||||||
PluginRunAPIView,
|
PluginRunAPIView,
|
||||||
PluginEnabledAPIView,
|
PluginEnabledAPIView,
|
||||||
|
PluginImportAPIView,
|
||||||
|
PluginDeleteAPIView,
|
||||||
)
|
)
|
||||||
|
|
||||||
app_name = "plugins"
|
app_name = "plugins"
|
||||||
|
|
@ -12,6 +14,8 @@ app_name = "plugins"
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("plugins/", PluginsListAPIView.as_view(), name="list"),
|
path("plugins/", PluginsListAPIView.as_view(), name="list"),
|
||||||
path("plugins/reload/", PluginReloadAPIView.as_view(), name="reload"),
|
path("plugins/reload/", PluginReloadAPIView.as_view(), name="reload"),
|
||||||
|
path("plugins/import/", PluginImportAPIView.as_view(), name="import"),
|
||||||
|
path("plugins/<str:key>/delete/", PluginDeleteAPIView.as_view(), name="delete"),
|
||||||
path("plugins/<str:key>/settings/", PluginSettingsAPIView.as_view(), name="settings"),
|
path("plugins/<str:key>/settings/", PluginSettingsAPIView.as_view(), name="settings"),
|
||||||
path("plugins/<str:key>/run/", PluginRunAPIView.as_view(), name="run"),
|
path("plugins/<str:key>/run/", PluginRunAPIView.as_view(), name="run"),
|
||||||
path("plugins/<str:key>/enabled/", PluginEnabledAPIView.as_view(), name="enabled"),
|
path("plugins/<str:key>/enabled/", PluginEnabledAPIView.as_view(), name="enabled"),
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,13 @@ from rest_framework.views import APIView
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.decorators import api_view
|
from rest_framework.decorators import api_view
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.files.uploadedfile import UploadedFile
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import zipfile
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
from apps.accounts.permissions import (
|
from apps.accounts.permissions import (
|
||||||
Authenticated,
|
Authenticated,
|
||||||
permission_classes_by_method,
|
permission_classes_by_method,
|
||||||
|
|
@ -45,6 +52,144 @@ class PluginReloadAPIView(APIView):
|
||||||
return Response({"success": True, "count": len(pm._registry)})
|
return Response({"success": True, "count": len(pm._registry)})
|
||||||
|
|
||||||
|
|
||||||
|
class PluginImportAPIView(APIView):
|
||||||
|
def get_permissions(self):
|
||||||
|
try:
|
||||||
|
return [
|
||||||
|
perm() for perm in permission_classes_by_method[self.request.method]
|
||||||
|
]
|
||||||
|
except KeyError:
|
||||||
|
return [Authenticated()]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
file: UploadedFile = request.FILES.get("file")
|
||||||
|
if not file:
|
||||||
|
return Response({"success": False, "error": "Missing 'file' upload"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
pm = PluginManager.get()
|
||||||
|
plugins_dir = pm.plugins_dir
|
||||||
|
|
||||||
|
try:
|
||||||
|
zf = zipfile.ZipFile(file)
|
||||||
|
except zipfile.BadZipFile:
|
||||||
|
return Response({"success": False, "error": "Invalid zip file"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
# Extract to a temporary directory first to avoid server reload thrash
|
||||||
|
tmp_root = tempfile.mkdtemp(prefix="plugin_import_")
|
||||||
|
try:
|
||||||
|
file_members = [m for m in zf.infolist() if not m.is_dir()]
|
||||||
|
if not file_members:
|
||||||
|
shutil.rmtree(tmp_root, ignore_errors=True)
|
||||||
|
return Response({"success": False, "error": "Archive is empty"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
for member in file_members:
|
||||||
|
name = member.filename
|
||||||
|
if not name or name.endswith("/"):
|
||||||
|
continue
|
||||||
|
# Normalize and prevent path traversal
|
||||||
|
norm = os.path.normpath(name)
|
||||||
|
if norm.startswith("..") or os.path.isabs(norm):
|
||||||
|
shutil.rmtree(tmp_root, ignore_errors=True)
|
||||||
|
return Response({"success": False, "error": "Unsafe path in archive"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
dest_path = os.path.join(tmp_root, norm)
|
||||||
|
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
|
||||||
|
with zf.open(member, 'r') as src, open(dest_path, 'wb') as dst:
|
||||||
|
shutil.copyfileobj(src, dst)
|
||||||
|
|
||||||
|
# Find candidate directory containing plugin.py or __init__.py
|
||||||
|
candidates = []
|
||||||
|
for dirpath, dirnames, filenames in os.walk(tmp_root):
|
||||||
|
has_pluginpy = "plugin.py" in filenames
|
||||||
|
has_init = "__init__.py" in filenames
|
||||||
|
if has_pluginpy or has_init:
|
||||||
|
depth = len(os.path.relpath(dirpath, tmp_root).split(os.sep))
|
||||||
|
candidates.append((0 if has_pluginpy else 1, depth, dirpath))
|
||||||
|
if not candidates:
|
||||||
|
shutil.rmtree(tmp_root, ignore_errors=True)
|
||||||
|
return Response({"success": False, "error": "Invalid plugin: missing plugin.py or package __init__.py"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
candidates.sort()
|
||||||
|
chosen = candidates[0][2]
|
||||||
|
# Determine plugin key: prefer chosen folder name; if chosen is tmp_root, use zip base name
|
||||||
|
base_name = os.path.splitext(getattr(file, "name", "plugin"))[0]
|
||||||
|
plugin_key = os.path.basename(chosen.rstrip(os.sep))
|
||||||
|
if chosen.rstrip(os.sep) == tmp_root.rstrip(os.sep):
|
||||||
|
plugin_key = base_name
|
||||||
|
plugin_key = plugin_key.replace(" ", "_").lower()
|
||||||
|
|
||||||
|
final_dir = os.path.join(plugins_dir, plugin_key)
|
||||||
|
if os.path.exists(final_dir):
|
||||||
|
# If final dir exists but contains a valid plugin, refuse; otherwise clear it
|
||||||
|
if os.path.exists(os.path.join(final_dir, "plugin.py")) or os.path.exists(os.path.join(final_dir, "__init__.py")):
|
||||||
|
shutil.rmtree(tmp_root, ignore_errors=True)
|
||||||
|
return Response({"success": False, "error": f"Plugin '{plugin_key}' already exists"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
try:
|
||||||
|
shutil.rmtree(final_dir)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Move chosen directory into final location
|
||||||
|
if chosen.rstrip(os.sep) == tmp_root.rstrip(os.sep):
|
||||||
|
# Move all contents into final_dir
|
||||||
|
os.makedirs(final_dir, exist_ok=True)
|
||||||
|
for item in os.listdir(tmp_root):
|
||||||
|
shutil.move(os.path.join(tmp_root, item), os.path.join(final_dir, item))
|
||||||
|
else:
|
||||||
|
shutil.move(chosen, final_dir)
|
||||||
|
# Cleanup temp
|
||||||
|
shutil.rmtree(tmp_root, ignore_errors=True)
|
||||||
|
target_dir = final_dir
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
shutil.rmtree(tmp_root, ignore_errors=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Reload discovery and validate plugin entry
|
||||||
|
pm.discover_plugins()
|
||||||
|
plugin = pm._registry.get(plugin_key)
|
||||||
|
if not plugin:
|
||||||
|
# Cleanup the copied folder to avoid leaving invalid plugin behind
|
||||||
|
try:
|
||||||
|
shutil.rmtree(target_dir, ignore_errors=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return Response({"success": False, "error": "Invalid plugin: missing Plugin class in plugin.py or __init__.py"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
# Extra validation: ensure Plugin.run exists
|
||||||
|
instance = getattr(plugin, "instance", None)
|
||||||
|
run_method = getattr(instance, "run", None)
|
||||||
|
if not callable(run_method):
|
||||||
|
try:
|
||||||
|
shutil.rmtree(target_dir, ignore_errors=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return Response({"success": False, "error": "Invalid plugin: Plugin class must define a callable run(action, params, context)"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
# Find DB config to return enabled/ever_enabled
|
||||||
|
try:
|
||||||
|
cfg = PluginConfig.objects.get(key=plugin_key)
|
||||||
|
enabled = cfg.enabled
|
||||||
|
ever_enabled = getattr(cfg, "ever_enabled", False)
|
||||||
|
except PluginConfig.DoesNotExist:
|
||||||
|
enabled = False
|
||||||
|
ever_enabled = False
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
"success": True,
|
||||||
|
"plugin": {
|
||||||
|
"key": plugin.key,
|
||||||
|
"name": plugin.name,
|
||||||
|
"version": plugin.version,
|
||||||
|
"description": plugin.description,
|
||||||
|
"enabled": enabled,
|
||||||
|
"ever_enabled": ever_enabled,
|
||||||
|
"fields": plugin.fields or [],
|
||||||
|
"actions": plugin.actions or [],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
class PluginSettingsAPIView(APIView):
|
class PluginSettingsAPIView(APIView):
|
||||||
def get_permissions(self):
|
def get_permissions(self):
|
||||||
try:
|
try:
|
||||||
|
|
@ -115,7 +260,47 @@ class PluginEnabledAPIView(APIView):
|
||||||
try:
|
try:
|
||||||
cfg = PluginConfig.objects.get(key=key)
|
cfg = PluginConfig.objects.get(key=key)
|
||||||
cfg.enabled = bool(enabled)
|
cfg.enabled = bool(enabled)
|
||||||
cfg.save(update_fields=["enabled", "updated_at"])
|
# Mark that this plugin has been enabled at least once
|
||||||
return Response({"success": True, "enabled": cfg.enabled})
|
if cfg.enabled and not cfg.ever_enabled:
|
||||||
|
cfg.ever_enabled = True
|
||||||
|
cfg.save(update_fields=["enabled", "ever_enabled", "updated_at"])
|
||||||
|
return Response({"success": True, "enabled": cfg.enabled, "ever_enabled": cfg.ever_enabled})
|
||||||
except PluginConfig.DoesNotExist:
|
except PluginConfig.DoesNotExist:
|
||||||
return Response({"success": False, "error": "Plugin not found"}, status=status.HTTP_404_NOT_FOUND)
|
return Response({"success": False, "error": "Plugin not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
|
||||||
|
class PluginDeleteAPIView(APIView):
|
||||||
|
def get_permissions(self):
|
||||||
|
try:
|
||||||
|
return [
|
||||||
|
perm() for perm in permission_classes_by_method[self.request.method]
|
||||||
|
]
|
||||||
|
except KeyError:
|
||||||
|
return [Authenticated()]
|
||||||
|
|
||||||
|
def delete(self, request, key):
|
||||||
|
pm = PluginManager.get()
|
||||||
|
plugins_dir = pm.plugins_dir
|
||||||
|
target_dir = os.path.join(plugins_dir, key)
|
||||||
|
# Safety: ensure path inside plugins_dir
|
||||||
|
abs_plugins = os.path.abspath(plugins_dir) + os.sep
|
||||||
|
abs_target = os.path.abspath(target_dir)
|
||||||
|
if not abs_target.startswith(abs_plugins):
|
||||||
|
return Response({"success": False, "error": "Invalid plugin path"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
# Remove files
|
||||||
|
if os.path.isdir(target_dir):
|
||||||
|
try:
|
||||||
|
shutil.rmtree(target_dir)
|
||||||
|
except Exception as e:
|
||||||
|
return Response({"success": False, "error": f"Failed to delete plugin files: {e}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
|
# Remove DB record
|
||||||
|
try:
|
||||||
|
PluginConfig.objects.filter(key=key).delete()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Reload registry
|
||||||
|
pm.discover_plugins()
|
||||||
|
return Response({"success": True})
|
||||||
|
|
|
||||||
|
|
@ -71,24 +71,41 @@ class PluginManager:
|
||||||
return self._registry
|
return self._registry
|
||||||
|
|
||||||
def _load_plugin(self, key: str, path: str):
|
def _load_plugin(self, key: str, path: str):
|
||||||
# Plugin can be a package or contain plugin.py
|
# Plugin can be a package and/or contain plugin.py. Prefer plugin.py when present.
|
||||||
module_name = None
|
has_pkg = os.path.exists(os.path.join(path, "__init__.py"))
|
||||||
if os.path.exists(os.path.join(path, "__init__.py")):
|
has_pluginpy = os.path.exists(os.path.join(path, "plugin.py"))
|
||||||
module_name = key
|
if not (has_pkg or has_pluginpy):
|
||||||
elif os.path.exists(os.path.join(path, "plugin.py")):
|
|
||||||
module_name = f"{key}.plugin"
|
|
||||||
else:
|
|
||||||
logger.debug(f"Skipping {path}: no plugin.py or package")
|
logger.debug(f"Skipping {path}: no plugin.py or package")
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.debug(f"Importing plugin module {module_name}")
|
candidate_modules = []
|
||||||
module = importlib.import_module(module_name)
|
if has_pluginpy:
|
||||||
|
candidate_modules.append(f"{key}.plugin")
|
||||||
|
if has_pkg:
|
||||||
|
candidate_modules.append(key)
|
||||||
|
|
||||||
|
module = None
|
||||||
|
plugin_cls = None
|
||||||
|
last_error = None
|
||||||
|
for module_name in candidate_modules:
|
||||||
|
try:
|
||||||
|
logger.debug(f"Importing plugin module {module_name}")
|
||||||
|
module = importlib.import_module(module_name)
|
||||||
|
plugin_cls = getattr(module, "Plugin", None)
|
||||||
|
if plugin_cls is not None:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
logger.warning(f"Module {module_name} has no Plugin class")
|
||||||
|
except Exception as e:
|
||||||
|
last_error = e
|
||||||
|
logger.exception(f"Error importing module {module_name}")
|
||||||
|
|
||||||
# Expect a class named Plugin in the module
|
|
||||||
plugin_cls = getattr(module, "Plugin", None)
|
|
||||||
if plugin_cls is None:
|
if plugin_cls is None:
|
||||||
logger.warning(f"Module {module_name} has no Plugin class; skipping")
|
if last_error:
|
||||||
return
|
raise last_error
|
||||||
|
else:
|
||||||
|
logger.warning(f"No Plugin class found for {key}; skipping")
|
||||||
|
return
|
||||||
|
|
||||||
instance = plugin_cls()
|
instance = plugin_cls()
|
||||||
|
|
||||||
|
|
@ -138,8 +155,10 @@ class PluginManager:
|
||||||
def list_plugins(self) -> List[Dict[str, Any]]:
|
def list_plugins(self) -> List[Dict[str, Any]]:
|
||||||
from .models import PluginConfig
|
from .models import PluginConfig
|
||||||
|
|
||||||
plugins = []
|
plugins: List[Dict[str, Any]] = []
|
||||||
configs = {c.key: c for c in PluginConfig.objects.all()}
|
configs = {c.key: c for c in PluginConfig.objects.all()}
|
||||||
|
|
||||||
|
# First, include all discovered plugins
|
||||||
for key, lp in self._registry.items():
|
for key, lp in self._registry.items():
|
||||||
conf = configs.get(key)
|
conf = configs.get(key)
|
||||||
plugins.append(
|
plugins.append(
|
||||||
|
|
@ -148,12 +167,35 @@ class PluginManager:
|
||||||
"name": lp.name,
|
"name": lp.name,
|
||||||
"version": lp.version,
|
"version": lp.version,
|
||||||
"description": lp.description,
|
"description": lp.description,
|
||||||
"enabled": conf.enabled if conf else True,
|
"enabled": conf.enabled if conf else False,
|
||||||
|
"ever_enabled": getattr(conf, "ever_enabled", False) if conf else False,
|
||||||
"fields": lp.fields or [],
|
"fields": lp.fields or [],
|
||||||
"settings": (conf.settings if conf else {}),
|
"settings": (conf.settings if conf else {}),
|
||||||
"actions": lp.actions or [],
|
"actions": lp.actions or [],
|
||||||
|
"missing": False,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Then, include any DB-only configs (files missing or failed to load)
|
||||||
|
discovered_keys = set(self._registry.keys())
|
||||||
|
for key, conf in configs.items():
|
||||||
|
if key in discovered_keys:
|
||||||
|
continue
|
||||||
|
plugins.append(
|
||||||
|
{
|
||||||
|
"key": key,
|
||||||
|
"name": conf.name,
|
||||||
|
"version": conf.version,
|
||||||
|
"description": conf.description,
|
||||||
|
"enabled": conf.enabled,
|
||||||
|
"ever_enabled": getattr(conf, "ever_enabled", False),
|
||||||
|
"fields": [],
|
||||||
|
"settings": conf.settings or {},
|
||||||
|
"actions": [],
|
||||||
|
"missing": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return plugins
|
return plugins
|
||||||
|
|
||||||
def get_plugin(self, key: str) -> Optional[LoadedPlugin]:
|
def get_plugin(self, key: str) -> Optional[LoadedPlugin]:
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,11 @@ class Migration(migrations.Migration):
|
||||||
("name", models.CharField(max_length=255)),
|
("name", models.CharField(max_length=255)),
|
||||||
("version", models.CharField(blank=True, default="", max_length=64)),
|
("version", models.CharField(blank=True, default="", max_length=64)),
|
||||||
("description", models.TextField(blank=True, default="")),
|
("description", models.TextField(blank=True, default="")),
|
||||||
("enabled", models.BooleanField(default=True)),
|
("enabled", models.BooleanField(default=False)), # merged change
|
||||||
|
("ever_enabled", models.BooleanField(default=False)), # merged addition
|
||||||
("settings", models.JSONField(blank=True, default=dict)),
|
("settings", models.JSONField(blank=True, default=dict)),
|
||||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
("updated_at", models.DateTimeField(auto_now=True)),
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,12 @@ class PluginConfig(models.Model):
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
version = models.CharField(max_length=64, blank=True, default="")
|
version = models.CharField(max_length=64, blank=True, default="")
|
||||||
description = models.TextField(blank=True, default="")
|
description = models.TextField(blank=True, default="")
|
||||||
enabled = models.BooleanField(default=True)
|
enabled = models.BooleanField(default=False)
|
||||||
|
# Tracks whether this plugin has ever been enabled at least once
|
||||||
|
ever_enabled = models.BooleanField(default=False)
|
||||||
settings = models.JSONField(default=dict, blank=True)
|
settings = models.JSONField(default=dict, blank=True)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f"{self.name} ({self.key})"
|
return f"{self.name} ({self.key})"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1240,6 +1240,34 @@ export default class API {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async importPlugin(file) {
|
||||||
|
try {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
const response = await request(`${host}/api/plugins/plugins/import/`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
} catch (e) {
|
||||||
|
// Show only the concise error message for plugin import
|
||||||
|
const msg = (e?.body && (e.body.error || e.body.detail)) || e?.message || 'Failed to import plugin';
|
||||||
|
notifications.show({ title: 'Import failed', message: msg, color: 'red' });
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async deletePlugin(key) {
|
||||||
|
try {
|
||||||
|
const response = await request(`${host}/api/plugins/plugins/${key}/delete/`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
} catch (e) {
|
||||||
|
errorNotification('Failed to delete plugin', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static async updatePluginSettings(key, settings) {
|
static async updatePluginSettings(key, settings) {
|
||||||
try {
|
try {
|
||||||
const response = await request(
|
const response = await request(
|
||||||
|
|
@ -1273,7 +1301,7 @@ export default class API {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { enabled },
|
body: { enabled },
|
||||||
});
|
});
|
||||||
return response?.enabled;
|
return response;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorNotification('Failed to update plugin enabled state', e);
|
errorNotification('Failed to update plugin enabled state', e);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
AppShell,
|
AppShell,
|
||||||
Box,
|
Box,
|
||||||
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Group,
|
Group,
|
||||||
|
|
@ -15,8 +16,11 @@ import {
|
||||||
Divider,
|
Divider,
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
|
Modal,
|
||||||
|
FileInput,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { RefreshCcw } from 'lucide-react';
|
import { Dropzone } from '@mantine/dropzone';
|
||||||
|
import { RefreshCcw, Trash2 } from 'lucide-react';
|
||||||
import API from '../api';
|
import API from '../api';
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
|
|
||||||
|
|
@ -62,12 +66,23 @@ const Field = ({ field, value, onChange }) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const PluginCard = ({ plugin, onSaveSettings, onRunAction, onToggleEnabled }) => {
|
const PluginCard = ({ plugin, onSaveSettings, onRunAction, onToggleEnabled, onRequireTrust, onRequestDelete }) => {
|
||||||
const [settings, setSettings] = useState(plugin.settings || {});
|
const [settings, setSettings] = useState(plugin.settings || {});
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [running, setRunning] = useState(false);
|
const [running, setRunning] = useState(false);
|
||||||
const [enabled, setEnabled] = useState(plugin.enabled !== false);
|
const [enabled, setEnabled] = useState(!!plugin.enabled);
|
||||||
const [lastResult, setLastResult] = useState(null);
|
const [lastResult, setLastResult] = useState(null);
|
||||||
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
|
const [confirmConfig, setConfirmConfig] = useState({ title: '', message: '', onConfirm: null });
|
||||||
|
|
||||||
|
// Keep local enabled state in sync with props (e.g., after import + enable)
|
||||||
|
React.useEffect(() => {
|
||||||
|
setEnabled(!!plugin.enabled);
|
||||||
|
}, [plugin.enabled]);
|
||||||
|
// Sync settings if plugin changes identity
|
||||||
|
React.useEffect(() => {
|
||||||
|
setSettings(plugin.settings || {});
|
||||||
|
}, [plugin.key]);
|
||||||
|
|
||||||
const updateField = (id, val) => {
|
const updateField = (id, val) => {
|
||||||
setSettings((prev) => ({ ...prev, [id]: val }));
|
setSettings((prev) => ({ ...prev, [id]: val }));
|
||||||
|
|
@ -83,30 +98,52 @@ const PluginCard = ({ plugin, onSaveSettings, onRunAction, onToggleEnabled }) =>
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const missing = plugin.missing;
|
||||||
return (
|
return (
|
||||||
<Card shadow="sm" radius="md" withBorder style={{ opacity: enabled ? 1 : 0.6 }}>
|
<Card shadow="sm" radius="md" withBorder style={{ opacity: !missing && enabled ? 1 : 0.6 }}>
|
||||||
<Group justify="space-between" mb="xs" align="center">
|
<Group justify="space-between" mb="xs" align="center">
|
||||||
<div>
|
<div>
|
||||||
<Text fw={600}>{plugin.name}</Text>
|
<Text fw={600}>{plugin.name}</Text>
|
||||||
<Text size="sm" c="dimmed">{plugin.description}</Text>
|
<Text size="sm" c="dimmed">{plugin.description}</Text>
|
||||||
</div>
|
</div>
|
||||||
<Group gap="xs" align="center">
|
<Group gap="xs" align="center">
|
||||||
|
<ActionIcon variant="subtle" color="red" title="Delete plugin" onClick={() => onRequestDelete && onRequestDelete(plugin)}>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</ActionIcon>
|
||||||
<Text size="xs" c="dimmed">v{plugin.version || '1.0.0'}</Text>
|
<Text size="xs" c="dimmed">v{plugin.version || '1.0.0'}</Text>
|
||||||
<Switch
|
<Switch
|
||||||
checked={enabled}
|
checked={!missing && enabled}
|
||||||
onChange={async (e) => {
|
onChange={async (e) => {
|
||||||
const next = e.currentTarget.checked;
|
const next = e.currentTarget.checked;
|
||||||
|
if (next && !plugin.ever_enabled && onRequireTrust) {
|
||||||
|
const ok = await onRequireTrust(plugin);
|
||||||
|
if (!ok) {
|
||||||
|
// Revert
|
||||||
|
setEnabled(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
setEnabled(next);
|
setEnabled(next);
|
||||||
await onToggleEnabled(plugin.key, next);
|
const resp = await onToggleEnabled(plugin.key, next);
|
||||||
|
if (next && resp?.ever_enabled) {
|
||||||
|
plugin.ever_enabled = true;
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
size="xs"
|
size="xs"
|
||||||
onLabel="On"
|
onLabel="On"
|
||||||
offLabel="Off"
|
offLabel="Off"
|
||||||
|
disabled={missing}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{plugin.fields && plugin.fields.length > 0 && (
|
{missing && (
|
||||||
|
<Text size="sm" c="red">
|
||||||
|
Missing plugin files. Re-import or delete this entry.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!missing && plugin.fields && plugin.fields.length > 0 && (
|
||||||
<Stack gap="xs" mt="sm">
|
<Stack gap="xs" mt="sm">
|
||||||
{plugin.fields.map((f) => (
|
{plugin.fields.map((f) => (
|
||||||
<Field key={f.id} field={f} value={settings?.[f.id]} onChange={updateField} />
|
<Field key={f.id} field={f} value={settings?.[f.id]} onChange={updateField} />
|
||||||
|
|
@ -117,7 +154,7 @@ const PluginCard = ({ plugin, onSaveSettings, onRunAction, onToggleEnabled }) =>
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{plugin.actions && plugin.actions.length > 0 && (
|
{!missing && plugin.actions && plugin.actions.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Divider my="sm" />
|
<Divider my="sm" />
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
|
|
@ -136,18 +173,31 @@ const PluginCard = ({ plugin, onSaveSettings, onRunAction, onToggleEnabled }) =>
|
||||||
setRunning(true);
|
setRunning(true);
|
||||||
setLastResult(null);
|
setLastResult(null);
|
||||||
try {
|
try {
|
||||||
// Determine if confirmation is required
|
// Determine if confirmation is required from action metadata or fallback field
|
||||||
|
const actionConfirm = a.confirm;
|
||||||
const confirmField = (plugin.fields || []).find((f) => f.id === 'confirm');
|
const confirmField = (plugin.fields || []).find((f) => f.id === 'confirm');
|
||||||
let requireConfirm = false;
|
let requireConfirm = false;
|
||||||
if (confirmField) {
|
let confirmTitle = `Run ${a.label}?`;
|
||||||
|
let confirmMessage = `You're about to run "${a.label}" from "${plugin.name}".`;
|
||||||
|
if (actionConfirm) {
|
||||||
|
if (typeof actionConfirm === 'boolean') {
|
||||||
|
requireConfirm = actionConfirm;
|
||||||
|
} else if (typeof actionConfirm === 'object') {
|
||||||
|
requireConfirm = actionConfirm.required !== false;
|
||||||
|
if (actionConfirm.title) confirmTitle = actionConfirm.title;
|
||||||
|
if (actionConfirm.message) confirmMessage = actionConfirm.message;
|
||||||
|
}
|
||||||
|
} else if (confirmField) {
|
||||||
const settingVal = settings?.confirm;
|
const settingVal = settings?.confirm;
|
||||||
const effectiveConfirm = (settingVal !== undefined ? settingVal : confirmField.default) ?? false;
|
const effectiveConfirm = (settingVal !== undefined ? settingVal : confirmField.default) ?? false;
|
||||||
requireConfirm = !!effectiveConfirm;
|
requireConfirm = !!effectiveConfirm;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requireConfirm) {
|
if (requireConfirm) {
|
||||||
const ok = window.confirm(`Run "${a.label}" from "${plugin.name}"?`);
|
await new Promise((resolve) => {
|
||||||
if (!ok) { return; }
|
setConfirmConfig({ title: confirmTitle, message: confirmMessage, onConfirm: resolve });
|
||||||
|
setConfirmOpen(true);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save settings before running to ensure backend uses latest values
|
// Save settings before running to ensure backend uses latest values
|
||||||
|
|
@ -184,6 +234,19 @@ const PluginCard = ({ plugin, onSaveSettings, onRunAction, onToggleEnabled }) =>
|
||||||
</Stack>
|
</Stack>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<Modal opened={confirmOpen} onClose={() => { setConfirmOpen(false); setConfirmConfig({ title: '', message: '', onConfirm: null }); }} title={confirmConfig.title} centered>
|
||||||
|
<Stack>
|
||||||
|
<Text size="sm">{confirmConfig.message}</Text>
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="default" size="xs" onClick={() => { setConfirmOpen(false); setConfirmConfig({ title: '', message: '', onConfirm: null }); }}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button size="xs" onClick={() => { const cb = confirmConfig.onConfirm; setConfirmOpen(false); setConfirmConfig({ title: '', message: '', onConfirm: null }); cb && cb(true); }}>
|
||||||
|
Confirm
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -191,6 +254,17 @@ const PluginCard = ({ plugin, onSaveSettings, onRunAction, onToggleEnabled }) =>
|
||||||
export default function PluginsPage() {
|
export default function PluginsPage() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [plugins, setPlugins] = useState([]);
|
const [plugins, setPlugins] = useState([]);
|
||||||
|
const [importOpen, setImportOpen] = useState(false);
|
||||||
|
const [importFile, setImportFile] = useState(null);
|
||||||
|
const [importing, setImporting] = useState(false);
|
||||||
|
const [imported, setImported] = useState(null);
|
||||||
|
const [enableAfterImport, setEnableAfterImport] = useState(false);
|
||||||
|
const [trustOpen, setTrustOpen] = useState(false);
|
||||||
|
const [trustResolve, setTrustResolve] = useState(null);
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const [uploadNoticeId, setUploadNoticeId] = useState(null);
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -206,13 +280,25 @@ export default function PluginsPage() {
|
||||||
load();
|
load();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const requireTrust = (plugin) => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTrustResolve(() => resolve);
|
||||||
|
setTrustOpen(true);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell.Main style={{ padding: 16 }}>
|
<AppShell.Main style={{ padding: 16 }}>
|
||||||
<Group justify="space-between" mb="md">
|
<Group justify="space-between" mb="md">
|
||||||
<Text fw={700} size="lg">Plugins</Text>
|
<Text fw={700} size="lg">Plugins</Text>
|
||||||
<ActionIcon variant="light" onClick={async () => { await API.reloadPlugins(); await load(); }} title="Reload">
|
<Group>
|
||||||
<RefreshCcw size={18} />
|
<Button size="xs" variant="light" onClick={() => { setImportOpen(true); setImported(null); setImportFile(null); setEnableAfterImport(false); }}>
|
||||||
</ActionIcon>
|
Import Plugin
|
||||||
|
</Button>
|
||||||
|
<ActionIcon variant="light" onClick={async () => { await API.reloadPlugins(); await load(); }} title="Reload">
|
||||||
|
<RefreshCcw size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
|
@ -226,7 +312,17 @@ export default function PluginsPage() {
|
||||||
plugin={p}
|
plugin={p}
|
||||||
onSaveSettings={API.updatePluginSettings}
|
onSaveSettings={API.updatePluginSettings}
|
||||||
onRunAction={API.runPluginAction}
|
onRunAction={API.runPluginAction}
|
||||||
onToggleEnabled={API.setPluginEnabled}
|
onToggleEnabled={async (key, next) => {
|
||||||
|
const resp = await API.setPluginEnabled(key, next);
|
||||||
|
if (resp?.ever_enabled !== undefined) {
|
||||||
|
setPlugins((prev) => prev.map((pl) => pl.key === key ? { ...pl, ever_enabled: resp.ever_enabled, enabled: resp.enabled } : pl));
|
||||||
|
} else {
|
||||||
|
setPlugins((prev) => prev.map((pl) => pl.key === key ? { ...pl, enabled: next } : pl));
|
||||||
|
}
|
||||||
|
return resp;
|
||||||
|
}}
|
||||||
|
onRequireTrust={requireTrust}
|
||||||
|
onRequestDelete={(plugin) => { setDeleteTarget(plugin); setDeleteOpen(true); }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
@ -237,6 +333,121 @@ export default function PluginsPage() {
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{/* Import Plugin Modal */}
|
||||||
|
<Modal opened={importOpen} onClose={() => setImportOpen(false)} title="Import Plugin" centered>
|
||||||
|
<Stack>
|
||||||
|
<Text size="sm" c="dimmed">Upload a ZIP containing your plugin folder or package.</Text>
|
||||||
|
<Alert color="yellow" variant="light" title="Heads up">
|
||||||
|
Importing a plugin may briefly restart the backend (you might see a temporary disconnect). Please wait a few seconds and the app will reconnect automatically.
|
||||||
|
</Alert>
|
||||||
|
<Dropzone onDrop={(files) => files[0] && setImportFile(files[0])} onReject={() => {}}
|
||||||
|
maxFiles={1}
|
||||||
|
accept={['application/zip', 'application/x-zip-compressed', 'application/octet-stream']}
|
||||||
|
multiple={false}
|
||||||
|
>
|
||||||
|
<Group justify="center" mih={80}>
|
||||||
|
<Text size="sm">Drag and drop plugin .zip here</Text>
|
||||||
|
</Group>
|
||||||
|
</Dropzone>
|
||||||
|
<FileInput placeholder="Select plugin .zip" value={importFile} onChange={setImportFile} accept=".zip" clearable />
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="default" onClick={() => setImportOpen(false)} size="xs">Close</Button>
|
||||||
|
<Button size="xs" loading={importing} disabled={!importFile} onClick={async () => {
|
||||||
|
setImporting(true);
|
||||||
|
const id = notifications.show({ title: 'Uploading plugin', message: 'Backend may restart; please wait…', loading: true, autoClose: false, withCloseButton: false });
|
||||||
|
setUploadNoticeId(id);
|
||||||
|
try {
|
||||||
|
const resp = await API.importPlugin(importFile);
|
||||||
|
if (resp?.success && resp.plugin) {
|
||||||
|
setImported(resp.plugin);
|
||||||
|
setPlugins((prev) => [resp.plugin, ...prev.filter((p) => p.key !== resp.plugin.key)]);
|
||||||
|
notifications.update({ id, loading: false, color: 'green', title: 'Imported', message: 'Plugin imported. If the app briefly disconnected, it should be back now.', autoClose: 3000 });
|
||||||
|
} else {
|
||||||
|
notifications.update({ id, loading: false, color: 'red', title: 'Import failed', message: (resp?.error || 'Unknown error'), autoClose: 5000 });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// API.importPlugin already showed a concise error; just update the loading notice
|
||||||
|
notifications.update({ id, loading: false, color: 'red', title: 'Import failed', message: ((e?.body && (e.body.error || e.body.detail)) || e?.message || 'Failed'), autoClose: 5000 });
|
||||||
|
} finally {
|
||||||
|
setImporting(false);
|
||||||
|
setUploadNoticeId(null);
|
||||||
|
}
|
||||||
|
}}>Upload</Button>
|
||||||
|
</Group>
|
||||||
|
{imported && (
|
||||||
|
<Box>
|
||||||
|
<Divider my="sm" />
|
||||||
|
<Text fw={600}>{imported.name}</Text>
|
||||||
|
<Text size="sm" c="dimmed">{imported.description}</Text>
|
||||||
|
<Group justify="space-between" mt="sm" align="center">
|
||||||
|
<Text size="sm">Enable now</Text>
|
||||||
|
<Switch size="sm" checked={enableAfterImport} onChange={(e) => setEnableAfterImport(e.currentTarget.checked)} />
|
||||||
|
</Group>
|
||||||
|
<Group justify="flex-end" mt="md">
|
||||||
|
<Button variant="default" size="xs" onClick={() => { setImportOpen(false); setImported(null); setImportFile(null); setEnableAfterImport(false); }}>Done</Button>
|
||||||
|
<Button size="xs" disabled={!enableAfterImport} onClick={async () => {
|
||||||
|
if (!imported) return;
|
||||||
|
let proceed = true;
|
||||||
|
if (!imported.ever_enabled) {
|
||||||
|
proceed = await requireTrust(imported);
|
||||||
|
}
|
||||||
|
if (proceed) {
|
||||||
|
const resp = await API.setPluginEnabled(imported.key, true);
|
||||||
|
if (resp?.success) {
|
||||||
|
setPlugins((prev) => prev.map((p) => p.key === imported.key ? { ...p, enabled: true, ever_enabled: true } : p));
|
||||||
|
notifications.show({ title: imported.name, message: 'Plugin enabled', color: 'green' });
|
||||||
|
}
|
||||||
|
setImportOpen(false);
|
||||||
|
setImported(null);
|
||||||
|
setEnableAfterImport(false);
|
||||||
|
}
|
||||||
|
}}>Enable</Button>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Trust Warning Modal */}
|
||||||
|
<Modal opened={trustOpen} onClose={() => { setTrustOpen(false); trustResolve && trustResolve(false); }} title="Enable third-party plugins?" centered>
|
||||||
|
<Stack>
|
||||||
|
<Text size="sm">
|
||||||
|
Plugins run server-side code with full access to your Dispatcharr instance and its data. Only enable plugins from developers you trust.
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Why: Malicious plugins could read or modify data, call internal APIs, or perform unwanted actions. Review the source or trust the author before enabling.
|
||||||
|
</Text>
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="default" size="xs" onClick={() => { setTrustOpen(false); trustResolve && trustResolve(false); }}>Cancel</Button>
|
||||||
|
<Button size="xs" color="red" onClick={() => { setTrustOpen(false); trustResolve && trustResolve(true); }}>I understand, enable</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Delete Plugin Modal */}
|
||||||
|
<Modal opened={deleteOpen} onClose={() => { setDeleteOpen(false); setDeleteTarget(null); }} title={deleteTarget ? `Delete ${deleteTarget.name}?` : 'Delete Plugin'} centered>
|
||||||
|
<Stack>
|
||||||
|
<Text size="sm">This will remove the plugin files and its configuration. This action cannot be undone.</Text>
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="default" size="xs" onClick={() => { setDeleteOpen(false); setDeleteTarget(null); }}>Cancel</Button>
|
||||||
|
<Button size="xs" color="red" loading={deleting} onClick={async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
const resp = await API.deletePlugin(deleteTarget.key);
|
||||||
|
if (resp?.success) {
|
||||||
|
setPlugins((prev) => prev.filter((p) => p.key !== deleteTarget.key));
|
||||||
|
notifications.show({ title: deleteTarget.name, message: 'Plugin deleted', color: 'green' });
|
||||||
|
}
|
||||||
|
setDeleteOpen(false);
|
||||||
|
setDeleteTarget(null);
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
}}>Delete</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
</AppShell.Main>
|
</AppShell.Main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue