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

[Feature]: Add filesizes to plugin hub
This commit is contained in:
SergeantPanda 2026-04-17 15:10:51 -05:00 committed by GitHub
commit 4c19eb0dc6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 868 additions and 361 deletions

View file

@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **Plugin file sizes**: the plugin hub now displays the download size of a plugin directly on the Install / Update / Downgrade / Overwrite buttons (e.g. `Install 142 KB`) when the repository manifest includes size data. The size is also shown in the version detail panel. Falls back gracefully to a plain button when no size is provided. A `formatKB` utility was added to convert raw KB values to human-readable strings (KB/MB). A "Publish Your Plugin" button linking to the contributing guide was added to the plugin store toolbar. — Thanks [@sethwv](https://github.com/sethwv)
### Performance
- Eliminated repeated DB queries in the `ts_proxy` hot path. `StreamManager`, `StreamGenerator`, and `ProxyServer` were each calling `Channel.objects.get(uuid=...)` on every retry, reconnect, failover, and buffering event solely to retrieve `channel.name` for log events. `StreamManager` and `StreamGenerator` now fetch the channel name once at construction via a lightweight `values_list` query and store it as `self.channel_name`. `ProxyServer` caches the name in a `_channel_names` dict keyed by channel ID at channel-start time and pops it at channel-stop time. (Fixes #1138)

View file

@ -55,12 +55,17 @@ This is the simplest valid repo manifest - one plugin with enough info to show i
"name": "Weather Display",
"description": "Shows weather info on the dashboard",
"author": "Acme Labs",
"maintainers": ["alice", "bob"],
"license": "MIT",
"deprecated": false,
"repo_url": "https://github.com/acmelabs/dispatcharr-weather",
"discord_thread": "https://discord.com/channels/123456/789012",
"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",
"latest_size": 142,
"icon_url": "plugins/weather_display/logo.png",
"min_dispatcharr_version": "2.5.0",
"max_dispatcharr_version": null
@ -128,13 +133,18 @@ If the name contains any of these, the repo will be rejected on add and skipped
| `name` | **Yes** | Human-readable display name. |
| `description` | No | Short description shown on the plugin card. |
| `author` | No | Author or organization name. |
| `maintainers` | No | Array of maintainer GitHub usernames (e.g. `["alice", "bob"]`). Shown in the detail view. |
| `license` | No | SPDX license identifier (e.g. `MIT`, `GPL-3.0`). Displayed as a link to the SPDX license page. |
| `deprecated` | No | Boolean. When `true`, marks the plugin as deprecated in the store UI. Omit or set to `false` for active plugins. |
| `repo_url` | No | URL to the plugin's source code repository (e.g. GitHub). |
| `discord_thread` | No | URL to a Discord thread or channel for plugin support. Must start with `http://` or `https://`. |
| `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. |
| `latest_size` | No | Size of the latest release zip in kilobytes. Informational only. |
| `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. |
@ -216,11 +226,14 @@ Use the wrapped format if you want to GPG-sign the per-plugin manifest.
"author": "Acme Labs",
"license": "MIT",
"latest_version": "1.2.5",
"registry_name": "Acme Labs Plugins",
"registry_url": "https://github.com/acmelabs/dispatcharr-plugins",
"versions": [
{
"version": "1.2.5",
"url": "releases/weather_display-1.2.5.zip",
"checksum_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"size": 142,
"build_timestamp": "2025-01-20T15:30:00Z",
"commit_sha": "4e8f1b108c1e84f60520710d13e54eb2fb519648",
"commit_sha_short": "4e8f1b1",
@ -231,6 +244,7 @@ Use the wrapped format if you want to GPG-sign the per-plugin manifest.
"version": "1.2.5-rc.1",
"url": "releases/weather_display-1.2.5-rc.1.zip",
"checksum_sha256": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
"size": 141,
"prerelease": true,
"build_timestamp": "2025-01-18T09:00:00Z",
"min_dispatcharr_version": "2.5.0"
@ -239,6 +253,7 @@ Use the wrapped format if you want to GPG-sign the per-plugin manifest.
"version": "1.2.4",
"url": "releases/weather_display-1.2.4.zip",
"checksum_sha256": "d4d967a67a4947e55183308cece206b30dda3e1b4fe00aae60f45a49c83b7ed6",
"size": 138,
"build_timestamp": "2025-01-15T10:00:00Z",
"min_dispatcharr_version": "2.4.0"
}
@ -246,7 +261,9 @@ Use the wrapped format if you want to GPG-sign the per-plugin manifest.
"latest": {
"version": "1.2.5",
"url": "releases/weather_display-1.2.5.zip",
"latest_url": "releases/weather_display-latest.zip",
"checksum_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"size": 142,
"build_timestamp": "2025-01-20T15:30:00Z",
"min_dispatcharr_version": "2.5.0"
}
@ -263,8 +280,10 @@ Use the wrapped format if you want to GPG-sign the per-plugin manifest.
| `author` | No | Author/org name shown in the detail modal. |
| `license` | No | SPDX license identifier. |
| `latest_version` | No | Latest version string. |
| `registry_name` | No | Registry name inherited from the parent repo manifest. Injected automatically by the official publish tooling. |
| `registry_url` | No | Registry URL inherited from the parent repo manifest. Used by the store to build commit links. Injected automatically by the official publish tooling. |
| `versions` | No | Array of version objects (newest first recommended). |
| `latest` | No | Object mirroring the latest version entry for quick access. |
| `latest` | No | Object mirroring the latest version entry for quick access. Accepts all the same fields as a version object. Additionally, `latest_url` may appear here pointing to a stable symlink (e.g. `plugin-latest.zip`) that always resolves to the newest release. |
### Version Object Fields
@ -277,6 +296,7 @@ Use the wrapped format if you want to GPG-sign the per-plugin manifest.
| `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. |
| `size` | No | Size of this version's zip in kilobytes. Informational only. |
| `min_dispatcharr_version` | No | Minimum compatible Dispatcharr version. |
| `max_dispatcharr_version` | No | Maximum compatible Dispatcharr version. |
@ -536,12 +556,18 @@ You can host release zips as GitHub Release assets and reference them with absol
"name": "string (required)",
"description": "string",
"author": "string",
"maintainers": ["string"],
"license": "string (SPDX)",
"deprecated": "boolean",
"repo_url": "string (URL)",
"discord_thread": "string (URL)",
"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)",
"latest_md5": "string",
"latest_size": "number (KB)",
"icon_url": "string (URL or relative path)",
"min_dispatcharr_version": "string (semver)",
"max_dispatcharr_version": "string (semver) or null"
@ -562,11 +588,15 @@ You can host release zips as GitHub Release assets and reference them with absol
"author": "string",
"license": "string (SPDX)",
"latest_version": "string (semver)",
"registry_name": "string",
"registry_url": "string (URL)",
"versions": [
{
"version": "string (required)",
"url": "string (required, URL or relative path)",
"checksum_sha256": "string (64-char hex)",
"size": "number (KB)",
"prerelease": "boolean",
"build_timestamp": "string (ISO 8601)",
"commit_sha": "string",
"commit_sha_short": "string",
@ -577,7 +607,9 @@ You can host release zips as GitHub Release assets and reference them with absol
"latest": {
"version": "string",
"url": "string",
"latest_url": "string (stable symlink URL)",
"checksum_sha256": "string",
"size": "number (KB)",
"build_timestamp": "string",
"min_dispatcharr_version": "string",
"max_dispatcharr_version": "string or null"

View file

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React from 'react';
import {
ActionIcon,
Alert,
@ -12,8 +12,17 @@ import {
Text,
Tooltip,
} from '@mantine/core';
import { AlertTriangle, Ban, Check, Download, RefreshCw, ShieldAlert, ShieldCheck, Trash2 } from 'lucide-react';
import {
AlertTriangle,
Ban,
Download,
RefreshCw,
ShieldAlert,
ShieldCheck,
Trash2,
} from 'lucide-react';
import { compareVersions } from './pluginUtils.js';
import { formatKB } from '../utils/networkUtils.js';
export const GitHubIcon = ({ size = 16 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
@ -68,13 +77,19 @@ const PluginDetailPanel = ({
return (
<Stack align="center" py="xl">
<Loader size="sm" />
<Text size="sm" c="dimmed">Loading plugin details</Text>
<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>;
return (
<Text size="sm" c="dimmed">
Failed to load plugin details.
</Text>
);
}
const manifest = detail.manifest;
@ -82,19 +97,28 @@ const PluginDetailPanel = ({
(v) => v.version === selectedVersion
);
const isSelSame = installedVersion && selectedVersion &&
const isSelSame =
installedVersion &&
selectedVersion &&
compareVersions(selectedVersion, installedVersion) === 0;
const isSelDowngrade = installedVersion && selectedVersion &&
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 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 isOverwrite =
installStatus === 'unmanaged' || installStatus === 'different_repo';
const handleInstallClick = () => {
if (isSelSame && onUninstall) {
@ -122,9 +146,10 @@ const PluginDetailPanel = ({
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`,
tooltip:
installStatus === 'unmanaged'
? 'Installed manually installing will take over management'
: `Managed by ${installedSourceRepoName || 'another repo'} installing will transfer management to this repo`,
};
}
if (isSelSame) {
@ -168,13 +193,13 @@ const PluginDetailPanel = ({
};
const btnProps = getButtonProps();
const btnDisabled = (isSelSame ? uninstalling : (!selCompatible || installing || !selectedVersionData?.url));
const btnDisabled = isSelSame
? uninstalling
: !selCompatible || installing || !selectedVersionData?.url;
return (
<Stack gap="md">
{manifest.description && (
<Text size="sm">{manifest.description}</Text>
)}
{manifest.description && <Text size="sm">{manifest.description}</Text>}
<Group gap="xs" wrap="wrap">
{manifest.author && (
@ -197,8 +222,8 @@ const PluginDetailPanel = ({
{manifest.license}
</Badge>
)}
{detail.signature_verified != null && (
detail.signature_verified ? (
{detail.signature_verified != null &&
(detail.signature_verified ? (
<Badge
size="sm"
variant="default"
@ -217,8 +242,7 @@ const PluginDetailPanel = ({
Unverified
</Badge>
</Tooltip>
)
)}
))}
{manifest.repo_url && (
<Tooltip label="Source Repository">
<ActionIcon
@ -234,25 +258,36 @@ const PluginDetailPanel = ({
</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>
);
})()}
{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 && (
@ -262,61 +297,77 @@ const PluginDetailPanel = ({
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.
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)' : ''}`;
{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);
let versions = [...manifest.versions];
if (installedVersionIsPrerelease) {
const prereleases = versions.filter((v) => v.prerelease);
const stable = versions.filter((v) => !v.prerelease);
versions = [...prereleases, ...stable];
}
}
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}>
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}
@ -327,102 +378,140 @@ const PluginDetailPanel = ({
>
{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"
)}
{!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})`}
>
{selectedVersionData.commit_sha_short}
</Text>
) : (
selectedVersionData.commit_sha_short
<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>
)}
{Number.isFinite(selectedVersionData.size) &&
selectedVersionData.size > 0 && (
<Table.Tr>
<Table.Td fw={500} style={{ whiteSpace: 'nowrap' }}>
File Size
</Table.Td>
<Table.Td>
{formatKB(selectedVersionData.size)}
</Table.Td>
</Table.Tr>
)}
</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>
)}
</>
);
})()}
{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>
);
};

View file

@ -14,12 +14,25 @@ import {
Text,
Tooltip,
} from '@mantine/core';
import { AlertTriangle, Ban, Check, Download, FlaskConical, Info, RefreshCw, RotateCcw, ShieldAlert, ShieldCheck, Trash2 } from 'lucide-react';
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';
import SizedInstallButton from '../theme/SizedInstallButton.jsx';
const RepoBadge = ({ isOfficial, repoName, signatureVerified }) => {
if (isOfficial) {
@ -27,15 +40,34 @@ const RepoBadge = ({ isOfficial, repoName, signatureVerified }) => {
<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}
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;
<Tooltip
label={signatureVerified ? 'Verified Signature' : 'Invalid Signature'}
>
{badge}
</Tooltip>
) : (
badge
);
}
if (!repoName) return null;
const badge = (
@ -43,58 +75,118 @@ const RepoBadge = ({ isOfficial, repoName, signatureVerified }) => {
size="xs"
variant="filled"
color={signatureVerified === false ? 'red.9' : 'gray'}
leftSection={signatureVerified != null ? (signatureVerified ? <ShieldCheck size={10} /> : <ShieldAlert size={10} />) : undefined}
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;
<Tooltip
label={signatureVerified ? 'Verified Signature' : 'Invalid Signature'}
>
{badge}
</Tooltip>
) : (
badge
);
};
const StatusBadge = ({ status, deprecated, isPrerelease, isLatestDowngrade, installedSourceRepoName }) => {
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} />}>
<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} />}>
<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';
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} />}>
<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} />}>
<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)' : ''}`;
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} />}>
<Badge
size="xs"
variant="light"
color={deprecated ? 'red' : 'orange'}
leftSection={deprecated ? <Ban size={8} /> : <Check size={8} />}
>
{deprecated ? 'Installed · Deprecated' : 'Installed'}
</Badge>
</Tooltip>
@ -103,7 +195,12 @@ const StatusBadge = ({ status, deprecated, isPrerelease, isLatestDowngrade, inst
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} />}>
<Badge
size="xs"
variant="light"
color="red"
leftSection={<Ban size={8} />}
>
Deprecated
</Badge>
</Tooltip>
@ -112,9 +209,22 @@ const StatusBadge = ({ status, deprecated, isPrerelease, isLatestDowngrade, inst
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 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);
@ -133,14 +243,17 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
const [uninstallConfirmOpen, setUninstallConfirmOpen] = useState(false);
const [uninstalling, setUninstalling] = useState(false);
const [deprecationWarnOpen, setDeprecationWarnOpen] = useState(false);
const [pendingDeprecatedInstall, setPendingDeprecatedInstall] = useState(null);
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 &&
const isLatestDowngrade =
plugin.install_status === 'update_available' &&
plugin.latest_version &&
plugin.installed_version &&
compareVersions(plugin.latest_version, plugin.installed_version) < 0;
const doInstall = (params) => {
@ -169,7 +282,9 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
const executeInstall = async (params) => {
const wasInstalled = plugin.installed;
const wasDowngrade = plugin.installed_version && params.version &&
const wasDowngrade =
plugin.installed_version &&
params.version &&
compareVersions(params.version, plugin.installed_version) < 0;
onBeforeInstall?.(plugin.slug);
setInstalling(true);
@ -177,7 +292,9 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
setInstalling(false);
setPendingInstall(null);
if (result?.success) {
setInstallAction(wasDowngrade ? 'downgraded' : wasInstalled ? 'updated' : 'installed');
setInstallAction(
wasDowngrade ? 'downgraded' : wasInstalled ? 'updated' : 'installed'
);
setInstalledKey(result.plugin?.key || params.slug);
setPluginIsDisabled(result.plugin?.enabled === false);
setEnableNow(false);
@ -229,15 +346,22 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
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,
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,
size: plugin.latest_size,
},
]
: [],
latest: plugin.latest_version
? { version: plugin.latest_version }
: null,
},
signature_verified: plugin.signature_verified ?? null,
});
@ -245,7 +369,10 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
return;
}
setDetailLoading(true);
const result = await API.getPluginDetailManifest(plugin.repo_id, plugin.manifest_url);
const result = await API.getPluginDetailManifest(
plugin.repo_id,
plugin.manifest_url
);
if (result) {
setDetail(result);
if (result.manifest?.versions?.length) {
@ -257,7 +384,7 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
React.useEffect(() => {
if (autoOpenDetail) handleMoreInfo();
// eslint-disable-next-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const latestInstallParams = {
@ -280,16 +407,24 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
flexDirection: 'column',
minHeight: 220,
backgroundColor: '#27272A',
...(multiRepo && plugin.is_official_repo ? { borderColor: '#0e6459' } : {}),
...(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 }}>
<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`}
imageProps={{ draggable: false }}
>
{plugin.name?.[0]?.toUpperCase()}
</Avatar>
@ -299,7 +434,12 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
</Text>
<Group gap={6} align="center" wrap="nowrap">
{plugin.author && (
<Text size="xs" c="dimmed" truncate style={{ minWidth: 0, maxWidth: '100%' }}>
<Text
size="xs"
c="dimmed"
truncate
style={{ minWidth: 0, maxWidth: '100%' }}
>
{plugin.author}
</Text>
)}
@ -322,7 +462,15 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
</Group>
</Group>
<Box style={{ flex: 1, minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<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}
@ -330,11 +478,11 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
</div>
<Stack gap={2} mt="auto" pt={4} style={{ flexShrink: 0 }}>
<Group gap="xs" wrap="wrap">
<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}
<span style={{ opacity: 0.5, marginRight: 4 }}>LATEST</span>v
{plugin.latest_version}
</Badge>
)}
{plugin.license && (
@ -374,103 +522,144 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
</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 &&
(() => {
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="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">
<SizedInstallButton
size="xs"
variant="filled"
color="orange"
latest_size={plugin.latest_size}
leftSection={
installing ? <Loader size={14} /> : <Download size={14} />
}
disabled={!meetsVersion || installing}
onClick={() => doInstall(latestInstallParams)}
>
{installing ? 'Installing...' : 'Overwrite'}
</SizedInstallButton>
</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`}
>
<SizedInstallButton
size="xs"
variant="filled"
color="orange"
latest_size={plugin.latest_size}
leftSection={
installing ? <Loader size={14} /> : <Download size={14} />
}
disabled={!meetsVersion || installing}
onClick={() => doInstall(latestInstallParams)}
>
{installing ? 'Installing...' : 'Overwrite'}
</SizedInstallButton>
</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' && (
<SizedInstallButton
size="xs"
variant="filled"
color="orange"
leftSection={installing ? <Loader size={14} /> : <Download size={14} />}
color={isLatestDowngrade ? 'orange' : 'yellow'}
latest_size={plugin.latest_size}
leftSection={
installing ? (
<Loader size={14} />
) : isLatestDowngrade ? (
<AlertTriangle size={14} />
) : (
<RefreshCw 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>
)}
{installing
? isLatestDowngrade
? 'Downgrading...'
: 'Updating...'
: isLatestDowngrade
? 'Downgrade'
: 'Update'}
</SizedInstallButton>
)}
{(!plugin.install_status ||
plugin.install_status === 'not_installed') &&
plugin.latest_url && (
<SizedInstallButton
size="xs"
variant="filled"
latest_size={plugin.latest_size}
leftSection={
installing ? <Loader size={14} /> : <Download size={14} />
}
disabled={!meetsVersion || installing}
onClick={() => doInstall(latestInstallParams)}
>
{installing ? 'Installing...' : 'Install'}
</SizedInstallButton>
)}
</Group>
</Group>
{/* Detail Modal */}
<Modal
opened={detailOpen}
onClose={() => { setDetailOpen(false); onDetailClose?.(); }}
onClose={() => {
setDetailOpen(false);
onDetailClose?.();
}}
title={
<Group gap="xs" align="center">
<Avatar
@ -478,6 +667,7 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
radius="sm"
size={28}
alt={`${plugin.name} logo`}
imageProps={{ draggable: false }}
>
{plugin.name?.[0]?.toUpperCase()}
</Avatar>
@ -485,7 +675,9 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
<RepoBadge
isOfficial={plugin.is_official_repo}
repoName={plugin.repo_name}
signatureVerified={detail?.signature_verified ?? plugin.signature_verified}
signatureVerified={
detail?.signature_verified ?? plugin.signature_verified
}
/>
</Group>
}
@ -497,7 +689,9 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
selectedVersion={selectedVersion}
onVersionChange={setSelectedVersion}
installedVersion={plugin.installed_version}
installedVersionIsPrerelease={!!plugin.installed_version_is_prerelease}
installedVersionIsPrerelease={
!!plugin.installed_version_is_prerelease
}
appVersion={appVersion}
installing={installing}
uninstalling={uninstalling}
@ -513,7 +707,10 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
{/* Deprecation warning modal */}
<Modal
opened={deprecationWarnOpen}
onClose={() => { setDeprecationWarnOpen(false); setPendingDeprecatedInstall(null); }}
onClose={() => {
setDeprecationWarnOpen(false);
setPendingDeprecatedInstall(null);
}}
zIndex={300}
title={
<Group gap="xs" align="center">
@ -525,18 +722,25 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
>
<Stack gap="md">
<Text size="sm">
<b>{plugin.name}</b> has been marked as <b>deprecated</b> by its maintainer.
<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.
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>
<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); }}
onClick={() => {
setDeprecationWarnOpen(false);
setPendingDeprecatedInstall(null);
}}
>
Cancel
</Button>
@ -554,26 +758,49 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
{/* Unified install confirmation modal */}
{(() => {
const isDowngrade = pendingInstall && plugin.installed_version &&
const isDowngrade =
pendingInstall &&
plugin.installed_version &&
compareVersions(pendingInstall.version, plugin.installed_version) < 0;
const isUpdate = pendingInstall && plugin.installed_version &&
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;
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); }}
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} />}
{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>
}
@ -581,55 +808,76 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
>
<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></> : ''}.
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.
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.
<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.
<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.
<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>.
<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>
<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); }}
onClick={() => {
setConfirmOpen(false);
setPendingInstall(null);
}}
>
Cancel
</Button>
<Button
size="xs"
color={btnColor}
onClick={confirmAndInstall}
>
<Button size="xs" color={btnColor} onClick={confirmAndInstall}>
{actionLabel}
</Button>
</Group>
@ -697,7 +945,11 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
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)}>
<Button
size="xs"
variant="default"
onClick={() => setUninstallDoneOpen(false)}
>
Done
</Button>
</Group>
@ -713,7 +965,12 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
<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'}
Plugin{' '}
{installAction === 'installed'
? 'Installed'
: installAction === 'downgraded'
? 'Downgraded'
: 'Updated'}
</Text>
</Group>
}
@ -721,15 +978,18 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
>
<Stack gap="md">
<Text size="sm">
<b>{plugin.name}</b> has been {installAction || 'installed'} successfully.
<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.
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.
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>

View file

@ -177,6 +177,7 @@ const PluginCard = ({
min_dispatcharr_version: avail.min_dispatcharr_version,
max_dispatcharr_version: avail.max_dispatcharr_version,
build_timestamp: avail.last_updated,
size: avail.latest_size,
}] : [],
latest: avail.latest_version ? { version: avail.latest_version } : null,
},
@ -363,6 +364,7 @@ const PluginCard = ({
alt={`${plugin.name} logo`}
onClick={isManaged ? () => openModal('details') : undefined}
style={isManaged ? { cursor: 'pointer' } : undefined}
imageProps={{ draggable: false }}
>
{plugin.name?.[0]?.toUpperCase()}
</Avatar>
@ -529,7 +531,7 @@ const PluginCard = ({
onClose={() => setModalOpen(false)}
title={
<Group gap="xs" align="center">
<Avatar src={plugin.logo_url} radius="sm" size={28} alt={`${plugin.name} logo`}>
<Avatar src={plugin.logo_url} radius="sm" size={28} alt={`${plugin.name} logo`} imageProps={{ draggable: false }}>
{plugin.name?.[0]?.toUpperCase()}
</Avatar>
<Text fw={600}>{plugin.name}</Text>

View file

@ -0,0 +1,97 @@
import React, { useState } from 'react';
import { Box, Button, Group } from '@mantine/core';
import { formatKB } from '../../utils/networkUtils.js';
const SizedInstallButton = ({
latest_size,
children,
color,
loading,
disabled,
onClick,
...buttonProps
}) => {
const [hovered, setHovered] = useState(false);
if (!Number.isFinite(latest_size) || latest_size <= 0) {
return (
<Button
color={color}
loading={loading}
disabled={disabled}
onClick={onClick}
{...buttonProps}
>
{children}
</Button>
);
}
const isDisabled = disabled || loading;
const colorVar = color
? `var(--mantine-color-${color}-filled)`
: 'var(--mantine-primary-color-filled)';
return (
<Group
gap={0}
align="stretch"
wrap="nowrap"
onMouseEnter={() => {
if (!isDisabled) setHovered(true);
}}
onMouseLeave={() => setHovered(false)}
>
<Button
color={color}
loading={loading}
disabled={disabled}
onClick={onClick}
styles={
hovered && !isDisabled
? {
root: {
background: color
? `var(--mantine-color-${color}-filled-hover)`
: 'var(--mantine-primary-color-filled-hover)',
},
}
: undefined
}
{...buttonProps}
style={{ borderTopRightRadius: 0, borderBottomRightRadius: 0 }}
>
{children}
</Button>
<Box
onClick={!isDisabled ? onClick : undefined}
style={{
background: isDisabled
? colorVar
: hovered
? color
? `var(--mantine-color-${color}-filled-hover)`
: 'var(--mantine-primary-color-filled-hover)'
: colorVar,
filter: isDisabled
? 'brightness(0.65) saturate(0.7)'
: hovered
? 'brightness(0.86)'
: 'brightness(0.82)',
borderLeft: '1px solid rgba(0,0,0,0.2)',
borderTopRightRadius: 'var(--mantine-radius-sm)',
borderBottomRightRadius: 'var(--mantine-radius-sm)',
display: 'flex',
alignItems: 'center',
padding: '0 9px',
fontSize: 11,
color: '#fff',
cursor: isDisabled ? 'default' : 'pointer',
userSelect: 'none',
whiteSpace: 'nowrap',
}}
>
{formatKB(latest_size)}
</Box>
</Group>
);
};
export default SizedInstallButton;

View file

@ -26,6 +26,7 @@ import {
KeyRound,
ShieldCheck,
ShieldAlert,
Package,
} from 'lucide-react';
import { usePluginStore } from '../store/plugins.jsx';
import useSettingsStore from '../store/settings.jsx';
@ -364,6 +365,18 @@ export default function PluginBrowsePage() {
)}
</Group>
<Group>
<Button
size="xs"
variant="light"
color="teal"
component="a"
href="https://github.com/Dispatcharr/Plugins?tab=contributing-ov-file"
target="_blank"
rel="noopener noreferrer"
leftSection={<Package size={14} />}
>
Publish Your Plugin
</Button>
<Button
size="xs"
variant="light"

View file

@ -15,6 +15,16 @@ export function formatBytes(bytes) {
return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + sizes[i];
}
// Converts a size in KB (as used in plugin manifests) to a human-readable string.
// Trailing zeros are stripped (e.g. "1 KB" not "1.00 KB").
export function formatKB(kb) {
const bytes = kb * 1024;
if (bytes === 0) return '0 Bytes';
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return parseFloat((bytes / Math.pow(1024, i)).toFixed(2)) + ' ' + sizes[i];
}
export function formatSpeed(bytes) {
if (bytes === 0) return '0 Bytes';