Merge pull request #1155 from sv-dispatcharr/feat/plugin-hub
Some checks failed
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
Frontend Tests / test (push) Waiting to run
Base Image Build / prepare (push) Has been cancelled
Base Image Build / docker (amd64, ubuntu-24.04) (push) Has been cancelled
Base Image Build / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
Base Image Build / create-manifest (push) Has been cancelled

[Feature]: Add in-app plugin repo browser & management
This commit is contained in:
SergeantPanda 2026-04-10 15:01:09 -05:00 committed by GitHub
commit d8d0695963
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 4856 additions and 474 deletions

586
Plugin_repo.md Normal file
View file

@ -0,0 +1,586 @@
# Dispatcharr Plugin Repository Specification
How to create and host a plugin repository that Dispatcharr can consume.
For writing plugins themselves, see [Plugins.md](Plugins.md).
---
## Overview
Dispatcharr discovers plugins from remote repositories using a two-level manifest system:
1. **Repo manifest** - a JSON file listing all plugins in the repo with basic metadata.
2. **Per-plugin manifest** (optional) - a JSON file per plugin with full version history, checksums, and compatibility info.
Users add a repo by its manifest URL. Dispatcharr fetches and caches the repo manifest periodically (default: every 6 hours, configurable). The UI displays all plugins from enabled repos in a browsable store.
---
## Repo Manifest
The repo manifest is the entry point. Dispatcharr fetches this URL and caches the response.
### Minimal Example (no signing)
```json
{
"registry_name": "My Plugin Repo",
"plugins": [
{
"slug": "my_plugin",
"name": "My Plugin",
"description": "Does something useful",
"author": "Your Name",
"latest_version": "1.0.0",
"latest_url": "https://example.com/releases/my_plugin-1.0.0.zip"
}
]
}
```
This is the simplest valid repo manifest - one plugin with enough info to show in the store and install.
### Full Example (with signing)
```json
{
"manifest": {
"registry_name": "My Plugin Repo",
"registry_url": "https://github.com/myorg/my-plugins",
"root_url": "https://raw.githubusercontent.com/myorg/my-plugins/releases",
"plugins": [
{
"slug": "weather_display",
"name": "Weather Display",
"description": "Shows weather info on the dashboard",
"author": "Acme Labs",
"license": "MIT",
"latest_version": "1.2.5",
"last_updated": "2025-01-20T15:30:00Z",
"manifest_url": "plugins/weather_display/manifest.json",
"latest_url": "plugins/weather_display/releases/weather_display-1.2.5.zip",
"latest_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"icon_url": "plugins/weather_display/logo.png",
"min_dispatcharr_version": "2.5.0",
"max_dispatcharr_version": null
}
]
},
"signature": "-----BEGIN PGP SIGNATURE-----\n..."
}
```
### Accepted Formats
Dispatcharr accepts two top-level shapes:
**Wrapped (supports signing):**
```json
{
"manifest": { "plugins": [...], ... },
"signature": "..."
}
```
**Flat (no signing):**
```json
{
"plugins": [...],
"registry_name": "...",
"root_url": "..."
}
```
The wrapped format is required for signing. If you don't need signing, the flat format works and is simpler.
### Name Restrictions
`registry_name` is required. Dispatcharr rejects repos that are missing it.
Third-party repos must not use names that could be confused with an official Dispatcharr repo. The following words are blocked in `registry_name` (case-insensitive):
- "official"
- "dispatcharr plugins"
- "dispatcharr repo"
- "dispatcharr official"
If the name contains any of these, the repo will be rejected on add and skipped during refresh.
---
## Repo Manifest Fields
### Top-Level Metadata
| Field | Required | Description |
|-------|----------|-------------|
| `registry_name` | **Yes** | Display name for the repo. Must not contain words like "official" or "dispatcharr" that could be mistaken for an official repo (see [Name Restrictions](#name-restrictions)). |
| `registry_url` | No | URL to the repo's home page (e.g. GitHub). Used as a fallback for generating icon URLs. |
| `root_url` | No | Base URL for resolving relative URLs in plugin entries. Trailing slashes are stripped. |
| `plugins` | **Yes** | Array of plugin entry objects. |
### Plugin Entry Fields
| Field | Required | Description |
|-------|----------|-------------|
| `slug` | **Yes** | Unique identifier. Alphanumeric, dashes, and underscores. Used as the install directory name (lowercased, dashes converted to underscores). |
| `name` | **Yes** | Human-readable display name. |
| `description` | No | Short description shown on the plugin card. |
| `author` | No | Author or organization name. |
| `license` | No | SPDX license identifier (e.g. `MIT`, `GPL-3.0`). Displayed as a link to the SPDX license page. |
| `latest_version` | No | Current latest version string (semver: `1.2.3` or `v1.2.3`). Drives update detection. |
| `last_updated` | No | ISO 8601 timestamp of the latest release. Shown as "Built" date in the detail view. |
| `manifest_url` | No | URL (or relative path) to the per-plugin manifest with full version history. See [Per-Plugin Manifest](#per-plugin-manifest). |
| `latest_url` | No | Direct download URL (or relative path) to the latest release zip. |
| `latest_sha256` | No | SHA256 checksum of the latest release zip (lowercase hex, 64 chars). |
| `latest_md5` | No | MD5 checksum of the latest release zip. Informational only - not validated by Dispatcharr. |
| `icon_url` | No | URL (or relative path) to a logo image (PNG recommended). |
| `min_dispatcharr_version` | No | Minimum Dispatcharr version required. Install is blocked if the running version is older. |
| `max_dispatcharr_version` | No | Maximum Dispatcharr version supported. Install is blocked if the running version is newer. |
Extra fields in a plugin entry are passed through to the frontend as-is, so you can include custom metadata (e.g. `homepage`, `tags`) without breaking anything.
### URL Resolution
If `root_url` is set and a URL field (`manifest_url`, `latest_url`, `icon_url`) does not start with `http://` or `https://`, it is treated as relative and resolved as:
```
{root_url}/{field_value}
```
This lets you keep plugin entries compact:
```json
{
"root_url": "https://raw.githubusercontent.com/myorg/my-plugins/releases",
"plugins": [
{
"slug": "my_plugin",
"latest_url": "plugins/my_plugin/my_plugin-1.0.0.zip",
"icon_url": "plugins/my_plugin/logo.png",
"manifest_url": "plugins/my_plugin/manifest.json"
}
]
}
```
**Icon fallback:** If `icon_url` is missing and `registry_url` is set, Dispatcharr generates a fallback URL by converting the GitHub URL to a raw content URL:
```
{registry_url => raw.githubusercontent.com}/refs/heads/main/plugins/{slug}/logo.png
```
---
## Per-Plugin Manifest (Optional)
The per-plugin manifest provides full version history. It is fetched on-demand when a user clicks "More Info" on a plugin card. It is **not required** - if `manifest_url` is absent, the UI builds a detail view from the repo-level fields instead.
Include a per-plugin manifest if you want to:
- Offer multiple downloadable versions
- Show per-version compatibility ranges
- Display build timestamps and commit links for each version
- Provide detailed author/license info beyond what's in the repo manifest
### Accepted Formats
Same as the root manifest - both flat and wrapped formats are accepted:
**Flat (no signing):**
```json
{
"slug": "...",
"versions": [...]
}
```
**Wrapped (supports signing):**
```json
{
"manifest": {
"slug": "...",
"versions": [...]
},
"signature": "-----BEGIN PGP SIGNATURE-----\n..."
}
```
Use the wrapped format if you want to GPG-sign the per-plugin manifest.
### Example
```json
{
"slug": "weather_display",
"name": "Weather Display",
"description": "Shows weather information on the Dispatcharr dashboard",
"author": "Acme Labs",
"license": "MIT",
"latest_version": "1.2.5",
"versions": [
{
"version": "1.2.5",
"url": "releases/weather_display-1.2.5.zip",
"checksum_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"build_timestamp": "2025-01-20T15:30:00Z",
"commit_sha": "4e8f1b108c1e84f60520710d13e54eb2fb519648",
"commit_sha_short": "4e8f1b1",
"min_dispatcharr_version": "2.5.0",
"max_dispatcharr_version": null
},
{
"version": "1.2.5-rc.1",
"url": "releases/weather_display-1.2.5-rc.1.zip",
"checksum_sha256": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
"prerelease": true,
"build_timestamp": "2025-01-18T09:00:00Z",
"min_dispatcharr_version": "2.5.0"
},
{
"version": "1.2.4",
"url": "releases/weather_display-1.2.4.zip",
"checksum_sha256": "d4d967a67a4947e55183308cece206b30dda3e1b4fe00aae60f45a49c83b7ed6",
"build_timestamp": "2025-01-15T10:00:00Z",
"min_dispatcharr_version": "2.4.0"
}
],
"latest": {
"version": "1.2.5",
"url": "releases/weather_display-1.2.5.zip",
"checksum_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"build_timestamp": "2025-01-20T15:30:00Z",
"min_dispatcharr_version": "2.5.0"
}
}
```
### Per-Plugin Manifest Fields
| Field | Required | Description |
|-------|----------|-------------|
| `slug` | No | Plugin identifier (should match the repo entry). |
| `name` | No | Display name. |
| `description` | No | Full description shown in the detail modal. |
| `author` | No | Author/org name shown in the detail modal. |
| `license` | No | SPDX license identifier. |
| `latest_version` | No | Latest version string. |
| `versions` | No | Array of version objects (newest first recommended). |
| `latest` | No | Object mirroring the latest version entry for quick access. |
### Version Object Fields
| Field | Required | Description |
|-------|----------|-------------|
| `version` | **Yes** | Version string (`1.2.3` or `v1.2.3`). |
| `url` | **Yes** | Download URL for the zip. Relative URLs are resolved against the repo's `root_url`. |
| `checksum_sha256` | No | SHA256 hex checksum. **Strongly recommended.** Validated on install - mismatch blocks the install. |
| `prerelease` | No | Boolean. When `true`, marks this version as a pre-release (alpha, beta, RC, etc.). If the installed version is a prerelease, Dispatcharr will not suggest updating to the latest stable version - the user must install a new version manually. The latest version in the root manifest is always assumed to be stable, so this field only needs to appear in the per-plugin manifest. Omit or set to `false` for stable releases. |
| `build_timestamp` | No | ISO 8601 build timestamp. Shown as "Built" in the version detail. |
| `commit_sha` | No | Full Git commit SHA. Used to build a commit link if `registry_url` is set. |
| `commit_sha_short` | No | Abbreviated commit SHA. Displayed in the version detail table as a clickable link. |
| `min_dispatcharr_version` | No | Minimum compatible Dispatcharr version. |
| `max_dispatcharr_version` | No | Maximum compatible Dispatcharr version. |
Relative `url` values in versions are resolved the same way as repo-level URLs: `{root_url}/{url}`.
---
## Without a Per-Plugin Manifest
If you omit `manifest_url` from a plugin entry, the store still works. When a user clicks "More Info", the UI builds a detail view from the repo-level fields:
- `description`, `author`, `license` from the plugin entry
- A single version entry built from `latest_version`, `latest_url`, `latest_sha256`, `min_dispatcharr_version`, `max_dispatcharr_version`, and `last_updated`
This is the simplest path for third-party repos that only publish one version at a time. You lose version history and per-version release dates, but install, update detection, and everything else works the same.
---
## Signing
Signing your repo manifest lets Dispatcharr verify it hasn't been tampered with. Signing is **optional** - unsigned repos work fine but show an "unverified" badge in the UI.
### How It Works
1. You generate a GPG keypair.
2. You sign the manifest JSON and include the detached signature in the response.
3. When adding the repo in Dispatcharr, the user pastes your public key.
4. Dispatcharr verifies the signature on every manifest fetch.
### Key Format
Standard PGP/GPG armored keys:
```
-----BEGIN PGP PUBLIC KEY BLOCK-----
mQINBG...
...
-----END PGP PUBLIC KEY BLOCK-----
```
### Signing Convention
The signature is computed over the **canonical JSON** representation of the `manifest` object (not the entire response), plus a trailing newline:
```bash
# Canonical format: compact JSON (no spaces) + trailing newline
jq -c '.manifest' manifest.json | gpg --armor --detach-sign
```
In code terms:
```python
import json
canonical = json.dumps(manifest_obj, separators=(",", ":")) + "\n"
```
> **Important:** The signing input must be `json.dumps(obj, separators=(",", ":")) + "\n"` - compact JSON with no whitespace, followed by exactly one newline. Any difference (pretty-printing, trailing spaces, key ordering changes) will cause verification to fail.
### Manifest Structure for Signing
Use the wrapped format so the signature sits alongside the manifest:
```json
{
"manifest": {
"registry_name": "...",
"plugins": [...]
},
"signature": "-----BEGIN PGP SIGNATURE-----\n...\n-----END PGP SIGNATURE-----"
}
```
### Verification Results
| Result | Meaning | UI Badge |
|--------|---------|----------|
| `true` | Valid signature | Green checkmark |
| `false` | Invalid signature or verification error | Red X |
| `null` | Not attempted (no signature, no key, or `python-gnupg` not installed) | Gray/neutral |
### Signing Workflow Example
```bash
# Generate a keypair (one-time)
gpg --gen-key
# Export your public key (give this to repo users)
gpg --armor --export "your@email.com" > my-repo.pub
# Build your manifest
cat > manifest.json << 'EOF'
{
"manifest": {
"registry_name": "My Repo",
"root_url": "https://example.com/releases",
"plugins": [
{
"slug": "my_plugin",
"name": "My Plugin",
"latest_version": "1.0.0",
"latest_url": "plugins/my_plugin/my_plugin-1.0.0.zip"
}
]
}
}
EOF
# Sign the manifest object (canonical JSON + newline)
jq -c '.manifest' manifest.json | gpg --armor --detach-sign > manifest.sig
# Combine into final output
jq --arg sig "$(cat manifest.sig)" '.signature = $sig' manifest.json > signed_manifest.json
```
### Third-Party Key Management
When a user adds your repo URL, they can paste your public key. Dispatcharr stores the key per-repo and uses it for verification. Users can update the key at any time from the repo management UI.
If you don't provide a key and the repo is not the official Dispatcharr repo, signature verification is skipped (result: `null`).
---
## Release Zip Format
Each plugin release is a `.zip` archive.
### Requirements
- Must contain a `plugin.py` with a `Plugin` class, **or** a Python package with `__init__.py` exporting a `Plugin` class.
- Files can be at the top level of the zip or inside a single subdirectory.
- Optionally include `plugin.json` for metadata discovery without code execution.
- Optionally include `logo.png` for the plugin icon.
### Size Limits
- Maximum 2000 files per archive.
- Maximum total size: 200 MB (configurable via `MAX_PLUGIN_IMPORT_BYTES` setting).
### Recommended Structure
```
my_plugin-1.0.0.zip
plugin.py
plugin.json
logo.png
(any other files your plugin needs)
```
Or with a subdirectory:
```
my_plugin-1.0.0.zip
my_plugin/
plugin.py
plugin.json
logo.png
utils.py
```
---
## Install Flow
When a user installs a plugin from the store:
1. **Version compatibility check** - if `min_dispatcharr_version` or `max_dispatcharr_version` is set, the running Dispatcharr version is compared. Install is blocked if out of range.
2. **Download** - the zip is streamed from `download_url` (max 200 MB).
3. **SHA256 integrity check** - if `sha256` was provided, the download is hashed and compared. Mismatch blocks the install.
4. **Extraction** - the zip is extracted to a temp directory, validated, then moved to `/data/plugins/{plugin_key}/`. If the plugin already exists, the old version is backed up and restored on failure (atomic rollback).
5. **Registration** - a `PluginConfig` record is created or updated, linking the plugin to its source repo and slug.
6. **Discovery reload** - the plugin loader re-scans all plugin directories.
The plugin is installed **disabled** by default. The user can enable it from the post-install dialog or the My Plugins page.
---
## Update Detection
Dispatcharr detects updates by comparing `installed_version` (stored in the database) against `latest_version` from the repo manifest. This uses repo-level fields only - per-plugin manifests are not needed for update detection.
A plugin shows "Update Available" when:
- It is managed (installed from a repo)
- Its `installed_version` differs from `latest_version`
- It was installed from the same repo
---
## Hosting Options
A plugin repo manifest is just a JSON file served over HTTPS. Some options:
### GitHub Pages / Raw Content
Host your manifest and release zips in a GitHub repo. Use raw.githubusercontent.com URLs:
```
https://raw.githubusercontent.com/myorg/my-plugins/main/manifest.json
```
Use `root_url` pointing to your releases branch/path so version URLs stay relative.
### Static File Server
Any web server that serves JSON works. Dispatcharr fetches manifests server-side, so CORS is not needed.
### GitHub Releases
You can host release zips as GitHub Release assets and reference them with absolute URLs in your manifest. The manifest itself can live in the repo's default branch.
---
## Refresh Behavior
- Manifests are refreshed automatically at a configurable interval (default: 6 hours, setting: `refresh_interval_hours`, 0 = disabled).
- Users can force a refresh from the repo management UI.
- A new repo is refreshed immediately when added.
- On refresh, if a plugin's `slug` disappears from the manifest, its `PluginConfig` is unlinked from the repo (becomes "unmanaged") but the installed files are not deleted.
---
## Checklist: Publishing a Plugin Repo
### Minimum Viable Repo
- [ ] Host a JSON file at a stable, public URL
- [ ] Set `registry_name` (required, must not sound official)
- [ ] Include at least one plugin entry with `slug`, `name`, and `latest_version`
- [ ] Host a downloadable `.zip` for each plugin and set `latest_url`
- [ ] Share the manifest URL with users
### Recommended
- [ ] Set `root_url` so plugin URLs can be relative
- [ ] Include `description`, `author`, and `icon_url` per plugin
- [ ] Include `latest_sha256` for integrity verification
- [ ] Include `license` (SPDX identifier)
- [ ] Include `last_updated` timestamps
- [ ] Add a per-plugin `manifest_url` with version history
- [ ] Include `sha256` in every version object
- [ ] Include `min_dispatcharr_version` where applicable
- [ ] Include `plugin.json` in each release zip
### Optional
- [ ] Sign your manifest with GPG and publish your public key
- [ ] Set `registry_url` to enable automatic icon fallback
- [ ] Set `max_dispatcharr_version` if a plugin is incompatible with newer releases
---
## Quick Reference: Repo Manifest Schema
```json
{
"manifest": {
"registry_name": "string (required)",
"registry_url": "string (optional)",
"root_url": "string (optional)",
"plugins": [
{
"slug": "string (required)",
"name": "string (required)",
"description": "string",
"author": "string",
"license": "string (SPDX)",
"latest_version": "string (semver)",
"last_updated": "string (ISO 8601)",
"manifest_url": "string (URL or relative path)",
"latest_url": "string (URL or relative path)",
"latest_sha256": "string (64-char hex)",
"icon_url": "string (URL or relative path)",
"min_dispatcharr_version": "string (semver)",
"max_dispatcharr_version": "string (semver) or null"
}
]
},
"signature": "string (armored PGP signature, optional)"
}
```
## Quick Reference: Per-Plugin Manifest Schema
```json
{
"slug": "string",
"name": "string",
"description": "string",
"author": "string",
"license": "string (SPDX)",
"latest_version": "string (semver)",
"versions": [
{
"version": "string (required)",
"url": "string (required, URL or relative path)",
"checksum_sha256": "string (64-char hex)",
"build_timestamp": "string (ISO 8601)",
"commit_sha": "string",
"commit_sha_short": "string",
"min_dispatcharr_version": "string (semver)",
"max_dispatcharr_version": "string (semver) or null"
}
],
"latest": {
"version": "string",
"url": "string",
"checksum_sha256": "string",
"build_timestamp": "string",
"min_dispatcharr_version": "string",
"max_dispatcharr_version": "string or null"
}
}
```

View file

@ -8,6 +8,14 @@ from .api_views import (
PluginImportAPIView,
PluginDeleteAPIView,
PluginLogoAPIView,
PluginRepoListCreateAPIView,
PluginRepoPreviewAPIView,
PluginRepoDetailAPIView,
PluginRepoRefreshAPIView,
AvailablePluginsAPIView,
PluginDetailManifestAPIView,
PluginInstallFromRepoAPIView,
PluginRepoSettingsAPIView,
)
app_name = "plugins"
@ -21,4 +29,13 @@ urlpatterns = [
path("plugins/<str:key>/run/", PluginRunAPIView.as_view(), name="run"),
path("plugins/<str:key>/enabled/", PluginEnabledAPIView.as_view(), name="enabled"),
path("plugins/<str:key>/logo/", PluginLogoAPIView.as_view(), name="logo"),
# Plugin repos (hub / store) - static paths first, then parametric
path("repos/", PluginRepoListCreateAPIView.as_view(), name="repo-list"),
path("repos/available/", AvailablePluginsAPIView.as_view(), name="available-plugins"),
path("repos/plugin-detail/", PluginDetailManifestAPIView.as_view(), name="plugin-detail-manifest"),
path("repos/install/", PluginInstallFromRepoAPIView.as_view(), name="repo-install"),
path("repos/settings/", PluginRepoSettingsAPIView.as_view(), name="repo-settings"),
path("repos/preview/", PluginRepoPreviewAPIView.as_view(), name="repo-preview"),
path("repos/<int:pk>/", PluginRepoDetailAPIView.as_view(), name="repo-detail"),
path("repos/<int:pk>/refresh/", PluginRepoRefreshAPIView.as_view(), name="repo-refresh"),
]

File diff suppressed because it is too large Load diff

View file

@ -52,3 +52,53 @@ class PluginsConfig(AppConfig):
import logging
logging.getLogger(__name__).exception("Plugin discovery wiring failed during app ready")
# Register periodic task for refreshing plugin repo manifests
self._setup_repo_refresh_schedule()
# Refresh repo manifests once at startup so the UI always has current data
self._enqueue_startup_refresh()
def _enqueue_startup_refresh(self):
from dispatcharr.app_initialization import should_skip_initialization
if should_skip_initialization():
return
try:
from .tasks import refresh_plugin_repos
refresh_plugin_repos.apply_async(countdown=10)
except Exception:
import logging
logging.getLogger(__name__).debug(
"Could not enqueue startup plugin repo refresh (Celery may not be ready yet)"
)
def _setup_repo_refresh_schedule(self):
from dispatcharr.app_initialization import should_skip_initialization
if should_skip_initialization():
return
try:
from core.scheduling import create_or_update_periodic_task, delete_periodic_task
from core.models import CoreSettings
from .tasks import PLUGIN_REPO_REFRESH_TASK_NAME
interval = 6
try:
obj = CoreSettings.objects.get(key="plugin_repo_settings")
interval = obj.value.get("refresh_interval_hours", 6)
except CoreSettings.DoesNotExist:
pass
if interval == 0:
delete_periodic_task(PLUGIN_REPO_REFRESH_TASK_NAME)
else:
create_or_update_periodic_task(
task_name=PLUGIN_REPO_REFRESH_TASK_NAME,
celery_task_path="apps.plugins.tasks.refresh_plugin_repos",
interval_hours=interval,
enabled=True,
)
except Exception:
import logging
logging.getLogger(__name__).debug(
"Could not set up plugin repo refresh schedule (migrations may not have run yet)"
)

View file

@ -0,0 +1,11 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
mDMEacgfABYJKwYBBAHaRw8BAQdAh1MuVNBxk+CExQPjOVDvAGvIk6BdGS2ce9/h
zB7lYtW0TERpc3BhdGNoYXJyIFBsdWdpbiBSZXBvIChkaXNwYXRjaGFyci1hdXRv
Z2VuZXJhdGVkKSA8cGx1Z2luc0BkaXNwYXRjaGFyci50dj6IrwQTFgoAVxYhBEap
MFaOD7nKg0zX+H7AOmtMIjTOBQJpyB8AGxSAAAAAAAQADm1hbnUyLDIuNSsxLjEy
LDAsMwIbAwULCQgHAgIiAgYVCgkICwIEFgIDAQIeBwIXgAAKCRB+wDprTCI0zvNZ
AP9r3TpMpiI8BCNo9B5M9lJ+QLRo9ihPWIcqBzJ9eFCoSQEAgguiZsNy6aJzKjIb
yDvGuoZi3I2/GNM/f2qVzFtgPQk=
=Zf/y
-----END PGP PUBLIC KEY BLOCK-----

View file

@ -367,21 +367,35 @@ class PluginManager:
obj.save()
def list_plugins(self) -> List[Dict[str, Any]]:
from .models import PluginConfig
from .models import PluginConfig, PluginRepo
plugins: List[Dict[str, Any]] = []
with self._lock:
registry_snapshot = dict(self._registry)
try:
configs = {c.key: c for c in PluginConfig.objects.all()}
configs = {c.key: c for c in PluginConfig.objects.select_related("source_repo").all()}
except Exception as e:
# Database might not be migrated yet; fall back to registry only
logger.warning("PluginConfig table unavailable; listing registry only: %s", e)
configs = {}
# Build repo latest-version lookup from cached manifests
repo_latest = {} # slug -> latest_version
try:
for repo in PluginRepo.objects.filter(enabled=True):
manifest_data = repo.cached_manifest or {}
manifest = manifest_data.get("manifest", manifest_data)
for rp in manifest.get("plugins", []):
s = rp.get("slug", "")
if s:
repo_latest[s] = rp.get("latest_version", "")
except Exception:
pass
# First, include all discovered plugins
for key, lp in registry_snapshot.items():
conf = configs.get(key)
conf_slug = conf.slug if conf else ""
trusted = bool(conf and (conf.ever_enabled or conf.enabled))
logo_url = self._get_logo_url(key, path=lp.path)
plugins.append(
@ -393,7 +407,7 @@ class PluginManager:
"author": getattr(lp, "author", "") or "",
"help_url": getattr(lp, "help_url", "") or "",
"enabled": conf.enabled if conf else False,
"ever_enabled": getattr(conf, "ever_enabled", False) if conf else False,
"ever_enabled": conf.ever_enabled if conf else False,
"fields": lp.fields or [],
"settings": (conf.settings if conf else {}),
"actions": lp.actions or [],
@ -402,6 +416,22 @@ class PluginManager:
"loaded": bool(lp.loaded),
"legacy": bool(getattr(lp, "legacy", False)),
"logo_url": logo_url,
"source_repo": conf.source_repo_id if conf else None,
"source_repo_name": conf.source_repo.name if conf and conf.source_repo else None,
"is_official_repo": bool(conf and conf.source_repo and conf.source_repo.is_official),
"slug": conf_slug,
"is_managed": bool(conf and conf.source_repo_id),
"installed_version_is_prerelease": bool(
conf and conf.installed_version_is_prerelease
),
"update_available": bool(
conf_slug and conf and conf.source_repo_id
and not (conf and conf.installed_version_is_prerelease)
and repo_latest.get(conf_slug)
and lp.version != repo_latest.get(conf_slug)
),
"latest_version": repo_latest.get(conf_slug, ""),
"deprecated": conf.deprecated if conf else False,
}
)
@ -428,6 +458,22 @@ class PluginManager:
"loaded": False,
"legacy": False,
"logo_url": self._get_logo_url(key),
"source_repo": conf.source_repo_id,
"source_repo_name": conf.source_repo.name if conf.source_repo else None,
"is_official_repo": bool(conf.source_repo and conf.source_repo.is_official),
"slug": conf.slug,
"is_managed": bool(conf.source_repo_id),
"installed_version_is_prerelease": bool(
conf.installed_version_is_prerelease
),
"update_available": bool(
conf.slug and conf.source_repo_id
and not conf.installed_version_is_prerelease
and repo_latest.get(conf.slug)
and conf.version != repo_latest.get(conf.slug)
),
"latest_version": repo_latest.get(conf.slug or "", ""),
"deprecated": conf.deprecated,
}
)

