mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-22 18:28:00 +00:00
Archive plugin system prototype
We’re not using this prototype. It remains here solely for reference.
This commit is contained in:
parent
6536f35dc0
commit
264c97caaf
16 changed files with 6923 additions and 189 deletions
364
Plugins.md
364
Plugins.md
|
|
@ -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 plugin’s `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:
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}'"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
136
frontend/src/pages/PluginWorkspace.jsx
Normal file
136
frontend/src/pages/PluginWorkspace.jsx
Normal 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;
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
158
frontend/src/plugin-ui/PluginContext.jsx
Normal file
158
frontend/src/plugin-ui/PluginContext.jsx
Normal 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;
|
||||
4583
frontend/src/plugin-ui/PluginRenderer.jsx
Normal file
4583
frontend/src/plugin-ui/PluginRenderer.jsx
Normal file
File diff suppressed because it is too large
Load diff
532
frontend/src/plugin-ui/hooks/usePluginDataSource.js
Normal file
532
frontend/src/plugin-ui/hooks/usePluginDataSource.js
Normal 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;
|
||||
3
frontend/src/plugin-ui/index.js
Normal file
3
frontend/src/plugin-ui/index.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { PluginUIProvider, usePluginUI } from './PluginContext';
|
||||
export { default as PluginCanvas, PluginNode } from './PluginRenderer';
|
||||
export { default as usePluginDataSource } from './hooks/usePluginDataSource';
|
||||
140
frontend/src/plugin-ui/utils.js
Normal file
140
frontend/src/plugin-ui/utils.js
Normal 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}`;
|
||||
};
|
||||
})();
|
||||
189
frontend/src/store/plugins.js
Normal file
189
frontend/src/store/plugins.js
Normal 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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue