f56b088643
Base Image Build / 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
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
770 lines
28 KiB
React
770 lines
28 KiB
React
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;
|