View file

@ -0,0 +1,84 @@
import django.db.models.deletion
from django.db import migrations, models
def seed_official_repo(apps, schema_editor):
PluginRepo = apps.get_model("plugins", "PluginRepo")
PluginRepo.objects.get_or_create(
url="https://raw.githubusercontent.com/Dispatcharr/Plugins/releases/manifest.json",
defaults={
"name": "Dispatcharr Official",
"is_official": True,
"enabled": True,
},
)
def unseed_official_repo(apps, schema_editor):
PluginRepo = apps.get_model("plugins", "PluginRepo")
PluginRepo.objects.filter(is_official=True).delete()
class Migration(migrations.Migration):
dependencies = [
("plugins", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="PluginRepo",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)),
("url", models.URLField(unique=True)),
("is_official", models.BooleanField(default=False)),
("enabled", models.BooleanField(default=True)),
("cached_manifest", models.JSONField(blank=True, default=dict)),
("last_fetched", models.DateTimeField(blank=True, null=True)),
("public_key", models.TextField(blank=True, default="")),
("signature_verified", models.BooleanField(blank=True, default=None, null=True)),
("last_fetch_status", models.CharField(blank=True, default="", max_length=255)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={
"ordering": ["-is_official", "name"],
},
),
migrations.RunPython(seed_official_repo, unseed_official_repo),
migrations.AddField(
model_name="pluginconfig",
name="source_repo",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="installed_plugins",
to="plugins.pluginrepo",
),
),
migrations.AddField(
model_name="pluginconfig",
name="slug",
field=models.CharField(blank=True, default="", max_length=128),
),
migrations.AddField(
model_name="pluginconfig",
name="installed_version_is_prerelease",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="pluginconfig",
name="deprecated",
field=models.BooleanField(default=False),
),
]

View file

@ -12,8 +12,52 @@ class PluginConfig(models.Model):
# Tracks whether this plugin has ever been enabled at least once
ever_enabled = models.BooleanField(default=False)
settings = models.JSONField(default=dict, blank=True)
# Managed plugin fields (populated when installed from a repo)
source_repo = models.ForeignKey(
"PluginRepo",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="installed_plugins",
)
slug = models.CharField(max_length=128, blank=True, default="")
installed_version_is_prerelease = models.BooleanField(default=False)
deprecated = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@property
def is_managed(self):
return bool(self.source_repo_id)
def __str__(self) -> str:
return f"{self.name} ({self.key})"
OFFICIAL_REPO_URL = (
"https://raw.githubusercontent.com/Dispatcharr/Plugins/releases/manifest.json"
)
class PluginRepo(models.Model):
"""A remote plugin repository manifest URL."""
name = models.CharField(max_length=255)
url = models.URLField(unique=True)
is_official = models.BooleanField(default=False)
enabled = models.BooleanField(default=True)
cached_manifest = models.JSONField(default=dict, blank=True)
public_key = models.TextField(blank=True, default="")
signature_verified = models.BooleanField(null=True, blank=True, default=None)
last_fetched = models.DateTimeField(null=True, blank=True)
last_fetch_status = models.CharField(max_length=255, blank=True, default="")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-is_official", "name"]
def __str__(self) -> str:
return self.name

View file

@ -1,4 +1,5 @@
from rest_framework import serializers
from .models import PluginRepo
class PluginActionSerializer(serializers.Serializer):
@ -46,3 +47,40 @@ class PluginSerializer(serializers.Serializer):
fields = PluginFieldSerializer(many=True)
settings = serializers.JSONField()
actions = PluginActionSerializer(many=True)
source_repo = serializers.IntegerField(required=False, allow_null=True)
slug = serializers.CharField(required=False, allow_blank=True)
is_managed = serializers.BooleanField(required=False)
deprecated = serializers.BooleanField(required=False)
class PluginRepoSerializer(serializers.ModelSerializer):
registry_url = serializers.SerializerMethodField()
plugin_count = serializers.SerializerMethodField()
class Meta:
model = PluginRepo
fields = [
"id",
"name",
"url",
"is_official",
"enabled",
"public_key",
"signature_verified",
"registry_url",
"plugin_count",
"last_fetched",
"last_fetch_status",
"created_at",
"updated_at",
]
read_only_fields = ["id", "name", "is_official", "signature_verified", "registry_url", "plugin_count", "last_fetched", "last_fetch_status", "created_at", "updated_at"]
def get_registry_url(self, obj):
manifest = (obj.cached_manifest or {}).get("manifest", obj.cached_manifest or {})
return manifest.get("registry_url", "") or ""
def get_plugin_count(self, obj):
manifest = (obj.cached_manifest or {}).get("manifest", obj.cached_manifest or {})
plugins = manifest.get("plugins", [])
return len(plugins) if isinstance(plugins, list) else 0

33
apps/plugins/tasks.py Normal file
View file

@ -0,0 +1,33 @@
import logging
from celery import shared_task
logger = logging.getLogger(__name__)
PLUGIN_REPO_REFRESH_TASK_NAME = "plugin-repo-refresh-task"
@shared_task
def refresh_plugin_repos():
"""Refresh cached manifests for all enabled plugin repos."""
from .models import PluginRepo
from .api_views import _fetch_manifest, _save_fetched_manifest_to_repo, _unmanage_dropped_slugs
from django.utils import timezone
repos = PluginRepo.objects.filter(enabled=True)
for repo in repos:
try:
key_text = repo.public_key if not repo.is_official else None
data, verified = _fetch_manifest(repo.url, public_key_text=key_text)
err = _save_fetched_manifest_to_repo(repo, data, verified)
if err:
logger.warning("Skipping repo '%s': %s", repo.name, err)
continue
_unmanage_dropped_slugs(repo, data)
logger.info("Refreshed plugin repo '%s'", repo.name)
except Exception as e:
resp = getattr(e, 'response', None)
status_str = str(resp.status_code) if resp is not None and hasattr(resp, 'status_code') else type(e).__name__
repo.last_fetch_status = status_str[:255]
repo.last_fetched = timezone.now()
repo.save(update_fields=["last_fetch_status", "last_fetched", "updated_at"])
logger.warning("Failed to refresh plugin repo '%s': %s", repo.name, e)

View file

