mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-17 16:50:53 +00:00
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
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:
commit
d8d0695963
26 changed files with 4856 additions and 474 deletions
586
Plugin_repo.md
Normal file
586
Plugin_repo.md
Normal 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"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
@ -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
|
|
@ -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)"
|
||||
)
|
||||
|
|
|
|||
11
apps/plugins/keys/dispatcharr-plugins.pub
Normal file
11
apps/plugins/keys/dispatcharr-plugins.pub
Normal 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-----
|
||||
|
|
@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
84
apps/plugins/migrations/0002_pluginrepo.py
Normal file
84
apps/plugins/migrations/0002_pluginrepo.py
Normal 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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
33
apps/plugins/tasks.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
430
frontend/src/components/PluginDetailPanel.jsx
Normal file
430
frontend/src/components/PluginDetailPanel.jsx
Normal 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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
769
frontend/src/components/cards/AvailablePluginCard.jsx
Normal file
769
frontend/src/components/cards/AvailablePluginCard.jsx
Normal 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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 ───────────────────────────────────────────────────────
|
||||
|
|
|
|||
25
frontend/src/components/pluginUtils.js
Normal file
25
frontend/src/components/pluginUtils.js
Normal 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;
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
729
frontend/src/pages/PluginBrowse.jsx
Normal file
729
frontend/src/pages/PluginBrowse.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue