Archive plugin system prototype

We’re not using this prototype. It remains here solely for reference.
This commit is contained in:
Dispatcharr 2025-10-05 15:15:14 -05:00
parent 6536f35dc0
commit 264c97caaf
16 changed files with 6923 additions and 189 deletions

View file

@ -52,6 +52,8 @@ class Plugin:
---
> **Heads up:** The example above uses the legacy `fields` + `actions` interface. Existing plugins continue to work unchanged, but Dispatcharr now ships with a declarative UI schema that lets plugins build complete dashboards, tables, charts, forms, and sidebar pages. Jump to [Advanced UI schema](#advanced-ui-schema) for details.
## Where Plugins Live
- Default directory: `/app/data/plugins` inside the container.
@ -78,6 +80,17 @@ Backend code:
- API URLs: `apps/plugins/api_urls.py`
- Model: `apps/plugins/models.py` (stores `enabled` flag and `settings` per plugin)
### Plugin Management UI
The Plugins page provides:
- Enable/disable toggle per plugin (with first-use trust modal).
- Card status badges showing the last reload time or the most recent reload error.
- Search/filter input for quickly locating plugins by name/description.
- Open button for plugins that define an advanced UI layout.
- Reorder controls that change the sidebar order for `placement: "sidebar"` pages.
- Inline delete/import/reload operations (mirrors REST endpoints listed below).
---
## Plugin Interface
@ -89,7 +102,9 @@ Export a `Plugin` class. Supported attributes and behavior:
- `description` (str): Short description.
- `fields` (list): Settings schema used by the UI to render controls.
- `actions` (list): Available actions; the UI renders a Run button for each.
- `ui` / `ui_schema` (dict, optional): Declarative UI specification (see [Advanced UI Schema](#advanced-ui-schema)).
- `run(action, params, context)` (callable): Invoked when a user clicks an action.
- `resolve_ui_resource(resource_id, params, context)` (optional callable): Handle advanced UI data requests.
### Settings Schema
Supported field `type`s:
@ -115,6 +130,7 @@ Each action is a dict:
- `id` (str): Unique action id.
- `label` (str): Button label.
- `description` (str, optional): Helper text.
- Optional keys: `button_label`, `running_label`, `variant`, `color`, `size`, `success_message`, `error_message`, `confirm`, `params`, `download` metadata, etc.
Clicking an action calls your plugins `run(action, params, context)` and shows a notification with the result or error.
@ -138,6 +154,279 @@ actions = [
---
## Advanced UI Schema
Dispatcharr now includes a declarative UI builder so plugins can render full dashboards, tables, data visualisations, forms, and even custom sidebar pages. The legacy `fields` + `actions` attributes continue to work; the optional `ui`/`ui_schema` attribute extends that foundation without breaking existing plugins.
### Declaring the Schema
Define a `ui` (or `ui_schema`) attribute on your plugin. The schema is JSON-serialisable and describes data sources, a component tree, and optional additional pages.
```python
class Plugin:
name = "Service Monitor"
version = "2.0.0"
description = "Track worker status and run jobs."
ui = {
"version": 1,
"dataSources": {
"workers": {
"type": "action",
"action": "list_workers",
"refresh": {"interval": 30},
"subscribe": {"event": "plugin_event", "filter": {"event": "worker_update"}},
},
"metrics": {
"type": "resource",
"resource": "metrics",
"allowDisabled": True,
"refresh": {"interval": 10},
},
},
"layout": {
"type": "stack",
"gap": "md",
"children": [
{"type": "stat", "label": "Active Workers", "value": "{{ metrics.active }}", "icon": "Server"},
{
"type": "table",
"source": "workers",
"columns": [
{"id": "name", "label": "Name", "accessor": "name", "sortable": True},
{"id": "status", "label": "Status", "badge": {"colors": {"up": "teal", "down": "red"}}},
{
"id": "actions",
"type": "actions",
"actions": [
{"id": "restart_worker", "label": "Restart", "button_label": "Restart", "color": "orange", "params": {"id": "{{ row.id }}"}},
],
},
],
"expandable": {
"fields": [
{"path": "last_heartbeat", "label": "Last heartbeat"},
{"path": "notes", "label": "Notes"},
]
},
},
{
"type": "form",
"title": "Queue Job",
"action": "queue_job",
"submitLabel": "Queue",
"fields": [
{"id": "name", "label": "Job Name", "type": "text", "required": True},
{"id": "priority", "label": "Priority", "type": "number", "default": 5, "min": 1, "max": 10},
{"id": "payload", "label": "Payload", "type": "json"},
],
},
],
},
"pages": [
{
"id": "service-dashboard",
"label": "Service Dashboard",
"placement": "sidebar",
"icon": "Activity",
"route": "/dashboards/service-monitor",
"layout": {
"type": "tabs",
"tabs": [
{"id": "overview", "label": "Overview", "children": [{"type": "chart", "chartType": "line", "source": "metrics", "xKey": "timestamp", "series": [{"id": "queue", "dataKey": "queue_depth", "color": "#4dabf7"}]}]},
{"id": "logs", "label": "Logs", "children": [{"type": "logStream", "source": "workers", "path": "payload.logs", "limit": 200}]},
],
},
}
],
}
```
### Pages & Navigation
- `layout` component tree rendered inside the plugin card on the Plugins page. Use layout components such as `stack`, `group`, `grid`, `card`, `tabs`, `accordion`, `modal`, or `drawer` to organise content.
- `pages` optional additional pages. Set `placement`:
- `plugin` (default) renders inside the plugin card.
- `sidebar` adds a navigation entry in the main sidebar and exposes a route (`page.route` or `/plugins/<key>/<page id>`).
- `hidden` registered but not surfaced automatically.
- `icon` accepts any [lucide](https://lucide.dev) icon name (`"Activity"`, `"Server"`, `"Gauge"`, etc.).
- `requiresSetting` (optional) hides the page unless the specified setting is truthy—useful for feature toggles such as a “Show in sidebar” switch.
Pages render inside `/plugins/<plugin-key>` and can also map to custom routes. Dispatcharr automatically registers `<Route path='/plugins/<key>' …>` and any explicit `page.route`. The Sidebar reads `placement: "sidebar"` pages and lists them under the standard navigation.
### Data Sources
Declare reusable data sources under `ui.dataSources` and reference them by id from components (`{"type": "table", "source": "alerts"}`). Each source can be customised by components via `dataSource` overrides and at runtime via templated params.
| Option | Description |
| --- | --- |
| `type` | `action` (default) calls `Plugin.run`; `resource` calls `resolve_ui_resource`; `static` returns a literal payload; `url` performs an HTTP request |
| `action` / `resource` | Identifier invoked for `type: action`/`resource` |
| `params` | Base parameters merged with component `params` and runtime overrides |
| `refresh.interval` | Poll every _n_ seconds (`{"interval": 5}`) |
| `refresh.lazy` | Skip the initial fetch; the component can call `refresh()` manually |
| `allowDisabled` | Allow a resource to run even when the plugin is disabled (read-only dashboards) |
| `default` | Fallback data while the first fetch runs (accepts literals or callables) |
| `extract` / `responsePath` / `path` | Dot-path into the response object (e.g. `payload.items`) |
| `pick` | For array responses, keep only specified keys per object |
| `subscribe` | WebSocket subscription spec for live updates (see below) |
**WebSocket subscriptions**
```json
"subscribe": {
"event": "plugin_event",
"filter": { "plugin": "self", "event": "log" },
"mode": "append",
"path": "payload.entry",
"limit": 200
}
```
- `mode: "refresh"` (default) triggers a refetch when the filter matches.
- `mode: "append"` treats the data as an array, appending or prepending (`"prepend": true`) new entries, trimmed by `limit`.
- `mode: "patch"` merges object payloads into the current state.
- `path` resolves the payload (falls back to `event.payload`).
Emit events with `context["emit_event"]("log", {"entry": {...}})` or `send_websocket_update`.
**HTTP sources**
```json
"dataSources": {
"external": {
"type": "url",
"url": "https://api.example.com/metrics",
"method": "POST",
"params": { "token": "{{ settings.api_token }}" }
}
}
```
`type: "url"` honours `method`, `headers`, and serialises `params` (JSON for non-GET, query string for GET).
### Templating & Scope
Any string value can reference data with `{{ ... }}`. The renderer merges several scopes:
- `settings` plugin settings returned by the backend.
- `context` metadata provided to `PluginCanvas` (`plugin`, `page`, `location`).
- `{sourceId}` payload for each data source (e.g. `summary`, `alerts`).
- `data` shorthand for the payload bound to the current component.
- `row`, `value` row-level context inside tables, card lists, and sortable lists.
Examples:
```json
"value": "{{ summary.metrics.health_percent }}%",
"confirm": {"message": "Stop channel {{ row.channel_display }}?"},
"params": {"id": "{{ row.id }}", "cluster": "{{ context.plugin.settings.cluster }}"}
```
### Component Library
The renderer understands a broad set of components. Highlights include:
- **Layout** `stack`, `group`, `grid`, `card`, `tabs`, `accordion`, `split`, `modal`, `drawer`, `simpleGrid`.
- **Forms & Inputs** text/password/search, textarea, number with min/max/step, sliders and range sliders, checkbox/switch/radio, single & multi select (searchable + creatable tags), segmented controls, date/time/datetime/daterange pickers, color picker, file upload (drag-and-drop via dropzone), JSON editor, chips/tag input.
- **Data displays** tables with sorting, column filters, pagination, inline/per-row actions (templated params, confirmations), expandable detail rows; card lists with thumbnails/metadata; tree/hierarchical lists; timeline; statistic cards; markdown/html blocks.
- **Charts & Visualisations** line, area, bar, pie/donut, radar, heatmap, progress bars, ring/radial progress, loaders/spinners, status lights with custom colours.
- **Real-time** `logStream`, auto-refresh data sources, event subscriptions, status indicators that update via WebSocket.
- **Interactions** `actionButton`, button groups, confirmation modals, sortable/drag-and-drop lists, embedded forms, `settingsForm` (binds directly to plugin settings).
### Forms & Settings
- `form` arbitrary action forms. `fields` accept any input type listed above. Useful options: `submitLabel`, `resetOnSuccess`, `encode: 'formdata'` for file uploads, `actions` (secondary buttons), `initialValues`, `successMessage`, `errorMessage`, `confirm` (modal before submit).
- `settingsForm` specialised form that reads/writes `PluginConfig.settings` automatically.
- `action`, `actionButton`, and `buttons` lightweight buttons that trigger actions. They support templated `params` (`{"channel_id": "{{ row.channel_id }}"}`), templated `confirm` objects (`{"title": "Delete", "message": "Remove {{ row.name }}?", "confirmLabel": "Delete"}`), and inherit the button styling keys (`variant`, `color`, `size`, `icon`).
- Return `{"download": {"filename": "report.csv", "content_type": "text/csv", "data": base64}}` (or `download.url`) from `run` to trigger a download, which the UI automatically handles.
### Statistic Cards (`stat`)
Use stat nodes for quick KPIs. They can display literal values or read from a data source.
```json
{
"type": "stat",
"source": "summary",
"label": "Active Channels",
"metricPath": "summary.metrics.active_channels.display",
"fallback": "0",
"icon": "Activity",
"delta": "{{ summary.metrics.active_channels.delta }}"
}
```
- `source` + `metricPath` resolves a value from the bound data. The component scope exposes `data`, `{sourceId}`, and `context` (plugin metadata, current page, etc.).
- `fallback`, `defaultValue`, or `placeholder` are shown when the metric is missing or still loading.
- `delta` renders a green/red indicator automatically when numeric. Provide plain text ("+5% vs last hour") to bypass arrows.
### Tables & Row Actions
- `columns` support `accessor`, `template`, `format` (`date`, `time`, `datetime`), `badge` colour maps, `render` (`json`, `status`, `progress`), `width`, and `sortable` flags.
- `rowActions` renders button groups at the end of each row. Actions inherit the same schema as `actionButton` (params & confirm templating, variants, icons). Example:
```json
{
"type": "table",
"source": "workers",
"columns": [ ... ],
"rowActions": [
{
"id": "restart_worker",
"label": "Restart",
"color": "orange",
"params": {"worker_id": "{{ row.id }}"},
"confirm": {"title": "Restart?", "message": "Restart {{ row.name }}?", "confirmLabel": "Restart"}
}
],
"expandable": {
"fields": [
{"label": "Last heartbeat", "path": "last_heartbeat"},
{"label": "Notes", "path": "notes"}
]
},
"initialSort": [{"id": "status", "desc": true}],
"filterable": true,
"pageSize": 25
}
```
- `expandable` with `fields` renders key/value pairs; omit `fields` to show JSON.
- `initialSort`, `filterable`, `pageSize`, and column-level `filter` definitions enable familiar datatable behaviour.
### Real-time Widgets
- `logStream` consumes append-mode data sources. Configure `dataSource` overrides to change polling interval, limits, or default text.
- `timeline`, `tree`, `cardList`, `progress`, `loader`, `status`, and the various `chart` types all accept `source` and templated values. Provide `series` definitions for multi-line charts (`[{"id": "errors", "dataKey": "errors", "color": "#fa5252"}]`).
- `sortableList` enables drag-and-drop reordering of items. When `action` is set, the renderer sends `{ order: [ids...] }` to that action after each drop; call the supplied `refresh()` callback to reload.
### Real-time & Events
- Call `context["emit_event"](event_name, payload)` inside `run` or `resolve_ui_resource` to broadcast `{"type": "plugin_event", "plugin": key, "event": event_name, "payload": payload}` over the `updates` WebSocket channel. Components with `subscribe` refresh automatically and the frontend can show rich notifications when `notification` metadata is included.
- `context["files"]` exposes uploaded files when an action is triggered with multipart/form-data. Each entry is a Django `UploadedFile`.
- `context["ui_schema"]` returns the resolved schema for convenience.
### Backend Helpers
- `resolve_ui_resource(self, resource_id, params, context)` optional method invoked by `type: "resource"` data sources or `POST /api/plugins/plugins/<key>/ui/resource/`. Return JSON-like structures (dict/list) or raise to signal errors. `allowDisabled=True` lets resources run when the plugin is disabled (useful for dashboards).
- `context` now includes `emit_event`, `files`, `plugin` metadata, and the `actions` map alongside `settings` and `logger`.
- `/api/plugins/plugins/<key>/ui/resource/` accepts JSON or form data (`resource`, `params`, `allow_disabled`). Responses mirror `run`: `{"success": true, "result": {...}}`.
### Sidebar & Workspace
- The Plugins page renders the primary `layout`. Clicking **Open** on a plugin with an advanced UI navigates to `/plugins/<key>` which hosts the same layout. Additional pages registered with `placement: "sidebar"` appear in the main navigation and receive dedicated routes (`page.route` or `/plugins/<key>/<page id>`).
- All pages share the same component library; the only difference is where they surface.
### Compatibility
- `fields` + `actions` remain fully supported. Use them for quick settings; mix in `ui` gradually.
- When both are provided, the legacy sections render only if no advanced layout is supplied for the plugin card.
---
---
## Accessing Dispatcharr APIs from Plugins
Plugins are server-side Python code running within the Django application. You can:
@ -159,10 +448,13 @@ Plugins are server-side Python code running within the Django application. You c
refresh_all_epg_data.delay()
```
- Send WebSocket updates:
- Send WebSocket updates or trigger UI refreshes:
```
from core.utils import send_websocket_update
send_websocket_update('updates', 'update', {"type": "plugin", "plugin": "my_plugin", "message": "Done"})
# Inside run / resolve_ui_resource you can also use the provided helper:
context["emit_event"]("worker_update", {"id": worker.id, "status": "up"})
```
- Use transactions:
@ -180,18 +472,86 @@ Plugins are server-side Python code running within the Django application. You c
logger.info("running action %s", action)
```
- Access uploaded files submitted through advanced forms:
```
def run(self, action, params, context):
files = context.get("files", {}) # dict keyed by form field id
upload = files.get("payload")
if upload:
handle_file(upload)
```
Prefer Celery tasks (`.delay()`) to keep `run` fast and non-blocking.
### Core Django Modules
Prefer calling Django models and services directly; the REST API uses the same code paths. Common imports include:
```python
# Core configuration and helpers
from core.models import CoreSettings, StreamProfile, UserAgent
from core.utils import RedisClient, send_websocket_update
# Channels / DVR
from apps.channels.models import (
Channel, ChannelGroup, ChannelStream, Stream,
Recording, RecurringRecordingRule, ChannelProfile,
)
from apps.channels.tasks import (
match_channels_to_epg, match_epg_channels, match_single_channel_epg,
evaluate_series_rules, reschedule_upcoming_recordings_for_offset_change,
rebuild_recurring_rule, maintain_recurring_recordings,
run_recording, recover_recordings_on_startup, comskip_process_recording,
prefetch_recording_artwork,
)
from apps.channels.services.channel_service import ChannelService
# M3U / ingest sources
from apps.m3u.models import M3UAccount, M3UFilter, M3UAccountProfile, ServerGroup
from apps.m3u.tasks import (
refresh_m3u_accounts, refresh_single_m3u_account,
refresh_m3u_groups, cleanup_streams, sync_auto_channels,
refresh_account_info,
)
# EPG
from apps.epg.models import EPGSource, EPGData, ProgramData
from apps.epg.tasks import refresh_all_epg_data, refresh_epg_data, parse_programs_for_source
# VOD / media library
from apps.vod.models import (
VODCategory, Series, Movie, Episode,
M3USeriesRelation, M3UMovieRelation, M3UEpisodeRelation,
)
from apps.vod.tasks import (
refresh_vod_content, refresh_categories, refresh_movies,
refresh_series, refresh_series_episodes, cleanup_orphaned_vod_content,
)
# Proxy / streaming state
from apps.proxy.ts_proxy.channel_status import ChannelStatus
from apps.proxy.ts_proxy.services.channel_service import ChannelService as TsChannelService
from apps.proxy.ts_proxy.utils import detect_stream_type, get_client_ip
from apps.proxy.vod_proxy.multi_worker_connection_manager import MultiWorkerVODConnectionManager
# Plugin infrastructure
from apps.plugins.loader import PluginManager
from apps.plugins.models import PluginConfig
```
Each app exposes additional utilities (serializers, services, helpers). Browse the `apps/` directory to discover modules relevant to your plugin.
---
## REST Endpoints (for UI and tooling)
- 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, ui_schema }, ...] }`
- 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": {...}}`
- Resolve UI resource: `POST /api/plugins/plugins/<key>/ui/resource/` with `{"resource": "id", "params": {...}, "allow_disabled": false}`
- Enable/disable: `POST /api/plugins/plugins/<key>/enabled/` with `{"enabled": true|false}`
Notes:

View file

@ -7,6 +7,7 @@ from .api_views import (
PluginEnabledAPIView,
PluginImportAPIView,
PluginDeleteAPIView,
PluginUIResourceAPIView,
)
app_name = "plugins"
@ -18,5 +19,6 @@ urlpatterns = [
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>/ui/resource/", PluginUIResourceAPIView.as_view(), name="ui-resource"),
path("plugins/<str:key>/enabled/", PluginEnabledAPIView.as_view(), name="enabled"),
]

View file

@ -1,8 +1,8 @@
import json
import logging
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
@ -21,6 +21,45 @@ from .models import PluginConfig
logger = logging.getLogger(__name__)
def _normalize_params(raw):
if raw is None:
return {}
if isinstance(raw, dict):
return raw
if hasattr(raw, "dict"):
try:
return raw.dict()
except Exception:
pass
if hasattr(raw, "items") and not isinstance(raw, dict):
try:
return {k: v for k, v in raw.items()}
except Exception:
return {}
if isinstance(raw, (list, tuple)):
return list(raw)
if isinstance(raw, str):
payload = raw.strip()
if not payload:
return {}
try:
decoded = json.loads(payload)
return decoded if isinstance(decoded, (dict, list)) else {"value": decoded}
except json.JSONDecodeError:
return {"value": payload}
return raw
def _coerce_bool(raw) -> bool:
if isinstance(raw, bool):
return raw
if raw is None:
return False
if isinstance(raw, str):
return raw.strip().lower() in {"1", "true", "yes", "on"}
return bool(raw)
class PluginsListAPIView(APIView):
def get_permissions(self):
try:
@ -186,6 +225,7 @@ class PluginImportAPIView(APIView):
"ever_enabled": ever_enabled,
"fields": plugin.fields or [],
"actions": plugin.actions or [],
"ui_schema": plugin.ui_schema or {},
}
})
@ -221,8 +261,9 @@ class PluginRunAPIView(APIView):
def post(self, request, key):
pm = PluginManager.get()
action = request.data.get("action")
params = request.data.get("params", {})
data = request.data or {}
action = data.get("action")
params = _normalize_params(data.get("params", {}))
if not action:
return Response({"success": False, "error": "Missing 'action'"}, status=status.HTTP_400_BAD_REQUEST)
@ -235,7 +276,13 @@ class PluginRunAPIView(APIView):
return Response({"success": False, "error": "Plugin not found"}, status=status.HTTP_404_NOT_FOUND)
try:
result = pm.run_action(key, action, params)
result = pm.run_action(
key,
action,
params,
request=request,
files=request.FILES or None,
)
return Response({"success": True, "result": result})
except PermissionError as e:
return Response({"success": False, "error": str(e)}, status=status.HTTP_403_FORBIDDEN)
@ -244,6 +291,56 @@ class PluginRunAPIView(APIView):
return Response({"success": False, "error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
class PluginUIResourceAPIView(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, key):
pm = PluginManager.get()
data = request.data or {}
resource = data.get("resource") or data.get("id")
if not resource:
return Response(
{"success": False, "error": "Missing 'resource' identifier"},
status=status.HTTP_400_BAD_REQUEST,
)
params = _normalize_params(data.get("params", {}))
allow_disabled = _coerce_bool(data.get("allow_disabled", False))
try:
result = pm.resolve_ui_resource(
key,
resource,
params,
request=request,
files=request.FILES or None,
allow_disabled=allow_disabled,
)
return Response({"success": True, "result": result})
except PermissionError as e:
return Response(
{"success": False, "error": str(e)},
status=status.HTTP_403_FORBIDDEN,
)
except PluginConfig.DoesNotExist:
return Response(
{"success": False, "error": "Plugin not found"},
status=status.HTTP_404_NOT_FOUND,
)
except Exception as e:
logger.exception("Plugin resource resolution failed")
return Response(
{"success": False, "error": str(e)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
class PluginEnabledAPIView(APIView):
def get_permissions(self):
try:

View file

@ -1,3 +1,4 @@
import copy
import importlib
import json
import logging
@ -8,6 +9,8 @@ from typing import Any, Dict, List, Optional
from django.db import transaction
from core.utils import send_websocket_update
from .models import PluginConfig
logger = logging.getLogger(__name__)
@ -23,6 +26,7 @@ class LoadedPlugin:
instance: Any = None
fields: List[Dict[str, Any]] = field(default_factory=list)
actions: List[Dict[str, Any]] = field(default_factory=list)
ui_schema: Dict[str, Any] = field(default_factory=dict)
class PluginManager:
@ -86,11 +90,23 @@ class PluginManager:
logger.debug(f"Skipping {path}: no plugin.py or package")
return
candidate_modules = []
if has_pluginpy:
candidate_modules.append(f"{key}.plugin")
if has_pkg:
candidate_modules.append(key)
module_base = os.path.basename(path)
sanitized_base = module_base.replace(" ", "_").replace("-", "_") if module_base else ""
module_variants: List[str] = []
for variant in (sanitized_base, key):
if not variant:
continue
if variant not in module_variants:
module_variants.append(variant)
candidate_modules: List[str] = []
for base in module_variants:
if has_pluginpy:
module_name = f"{base}.plugin"
if module_name not in candidate_modules:
candidate_modules.append(module_name)
if has_pkg and base not in candidate_modules:
candidate_modules.append(base)
module = None
plugin_cls = None
@ -121,7 +137,34 @@ class PluginManager:
version = getattr(instance, "version", "")
description = getattr(instance, "description", "")
fields = getattr(instance, "fields", [])
if callable(fields):
fields = fields()
actions = getattr(instance, "actions", [])
if callable(actions):
actions = actions()
ui_schema = getattr(instance, "ui_schema", None)
if ui_schema is None:
ui_schema = getattr(instance, "ui", None)
if callable(ui_schema):
ui_schema = ui_schema()
try:
fields = copy.deepcopy(fields) if fields is not None else []
except TypeError:
fields = json.loads(json.dumps(fields)) if fields else []
try:
actions = copy.deepcopy(actions) if actions is not None else []
except TypeError:
actions = json.loads(json.dumps(actions)) if actions else []
if ui_schema is None:
ui_schema = {}
else:
try:
ui_schema = copy.deepcopy(ui_schema)
except TypeError:
ui_schema = json.loads(json.dumps(ui_schema))
self._registry[key] = LoadedPlugin(
key=key,
@ -132,6 +175,7 @@ class PluginManager:
instance=instance,
fields=fields,
actions=actions,
ui_schema=ui_schema,
)
def _sync_db_with_registry(self):
@ -185,6 +229,7 @@ class PluginManager:
"fields": lp.fields or [],
"settings": (conf.settings if conf else {}),
"actions": lp.actions or [],
"ui_schema": lp.ui_schema or {},
"missing": False,
}
)
@ -205,6 +250,7 @@ class PluginManager:
"fields": [],
"settings": conf.settings or {},
"actions": [],
"ui_schema": {},
"missing": True,
}
)
@ -214,13 +260,49 @@ class PluginManager:
def get_plugin(self, key: str) -> Optional[LoadedPlugin]:
return self._registry.get(key)
def _build_context(self, lp: LoadedPlugin, cfg: PluginConfig, *, request=None, files=None):
def emit_event(event: str, payload: Optional[Dict[str, Any]] = None, *, success: bool = True):
data = {
"type": "plugin_event",
"plugin": lp.key,
"event": event,
"success": success,
}
if payload is not None:
data["payload"] = payload
send_websocket_update("updates", "update", data)
return {
"settings": cfg.settings or {},
"logger": logger,
"actions": {a.get("id"): a for a in (lp.actions or [])},
"emit_event": emit_event,
"request": request,
"files": files,
"ui_schema": lp.ui_schema or {},
"plugin": {
"key": lp.key,
"name": lp.name,
"version": lp.version,
"description": lp.description,
},
}
def update_settings(self, key: str, settings: Dict[str, Any]) -> Dict[str, Any]:
cfg = PluginConfig.objects.get(key=key)
cfg.settings = settings or {}
cfg.save(update_fields=["settings", "updated_at"])
return cfg.settings
def run_action(self, key: str, action_id: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
def run_action(
self,
key: str,
action_id: str,
params: Optional[Dict[str, Any]] = None,
*,
request=None,
files=None,
) -> Dict[str, Any]:
lp = self.get_plugin(key)
if not lp or not lp.instance:
raise ValueError(f"Plugin '{key}' not found")
@ -230,12 +312,7 @@ class PluginManager:
raise PermissionError(f"Plugin '{key}' is disabled")
params = params or {}
# Provide a context object to the plugin
context = {
"settings": cfg.settings or {},
"logger": logger,
"actions": {a.get("id"): a for a in (lp.actions or [])},
}
context = self._build_context(lp, cfg, request=request, files=files)
# Run either via Celery if plugin provides a delayed method, or inline
run_method = getattr(lp.instance, "run", None)
@ -252,3 +329,44 @@ class PluginManager:
if isinstance(result, dict):
return result
return {"status": "ok", "result": result}
def resolve_ui_resource(
self,
key: str,
resource_id: str,
params: Optional[Dict[str, Any]] = None,
*,
request=None,
files=None,
allow_disabled: bool = False,
) -> Dict[str, Any]:
lp = self.get_plugin(key)
if not lp or not lp.instance:
raise ValueError(f"Plugin '{key}' not found")
cfg = PluginConfig.objects.get(key=key)
if not allow_disabled and not cfg.enabled:
raise PermissionError(f"Plugin '{key}' is disabled")
resolver = getattr(lp.instance, "resolve_ui_resource", None)
params = params or {}
context = self._build_context(lp, cfg, request=request, files=files)
if callable(resolver):
result = resolver(resource_id, params, context)
if isinstance(result, dict):
return result
return {"status": "ok", "result": result}
if cfg.enabled or not allow_disabled:
return self.run_action(
key,
resource_id,
params,
request=request,
files=files,
)
raise PermissionError(
f"Plugin '{key}' is disabled and cannot resolve resource '{resource_id}'"
)

View file

@ -1,5 +1,5 @@
// frontend/src/App.js
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import {
BrowserRouter as Router,
Route,
@ -15,6 +15,7 @@ import Stats from './pages/Stats';
import DVR from './pages/DVR';
import Settings from './pages/Settings';
import PluginsPage from './pages/Plugins';
import PluginWorkspace from './pages/PluginWorkspace';
import Users from './pages/Users';
import LogosPage from './pages/Logos';
import VODsPage from './pages/VODs';
@ -33,6 +34,7 @@ import API from './api';
import { Notifications } from '@mantine/notifications';
import M3URefreshNotification from './components/M3URefreshNotification';
import 'allotment/dist/style.css';
import usePluginsStore from './store/plugins';
const drawerWidth = 240;
const miniDrawerWidth = 60;
@ -48,6 +50,46 @@ const App = () => {
const initData = useAuthStore((s) => s.initData);
const initializeAuth = useAuthStore((s) => s.initializeAuth);
const setSuperuserExists = useAuthStore((s) => s.setSuperuserExists);
const pluginsMap = usePluginsStore((state) => state.plugins);
const pluginOrder = usePluginsStore((state) => state.order);
const pluginRoutes = useMemo(() => {
const routes = [];
const seen = new Set();
pluginOrder.forEach((key) => {
const plugin = pluginsMap[key];
if (!plugin) return;
const basePath = `/plugins/${key}`;
if (!seen.has(basePath)) {
routes.push({
path: basePath,
element: <PluginWorkspace key={`plugin-${key}`} pluginKey={key} />,
});
seen.add(basePath);
}
const ui = plugin.ui_schema || {};
const pages = Array.isArray(ui.pages) ? ui.pages : [];
pages.forEach((page) => {
if (page.placement === 'hidden') return;
const rawPath = page.route || `/plugins/${key}/${page.id}`;
const normalized = rawPath.startsWith('/') ? rawPath : `/${rawPath}`;
if (seen.has(normalized)) return;
routes.push({
path: normalized,
element: (
<PluginWorkspace
key={`plugin-${key}-${page.id}`}
pluginKey={key}
initialPageId={page.id}
/>
),
});
seen.add(normalized);
});
});
return routes;
}, [pluginsMap, pluginOrder]);
const toggleDrawer = () => {
setOpen(!open);
@ -81,6 +123,7 @@ const App = () => {
const loggedIn = await initializeAuth();
if (loggedIn) {
await initData();
await API.getPlugins({ resetError: true });
// Start background logo loading after app is fully initialized (only once)
if (!backgroundLoadingStarted) {
setBackgroundLoadingStarted(true);
@ -143,6 +186,13 @@ const App = () => {
<Route path="/dvr" element={<DVR />} />
<Route path="/stats" element={<Stats />} />
<Route path="/plugins" element={<PluginsPage />} />
{pluginRoutes.map((route) => (
<Route
key={route.path}
path={route.path}
element={route.element}
/>
))}
<Route path="/users" element={<Users />} />
<Route path="/settings" element={<Settings />} />
<Route path="/logos" element={<LogosPage />} />

View file

@ -17,12 +17,17 @@ import API from './api';
import useSettingsStore from './store/settings';
import useAuthStore from './store/auth';
export const WebsocketContext = createContext([false, () => {}, null]);
export const WebsocketContext = createContext([
false,
() => {},
{ lastEvent: null, subscribe: () => () => {} },
]);
export const WebsocketProvider = ({ children }) => {
const [isReady, setIsReady] = useState(false);
const [val, setVal] = useState(null);
const ws = useRef(null);
const listenersRef = useRef(new Set());
const reconnectTimerRef = useRef(null);
const [reconnectAttempts, setReconnectAttempts] = useState(0);
const [connectionError, setConnectionError] = useState(null);
@ -105,6 +110,16 @@ export const WebsocketProvider = ({ children }) => {
// Create new WebSocket connection
const socket = new WebSocket(wsUrl);
const notifyListeners = (payload) => {
listenersRef.current.forEach((listener) => {
try {
listener(payload);
} catch (listenerError) {
console.error('WebSocket listener error', listenerError);
}
});
};
socket.onopen = () => {
console.log('WebSocket connected successfully');
setIsReady(true);
@ -168,6 +183,8 @@ export const WebsocketProvider = ({ children }) => {
return;
}
notifyListeners(parsedEvent);
// Handle standard message format for other event types
switch (parsedEvent.data?.type) {
case 'comskip_status': {
@ -797,6 +814,29 @@ export const WebsocketProvider = ({ children }) => {
break;
}
case 'plugin_event': {
const data = parsedEvent.data || {};
const note = data.notification;
if (note) {
if (typeof note === 'string') {
notifications.show({
title: data.plugin || 'Plugin',
message: note,
});
} else if (typeof note === 'object') {
notifications.show({
title: note.title || data.event || data.plugin || 'Plugin',
message: note.message || '',
color: note.color,
autoClose: note.autoClose,
loading: note.loading,
});
}
}
setVal(parsedEvent);
break;
}
default:
console.error(
`Unknown websocket event type: ${parsedEvent.data?.type}`
@ -832,6 +872,7 @@ export const WebsocketProvider = ({ children }) => {
getReconnectDelay,
getWebSocketUrl,
isReady,
listenersRef,
]);
// Initial connection and cleanup
@ -870,9 +911,27 @@ export const WebsocketProvider = ({ children }) => {
const fetchLogos = useLogosStore((s) => s.fetchAllLogos);
const fetchChannelProfiles = useChannelsStore((s) => s.fetchChannelProfiles);
const subscribe = useCallback((listener) => {
if (typeof listener !== 'function') {
return () => {};
}
listenersRef.current.add(listener);
return () => {
listenersRef.current.delete(listener);
};
}, []);
const sendMessage = useCallback((payload) => {
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
ws.current.send(payload);
} else {
console.warn('WebSocket is not ready to send messages');
}
}, []);
const ret = useMemo(() => {
return [isReady, ws.current?.send.bind(ws.current), val];
}, [isReady, val]);
return [isReady, sendMessage, { lastEvent: val, subscribe }];
}, [isReady, sendMessage, val, subscribe]);
return (
<WebsocketContext.Provider value={ret}>

View file

@ -11,6 +11,7 @@ import useSettingsStore from './store/settings';
import { notifications } from '@mantine/notifications';
import useChannelsTableStore from './store/channelsTable';
import useUsersStore from './store/users';
import usePluginsStore from './store/plugins';
// If needed, you can set a base host or keep it empty if relative requests
const host = import.meta.env.DEV
@ -1303,22 +1304,39 @@ export default class API {
}
// Plugins API
static async getPlugins() {
static async getPlugins(options = {}) {
const store = usePluginsStore.getState();
store.setLoading(true);
if (options?.resetError) {
store.setError(null);
}
try {
const response = await request(`${host}/api/plugins/plugins/`);
return response.plugins || [];
const plugins = response.plugins || [];
store.setPlugins(plugins);
return plugins;
} catch (e) {
store.setError(e);
errorNotification('Failed to retrieve plugins', e);
} finally {
store.setLoading(false);
}
}
static async reloadPlugins() {
static async reloadPlugins(options = {}) {
try {
const response = await request(`${host}/api/plugins/plugins/reload/`, {
method: 'POST',
});
if (options?.refetch !== false) {
await API.getPlugins();
usePluginsStore.getState().markPluginsReloaded();
}
return response;
} catch (e) {
const message =
(e?.body && (e.body.error || e.body.detail)) || e?.message || 'Plugin reload failed';
usePluginsStore.getState().markPluginsReloadError(message);
errorNotification('Failed to reload plugins', e);
}
}
@ -1331,6 +1349,9 @@ export default class API {
method: 'POST',
body: form,
});
if (response?.plugin) {
usePluginsStore.getState().upsertPlugin(response.plugin);
}
return response;
} catch (e) {
// Show only the concise error message for plugin import
@ -1345,6 +1366,9 @@ export default class API {
const response = await request(`${host}/api/plugins/plugins/${key}/delete/`, {
method: 'DELETE',
});
if (response?.success) {
usePluginsStore.getState().removePlugin(key);
}
return response;
} catch (e) {
errorNotification('Failed to delete plugin', e);
@ -1360,30 +1384,116 @@ export default class API {
body: { settings },
}
);
if (response?.settings) {
usePluginsStore.getState().updateSettings(key, response.settings);
}
return response?.settings || {};
} catch (e) {
errorNotification('Failed to update plugin settings', e);
}
}
static async runPluginAction(key, action, params = {}) {
static async runPluginAction(key, action, params = {}, options = {}) {
try {
const response = await request(`${host}/api/plugins/plugins/${key}/run/`, {
let body;
if (options?.formData instanceof FormData) {
body = options.formData;
if (!options.skipActionField && !body.has('action')) {
body.append('action', action);
}
if (options.appendParams !== false) {
const payload =
typeof params === 'string' ? params : JSON.stringify(params || {});
body.append('params', payload);
}
} else {
body = { action, params };
}
const requestOptions = {
method: 'POST',
body: { action, params },
});
body,
...(options.fetchOptions || {}),
};
const response = await request(
`${host}/api/plugins/plugins/${key}/run/`,
requestOptions
);
if (options?.syncSettings && response?.result?.settings) {
usePluginsStore
.getState()
.updateSettings(key, response.result.settings);
}
return response;
} catch (e) {
errorNotification('Failed to run plugin action', e);
}
}
static async resolvePluginResource(
key,
resource,
params = {},
options = {}
) {
try {
let body;
if (options?.formData instanceof FormData) {
body = options.formData;
if (!body.has('resource')) {
body.append('resource', resource);
}
if (options.appendParams !== false) {
const payload =
typeof params === 'string' ? params : JSON.stringify(params || {});
body.append('params', payload);
}
if (options.allowDisabled !== undefined && !body.has('allow_disabled')) {
body.append('allow_disabled', String(options.allowDisabled));
}
} else {
body = {
resource,
params,
};
if (options.allowDisabled !== undefined) {
body.allow_disabled = options.allowDisabled;
}
}
const requestOptions = {
method: 'POST',
body,
...(options.fetchOptions || {}),
};
const response = await request(
`${host}/api/plugins/plugins/${key}/ui/resource/`,
requestOptions
);
return response?.result;
} catch (e) {
errorNotification('Failed to resolve plugin resource', e);
}
}
static async setPluginEnabled(key, enabled) {
try {
const response = await request(`${host}/api/plugins/plugins/${key}/enabled/`, {
method: 'POST',
body: { enabled },
});
if (response?.success && Object.prototype.hasOwnProperty.call(response, 'enabled')) {
usePluginsStore
.getState()
.updatePluginMeta(key, {
enabled: response.enabled,
ever_enabled: response.ever_enabled,
});
}
return response;
} catch (e) {
errorNotification('Failed to update plugin enabled state', e);

View file

@ -1,4 +1,4 @@
import React, { useRef, useEffect, useState } from 'react';
import React, { useRef, useEffect, useState, useMemo } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { copyToClipboard } from '../utils';
import {
@ -16,6 +16,7 @@ import {
User,
FileImage,
} from 'lucide-react';
import * as LucideIcons from 'lucide-react';
import {
Avatar,
AppShell,
@ -37,6 +38,8 @@ import useAuthStore from '../store/auth'; // Add this import
import API from '../api';
import { USER_LEVELS } from '../constants';
import UserForm from './forms/User';
import usePluginsStore from '../store/plugins';
import { ensureArray } from '../plugin-ui/utils';
const NavLink = ({ item, isActive, collapsed }) => {
return (
@ -89,8 +92,49 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
const closeUserForm = () => setUserFormOpen(false);
const pluginsMap = usePluginsStore((state) => state.plugins);
const pluginOrder = usePluginsStore((state) => state.order);
const resolvePluginIcon = (iconName) => {
if (!iconName) return null;
const formatted = iconName
.replace(/[-_](\w)/g, (_, char) => char.toUpperCase())
.replace(/^(\w)/, (match) => match.toUpperCase());
return LucideIcons[iconName] || LucideIcons[formatted] || null;
};
const pluginNavItems = useMemo(() => {
const items = [];
pluginOrder.forEach((key) => {
const plugin = pluginsMap[key];
if (!plugin || plugin.missing || !plugin.enabled) return;
const ui = plugin.ui_schema || {};
const pages = ensureArray(ui.pages);
pages.forEach((page) => {
if ((page.placement || 'plugin').toLowerCase() !== 'sidebar') return;
if (plugin.settings?.show_sidebar === false) return;
if (page.requiresSetting) {
const flag = plugin.settings?.[page.requiresSetting];
if (flag !== undefined && flag !== null && !flag) {
return;
}
}
const rawPath = page.route || `/plugins/${key}/${page.id}`;
const path = rawPath.startsWith('/') ? rawPath : `/${rawPath}`;
const IconComponent = resolvePluginIcon(page.icon || ui.icon);
items.push({
label: page.label || plugin.name,
path,
icon: IconComponent ? <IconComponent size={20} /> : <PlugZap size={20} />,
badge: page.badge,
});
});
});
return items;
}, [pluginOrder, pluginsMap]);
// Navigation Items
const navItems =
const baseNavItems =
authUser && authUser.user_level == USER_LEVELS.ADMIN
? [
{
@ -144,6 +188,8 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
},
];
const navItems = [...baseNavItems, ...pluginNavItems];
// Fetch environment settings including version on component mount
useEffect(() => {
if (!isAuthenticated) {

View file

@ -0,0 +1,136 @@
import React, { useMemo } from 'react';
import { useParams, useLocation, useNavigate } from 'react-router-dom';
import { AppShell, Box, Loader, Stack, Text, Tabs } from '@mantine/core';
import usePluginsStore from '../store/plugins';
import { PluginUIProvider, PluginCanvas } from '../plugin-ui';
import { ensureArray } from '../plugin-ui/utils';
const resolvePagePath = (pluginKey, page) => {
if (!page) return `/plugins/${pluginKey}`;
return page.route || `/plugins/${pluginKey}/${page.id}`;
};
const PluginWorkspace = ({ pluginKey: propKey, initialPageId }) => {
const params = useParams();
const location = useLocation();
const navigate = useNavigate();
const pluginKey = propKey || params.pluginKey;
const routePageId = params.pageId;
const plugin = usePluginsStore((state) =>
pluginKey ? state.plugins[pluginKey] : null
);
const loading = usePluginsStore((state) => state.loading);
const ui = plugin?.ui_schema || {};
const pages = useMemo(() => ensureArray(ui.pages), [ui.pages]);
const defaultPage = useMemo(() => {
if (!pages.length) return null;
const primary = pages.find((page) => (page.placement || 'plugin') === 'plugin');
return primary || pages[0];
}, [pages]);
const effectivePageId = routePageId || initialPageId || defaultPage?.id;
const targetPage = pages.find((page) => page.id === effectivePageId) || defaultPage;
const layout = targetPage?.layout || ui.layout;
const pageTitle = targetPage?.label || plugin?.name || 'Plugin';
const pageDescription = targetPage?.description || plugin?.description || '';
const tabPages = useMemo(
() =>
pages.filter((page) => {
if (page.placement === 'sidebar') return false;
if (page.placement === 'hidden') return false;
return true;
}),
[pages]
);
if (!plugin && loading) {
return (
<AppShell.Main style={{ padding: 24 }}>
<Loader />
</AppShell.Main>
);
}
if (!plugin) {
return (
<AppShell.Main style={{ padding: 24 }}>
<Stack gap="sm">
<Text fw={700} size="lg">
Plugin not found
</Text>
<Text size="sm" c="dimmed">
The requested plugin workspace does not exist. It may have been removed or is unavailable.
</Text>
</Stack>
</AppShell.Main>
);
}
if (!layout) {
return (
<AppShell.Main style={{ padding: 24 }}>
<Stack gap="sm">
<Text fw={700} size="lg">
{plugin.name}
</Text>
<Text size="sm" c="dimmed">
This plugin does not define an advanced workspace layout yet.
</Text>
</Stack>
</AppShell.Main>
);
}
const handleTabChange = (value) => {
const nextPage = pages.find((page) => page.id === value);
if (nextPage) {
navigate(resolvePagePath(plugin.key, nextPage));
}
};
return (
<AppShell.Main style={{ padding: 24 }}>
<Stack gap="md">
<Box>
<Text fw={700} size="lg">
{pageTitle}
</Text>
{pageDescription && (
<Text size="sm" c="dimmed">
{pageDescription}
</Text>
)}
</Box>
{tabPages.length > 1 && (
<Tabs
value={targetPage?.id}
onChange={handleTabChange}
keepMounted={false}
variant="outline"
>
<Tabs.List>
{tabPages.map((page) => (
<Tabs.Tab value={page.id} key={page.id}>
{page.label || page.id}
</Tabs.Tab>
))}
</Tabs.List>
</Tabs>
)}
<PluginUIProvider pluginKey={plugin.key} plugin={plugin}>
<PluginCanvas layout={layout} context={{ plugin, page: targetPage, location }} />
</PluginUIProvider>
</Stack>
</AppShell.Main>
);
};
export default PluginWorkspace;

View file

@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
AppShell,
Box,
@ -20,9 +21,21 @@ import {
FileInput,
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { RefreshCcw, Trash2 } from 'lucide-react';
import { RefreshCcw, Trash2, ArrowUp, ArrowDown } from 'lucide-react';
import API from '../api';
import { notifications } from '@mantine/notifications';
import usePluginsStore from '../store/plugins';
import { PluginUIProvider, PluginCanvas } from '../plugin-ui';
import { ensureArray } from '../plugin-ui/utils';
const formatTimestamp = (iso) => {
if (!iso) return null;
try {
return new Date(iso).toLocaleString();
} catch {
return iso;
}
};
const Field = ({ field, value, onChange }) => {
const common = { label: field.label, description: field.help_text };
@ -71,15 +84,22 @@ const Field = ({ field, value, onChange }) => {
const PluginCard = ({
plugin,
status,
canMoveUp,
canMoveDown,
onMoveUp,
onMoveDown,
onSaveSettings,
onRunAction,
onToggleEnabled,
onRequireTrust,
onRequestDelete,
}) => {
const navigate = useNavigate();
const [settings, setSettings] = useState(plugin.settings || {});
const [saving, setSaving] = useState(false);
const [running, setRunning] = useState(false);
const [runningAction, setRunningAction] = useState(null);
const [enabled, setEnabled] = useState(!!plugin.enabled);
const [lastResult, setLastResult] = useState(null);
const [confirmOpen, setConfirmOpen] = useState(false);
@ -89,6 +109,16 @@ const PluginCard = ({
onConfirm: null,
});
const clearPluginError = usePluginsStore((state) => state.clearPluginError);
const ui = plugin.ui_schema || {};
const missing = plugin.missing;
const pluginPages = ensureArray(Array.isArray(ui.pages) ? ui.pages : []);
const pluginPage = pluginPages.find((page) => (page.placement || 'plugin') === 'plugin');
const pluginLayout = ui.layout || pluginPage?.layout;
const hasAdvanced = !!pluginLayout;
const hasFields = !missing && Array.isArray(plugin.fields) && plugin.fields.length > 0;
// Keep local enabled state in sync with props (e.g., after import + enable)
React.useEffect(() => {
setEnabled(!!plugin.enabled);
@ -96,7 +126,7 @@ const PluginCard = ({
// Sync settings if plugin changes identity
React.useEffect(() => {
setSettings(plugin.settings || {});
}, [plugin.key]);
}, [plugin.key, plugin.settings]);
const updateField = (id, val) => {
setSettings((prev) => ({ ...prev, [id]: val }));
@ -105,7 +135,10 @@ const PluginCard = ({
const save = async () => {
setSaving(true);
try {
await onSaveSettings(plugin.key, settings);
const updatedSettings = await onSaveSettings(plugin.key, settings);
if (updatedSettings && typeof updatedSettings === 'object') {
setSettings(updatedSettings);
}
notifications.show({
title: 'Saved',
message: `${plugin.name} settings updated`,
@ -116,7 +149,41 @@ const PluginCard = ({
}
};
const missing = plugin.missing;
const handleDownload = (download) => {
if (!download) return;
if (download.url) {
window.open(download.url, '_blank', 'noopener,noreferrer');
return;
}
if (download.data) {
try {
const binary = window.atob(download.data);
const buffer = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
buffer[i] = binary.charCodeAt(i);
}
const blob = new Blob([buffer], {
type: download.content_type || 'application/octet-stream',
});
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = download.filename || `${plugin.key}.bin`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
} catch (error) {
console.error('Failed to process plugin download payload', error);
notifications.show({
title: 'Download failed',
message: 'Unable to download file returned by plugin action',
color: 'red',
});
}
}
};
return (
<Card
shadow="sm"
@ -130,8 +197,46 @@ const PluginCard = ({
<Text size="sm" c="dimmed">
{plugin.description}
</Text>
{status?.lastError ? (
<Text size="xs" c="red">
Reload failed: {status.lastError}
</Text>
) : status?.lastReloadAt ? (
<Text size="xs" c="dimmed">
Last reload: {formatTimestamp(status.lastReloadAt)}
</Text>
) : null}
</div>
<Group gap="xs" align="center">
<Group gap="xs" align="center">
<Group gap={4}>
<ActionIcon
variant="subtle"
size="sm"
onClick={() => onMoveUp && onMoveUp(plugin.key)}
disabled={!canMoveUp}
title="Move up"
>
<ArrowUp size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
size="sm"
onClick={() => onMoveDown && onMoveDown(plugin.key)}
disabled={!canMoveDown}
title="Move down"
>
<ArrowDown size={16} />
</ActionIcon>
</Group>
{hasAdvanced && (
<Button
size="xs"
variant="light"
onClick={() => navigate(`/plugins/${plugin.key}`)}
>
Open
</Button>
)}
<ActionIcon
variant="subtle"
color="red"
@ -160,6 +265,9 @@ const PluginCard = ({
if (next && resp?.ever_enabled) {
plugin.ever_enabled = true;
}
if (next) {
clearPluginError(plugin.key);
}
}}
size="xs"
onLabel="On"
@ -175,7 +283,25 @@ const PluginCard = ({
</Text>
)}
{!missing && plugin.fields && plugin.fields.length > 0 && (
{hasAdvanced && !missing && (
<Stack gap="xs" mt="sm">
{plugin.ui_schema?.preview && (
<PluginUIProvider pluginKey={plugin.key} plugin={plugin}>
<PluginCanvas
layout={plugin.ui_schema.preview}
context={{ plugin, preview: true }}
/>
</PluginUIProvider>
)}
<Text size="sm" c="dimmed">
{enabled
? 'Use the Open button to explore the full workspace.'
: 'Enable to access the workspace.'}
</Text>
</Stack>
)}
{hasFields && (
<Stack gap="xs" mt="sm">
{plugin.fields.map((f) => (
<Field
@ -185,6 +311,11 @@ const PluginCard = ({
onChange={updateField}
/>
))}
{hasAdvanced && !hasFields && plugin.settings && (
<Text size="sm" c="dimmed">
Settings are managed programmatically for this plugin.
</Text>
)}
<Group>
<Button loading={saving} onClick={save} variant="default" size="xs">
Save Settings
@ -193,105 +324,128 @@ const PluginCard = ({
</Stack>
)}
{!missing && plugin.actions && plugin.actions.length > 0 && (
{!hasAdvanced && !missing && plugin.actions && plugin.actions.length > 0 && (
<>
<Divider my="sm" />
<Stack gap="xs">
{plugin.actions.map((a) => (
<Group key={a.id} justify="space-between">
<div>
<Text>{a.label}</Text>
{a.description && (
<Text size="sm" c="dimmed">
{a.description}
</Text>
)}
</div>
<Button
loading={running}
disabled={!enabled}
onClick={async () => {
setRunning(true);
setLastResult(null);
try {
// 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;
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) {
await new Promise((resolve) => {
setConfirmConfig({
title: confirmTitle,
message: confirmMessage,
onConfirm: resolve,
});
setConfirmOpen(true);
});
}
// Save settings before running to ensure backend uses latest values
{plugin.actions.map((a) => {
const isRunning = runningAction === a.id;
const buttonLabel = isRunning
? a.running_label || 'Running…'
: a.button_label || a.label || 'Run';
return (
<Group key={a.id} justify="space-between" align="flex-start">
<div>
<Text>{a.label}</Text>
{a.description && (
<Text size="sm" c="dimmed">
{a.description}
</Text>
)}
</div>
<Button
loading={isRunning}
disabled={!enabled || (running && !isRunning)}
variant={a.variant || 'filled'}
color={a.color || 'blue'}
size={a.size || 'xs'}
onClick={async () => {
setRunning(true);
setRunningAction(a.id);
setLastResult(null);
try {
await onSaveSettings(plugin.key, settings);
} catch (e) {
/* ignore, run anyway */
const actionConfirm = a.confirm;
const confirmField = (plugin.fields || []).find(
(f) => f.id === 'confirm'
);
let requireConfirm = false;
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) {
await new Promise((resolve) => {
setConfirmConfig({
title: confirmTitle,
message: confirmMessage,
onConfirm: resolve,
});
setConfirmOpen(true);
});
}
try {
const updatedSettings = await onSaveSettings(
plugin.key,
settings
);
if (updatedSettings && typeof updatedSettings === 'object') {
setSettings(updatedSettings);
}
} catch (e) {
// Ignore errors, action can still run
}
const resp = await onRunAction(plugin.key, a.id);
if (resp?.success) {
setLastResult(resp.result || {});
handleDownload(resp?.result?.download || resp?.result?.file_download);
const msg =
resp.result?.message ||
a.success_message ||
'Plugin action completed';
notifications.show({
title: a.success_title || plugin.name,
message: msg,
color: a.success_color || 'green',
});
} else {
const err = resp?.error || 'Unknown error';
setLastResult({ error: err });
notifications.show({
title: a.error_title || `${plugin.name} error`,
message: String(err),
color: a.error_color || 'red',
});
}
} finally {
setRunning(false);
setRunningAction(null);
}
const resp = await onRunAction(plugin.key, a.id);
if (resp?.success) {
setLastResult(resp.result || {});
const msg =
resp.result?.message || 'Plugin action completed';
notifications.show({
title: plugin.name,
message: msg,
color: 'green',
});
} else {
const err = resp?.error || 'Unknown error';
setLastResult({ error: err });
notifications.show({
title: `${plugin.name} error`,
message: String(err),
color: 'red',
});
}
} finally {
setRunning(false);
}
}}
size="xs"
>
{running ? 'Running…' : 'Run'}
</Button>
</Group>
))}
}}
>
{buttonLabel}
</Button>
</Group>
);
})}
{running && (
<Text size="sm" c="dimmed">
Running action please wait
</Text>
)}
{!running && lastResult?.download && (
<Text size="sm" c="dimmed">
Download ready: {lastResult.download.filename || 'file'}
</Text>
)}
{!running && lastResult?.file && (
<Text size="sm" c="dimmed">
Output: {lastResult.file}
@ -346,8 +500,6 @@ const PluginCard = ({
};
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);
@ -359,15 +511,30 @@ export default function PluginsPage() {
const [deleteTarget, setDeleteTarget] = useState(null);
const [deleting, setDeleting] = useState(false);
const [uploadNoticeId, setUploadNoticeId] = useState(null);
const [filter, setFilter] = useState('');
const pluginsMap = usePluginsStore((state) => state.plugins);
const order = usePluginsStore((state) => state.order);
const statusMap = usePluginsStore((state) => state.status);
const movePlugin = usePluginsStore((state) => state.movePlugin);
const pluginsLoading = usePluginsStore((state) => state.loading);
const pluginsList = useMemo(
() => order.map((key) => pluginsMap[key]).filter(Boolean),
[order, pluginsMap]
);
const filteredPlugins = useMemo(() => {
const query = filter.trim().toLowerCase();
if (!query) return pluginsList;
return pluginsList.filter((plugin) => {
const haystack = `${plugin.name || ''} ${plugin.description || ''}`.toLowerCase();
return haystack.includes(query);
});
}, [filter, pluginsList]);
const loading = pluginsLoading;
const load = async () => {
setLoading(true);
try {
const list = await API.getPlugins();
setPlugins(list);
} finally {
setLoading(false);
}
await API.getPlugins({ resetError: true });
usePluginsStore.getState().markPluginsReloaded();
};
useEffect(() => {
@ -413,6 +580,15 @@ export default function PluginsPage() {
</Group>
</Group>
<Group mb="md">
<TextInput
placeholder="Search plugins"
value={filter}
onChange={(event) => setFilter(event.currentTarget.value)}
w={320}
/>
</Group>
{loading ? (
<Loader />
) : (
@ -423,48 +599,37 @@ export default function PluginsPage() {
verticalSpacing="md"
breakpoints={[{ maxWidth: '48em', cols: 1 }]}
>
{plugins.map((p) => (
<PluginCard
key={p.key}
plugin={p}
onSaveSettings={API.updatePluginSettings}
onRunAction={API.runPluginAction}
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);
}}
/>
))}
{filteredPlugins.map((p) => {
const orderIndex = order.indexOf(p.key);
const canMoveUp = orderIndex > 0;
const canMoveDown = orderIndex !== -1 && orderIndex < order.length - 1;
return (
<PluginCard
key={p.key}
plugin={p}
status={statusMap[p.key]}
canMoveUp={canMoveUp}
canMoveDown={canMoveDown}
onMoveUp={() => movePlugin(p.key, 'up')}
onMoveDown={() => movePlugin(p.key, 'down')}
onSaveSettings={API.updatePluginSettings}
onRunAction={API.runPluginAction}
onToggleEnabled={API.setPluginEnabled}
onRequireTrust={requireTrust}
onRequestDelete={(plugin) => {
setDeleteTarget(plugin);
setDeleteOpen(true);
}}
/>
);
})}
</SimpleGrid>
{plugins.length === 0 && (
{filteredPlugins.length === 0 && (
<Box>
<Text c="dimmed">
No plugins found. Drop a plugin into <code>/data/plugins</code>{' '}
and reload.
{filter.trim()
? 'No plugins match your search.'
: 'No plugins found. Drop a plugin into /data/plugins and reload.'}
</Text>
</Box>
)}
@ -534,10 +699,6 @@ export default function PluginsPage() {
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,
@ -624,13 +785,6 @@ export default function PluginsPage() {
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',
@ -733,9 +887,6 @@ export default function PluginsPage() {
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',

View file

@ -0,0 +1,158 @@
import React, { createContext, useContext, useMemo, useCallback, useRef } from 'react';
import API from '../api';
import usePluginsStore from '../store/plugins';
const PluginUIContext = createContext(null);
export const PluginUIProvider = ({ pluginKey, plugin: pluginProp, children }) => {
const storePlugin = usePluginsStore(
useCallback((state) => (pluginKey ? state.plugins[pluginKey] : null), [pluginKey])
);
const plugin = storePlugin || pluginProp || { key: pluginKey };
const sourceCacheRef = useRef(new Map());
const subscribersRef = useRef(new Map());
const inFlightRef = useRef(new Map());
const refCountRef = useRef(new Map());
const saveSettings = useCallback(
async (settings) => {
if (!pluginKey) return {};
const updated = await API.updatePluginSettings(pluginKey, settings);
return updated;
},
[pluginKey]
);
const runAction = useCallback(
async (actionId, params = {}, options = {}) => {
if (!pluginKey) return null;
return API.runPluginAction(pluginKey, actionId, params, options);
},
[pluginKey]
);
const resolveResource = useCallback(
async (resourceId, params = {}, options = {}) => {
if (!pluginKey) return null;
return API.resolvePluginResource(pluginKey, resourceId, params, options);
},
[pluginKey]
);
const getSourceSnapshot = useCallback((id) => sourceCacheRef.current.get(id), []);
const setSourceSnapshot = useCallback((id, snapshot) => {
if (!id) return;
sourceCacheRef.current.set(id, snapshot);
const subs = subscribersRef.current.get(id);
if (subs) {
subs.forEach((listener) => {
try {
listener(snapshot);
} catch (err) {
console.warn('Plugin data source listener error', err);
}
});
}
}, []);
const subscribeSource = useCallback((id, listener) => {
if (!id || typeof listener !== 'function') {
return () => {};
}
const subs = subscribersRef.current.get(id) || new Set();
subs.add(listener);
subscribersRef.current.set(id, subs);
return () => {
const current = subscribersRef.current.get(id);
if (!current) return;
current.delete(listener);
if (current.size === 0) {
subscribersRef.current.delete(id);
}
};
}, []);
const runSourceFetch = useCallback((id, factory) => {
if (!id || typeof factory !== 'function') {
return Promise.resolve(null);
}
const existing = inFlightRef.current.get(id);
if (existing) {
return existing;
}
const promise = (async () => {
try {
return await factory();
} finally {
inFlightRef.current.delete(id);
}
})();
inFlightRef.current.set(id, promise);
return promise;
}, []);
const acquireSourceOwner = useCallback((id) => {
if (!id) return 0;
const next = (refCountRef.current.get(id) || 0) + 1;
refCountRef.current.set(id, next);
return next;
}, []);
const releaseSourceOwner = useCallback((id) => {
if (!id) return 0;
const current = refCountRef.current.get(id) || 0;
const next = Math.max(current - 1, 0);
if (next === 0) {
refCountRef.current.delete(id);
} else {
refCountRef.current.set(id, next);
}
return next;
}, []);
const value = useMemo(
() => ({
pluginKey,
plugin,
schema: plugin?.ui_schema || {},
settings: plugin?.settings || {},
saveSettings,
runAction,
resolveResource,
getSourceSnapshot,
setSourceSnapshot,
subscribeSource,
runSourceFetch,
acquireSourceOwner,
releaseSourceOwner,
}),
[
pluginKey,
plugin,
saveSettings,
runAction,
resolveResource,
getSourceSnapshot,
setSourceSnapshot,
subscribeSource,
runSourceFetch,
acquireSourceOwner,
releaseSourceOwner,
]
);
return <PluginUIContext.Provider value={value}>{children}</PluginUIContext.Provider>;
};
export const usePluginUI = () => {
const ctx = useContext(PluginUIContext);
if (!ctx) {
throw new Error('usePluginUI must be used within a PluginUIProvider');
}
return ctx;
};
export default PluginUIContext;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,532 @@
import { useEffect, useMemo, useState, useCallback, useRef } from 'react';
import { notifications } from '@mantine/notifications';
import { usePluginUI } from '../PluginContext';
import { useWebSocket } from '../../WebSocket';
import {
applyTemplate,
deepMerge,
ensureArray,
getByPath,
boolFrom,
} from '../utils';
const DEFAULT_DATA = null;
const resolveConfig = (schemaSources = {}, source, override = {}) => {
const base =
typeof source === 'string' ? schemaSources[source] || { id: source } : source || {};
return deepMerge(base, override);
};
const extractData = (payload, config) => {
if (!config) return payload;
let result = payload;
const extractPath = config.extract || config.responsePath || config.path;
if (extractPath) {
const extracted = getByPath(result, extractPath);
if (extracted !== undefined) {
result = extracted;
}
}
if (config.pick && Array.isArray(result)) {
result = result.map((item) => {
const picked = {};
config.pick.forEach((field) => {
if (item && Object.prototype.hasOwnProperty.call(item, field)) {
picked[field] = item[field];
}
});
return picked;
});
}
if (config.default !== undefined && (result === undefined || result === null)) {
return config.default;
}
return result;
};
const matchesFilter = (eventData, filter = {}) => {
if (!filter || typeof filter !== 'object') {
return true;
}
for (const key in filter) {
if (!Object.prototype.hasOwnProperty.call(filter, key)) continue;
const expected = filter[key];
const actual = getByPath(eventData, key, getByPath(eventData?.payload, key));
if (Array.isArray(expected)) {
if (!expected.includes(actual)) {
return false;
}
} else if (actual !== expected) {
return false;
}
}
return true;
};
const normalizeMode = (mode) => {
if (!mode) return 'refresh';
return String(mode).toLowerCase();
};
const ensurePlugin = (pluginKey) => {
if (!pluginKey) {
throw new Error('Plugin key is required for data sources');
}
};
const useStableCallback = (fn) => {
const ref = useRef(fn);
useEffect(() => {
ref.current = fn;
}, [fn]);
return useCallback((...args) => ref.current?.(...args), []);
};
const useLatest = (value) => {
const ref = useRef(value);
useEffect(() => {
ref.current = value;
}, [value]);
return ref;
};
const createErrorNotification = (title, message) => {
notifications.show({
title,
message,
color: 'red',
});
};
const defaultSubscribeFilter = (pluginKey) => ({
plugin: pluginKey,
});
const buildFinalParams = (configParams = {}, stateParams = {}, runtimeParams = {}) => {
return {
...(configParams || {}),
...(stateParams || {}),
...(runtimeParams || {}),
};
};
const resolveTemplate = (input, context) => {
if (!input) return input;
return applyTemplate(input, context);
};
const resolveDataValue = (value, current) => {
if (typeof value === 'function') {
try {
return value(current ?? {});
} catch (error) {
if (import.meta?.env?.DEV) {
// eslint-disable-next-line no-console
console.warn('[Dispatcharr Plugin UI] Data factory threw', error);
}
return current ?? {};
}
}
return value;
};
const usePluginDataSource = (source, options = {}) => {
const {
pluginKey,
schema,
runAction,
resolveResource,
getSourceSnapshot,
setSourceSnapshot,
subscribeSource,
runSourceFetch,
acquireSourceOwner,
releaseSourceOwner,
} = usePluginUI();
ensurePlugin(pluginKey);
const schemaSources = schema?.dataSources || {};
const config = useMemo(
() => resolveConfig(schemaSources, source, options.override || {}),
[schemaSources, source, options.override]
);
const sourceId = config.id || config.key || (typeof source === 'string' ? source : config.action || config.resource);
const type = config.type || (config.resource ? 'resource' : 'action');
const baseParams = useMemo(
() => buildFinalParams(config.params, options.params),
[config.params, options.params]
);
const cachedSnapshot = useMemo(() => getSourceSnapshot(sourceId), [getSourceSnapshot, sourceId]);
const initialData = useMemo(() => {
try {
if (cachedSnapshot && Object.prototype.hasOwnProperty.call(cachedSnapshot, 'data')) {
return resolveDataValue(cachedSnapshot.data, undefined);
}
if (config.default !== undefined) {
return resolveDataValue(extractData(config.default, config), undefined);
}
return DEFAULT_DATA;
} catch (error) {
// eslint-disable-next-line no-console
console.error('[Dispatcharr Plugin UI] Failed to derive initial data', {
source,
override: options.override,
cachedSnapshot,
config,
}, error);
throw error;
}
}, [cachedSnapshot, config, source, options.override]);
const initialError = cachedSnapshot?.error ?? null;
const initialLastUpdated = cachedSnapshot?.lastUpdated ?? null;
const initialStatus = cachedSnapshot?.status ?? {};
const normaliseInitialState = useCallback((value) => {
if (value === null || value === undefined) {
return [];
}
if (Array.isArray(value)) {
return [...value];
}
if (typeof value === 'object') {
const safeObject = value ?? {};
return { ...safeObject };
}
return value;
}, []);
const [data, setDataState] = useState(() => normaliseInitialState(initialData));
const dataRef = useLatest(data);
const setData = useCallback(
(next) => {
try {
const rawValue = typeof next === 'function' ? next(dataRef.current ?? {}) : next;
const value = resolveDataValue(rawValue, dataRef.current);
setDataState(normaliseInitialState(value));
} catch (error) {
// eslint-disable-next-line no-console
console.error('[Dispatcharr Plugin UI] Failed to update data state', { next }, error);
throw error;
}
},
[normaliseInitialState, dataRef]
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(initialError);
const [lastUpdated, setLastUpdated] = useState(initialLastUpdated);
const [status, setStatus] = useState(initialStatus);
const paramsRef = useLatest(baseParams);
const ownerRef = useRef(false);
const [isOwner, setIsOwner] = useState(false);
const [, , socketExtras] = useWebSocket();
const wsSubscribe = socketExtras?.subscribe;
const fetchData = useCallback(
async (runtimeParams = {}, meta = {}) => {
if (!sourceId) {
return null;
}
const isController = ownerRef.current || meta.force;
if (!isController) {
const snapshotData = getSourceSnapshot(sourceId)?.data;
return resolveDataValue(snapshotData, dataRef.current);
}
setLoading(true);
setError(null);
try {
const snapshot = await runSourceFetch(sourceId, async () => {
const finalParams = buildFinalParams(
config.params,
paramsRef.current,
runtimeParams
);
const templatedParams = resolveTemplate(finalParams, meta.context || {});
let response = null;
if (type === 'resource') {
response = await resolveResource(
config.resource || sourceId,
templatedParams,
{
allowDisabled: boolFrom(
meta.allowDisabled ?? config.allowDisabled ?? options.allowDisabled,
false
),
}
);
} else if (type === 'static') {
response = config.data ?? config.value ?? null;
} else if (type === 'url' && config.url) {
const url = resolveTemplate(config.url, templatedParams);
const method = (config.method || 'GET').toUpperCase();
const fetchOptions = {
method,
};
if (method !== 'GET') {
fetchOptions.body = JSON.stringify(templatedParams || {});
fetchOptions.headers = {
'Content-Type': 'application/json',
};
}
const res = await fetch(url, fetchOptions);
response = await res.json();
} else {
response = await runAction(
config.action || sourceId,
templatedParams,
config.requestOptions || {}
);
}
const payload = response?.result ?? response;
const transformed = resolveDataValue(extractData(payload, config), dataRef.current);
const nextSnapshot = {
data: transformed,
status: {
ok: true,
meta: {
sourceId,
params: templatedParams,
},
},
lastUpdated: new Date(),
error: null,
};
setSourceSnapshot(sourceId, nextSnapshot);
if (options.onData) {
options.onData(transformed);
}
return nextSnapshot;
});
if (snapshot) {
setData(snapshot.data);
setStatus(snapshot.status || {});
setLastUpdated(snapshot.lastUpdated || new Date());
}
return snapshot?.data ?? null;
} catch (err) {
const failureSnapshot = {
data: dataRef.current,
status: { ok: false },
lastUpdated: new Date(),
error: err,
};
setSourceSnapshot(sourceId, failureSnapshot);
setError(err);
setStatus({ ok: false, error: err });
if (options.notifyOnError || config.notifyOnError) {
createErrorNotification(
options.errorTitle || config.errorTitle || 'Plugin data source failed',
err?.message || String(err)
);
}
return null;
} finally {
setLoading(false);
}
},
[
config,
paramsRef,
resolveResource,
runAction,
sourceId,
type,
options.allowDisabled,
options.notifyOnError,
options.errorTitle,
options.onData,
runSourceFetch,
setSourceSnapshot,
dataRef,
getSourceSnapshot,
]
);
const refresh = useStableCallback((runtimeParams = {}, meta = {}) =>
fetchData(runtimeParams, { ...meta, force: true })
);
// Auto load
useEffect(() => {
if (options.lazy || config.lazy || !isOwner) {
return undefined;
}
let cancelled = false;
(async () => {
const result = await fetchData({}, { force: true });
if (!cancelled && options.onLoad) {
options.onLoad(result);
}
})();
return () => {
cancelled = true;
};
}, [fetchData, options.lazy, options.onLoad, config.lazy, isOwner]);
// Interval refresh
useEffect(() => {
const interval = config.refresh?.interval ?? options.refreshInterval;
if (!isOwner || !interval || interval <= 0) return undefined;
const timer = setInterval(() => {
fetchData({}, { force: true });
}, interval * 1000);
return () => clearInterval(timer);
}, [config.refresh?.interval, options.refreshInterval, fetchData, isOwner]);
// Subscribe to websocket events
useEffect(() => {
if (!wsSubscribe || !config.subscribe) {
return undefined;
}
const spec = typeof config.subscribe === 'string'
? { event: config.subscribe }
: config.subscribe;
const mode = normalizeMode(spec.mode);
const filter = {
...defaultSubscribeFilter(pluginKey),
...(spec.filter || {}),
};
const limit = spec.limit || config.limit;
const handler = (event) => {
const eventData = event?.data;
if (!eventData) return;
if (spec.event && eventData.type !== spec.event) return;
if (spec.channel && eventData.channel !== spec.channel) return;
if (
spec.plugin &&
eventData.plugin !== (spec.plugin === 'self' ? pluginKey : spec.plugin)
)
return;
if (filter && !matchesFilter(eventData, filter)) return;
if (mode === 'append') {
const payloadPath = spec.path || 'payload';
const payload = getByPath(eventData, payloadPath, eventData.payload ?? eventData);
if (payload !== undefined) {
const next = [...ensureArray(dataRef.current || [])];
const chunk = ensureArray(payload);
const merged = spec.prepend ? [...chunk, ...next] : [...next, ...chunk];
const bounded = limit ? merged.slice(-limit) : merged;
setData(resolveDataValue(bounded, dataRef.current));
setLastUpdated(new Date());
}
} else if (mode === 'patch') {
const patchPath = spec.path || 'payload';
const patch = getByPath(eventData, patchPath, eventData.payload ?? {});
if (patch && typeof patch === 'object') {
const base = dataRef.current;
const safeBase =
base && typeof base === 'object' && !Array.isArray(base) ? base : {};
setData({ ...safeBase, ...patch });
setLastUpdated(new Date());
}
} else if (ownerRef.current) {
fetchData({}, { force: true });
}
};
const unsubscribe = wsSubscribe(handler);
return () => unsubscribe && unsubscribe();
}, [wsSubscribe, config.subscribe, fetchData, pluginKey, config.limit, dataRef]);
useEffect(() => {
const unsubscribe = subscribeSource(sourceId, (snapshot) => {
if (!snapshot) return;
if (snapshot.data !== undefined) {
setData(resolveDataValue(snapshot.data, dataRef.current));
}
if (snapshot.status !== undefined) {
setStatus(snapshot.status || {});
}
if (snapshot.lastUpdated) {
setLastUpdated(snapshot.lastUpdated);
}
if (Object.prototype.hasOwnProperty.call(snapshot, 'error')) {
setError(snapshot.error);
}
});
const existing = getSourceSnapshot(sourceId);
if (existing) {
setData(resolveDataValue(existing.data, dataRef.current));
setStatus(existing.status || {});
setLastUpdated(existing.lastUpdated || null);
setError(existing.error || null);
}
return unsubscribe;
}, [getSourceSnapshot, subscribeSource, sourceId, normaliseInitialState]);
const setParams = useCallback(
(updater) => {
paramsRef.current =
typeof updater === 'function' ? updater(paramsRef.current || {}) : updater;
fetchData(paramsRef.current, {
context: { params: paramsRef.current },
force: true,
});
},
[fetchData, paramsRef]
);
useEffect(() => {
if (!sourceId) return undefined;
const count = acquireSourceOwner(sourceId);
const becameOwner = count === 1;
ownerRef.current = becameOwner;
setIsOwner(becameOwner);
const snapshot = getSourceSnapshot(sourceId);
if (snapshot && snapshot.data !== undefined) {
setData(resolveDataValue(snapshot.data, dataRef.current));
setStatus(snapshot.status || {});
setLastUpdated(snapshot.lastUpdated || null);
setError(snapshot.error || null);
} else if (config.default !== undefined) {
const defaults = resolveDataValue(extractData(config.default, config), dataRef.current);
setData(defaults);
setStatus((snapshot && snapshot.status) || {});
setLastUpdated((snapshot && snapshot.lastUpdated) || null);
setError((snapshot && snapshot.error) || null);
}
if (becameOwner && !(options.lazy || config.lazy)) {
fetchData({}, { force: true });
}
return () => {
releaseSourceOwner(sourceId);
ownerRef.current = false;
setIsOwner(false);
};
}, [
sourceId,
acquireSourceOwner,
releaseSourceOwner,
getSourceSnapshot,
options.lazy,
config.lazy,
fetchData,
normaliseInitialState,
]);
return {
id: sourceId,
data,
loading,
error,
status,
refresh,
setParams,
params: paramsRef.current,
lastUpdated,
config,
};
};
export default usePluginDataSource;

View file

@ -0,0 +1,3 @@
export { PluginUIProvider, usePluginUI } from './PluginContext';
export { default as PluginCanvas, PluginNode } from './PluginRenderer';
export { default as usePluginDataSource } from './hooks/usePluginDataSource';

View file

@ -0,0 +1,140 @@
export const getByPath = (source, path, fallback = undefined) => {
if (!path || typeof path !== 'string') {
return fallback;
}
const segments = path.split('.');
let current = source;
for (const segment of segments) {
if (current == null) {
return fallback;
}
const match = segment.match(/^(\w+)(\[(\d+)])?$/);
if (!match) {
return fallback;
}
const key = match[1];
current = current[key];
if (match[3] !== undefined && Array.isArray(current)) {
const index = Number(match[3]);
current = current[index];
}
}
return current ?? fallback;
};
export const applyTemplate = (value, context = {}) => {
if (typeof value === 'string') {
return value.replace(/\{\{\s*([^}]+)\s*}}/g, (_, expr) => {
const trimmed = expr.trim();
const resolved = getByPath(context, trimmed, '');
return resolved == null ? '' : String(resolved);
});
}
if (Array.isArray(value)) {
return value.map((item) => applyTemplate(item, context));
}
if (value && typeof value === 'object') {
const next = {};
for (const key of Object.keys(value)) {
next[key] = applyTemplate(value[key], context);
}
return next;
}
return value;
};
export const ensureArray = (input) => {
if (Array.isArray(input)) {
return input;
}
if (input === null || input === undefined) {
return [];
}
return [input];
};
export const safeEntries = (input) => {
if (!input || typeof input !== 'object') {
return [];
}
const pairs = [];
for (const key in input) {
if (Object.prototype.hasOwnProperty.call(input, key)) {
pairs.push([key, input[key]]);
}
}
return pairs;
};
export const toNumber = (value, fallback = 0) => {
if (typeof value === 'number') return value;
if (typeof value === 'string' && value.trim() !== '') {
const num = Number(value);
return Number.isNaN(num) ? fallback : num;
}
return fallback;
};
export const deepMerge = (target = {}, source = {}) => {
const safeTarget = target && typeof target === 'object' && !Array.isArray(target) ? target : {};
const safeSource = source && typeof source === 'object' && !Array.isArray(source) ? source : {};
const output = { ...safeTarget };
for (const [key, value] of safeEntries(safeSource)) {
if (
value &&
typeof value === 'object' &&
!Array.isArray(value) &&
typeof output[key] === 'object' &&
!Array.isArray(output[key])
) {
output[key] = deepMerge(output[key], value);
} else {
output[key] = value;
}
}
return output;
};
export const pickFields = (obj, fields = []) => {
if (!obj || typeof obj !== 'object') {
return {};
}
if (!fields || fields.length === 0) {
return { ...obj };
}
return fields.reduce((acc, field) => {
if (Object.prototype.hasOwnProperty.call(obj, field)) {
acc[field] = obj[field];
}
return acc;
}, {});
};
export const boolFrom = (value, fallback = false) => {
if (typeof value === 'boolean') return value;
if (typeof value === 'string') {
return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase());
}
if (typeof value === 'number') {
return value !== 0;
}
return fallback;
};
export const clamp = (value, min, max) => {
const num = toNumber(value, min);
if (typeof min === 'number' && num < min) return min;
if (typeof max === 'number' && num > max) return max;
return num;
};
export const uniqueId = (() => {
let counter = 0;
return (prefix = 'id') => {
counter += 1;
return `${prefix}-${counter}`;
};
})();

View file

@ -0,0 +1,189 @@
import { create } from 'zustand';
const normalizePlugin = (plugin) => {
if (!plugin || !plugin.key) return plugin;
const normalized = {
...plugin,
settings: plugin.settings || {},
fields: plugin.fields || [],
actions: plugin.actions || [],
ui_schema: plugin.ui_schema || {},
};
const pages = normalized.ui_schema?.pages || [];
const hasSidebarPage = pages.some(
(page) => (page?.placement || 'plugin').toLowerCase() === 'sidebar'
);
if (hasSidebarPage) {
const hasField = normalized.fields.some((field) => field.id === 'show_sidebar');
if (!hasField) {
normalized.fields = [
...normalized.fields,
{
id: 'show_sidebar',
label: 'Show in sidebar',
type: 'boolean',
default: true,
help_text: "Adds this plugin's shortcut to the main sidebar when enabled.",
},
];
}
if (normalized.settings.show_sidebar === undefined) {
normalized.settings = {
...normalized.settings,
show_sidebar: true,
};
}
}
return normalized;
};
const usePluginsStore = create((set, get) => ({
plugins: {},
order: [],
status: {},
loading: false,
error: null,
setLoading: (loading) => set({ loading }),
setError: (error) => set({ error }),
setPlugins: (pluginsList = []) => {
set((state) => {
const normalized = {};
const nextStatus = { ...state.status };
const order = [];
pluginsList.forEach((plugin) => {
if (!plugin?.key) return;
normalized[plugin.key] = normalizePlugin(plugin);
order.push(plugin.key);
if (!nextStatus[plugin.key]) {
nextStatus[plugin.key] = { lastReloadAt: null, lastError: null };
}
});
// Drop status for removed plugins
Object.keys(nextStatus).forEach((key) => {
if (!normalized[key]) {
delete nextStatus[key];
}
});
return { plugins: normalized, order, status: nextStatus };
});
},
upsertPlugin: (plugin) =>
set((state) => {
if (!plugin?.key) return state;
const status = { ...state.status };
if (!status[plugin.key]) {
status[plugin.key] = { lastReloadAt: null, lastError: null };
}
return {
plugins: {
...state.plugins,
[plugin.key]: normalizePlugin({
...state.plugins[plugin.key],
...plugin,
}),
},
order: state.order.includes(plugin.key)
? state.order
: [...state.order, plugin.key],
status,
};
}),
removePlugin: (key) =>
set((state) => {
if (!state.plugins[key]) return state;
const nextPlugins = { ...state.plugins };
delete nextPlugins[key];
const nextStatus = { ...state.status };
delete nextStatus[key];
return {
plugins: nextPlugins,
order: state.order.filter((k) => k !== key),
status: nextStatus,
};
}),
updateSettings: (key, settings) =>
set((state) => {
const plugin = state.plugins[key];
if (!plugin) return state;
return {
plugins: {
...state.plugins,
[key]: {
...plugin,
settings: settings || {},
},
},
};
}),
updatePluginMeta: (key, patch) =>
set((state) => {
const plugin = state.plugins[key];
if (!plugin) return state;
return {
plugins: {
...state.plugins,
[key]: {
...plugin,
...patch,
},
},
};
}),
markPluginsReloaded: () =>
set((state) => {
const now = new Date().toISOString();
const status = { ...state.status };
state.order.forEach((key) => {
status[key] = {
...(status[key] || {}),
lastReloadAt: now,
lastError: null,
};
});
return { status };
}),
markPluginsReloadError: (error) =>
set((state) => {
const status = { ...state.status };
state.order.forEach((key) => {
status[key] = {
...(status[key] || {}),
lastError: error,
};
});
return { status };
}),
clearPluginError: (key) =>
set((state) => {
const entry = state.status[key];
if (!entry) return state;
return {
status: {
...state.status,
[key]: {
...entry,
lastError: null,
},
},
};
}),
movePlugin: (key, direction) =>
set((state) => {
const currentIndex = state.order.indexOf(key);
if (currentIndex === -1) return state;
const targetIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
if (targetIndex < 0 || targetIndex >= state.order.length) {
return state;
}
const nextOrder = [...state.order];
const [item] = nextOrder.splice(currentIndex, 1);
nextOrder.splice(targetIndex, 0, item);
return { order: nextOrder };
}),
getPlugin: (key) => get().plugins[key],
}));
export default usePluginsStore;