@ -14,6 +14,7 @@ import Stats from './pages/Stats';
import DVR from './pages/DVR';
import Settings from './pages/Settings';
import PluginsPage from './pages/Plugins';
import PluginBrowsePage from './pages/PluginBrowse';
import ConnectPage from './pages/Connect';
import ConnectLogsPage from './pages/ConnectLogs';
import Users from './pages/Users';
@ -153,6 +154,10 @@ const App = () => {
<Route path="/guide" element={<Guide />} />
<Route path="/dvr" element={<DVR />} />
<Route path="/stats" element={<Stats />} />
<Route
path="/plugins/browse"
element={<PluginBrowsePage />}
/>
<Route path="/plugins" element={<PluginsPage />} />
<Route path="/connect" element={<ConnectPage />} />
<Route

View file

@ -1908,26 +1908,28 @@ export default class API {
}
}
static async importPlugin(file) {
static async importPlugin(file, overwrite = false, silent = false) {
try {
const form = new FormData();
form.append('file', file);
if (overwrite) form.append('overwrite', 'true');
const response = await request(`${host}/api/plugins/plugins/import/`, {
method: 'POST',
body: form,
});
return response;
} catch (e) {
// Show only the concise error message for plugin import
const msg =
(e?.body && (e.body.error || e.body.detail)) ||
e?.message ||
'Failed to import plugin';
notifications.show({
title: 'Import failed',
message: msg,
color: 'red',
});
if (!silent) {
const msg =
(e?.body && (e.body.error || e.body.detail)) ||
e?.message ||
'Failed to import plugin';
notifications.show({
title: 'Import failed',
message: msg,
color: 'red',
});
}
throw e;
}
}
@ -1992,6 +1994,132 @@ export default class API {
}
}
// Plugin Repos API
static async getPluginRepos() {
try {
return await request(`${host}/api/plugins/repos/`);
} catch (e) {
errorNotification('Failed to retrieve plugin repos', e);
return [];
}
}
static async addPluginRepo(data) {
try {
return await request(`${host}/api/plugins/repos/`, {
method: 'POST',
body: data,
});
} catch (e) {
errorNotification('Failed to add plugin repo', e);
throw e;
}
}
static async deletePluginRepo(id) {
try {
return await request(`${host}/api/plugins/repos/${id}/`, {
method: 'DELETE',
});
} catch (e) {
errorNotification('Failed to delete plugin repo', e);
throw e;
}
}
static async updatePluginRepo(id, data) {
try {
return await request(`${host}/api/plugins/repos/${id}/`, {
method: 'PUT',
body: data,
});
} catch (e) {
errorNotification('Failed to update plugin repo', e);
}
}
static async refreshPluginRepo(id) {
try {
return await request(`${host}/api/plugins/repos/${id}/refresh/`, {
method: 'POST',
});
} catch (e) {
errorNotification('Failed to refresh plugin repo', e);
}
}
static async getAvailablePlugins() {
try {
const response = await request(
`${host}/api/plugins/repos/available/`
);
return response.plugins || [];
} catch (e) {
errorNotification('Failed to retrieve available plugins', e);
return [];
}
}
static async getPluginDetailManifest(repoId, manifestUrl) {
try {
const response = await request(
`${host}/api/plugins/repos/plugin-detail/`,
{
method: 'POST',
body: { repo_id: repoId, manifest_url: manifestUrl },
}
);
return response;
} catch (e) {
errorNotification('Failed to retrieve plugin details', e);
return null;
}
}
static async getPluginRepoSettings() {
try {
return await request(`${host}/api/plugins/repos/settings/`);
} catch (e) {
errorNotification('Failed to retrieve repo settings', e);
return null;
}
}
static async updatePluginRepoSettings(data) {
try {
return await request(`${host}/api/plugins/repos/settings/`, {
method: 'PUT',
body: data,
});
} catch (e) {
errorNotification('Failed to update repo settings', e);
return null;
}
}
static async installPluginFromRepo(data) {
try {
return await request(`${host}/api/plugins/repos/install/`, {
method: 'POST',
body: data,
});
} catch (e) {
errorNotification('Failed to install plugin', e);
return null;
}
}
static async previewPluginRepo(url, publicKey) {
try {
return await request(`${host}/api/plugins/repos/preview/`, {
method: 'POST',
body: { url, public_key: publicKey || '' },
});
} catch {
return null;
}
}
static async checkSetting(values) {
const { id, ...payload } = values;

View file

@ -0,0 +1,430 @@
import React, { useState } from 'react';
import {
ActionIcon,
Alert,
Badge,
Button,
Group,
Loader,
Select,
Stack,
Table,
Text,
Tooltip,
} from '@mantine/core';
import { AlertTriangle, Ban, Check, Download, RefreshCw, ShieldAlert, ShieldCheck, Trash2 } from 'lucide-react';
import { compareVersions } from './pluginUtils.js';
export const GitHubIcon = ({ size = 16 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
</svg>
);
export const DiscordIcon = ({ size = 16 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
<path d="M20.317 4.37a19.791 19.791 0 00-4.885-1.515.074.074 0 00-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 00-5.487 0 12.64 12.64 0 00-.617-1.25.077.077 0 00-.079-.037A19.736 19.736 0 003.677 4.37a.07.07 0 00-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 00.031.057 19.9 19.9 0 005.993 3.03.078.078 0 00.084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 00-.041-.106 13.107 13.107 0 01-1.872-.892.077.077 0 01-.008-.128 10.2 10.2 0 00.372-.292.074.074 0 01.077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 01.078.01c.12.098.246.198.373.292a.077.077 0 01-.006.127 12.299 12.299 0 01-1.873.892.077.077 0 00-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 00.084.028 19.839 19.839 0 006.002-3.03.077.077 0 00.032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 00-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.947 2.418-2.157 2.418z" />
</svg>
);
/**
* Shared plugin detail panel used in both PluginCard and AvailablePluginCard modals.
*
* Props:
* - detail manifest detail object { manifest: { ... }, signature_verified }
* - detailLoading boolean
* - selectedVersion string | null
* - onVersionChange (version) => void
* - installedVersion string | null currently installed version
* - appVersion string current app version for compat checks
* - installing boolean
* - uninstalling boolean
* - onInstall (params) => void called with { version, url, sha256, min/max }
* - onUninstall () => void called when uninstall button clicked
* - installStatus string | null 'unmanaged' | 'different_repo' | 'installed' | 'update_available' | 'not_installed'
* - installedSourceRepoName string for different_repo tooltip
* - installedVersionIsPrerelease boolean
* - repoId number
* - slug string
*/
const PluginDetailPanel = ({
detail,
detailLoading,
selectedVersion,
onVersionChange,
installedVersion,
installedVersionIsPrerelease = false,
appVersion,
installing = false,
uninstalling = false,
onInstall,
onUninstall,
installStatus,
installedSourceRepoName,
repoId,
slug,
}) => {
if (detailLoading) {
return (
<Stack align="center" py="xl">
<Loader size="sm" />
<Text size="sm" c="dimmed">Loading plugin details</Text>
</Stack>
);
}
if (!detail?.manifest) {
return <Text size="sm" c="dimmed">Failed to load plugin details.</Text>;
}
const manifest = detail.manifest;
const selectedVersionData = manifest.versions?.find(
(v) => v.version === selectedVersion
);
const isSelSame = installedVersion && selectedVersion &&
compareVersions(selectedVersion, installedVersion) === 0;
const isSelDowngrade = installedVersion && selectedVersion &&
compareVersions(selectedVersion, installedVersion) < 0;
const isInstalled = !!installedVersion;
const selMeetsMin = !selectedVersionData?.min_dispatcharr_version ||
compareVersions(appVersion, selectedVersionData.min_dispatcharr_version) >= 0;
const selMeetsMax = !selectedVersionData?.max_dispatcharr_version ||
compareVersions(appVersion, selectedVersionData.max_dispatcharr_version) <= 0;
const selCompatible = selMeetsMin && selMeetsMax;
const isOverwrite = installStatus === 'unmanaged' || installStatus === 'different_repo';
const handleInstallClick = () => {
if (isSelSame && onUninstall) {
onUninstall();
return;
}
if (!selectedVersionData?.url || !onInstall) return;
const params = {
repo_id: repoId,
slug,
version: selectedVersion,
download_url: selectedVersionData.url,
sha256: selectedVersionData.checksum_sha256,
min_dispatcharr_version: selectedVersionData.min_dispatcharr_version,
max_dispatcharr_version: selectedVersionData.max_dispatcharr_version,
prerelease: selectedVersionData.prerelease === true,
};
onInstall(params);
};
const getButtonProps = () => {
if (isOverwrite) {
return {
label: installing ? 'Installing…' : 'Overwrite',
color: 'orange',
icon: installing ? <Loader size={14} /> : <Download size={14} />,
variant: 'filled',
tooltip: installStatus === 'unmanaged'
? 'Installed manually installing will take over management'
: `Managed by ${installedSourceRepoName || 'another repo'} installing will transfer management to this repo`,
};
}
if (isSelSame) {
return {
label: uninstalling ? 'Uninstalling…' : 'Uninstall',
color: 'red',
icon: uninstalling ? <Loader size={14} /> : <Trash2 size={14} />,
variant: 'light',
};
}
if (!selCompatible) {
return {
label: 'Incompatible',
color: 'gray',
icon: <AlertTriangle size={14} />,
variant: 'filled',
};
}
if (isSelDowngrade) {
return {
label: installing ? 'Downgrading…' : 'Downgrade',
color: 'orange',
icon: installing ? <Loader size={14} /> : <AlertTriangle size={14} />,
variant: 'filled',
};
}
if (isInstalled && !installedVersionIsPrerelease) {
return {
label: installing ? 'Updating…' : 'Update',
color: 'yellow',
icon: installing ? <Loader size={14} /> : <RefreshCw size={14} />,
variant: 'filled',
};
}
return {
label: installing ? 'Installing…' : 'Install',
color: undefined,
icon: installing ? <Loader size={14} /> : <Download size={14} />,
variant: 'filled',
};
};
const btnProps = getButtonProps();
const btnDisabled = (isSelSame ? uninstalling : (!selCompatible || installing || !selectedVersionData?.url));
return (
<Stack gap="md">
{manifest.description && (
<Text size="sm">{manifest.description}</Text>
)}
<Group gap="xs" wrap="wrap">
{manifest.author && (
<Badge size="sm" variant="default">
<span style={{ opacity: 0.5, marginRight: 4 }}>AUTHOR</span>
{manifest.author}
</Badge>
)}
{manifest.license && (
<Badge
size="sm"
variant="default"
component="a"
href={`https://spdx.org/licenses/${encodeURIComponent(manifest.license)}.html`}
target="_blank"
rel="noopener noreferrer"
style={{ cursor: 'pointer' }}
>
<span style={{ opacity: 0.5, marginRight: 4 }}>LICENSE</span>
{manifest.license}
</Badge>
)}
{detail.signature_verified != null && (
detail.signature_verified ? (
<Badge
size="sm"
variant="default"
leftSection={<ShieldCheck size={10} />}
>
Verified Signature
</Badge>
) : (
<Tooltip label="Invalid Signature">
<Badge
size="sm"
variant="filled"
color="red"
leftSection={<ShieldAlert size={10} />}
>
Unverified
</Badge>
</Tooltip>
)
)}
{manifest.repo_url && (
<Tooltip label="Source Repository">
<ActionIcon
variant="subtle"
color="gray"
size="sm"
component="a"
href={manifest.repo_url}
target="_blank"
rel="noopener noreferrer"
>
<GitHubIcon size={16} />
</ActionIcon>
</Tooltip>
)}
{manifest.discord_thread && (() => {
const isDiscordChannel = /^https:\/\/discord\.com\/channels\//.test(manifest.discord_thread);
return (
<Tooltip label="Discord Discussion">
<ActionIcon
variant="subtle"
color="gray"
size="sm"
component="a"
href={isDiscordChannel
? manifest.discord_thread.replace('https://', 'discord://')
: manifest.discord_thread}
{...(!isDiscordChannel && { target: '_blank', rel: 'noopener noreferrer' })}
>
<DiscordIcon size={16} />
</ActionIcon>
</Tooltip>
);
})()}
</Group>
{manifest.deprecated && (
<Alert
icon={<Ban size={16} />}
color="red"
variant="light"
title="Deprecated Plugin"
>
This plugin has been marked as deprecated by its maintainer. It may no longer receive
updates or fixes, and could stop working with future versions of Dispatcharr.
Consider looking for an alternative.
</Alert>
)}
{manifest.versions?.length > 0 && (() => {
const installedMissing = installedVersion &&
!manifest.versions.some((v) => compareVersions(v.version, installedVersion) === 0);
const buildLabel = (v) =>
`v${v.version}${v.prerelease ? ' (prerelease)' : ''}${v.version === manifest.latest?.version ? ' (latest)' : ''}${installedVersion && compareVersions(v.version, installedVersion) === 0 ? ' (installed)' : ''}`;
let versions = [...manifest.versions];
if (installedVersionIsPrerelease) {
const prereleases = versions.filter((v) => v.prerelease);
const stable = versions.filter((v) => !v.prerelease);
versions = [...prereleases, ...stable];
}
const versionItems = versions.map((v) => ({
value: v.version,
label: buildLabel(v),
disabled: false,
}));
if (installedMissing) {
const ghostItem = {
value: installedVersion,
label: `v${installedVersion} (installed)`,
disabled: true,
};
// Insert in sorted position (newest first, matching manifest order convention)
const idx = versionItems.findIndex(
(item) => compareVersions(installedVersion, item.value) > 0
);
if (idx === -1) {
versionItems.push(ghostItem);
} else {
versionItems.splice(idx, 0, ghostItem);
}
}
return (
<>
<Group gap="xs" align="flex-end">
<Select
label="Version"
size="xs"
allowDeselect={false}
value={selectedVersion}
onChange={onVersionChange}
data={versionItems}
style={{ maxWidth: 240 }}
/>
<Group gap="xs" align="center">
{btnProps.tooltip ? (
<Tooltip label={btnProps.tooltip}>
<Button
size="xs"
variant={btnProps.variant}
color={btnProps.color}
leftSection={btnProps.icon}
disabled={btnDisabled}
onClick={handleInstallClick}
>
{btnProps.label}
</Button>
</Tooltip>
) : (
<Button
size="xs"
variant={btnProps.variant}
color={btnProps.color}
leftSection={btnProps.icon}
disabled={btnDisabled}
onClick={handleInstallClick}
>
{btnProps.label}
</Button>
)}
{!selCompatible && selectedVersionData && !isSelSame && (() => {
const parts = [];
if (!selMeetsMin) parts.push(`${selectedVersionData.min_dispatcharr_version} or newer`);
if (!selMeetsMax) parts.push(`${selectedVersionData.max_dispatcharr_version} or older`);
const label = !selMeetsMin
? `Min ${selectedVersionData.min_dispatcharr_version}`
: `Max ${selectedVersionData.max_dispatcharr_version}`;
return (
<Tooltip label={`Incompatible: requires Dispatcharr ${parts.join(' and ')} (you have v${appVersion})`}>
<Group gap={4} align="center" wrap="nowrap">
<AlertTriangle size={14} color="var(--mantine-color-yellow-6)" />
<Text size="xs" c="yellow">{label}</Text>
</Group>
</Tooltip>
);
})()}
</Group>
</Group>
{selectedVersionData && (
<Table fontSize="xs" striped highlightOnHover style={{ tableLayout: 'auto' }}>
<Table.Tbody>
{selectedVersionData.build_timestamp && (
<Table.Tr>
<Table.Td fw={500} style={{ whiteSpace: 'nowrap' }}>Built</Table.Td>
<Table.Td>{new Date(selectedVersionData.build_timestamp).toLocaleString()}</Table.Td>
</Table.Tr>
)}
{selectedVersionData.min_dispatcharr_version && (
<Table.Tr>
<Table.Td fw={500} style={{ whiteSpace: 'nowrap' }}>Min Version</Table.Td>
<Table.Td>{selectedVersionData.min_dispatcharr_version}</Table.Td>
</Table.Tr>
)}
{selectedVersionData.max_dispatcharr_version && (
<Table.Tr>
<Table.Td fw={500} style={{ whiteSpace: 'nowrap' }}>Max Version</Table.Td>
<Table.Td>{selectedVersionData.max_dispatcharr_version}</Table.Td>
</Table.Tr>
)}
{selectedVersionData.commit_sha_short && (
<Table.Tr>
<Table.Td fw={500} style={{ whiteSpace: 'nowrap' }}>Commit</Table.Td>
<Table.Td>
{manifest.registry_url ? (
<Text
size="xs"
component="a"
href={`${manifest.registry_url}/commit/${selectedVersionData.commit_sha}`}
target="_blank"
rel="noopener noreferrer"
c="blue"
>
{selectedVersionData.commit_sha_short}
</Text>
) : (
selectedVersionData.commit_sha_short
)}
</Table.Td>
</Table.Tr>
)}
{selectedVersionData.url && (
<Table.Tr>
<Table.Td fw={500} style={{ whiteSpace: 'nowrap' }}>Download</Table.Td>
<Table.Td>
<Text
size="xs"
component="a"
href={selectedVersionData.url}
target="_blank"
rel="noopener noreferrer"
c="blue"
>
{selectedVersionData.url.split('/').pop()}
</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
)}
</>
);
})()}
</Stack>
);
};
export default PluginDetailPanel;

View file

@ -57,6 +57,8 @@ vi.mock('lucide-react', () => ({
MonitorCog: () => <div data-testid="monitor-cog-icon" />,
Blocks: () => <div data-testid="blocks-icon" />,
Heart: () => <div data-testid="heart-icon" />,
Package: () => <div data-testid="package-icon" />,
Download: () => <div data-testid="download-icon" />,
}));
// Mock UserForm component

View file

@ -0,0 +1,769 @@
import React, { useState } from 'react';
import {
ActionIcon,
Avatar,
Badge,
Box,
Button,
Card,
Group,
Loader,
Modal,
Stack,
Switch,
Text,
Tooltip,
} from '@mantine/core';
import { AlertTriangle, Ban, Check, Download, FlaskConical, Info, RefreshCw, RotateCcw, ShieldAlert, ShieldCheck, Trash2 } from 'lucide-react';
import { useNavigate, useLocation } from 'react-router-dom';
import API from '../../api';
import { usePluginStore } from '../../store/plugins';
import PluginDetailPanel from '../PluginDetailPanel.jsx';
import { compareVersions } from '../pluginUtils.js';
const RepoBadge = ({ isOfficial, repoName, signatureVerified }) => {
if (isOfficial) {
const badge = (
<Badge
size="xs"
variant="filled"
style={{ backgroundColor: signatureVerified === false ? 'var(--mantine-color-red-9)' : '#14917E' }}
leftSection={signatureVerified != null ? (signatureVerified ? <ShieldCheck size={10} /> : <ShieldAlert size={10} />) : undefined}
>
Official Repo
</Badge>
);
return signatureVerified != null ? (
<Tooltip label={signatureVerified ? 'Verified Signature' : 'Invalid Signature'}>{badge}</Tooltip>
) : badge;
}
if (!repoName) return null;
const badge = (
<Badge
size="xs"
variant="filled"
color={signatureVerified === false ? 'red.9' : 'gray'}
leftSection={signatureVerified != null ? (signatureVerified ? <ShieldCheck size={10} /> : <ShieldAlert size={10} />) : undefined}
>
{repoName}
</Badge>
);
return signatureVerified != null ? (
<Tooltip label={signatureVerified ? 'Verified Signature' : 'Invalid Signature'}>{badge}</Tooltip>
) : badge;
};
const StatusBadge = ({ status, deprecated, isPrerelease, isLatestDowngrade, installedSourceRepoName }) => {
if (status === 'installed') {
const baseLabel = isPrerelease ? 'Prerelease' : 'Installed';
if (!deprecated) {
return (
<Badge size="xs" variant="light" color={isPrerelease ? 'violet' : 'green'} leftSection={isPrerelease ? <FlaskConical size={8} /> : <Check size={8} />}>
{baseLabel}
</Badge>
);
}
return (
<Tooltip label={`${isPrerelease ? 'Prerelease installed' : 'Installed'}, but this plugin has been deprecated by its maintainer`}>
<Badge size="xs" variant="light" color={isPrerelease ? 'red' : 'orange'} leftSection={<Ban size={8} />}>
{baseLabel} · Deprecated
</Badge>
</Tooltip>
);
}
if (status === 'update_available') {
const baseLabel = isLatestDowngrade ? 'Newer Installed' : 'Update Available';
if (!deprecated) {
return (
<Badge size="xs" variant="light" color={isLatestDowngrade ? 'orange' : 'yellow'} leftSection={isLatestDowngrade ? <AlertTriangle size={8} /> : <RefreshCw size={8} />}>
{baseLabel}
</Badge>
);
}
return (
<Tooltip label="Update available, but this plugin has been deprecated by its maintainer">
<Badge size="xs" variant="light" color="red" leftSection={<Ban size={8} />}>
{baseLabel} · Deprecated
</Badge>
</Tooltip>
);
}
if (status === 'unmanaged' || status === 'different_repo') {
const tooltip = status === 'unmanaged'
? (deprecated ? 'Installed manually (deprecated) - installing from this repo will take over management' : 'Installed manually - installing from this repo will take over management')
: `Managed by ${installedSourceRepoName || 'another repo'}${deprecated ? ' (deprecated)' : ''}`;
return (
<Tooltip label={tooltip}>
<Badge size="xs" variant="light" color={deprecated ? 'red' : 'orange'} leftSection={deprecated ? <Ban size={8} /> : <Check size={8} />}>
{deprecated ? 'Installed · Deprecated' : 'Installed'}
</Badge>
</Tooltip>
);
}
if (deprecated) {
return (
<Tooltip label="This plugin has been marked as deprecated by its maintainer">
<Badge size="xs" variant="light" color="red" leftSection={<Ban size={8} />}>
Deprecated
</Badge>
</Tooltip>
);
}
return null;
};
const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDetail = false, onDetailClose, onInstalled, onUninstalled, onBeforeInstall }) => {
const meetsMinVersion = !plugin.min_dispatcharr_version || compareVersions(appVersion, plugin.min_dispatcharr_version) >= 0;
const meetsMaxVersion = !plugin.max_dispatcharr_version || compareVersions(appVersion, plugin.max_dispatcharr_version) <= 0;
const meetsVersion = meetsMinVersion && meetsMaxVersion;
const [detailOpen, setDetailOpen] = useState(false);
const [detail, setDetail] = useState(null);
const [detailLoading, setDetailLoading] = useState(false);
const [selectedVersion, setSelectedVersion] = useState(null);
const [installing, setInstalling] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const [restartPromptOpen, setRestartPromptOpen] = useState(false);
const [installAction, setInstallAction] = useState(null); // 'installed' | 'updated' | 'downgraded'
const [pendingInstall, setPendingInstall] = useState(null);
const [installedKey, setInstalledKey] = useState(null);
const [enableNow, setEnableNow] = useState(false);
const [enabling, setEnabling] = useState(false);
const [pluginIsDisabled, setPluginIsDisabled] = useState(false);
const [uninstallConfirmOpen, setUninstallConfirmOpen] = useState(false);
const [uninstalling, setUninstalling] = useState(false);
const [deprecationWarnOpen, setDeprecationWarnOpen] = useState(false);
const [pendingDeprecatedInstall, setPendingDeprecatedInstall] = useState(null);
const installPlugin = usePluginStore((s) => s.installPlugin);
const navigate = useNavigate();
const { pathname } = useLocation();
const onMyPlugins = pathname === '/plugins';
const isLatestDowngrade = plugin.install_status === 'update_available' &&
plugin.latest_version && plugin.installed_version &&
compareVersions(plugin.latest_version, plugin.installed_version) < 0;
const doInstall = (params) => {
if (plugin.deprecated) {
setPendingDeprecatedInstall(params);
setDeprecationWarnOpen(true);
return;
}
setPendingInstall(params);
setConfirmOpen(true);
};
const confirmDeprecatedInstall = () => {
setDeprecationWarnOpen(false);
if (pendingDeprecatedInstall) {
setPendingInstall(pendingDeprecatedInstall);
setPendingDeprecatedInstall(null);
setConfirmOpen(true);
}
};
const confirmAndInstall = () => {
setConfirmOpen(false);
if (pendingInstall) executeInstall(pendingInstall);
};
const executeInstall = async (params) => {
const wasInstalled = plugin.installed;
const wasDowngrade = plugin.installed_version && params.version &&
compareVersions(params.version, plugin.installed_version) < 0;
onBeforeInstall?.(plugin.slug);
setInstalling(true);
const result = await installPlugin(params);
setInstalling(false);
setPendingInstall(null);
if (result?.success) {
setInstallAction(wasDowngrade ? 'downgraded' : wasInstalled ? 'updated' : 'installed');
setInstalledKey(result.plugin?.key || params.slug);
setPluginIsDisabled(result.plugin?.enabled === false);
setEnableNow(false);
setRestartPromptOpen(true);
onInstalled?.(plugin.slug);
}
};
const [uninstallDoneOpen, setUninstallDoneOpen] = useState(false);
const handleDismissRestart = async (andNavigate = false) => {
if (enableNow && installedKey) {
setEnabling(true);
try {
await API.setPluginEnabled(installedKey, true);
} finally {
setEnabling(false);
}
}
setRestartPromptOpen(false);
if (andNavigate) navigate('/plugins');
};
const handleUninstall = async () => {
const key = plugin.key || installedKey;
if (!key) return;
setUninstalling(true);
try {
const resp = await API.deletePlugin(key);
if (resp?.success) {
onUninstalled?.(plugin.slug);
usePluginStore.getState().invalidatePlugins();
usePluginStore.getState().fetchAvailablePlugins();
setUninstallConfirmOpen(false);
setUninstallDoneOpen(true);
}
} finally {
setUninstalling(false);
}
};
const handleMoreInfo = async () => {
setDetailOpen(true);
if (detailLoading) return;
if (!plugin.manifest_url) {
// No per-plugin manifest synthesize from top-level repo entry (latest only)
setDetail({
manifest: {
description: plugin.description,
author: plugin.author,
license: plugin.license,
versions: plugin.latest_version ? [{
version: plugin.latest_version,
url: plugin.latest_url,
checksum_sha256: plugin.latest_sha256,
min_dispatcharr_version: plugin.min_dispatcharr_version,
max_dispatcharr_version: plugin.max_dispatcharr_version,
build_timestamp: plugin.last_updated,
}] : [],
latest: plugin.latest_version ? { version: plugin.latest_version } : null,
},
signature_verified: plugin.signature_verified ?? null,
});
if (plugin.latest_version) setSelectedVersion(plugin.latest_version);
return;
}
setDetailLoading(true);
const result = await API.getPluginDetailManifest(plugin.repo_id, plugin.manifest_url);
if (result) {
setDetail(result);
if (result.manifest?.versions?.length) {
setSelectedVersion(result.manifest.versions[0].version);
}
}
setDetailLoading(false);
};
React.useEffect(() => {
if (autoOpenDetail) handleMoreInfo();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const latestInstallParams = {
repo_id: plugin.repo_id,
slug: plugin.slug,
version: plugin.latest_version,
download_url: plugin.latest_url,
sha256: plugin.latest_sha256,
min_dispatcharr_version: plugin.min_dispatcharr_version,
max_dispatcharr_version: plugin.max_dispatcharr_version,
};
return (
<Card
shadow="sm"
radius="md"
withBorder
style={{
display: 'flex',
flexDirection: 'column',
minHeight: 220,
backgroundColor: '#27272A',
...(multiRepo && plugin.is_official_repo ? { borderColor: '#0e6459' } : {}),
}}
>
<Group justify="space-between" mb="xs" align="flex-start" wrap="nowrap">
<Group gap="sm" align="flex-start" wrap="nowrap" style={{ minWidth: 0, flex: 1 }}>
<Avatar
src={plugin.icon_url}
radius="sm"
size={48}
alt={`${plugin.name} logo`}
>
{plugin.name?.[0]?.toUpperCase()}
</Avatar>
<Box style={{ minWidth: 0, flex: 1 }}>
<Text fw={600} lineClamp={1}>
{plugin.name}
</Text>
<Group gap={6} align="center" wrap="nowrap">
{plugin.author && (
<Text size="xs" c="dimmed" truncate style={{ minWidth: 0, maxWidth: '100%' }}>
{plugin.author}
</Text>
)}
<StatusBadge
status={plugin.install_status}
deprecated={plugin.deprecated}
isPrerelease={plugin.installed_version_is_prerelease}
isLatestDowngrade={isLatestDowngrade}
installedSourceRepoName={plugin.installed_source_repo_name}
/>
</Group>
</Box>
</Group>
<Group gap={4} wrap="nowrap" style={{ flexShrink: 0 }}>
<RepoBadge
isOfficial={plugin.is_official_repo}
repoName={plugin.repo_name}
signatureVerified={plugin.signature_verified}
/>
</Group>
</Group>
<Box style={{ flex: 1, minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<div style={{ overflow: 'hidden' }}>
<Text size="sm" c="dimmed" lineClamp={3} mb={0}>
{plugin.description}
</Text>
</div>
<Stack gap={2} mt="auto" pt={4} style={{ flexShrink: 0 }}>
<Group gap="xs" wrap="wrap">
{plugin.latest_version && (
<Badge size="xs" variant="default">
<span style={{ opacity: 0.5, marginRight: 4 }}>LATEST</span>
v{plugin.latest_version}
</Badge>
)}
{plugin.license && (
<Badge
size="xs"
variant="default"
component="a"
href={`https://spdx.org/licenses/${encodeURIComponent(plugin.license)}.html`}
target="_blank"
rel="noopener noreferrer"
style={{ cursor: 'pointer' }}
>
<span style={{ opacity: 0.5, marginRight: 4 }}>LICENSE</span>
{plugin.license}
</Badge>
)}
{plugin.min_dispatcharr_version && (
<Badge size="xs" variant="default">
<span style={{ opacity: 0.5, marginRight: 4 }}>MIN</span>
{plugin.min_dispatcharr_version}
</Badge>
)}
{plugin.max_dispatcharr_version && (
<Badge size="xs" variant="default">
<span style={{ opacity: 0.5, marginRight: 4 }}>MAX</span>
{plugin.max_dispatcharr_version}
</Badge>
)}
{plugin.last_updated && (
<Badge size="xs" variant="default">
<span style={{ opacity: 0.5, marginRight: 4 }}>UPDATED</span>
{new Date(plugin.last_updated).toLocaleDateString()}
</Badge>
)}
</Group>
</Stack>
</Box>
<Group justify="space-between" mt="sm" align="center" wrap="nowrap">
{!meetsVersion && (() => {
const parts = [];
if (!meetsMinVersion) parts.push(`${plugin.min_dispatcharr_version} or newer`);
if (!meetsMaxVersion) parts.push(`${plugin.max_dispatcharr_version} or older`);
const label = !meetsMinVersion
? `Min ${plugin.min_dispatcharr_version}`
: `Max ${plugin.max_dispatcharr_version}`;
return (
<Tooltip label={`Incompatible: requires Dispatcharr ${parts.join(' and ')} (you have v${appVersion})`}>
<Group gap={4} align="center" wrap="nowrap">
<AlertTriangle size={14} color="var(--mantine-color-yellow-6)" />
<Text size="xs" c="yellow">{label}</Text>
</Group>
</Tooltip>
);
})()}
{meetsVersion && <span />}
<Group gap="xs" wrap="nowrap">
<Button
size="xs"
variant="default"
leftSection={<Info size={14} />}
onClick={handleMoreInfo}
>
More Info
</Button>
{(plugin.install_status === 'unmanaged') && plugin.latest_version && plugin.latest_url && (
<Tooltip label="Installed manually - installing from this repo will take over management">
<Button
size="xs"
variant="filled"
color="orange"
leftSection={installing ? <Loader size={14} /> : <Download size={14} />}
disabled={!meetsVersion || installing}
onClick={() => doInstall(latestInstallParams)}
>
{installing ? 'Installing...' : 'Overwrite'}
</Button>
</Tooltip>
)}
{(plugin.install_status === 'different_repo') && plugin.latest_url && (
<Tooltip label={`Managed by ${plugin.installed_source_repo_name || 'another repo'} - installing will transfer management to this repo`}>
<Button
size="xs"
variant="filled"
color="orange"
leftSection={installing ? <Loader size={14} /> : <Download size={14} />}
disabled={!meetsVersion || installing}
onClick={() => doInstall(latestInstallParams)}
>
{installing ? 'Installing...' : 'Overwrite'}
</Button>
</Tooltip>
)}
{(plugin.install_status === 'installed') && (
<Button
size="xs"
variant="light"
color="red"
leftSection={<Trash2 size={14} />}
onClick={() => setUninstallConfirmOpen(true)}
>
Uninstall
</Button>
)}
{(plugin.install_status === 'update_available') && (
<Button
size="xs"
variant="filled"
color={isLatestDowngrade ? 'orange' : 'yellow'}
leftSection={installing ? <Loader size={14} /> : isLatestDowngrade ? <AlertTriangle size={14} /> : <RefreshCw size={14} />}
disabled={!meetsVersion || installing}
onClick={() => doInstall(latestInstallParams)}
>
{installing
? (isLatestDowngrade ? 'Downgrading...' : 'Updating...')
: (isLatestDowngrade ? 'Downgrade' : 'Update')}
</Button>
)}
{(!plugin.install_status || plugin.install_status === 'not_installed') && plugin.latest_url && (
<Button
size="xs"
variant="filled"
leftSection={installing ? <Loader size={14} /> : <Download size={14} />}
disabled={!meetsVersion || installing}
onClick={() => doInstall(latestInstallParams)}
>
{installing ? 'Installing...' : 'Install'}
</Button>
)}
</Group>
</Group>
{/* Detail Modal */}
<Modal
opened={detailOpen}
onClose={() => { setDetailOpen(false); onDetailClose?.(); }}
title={
<Group gap="xs" align="center">
<Avatar
src={plugin.icon_url}
radius="sm"
size={28}
alt={`${plugin.name} logo`}
>
{plugin.name?.[0]?.toUpperCase()}
</Avatar>
<Text fw={600}>{plugin.name}</Text>
<RepoBadge
isOfficial={plugin.is_official_repo}
repoName={plugin.repo_name}
signatureVerified={detail?.signature_verified ?? plugin.signature_verified}
/>
</Group>
}
size="lg"
>
<PluginDetailPanel
detail={detail}
detailLoading={detailLoading}
selectedVersion={selectedVersion}
onVersionChange={setSelectedVersion}
installedVersion={plugin.installed_version}
installedVersionIsPrerelease={!!plugin.installed_version_is_prerelease}
appVersion={appVersion}
installing={installing}
uninstalling={uninstalling}
onInstall={doInstall}
onUninstall={() => setUninstallConfirmOpen(true)}
installStatus={plugin.install_status}
installedSourceRepoName={plugin.installed_source_repo_name}
repoId={plugin.repo_id}
slug={plugin.slug}
/>
</Modal>
{/* Deprecation warning modal */}
<Modal
opened={deprecationWarnOpen}
onClose={() => { setDeprecationWarnOpen(false); setPendingDeprecatedInstall(null); }}
zIndex={300}
title={
<Group gap="xs" align="center">
<Ban size={18} color="var(--mantine-color-red-6)" />
<Text fw={600}>Deprecated Plugin</Text>
</Group>
}
size="sm"
>
<Stack gap="md">
<Text size="sm">
<b>{plugin.name}</b> has been marked as <b>deprecated</b> by its maintainer.
</Text>
<Text size="sm" c="dimmed">
Deprecated plugins may no longer receive updates or fixes, and could stop working with future
versions of Dispatcharr. It is recommended to look for an alternative.
</Text>
<Text size="sm" fw={500}>Do you still want to proceed?</Text>
<Group justify="flex-end" gap="xs">
<Button
size="xs"
variant="default"
onClick={() => { setDeprecationWarnOpen(false); setPendingDeprecatedInstall(null); }}
>
Cancel
</Button>
<Button
size="xs"
color="red"
leftSection={<Ban size={14} />}
onClick={confirmDeprecatedInstall}
>
Install Anyway
</Button>
</Group>
</Stack>
</Modal>
{/* Unified install confirmation modal */}
{(() => {
const isDowngrade = pendingInstall && plugin.installed_version &&
compareVersions(pendingInstall.version, plugin.installed_version) < 0;
const isUpdate = pendingInstall && plugin.installed_version &&
!isDowngrade &&
compareVersions(pendingInstall.version, plugin.installed_version) > 0;
const isBadSig = plugin.signature_verified === false;
const actionLabel = isDowngrade ? 'Downgrade' : isUpdate ? 'Update' : 'Install';
const btnColor = (isDowngrade && isBadSig) ? 'red' : isDowngrade ? 'orange' : isBadSig ? 'red' : undefined;
return (
<Modal
opened={confirmOpen}
onClose={() => { setConfirmOpen(false); setPendingInstall(null); }}
zIndex={300}
title={
<Group gap="xs" align="center">
{isBadSig
? <ShieldAlert size={18} color="var(--mantine-color-red-6)" />
: isDowngrade
? <AlertTriangle size={18} color="var(--mantine-color-orange-6)" />
: <Download size={18} />}
<Text fw={600}>Confirm {actionLabel}</Text>
</Group>
}
size="sm"
>
<Stack gap="md">
<Text size="sm">
You are about to {actionLabel.toLowerCase()} <b>{plugin.name}</b>{' '}
{isUpdate || isDowngrade
? <>from <b>v{plugin.installed_version}</b> to <b>v{pendingInstall?.version}</b></>
: <><b>v{pendingInstall?.version}</b></>}
{plugin.repo_name ? <> from <b>{plugin.repo_name}</b></> : ''}.
</Text>
<Text size="sm" c="dimmed">
Plugins run server-side code with full access to your Dispatcharr instance and its
data. Only install plugins from developers you trust. Malicious plugins could read
or modify data, call internal APIs, or perform unwanted actions.
</Text>
{isDowngrade && (
<Text size="sm" c="orange">
<b>Warning:</b> Downgrading may cause issues with saved settings or data.
</Text>
)}
{isBadSig && (
<Text size="sm" c="red">
<b>Warning:</b> This repository has an invalid or unverified signature.
Installing plugins from unverified sources may be risky.
</Text>
)}
{plugin.install_status === 'unmanaged' && (
<Text size="sm" c="orange">
<b>Note:</b> This plugin was installed manually. Installing from this repo
will bring it under repo management and enable future update checks.
</Text>
)}
{plugin.install_status === 'different_repo' && (
<Text size="sm" c="orange">
<b>Note:</b> This plugin is currently managed
by <b>{plugin.installed_source_repo_name || 'another repo'}</b>.
Installing will transfer management to this repo.
</Text>
)}
<Text size="sm" fw={500}>Are you sure you want to proceed?</Text>
<Group justify="flex-end" gap="xs">
<Button
size="xs"
variant="default"
onClick={() => { setConfirmOpen(false); setPendingInstall(null); }}
>
Cancel
</Button>
<Button
size="xs"
color={btnColor}
onClick={confirmAndInstall}
>
{actionLabel}
</Button>
</Group>
</Stack>
</Modal>
);
})()}
{/* Uninstall confirmation modal */}
<Modal
opened={uninstallConfirmOpen}
onClose={() => setUninstallConfirmOpen(false)}
zIndex={300}
title={
<Group gap="xs" align="center">
<Trash2 size={18} color="var(--mantine-color-red-6)" />
<Text fw={600}>Uninstall Plugin</Text>
</Group>
}
size="sm"
>
<Stack gap="md">
<Text size="sm">
Are you sure you want to uninstall <b>{plugin.name}</b>? This will
remove the plugin files and all associated settings.
</Text>
<Group justify="flex-end" gap="xs">
<Button
size="xs"
variant="default"
onClick={() => setUninstallConfirmOpen(false)}
>
Cancel
</Button>
<Button
size="xs"
color="red"
loading={uninstalling}
onClick={handleUninstall}
>
Uninstall
</Button>
</Group>
</Stack>
</Modal>
{/* Post-uninstall notice */}
<Modal
opened={uninstallDoneOpen}
onClose={() => setUninstallDoneOpen(false)}
zIndex={300}
title={
<Group gap="xs" align="center">
<Trash2 size={18} color="var(--mantine-color-green-6)" />
<Text fw={600}>Plugin Uninstalled</Text>
</Group>
}
size="sm"
>
<Stack gap="md">
<Text size="sm">
<b>{plugin.name}</b> has been uninstalled successfully.
</Text>
<Text size="sm">
A restart of Dispatcharr may be required to fully unload the plugin.
</Text>
<Group justify="flex-end">
<Button size="xs" variant="default" onClick={() => setUninstallDoneOpen(false)}>
Done
</Button>
</Group>
</Stack>
</Modal>
{/* Post-install restart prompt */}
<Modal
opened={restartPromptOpen}
onClose={() => setRestartPromptOpen(false)}
zIndex={300}
title={
<Group gap="xs" align="center">
<RotateCcw size={18} color="var(--mantine-color-blue-6)" />
<Text fw={600}>
Plugin {installAction === 'installed' ? 'Installed' : installAction === 'downgraded' ? 'Downgraded' : 'Updated'}
</Text>
</Group>
}
size="sm"
>
<Stack gap="md">
<Text size="sm">
<b>{plugin.name}</b> has been {installAction || 'installed'} successfully.
</Text>
<Text size="sm">
A restart of Dispatcharr may be required for the plugin to be fully loaded.
</Text>
{pluginIsDisabled && (
<>
<Text size="xs" c="dimmed">
This plugin is currently disabled. You can enable it now or at any time from My Plugins.
</Text>
<Group justify="space-between" align="center">
<Text size="sm">Enable plugin</Text>
<Switch
size="sm"
checked={enableNow}
onChange={(e) => setEnableNow(e.currentTarget.checked)}
/>
</Group>
</>
)}
<Group justify="flex-end" gap="xs">
<Button
size="xs"
variant="default"
loading={enabling}
onClick={() => handleDismissRestart(false)}
>
Done
</Button>
{!onMyPlugins && (
<Button
size="xs"
loading={enabling}
onClick={() => handleDismissRestart(true)}
>
Go to My Plugins
</Button>
)}
</Group>
</Stack>
</Modal>
</Card>
);
};
export default AvailablePluginCard;

View file

@ -2,23 +2,29 @@ import React, { useState } from 'react';
import { showNotification } from '../../utils/notificationUtils.js';
import { Field } from '../Field.jsx';
import {
ActionIcon,
Anchor,
Box,
Avatar,
Badge,
Box,
Button,
Card,
Divider,
Group,
Loader,
Modal,
Stack,
Switch,
Tabs,
Text,
UnstyledButton,
Badge,
Tooltip,
} from '@mantine/core';
import { ChevronDown, ChevronRight, Trash2 } from 'lucide-react';
import { Ban, Check, FlaskConical, Info, RefreshCw, Settings, Trash2, Zap } from 'lucide-react';
import { getConfirmationDetails } from '../../utils/cards/PluginCardUtils.js';
import { SUBSCRIPTION_EVENTS } from '../../constants.js';
import useSettingsStore from '../../store/settings.jsx';
import { usePluginStore } from '../../store/plugins.jsx';
import API from '../../api';
import PluginDetailPanel from '../PluginDetailPanel.jsx';
import { compareVersions } from '../pluginUtils.js';
const PluginFieldList = ({ plugin, settings, updateField }) => {
return plugin.fields.map((f) => (
@ -42,19 +48,19 @@ const PluginActionList = ({
return (
<Group key={action.id} justify="space-between">
<div>
<Text>{action.label}</Text>
<Text size="sm">{action.label}</Text>
{action.description && (
<Text size="sm" c="dimmed">
<Text size="xs" c="dimmed">
{action.description}
</Text>
)}
{events.length > 0 && (
<>
<Text size="xs" style={{ paddingTop: 10 }}>
<Text size="xs" style={{ paddingTop: 6 }}>
Event Triggers
</Text>
{events.map((event) => (
<Badge key={`${action.id}:${event}`} size="sm" variant="light" color="green">
<Badge key={`${action.id}:${event}`} size="xs" variant="light" color="green">
{SUBSCRIPTION_EVENTS[event] || event}
</Badge>
))}
@ -82,17 +88,17 @@ const PluginActionStatus = ({ running, lastResult }) => {
return (
<>
{running && (
<Text size="sm" c="dimmed">
<Text size="xs" c="dimmed">
Running action please wait
</Text>
)}
{!running && lastResult?.file && (
<Text size="sm" c="dimmed">
<Text size="xs" c="dimmed">
Output: {lastResult.file}
</Text>
)}
{!running && lastResult?.error && (
<Text size="sm" c="red">
<Text size="xs" c="red">
Error: {String(lastResult.error)}
</Text>
)}
@ -109,27 +115,92 @@ const PluginCard = ({
onRequestDelete,
onRequestConfirm,
}) => {
const appVersion = useSettingsStore((s) => s.version?.version || '');
const [settings, setSettings] = useState(plugin.settings || {});
const [saving, setSaving] = useState(false);
const [runningActionId, setRunningActionId] = useState(null);
const [enabled, setEnabled] = useState(!!plugin.enabled);
const [lastResult, setLastResult] = useState(null);
const [expanded, setExpanded] = useState(!!plugin.enabled);
const [modalOpen, setModalOpen] = useState(false);
const [modalTab, setModalTab] = useState('settings');
const [detail, setDetail] = useState(null);
const [detailLoading, setDetailLoading] = useState(false);
const [selectedVersion, setSelectedVersion] = useState(null);
const [installing, setInstalling] = useState(false);
const [uninstalling] = useState(false);
// Keep local enabled state in sync with props (e.g., after import + enable)
const installPlugin = usePluginStore((s) => s.installPlugin);
// Keep local enabled state in sync with props
React.useEffect(() => {
setEnabled(!!plugin.enabled);
}, [plugin.enabled]);
React.useEffect(() => {
if (!plugin.enabled) {
setExpanded(false);
}
}, [plugin.enabled]);
// Sync settings if plugin changes identity
React.useEffect(() => {
setSettings(plugin.settings || {});
}, [plugin.key, plugin.settings]);
const hasActions = !plugin.missing && enabled && plugin.actions?.length > 0;
const isManaged = !!(plugin.slug && plugin.source_repo);
const fetchDetail = async () => {
if (detailLoading || !isManaged) return;
// Find the available plugin entry for manifest_url
let avail = usePluginStore.getState().availablePlugins.find(
(ap) => ap.slug === plugin.slug && ap.repo_id === plugin.source_repo
);
if (!avail) {
setDetailLoading(true);
try {
await usePluginStore.getState().fetchAvailablePlugins();
avail = usePluginStore.getState().availablePlugins.find(
(ap) => ap.slug === plugin.slug && ap.repo_id === plugin.source_repo
);
} catch { /* ignore */ }
}
if (!avail) { setDetailLoading(false); return; }
if (!avail.manifest_url) {
// Synthesize from top-level entry
setDetail({
manifest: {
description: avail.description,
author: avail.author,
license: avail.license,
repo_url: avail.repo_url,
discord_thread: avail.discord_thread,
registry_url: avail.registry_url,
versions: avail.latest_version ? [{
version: avail.latest_version,
url: avail.latest_url,
checksum_sha256: avail.latest_sha256,
min_dispatcharr_version: avail.min_dispatcharr_version,
max_dispatcharr_version: avail.max_dispatcharr_version,
build_timestamp: avail.last_updated,
}] : [],
latest: avail.latest_version ? { version: avail.latest_version } : null,
},
signature_verified: avail.signature_verified ?? null,
_avail: avail,
});
if (avail.latest_version) setSelectedVersion(avail.latest_version);
setDetailLoading(false);
return;
}
setDetailLoading(true);
try {
const result = await API.getPluginDetailManifest(avail.repo_id, avail.manifest_url);
if (result) {
setDetail({ ...result, _avail: avail });
if (result.manifest?.versions?.length) {
setSelectedVersion(result.manifest.versions[0].version);
}
}
} finally {
setDetailLoading(false);
}
};
const updateField = (id, val) => {
setSettings((prev) => ({ ...prev, [id]: val }));
};
@ -170,7 +241,6 @@ const PluginCard = ({
if (next && !plugin.ever_enabled && onRequireTrust) {
const ok = await onRequireTrust(plugin);
if (!ok) {
// Revert
setEnabled(false);
return;
}
@ -183,7 +253,7 @@ const PluginCard = ({
setEnabled(previous);
return;
}
} catch (e) {
} catch {
setEnabled(previous);
}
};
@ -191,17 +261,12 @@ const PluginCard = ({
const handlePluginRun = async (a) => {
try {
// Determine if confirmation is required from action metadata or fallback field
const { requireConfirm, confirmTitle, confirmMessage } =
getConfirmationDetails(a, plugin, settings);
if (requireConfirm) {
const confirmed = await onRequestConfirm(confirmTitle, confirmMessage);
if (!confirmed) {
// User canceled, abort the action
return;
}
if (!confirmed) return;
}
setRunningActionId(a.id);
@ -210,7 +275,7 @@ const PluginCard = ({
// Save settings before running to ensure backend uses latest values
try {
await onSaveSettings(plugin.key, settings);
} catch (e) {
} catch {
/* ignore, run anyway */
}
const resp = await onRunAction(plugin.key, a.id);
@ -236,149 +301,320 @@ const PluginCard = ({
}
};
const toggleExpanded = () => {
setExpanded((prev) => !prev);
const hasFields = !missing && enabled && plugin.fields?.length > 0;
const openModal = (tab) => {
setModalTab(tab);
setModalOpen(true);
if (tab === 'details') fetchDetail();
};
const handleDetailInstall = async (params) => {
const selVer = params.version;
const isDown = plugin.version && compareVersions(selVer, plugin.version) < 0;
const action = isDown ? 'downgrade' : 'update';
const confirmed = await onRequestConfirm(
`${isDown ? 'Downgrade' : 'Update'} ${plugin.name}?`,
`${isDown ? 'Downgrade' : 'Update'} from v${plugin.version} to v${selVer}?`
);
if (!confirmed) return;
setInstalling(true);
try {
const result = await installPlugin(params);
if (result?.success) {
showNotification({
title: plugin.name,
message: `Successfully ${action === 'downgrade' ? 'downgraded' : 'updated'} to v${selVer}`,
color: 'green',
});
usePluginStore.getState().invalidatePlugins();
}
} finally {
setInstalling(false);
}
};
const handleDetailUninstall = () => {
onRequestDelete && onRequestDelete(plugin);
};
return (
<Card
shadow="sm"
radius="md"
withBorder
style={{ opacity: !missing && enabled ? 1 : 0.6 }}
>
<Group justify="space-between" mb="xs" align="flex-start" wrap="nowrap">
<Group
gap="sm"
align="flex-start"
wrap="nowrap"
style={{ minWidth: 0, flex: 1 }}
>
<ActionIcon
variant="subtle"
size="sm"
onClick={toggleExpanded}
title={expanded ? 'Collapse settings' : 'Expand settings'}
>
{expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</ActionIcon>
{plugin.logo_url && (
<div style={{ position: 'relative' }}>
<Card
shadow="sm"
radius="md"
withBorder
style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
minHeight: 220,
backgroundColor: '#27272A',
opacity: !missing && enabled ? 1 : 0.6,
}}
>
{/* Header: avatar, name/author, badges, toggle */}
<Group justify="space-between" mb="xs" align="flex-start" wrap="nowrap">
<Group gap="sm" align="flex-start" wrap="nowrap" style={{ minWidth: 0, flex: 1 }}>
<Avatar
src={plugin.logo_url}
radius="sm"
size={44}
size={48}
alt={`${plugin.name} logo`}
/>
)}
<UnstyledButton
onClick={toggleExpanded}
style={{ minWidth: 0, flex: 1, textAlign: 'left' }}
>
onClick={isManaged ? () => openModal('details') : undefined}
style={isManaged ? { cursor: 'pointer' } : undefined}
>
{plugin.name?.[0]?.toUpperCase()}
</Avatar>
<Box style={{ minWidth: 0, flex: 1 }}>
<Text fw={600}>{plugin.name}</Text>
<Text size="sm" c="dimmed">
{plugin.description}
<Text
fw={600}
lineClamp={1}
onClick={isManaged ? () => openModal('details') : undefined}
style={isManaged ? { cursor: 'pointer' } : undefined}
>
{plugin.name}
</Text>
{(plugin.author || plugin.help_url) && (
<Group gap="xs" mt={2}>
{plugin.author && (
<Text size="xs" c="dimmed">
By {plugin.author}
</Text>
)}
{plugin.help_url && (
<Anchor
href={plugin.help_url}
target="_blank"
rel="noreferrer"
size="xs"
onClick={(e) => e.stopPropagation()}
>
Docs
</Anchor>
)}
</Group>
)}
<Group gap={6} align="center" wrap="nowrap">
{plugin.author && (
<Text
size="xs"
c="dimmed"
truncate
onClick={isManaged ? () => openModal('details') : undefined}
style={{ minWidth: 0, maxWidth: '100%', ...(isManaged ? { cursor: 'pointer' } : {}) }}
>
{plugin.author}
</Text>
)}
{plugin.help_url && (
<Anchor
href={plugin.help_url}
target="_blank"
rel="noreferrer"
size="xs"
>
Docs
</Anchor>
)}
</Group>
</Box>
</UnstyledButton>
</Group>
<Group gap={6} wrap="nowrap" align="center" style={{ flexShrink: 0 }}>
{plugin.is_managed && plugin.installed_version_is_prerelease ? (
<Tooltip label={plugin.deprecated ? 'Prerelease installed (deprecated), click for details' : 'Prerelease installed, click for details'}>
<Badge
size="xs"
variant="light"
color={plugin.deprecated ? 'red' : 'violet'}
leftSection={detailLoading ? <Loader size={8} /> : plugin.deprecated ? <Ban size={8} /> : <FlaskConical size={8} />}
style={{ cursor: 'pointer' }}
onClick={() => openModal('details')}
>
{plugin.deprecated ? 'Prerelease · Deprecated' : 'Prerelease'}
</Badge>
</Tooltip>
) : plugin.update_available ? (
<Tooltip label={plugin.deprecated ? `Update available: v${plugin.latest_version} (deprecated)` : `Update available: v${plugin.latest_version}`}>
<Badge
size="xs"
variant="light"
color={plugin.deprecated ? 'red' : 'yellow'}
leftSection={detailLoading ? <Loader size={8} /> : plugin.deprecated ? <Ban size={8} /> : <RefreshCw size={8} />}
style={{ cursor: 'pointer' }}
onClick={() => openModal('details')}
>
{plugin.deprecated ? 'Update · Deprecated' : 'Update'}
</Badge>
</Tooltip>
) : plugin.is_managed ? (
<Tooltip label={plugin.deprecated ? 'Installed (deprecated), click for details' : 'View plugin details'}>
<Badge
size="xs"
variant="light"
color={plugin.deprecated ? 'orange' : 'green'}
leftSection={detailLoading ? <Loader size={8} /> : plugin.deprecated ? <Ban size={8} /> : <Check size={8} />}
style={{ cursor: 'pointer' }}
onClick={() => openModal('details')}
>
{plugin.deprecated ? 'Deprecated' : 'Up to Date'}
</Badge>
</Tooltip>
) : (
<Badge size="xs" variant="light" color="gray">
Unmanaged
</Badge>
)}
<Switch
checked={!missing && enabled}
onChange={handleEnableChange()}
size="xs"
onLabel="On"
offLabel="Off"
disabled={missing}
/>
</Group>
</Group>
<Group gap="xs" align="center" wrap="nowrap" style={{ flexShrink: 0 }}>
<ActionIcon
variant="subtle"
{/* Description */}
<div style={{ overflow: 'hidden' }}>
<Text size="sm" c="dimmed" lineClamp={3} mb={0}>
{plugin.description}
</Text>
</div>
{/* Status warnings */}
{(missing || plugin.legacy) && (
<Text size="xs" c={missing ? 'red' : 'yellow'} mt="xs">
{missing
? 'Missing plugin files. Re-import or delete this entry.'
: 'Please update or ask the developer to add plugin.json.'}
</Text>
)}
{/* Bottom metadata pills */}
<Stack gap={2} mt="auto" pt={4} style={{ flexShrink: 0 }}>
<Group gap="xs" wrap="wrap">
<Badge size="xs" variant="default">
<span style={{ opacity: 0.5, marginRight: 4 }}>VERSION</span>
v{plugin.version || '1.0.0'}
</Badge>
{plugin.is_managed && plugin.source_repo_name && (
<Badge size="xs" variant="default">
<span style={{ opacity: 0.5, marginRight: 4 }}>REPO</span>
{plugin.source_repo_name}
</Badge>
)}
</Group>
</Stack>
{/* Bottom button row */}
<Group justify="flex-end" mt="sm" gap="xs">
{hasFields && (
<Button
size="xs"
variant="default"
leftSection={<Settings size={14} />}
onClick={() => openModal('settings')}
>
Settings
</Button>
)}
{hasActions && (
<Button
size="xs"
variant="light"
color="blue"
leftSection={<Zap size={14} />}
onClick={() => openModal('actions')}
>
Actions
</Button>
)}
<Button
size="xs"
variant="light"
color="red"
title="Delete plugin"
leftSection={<Trash2 size={14} />}
onClick={() => onRequestDelete && onRequestDelete(plugin)}
>
<Trash2 size={16} />
</ActionIcon>
<Text size="xs" c="dimmed">
v{plugin.version || '1.0.0'}
</Text>
<Switch
checked={!missing && enabled}
onChange={handleEnableChange()}
size="xs"
onLabel="On"
offLabel="Off"
disabled={missing}
/>
Uninstall
</Button>
</Group>
</Group>
</Card>
{(missing || plugin.legacy) && (
<Text size="sm" c={missing ? 'red' : 'yellow'}>
{missing
? 'Missing plugin files. Re-import or delete this entry.'
: 'Please update or ask the developer to add plugin.json.'}
</Text>
)}
{/* Settings & Actions Modal */}
<Modal
opened={modalOpen}
onClose={() => setModalOpen(false)}
title={
<Group gap="xs" align="center">
<Avatar src={plugin.logo_url} radius="sm" size={28} alt={`${plugin.name} logo`}>
{plugin.name?.[0]?.toUpperCase()}
</Avatar>
<Text fw={600}>{plugin.name}</Text>
</Group>
}
size="lg"
>
<Tabs value={modalTab} onChange={(tab) => { setModalTab(tab); if (tab === 'details') fetchDetail(); }}>
<Tabs.List>
{isManaged && <Tabs.Tab value="details" leftSection={<Info size={14} />}>Details</Tabs.Tab>}
{hasFields && <Tabs.Tab value="settings" leftSection={<Settings size={14} />}>Settings</Tabs.Tab>}
{hasActions && <Tabs.Tab value="actions" leftSection={<Zap size={14} />}>Actions</Tabs.Tab>}
</Tabs.List>
{expanded &&
!missing &&
enabled &&
plugin.fields &&
plugin.fields.length > 0 && (
<Stack gap="xs" mt="sm">
<PluginFieldList
plugin={plugin}
settings={settings}
updateField={updateField}
/>
<Group>
<Button
loading={saving}
onClick={save}
variant="default"
size="xs"
>
Save Settings
</Button>
</Group>
</Stack>
)}
{expanded &&
!missing &&
enabled &&
plugin.actions &&
plugin.actions.length > 0 && (
<>
<Divider my="sm" />
<Stack gap="xs">
<PluginActionList
plugin={plugin}
enabled={enabled}
runningActionId={runningActionId}
handlePluginRun={handlePluginRun}
{isManaged && (
<Tabs.Panel value="details" pt="md">
<PluginDetailPanel
detail={detail}
detailLoading={detailLoading}
selectedVersion={selectedVersion}
onVersionChange={setSelectedVersion}
installedVersion={plugin.version}
installedVersionIsPrerelease={!!plugin.installed_version_is_prerelease}
appVersion={appVersion}
installing={installing}
uninstalling={uninstalling}
onInstall={handleDetailInstall}
onUninstall={handleDetailUninstall}
installStatus="installed"
repoId={plugin.source_repo}
slug={plugin.slug}
/>
<PluginActionStatus
running={!!runningActionId}
lastResult={lastResult}
/>
</Stack>
</>
)}
</Card>
</Tabs.Panel>
)}
{hasFields && (
<Tabs.Panel value="settings" pt="md">
<Stack gap="md">
<PluginFieldList
plugin={plugin}
settings={settings}
updateField={updateField}
/>
<Group justify="flex-end">
<Button
variant="default"
size="xs"
onClick={() => setModalOpen(false)}
>
Cancel
</Button>
<Button
loading={saving}
onClick={async () => {
await save();
setModalOpen(false);
}}
size="xs"
>
Save
</Button>
</Group>
</Stack>
</Tabs.Panel>
)}
{hasActions && (
<Tabs.Panel value="actions" pt="md">
<Stack gap="sm">
<PluginActionList
plugin={plugin}
enabled={enabled}
runningActionId={runningActionId}
handlePluginRun={handlePluginRun}
/>
<PluginActionStatus
running={!!runningActionId}
lastResult={lastResult}
/>
</Stack>
</Tabs.Panel>
)}
</Tabs>
</Modal>
</div>
);
};

View file

@ -54,6 +54,32 @@ vi.mock('@mantine/core', async () => {
Text: ({ children, ...props }) => <span {...props}>{children}</span>,
UnstyledButton: ({ children, ...props }) => <button {...props}>{children}</button>,
Badge: ({ children, ...props }) => <span {...props}>{children}</span>,
Loader: ({ size }) => <span data-testid="loader" data-size={size} />,
Modal: ({ opened, onClose, title, children }) =>
opened ? (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
<button onClick={onClose}>Close Modal</button>
{children}
</div>
) : null,
Tabs: Object.assign(
({ children, value, onChange }) => (
<div data-testid="tabs" data-value={value}>{children}</div>
),
{
List: ({ children }) => <div>{children}</div>,
Tab: ({ children, value, leftSection }) => (
<button data-value={value}>{leftSection}{children}</button>
),
Panel: ({ children, value }) => (
<div data-testid={`tab-panel-${value}`}>{children}</div>
),
}
),
Tooltip: ({ children, label }) => (
<div title={label}>{children}</div>
),
};
});
@ -112,7 +138,7 @@ describe('PluginCard', () => {
expect(screen.getByText('Test Plugin')).toBeInTheDocument();
expect(screen.getByText('A test plugin')).toBeInTheDocument();
expect(screen.getByText('v1.0.0')).toBeInTheDocument();
expect(screen.getByText('By Test Author')).toBeInTheDocument();
expect(screen.getByText('Test Author')).toBeInTheDocument();
});
it('should render plugin logo when logo_url is provided', () => {
@ -166,43 +192,28 @@ describe('PluginCard', () => {
});
});
describe('Expansion/Collapse', () => {
it('should toggle expanded state when clicking chevron button', async () => {
render(<PluginCard plugin={mockPlugin} />);
await waitFor(() => {
fireEvent.click(screen.getByTitle('Collapse settings'));
expect(screen.queryByText('Test Action')).not.toBeInTheDocument();
});
await waitFor(() => {
fireEvent.click(screen.getByTitle('Expand settings'));
});
expect(await screen.findByText('Test Action')).toBeInTheDocument();
});
it('should toggle expanded state when clicking plugin name', () => {
describe('Modal Behavior', () => {
it('should open settings modal when Settings button is clicked', () => {
render(<PluginCard {...defaultProps} />);
expect(screen.getByText('Test Action')).toBeInTheDocument();
const nameButton = screen.getByText('Test Plugin');
fireEvent.click(nameButton);
expect(screen.queryByText('Test Action')).not.toBeInTheDocument();
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
fireEvent.click(screen.getByText('Settings'));
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
it('should collapse when plugin is disabled', () => {
const { rerender } = render(<PluginCard {...defaultProps} />);
it('should open actions modal when Actions button is clicked', () => {
render(<PluginCard {...defaultProps} />);
const expandButton = screen.getAllByRole('button')[0];
fireEvent.click(expandButton);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
fireEvent.click(screen.getByText('Actions'));
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
it('should not show Actions button when plugin is disabled', () => {
const disabledPlugin = { ...mockPlugin, enabled: false };
rerender(<PluginCard {...defaultProps} plugin={disabledPlugin} />);
render(<PluginCard {...defaultProps} plugin={disabledPlugin} />);
expect(screen.queryByText('Test Action')).not.toBeInTheDocument();
expect(screen.queryByText('Actions')).not.toBeInTheDocument();
});
});
@ -278,7 +289,8 @@ describe('PluginCard', () => {
defaultProps.onSaveSettings.mockResolvedValue(true);
render(<PluginCard {...defaultProps} />);
const saveButton = screen.getByText('Save Settings');
fireEvent.click(screen.getByText('Settings'));
const saveButton = screen.getByText('Save');
fireEvent.click(saveButton);
await waitFor(() => {
@ -298,7 +310,8 @@ describe('PluginCard', () => {
defaultProps.onSaveSettings.mockResolvedValue(false);
render(<PluginCard {...defaultProps} />);
const saveButton = screen.getByText('Save Settings');
fireEvent.click(screen.getByText('Settings'));
const saveButton = screen.getByText('Save');
fireEvent.click(saveButton);
await waitFor(() => {
@ -315,7 +328,8 @@ describe('PluginCard', () => {
defaultProps.onSaveSettings.mockRejectedValue(error);
render(<PluginCard {...defaultProps} />);
const saveButton = screen.getByText('Save Settings');
fireEvent.click(screen.getByText('Settings'));
const saveButton = screen.getByText('Save');
fireEvent.click(saveButton);
await waitFor(() => {
@ -332,6 +346,7 @@ describe('PluginCard', () => {
it('should render action buttons', () => {
render(<PluginCard {...defaultProps} />);
fireEvent.click(screen.getByText('Actions'));
expect(screen.getByText('Run Action')).toBeInTheDocument();
});
@ -344,6 +359,7 @@ describe('PluginCard', () => {
render(<PluginCard {...defaultProps} />);
fireEvent.click(screen.getByText('Actions'));
const actionButton = screen.getByText('Run Action');
fireEvent.click(actionButton);
@ -369,6 +385,7 @@ describe('PluginCard', () => {
render(<PluginCard {...defaultProps} />);
fireEvent.click(screen.getByText('Actions'));
const actionButton = screen.getByText('Run Action');
fireEvent.click(actionButton);
@ -391,6 +408,7 @@ describe('PluginCard', () => {
render(<PluginCard {...defaultProps} />);
fireEvent.click(screen.getByText('Actions'));
const actionButton = screen.getByText('Run Action');
fireEvent.click(actionButton);
@ -412,6 +430,7 @@ describe('PluginCard', () => {
render(<PluginCard {...errorProps} />);
fireEvent.click(screen.getByText('Actions'));
const actionButton = screen.getByText('Run Action');
fireEvent.click(actionButton);
@ -439,6 +458,7 @@ describe('PluginCard', () => {
render(<PluginCard {...defaultProps} plugin={pluginWithEvents} />);
fireEvent.click(screen.getByText('Actions'));
expect(screen.getByText('Event Triggers')).toBeInTheDocument();
});
});
@ -447,7 +467,7 @@ describe('PluginCard', () => {
it('should call onRequestDelete when delete button is clicked', () => {
render(<PluginCard {...defaultProps} />);
const deleteButton = screen.getByTitle('Delete plugin');
const deleteButton = screen.getByText('Uninstall');
fireEvent.click(deleteButton);
expect(defaultProps.onRequestDelete).toHaveBeenCalledWith(mockPlugin);
@ -476,8 +496,8 @@ describe('PluginCard', () => {
};
rerender(<PluginCard {...defaultProps} plugin={newPlugin} />);
// Settings should be updated internally
expect(screen.getByText('Save Settings')).toBeInTheDocument();
// Settings button should still be present after key change
expect(screen.getByText('Settings')).toBeInTheDocument();
});
});
});

View file

@ -168,6 +168,9 @@ vi.mock('lucide-react', () => ({
SquareX: () => <svg data-testid="icon-square-x" />,
Timer: () => <svg data-testid="icon-timer" />,
Users: () => <svg data-testid="icon-users" />,
Video: () => <svg data-testid="icon-video" />,
Package: () => <svg data-testid="icon-package" />,
Download: () => <svg data-testid="icon-download" />,
}));
// Imports after mocks

View file

@ -0,0 +1,25 @@
/**
* Compare two semver-like version strings.
* Returns negative if a < b, 0 if equal, positive if a > b.
*
* If either version is a prerelease (any dot-segment contains non-digit
* characters), numeric ordering is meaningless. Fall back to exact string
* equality: 0 if identical, non-zero otherwise.
*/
export function compareVersions(a, b) {
if (!a || !b) return 0;
const normalize = (v) => v.replace(/^v/, '');
const na = normalize(a);
const nb = normalize(b);
const isPrerelease = (v) => v.split('.').some((p) => !/^\d+$/.test(p));
if (isPrerelease(na) || isPrerelease(nb)) {
return na === nb ? 0 : 1;
}
const pa = na.split('.').map(Number);
const pb = nb.split('.').map(Number);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const diff = (pa[i] || 0) - (pb[i] || 0);
if (diff !== 0) return diff;
}
return 0;
}

View file

@ -7,6 +7,8 @@ import {
ChartLine,
Video,
PlugZap,
Package,
Download,
User,
FileImage,
Webhook,
@ -63,8 +65,11 @@ export const NAV_ITEMS = {
id: 'plugins',
label: 'Plugins',
icon: PlugZap,
path: '/plugins',
adminOnly: true,
paths: [
{ label: 'My Plugins', icon: Package, path: '/plugins' },
{ label: 'Find Plugins', icon: Download, path: '/plugins/browse' },
],
},
integrations: {
id: 'integrations',

View file

@ -0,0 +1,729 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
ActionIcon,
AppShellMain,
Badge,
Box,
Button,
Group,
Loader,
Modal,
NumberInput,
Pagination,
Select,
SimpleGrid,
Stack,
Text,
Textarea,
TextInput,
} from '@mantine/core';
import API from '../api.js';
import { RefreshCcw, Trash2, Plus, Search, KeyRound, ShieldCheck, ShieldAlert } from 'lucide-react';
import { usePluginStore } from '../store/plugins.jsx';
import useSettingsStore from '../store/settings.jsx';
import AvailablePluginCard from '../components/cards/AvailablePluginCard.jsx';
import { showNotification } from '../utils/notificationUtils.js';
import { reloadPlugins } from '../utils/pages/PluginsUtils.js';
import { compareVersions } from '../components/pluginUtils.js';
export default function PluginBrowsePage() {
const repos = usePluginStore((s) => s.repos);
const reposLoading = usePluginStore((s) => s.reposLoading);
const availablePlugins = usePluginStore((s) => s.availablePlugins);
const availableLoading = usePluginStore((s) => s.availableLoading);
const fetchRepos = usePluginStore((s) => s.fetchRepos);
const fetchAvailablePlugins = usePluginStore((s) => s.fetchAvailablePlugins);
const refreshRepo = usePluginStore((s) => s.refreshRepo);
const addRepo = usePluginStore((s) => s.addRepo);
const removeRepo = usePluginStore((s) => s.removeRepo);
const updateRepo = usePluginStore((s) => s.updateRepo);
const appVersion = useSettingsStore((s) => s.version?.version || '');
const [repoModalOpen, setRepoModalOpen] = useState(false);
const [newRepoUrl, setNewRepoUrl] = useState('');
const [newRepoPublicKey, setNewRepoPublicKey] = useState('');
const [addingRepo, setAddingRepo] = useState(false);
const [refreshingAll, setRefreshingAll] = useState(false);
const [editingKeyRepoId, setEditingKeyRepoId] = useState(null);
const [editKeyValue, setEditKeyValue] = useState('');
const [savingKey, setSavingKey] = useState(false);
const [showAddRepo, setShowAddRepo] = useState(false);
const [deleteConfirmId, setDeleteConfirmId] = useState(null);
const [gpgKeyFocused, setGpgKeyFocused] = useState(false);
const [repoPreview, setRepoPreview] = useState(null);
const [previewLoading, setPreviewLoading] = useState(false);
const previewTimer = useRef(null);
const [refreshInterval, setRefreshInterval] = useState(6);
const [savingInterval, setSavingInterval] = useState(false);
const recentlyInstalledSlugs = useRef(new Set());
const recentlyUninstalledSlugs = useRef(new Set());
const [searchQuery, setSearchQuery] = useState('');
const [sortBy, setSortBy] = useState('updated');
const [filterRepo, setFilterRepo] = useState('all');
const [filterStatus, setFilterStatus] = useState('all');
const [page, setPage] = useState(1);
const perPage = 9;
const hasFetched = useRef(false);
useEffect(() => {
if (!hasFetched.current) {
hasFetched.current = true;
fetchRepos();
fetchAvailablePlugins();
}
}, [fetchRepos, fetchAvailablePlugins]);
const handleRefreshAll = useCallback(async () => {
setRefreshingAll(true);
try {
for (const repo of usePluginStore.getState().repos) {
await refreshRepo(repo.id);
}
await fetchAvailablePlugins();
await reloadPlugins();
usePluginStore.getState().invalidatePlugins();
showNotification({
title: 'Refreshed',
message: 'All plugin repos refreshed',
color: 'green',
});
} catch {
showNotification({
title: 'Error',
message: 'Some repos failed to refresh',
color: 'red',
});
} finally {
setRefreshingAll(false);
}
}, [refreshRepo, fetchAvailablePlugins]);
const handleAddRepo = useCallback(async () => {
if (!newRepoUrl.trim()) return;
setAddingRepo(true);
try {
await addRepo({ url: newRepoUrl.trim(), public_key: newRepoPublicKey.trim() });
setNewRepoUrl('');
setNewRepoPublicKey('');
setRepoPreview(null);
setShowAddRepo(false);
await fetchAvailablePlugins();
showNotification({
title: 'Added',
message: 'Plugin repo added',
color: 'green',
});
} catch {
// Error notification handled by API layer
} finally {
setAddingRepo(false);
}
}, [newRepoUrl, newRepoPublicKey, addRepo, fetchAvailablePlugins]);
const handleDeleteRepo = useCallback(
async (id) => {
await removeRepo(id);
setDeleteConfirmId(null);
await fetchAvailablePlugins();
showNotification({
title: 'Removed',
message: 'Plugin repo removed',
color: 'green',
});
},
[removeRepo, fetchAvailablePlugins]
);
const handleEditKey = useCallback((repo) => {
setEditingKeyRepoId(repo.id);
setEditKeyValue(repo.public_key || '');
}, []);
const handleSaveKey = useCallback(async () => {
if (editingKeyRepoId == null) return;
setSavingKey(true);
try {
await updateRepo(editingKeyRepoId, { public_key: editKeyValue });
await refreshRepo(editingKeyRepoId);
await fetchAvailablePlugins();
showNotification({ title: 'Updated', message: 'Public key updated', color: 'green' });
setEditingKeyRepoId(null);
setEditKeyValue('');
} catch {
showNotification({ title: 'Error', message: 'Failed to update key', color: 'red' });
} finally {
setSavingKey(false);
}
}, [editingKeyRepoId, editKeyValue, updateRepo, refreshRepo, fetchAvailablePlugins]);
const loadRepoSettings = useCallback(async () => {
const data = await API.getPluginRepoSettings();
if (data) setRefreshInterval(data.refresh_interval_hours ?? 6);
}, []);
const handleSaveInterval = useCallback(async (val) => {
const hours = val ?? 0;
setRefreshInterval(hours);
setSavingInterval(true);
try {
await API.updatePluginRepoSettings({ refresh_interval_hours: hours });
} catch {
// Error notification handled by API layer
} finally {
setSavingInterval(false);
}
}, []);
// Debounced manifest preview
const fetchPreview = useCallback((url, publicKey) => {
if (previewTimer.current) clearTimeout(previewTimer.current);
if (!url.trim() || !url.match(/^https?:\/\/.+/i)) {
setRepoPreview(null);
setPreviewLoading(false);
return;
}
setPreviewLoading(true);
previewTimer.current = setTimeout(async () => {
const result = await API.previewPluginRepo(url.trim(), publicKey?.trim());
setRepoPreview(result);
setPreviewLoading(false);
}, 600);
}, []);
// Cleanup any pending preview timer on unmount
useEffect(() => {
return () => {
if (previewTimer.current) {
clearTimeout(previewTimer.current);
}
};
}, []);
// Load settings when modal opens
useEffect(() => {
if (repoModalOpen) loadRepoSettings();
}, [repoModalOpen, loadRepoSettings]);
const loading = availableLoading && availablePlugins.length === 0;
// Build repo filter options from available plugins
const repoOptions = React.useMemo(() => {
const seen = new Map();
availablePlugins.forEach((p) => {
if (!seen.has(p.repo_id)) {
seen.set(p.repo_id, p.repo_name || `Repo ${p.repo_id}`);
}
});
return [
{ value: 'all', label: 'All Repos' },
...Array.from(seen, ([id, name]) => ({ value: String(id), label: name })),
];
}, [availablePlugins]);
// Filter and sort plugins
const filteredPlugins = React.useMemo(() => {
let list = [...availablePlugins];
// Text search
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase().trim();
list = list.filter(
(p) =>
p.name?.toLowerCase().includes(q) ||
p.description?.toLowerCase().includes(q) ||
p.author?.toLowerCase().includes(q)
);
}
// Repo filter
if (filterRepo !== 'all') {
list = list.filter((p) => String(p.repo_id) === filterRepo);
}
// Status filter
if (filterStatus === 'installed') {
list = list.filter((p) => p.installed);
} else if (filterStatus === 'not-installed') {
list = list.filter((p) => !p.installed);
} else if (filterStatus === 'compatible') {
list = list.filter((p) => {
const meetsMin = !p.min_dispatcharr_version || compareVersions(appVersion, p.min_dispatcharr_version) >= 0;
const meetsMax = !p.max_dispatcharr_version || compareVersions(appVersion, p.max_dispatcharr_version) <= 0;
return meetsMin && meetsMax;
});
}
// Sort
list.sort((a, b) => {
// Pre-sort weights: deprecated installed incompatible sink to bottom (in that order)
// Recently installed plugins are exempt so they don't jump away after install
const weight = (p) => {
if (recentlyInstalledSlugs.current.has(p.slug)) return 0;
if (recentlyUninstalledSlugs.current.has(p.slug)) return 2;
const meetsMin = !p.min_dispatcharr_version || compareVersions(appVersion, p.min_dispatcharr_version) >= 0;
const meetsMax = !p.max_dispatcharr_version || compareVersions(appVersion, p.max_dispatcharr_version) <= 0;
if (p.deprecated) return 1;
if (p.installed) return 2;
if (!meetsMin || !meetsMax) return 3;
return 0;
};
const wa = weight(a);
const wb = weight(b);
if (wa !== wb) return wa - wb;
switch (sortBy) {
case 'name-asc':
return (a.name || '').localeCompare(b.name || '');
case 'name-desc':
return (b.name || '').localeCompare(a.name || '');
case 'author':
return (a.author || '').localeCompare(b.author || '');
case 'updated':
return (b.last_updated || '').localeCompare(a.last_updated || '');
default:
return 0;
}
});
return list;
}, [availablePlugins, searchQuery, filterRepo, filterStatus, sortBy, appVersion]);
// Reset to page 1 when filters/search change
React.useEffect(() => {
setPage(1);
}, [searchQuery, filterRepo, filterStatus, sortBy]);
const totalPages = Math.ceil(filteredPlugins.length / perPage);
const paginatedPlugins = filteredPlugins.slice((page - 1) * perPage, page * perPage);
return (
<AppShellMain p={16}>
<Group justify="space-between" mb="md">
<Group gap="xs" align="center">
<Text fw={700} size="lg">
Find Plugins
</Text>
{availablePlugins.length > 0 && (
<Badge variant="light" color="gray" size="sm">{availablePlugins.length} Plugins Available</Badge>
)}
{repos.length > 1 && (
<Badge variant="light" color="gray" size="sm">{repos.length} Repos</Badge>
)}
</Group>
<Group>
<Button
size="xs"
variant="light"
onClick={() => setRepoModalOpen(true)}
>
Manage Repos
</Button>
<ActionIcon
variant="light"
onClick={handleRefreshAll}
title="Refresh all repos"
loading={refreshingAll}
>
<RefreshCcw size={18} />
</ActionIcon>
</Group>
</Group>
{loading && <Loader />}
{!loading && (
<Group gap="sm" mb="md" wrap="wrap">
<TextInput
placeholder="Search plugins..."
leftSection={<Search size={14} />}
size="xs"
value={searchQuery}
onChange={(e) => setSearchQuery(e.currentTarget.value)}
style={{ flex: 1, minWidth: 180, maxWidth: 300 }}
/>
<Select
size="xs"
allowDeselect={false}
value={sortBy}
onChange={setSortBy}
data={[
{ value: 'name-asc', label: 'Name A-Z' },
{ value: 'name-desc', label: 'Name Z-A' },
{ value: 'author', label: 'Author' },
{ value: 'updated', label: 'Recently Updated' },
]}
style={{ width: 170 }}
/>
{repoOptions.length > 2 && (
<Select
size="xs"
allowDeselect={false}
value={filterRepo}
onChange={setFilterRepo}
data={repoOptions}
style={{ width: 160 }}
/>
)}
<Select
size="xs"
allowDeselect={false}
value={filterStatus}
onChange={setFilterStatus}
data={[
{ value: 'all', label: 'All Plugins' },
{ value: 'installed', label: 'Installed' },
{ value: 'not-installed', label: 'Not Installed' },
{ value: 'compatible', label: 'Compatible' },
]}
style={{ width: 150 }}
/>
</Group>
)}
{!loading && filteredPlugins.length === 0 && availablePlugins.length > 0 && (
<Box>
<Text c="dimmed">
No plugins match your filters. Try adjusting your search or filter criteria.
</Text>
</Box>
)}
{!loading && availablePlugins.length === 0 && (
<Box>
<Text c="dimmed">
No plugins available. Try refreshing repos or adding a new plugin
repository.
</Text>
</Box>
)}
{!loading && filteredPlugins.length > 0 && (
<>
<SimpleGrid
cols={{ base: 1, md: 2, xl: 3 }}
spacing="md"
>
{paginatedPlugins.map((p) => (
<AvailablePluginCard
key={`${p.repo_id}-${p.slug}`}
plugin={p}
appVersion={appVersion}
multiRepo={repos.length > 1}
onBeforeInstall={(slug) => { if (slug) recentlyInstalledSlugs.current.add(slug); }}
onInstalled={(slug) => { if (slug) recentlyInstalledSlugs.current.add(slug); fetchAvailablePlugins(); }}
onUninstalled={(slug) => { if (slug) recentlyUninstalledSlugs.current.add(slug); }}
/>
))}
</SimpleGrid>
{totalPages > 1 && (
<Group justify="center" mt="lg">
<Pagination
value={page}
onChange={setPage}
total={totalPages}
size="sm"
/>
</Group>
)}
</>
)}
{/* Manage Repos Modal */}
<Modal
opened={repoModalOpen}
onClose={() => setRepoModalOpen(false)}
title={
<Group justify="space-between" align="flex-start" w="100%">
<div style={{ flex: 1 }}>
<Text fw={600}>Plugin Repositories</Text>
<Text size="sm" c="dimmed" mt={4}>
Add third-party plugin repositories or manage existing ones.
Manifests are fetched automatically at the configured interval.
</Text>
</div>
<div style={{ textAlign: 'left', flexShrink: 0 }}>
<Text size="sm" fw={500} mb={2}>Refresh Interval</Text>
<NumberInput
value={refreshInterval}
onChange={handleSaveInterval}
min={0}
max={168}
size="xs"
disabled={savingInterval}
w={115}
/>
<Text size="xs" c="dimmed" mt={2}>Hours, 0 to disable</Text>
</div>
</Group>
}
centered
size="lg"
styles={{ title: { width: '100%' }, header: { alignItems: 'flex-start' } }}
>
<Stack gap="md">
{reposLoading && repos.length === 0 && <Loader size="sm" />}
{repos.map((repo) => (
<React.Fragment key={repo.id}>
<Group
justify="space-between"
align="center"
wrap="nowrap"
style={{
padding: '8px 12px',
borderRadius: 8,
backgroundColor: 'var(--mantine-color-dark-6)',
}}
>
<Box style={{ minWidth: 0, flex: 1 }}>
<Group gap="xs" align="center">
<Text fw={500} size="sm" lineClamp={1}>
{repo.name}
</Text>
{repo.is_official && (
<Badge size="xs" variant="filled" style={{ backgroundColor: '#14917E' }}>
Official Repo
</Badge>
)}
{repo.signature_verified === true && (
<Badge size="xs" variant="light" color="green" leftSection={<ShieldCheck size={10} />}>
Verified Signature
</Badge>
)}
{repo.signature_verified === false && (
<Badge size="xs" variant="light" color="red" leftSection={<ShieldAlert size={10} />}>
Invalid Signature
</Badge>
)}
</Group>
{repo.registry_url ? (
<Text size="xs" c="dimmed" lineClamp={1}>
<a
href={repo.registry_url}
target="_blank"
rel="noopener noreferrer"
style={{ color: 'var(--mantine-color-blue-4)', textDecoration: 'none' }}
>
{repo.registry_url}
</a>
</Text>
) : null}
<Text size="xs" c="dimmed" lineClamp={1}>
{repo.url}
</Text>
{repo.last_fetched && (
<Text size="xs" c={repo.last_fetch_status && repo.last_fetch_status !== '200' ? 'red' : 'dimmed'}>
Last fetched:{' '}
{new Date(repo.last_fetched).toLocaleString()}
{repo.last_fetch_status && repo.last_fetch_status !== '200'
? ` · ${repo.last_fetch_status}`
: repo.plugin_count != null
? ` · ${repo.plugin_count} plugin${repo.plugin_count !== 1 ? 's' : ''} available`
: ''}
</Text>
)}
</Box>
{!repo.is_official && (
<Stack gap={4} align="center">
<ActionIcon
variant="subtle"
color="gray"
title="Edit public key"
onClick={() => handleEditKey(repo)}
>
<KeyRound size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
title="Remove repo"
onClick={() => setDeleteConfirmId(repo.id)}
>
<Trash2 size={16} />
</ActionIcon>
</Stack>
)}
</Group>
{editingKeyRepoId === repo.id && (
<Stack gap="xs" mt="xs">
<Textarea
placeholder={"-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nOptional: Paste public GPG key here\n\n-----END PGP PUBLIC KEY BLOCK-----"}
value={editKeyValue}
onChange={(e) => setEditKeyValue(e.currentTarget.value)}
size="xs"
minRows={3}
autosize
/>
<Group gap="xs">
<Button size="xs" onClick={handleSaveKey} loading={savingKey}>
Save Key
</Button>
<Button size="xs" variant="subtle" color="gray" onClick={() => setEditingKeyRepoId(null)}>
Cancel
</Button>
</Group>
</Stack>
)}
</React.Fragment>
))}
{!showAddRepo ? (
<Button
variant="light"
leftSection={<Plus size={16} />}
size="sm"
onClick={() => setShowAddRepo(true)}
>
Add Repository
</Button>
) : (
<>
<Text fw={500} size="sm" mt="sm">
Add Repository
</Text>
<Box
style={{
padding: '8px 12px',
borderRadius: 8,
minHeight: 90,
display: 'flex',
alignItems: 'center',
backgroundColor: 'var(--mantine-color-dark-6)',
border: repoPreview && !previewLoading && !repoPreview.valid
? '1px solid var(--mantine-color-red-7)'
: 'none',
}}
>
{previewLoading ? (
<Group gap="xs" align="center">
<Loader size={14} />
<Text size="xs" c="dimmed">Checking manifest...</Text>
</Group>
) : repoPreview ? (
repoPreview.valid ? (
<Box>
<Group gap="xs" align="center">
<Text fw={500} size="sm">
{repoPreview.registry_name}
</Text>
{repoPreview.signature_verified === true && (
<Badge size="xs" variant="light" color="green" leftSection={<ShieldCheck size={10} />}>
Verified Signature
</Badge>
)}
{repoPreview.signature_verified === false && (
<>
<Badge size="xs" variant="light" color="gray" leftSection={<ShieldCheck size={10} />}>
Signed Manifest
</Badge>
<Text size="xs" c="var(--mantine-color-yellow-6)" fs="italic">Public key required for verification</Text>
</>
)}
{repoPreview.signature_verified == null && (
<Badge size="xs" variant="light" color="gray">
No Signature
</Badge>
)}
</Group>
{repoPreview.registry_url ? (
<Text size="xs" c="dimmed">
<a
href={repoPreview.registry_url}
target="_blank"
rel="noopener noreferrer"
style={{ color: 'var(--mantine-color-blue-4)', textDecoration: 'none' }}
>
{repoPreview.registry_url}
</a>
</Text>
) : null}
<Text size="xs" c="dimmed" lineClamp={1}>
{newRepoUrl.trim()}
</Text>
<Text size="xs" c="dimmed">
{repoPreview.plugin_count} plugin{repoPreview.plugin_count !== 1 ? 's' : ''} available
</Text>
</Box>
) : (
<Text size="xs" c="red">
{repoPreview.errors?.join(' ') || 'Invalid manifest'}
</Text>
)
) : (
<Text size="xs" c="yellow">
Third-party repositories are not reviewed by the Dispatcharr team.
<br />Adding sources and installing plugins is done at your own risk.
</Text>
)}
</Box>
<TextInput
placeholder="Repository Manifest URL (ending in .json)"
value={newRepoUrl}
onChange={(e) => {
setNewRepoUrl(e.currentTarget.value);
fetchPreview(e.currentTarget.value, newRepoPublicKey);
}}
size="sm"
/>
<Textarea
placeholder={gpgKeyFocused ? "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nPaste public GPG key here\n\n-----END PGP PUBLIC KEY BLOCK-----" : "Optional: Paste public GPG key here"}
value={newRepoPublicKey}
onChange={(e) => {
const value = e.currentTarget.value;
setNewRepoPublicKey(value);
fetchPreview(newRepoUrl, value);
}}
size="sm"
minRows={gpgKeyFocused || newRepoPublicKey ? 4 : 1}
maxRows={8}
autosize
onFocus={() => setGpgKeyFocused(true)}
onBlur={() => { if (!newRepoPublicKey) setGpgKeyFocused(false); }}
styles={repoPreview?.valid && repoPreview?.signature_verified === false && !newRepoPublicKey.trim() ? { input: { borderColor: 'var(--mantine-color-yellow-6)' } } : undefined}
/>
<Group gap="xs" justify="flex-end">
<Button
variant="subtle"
color="gray"
size="sm"
onClick={() => { setShowAddRepo(false); setNewRepoUrl(''); setNewRepoPublicKey(''); setRepoPreview(null); }}
>
Cancel
</Button>
<Button
onClick={handleAddRepo}
loading={addingRepo}
disabled={!newRepoUrl.trim()}
leftSection={<Plus size={16} />}
size="sm"
>
Add Repo
</Button>
</Group>
</>
)}
</Stack>
</Modal>
{/* Delete Confirmation Modal */}
<Modal
opened={deleteConfirmId != null}
onClose={() => setDeleteConfirmId(null)}
title="Remove Repository"
size="sm"
centered
>
<Text size="sm">Are you sure you want to remove this repository?</Text>
<Text size="xs" c="dimmed" mt="xs">Plugins installed from this repo will remain installed but become unmanaged.</Text>
<Group mt="md" justify="flex-end" gap="xs">
<Button variant="subtle" color="gray" onClick={() => setDeleteConfirmId(null)}>Cancel</Button>
<Button color="red" onClick={() => handleDeleteRepo(deleteConfirmId)}>Remove</Button>
</Group>
</Modal>
</AppShellMain>
);
}

View file

@ -2,6 +2,7 @@ import React, {
Suspense,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
@ -9,6 +10,7 @@ import {
ActionIcon,
Alert,
AppShellMain,
Badge,
Box,
Button,
Divider,
@ -16,10 +18,12 @@ import {
Group,
Loader,
Modal,
Select,
SimpleGrid,
Stack,
Switch,
Text,
TextInput,
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import {
@ -35,16 +39,27 @@ import {
setPluginEnabled,
updatePluginSettings,
} from '../utils/pages/PluginsUtils.js';
import { RefreshCcw } from 'lucide-react';
import { RefreshCcw, Search } from 'lucide-react';
import ErrorBoundary from '../components/ErrorBoundary.jsx';
const PluginCard = React.lazy(
() => import('../components/cards/PluginCard.jsx')
);
const FILTER_OPTIONS = [
{ value: 'all', label: 'All Plugins' },
{ value: 'enabled', label: 'Enabled' },
{ value: 'disabled', label: 'Disabled' },
{ value: 'update', label: 'Update Available' },
{ value: 'managed', label: 'Managed' },
{ value: 'unmanaged', label: 'Unmanaged' },
];
const PluginsList = ({ onRequestDelete, onRequireTrust, onRequestConfirm }) => {
const plugins = usePluginStore((state) => state.plugins);
const loading = usePluginStore((state) => state.loading);
const hasFetchedRef = useRef(false);
const [searchQuery, setSearchQuery] = useState('');
const [filterStatus, setFilterStatus] = useState('all');
useEffect(() => {
if (!hasFetchedRef.current) {
@ -53,6 +68,42 @@ const PluginsList = ({ onRequestDelete, onRequireTrust, onRequestConfirm }) => {
}
}, []);
const filteredPlugins = useMemo(() => {
let result = plugins;
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
result = result.filter(
(p) =>
p.name?.toLowerCase().includes(q) ||
p.description?.toLowerCase().includes(q) ||
p.author?.toLowerCase().includes(q)
);
}
switch (filterStatus) {
case 'enabled':
result = result.filter((p) => p.enabled);
break;
case 'disabled':
result = result.filter((p) => !p.enabled);
break;
case 'update':
result = result.filter((p) => p.update_available);
break;
case 'managed':
result = result.filter((p) => p.is_managed);
break;
case 'unmanaged':
result = result.filter((p) => !p.is_managed);
break;
}
result.sort((a, b) => {
if (a.update_available && !b.update_available) return -1;
if (!a.update_available && b.update_available) return 1;
return (a.name || '').localeCompare(b.name || '');
});
return result;
}, [plugins, searchQuery, filterStatus]);
const handleTogglePluginEnabled = async (key, next) => {
const resp = await setPluginEnabled(key, next);
@ -72,15 +123,33 @@ const PluginsList = ({ onRequestDelete, onRequireTrust, onRequestConfirm }) => {
return (
<>
{plugins.length > 0 && (
<Group gap="sm" mb="md" wrap="wrap">
<TextInput
placeholder="Search plugins…"
leftSection={<Search size={14} />}
value={searchQuery}
onChange={(e) => setSearchQuery(e.currentTarget.value)}
style={{ flex: 1, minWidth: 180, maxWidth: 300 }}
size="xs"
/>
<Select
data={FILTER_OPTIONS}
value={filterStatus}
onChange={(v) => setFilterStatus(v || 'all')}
size="xs"
allowDeselect={false}
style={{ width: 170 }}
/>
</Group>
{filteredPlugins.length > 0 && (
<SimpleGrid
cols={2}
cols={{ base: 1, md: 2, xl: 3 }}
spacing="md"
breakpoints={[{ maxWidth: '48em', cols: 1 }]}
>
<ErrorBoundary>
<Suspense fallback={<Loader />}>
{plugins.map((p) => (
{filteredPlugins.map((p) => (
<PluginCard
key={p.key}
plugin={p}
@ -97,6 +166,12 @@ const PluginsList = ({ onRequestDelete, onRequireTrust, onRequestConfirm }) => {
</SimpleGrid>
)}
{filteredPlugins.length === 0 && plugins.length > 0 && (
<Box>
<Text c="dimmed">No plugins match your search or filter.</Text>
</Box>
)}
{plugins.length === 0 && (
<Box>
<Text c="dimmed">
@ -110,6 +185,7 @@ const PluginsList = ({ onRequestDelete, onRequireTrust, onRequestConfirm }) => {
};
export default function PluginsPage() {
const plugins = usePluginStore((state) => state.plugins);
const [importOpen, setImportOpen] = useState(false);
const [importFile, setImportFile] = useState(null);
const [importing, setImporting] = useState(false);
@ -120,6 +196,7 @@ export default function PluginsPage() {
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState(null);
const [deleting, setDeleting] = useState(false);
const [reloading, setReloading] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const [confirmConfig, setConfirmConfig] = useState({
title: '',
@ -128,8 +205,31 @@ export default function PluginsPage() {
});
const handleReload = async () => {
await reloadPlugins();
usePluginStore.getState().invalidatePlugins();
const { repos, refreshRepo, fetchAvailablePlugins, fetchPlugins } = usePluginStore.getState();
setReloading(true);
try {
for (const repo of repos) {
try { await refreshRepo(repo.id); } catch {
console.error(`Failed to refresh repo ${repo.name} (${repo.id})`);
}
}
await fetchAvailablePlugins();
await reloadPlugins();
await fetchPlugins();
showNotification({
title: 'Refreshed',
message: 'Plugin repos and registry reloaded',
color: 'green',
});
} catch {
showNotification({
title: 'Error',
message: 'Some repos failed to refresh',
color: 'red',
});
} finally {
setReloading(false);
}
};
const handleRequestDelete = useCallback((pl) => {
@ -137,6 +237,7 @@ export default function PluginsPage() {
setDeleteOpen(true);
}, []);
// eslint-disable-next-line no-unused-vars
const requireTrust = useCallback((plugin) => {
return new Promise((resolve) => {
setTrustResolve(() => resolve);
@ -160,55 +261,73 @@ export default function PluginsPage() {
const handleImportPlugin = () => {
return async () => {
setImporting(true);
const id = showNotification({
title: 'Uploading plugin',
message: 'Backend may restart; please wait…',
loading: true,
autoClose: false,
withCloseButton: false,
});
try {
const resp = await importPlugin(importFile);
if (resp?.success && resp.plugin) {
setImported(resp.plugin);
usePluginStore.getState().invalidatePlugins();
updateNotification({
id,
loading: false,
color: 'green',
title: 'Imported',
message:
'Plugin imported. If the app briefly disconnected, it should be back now.',
autoClose: 3000,
});
} else {
updateNotification({
id,
loading: false,
color: 'red',
title: 'Import failed',
message: resp?.error || 'Unknown error',
autoClose: 5000,
});
}
} catch (e) {
// API.importPlugin already showed a concise error; just update the loading notice
updateNotification({
id,
loading: false,
color: 'red',
title: 'Import failed',
message:
(e?.body && (e.body.error || e.body.detail)) ||
e?.message ||
'Failed',
autoClose: 5000,
const run = async (overwrite) => {
setImporting(true);
const notifId = showNotification({
title: 'Uploading plugin',
message: 'Backend may restart; please wait…',
loading: true,
autoClose: false,
withCloseButton: false,
});
} finally {
setImporting(false);
}
try {
const resp = await importPlugin(importFile, overwrite, /* silent */ true);
if (resp?.success && resp.plugin) {
setImported({ ...resp.plugin, was_managed: resp.was_managed, was_overwrite: overwrite });
usePluginStore.getState().invalidatePlugins();
updateNotification({
id: notifId,
loading: false,
color: 'green',
title: 'Imported',
message:
'Plugin imported. If the app briefly disconnected, it should be back now.',
autoClose: 3000,
});
} else {
updateNotification({
id: notifId,
loading: false,
color: 'red',
title: 'Import failed',
message: resp?.error || 'Unknown error',
autoClose: 5000,
});
}
} catch (e) {
const msg =
(e?.body && (e.body.error || e.body.detail)) || e?.message || '';
if (!overwrite && /already exists/i.test(msg)) {
// Dismiss the loading toast before showing the confirm dialog
updateNotification({
id: notifId,
loading: false,
autoClose: 100,
withCloseButton: false,
});
const pluginName = msg.match(/'([^']+)'/)?.[1] || 'this plugin';
const confirmed = await requestConfirm(
'Plugin already exists',
`'${pluginName}' is already installed. Do you want to replace it?`
);
if (confirmed) {
await run(true);
}
} else {
updateNotification({
id: notifId,
loading: false,
color: 'red',
title: 'Import failed',
message: msg || 'Failed',
autoClose: 5000,
});
}
} finally {
setImporting(false);
}
};
await run(false);
};
};
@ -272,14 +391,19 @@ export default function PluginsPage() {
return (
<AppShellMain p={16}>
<Group justify="space-between" mb="md">
<Text fw={700} size="lg">
Plugins
</Text>
<Group gap="xs" align="center">
<Text fw={700} size="lg">
My Plugins
</Text>
{plugins.length > 0 && (
<Badge variant="light" color="gray" size="sm">{plugins.length} Plugins Installed</Badge>
)}
</Group>
<Group>
<Button size="xs" variant="light" onClick={showImportForm}>
Import Plugin
</Button>
<ActionIcon variant="light" onClick={handleReload} title="Reload">
<ActionIcon variant="light" onClick={handleReload} title="Reload" loading={reloading} disabled={reloading}>
<RefreshCcw size={18} />
</ActionIcon>
</Group>
@ -349,20 +473,30 @@ export default function PluginsPage() {
{imported && (
<Box>
<Divider my="sm" />
<Text fw={600}>{imported.name}</Text>
<Text size="sm" c="dimmed">
{imported.description}
</Text>
<Group justify="space-between" mt="sm" align="center">
<Text size="sm">Enable now</Text>
<Switch
size="sm"
checked={enableAfterImport}
onChange={(e) =>
setEnableAfterImport(e.currentTarget.checked)
}
/>
</Group>
<Alert color="blue" variant="light" mb="xs">
{imported.was_overwrite
? `'${imported.name}' was successfully overwritten.`
: `'${imported.name}' was successfully installed.`}
</Alert>
{imported.was_managed && (
<Alert color="orange" variant="light" mt="xs">
This plugin was previously managed by a repo. Manual
installation removes it from repo management, so it will no
longer receive update checks or version tracking.
</Alert>
)}
{imported.enabled === false && (
<Group justify="space-between" mt="sm" align="center">
<Text size="sm">Enable now</Text>
<Switch
size="sm"
checked={enableAfterImport}
onChange={(e) =>
setEnableAfterImport(e.currentTarget.checked)
}
/>
</Group>
)}
<Group justify="flex-end" mt="md">
<Button
variant="default"
@ -376,13 +510,14 @@ export default function PluginsPage() {
>
Done
</Button>
<Button
size="xs"
disabled={!enableAfterImport}
onClick={handleEnablePlugin()}
>
Enable
</Button>
{imported.enabled === false && enableAfterImport && (
<Button
size="xs"
onClick={handleEnablePlugin()}
>
Enable
</Button>
)}
</Group>
</Box>
)}
@ -398,6 +533,7 @@ export default function PluginsPage() {
}}
title="Enable third-party plugins?"
centered
zIndex={300}
>
<Stack>
<Text size="sm">
@ -444,6 +580,7 @@ export default function PluginsPage() {
}}
title={deleteTarget ? `Delete ${deleteTarget.name}?` : 'Delete Plugin'}
centered
zIndex={300}
>
<Stack>
<Text size="sm">
@ -479,6 +616,7 @@ export default function PluginsPage() {
onClose={() => handleConfirm(false)}
title={confirmConfig.title}
centered
zIndex={300}
>
<Stack>
<Text size="sm">{confirmConfig.message}</Text>

View file

@ -94,6 +94,41 @@ vi.mock('@mantine/core', async () => {
{children}
</button>
),
Badge: ({ children, color, variant, size, leftSection, style, onClick }) => (
<span data-color={color} data-variant={variant} data-size={size} style={style} onClick={onClick}>
{leftSection}{children}
</span>
),
Select: ({ value, onChange, data, label, placeholder, disabled }) => (
<div>
{label && <label>{label}</label>}
<select
value={value || ''}
onChange={(e) => onChange?.(e.target.value)}
disabled={disabled}
aria-label={label}
>
{(data || []).map((item) => (
<option key={item.value ?? item} value={item.value ?? item}>
{item.label ?? item}
</option>
))}
</select>
</div>
),
TextInput: ({ value, onChange, label, placeholder, disabled }) => (
<div>
{label && <label>{label}</label>}
<input
type="text"
value={value || ''}
onChange={(e) => onChange?.(e)}
placeholder={placeholder}
disabled={disabled}
aria-label={label}
/>
</div>
),
SimpleGrid: ({ children, cols }) => <div data-cols={cols}>{children}</div>,
Modal: ({ opened, onClose, title, children, size, centered }) =>
opened ? (
@ -166,10 +201,13 @@ describe('PluginsPage', () => {
const mockPluginStoreState = {
plugins: mockPlugins,
loading: false,
repos: [],
fetchPlugins: vi.fn(),
updatePlugin: vi.fn(),
removePlugin: vi.fn(),
invalidatePlugins: vi.fn(),
refreshRepo: vi.fn(),
fetchAvailablePlugins: vi.fn(),
};
beforeEach(() => {
@ -185,7 +223,7 @@ describe('PluginsPage', () => {
render(<PluginsPage />);
await waitFor(() => {
expect(screen.getByText('Plugins')).toBeInTheDocument();
expect(screen.getByText('My Plugins')).toBeInTheDocument();
expect(screen.getByText('Test Plugin 1')).toBeInTheDocument();
expect(screen.getByText('Test Plugin 2')).toBeInTheDocument();
});
@ -319,7 +357,7 @@ describe('PluginsPage', () => {
fireEvent.click(uploadButton);
await waitFor(() => {
expect(importPlugin).toHaveBeenCalledWith(file);
expect(importPlugin).toHaveBeenCalledWith(file, false, true);
expect(showNotification).toHaveBeenCalled();
expect(updateNotification).toHaveBeenCalled();
});
@ -362,6 +400,7 @@ describe('PluginsPage', () => {
name: 'New Plugin',
description: 'New Description',
ever_enabled: false,
enabled: false,
};
importPlugin.mockResolvedValue({
success: true,
@ -384,7 +423,7 @@ describe('PluginsPage', () => {
fireEvent.click(uploadButton);
await waitFor(() => {
expect(screen.getByText('New Plugin')).toBeInTheDocument();
expect(screen.getByText(/'New Plugin'/)).toBeInTheDocument();
expect(screen.getByText('Enable now')).toBeInTheDocument();
});
});
@ -395,6 +434,7 @@ describe('PluginsPage', () => {
name: 'New Plugin',
description: 'New Description',
ever_enabled: true,
enabled: false,
};
importPlugin.mockResolvedValue({
success: true,
@ -442,6 +482,7 @@ describe('PluginsPage', () => {
name: 'New Plugin',
description: 'New Description',
ever_enabled: false,
enabled: false,
};
importPlugin.mockResolvedValue({
success: true,
@ -489,6 +530,7 @@ describe('PluginsPage', () => {
name: 'New Plugin',
description: 'New Description',
ever_enabled: false,
enabled: false,
};
importPlugin.mockResolvedValue({
success: true,
@ -540,6 +582,7 @@ describe('PluginsPage', () => {
name: 'New Plugin',
description: 'New Description',
ever_enabled: false,
enabled: false,
};
importPlugin.mockResolvedValue({
success: true,
@ -589,10 +632,10 @@ describe('PluginsPage', () => {
describe('Reload', () => {
it('reloads plugins when reload button is clicked', async () => {
const invalidatePlugins = vi.fn();
const fetchPlugins = vi.fn().mockResolvedValue(undefined);
usePluginStore.getState = vi.fn(() => ({
...mockPluginStoreState,
invalidatePlugins,
fetchPlugins,
}));
render(<PluginsPage />);
@ -602,7 +645,7 @@ describe('PluginsPage', () => {
await waitFor(() => {
expect(reloadPlugins).toHaveBeenCalled();
expect(invalidatePlugins).toHaveBeenCalled();
expect(fetchPlugins).toHaveBeenCalled();
});
});
});

View file

@ -6,6 +6,12 @@ export const usePluginStore = create((set, get) => ({
loading: false,
error: null,
// Plugin repos (hub)
repos: [],
availablePlugins: [],
reposLoading: false,
availableLoading: false,
fetchPlugins: async () => {
set({ loading: true, error: null });
try {
@ -38,4 +44,74 @@ export const usePluginStore = create((set, get) => ({
set({ plugins: [] });
get().fetchPlugins();
},
// Repo management
fetchRepos: async () => {
set({ reposLoading: true });
try {
const repos = await API.getPluginRepos();
set({ repos: repos || [], reposLoading: false });
} catch {
set({ reposLoading: false });
}
},
addRepo: async (data) => {
const repo = await API.addPluginRepo(data);
set((state) => ({ repos: [...state.repos, repo] }));
return repo;
},
removeRepo: async (id) => {
await API.deletePluginRepo(id);
set((state) => ({ repos: state.repos.filter((r) => r.id !== id) }));
},
updateRepo: async (id, data) => {
const updated = await API.updatePluginRepo(id, data);
if (updated) {
set((state) => ({
repos: state.repos.map((r) => (r.id === id ? updated : r)),
}));
}
return updated;
},
refreshRepo: async (id) => {
const updated = await API.refreshPluginRepo(id);
if (updated) {
set((state) => ({
repos: state.repos.map((r) => (r.id === id ? updated : r)),
}));
}
return updated;
},
fetchAvailablePlugins: async () => {
set({ availableLoading: true });
try {
const plugins = await API.getAvailablePlugins();
set({ availablePlugins: plugins || [], availableLoading: false });
} catch {
set({ availableLoading: false });
}
},
installPlugin: async ({ repo_id, slug, version, download_url, sha256, min_dispatcharr_version, max_dispatcharr_version, prerelease }) => {
const result = await API.installPluginFromRepo({
repo_id,
slug,
version,
download_url,
sha256,
min_dispatcharr_version,
max_dispatcharr_version,
prerelease: prerelease === true,
});
if (result?.success) {
await get().fetchAvailablePlugins();
await get().fetchPlugins();
}
return result;
},
}));

View file

@ -9,8 +9,8 @@ export const runPluginAction = async (key, actionId) => {
export const setPluginEnabled = async (key, next) => {
return await API.setPluginEnabled(key, next);
};
export const importPlugin = async (importFile) => {
return await API.importPlugin(importFile);
export const importPlugin = async (importFile, overwrite = false, silent = false) => {
return await API.importPlugin(importFile, overwrite, silent);
};
export const reloadPlugins = async () => {
return await API.reloadPlugins();

View file

@ -205,7 +205,7 @@ describe('PluginsUtils', () => {
await PluginsUtils.importPlugin(importFile);
expect(API.importPlugin).toHaveBeenCalledWith(importFile);
expect(API.importPlugin).toHaveBeenCalledWith(importFile, false, false);
expect(API.importPlugin).toHaveBeenCalledTimes(1);
});
@ -227,7 +227,7 @@ describe('PluginsUtils', () => {
await PluginsUtils.importPlugin(importFile);
expect(API.importPlugin).toHaveBeenCalledWith(importFile);
expect(API.importPlugin).toHaveBeenCalledWith(importFile, false, false);
});
it('should handle FormData', async () => {
@ -236,7 +236,7 @@ describe('PluginsUtils', () => {
await PluginsUtils.importPlugin(formData);
expect(API.importPlugin).toHaveBeenCalledWith(formData);
expect(API.importPlugin).toHaveBeenCalledWith(formData, false, false);
});
it('should propagate API errors', async () => {