mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-22 18:28:00 +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.
|
||||
|
||||
### Confirmation Prompt
|
||||
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.
|
||||
### Action Confirmation (Modal)
|
||||
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:
|
||||
```
|
||||
fields = [
|
||||
{"id": "confirm", "label": "Require confirmation", "type": "boolean", "default": True},
|
||||
actions = [
|
||||
{
|
||||
"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/`
|
||||
- Response: `{ "plugins": [{ key, name, version, description, enabled, fields, settings, actions }, ...] }`
|
||||
- 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": {...}}`
|
||||
- Run action: `POST /api/plugins/plugins/<key>/run/` with `{"action": "id", "params": {...}}`
|
||||
- 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
|
||||
|
||||
- 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.
|
||||
- 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`
|
||||
- Frontend page: `frontend/src/pages/Plugins.jsx`
|
||||
- Sidebar entry: `frontend/src/components/Sidebar.jsx`
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ from .api_views import (
|
|||
PluginSettingsAPIView,
|
||||
PluginRunAPIView,
|
||||
PluginEnabledAPIView,
|
||||
PluginImportAPIView,
|
||||
PluginDeleteAPIView,
|
||||
)
|
||||
|
||||
app_name = "plugins"
|
||||
|
|
@ -12,6 +14,8 @@ app_name = "plugins"
|
|||
urlpatterns = [
|
||||
path("plugins/", PluginsListAPIView.as_view(), name="list"),
|
||||
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>/run/", PluginRunAPIView.as_view(), name="run"),
|
||||
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 import status
|
||||
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 (
|
||||
Authenticated,
|
||||
permission_classes_by_method,
|
||||
|
|
@ -45,6 +52,144 @@ class PluginReloadAPIView(APIView):
|
|||
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):
|
||||
def get_permissions(self):
|
||||
try:
|
||||
|
|
@ -115,7 +260,47 @@ class PluginEnabledAPIView(APIView):
|
|||
try:
|
||||
cfg = PluginConfig.objects.get(key=key)
|
||||
cfg.enabled = bool(enabled)
|
||||
cfg.save(update_fields=["enabled", "updated_at"])
|
||||
return Response({"success": True, "enabled": cfg.enabled})
|
||||
# Mark that this plugin has been enabled at least once
|
||||
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:
|
||||
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
|
||||
|
||||
def _load_plugin(self, key: str, path: str):
|
||||
# Plugin can be a package or contain plugin.py
|
||||
module_name = None
|
||||
if os.path.exists(os.path.join(path, "__init__.py")):
|
||||
module_name = key
|
||||
elif os.path.exists(os.path.join(path, "plugin.py")):
|
||||
module_name = f"{key}.plugin"
|
||||
else:
|
||||
# Plugin can be a package and/or contain plugin.py. Prefer plugin.py when present.
|
||||
has_pkg = os.path.exists(os.path.join(path, "__init__.py"))
|
||||
has_pluginpy = os.path.exists(os.path.join(path, "plugin.py"))
|
||||
if not (has_pkg or has_pluginpy):
|
||||
logger.debug(f"Skipping {path}: no plugin.py or package")
|
||||
return
|
||||
|
||||
logger.debug(f"Importing plugin module {module_name}")
|
||||
module = importlib.import_module(module_name)
|
||||
candidate_modules = []
|
||||
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:
|
||||
logger.warning(f"Module {module_name} has no Plugin class; skipping")
|
||||
return
|
||||
if last_error:
|
||||
raise last_error
|
||||
else:
|
||||
logger.warning(f"No Plugin class found for {key}; skipping")
|
||||
return
|
||||
|
||||
instance = plugin_cls()
|
||||
|
||||
|
|
@ -138,8 +155,10 @@ class PluginManager:
|
|||
def list_plugins(self) -> List[Dict[str, Any]]:
|
||||
from .models import PluginConfig
|
||||
|
||||
plugins = []
|
||||
plugins: List[Dict[str, Any]] = []
|
||||
configs = {c.key: c for c in PluginConfig.objects.all()}
|
||||
|
||||
# First, include all discovered plugins
|
||||
for key, lp in self._registry.items():
|
||||
conf = configs.get(key)
|
||||
plugins.append(
|
||||
|
|
@ -148,12 +167,35 @@ class PluginManager:
|
|||
"name": lp.name,
|
||||
"version": lp.version,
|
||||
"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 [],
|
||||
"settings": (conf.settings if conf else {}),
|
||||
"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
|
||||
|
||||
def get_plugin(self, key: str) -> Optional[LoadedPlugin]:
|
||||
|
|
|
|||
|
|
@ -15,11 +15,11 @@ class Migration(migrations.Migration):
|
|||
("name", models.CharField(max_length=255)),
|
||||
("version", models.CharField(blank=True, default="", max_length=64)),
|
||||
("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)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
)
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -8,11 +8,12 @@ class PluginConfig(models.Model):
|
|||
name = models.CharField(max_length=255)
|
||||
version = models.CharField(max_length=64, 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)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
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) {
|
||||
try {
|
||||
const response = await request(
|
||||
|
|
@ -1273,7 +1301,7 @@ export default class API {
|
|||
method: 'POST',
|
||||
body: { enabled },
|
||||
});
|
||||
return response?.enabled;
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to update plugin enabled state', e);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
|
|||
import {
|
||||
AppShell,
|
||||
Box,
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
|
|
@ -15,8 +16,11 @@ import {
|
|||
Divider,
|
||||
ActionIcon,
|
||||
SimpleGrid,
|
||||
Modal,
|
||||
FileInput,
|
||||
} from '@mantine/core';
|
||||
import { RefreshCcw } from 'lucide-react';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { RefreshCcw, Trash2 } from 'lucide-react';
|
||||
import API from '../api';
|
||||
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 [saving, setSaving] = 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 [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) => {
|
||||
setSettings((prev) => ({ ...prev, [id]: val }));
|
||||
|
|
@ -83,30 +98,52 @@ const PluginCard = ({ plugin, onSaveSettings, onRunAction, onToggleEnabled }) =>
|
|||
}
|
||||
};
|
||||
|
||||
const missing = plugin.missing;
|
||||
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">
|
||||
<div>
|
||||
<Text fw={600}>{plugin.name}</Text>
|
||||
<Text size="sm" c="dimmed">{plugin.description}</Text>
|
||||
</div>
|
||||
<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>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
checked={!missing && enabled}
|
||||
onChange={async (e) => {
|
||||
const next = e.currentTarget.checked;
|
||||
if (next && !plugin.ever_enabled && onRequireTrust) {
|
||||
const ok = await onRequireTrust(plugin);
|
||||
if (!ok) {
|
||||
// Revert
|
||||
setEnabled(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
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"
|
||||
onLabel="On"
|
||||
offLabel="Off"
|
||||
disabled={missing}
|
||||
/>
|
||||
</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">
|
||||
{plugin.fields.map((f) => (
|
||||
<Field key={f.id} field={f} value={settings?.[f.id]} onChange={updateField} />
|
||||
|
|
@ -117,7 +154,7 @@ const PluginCard = ({ plugin, onSaveSettings, onRunAction, onToggleEnabled }) =>
|
|||
</Stack>
|
||||
)}
|
||||
|
||||
{plugin.actions && plugin.actions.length > 0 && (
|
||||
{!missing && plugin.actions && plugin.actions.length > 0 && (
|
||||
<>
|
||||
<Divider my="sm" />
|
||||
<Stack gap="xs">
|
||||
|
|
@ -136,18 +173,31 @@ const PluginCard = ({ plugin, onSaveSettings, onRunAction, onToggleEnabled }) =>
|
|||
setRunning(true);
|
||||
setLastResult(null);
|
||||
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');
|
||||
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 effectiveConfirm = (settingVal !== undefined ? settingVal : confirmField.default) ?? false;
|
||||
requireConfirm = !!effectiveConfirm;
|
||||
}
|
||||
|
||||
if (requireConfirm) {
|
||||
const ok = window.confirm(`Run "${a.label}" from "${plugin.name}"?`);
|
||||
if (!ok) { return; }
|
||||
await new Promise((resolve) => {
|
||||
setConfirmConfig({ title: confirmTitle, message: confirmMessage, onConfirm: resolve });
|
||||
setConfirmOpen(true);
|
||||
});
|
||||
}
|
||||
|
||||
// Save settings before running to ensure backend uses latest values
|
||||
|
|
@ -184,6 +234,19 @@ const PluginCard = ({ plugin, onSaveSettings, onRunAction, onToggleEnabled }) =>
|
|||
</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>
|
||||
);
|
||||
};
|
||||
|
|
@ -191,6 +254,17 @@ const PluginCard = ({ plugin, onSaveSettings, onRunAction, onToggleEnabled }) =>
|
|||
export default function PluginsPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
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 () => {
|
||||
setLoading(true);
|
||||
|
|
@ -206,13 +280,25 @@ export default function PluginsPage() {
|
|||
load();
|
||||
}, []);
|
||||
|
||||
const requireTrust = (plugin) => {
|
||||
return new Promise((resolve) => {
|
||||
setTrustResolve(() => resolve);
|
||||
setTrustOpen(true);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AppShell.Main style={{ padding: 16 }}>
|
||||
<Group justify="space-between" mb="md">
|
||||
<Text fw={700} size="lg">Plugins</Text>
|
||||
<ActionIcon variant="light" onClick={async () => { await API.reloadPlugins(); await load(); }} title="Reload">
|
||||
<RefreshCcw size={18} />
|
||||
</ActionIcon>
|
||||
<Group>
|
||||
<Button size="xs" variant="light" onClick={() => { setImportOpen(true); setImported(null); setImportFile(null); setEnableAfterImport(false); }}>
|
||||
Import Plugin
|
||||
</Button>
|
||||
<ActionIcon variant="light" onClick={async () => { await API.reloadPlugins(); await load(); }} title="Reload">
|
||||
<RefreshCcw size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{loading ? (
|
||||
|
|
@ -226,7 +312,17 @@ export default function PluginsPage() {
|
|||
plugin={p}
|
||||
onSaveSettings={API.updatePluginSettings}
|
||||
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>
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue