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 = (
: ) : undefined}
>
Official Repo
);
return signatureVerified != null ? (
{badge}
) : badge;
}
if (!repoName) return null;
const badge = (
: ) : undefined}
>
{repoName}
);
return signatureVerified != null ? (
{badge}
) : badge;
};
const StatusBadge = ({ status, deprecated, isPrerelease, isLatestDowngrade, installedSourceRepoName }) => {
if (status === 'installed') {
const baseLabel = isPrerelease ? 'Prerelease' : 'Installed';
if (!deprecated) {
return (
: }>
{baseLabel}
);
}
return (
}>
{baseLabel} · Deprecated
);
}
if (status === 'update_available') {
const baseLabel = isLatestDowngrade ? 'Newer Installed' : 'Update Available';
if (!deprecated) {
return (
: }>
{baseLabel}
);
}
return (
}>
{baseLabel} · Deprecated
);
}
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 (
: }>
{deprecated ? 'Installed · Deprecated' : 'Installed'}
);
}
if (deprecated) {
return (
}>
Deprecated
);
}
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 (
{plugin.name?.[0]?.toUpperCase()}
{plugin.name}
{plugin.author && (
{plugin.author}
)}
{plugin.description}
{plugin.latest_version && (
LATEST
v{plugin.latest_version}
)}
{plugin.license && (
LICENSE
{plugin.license}
)}
{plugin.min_dispatcharr_version && (
MIN
{plugin.min_dispatcharr_version}
)}
{plugin.max_dispatcharr_version && (
MAX
{plugin.max_dispatcharr_version}
)}
{plugin.last_updated && (
UPDATED
{new Date(plugin.last_updated).toLocaleDateString()}
)}
{!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 (
{label}
);
})()}
{meetsVersion && }
}
onClick={handleMoreInfo}
>
More Info
{(plugin.install_status === 'unmanaged') && plugin.latest_version && plugin.latest_url && (
: }
disabled={!meetsVersion || installing}
onClick={() => doInstall(latestInstallParams)}
>
{installing ? 'Installing...' : 'Overwrite'}
)}
{(plugin.install_status === 'different_repo') && plugin.latest_url && (
: }
disabled={!meetsVersion || installing}
onClick={() => doInstall(latestInstallParams)}
>
{installing ? 'Installing...' : 'Overwrite'}
)}
{(plugin.install_status === 'installed') && (
}
onClick={() => setUninstallConfirmOpen(true)}
>
Uninstall
)}
{(plugin.install_status === 'update_available') && (
: isLatestDowngrade ? : }
disabled={!meetsVersion || installing}
onClick={() => doInstall(latestInstallParams)}
>
{installing
? (isLatestDowngrade ? 'Downgrading...' : 'Updating...')
: (isLatestDowngrade ? 'Downgrade' : 'Update')}
)}
{(!plugin.install_status || plugin.install_status === 'not_installed') && plugin.latest_url && (
: }
disabled={!meetsVersion || installing}
onClick={() => doInstall(latestInstallParams)}
>
{installing ? 'Installing...' : 'Install'}
)}
{/* Detail Modal */}
{ setDetailOpen(false); onDetailClose?.(); }}
title={
{plugin.name?.[0]?.toUpperCase()}
{plugin.name}
}
size="lg"
>
setUninstallConfirmOpen(true)}
installStatus={plugin.install_status}
installedSourceRepoName={plugin.installed_source_repo_name}
repoId={plugin.repo_id}
slug={plugin.slug}
/>
{/* Deprecation warning modal */}
{ setDeprecationWarnOpen(false); setPendingDeprecatedInstall(null); }}
zIndex={300}
title={
Deprecated Plugin
}
size="sm"
>
{plugin.name} has been marked as deprecated by its maintainer.
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.
Do you still want to proceed?
}
onClick={confirmDeprecatedInstall}
>
Install Anyway
{/* 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 (
{ setConfirmOpen(false); setPendingInstall(null); }}
zIndex={300}
title={
{isBadSig
?
: isDowngrade
?
: }
Confirm {actionLabel}
}
size="sm"
>
You are about to {actionLabel.toLowerCase()} {plugin.name}{' '}
{isUpdate || isDowngrade
? <>from v{plugin.installed_version} to v{pendingInstall?.version}>
: <>v{pendingInstall?.version}>}
{plugin.repo_name ? <> from {plugin.repo_name}> : ''}.
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.
{isDowngrade && (
Warning: Downgrading may cause issues with saved settings or data.
)}
{isBadSig && (
Warning: This repository has an invalid or unverified signature.
Installing plugins from unverified sources may be risky.
)}
{plugin.install_status === 'unmanaged' && (
Note: This plugin was installed manually. Installing from this repo
will bring it under repo management and enable future update checks.
)}
{plugin.install_status === 'different_repo' && (
Note: This plugin is currently managed
by {plugin.installed_source_repo_name || 'another repo'}.
Installing will transfer management to this repo.
)}
Are you sure you want to proceed?
);
})()}
{/* Uninstall confirmation modal */}
setUninstallConfirmOpen(false)}
zIndex={300}
title={
Uninstall Plugin
}
size="sm"
>
Are you sure you want to uninstall {plugin.name}? This will
remove the plugin files and all associated settings.
{/* Post-uninstall notice */}
setUninstallDoneOpen(false)}
zIndex={300}
title={
Plugin Uninstalled
}
size="sm"
>
{plugin.name} has been uninstalled successfully.
A restart of Dispatcharr may be required to fully unload the plugin.
{/* Post-install restart prompt */}
setRestartPromptOpen(false)}
zIndex={300}
title={
Plugin {installAction === 'installed' ? 'Installed' : installAction === 'downgraded' ? 'Downgraded' : 'Updated'}
}
size="sm"
>
{plugin.name} has been {installAction || 'installed'} successfully.
A restart of Dispatcharr may be required for the plugin to be fully loaded.
{pluginIsDisabled && (
<>
This plugin is currently disabled. You can enable it now or at any time from My Plugins.
Enable plugin
setEnableNow(e.currentTarget.checked)}
/>
>
)}
{!onMyPlugins && (
)}
);
};
export default AvailablePluginCard;