From 264c97caaf25a046bd4690fe619124a4e328067d Mon Sep 17 00:00:00 2001 From: Dispatcharr Date: Sun, 5 Oct 2025 15:15:14 -0500 Subject: [PATCH] Archive plugin system prototype MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We’re not using this prototype. It remains here solely for reference. --- Plugins.md | 364 +- apps/plugins/api_urls.py | 2 + apps/plugins/api_views.py | 105 +- apps/plugins/loader.py | 142 +- frontend/src/App.jsx | 52 +- frontend/src/WebSocket.jsx | 65 +- frontend/src/api.js | 124 +- frontend/src/components/Sidebar.jsx | 50 +- frontend/src/pages/PluginWorkspace.jsx | 136 + frontend/src/pages/Plugins.jsx | 467 +- frontend/src/plugin-ui/PluginContext.jsx | 158 + frontend/src/plugin-ui/PluginRenderer.jsx | 4583 +++++++++++++++++ .../plugin-ui/hooks/usePluginDataSource.js | 532 ++ frontend/src/plugin-ui/index.js | 3 + frontend/src/plugin-ui/utils.js | 140 + frontend/src/store/plugins.js | 189 + 16 files changed, 6923 insertions(+), 189 deletions(-) create mode 100644 frontend/src/pages/PluginWorkspace.jsx create mode 100644 frontend/src/plugin-ui/PluginContext.jsx create mode 100644 frontend/src/plugin-ui/PluginRenderer.jsx create mode 100644 frontend/src/plugin-ui/hooks/usePluginDataSource.js create mode 100644 frontend/src/plugin-ui/index.js create mode 100644 frontend/src/plugin-ui/utils.js create mode 100644 frontend/src/store/plugins.js diff --git a/Plugins.md b/Plugins.md index 62ea0d87..2ac3d53f 100644 --- a/Plugins.md +++ b/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//`). + - `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/` and can also map to custom routes. Dispatcharr automatically registers `` 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//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//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/` which hosts the same layout. Additional pages registered with `placement: "sidebar"` appear in the main navigation and receive dedicated routes (`page.route` or `/plugins//`). +- 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//settings/` with `{"settings": {...}}` - Run action: `POST /api/plugins/plugins//run/` with `{"action": "id", "params": {...}}` +- Resolve UI resource: `POST /api/plugins/plugins//ui/resource/` with `{"resource": "id", "params": {...}, "allow_disabled": false}` - Enable/disable: `POST /api/plugins/plugins//enabled/` with `{"enabled": true|false}` Notes: diff --git a/apps/plugins/api_urls.py b/apps/plugins/api_urls.py index a229a07c..7cdd2cb9 100644 --- a/apps/plugins/api_urls.py +++ b/apps/plugins/api_urls.py @@ -7,6 +7,7 @@ from .api_views import ( PluginEnabledAPIView, PluginImportAPIView, PluginDeleteAPIView, + PluginUIResourceAPIView, ) app_name = "plugins" @@ -18,5 +19,6 @@ urlpatterns = [ path("plugins//delete/", PluginDeleteAPIView.as_view(), name="delete"), path("plugins//settings/", PluginSettingsAPIView.as_view(), name="settings"), path("plugins//run/", PluginRunAPIView.as_view(), name="run"), + path("plugins//ui/resource/", PluginUIResourceAPIView.as_view(), name="ui-resource"), path("plugins//enabled/", PluginEnabledAPIView.as_view(), name="enabled"), ] diff --git a/apps/plugins/api_views.py b/apps/plugins/api_views.py index 0d68fc7d..07835582 100644 --- a/apps/plugins/api_views.py +++ b/apps/plugins/api_views.py @@ -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: diff --git a/apps/plugins/loader.py b/apps/plugins/loader.py index 5422ae7e..db990567 100644 --- a/apps/plugins/loader.py +++ b/apps/plugins/loader.py @@ -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}'" + ) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 4b701533..2e2fe5fd 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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: , + }); + 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: ( + + ), + }); + 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 = () => { } /> } /> } /> + {pluginRoutes.map((route) => ( + + ))} } /> } /> } /> diff --git a/frontend/src/WebSocket.jsx b/frontend/src/WebSocket.jsx index 1101c9f8..d225b4c6 100644 --- a/frontend/src/WebSocket.jsx +++ b/frontend/src/WebSocket.jsx @@ -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 ( diff --git a/frontend/src/api.js b/frontend/src/api.js index fa2063e6..67fdd435 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -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); diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index 143d01ab..a73b79cd 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -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 ? : , + 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) { diff --git a/frontend/src/pages/PluginWorkspace.jsx b/frontend/src/pages/PluginWorkspace.jsx new file mode 100644 index 00000000..3d98356a --- /dev/null +++ b/frontend/src/pages/PluginWorkspace.jsx @@ -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 ( + + + + ); + } + + if (!plugin) { + return ( + + + + Plugin not found + + + The requested plugin workspace does not exist. It may have been removed or is unavailable. + + + + ); + } + + if (!layout) { + return ( + + + + {plugin.name} + + + This plugin does not define an advanced workspace layout yet. + + + + ); + } + + const handleTabChange = (value) => { + const nextPage = pages.find((page) => page.id === value); + if (nextPage) { + navigate(resolvePagePath(plugin.key, nextPage)); + } + }; + + return ( + + + + + {pageTitle} + + {pageDescription && ( + + {pageDescription} + + )} + + + {tabPages.length > 1 && ( + + + {tabPages.map((page) => ( + + {page.label || page.id} + + ))} + + + )} + + + + + + + ); +}; + +export default PluginWorkspace; diff --git a/frontend/src/pages/Plugins.jsx b/frontend/src/pages/Plugins.jsx index f2902523..b7b91c22 100644 --- a/frontend/src/pages/Plugins.jsx +++ b/frontend/src/pages/Plugins.jsx @@ -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 ( {plugin.description} + {status?.lastError ? ( + + Reload failed: {status.lastError} + + ) : status?.lastReloadAt ? ( + + Last reload: {formatTimestamp(status.lastReloadAt)} + + ) : null} - + + + onMoveUp && onMoveUp(plugin.key)} + disabled={!canMoveUp} + title="Move up" + > + + + onMoveDown && onMoveDown(plugin.key)} + disabled={!canMoveDown} + title="Move down" + > + + + + {hasAdvanced && ( + + )} )} - {!missing && plugin.fields && plugin.fields.length > 0 && ( + {hasAdvanced && !missing && ( + + {plugin.ui_schema?.preview && ( + + + + )} + + {enabled + ? 'Use the Open button to explore the full workspace.' + : 'Enable to access the workspace.'} + + + )} + + {hasFields && ( {plugin.fields.map((f) => ( ))} + {hasAdvanced && !hasFields && plugin.settings && ( + + Settings are managed programmatically for this plugin. + + )} - - ))} + }} + > + {buttonLabel} + + + ); + })} {running && ( Running action… please wait )} + {!running && lastResult?.download && ( + + Download ready: {lastResult.download.filename || 'file'} + + )} {!running && lastResult?.file && ( 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() { + + setFilter(event.currentTarget.value)} + w={320} + /> + + {loading ? ( ) : ( @@ -423,48 +599,37 @@ export default function PluginsPage() { verticalSpacing="md" breakpoints={[{ maxWidth: '48em', cols: 1 }]} > - {plugins.map((p) => ( - { - 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 ( + 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); + }} + /> + ); + })} - {plugins.length === 0 && ( + {filteredPlugins.length === 0 && ( - No plugins found. Drop a plugin into /data/plugins{' '} - and reload. + {filter.trim() + ? 'No plugins match your search.' + : 'No plugins found. Drop a plugin into /data/plugins and reload.'} )} @@ -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', diff --git a/frontend/src/plugin-ui/PluginContext.jsx b/frontend/src/plugin-ui/PluginContext.jsx new file mode 100644 index 00000000..ecdb43f1 --- /dev/null +++ b/frontend/src/plugin-ui/PluginContext.jsx @@ -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 {children}; +}; + +export const usePluginUI = () => { + const ctx = useContext(PluginUIContext); + if (!ctx) { + throw new Error('usePluginUI must be used within a PluginUIProvider'); + } + return ctx; +}; + +export default PluginUIContext; diff --git a/frontend/src/plugin-ui/PluginRenderer.jsx b/frontend/src/plugin-ui/PluginRenderer.jsx new file mode 100644 index 00000000..28e5724c --- /dev/null +++ b/frontend/src/plugin-ui/PluginRenderer.jsx @@ -0,0 +1,4583 @@ +import React, { useMemo, useState, useCallback, useEffect, useRef } from 'react'; +import { + Box, + Stack, + Group, + Grid, + GridCol, + Card, + Text, + Title, + Divider, + Badge, + Button, + Tabs, + Accordion, + Modal, + Drawer, + SimpleGrid, + ScrollArea, + Table, + Progress, + Loader, + RingProgress, + Timeline, + Stepper, + Image, + Paper, + JsonInput, + Checkbox, + Switch, + Radio, + RadioGroup, + Select, + MultiSelect, + SegmentedControl, + TextInput, + Textarea, + PasswordInput, + NumberInput, + ColorInput, + FileInput, + Slider, + RangeSlider, + Pagination, + ActionIcon, + Tooltip, + Popover, + Menu, + LoadingOverlay, + Skeleton, + Affix, + CopyButton, + Anchor, + ThemeIcon, + Avatar, + HoverCard, + Highlight, + Code, + Kbd, +} from '@mantine/core'; +import { DatePickerInput, TimeInput, DateTimePicker } from '@mantine/dates'; +import { useForm } from '@mantine/form'; +import { + LineChart as ReLineChart, + Line, + BarChart as ReBarChart, + Bar, + PieChart as RePieChart, + Pie, + Cell, + Tooltip as ReTooltip, + Legend as ReLegend, + CartesianGrid, + XAxis, + YAxis, + AreaChart as ReAreaChart, + Area, + ResponsiveContainer, + RadarChart as ReRadarChart, + PolarGrid, + PolarAngleAxis, + PolarRadiusAxis, + Radar, + ComposedChart as ReComposedChart, + ScatterChart as ReScatterChart, + Scatter, + RadialBarChart as ReRadialBarChart, + RadialBar, + Treemap as ReTreemap, + ReferenceLine, + ReferenceArea, + ReferenceDot, + Brush, +} from 'recharts'; +import { useDisclosure } from '@mantine/hooks'; +import { Dropzone } from '@mantine/dropzone'; +import { + DndContext, + closestCenter, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { + SortableContext, + verticalListSortingStrategy, + useSortable, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { notifications } from '@mantine/notifications'; +import { + GripVertical, + Check, + X, + Search, + ChevronDown, + ChevronRight, + Play, + Copy as CopyIcon, + ExternalLink, + Settings2, +} from 'lucide-react'; +import { + useReactTable, + getCoreRowModel, + getSortedRowModel, + getFilteredRowModel, + getPaginationRowModel, + getGroupedRowModel, + getExpandedRowModel, + flexRender, +} from '@tanstack/react-table'; +import * as LucideIcons from 'lucide-react'; +import { usePluginUI } from './PluginContext'; +import usePluginDataSource from './hooks/usePluginDataSource'; +import { applyTemplate, ensureArray, getByPath, uniqueId, deepMerge, toNumber, safeEntries } from './utils'; + +const DEFAULT_CARD_PADDING = 'sm'; + +const resolveIcon = (iconName) => { + if (!iconName) return null; + if (typeof iconName === 'function') return iconName; + const formatted = iconName + .replace(/[-_](\w)/g, (_, char) => char.toUpperCase()) + .replace(/^(\w)/, (match) => match.toUpperCase()); + return LucideIcons[iconName] || LucideIcons[formatted] || null; +}; + +const listsMatchById = (a, b, resolver) => { + if (a === b) return true; + const left = ensureArray(a); + const right = ensureArray(b); + if (left.length !== right.length) return false; + for (let index = 0; index < left.length; index += 1) { + const leftValue = left[index]; + const rightValue = right[index]; + const leftId = resolver ? resolver(leftValue) : leftValue?.id ?? leftValue; + const rightId = resolver ? resolver(rightValue) : rightValue?.id ?? rightValue; + if (leftId !== rightId) { + return false; + } + } + return true; +}; + +const renderChildren = (children, context) => { + if (!children) return null; + return ensureArray(children).map((child, index) => ( + + )); +}; + +const renderDynamicContent = (value, context) => { + if (value === null || value === undefined) { + return null; + } + + if (React.isValidElement(value) || typeof value === 'string' || typeof value === 'number') { + return value; + } + + if (Array.isArray(value)) { + return renderChildren(value, context); + } + + if (typeof value === 'object') { + if (value.type) { + const rendered = renderChildren([value], context); + return Array.isArray(rendered) && rendered.length === 1 ? rendered[0] : rendered; + } + if (value.content !== undefined) { + return renderDynamicContent(value.content, context); + } + } + + return String(value); +}; + +const wrapWithTooltip = (node, element, context) => { + const tooltipConfig = node?.tooltip; + if (!tooltipConfig) { + return element; + } + + const config = + typeof tooltipConfig === 'string' + ? { label: tooltipConfig } + : { withArrow: true, ...tooltipConfig }; + + const label = config.label ?? config.content; + if (!label) { + return element; + } + + const { withinPortal = true, ...rest } = config; + const tooltipProps = { ...rest, withinPortal, label: renderDynamicContent(label, context) }; + + return ( + + + {element} + + + ); +}; + +const wrapWithPopover = (node, element, context) => { + const popoverConfig = node?.popover || node?.popOver; + if (!popoverConfig) { + return element; + } + + const config = + typeof popoverConfig === 'string' + ? { content: popoverConfig } + : { withinPortal: true, trapFocus: false, ...popoverConfig }; + + const { content, children: popoverChildren, targetProps, ...rest } = config; + const dropdownContent = renderDynamicContent(content ?? popoverChildren, context); + + if (!dropdownContent) { + return element; + } + + return ( + + + + {element} + + + {dropdownContent} + + ); +}; + +const wrapWithHoverCard = (node, element, context) => { + const hoverCardConfig = node?.hoverCard || node?.hovercard; + if (!hoverCardConfig) { + return element; + } + + const config = + typeof hoverCardConfig === 'string' + ? { content: hoverCardConfig } + : { withinPortal: true, openDelay: 150, closeDelay: 100, ...hoverCardConfig }; + + const { content, dropdown, children: hoverChildren, targetProps, ...rest } = config; + const dropdownContent = renderDynamicContent(dropdown ?? hoverChildren ?? content, context); + + if (!dropdownContent) { + return element; + } + + return ( + + + + {element} + + + {dropdownContent} + + ); +}; + +const resolveNodeLoadingState = (node, context) => { + if (typeof node?.loading === 'boolean') { + return node.loading; + } + if (typeof node?.isLoading === 'boolean') { + return node.isLoading; + } + if (node?.id && context?.loadingStates && typeof context.loadingStates[node.id] === 'boolean') { + return context.loadingStates[node.id]; + } + if (typeof context?.loading === 'boolean') { + return context.loading; + } + return false; +}; + +const wrapWithLoadingOverlay = (node, element, context) => { + const overlayConfig = node?.loadingOverlay; + if (!overlayConfig) { + return element; + } + + const config = overlayConfig === true ? { type: 'overlay' } : { type: 'overlay', ...overlayConfig }; + const loadingFlag = + typeof config.loading === 'boolean' ? config.loading : resolveNodeLoadingState(node, context); + + if (!loadingFlag) { + return element; + } + + if ((config.type || 'overlay').toLowerCase() === 'skeleton') { + const lines = Math.max(Number(config.lines) || 3, 1); + const height = config.lineHeight || config.height || 16; + const keepContent = config.keepContent; + const dim = config.dimContent ?? 0.3; + return ( + + {Array.from({ length: lines }).map((_, idx) => ( + + ))} + {keepContent ? ( + {element} + ) : null} + + ); + } + + return ( + + + {element} + + ); +}; + +const enhanceNodeElement = (node, element, context) => { + let enhanced = element; + enhanced = wrapWithTooltip(node, enhanced, context); + enhanced = wrapWithPopover(node, enhanced, context); + enhanced = wrapWithHoverCard(node, enhanced, context); + enhanced = wrapWithLoadingOverlay(node, enhanced, context); + return enhanced; +}; + +const FOCUSABLE_SELECTOR = + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; + + +const resolveAffixPosition = (affixConfig) => { + if (!affixConfig) { + return undefined; + } + + const offset = affixConfig.offset ?? 16; + const presetPositions = { + 'top-left': { top: offset, left: offset }, + 'top-right': { top: offset, right: offset }, + 'bottom-left': { bottom: offset, left: offset }, + 'bottom-right': { bottom: offset, right: offset }, + top: { top: offset, left: offset }, + bottom: { bottom: offset, right: offset }, + }; + + if (affixConfig.position && typeof affixConfig.position === 'object') { + return affixConfig.position; + } + + if (typeof affixConfig.position === 'string') { + return presetPositions[affixConfig.position] || { + bottom: offset, + right: offset, + }; + } + + return { + bottom: offset, + right: offset, + }; +}; + +const buildThemeIcon = (config, fallbackIcon) => { + if (!config && !fallbackIcon) { + return null; + } + + if (config === false) { + return null; + } + + const data = + typeof config === 'string' + ? { icon: config } + : config || { icon: fallbackIcon }; + + const IconComponent = resolveIcon(data.icon || fallbackIcon); + if (!IconComponent) { + return null; + } + + const size = data.size || 36; + return ( + + + + ); +}; + +const buildAvatar = (config, fallbackLabel = '') => { + if (!config) { + return null; + } + + if (config === false) { + return null; + } + + const data = typeof config === 'string' ? { initials: config } : config; + const size = data.size || 36; + const avatarProps = { + src: data.src, + radius: data.radius ?? 'xl', + size, + color: data.color, + variant: data.variant, + gradient: data.gradient, + alt: data.alt || fallbackLabel, + }; + + const initials = data.initials || (fallbackLabel ? fallbackLabel.trim().split(/\s+/).map((word) => word[0] || '').join('').slice(0, 2) : undefined); + const IconComponent = resolveIcon(data.icon); + + return ( + + {IconComponent ? : initials} + + ); +}; + +const buildAvatarGroup = (config, fallbackLabel = '') => { + if (!config) { + return null; + } + + const groupConfig = Array.isArray(config) + ? { members: config } + : config; + + const members = ensureArray(groupConfig.members || groupConfig.items); + if (!members.length) { + return null; + } + + return ( + + {members.map((member, idx) => ( + + {buildAvatar(member, fallbackLabel)} + + ))} + + ); +}; + +const resolveFormatter = (formatter, context, node) => { + if (!formatter) { + return undefined; + } + if (typeof formatter === 'function') { + return formatter; + } + if (typeof formatter === 'string') { + if (node?.formatters && typeof node.formatters[formatter] === 'function') { + return node.formatters[formatter]; + } + if (context?.formatters && typeof context.formatters[formatter] === 'function') { + return context.formatters[formatter]; + } + } + return undefined; +}; + +const buildAxisProps = (config, defaults, context, node) => { + const axisConfig = { ...(defaults || {}), ...(config || {}) }; + if (axisConfig.tickFormatter) { + const formatter = resolveFormatter(axisConfig.tickFormatter, context, node); + if (formatter) { + axisConfig.tickFormatter = formatter; + } + } + if (axisConfig.domainFormatter) { + const formatter = resolveFormatter(axisConfig.domainFormatter, context, node); + if (formatter) { + axisConfig.domain = formatter(axisConfig.domain, axisConfig, node) ?? axisConfig.domain; + } + } + return axisConfig; +}; + +const renderDefs = (defsConfig) => { + const defs = ensureArray(defsConfig).filter(Boolean); + if (!defs.length) { + return null; + } + + return ( + + {defs.map((definition) => { + const kind = (definition.kind || definition.type || '').toLowerCase(); + const key = definition.id || definition.key; + if (!key) return null; + switch (kind) { + case 'lineargradient': + return ( + + {ensureArray(definition.stops).map((stop, idx) => ( + + ))} + + ); + case 'radialgradient': + return ( + + {ensureArray(definition.stops).map((stop, idx) => ( + + ))} + + ); + default: + return null; + } + })} + + ); +}; + +const renderReferenceElements = (referenceConfig, context) => { + if (!referenceConfig) { + return null; + } + + const { lines, areas, dots } = referenceConfig; + return ( + <> + {ensureArray(lines).map((line, idx) => ( + + ))} + {ensureArray(areas).map((area, idx) => ( + + ))} + {ensureArray(dots).map((dot, idx) => ( + + ))} + + ); +}; + +const renderBrush = (brushConfig, fallbackDataKey) => { + if (!brushConfig) { + return null; + } + + const config = brushConfig === true ? {} : brushConfig; + return ( + + ); +}; + +const useActionHandler = () => { + const { runAction } = usePluginUI(); + return useCallback( + async (actionConfig = {}, params = {}, options = {}) => { + const actionId = actionConfig.action || actionConfig.id; + if (!actionId) { + console.warn('Action missing id'); + return null; + } + const { skipConfirm, ...runOptions } = options || {}; + if (!skipConfirm && actionConfig.confirm) { + const confirm = actionConfig.confirm; + let confirmMessage = 'Are you sure?'; + if (typeof confirm === 'string') { + confirmMessage = confirm; + } else if (typeof confirm === 'object') { + confirmMessage = confirm.message || confirm.text || confirm.title || confirmMessage; + } + const proceed = window.confirm(confirmMessage); + if (!proceed) { + return null; + } + } + const response = await runAction(actionId, params, runOptions); + if (response?.success && !options.silent) { + const result = response.result || {}; + const message = + result.message || actionConfig.successMessage || 'Action executed successfully'; + notifications.show({ + title: result.title || actionConfig.label || 'Plugin action', + message, + color: 'green', + }); + const undoPayload = response.undo || result.undo; + if (undoPayload) { + const undoConfig = + typeof undoPayload === 'string' + ? { action: undoPayload } + : undoPayload; + const undoId = undoConfig.id || `undo-${actionId}`; + notifications.show({ + id: undoId, + title: undoConfig.title || 'Undo available', + color: undoConfig.color || 'teal', + autoClose: undoConfig.autoClose ?? 7000, + message: ( + + {undoConfig.message || 'Revert this change?'} + + + ), + }); + } + } else if (response && response.success === false && !options.silent) { + notifications.show({ + title: actionConfig.label || 'Plugin action', + message: response.error || 'Action failed', + color: 'red', + }); + } + return response; + }, + [runAction] + ); +}; + +const TextNode = ({ node }) => { + const Component = node.variant ? Text[node.variant] || Text : Text; + const style = node.style || {}; + const content = node.richText ? ( + + ) : ( + + {node.content} + + ); + if (node.badge) { + return ( + + {content} + {node.badge.label} + + ); + } + return content; +}; + +const TitleNode = ({ node }) => { + const order = node.order || 3; + return ( + + {node.content} + + ); +}; + +const CardNode = ({ node, context }) => { + const padding = node.padding || DEFAULT_CARD_PADDING; + const shadow = node.shadow || 'sm'; + return ( + + {node.title && ( + +
+ {node.title} + {node.subtitle && ( + + {node.subtitle} + + )} +
+ {node.badge && ( + {node.badge.label} + )} +
+ )} + {renderChildren(node.children, context)} +
+ ); +}; + +const StackNode = ({ node, context }) => ( + {renderChildren(node.children, context)} +); + +const GroupNode = ({ node, context }) => ( + + {renderChildren(node.children, context)} + +); + +const BoxNode = ({ node, context }) => { + const padding = node.padding ?? node.p ?? 'sm'; + const radius = node.radius ?? 'sm'; + const border = node.withBorder ?? node.border; + const background = node.background ?? node.bg; + return ( + + {renderChildren(node.children, context)} + + ); +}; + +const GridNode = ({ node, context }) => { + const cols = ensureArray(node.columns || node.children); + return ( + + {cols.map((col, idx) => ( + + {renderChildren(col.children || (idx < (node.children?.length || 0) ? [node.children[idx]] : []), context)} + + ))} + + ); +}; + +const SimpleGridNode = ({ node, context }) => { + const children = ensureArray(node.children); + return ( + + {children.map((child, idx) => ( + {renderChildren(child, context)} + ))} + + ); +}; + +const VideoPlayerNode = ({ node, context }) => { + const templateScope = useMemo(() => { + const scope = {}; + if (context && typeof context === 'object') { + Object.assign(scope, context); + if (context.values && typeof context.values === 'object') { + scope.values = context.values; + } + if (context.form) { + scope.form = context.form; + } + } + return scope; + }, [context]); + + const coerceUrl = useCallback( + (value, fallback = '') => { + if (value === null || value === undefined) return fallback; + if (typeof value === 'string') { + const resolved = applyTemplate(value, templateScope); + return resolved !== undefined && resolved !== null && resolved !== '' + ? resolved + : fallback; + } + return String(value); + }, + [templateScope] + ); + + const boundUrl = useMemo(() => { + if (!node.valuePath) return ''; + const raw = getByPath(templateScope, node.valuePath); + return coerceUrl(raw); + }, [coerceUrl, node.valuePath, templateScope]); + + const fallbackUrl = useMemo(() => { + if (node.urlTemplate) { + const resolved = coerceUrl(node.urlTemplate); + if (resolved) return resolved; + } + if (node.url) { + const resolved = coerceUrl(node.url); + if (resolved) return resolved; + } + if (node.defaultUrl) { + const resolved = coerceUrl(node.defaultUrl); + if (resolved) return resolved; + } + return ''; + }, [coerceUrl, node.defaultUrl, node.url, node.urlTemplate]); + + const allowInput = node.allowInput !== false; + + const initialUrl = boundUrl || fallbackUrl; + const [inputUrl, setInputUrl] = useState(initialUrl); + const [inputDirty, setInputDirty] = useState(false); + const [activeUrl, setActiveUrl] = useState(null); + const [error, setError] = useState(null); + const videoRef = useRef(null); + + useEffect(() => { + const next = boundUrl || fallbackUrl; + if (!allowInput) { + if (next && next !== inputUrl) { + setInputUrl(next); + } + return; + } + if (!inputDirty && next && next !== inputUrl) { + setInputUrl(next); + } + }, [allowInput, boundUrl, fallbackUrl, inputDirty, inputUrl]); + + useEffect(() => { + const candidate = boundUrl || fallbackUrl; + if (!allowInput && candidate) { + setActiveUrl(candidate); + } + }, [allowInput, boundUrl, fallbackUrl]); + + useEffect(() => { + const videoEl = videoRef.current; + if (!videoEl) return () => {}; + const handleError = () => { + const mediaError = videoEl.error; + if (!mediaError) { + setError('Unable to play video'); + return; + } + switch (mediaError.code) { + case mediaError.MEDIA_ERR_ABORTED: + setError('Video playback aborted'); + break; + case mediaError.MEDIA_ERR_NETWORK: + setError('Network error while loading video'); + break; + case mediaError.MEDIA_ERR_DECODE: + setError('Video decode error – codec not supported'); + break; + case mediaError.MEDIA_ERR_SRC_NOT_SUPPORTED: + setError('Video format not supported by this browser'); + break; + default: + setError(mediaError.message || 'Unknown video error'); + } + }; + videoEl.addEventListener('error', handleError); + return () => { + videoEl.removeEventListener('error', handleError); + videoEl.pause(); + videoEl.removeAttribute('src'); + videoEl.load(); + }; + }, []); + + useEffect(() => { + if (!activeUrl || !videoRef.current) return; + const videoEl = videoRef.current; + setError(null); + videoEl.pause(); + videoEl.src = activeUrl; + videoEl.load(); + videoEl + .play() + .then(() => setError(null)) + .catch((err) => { + setError(err?.message || 'Auto-play prevented. Use the player controls.'); + }); + }, [activeUrl]); + + const handlePlay = () => { + const candidateUrl = allowInput ? inputUrl : boundUrl || fallbackUrl; + const trimmed = (candidateUrl || '').trim(); + if (!trimmed) { + setError('Enter a video URL first.'); + return; + } + setActiveUrl(trimmed); + }; + + return ( + + + + + {node.helper && ( + + {node.helper} + + )} + {error && ( + + {error} + + )} + {allowInput ? ( + + { + setInputDirty(true); + setInputUrl(event.currentTarget.value); + }} + style={{ flex: 1 }} + /> + + + ) : ( + + + + {node.inputLabel || 'Video URL'} + + + {boundUrl || fallbackUrl || inputUrl} + + + + + )} + + ); +}; + +const PaginationNode = ({ node }) => { + const total = Math.max(Number(node.total) || 1, 1); + const siblings = node.siblings ?? 1; + const boundaries = node.boundaries ?? 1; + const initialPage = Math.min(Math.max(Number(node.page) || 1, 1), total); + const [page, setPage] = useState(initialPage); + + return ( + { + setPage(value); + if (typeof node.onChange === 'function') { + node.onChange(value); + } + }} + total={total} + radius={node.radius || 'md'} + size={node.size || 'sm'} + siblings={siblings} + boundaries={boundaries} + withControls={node.withControls !== false} + withEdges={node.withEdges} + /> + ); +}; + +const ListNode = ({ node }) => { + const items = ensureArray(node.items); + return ( + + {items.map((item, idx) => ( + + {item.label} + {item.badge && {item.badge.label}} + + ))} + + ); +}; + +const StatusLight = ({ node, context }) => { + const sourceOptions = useMemo( + () => ({ + override: node.dataSource || {}, + }), + [node.dataSource] + ); + + const { data: sourceData } = usePluginDataSource(node.source, sourceOptions); + + const templateScope = useMemo(() => { + const scope = {}; + if (context && typeof context === 'object') { + scope.context = context; + Object.assign(scope, context); + } + if (node.scope && typeof node.scope === 'object') { + scope.scope = node.scope; + Object.assign(scope, node.scope); + } + if (typeof node.source === 'string') { + scope[node.source] = sourceData; + } + scope.data = sourceData; + return scope; + }, [context, node.scope, node.source, sourceData]); + + const statusConfig = node.status || node; + + const resolveField = useCallback( + (value, path) => { + if (path) { + const resolved = getByPath(templateScope, path); + if (resolved !== undefined) { + return resolved; + } + } + if (value === null || value === undefined) return value; + if (typeof value === 'string') { + return applyTemplate(value, templateScope); + } + return value; + }, + [templateScope] + ); + + const label = resolveField(statusConfig.label, statusConfig.labelPath); + const description = resolveField(statusConfig.description, statusConfig.descriptionPath); + const color = resolveField(statusConfig.color, statusConfig.colorPath); + + return ( + + + {label || 'Status'} + {description && ( + + {description} + + )} + + ); +}; + +const ProgressNode = ({ node }) => { + if (node.variant === 'ring' || node.variant === 'radial') { + return ( + + {node.label || `${node.value ?? 0}%`} +
+ } + /> + ); + } + if (node.variant === 'steps') { + const steps = ensureArray(node.steps); + return ( + + {steps.map((step, idx) => { + const IconComp = resolveIcon(step.icon); + return ( + : undefined} + /> + ); + })} + + ); + } + if (node.variant === 'loader' || node.variant === 'spinner') { + return ; + } + return ( + + ); +}; + +const LogStreamNode = ({ node }) => { + const override = useMemo(() => { + const base = node.dataSource || {}; + const defaults = ensureArray(base.default || node.default || [ + { + timestamp: '00:00:00', + level: 'INFO', + message: 'Waiting for log entries…', + }, + ]); + return deepMerge(base, { + default: defaults, + subscribe: false, + limit: node.limit || base.limit, + }); + }, [node.dataSource, node.default, node.limit]); + + const pollInterval = node.pollInterval ?? node.refreshInterval ?? 5; + const dataSourceOptions = useMemo(() => { + const options = { + override, + lazy: node.lazy, + }; + if (pollInterval > 0) { + options.refreshInterval = pollInterval; + } + return options; + }, [override, node.lazy, pollInterval]); + + const { data } = usePluginDataSource(node.source, dataSourceOptions); + const items = ensureArray(data); + + const formatEntry = (entry) => { + if (typeof entry === 'string') { + return entry; + } + if (entry && typeof entry === 'object') { + const timestamp = entry.timestamp || entry.time || ''; + const level = entry.level ? `[${entry.level}]` : ''; + const message = entry.message ?? entry.text ?? JSON.stringify(entry); + return [timestamp, level, message].filter(Boolean).join(' '); + } + return String(entry ?? ''); + }; + + return ( + + + {items.map((entry, idx) => ( + + {formatEntry(entry)} + + ))} + + + ); +}; + +const ChartNode = ({ node, context }) => { + const sourceOptions = useMemo( + () => ({ + override: node.dataSource || {}, + }), + [node.dataSource] + ); + const { data, loading } = usePluginDataSource(node.source, sourceOptions); + const dataset = ensureArray(data); + const height = node.height || 260; + const chartType = (node.chartType || node.type || 'line').toLowerCase(); + + if (loading && !dataset.length) { + return ( + + + + ); + } + + const defaultXKey = node.xKey || node.x || 'x'; + const defaultYKey = node.yKey || 'y'; + + const xAxisProps = buildAxisProps( + node.xAxis, + { + dataKey: defaultXKey, + type: node.xAxis?.type || node.xType, + scale: node.xAxis?.scale, + }, + context, + node + ); + + const yAxisConfigsInput = node.yAxis || node.yAxes; + const yAxisConfigs = ensureArray(yAxisConfigsInput && ensureArray(yAxisConfigsInput).length ? yAxisConfigsInput : [{}]).map( + (axis, index) => + buildAxisProps( + axis, + { + dataKey: axis?.dataKey || defaultYKey, + yAxisId: axis?.yAxisId ?? axis?.id ?? index, + }, + context, + node + ) + ); + + const tooltipConfig = typeof node.tooltip === 'object' ? { ...node.tooltip } : {}; + const tooltipFormatter = resolveFormatter(node.tooltipFormatter ?? tooltipConfig.formatter, context, node); + if (tooltipFormatter) { + tooltipConfig.formatter = tooltipFormatter; + } + const tooltipLabelFormatter = resolveFormatter(node.tooltipLabelFormatter ?? tooltipConfig.labelFormatter, context, node); + if (tooltipLabelFormatter) { + tooltipConfig.labelFormatter = tooltipLabelFormatter; + } + const tooltipElement = node.tooltip === false ? null : ; + + const legendConfig = typeof node.legend === 'object' ? { ...node.legend } : {}; + const legendFormatter = resolveFormatter(node.legendFormatter ?? legendConfig.formatter, context, node); + if (legendFormatter) { + legendConfig.formatter = legendFormatter; + } + const legendElement = node.legend === false ? null : ; + + const defsElement = renderDefs(node.defs); + const referenceElements = renderReferenceElements(node.reference, context); + const brushElement = renderBrush(node.brush, xAxisProps.dataKey || defaultXKey); + + const gridConfig = node.grid; + const gridElement = + gridConfig === false + ? null + : ( + + ); + + const chartProps = { + data: dataset, + syncId: node.syncId, + margin: node.margin, + barCategoryGap: node.barCategoryGap, + barGap: node.barGap, + stackOffset: node.stackOffset, + }; + + const renderCommon = (includeAxes = true) => ( + <> + {defsElement} + {gridElement} + {includeAxes && } + {includeAxes && + yAxisConfigs.map((axisProps, idx) => ( + + ))} + {tooltipElement} + {legendElement} + {referenceElements} + + ); + + const lineSeries = ensureArray( + node.series || node.lines || [ + { + dataKey: defaultYKey, + color: node.color || '#4dabf7', + }, + ] + ); + + const areaSeries = ensureArray( + node.series || node.areas || [ + { + dataKey: defaultYKey, + color: node.color || '#4dabf7', + }, + ] + ); + + const barSeries = ensureArray( + node.series || node.bars || [ + { + dataKey: defaultYKey, + color: node.color || '#4dabf7', + }, + ] + ); + + const pieSeries = ensureArray( + node.series && node.series.length + ? node.series + : [ + { + data: dataset, + dataKey: node.valueKey || defaultYKey, + nameKey: node.labelKey || defaultXKey, + innerRadius: node.innerRadius, + outerRadius: node.outerRadius, + label: node.showLabels, + }, + ] + ); + + const composedSeries = ensureArray(node.series || []); + const scatterSeries = ensureArray(node.series || [{ data: dataset, name: node.label || 'Data' }]); + + const renderLineChart = () => ( + + + {renderCommon(true)} + {lineSeries.map((series, idx) => ( + + ))} + {brushElement} + + + ); + + const renderAreaChart = () => ( + + + {renderCommon(true)} + {areaSeries.map((series, idx) => ( + + ))} + {brushElement} + + + ); + + const renderBarChart = () => ( + + + {renderCommon(true)} + {barSeries.map((series, idx) => ( + + ))} + {brushElement} + + + ); + + const renderComposedChart = () => ( + + + {renderCommon(true)} + {composedSeries.map((series, idx) => { + const key = series.id || series.dataKey || idx; + const kind = (series.kind || series.type || 'line').toLowerCase(); + const commonProps = { + key, + dataKey: series.dataKey || series.id, + data: series.data, + yAxisId: series.yAxisId, + stackId: series.stackId, + isAnimationActive: series.animation ?? node.animation, + }; + switch (kind) { + case 'area': + return ( + + ); + case 'bar': + return ( + + ); + case 'scatter': + return ( + + ); + default: + return ( + + ); + } + })} + {brushElement} + + + ); + + const renderPieChart = () => ( + + + {defsElement} + {tooltipElement} + {legendElement} + {pieSeries.map((series, idx) => ( + + {ensureArray(series.data || dataset).map((entry, entryIdx) => ( + + ))} + + ))} + + + ); + + const renderRadarChart = () => ( + + + + + + + {tooltipElement} + {legendElement} + + + ); + + const renderScatterChart = () => ( + + + {renderCommon(true)} + {scatterSeries.map((series, idx) => ( + + ))} + {brushElement} + + + ); + + const renderRadialBarChart = () => ( + + + {defsElement} + {tooltipElement} + {legendElement} + + + + ); + + const renderTreemap = () => ( + + + {tooltipElement} + {legendElement} + + + ); + + const renderHeatmap = () => { + const rows = ensureArray(dataset); + return ( + + + {rows.map((row, rowIndex) => ( + + {ensureArray(row.values || row).map((cell, cellIndex) => { + const value = typeof cell === 'object' ? cell.value : cell; + const intensity = toNumber(value, 0); + const max = node.max ?? 100; + const background = `rgba(77, 171, 247, ${Math.min(intensity / max, 1)})`; + return ( + + ); + })} + + ))} + + + ); + }; + + switch (chartType) { + case 'line': + return renderLineChart(); + case 'area': + return renderAreaChart(); + case 'bar': + return renderBarChart(); + case 'composed': + return renderComposedChart(); + case 'pie': + case 'donut': + case 'doughnut': + return renderPieChart(); + case 'radar': + return renderRadarChart(); + case 'scatter': + return renderScatterChart(); + case 'radialbar': + case 'radial-bar': + return renderRadialBarChart(); + case 'treemap': + return renderTreemap(); + case 'heatmap': + return renderHeatmap(); + default: + return renderLineChart(); + } +}; + +const ActionButtonNode = ({ node, context }) => { + const handleAction = useActionHandler(); + const params = useMemo(() => node.params || {}, [node.params]); + const label = node.label || node.text || 'Run'; + const icon = resolveIcon(node.icon); + + const actionOptions = useMemo( + () => node.actionOptions || node.options || {}, + [node.actionOptions, node.options] + ); + + const confirmConfig = useMemo(() => { + if (!node.confirm) return null; + if (typeof node.confirm === 'string') { + return { message: node.confirm }; + } + if (typeof node.confirm === 'object') { + return node.confirm; + } + return null; + }, [node.confirm]); + + const [opened, { open, close }] = useDisclosure(false); + const [submitting, setSubmitting] = useState(false); + + const execute = useCallback(async () => { + try { + setSubmitting(true); + await handleAction(node, params, { ...actionOptions, skipConfirm: true }); + close(); + } finally { + setSubmitting(false); + } + }, [actionOptions, close, handleAction, node, params]); + + const handleClick = () => { + if (confirmConfig) { + open(); + } else { + handleAction(node, params, actionOptions); + } + }; + + const confirmTitle = confirmConfig?.title || node.label || 'Confirm action'; + const confirmMessage = + confirmConfig?.message || confirmConfig?.text || + (typeof node.confirm === 'string' ? node.confirm : 'Are you sure?'); + const confirmLabel = confirmConfig?.confirmLabel || confirmConfig?.confirmText || 'Confirm'; + const cancelLabel = confirmConfig?.cancelLabel || confirmConfig?.cancelText || 'Cancel'; + + const button = ( + + ); + + const menuConfig = node.menu; + const buttonWithMenu = useMemo(() => { + if (!menuConfig) { + return button; + } + + const config = typeof menuConfig === 'object' ? menuConfig : { items: ensureArray(menuConfig) }; + const items = ensureArray(config.items ?? []); + if (!items.length) { + return button; + } + + return ( + + {button} + + {items.map((item, idx) => { + if (!item) { + return null; + } + if (item.type === 'divider') { + return ; + } + const ItemIcon = resolveIcon(item.icon); + const key = item.id || item.key || idx; + const onItemClick = async (event) => { + if (item.preventDefault) { + event.preventDefault(); + } + if (item.href) { + if (item.openInNewTab !== false && item.target !== '_self') { + window.open(item.href, item.target || '_blank', item.windowFeatures); + } + return; + } + if (item.onClick && typeof item.onClick === 'function') { + item.onClick(event, { context, params, node }); + return; + } + if (item.action || item.id) { + await handleAction(item, item.params || {}, item.options || {}); + } + }; + + const itemProps = { + key, + icon: ItemIcon ? : undefined, + color: item.color, + leftSection: item.leftSection, + rightSection: item.rightSection, + disabled: item.disabled, + }; + + if (item.href) { + return ( + + {item.label} + + ); + } + + return ( + + {item.label} + + ); + })} + + + ); + }, [button, context, handleAction, menuConfig, node, params]); + + return ( + <> + {buttonWithMenu} + {confirmConfig && ( + (submitting ? null : close())} + title={confirmTitle} + centered + > + + {confirmMessage} + + + + + + + )} + + ); +}; + +const ButtonGroupNode = ({ node, context }) => { + const buttons = ensureArray(node.buttons); + + const handleKeyDown = (event) => { + if (!['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown'].includes(event.key)) { + return; + } + const elements = Array.from( + event.currentTarget.querySelectorAll('[data-roving-button="true"]') + ); + if (!elements.length) return; + const direction = event.key === 'ArrowRight' || event.key === 'ArrowDown' ? 1 : -1; + const currentIndex = elements.findIndex((el) => el === document.activeElement); + const nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + elements.length) % elements.length; + const nextElement = elements[nextIndex]; + if (nextElement) { + const focusable = nextElement.querySelector(FOCUSABLE_SELECTOR); + (focusable || nextElement).focus?.(); + } + event.preventDefault(); + }; + + const group = ( + + {buttons.map((btn, idx) => { + const key = btn?.id || idx; + const element = enhanceNodeElement(btn, , context); + return ( + + {element} + + ); + })} + + ); + + if (!node.affix) { + return group; + } + + const affixConfig = typeof node.affix === 'object' ? node.affix : {}; + const position = resolveAffixPosition(affixConfig); + const content = affixConfig.paper ? ( + + {group} + + ) : ( + group + ); + + return ( + + {content} + + ); +}; + +const CopyNode = ({ node, context }) => { + const scope = useMemo(() => ({ ...(context || {}), data: context?.data, values: context?.values }), [context]); + const resolvedValue = useMemo(() => { + if (typeof node.value === 'string') { + const templated = applyTemplate(node.value, scope); + return templated ?? node.value ?? ''; + } + return node.value ?? ''; + }, [node.value, scope]); + + const CopyIconComponent = resolveIcon(node.copyIcon) || CopyIcon; + const CopiedIconComponent = resolveIcon(node.copiedIcon) || Check; + const label = node.label || 'Copy'; + const copiedLabel = node.copiedLabel || 'Copied'; + + return ( + + {({ copied, copy }) => { + if (node.variant === 'icon' || node.iconOnly) { + const IconComp = copied ? CopiedIconComponent : CopyIconComponent; + return ( + + {IconComp ? : null} + + ); + } + + return ( + + ); + }} + + ); +}; + +const LinkNode = ({ node, context }) => { + const scope = useMemo(() => ({ ...(context || {}), values: context?.values, data: context?.data }), [context]); + const href = useMemo(() => { + if (typeof node.href === 'string') { + const templated = applyTemplate(node.href, scope); + return templated ?? node.href; + } + return node.href || '#'; + }, [node.href, scope]); + const label = node.label || node.text || href; + const LeftIcon = resolveIcon(node.icon); + const fallbackRight = node.external !== false ? ExternalLink : null; + const RightIcon = resolveIcon(node.rightIcon || (node.external !== false ? 'ExternalLink' : null)) || fallbackRight; + + return ( + + + {LeftIcon ? : null} + {label} + {RightIcon ? : null} + + + ); +}; + +const HighlightNode = ({ node, context }) => { + const scope = useMemo(() => ({ ...(context || {}), values: context?.values }), [context]); + const content = useMemo(() => { + if (typeof node.content === 'string') { + const templated = applyTemplate(node.content, scope); + return templated ?? node.content; + } + if (node.text) { + const templated = applyTemplate(node.text, scope); + return templated ?? node.text; + } + return node.children || ''; + }, [node.children, node.content, node.text, scope]); + const targets = ensureArray(node.highlight || node.query || node.targets || []); + + return ( + + {typeof content === 'string' ? content : renderDynamicContent(content, context)} + + ); +}; + +const CodeNode = ({ node, context }) => { + const content = renderDynamicContent(node.content ?? node.value ?? node.children ?? '', context); + return ( + + {content} + + ); +}; + +const KbdNode = ({ node }) => { + const keys = ensureArray(node.keys || node.value || node.shortcut || node.content).filter(Boolean); + const separator = node.separator || '+'; + + if (keys.length <= 1) { + return {keys[0] || ''}; + } + + return ( + + {keys.map((key, idx) => ( + + {key} + {idx < keys.length - 1 && ( + + {separator} + + )} + + ))} + + ); +}; + +const HoverCardNode = ({ node, context }) => { + const config = node.config || node; + const targetContent = config.target || config.trigger || config.label || 'Hover'; + const dropdownContent = + config.dropdown || config.content || config.children || (node !== config ? node.children : null); + + return ( + + + + {renderDynamicContent(targetContent, context)} + + + + {renderDynamicContent(dropdownContent ?? node.children ?? node.text, context)} + + + ); +}; + +const RichCardList = ({ node, context }) => { + const dataSourceOptions = useMemo( + () => ({ + override: node.dataSource || {}, + }), + [node.dataSource] + ); + const { data, loading } = usePluginDataSource(node.source, dataSourceOptions); + + const items = useMemo(() => { + if (Array.isArray(data)) return data; + if (Array.isArray(data?.items)) return data.items; + if (Array.isArray(data?.cards)) return data.cards; + return []; + }, [data]); + + const cols = node.columns || 3; + + const cardDefaults = useMemo( + () => ({ + padding: node.cardProps?.padding ?? 'sm', + radius: node.cardProps?.radius ?? 'md', + withBorder: node.cardProps?.withBorder ?? true, + shadow: node.cardProps?.shadow, + }), + [node.cardProps] + ); + + if (loading && items.length === 0) { + return ( + + + + ); + } + return ( + + {items.map((item, idx) => { + const themeIconNode = buildThemeIcon(item.themeIcon, item.icon); + const avatarNode = buildAvatar(item.avatar, item.title || item.subtitle); + const avatarGroupNode = buildAvatarGroup(item.avatarGroup, item.title || item.subtitle); + const leadVisual = avatarGroupNode || avatarNode || themeIconNode; + + return ( + + + {node.showImage !== false && item.image ? ( + {item.imageAlt + ) : null} + + + {leadVisual} + + {item.title} + {item.subtitle && ( + + {item.subtitle} + + )} + + + {item.badge && ( + {item.badge.label} + )} + + {item.description && {item.description}} + {item.meta && typeof item.meta === 'object' && ( + + {safeEntries(item.meta).map(([key, value]) => ( + + + {key} + + {String(value)} + + ))} + + )} + {item.actions && } + + + ); + })} + + ); +}; + +const TableNode = ({ node, context }) => { + const handleAction = useActionHandler(); + const dataSourceOptions = useMemo( + () => ({ + override: node.dataSource || {}, + }), + [node.dataSource] + ); + const { data, loading } = usePluginDataSource(node.source, dataSourceOptions); + + const rows = useMemo(() => { + if (!data) return []; + if (Array.isArray(data)) return data; + if (Array.isArray(data?.rows)) return data.rows; + if (Array.isArray(data?.items)) return data.items; + return [data]; + }, [data]); + + const selectionConfig = typeof node.selection === 'object' ? node.selection : {}; + const selectionEnabled = node.selection === false ? false : Boolean(node.selection ?? selectionConfig.enabled ?? node.bulkActions?.length); + const bulkActions = ensureArray(selectionConfig.bulkActions || node.bulkActions); + + const virtualizationConfig = typeof node.virtualize === 'object' ? node.virtualize : node.virtualize ? {} : null; + const virtualize = Boolean(virtualizationConfig); + const virtualHeight = virtualizationConfig?.height || node.bodyHeight || node.height; + + const serverSide = node.serverSide === true; + + const [sorting, setSorting] = useState(node.initialSort || []); + const [globalFilter, setGlobalFilter] = useState(''); + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(node.pageSize || node.pagination?.pageSize || 10); + const [expandedRow, setExpandedRow] = useState(null); + const [rowSelection, setRowSelection] = useState({}); + const [columnVisibility, setColumnVisibility] = useState(node.columnVisibility || {}); + const [columnPinning, setColumnPinning] = useState(node.columnPinning || { left: [], right: [] }); + const [columnSizing, setColumnSizing] = useState(node.columnSizing || {}); + const [columnFilters, setColumnFilters] = useState([]); + const [grouping, setGrouping] = useState(node.grouping || node.initialGrouping || []); + const [editingValues, setEditingValues] = useState({}); + const [editingBusy, setEditingBusy] = useState({}); + + const toggleExpand = useCallback((rowId) => { + setExpandedRow((prev) => (prev === rowId ? null : rowId)); + }, []); + + const customFilterFns = useMemo( + () => ({ + arrIncludes: (row, columnId, filterValue) => { + if (!filterValue || !filterValue.length) return true; + const rowValue = row.getValue(columnId); + const values = Array.isArray(rowValue) ? rowValue : [rowValue]; + return ensureArray(filterValue).every((value) => values.includes(value)); + }, + between: (row, columnId, filterValue) => { + if (!filterValue) return true; + const [min, max] = filterValue; + const rawValue = Number(row.getValue(columnId)); + if (Number.isNaN(rawValue)) return false; + if (min !== undefined && min !== '' && rawValue < Number(min)) return false; + if (max !== undefined && max !== '' && rawValue > Number(max)) return false; + return true; + }, + }), + [] + ); + + const buildActions = useCallback( + (actions, row, value) => { + const scope = { row, value, context }; + const resolved = ensureArray(actions).map((action) => { + const next = { ...action }; + if (action.params) { + next.params = applyTemplate(action.params, scope); + } else if (!next.params) { + next.params = { rowId: row.id }; + } + if (action.confirm) { + if (typeof action.confirm === 'string') { + next.confirm = applyTemplate(action.confirm, scope); + } else if (typeof action.confirm === 'object') { + const confirmConfig = { ...action.confirm }; + if (confirmConfig.message) { + confirmConfig.message = applyTemplate(confirmConfig.message, scope); + } + if (confirmConfig.text) { + confirmConfig.text = applyTemplate(confirmConfig.text, scope); + } + if (confirmConfig.title) { + confirmConfig.title = applyTemplate(confirmConfig.title, scope); + } + if (confirmConfig.confirmLabel) { + confirmConfig.confirmLabel = applyTemplate(confirmConfig.confirmLabel, scope); + } + if (confirmConfig.cancelLabel) { + confirmConfig.cancelLabel = applyTemplate(confirmConfig.cancelLabel, scope); + } + next.confirm = confirmConfig; + } + } + return next; + }); + return ; + }, + [context] + ); + + const commitEdit = useCallback( + async ({ row, columnId, value: nextValue, columnConfig, editKey }) => { + const actionConfig = columnConfig?.onEditAction || node.onEditAction; + if (!actionConfig) return; + const actionNode = typeof actionConfig === 'string' ? { action: actionConfig } : actionConfig; + setEditingBusy((prev) => ({ ...prev, [editKey]: true })); + try { + await handleAction(actionNode, { + value: nextValue, + columnId, + column: columnConfig, + row, + rowId: row?.id ?? row?.key, + }); + } finally { + setEditingBusy((prev) => { + const next = { ...prev }; + delete next[editKey]; + return next; + }); + } + }, + [handleAction, node.onEditAction] + ); + + const getEditKey = useCallback((rowId, columnId) => `${rowId}::${columnId}`, []); + + const renderEditableCell = useCallback( + (info, col) => { + const value = info.getValue(); + const row = info.row.original; + const rowId = info.row.id; + const columnId = info.column.id; + const editKey = getEditKey(rowId, columnId); + const currentValue = editingValues[editKey] ?? value ?? ''; + const isBusy = Boolean(editingBusy[editKey]); + const editorType = (col.editor || col.editType || 'text').toLowerCase(); + + const stop = (event) => event.stopPropagation(); + + const handleValueChange = (nextVal) => { + setEditingValues((prev) => ({ ...prev, [editKey]: nextVal })); + }; + + const finalize = async () => { + if (col.saveOnBlur === false) return; + await commitEdit({ row, columnId, value: editingValues[editKey] ?? value, columnConfig: col, editKey }); + if (col.resetAfterSave !== false) { + setEditingValues((prev) => { + const next = { ...prev }; + delete next[editKey]; + return next; + }); + } + }; + + const handleKeyDown = async (event) => { + if (event.key === 'Enter' && (col.saveOnEnter ?? true)) { + event.preventDefault(); + await commitEdit({ row, columnId, value: editingValues[editKey] ?? value, columnConfig: col, editKey }); + } + }; + + const commonProps = { + size: col.editorSize || 'xs', + disabled: isBusy, + onClick: stop, + onKeyDown: handleKeyDown, + onBlur: finalize, + }; + + if (editorType === 'number') { + return ( + handleValueChange(val)} + /> + ); + } + + if (editorType === 'select') { + const options = ensureArray(col.options).map((opt) => + typeof opt === 'object' + ? { value: String(opt.value ?? opt.id), label: opt.label ?? String(opt.value ?? opt.id) } + : { value: String(opt), label: String(opt) } + ); + return ( + column.setFilterValue(val || undefined)} + placeholder={columnMeta.filterPlaceholder || 'Select'} + clearable + onClick={stop} + /> + ); + } + + if (filterType === 'multi-select') { + const options = ensureArray(columnMeta.options).map((opt) => + typeof opt === 'object' + ? { value: String(opt.value ?? opt.id), label: opt.label ?? String(opt.value ?? opt.id) } + : { value: String(opt), label: String(opt) } + ); + return ( + column.setFilterValue(val.length ? val : undefined)} + placeholder={columnMeta.filterPlaceholder || 'Filter'} + searchable + onClick={stop} + /> + ); + } + + if (filterType === 'between') { + const [min, max] = Array.isArray(filterValue) ? filterValue : ['', '']; + return ( + + column.setFilterValue([val, max])} + /> + column.setFilterValue([min, val])} + /> + + ); + } + + return ( + column.setFilterValue(event.currentTarget.value || undefined)} + placeholder={columnMeta.filterPlaceholder || 'Filter'} + onClick={stop} + /> + ); + }; + + const renderExpanded = (row) => { + if (!node.expandable) return null; + const fields = ensureArray(node.expandable.fields); + if (fields.length > 0) { + return ( + + {fields.map((field) => ( + + + {field.label || field.path} + + + {String(getByPath(row.original, field.path || field.id || field.label) ?? '')} + + + ))} + + ); + } + return ( + + ); + }; + + const pageCount = table.getPageCount(); + const selectedRows = table.getSelectedRowModel().rows; + + const mappedBulkActions = bulkActions.map((action) => ({ + ...action, + params: { + ...(action.params || {}), + rows: selectedRows.map((row) => row.original), + rowIds: selectedRows.map((row) => row.id), + }, + })); + + const columnToggleMenu = node.columnControls === false + ? null + : ( + + + + + + + + Columns + {table + .getAllLeafColumns() + .filter((column) => column.getCanHide()) + .map((column) => ( + { + event.preventDefault(); + column.toggleVisibility(); + }} + > + + + {column.columnDef.header || column.id} + + + ))} + + + ); + + const stickyHeader = node.sticky?.header ?? node.sticky === 'header'; + const stickyOffset = node.sticky?.offset ?? 0; + + const renderRowCells = (row) => + row.getVisibleCells().map((cell) => ( + {flexRender(cell.column.columnDef.cell, cell.getContext())} + )); + + const renderTableBody = () => { + return ( + + {loading && rows.length === 0 && ( + + + + + + + + )} + {tableRows.map((row) => ( + + + {renderRowCells(row)} + + {expandedRow === row.id && node.expandable && ( + + {renderExpanded(row)} + + )} + + ))} + + ); + }; + + const tableElement = ( + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const sortStatus = header.column.getIsSorted(); + const sortIndicator = sortStatus === 'asc' ? '▲' : sortStatus === 'desc' ? '▼' : null; + return ( + + + + {flexRender(header.column.columnDef.header, header.getContext())} + {sortIndicator ? {sortIndicator} : null} + + {header.column.getCanFilter() && node.columnFilters !== false + ? renderColumnFilterControl(header) + : null} + + {header.column.getCanResize() ? ( + + ) : null} + + ); + })} + + ))} + + {renderTableBody()} +
+ ); + + return ( + + {(node.filterable !== false || columnToggleMenu || (selectionEnabled && selectedRows.length > 0 && mappedBulkActions.length > 0)) && ( + + {node.filterable !== false ? ( + { + const value = event.currentTarget.value; + setGlobalFilter(value); + table.setGlobalFilter(value); + setPageIndex(0); + }} + style={{ flex: 1 }} + /> + ) : ( + + )} + + {columnToggleMenu} + + + )} + + {selectionEnabled && selectedRows.length > 0 && mappedBulkActions.length > 0 && ( + + {selectedRows.length} selected + + + )} + + {virtualize ? ( + + {tableElement} + + ) : ( + tableElement + )} + + {node.pagination !== false && pageCount > 1 && ( + +