mirror of
https://github.com/idanoo/autobrr
synced 2025-07-23 00:39:13 +00:00
feat: add support for proxies to use with IRC and Indexers (#1421)
* feat: add support for proxies * fix(http): release handler * fix(migrations): define proxy early * fix(migrations): pg proxy * fix(proxy): list update delete * fix(proxy): remove log and imports * feat(irc): use proxy * feat(irc): tests * fix(web): update imports for ProxyForms.tsx * fix(database): migration * feat(proxy): test * feat(proxy): validate proxy type * feat(proxy): validate and test * feat(proxy): improve validate and test * feat(proxy): fix db schema * feat(proxy): add db tests * feat(proxy): handle http errors * fix(http): imports * feat(proxy): use proxy for indexer downloads * feat(proxy): indexerforms select proxy * feat(proxy): handle torrent download * feat(proxy): skip if disabled * feat(proxy): imports * feat(proxy): implement in Feeds * feat(proxy): update helper text indexer proxy * feat(proxy): add internal cache
This commit is contained in:
parent
472d327308
commit
bc0f4cc055
59 changed files with 2533 additions and 371 deletions
|
@ -389,6 +389,20 @@ export const APIClient = {
|
|||
body: notification
|
||||
})
|
||||
},
|
||||
proxy: {
|
||||
list: () => appClient.Get<Proxy[]>("api/proxy"),
|
||||
getByID: (id: number) => appClient.Get<Proxy>(`api/proxy/${id}`),
|
||||
store: (proxy: ProxyCreate) => appClient.Post("api/proxy", {
|
||||
body: proxy
|
||||
}),
|
||||
update: (proxy: Proxy) => appClient.Put(`api/proxy/${proxy.id}`, {
|
||||
body: proxy
|
||||
}),
|
||||
delete: (id: number) => appClient.Delete(`api/proxy/${id}`),
|
||||
test: (proxy: Proxy) => appClient.Post("api/proxy/test", {
|
||||
body: proxy
|
||||
})
|
||||
},
|
||||
release: {
|
||||
find: (query?: string) => appClient.Get<ReleaseFindResponse>(`api/release${query}`),
|
||||
findRecent: () => appClient.Get<ReleaseFindResponse>("api/release/recent"),
|
||||
|
|
|
@ -11,7 +11,7 @@ import {
|
|||
FeedKeys,
|
||||
FilterKeys,
|
||||
IndexerKeys,
|
||||
IrcKeys, NotificationKeys,
|
||||
IrcKeys, NotificationKeys, ProxyKeys,
|
||||
ReleaseKeys,
|
||||
SettingsKeys
|
||||
} from "@api/query_keys";
|
||||
|
@ -137,3 +137,17 @@ export const ReleasesIndexersQueryOptions = () =>
|
|||
placeholderData: keepPreviousData,
|
||||
staleTime: Infinity
|
||||
});
|
||||
|
||||
export const ProxiesQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ProxyKeys.lists(),
|
||||
queryFn: () => APIClient.proxy.list(),
|
||||
refetchOnWindowFocus: false
|
||||
});
|
||||
|
||||
export const ProxyByIdQueryOptions = (proxyId: number) =>
|
||||
queryOptions({
|
||||
queryKey: ProxyKeys.detail(proxyId),
|
||||
queryFn: async ({queryKey}) => await APIClient.proxy.getByID(queryKey[2]),
|
||||
retry: false,
|
||||
});
|
||||
|
|
|
@ -79,4 +79,11 @@ export const NotificationKeys = {
|
|||
lists: () => [...NotificationKeys.all, "list"] as const,
|
||||
details: () => [...NotificationKeys.all, "detail"] as const,
|
||||
detail: (id: number) => [...NotificationKeys.details(), id] as const
|
||||
};
|
||||
};
|
||||
|
||||
export const ProxyKeys = {
|
||||
all: ["proxy"] as const,
|
||||
lists: () => [...ProxyKeys.all, "list"] as const,
|
||||
details: () => [...ProxyKeys.all, "detail"] as const,
|
||||
detail: (id: number) => [...ProxyKeys.details(), id] as const
|
||||
};
|
||||
|
|
|
@ -17,6 +17,7 @@ interface SelectFieldProps<T> {
|
|||
label: string;
|
||||
help?: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
defaultValue?: OptionBasicTyped<T>;
|
||||
tooltip?: JSX.Element;
|
||||
options: OptionBasicTyped<T>[];
|
||||
|
@ -158,7 +159,7 @@ export function SelectField<T>({ name, label, help, placeholder, options }: Sele
|
|||
);
|
||||
}
|
||||
|
||||
export function SelectFieldBasic<T>({ name, label, help, placeholder, tooltip, defaultValue, options }: SelectFieldProps<T>) {
|
||||
export function SelectFieldBasic<T>({ name, label, help, placeholder, required, tooltip, defaultValue, options }: SelectFieldProps<T>) {
|
||||
return (
|
||||
<div className="space-y-1 p-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<div>
|
||||
|
@ -182,6 +183,7 @@ export function SelectFieldBasic<T>({ name, label, help, placeholder, tooltip, d
|
|||
<Select
|
||||
{...field}
|
||||
id={name}
|
||||
required={required}
|
||||
components={{
|
||||
Input: common.SelectInput,
|
||||
Control: common.SelectControl,
|
||||
|
|
|
@ -79,4 +79,33 @@ const SwitchGroup = ({
|
|||
</Field>
|
||||
);
|
||||
|
||||
export { SwitchGroup };
|
||||
interface SwitchButtonProps {
|
||||
name: string;
|
||||
defaultValue?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const SwitchButton = ({ name, defaultValue }: SwitchButtonProps) => (
|
||||
<Field as="div" className="flex items-center justify-between">
|
||||
<FormikField
|
||||
name={name}
|
||||
defaultValue={defaultValue as boolean}
|
||||
type="checkbox"
|
||||
>
|
||||
{({
|
||||
field,
|
||||
form: { setFieldValue }
|
||||
}: FieldProps) => (
|
||||
<Checkbox
|
||||
{...field}
|
||||
value={!!field.checked}
|
||||
setValue={(value) => {
|
||||
setFieldValue(field?.name ?? "", value);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</FormikField>
|
||||
</Field>
|
||||
);
|
||||
|
||||
export { SwitchGroup, SwitchButton };
|
||||
|
|
|
@ -577,3 +577,10 @@ export const ExternalFilterWebhookMethodOptions: OptionBasicTyped<WebhookMethod>
|
|||
{ label: "PATCH", value: "PATCH" },
|
||||
{ label: "DELETE", value: "DELETE" }
|
||||
];
|
||||
|
||||
export const ProxyTypeOptions: OptionBasicTyped<ProxyType>[] = [
|
||||
{
|
||||
label: "SOCKS5",
|
||||
value: "SOCKS5"
|
||||
},
|
||||
];
|
||||
|
|
|
@ -16,14 +16,15 @@ import { classNames, sleep } from "@utils";
|
|||
import { DEBUG } from "@components/debug";
|
||||
import { APIClient } from "@api/APIClient";
|
||||
import { FeedKeys, IndexerKeys, ReleaseKeys } from "@api/query_keys";
|
||||
import { IndexersSchemaQueryOptions } from "@api/queries";
|
||||
import { IndexersSchemaQueryOptions, ProxiesQueryOptions } from "@api/queries";
|
||||
import { SlideOver } from "@components/panels";
|
||||
import Toast from "@components/notifications/Toast";
|
||||
import { PasswordFieldWide, SwitchGroupWide, TextFieldWide } from "@components/inputs";
|
||||
import { PasswordFieldWide, SwitchButton, SwitchGroupWide, TextFieldWide } from "@components/inputs";
|
||||
import { SelectFieldBasic, SelectFieldCreatable } from "@components/inputs/select_wide";
|
||||
import { FeedDownloadTypeOptions } from "@domain/constants";
|
||||
import { DocsLink } from "@components/ExternalLink";
|
||||
import * as common from "@components/inputs/common";
|
||||
import { SelectField } from "@forms/settings/IrcForms";
|
||||
|
||||
// const isRequired = (message: string) => (value?: string | undefined) => (!!value ? undefined : message);
|
||||
|
||||
|
@ -254,7 +255,7 @@ type SelectValue = {
|
|||
value: string;
|
||||
};
|
||||
|
||||
interface AddProps {
|
||||
export interface AddProps {
|
||||
isOpen: boolean;
|
||||
toggle: () => void;
|
||||
}
|
||||
|
@ -718,6 +719,8 @@ interface IndexerUpdateInitialValues {
|
|||
identifier_external: string;
|
||||
implementation: string;
|
||||
base_url: string;
|
||||
use_proxy?: boolean;
|
||||
proxy_id?: number;
|
||||
settings: {
|
||||
api_key?: string;
|
||||
api_user?: string;
|
||||
|
@ -735,6 +738,8 @@ interface UpdateProps {
|
|||
export function IndexerUpdateForm({ isOpen, toggle, indexer }: UpdateProps) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const proxies = useQuery(ProxiesQueryOptions());
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (indexer: Indexer) => APIClient.indexers.update(indexer),
|
||||
onSuccess: () => {
|
||||
|
@ -813,6 +818,8 @@ export function IndexerUpdateForm({ isOpen, toggle, indexer }: UpdateProps) {
|
|||
identifier_external: indexer.identifier_external,
|
||||
implementation: indexer.implementation,
|
||||
base_url: indexer.base_url,
|
||||
use_proxy: indexer.use_proxy,
|
||||
proxy_id: indexer.proxy_id,
|
||||
settings: indexer.settings?.reduce(
|
||||
(o: Record<string, string>, obj: IndexerSetting) => ({
|
||||
...o,
|
||||
|
@ -833,7 +840,7 @@ export function IndexerUpdateForm({ isOpen, toggle, indexer }: UpdateProps) {
|
|||
initialValues={initialValues}
|
||||
extraButtons={(values) => <TestApiButton values={values as FormikValues} show={indexer.implementation === "irc" && indexer.supports.includes("api")} />}
|
||||
>
|
||||
{() => (
|
||||
{(values) => (
|
||||
<div className="py-2 space-y-6 sm:py-0 sm:space-y-0 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<div className="space-y-1 p-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<label
|
||||
|
@ -863,14 +870,15 @@ export function IndexerUpdateForm({ isOpen, toggle, indexer }: UpdateProps) {
|
|||
tooltip={
|
||||
<div>
|
||||
<p>External Identifier for use with ARRs to get features like seed limits working.</p>
|
||||
<br />
|
||||
<p>This needs to match the indexer name in your ARR. If using Prowlarr it will likely be "{indexer.name} (Prowlarr)"</p>
|
||||
<br />
|
||||
<DocsLink href="https://autobrr.com/configuration/indexers#setup" />
|
||||
<br/>
|
||||
<p>This needs to match the indexer name in your ARR. If using Prowlarr it will likely be
|
||||
"{indexer.name} (Prowlarr)"</p>
|
||||
<br/>
|
||||
<DocsLink href="https://autobrr.com/configuration/indexers#setup"/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<SwitchGroupWide name="enabled" label="Enabled" />
|
||||
<SwitchGroupWide name="enabled" label="Enabled"/>
|
||||
|
||||
{indexer.implementation == "irc" && (
|
||||
<SelectFieldCreatable
|
||||
|
@ -882,6 +890,31 @@ export function IndexerUpdateForm({ isOpen, toggle, indexer }: UpdateProps) {
|
|||
)}
|
||||
|
||||
{renderSettingFields(indexer.settings)}
|
||||
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 py-4">
|
||||
<div className="flex justify-between px-4">
|
||||
<div className="space-y-1">
|
||||
<DialogTitle className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
Proxy
|
||||
</DialogTitle>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Set a proxy to be used for downloads of .torrent files and feeds.
|
||||
</p>
|
||||
</div>
|
||||
<SwitchButton name="use_proxy" />
|
||||
</div>
|
||||
|
||||
{values.use_proxy === true && (
|
||||
<div className="py-4 pt-6">
|
||||
<SelectField<number>
|
||||
name="proxy_id"
|
||||
label="Select proxy"
|
||||
placeholder="Select a proxy"
|
||||
options={proxies.data ? proxies.data.map((p) => ({ label: p.name, value: p.id })) : []}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SlideOver>
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*/
|
||||
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { XMarkIcon } from "@heroicons/react/24/solid";
|
||||
import type { FieldProps } from "formik";
|
||||
|
@ -16,11 +16,12 @@ import { DialogTitle } from "@headlessui/react";
|
|||
import { IrcAuthMechanismTypeOptions, OptionBasicTyped } from "@domain/constants";
|
||||
import { APIClient } from "@api/APIClient";
|
||||
import { IrcKeys } from "@api/query_keys";
|
||||
import { NumberFieldWide, PasswordFieldWide, SwitchGroupWide, TextFieldWide } from "@components/inputs";
|
||||
import { NumberFieldWide, PasswordFieldWide, SwitchButton, SwitchGroupWide, TextFieldWide } from "@components/inputs";
|
||||
import { SlideOver } from "@components/panels";
|
||||
import Toast from "@components/notifications/Toast";
|
||||
import * as common from "@components/inputs/common";
|
||||
import { classNames } from "@utils";
|
||||
import { ProxiesQueryOptions } from "@api/queries";
|
||||
|
||||
interface ChannelsFieldArrayProps {
|
||||
channels: IrcChannel[];
|
||||
|
@ -270,6 +271,8 @@ interface IrcNetworkUpdateFormValues {
|
|||
bouncer_addr: string;
|
||||
bot_mode: boolean;
|
||||
channels: Array<IrcChannel>;
|
||||
use_proxy: boolean;
|
||||
proxy_id: number;
|
||||
}
|
||||
|
||||
interface IrcNetworkUpdateFormProps {
|
||||
|
@ -285,6 +288,8 @@ export function IrcNetworkUpdateForm({
|
|||
}: IrcNetworkUpdateFormProps) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const proxies = useQuery(ProxiesQueryOptions());
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (network: IrcNetwork) => APIClient.irc.updateNetwork(network),
|
||||
onSuccess: () => {
|
||||
|
@ -325,7 +330,9 @@ export function IrcNetworkUpdateForm({
|
|||
use_bouncer: network.use_bouncer,
|
||||
bouncer_addr: network.bouncer_addr,
|
||||
bot_mode: network.bot_mode,
|
||||
channels: network.channels
|
||||
channels: network.channels,
|
||||
use_proxy: network.use_proxy,
|
||||
proxy_id: network.proxy_id,
|
||||
};
|
||||
|
||||
return (
|
||||
|
@ -348,7 +355,7 @@ export function IrcNetworkUpdateForm({
|
|||
required={true}
|
||||
/>
|
||||
|
||||
<SwitchGroupWide name="enabled" label="Enabled" />
|
||||
<SwitchGroupWide name="enabled" label="Enabled"/>
|
||||
<TextFieldWide
|
||||
name="server"
|
||||
label="Server"
|
||||
|
@ -362,7 +369,7 @@ export function IrcNetworkUpdateForm({
|
|||
required={true}
|
||||
/>
|
||||
|
||||
<SwitchGroupWide name="tls" label="TLS" />
|
||||
<SwitchGroupWide name="tls" label="TLS"/>
|
||||
|
||||
<PasswordFieldWide
|
||||
name="pass"
|
||||
|
@ -377,7 +384,7 @@ export function IrcNetworkUpdateForm({
|
|||
required={true}
|
||||
/>
|
||||
|
||||
<SwitchGroupWide name="use_bouncer" label="Bouncer (BNC)" />
|
||||
<SwitchGroupWide name="use_bouncer" label="Bouncer (BNC)"/>
|
||||
{values.use_bouncer && (
|
||||
<TextFieldWide
|
||||
name="bouncer_addr"
|
||||
|
@ -386,7 +393,32 @@ export function IrcNetworkUpdateForm({
|
|||
/>
|
||||
)}
|
||||
|
||||
<SwitchGroupWide name="bot_mode" label="IRCv3 Bot Mode" />
|
||||
<SwitchGroupWide name="bot_mode" label="IRCv3 Bot Mode"/>
|
||||
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 py-4">
|
||||
<div className="flex justify-between px-4">
|
||||
<div className="space-y-1">
|
||||
<DialogTitle className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
Proxy
|
||||
</DialogTitle>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Set a proxy to be used for connecting to the irc server.
|
||||
</p>
|
||||
</div>
|
||||
<SwitchButton name="use_proxy"/>
|
||||
</div>
|
||||
|
||||
{values.use_proxy === true && (
|
||||
<div className="py-4 pt-6">
|
||||
<SelectField<number>
|
||||
name="proxy_id"
|
||||
label="Select proxy"
|
||||
placeholder="Select a proxy"
|
||||
options={proxies.data ? proxies.data.map((p) => ({ label: p.name, value: p.id })) : []}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 py-5">
|
||||
<div className="px-4 space-y-1 mb-8">
|
||||
|
@ -416,17 +448,17 @@ export function IrcNetworkUpdateForm({
|
|||
/>
|
||||
</div>
|
||||
|
||||
<PasswordFieldWide name="invite_command" label="Invite command" />
|
||||
<PasswordFieldWide name="invite_command" label="Invite command"/>
|
||||
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 py-5">
|
||||
<div className="px-4 space-y-1 mb-8">
|
||||
<DialogTitle className="text-lg font-medium text-gray-900 dark:text-white">Channels</DialogTitle>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Channels are added when you setup IRC indexers. Do not edit unless you know what you are doing.
|
||||
Channels are added when you setup IRC indexers. Do not edit unless you know what you are doing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ChannelsFieldArray channels={values.channels} />
|
||||
<ChannelsFieldArray channels={values.channels}/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
@ -438,9 +470,10 @@ interface SelectFieldProps<T> {
|
|||
name: string;
|
||||
label: string;
|
||||
options: OptionBasicTyped<T>[]
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
function SelectField<T>({ name, label, options }: SelectFieldProps<T>) {
|
||||
export function SelectField<T>({ name, label, options, placeholder }: SelectFieldProps<T>) {
|
||||
return (
|
||||
<div className="flex items-center justify-between space-y-1 px-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<div>
|
||||
|
@ -454,9 +487,9 @@ function SelectField<T>({ name, label, options }: SelectFieldProps<T>) {
|
|||
<div className="sm:col-span-2">
|
||||
<Field name={name} type="select">
|
||||
{({
|
||||
field,
|
||||
form: { setFieldValue, resetForm }
|
||||
}: FieldProps) => (
|
||||
field,
|
||||
form: { setFieldValue }
|
||||
}: FieldProps) => (
|
||||
<Select
|
||||
{...field}
|
||||
id={name}
|
||||
|
@ -470,7 +503,7 @@ function SelectField<T>({ name, label, options }: SelectFieldProps<T>) {
|
|||
IndicatorSeparator: common.IndicatorSeparator,
|
||||
DropdownIndicator: common.DropdownIndicator
|
||||
}}
|
||||
placeholder="Choose a type"
|
||||
placeholder={placeholder ?? "Choose a type"}
|
||||
styles={{
|
||||
singleValue: (base) => ({
|
||||
...base,
|
||||
|
@ -487,14 +520,18 @@ function SelectField<T>({ name, label, options }: SelectFieldProps<T>) {
|
|||
})}
|
||||
value={field?.value && options.find(o => o.value == field?.value)}
|
||||
onChange={(option) => {
|
||||
resetForm();
|
||||
// resetForm();
|
||||
|
||||
// const opt = option as SelectOption;
|
||||
// setFieldValue("name", option?.label ?? "")
|
||||
setFieldValue(
|
||||
field.name,
|
||||
option.value ?? ""
|
||||
);
|
||||
if (option !== null) {
|
||||
// const opt = option as SelectOption;
|
||||
// setFieldValue("name", option?.label ?? "")
|
||||
setFieldValue(
|
||||
field.name,
|
||||
option.value ?? ""
|
||||
);
|
||||
} else {
|
||||
setFieldValue(field.name, undefined);
|
||||
}
|
||||
}}
|
||||
options={options}
|
||||
/>
|
||||
|
|
265
web/src/forms/settings/ProxyForms.tsx
Normal file
265
web/src/forms/settings/ProxyForms.tsx
Normal file
|
@ -0,0 +1,265 @@
|
|||
import { Fragment } from "react";
|
||||
import { Form, Formik, FormikValues } from "formik";
|
||||
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from "@headlessui/react";
|
||||
import { XMarkIcon } from "@heroicons/react/24/solid";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "react-hot-toast";
|
||||
|
||||
import { AddProps } from "@forms/settings/IndexerForms";
|
||||
import { DEBUG } from "@components/debug.tsx";
|
||||
import { PasswordFieldWide, SwitchGroupWide, TextFieldWide } from "@components/inputs";
|
||||
import { SelectFieldBasic } from "@components/inputs/select_wide";
|
||||
import { ProxyTypeOptions } from "@domain/constants";
|
||||
import { APIClient } from "@api/APIClient";
|
||||
import { ProxyKeys } from "@api/query_keys";
|
||||
import Toast from "@components/notifications/Toast";
|
||||
import { SlideOver } from "@components/panels";
|
||||
|
||||
export function ProxyAddForm({ isOpen, toggle }: AddProps) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (req: ProxyCreate) => APIClient.proxy.store(req),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ProxyKeys.lists() });
|
||||
|
||||
toast.custom((t) => <Toast type="success" body="Proxy added!" t={t} />);
|
||||
toggle();
|
||||
},
|
||||
onError: () => {
|
||||
toast.custom((t) => <Toast type="error" body="Proxy could not be added" t={t} />);
|
||||
}
|
||||
});
|
||||
|
||||
const onSubmit = (formData: FormikValues) => {
|
||||
createMutation.mutate(formData as ProxyCreate);
|
||||
}
|
||||
|
||||
const testMutation = useMutation({
|
||||
mutationFn: (data: Proxy) => APIClient.proxy.test(data),
|
||||
onError: (err) => {
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
|
||||
const testProxy = (data: unknown) => testMutation.mutate(data as Proxy);
|
||||
|
||||
const initialValues: ProxyCreate = {
|
||||
enabled: true,
|
||||
name: "Proxy",
|
||||
type: "SOCKS5",
|
||||
addr: "socks5://ip:port",
|
||||
user: "",
|
||||
pass: "",
|
||||
}
|
||||
|
||||
return (
|
||||
<Transition show={isOpen} as={Fragment}>
|
||||
<Dialog
|
||||
as="div"
|
||||
static
|
||||
className="fixed inset-0 overflow-hidden"
|
||||
open={isOpen}
|
||||
onClose={toggle}
|
||||
>
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<DialogPanel className="absolute inset-y-0 right-0 pl-10 max-w-full flex sm:pl-16">
|
||||
<TransitionChild
|
||||
as={Fragment}
|
||||
enter="transform transition ease-in-out duration-500 sm:duration-700"
|
||||
enterFrom="translate-x-full"
|
||||
enterTo="translate-x-0"
|
||||
leave="transform transition ease-in-out duration-500 sm:duration-700"
|
||||
leaveFrom="translate-x-0"
|
||||
leaveTo="translate-x-full"
|
||||
>
|
||||
<div className="w-screen max-w-2xl dark:border-gray-700 border-l">
|
||||
<Formik
|
||||
enableReinitialize={true}
|
||||
initialValues={initialValues}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
{({ values }) => (
|
||||
<Form className="h-full flex flex-col bg-white dark:bg-gray-800 shadow-xl overflow-y-auto">
|
||||
<div className="flex-1">
|
||||
<div className="px-4 py-6 bg-gray-50 dark:bg-gray-900 sm:px-6">
|
||||
<div className="flex items-start justify-between space-x-3">
|
||||
<div className="space-y-1">
|
||||
<DialogTitle className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
Add proxy
|
||||
</DialogTitle>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-200">
|
||||
Add proxy to be used with Indexers or IRC.
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-7 flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
className="bg-white dark:bg-gray-700 rounded-md text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
onClick={toggle}
|
||||
>
|
||||
<span className="sr-only">Close panel</span>
|
||||
<XMarkIcon className="h-6 w-6" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="py-6 space-y-4 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<SwitchGroupWide name="enabled" label="Enabled" />
|
||||
<TextFieldWide name="name" label="Name" defaultValue="" required={true} />
|
||||
|
||||
<SelectFieldBasic
|
||||
name="type"
|
||||
label="Proxy type"
|
||||
options={ProxyTypeOptions}
|
||||
tooltip={<span>Proxy type. Commonly SOCKS5.</span>}
|
||||
help="Usually SOCKS5"
|
||||
/>
|
||||
|
||||
<TextFieldWide name="addr" label="Addr" required={true} help="Addr: scheme://ip:port or scheme://domain" autoComplete="off"/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextFieldWide name="user" label="User" help="auth: username" autoComplete="off" />
|
||||
<PasswordFieldWide name="pass" label="Pass" help="auth: password" autoComplete="off"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex-shrink-0 px-4 border-t border-gray-200 dark:border-gray-700 py-5 sm:px-6">
|
||||
<div className="space-x-3 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="bg-white dark:bg-gray-700 py-2 px-4 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-blue-500"
|
||||
onClick={() => testProxy(values)}
|
||||
>
|
||||
Test
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="bg-white dark:bg-gray-700 py-2 px-4 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-blue-500"
|
||||
onClick={toggle}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 dark:bg-blue-600 hover:bg-blue-700 dark:hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-blue-500"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DEBUG values={values}/>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</div>
|
||||
|
||||
</TransitionChild>
|
||||
</DialogPanel>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
interface UpdateFormProps<T> {
|
||||
isOpen: boolean;
|
||||
toggle: () => void;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export function ProxyUpdateForm({ isOpen, toggle, data }: UpdateFormProps<Proxy>) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (req: Proxy) => APIClient.proxy.update(req),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ProxyKeys.lists() });
|
||||
|
||||
toast.custom((t) => <Toast type="success" body={`Proxy ${data.name} updated!`} t={t} />);
|
||||
toggle();
|
||||
},
|
||||
onError: () => {
|
||||
toast.custom((t) => <Toast type="error" body="Proxy could not be updated" t={t} />);
|
||||
}
|
||||
});
|
||||
|
||||
const onSubmit = (formData: unknown) => {
|
||||
updateMutation.mutate(formData as Proxy);
|
||||
}
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (proxyId: number) => APIClient.proxy.delete(proxyId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ProxyKeys.lists() });
|
||||
|
||||
toast.custom((t) => <Toast type="success" body={`Proxy ${data.name} was deleted.`} t={t}/>);
|
||||
}
|
||||
});
|
||||
|
||||
const deleteFn = () => deleteMutation.mutate(data.id);
|
||||
|
||||
const testMutation = useMutation({
|
||||
mutationFn: (data: Proxy) => APIClient.proxy.test(data),
|
||||
onError: (err) => {
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
|
||||
const testProxy = (data: unknown) => testMutation.mutate(data as Proxy);
|
||||
|
||||
const initialValues: Proxy = {
|
||||
id: data.id,
|
||||
enabled: data.enabled,
|
||||
name: data.name,
|
||||
type: data.type,
|
||||
addr: data.addr,
|
||||
user: data.user,
|
||||
pass: data.pass,
|
||||
}
|
||||
|
||||
return (
|
||||
<SlideOver<Proxy>
|
||||
title="Proxy"
|
||||
initialValues={initialValues}
|
||||
onSubmit={onSubmit}
|
||||
deleteAction={deleteFn}
|
||||
testFn={testProxy}
|
||||
isOpen={isOpen}
|
||||
toggle={toggle}
|
||||
type="UPDATE"
|
||||
>
|
||||
{() => (
|
||||
<div>
|
||||
<div className="py-6 space-y-4 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
|
||||
<SwitchGroupWide name="enabled" label="Enabled"/>
|
||||
|
||||
<TextFieldWide name="name" label="Name" defaultValue="" required={true}/>
|
||||
|
||||
<SelectFieldBasic
|
||||
name="type"
|
||||
label="Proxy type"
|
||||
required={true}
|
||||
options={ProxyTypeOptions}
|
||||
tooltip={<span>Proxy type. Commonly SOCKS5.</span>}
|
||||
help="Usually SOCKS5"
|
||||
/>
|
||||
|
||||
<TextFieldWide name="addr" label="Addr" required={true} help="Addr: scheme://ip:port or scheme://domain" autoComplete="off"/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextFieldWide name="user" label="User" help="auth: username" autoComplete="off"/>
|
||||
<PasswordFieldWide name="pass" label="Pass" help="auth: password" autoComplete="off"/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SlideOver>
|
||||
);
|
||||
}
|
|
@ -11,7 +11,7 @@ import {
|
|||
notFound,
|
||||
Outlet,
|
||||
redirect,
|
||||
} from "@tanstack/react-router";
|
||||
} from "@tanstack/react-router";
|
||||
import { z } from "zod";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
|
@ -30,7 +30,8 @@ import {
|
|||
FilterByIdQueryOptions,
|
||||
IndexersQueryOptions,
|
||||
IrcQueryOptions,
|
||||
NotificationsQueryOptions
|
||||
NotificationsQueryOptions,
|
||||
ProxiesQueryOptions
|
||||
} from "@api/queries";
|
||||
import LogSettings from "@screens/settings/Logs";
|
||||
import NotificationSettings from "@screens/settings/Notifications";
|
||||
|
@ -50,6 +51,7 @@ import { AuthContext, SettingsContext } from "@utils/Context";
|
|||
import { TanStackRouterDevtools } from "@tanstack/router-devtools";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { queryClient } from "@api/QueryClient";
|
||||
import ProxySettings from "@screens/settings/Proxy";
|
||||
|
||||
import { ErrorPage } from "@components/alerts";
|
||||
|
||||
|
@ -212,6 +214,13 @@ export const SettingsApiRoute = createRoute({
|
|||
component: APISettings
|
||||
});
|
||||
|
||||
export const SettingsProxiesRoute = createRoute({
|
||||
getParentRoute: () => SettingsRoute,
|
||||
path: 'proxies',
|
||||
loader: (opts) => opts.context.queryClient.ensureQueryData(ProxiesQueryOptions()),
|
||||
component: ProxySettings
|
||||
});
|
||||
|
||||
export const SettingsReleasesRoute = createRoute({
|
||||
getParentRoute: () => SettingsRoute,
|
||||
path: 'releases',
|
||||
|
@ -339,7 +348,7 @@ export const RootRoute = createRootRouteWithContext<{
|
|||
});
|
||||
|
||||
const filterRouteTree = FiltersRoute.addChildren([FilterIndexRoute, FilterGetByIdRoute.addChildren([FilterGeneralRoute, FilterMoviesTvRoute, FilterMusicRoute, FilterAdvancedRoute, FilterExternalRoute, FilterActionsRoute])])
|
||||
const settingsRouteTree = SettingsRoute.addChildren([SettingsIndexRoute, SettingsLogRoute, SettingsIndexersRoute, SettingsIrcRoute, SettingsFeedsRoute, SettingsClientsRoute, SettingsNotificationsRoute, SettingsApiRoute, SettingsReleasesRoute, SettingsAccountRoute])
|
||||
const settingsRouteTree = SettingsRoute.addChildren([SettingsIndexRoute, SettingsLogRoute, SettingsIndexersRoute, SettingsIrcRoute, SettingsFeedsRoute, SettingsClientsRoute, SettingsNotificationsRoute, SettingsApiRoute, SettingsProxiesRoute, SettingsReleasesRoute, SettingsAccountRoute])
|
||||
const authenticatedTree = AuthRoute.addChildren([AuthIndexRoute.addChildren([DashboardRoute, filterRouteTree, ReleasesRoute, settingsRouteTree, LogsRoute])])
|
||||
const routeTree = RootRoute.addChildren([
|
||||
authenticatedTree,
|
||||
|
|
|
@ -8,6 +8,7 @@ import {
|
|||
ChatBubbleLeftRightIcon,
|
||||
CogIcon,
|
||||
FolderArrowDownIcon,
|
||||
GlobeAltIcon,
|
||||
KeyIcon,
|
||||
RectangleStackIcon,
|
||||
RssIcon,
|
||||
|
@ -34,6 +35,7 @@ const subNavigation: NavTabType[] = [
|
|||
{ name: "Clients", href: "/settings/clients", icon: FolderArrowDownIcon },
|
||||
{ name: "Notifications", href: "/settings/notifications", icon: BellIcon },
|
||||
{ name: "API keys", href: "/settings/api", icon: KeyIcon },
|
||||
{ name: "Proxies", href: "/settings/proxies", icon: GlobeAltIcon },
|
||||
{ name: "Releases", href: "/settings/releases", icon: RectangleStackIcon },
|
||||
{ name: "Account", href: "/settings/account", icon: UserCircleIcon }
|
||||
// {name: 'Regex Playground', href: 'regex-playground', icon: CogIcon, current: false}
|
||||
|
|
140
web/src/screens/settings/Proxy.tsx
Normal file
140
web/src/screens/settings/Proxy.tsx
Normal file
|
@ -0,0 +1,140 @@
|
|||
import { useToggle } from "@hooks/hooks.ts";
|
||||
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { PlusIcon } from "@heroicons/react/24/solid";
|
||||
import { toast } from "react-hot-toast";
|
||||
|
||||
import { APIClient } from "@api/APIClient";
|
||||
import { ProxyKeys } from "@api/query_keys";
|
||||
import { ProxiesQueryOptions } from "@api/queries";
|
||||
import { Section } from "./_components";
|
||||
import { EmptySimple } from "@components/emptystates";
|
||||
import { Checkbox } from "@components/Checkbox";
|
||||
import { ProxyAddForm, ProxyUpdateForm } from "@forms/settings/ProxyForms";
|
||||
import Toast from "@components/notifications/Toast";
|
||||
|
||||
interface ListItemProps {
|
||||
proxy: Proxy;
|
||||
}
|
||||
|
||||
function ListItem({ proxy }: ListItemProps) {
|
||||
const [isOpen, toggleUpdate] = useToggle(false);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (req: Proxy) => APIClient.proxy.update(req),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ProxyKeys.lists() });
|
||||
|
||||
toast.custom(t => <Toast type="success" body={`Proxy ${proxy.name} was ${proxy.enabled ? "enabled" : "disabled"} successfully.`} t={t} />);
|
||||
},
|
||||
onError: () => {
|
||||
toast.custom((t) => <Toast type="error" body="Proxy state could not be updated" t={t} />);
|
||||
}
|
||||
});
|
||||
|
||||
const onToggleMutation = (newState: boolean) => {
|
||||
updateMutation.mutate({
|
||||
...proxy,
|
||||
enabled: newState
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<li>
|
||||
<ProxyUpdateForm isOpen={isOpen} toggle={toggleUpdate} data={proxy} />
|
||||
|
||||
<div className="grid grid-cols-12 items-center py-1.5">
|
||||
<div className="col-span-2 sm:col-span-1 flex pl-1 sm:pl-5 items-center">
|
||||
<Checkbox value={proxy.enabled ?? false} setValue={onToggleMutation} />
|
||||
</div>
|
||||
<div className="col-span-7 sm:col-span-8 pl-12 sm:pr-6 py-3 block flex-col text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
{proxy.name}
|
||||
</div>
|
||||
<div className="hidden md:block col-span-2 pr-6 py-3 text-left items-center whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 truncate">
|
||||
{proxy.type}
|
||||
</div>
|
||||
<div className="col-span-1 flex first-letter:px-6 py-3 whitespace-nowrap text-right text-sm font-medium">
|
||||
<span
|
||||
className="col-span-1 px-6 text-blue-600 dark:text-gray-300 hover:text-blue-900 dark:hover:text-blue-500 cursor-pointer"
|
||||
onClick={toggleUpdate}
|
||||
>
|
||||
Edit
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function ProxySettings() {
|
||||
const [addProxyIsOpen, toggleAddProxy] = useToggle(false);
|
||||
|
||||
const proxiesQuery = useSuspenseQuery(ProxiesQueryOptions())
|
||||
const proxies = proxiesQuery.data
|
||||
|
||||
return (
|
||||
<Section
|
||||
title="Proxies"
|
||||
description={
|
||||
<>
|
||||
Proxies that can be used with Indexers, feeds and IRC.<br/>
|
||||
</>
|
||||
}
|
||||
rightSide={
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAddProxy}
|
||||
className="relative inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 dark:bg-blue-600 hover:bg-blue-700 dark:hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-blue-500"
|
||||
>
|
||||
<PlusIcon className="h-5 w-5 mr-1"/>
|
||||
Add new
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<ProxyAddForm isOpen={addProxyIsOpen} toggle={toggleAddProxy} />
|
||||
|
||||
<div className="flex flex-col">
|
||||
{proxies.length ? (
|
||||
<ul className="min-w-full relative">
|
||||
<li className="grid grid-cols-12 border-b border-gray-200 dark:border-gray-700">
|
||||
<div
|
||||
className="flex col-span-2 sm:col-span-1 pl-0 sm:pl-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-gray-800 hover:dark:text-gray-250 transition-colors uppercase tracking-wider cursor-pointer"
|
||||
// onClick={() => sortedIndexers.requestSort("enabled")}
|
||||
>
|
||||
Enabled
|
||||
{/*<span className="sort-indicator">{sortedIndexers.getSortIndicator("enabled")}</span>*/}
|
||||
</div>
|
||||
<div
|
||||
className="col-span-7 sm:col-span-8 pl-12 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-gray-800 hover:dark:text-gray-250 transition-colors uppercase tracking-wider cursor-pointer"
|
||||
// onClick={() => sortedIndexers.requestSort("name")}
|
||||
>
|
||||
Name
|
||||
{/*<span className="sort-indicator">{sortedIndexers.getSortIndicator("name")}</span>*/}
|
||||
</div>
|
||||
<div
|
||||
className="hidden md:flex col-span-1 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-gray-800 hover:dark:text-gray-250 transition-colors uppercase tracking-wider cursor-pointer"
|
||||
// onClick={() => sortedIndexers.requestSort("implementation")}
|
||||
>
|
||||
Type
|
||||
{/*<span className="sort-indicator">{sortedIndexers.getSortIndicator("implementation")}</span>*/}
|
||||
</div>
|
||||
</li>
|
||||
{proxies.map((proxy) => (
|
||||
<ListItem proxy={proxy} key={proxy.id}/>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<EmptySimple
|
||||
title="No proxies"
|
||||
subtitle=""
|
||||
buttonText="Add new proxy"
|
||||
buttonAction={toggleAddProxy}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProxySettings;
|
|
@ -11,6 +11,7 @@ export { default as Indexer } from "./Indexer";
|
|||
export { default as Irc } from "./Irc";
|
||||
export { default as Logs } from "./Logs";
|
||||
export { default as Notification } from "./Notifications";
|
||||
export { default as Proxy } from "./Proxy";
|
||||
export { default as Release } from "./Releases";
|
||||
export { default as RegexPlayground } from "./RegexPlayground";
|
||||
export { default as Account } from "./Account";
|
||||
|
|
4
web/src/types/Indexer.d.ts
vendored
4
web/src/types/Indexer.d.ts
vendored
|
@ -11,6 +11,8 @@ interface Indexer {
|
|||
enabled: boolean;
|
||||
implementation: string;
|
||||
base_url: string;
|
||||
use_proxy?: boolean;
|
||||
proxy_id?: number;
|
||||
settings: Array<IndexerSetting>;
|
||||
}
|
||||
|
||||
|
@ -35,6 +37,8 @@ interface IndexerDefinition {
|
|||
protocol: string;
|
||||
urls: string[];
|
||||
supports: string[];
|
||||
use_proxy?: boolean;
|
||||
proxy_id?: number;
|
||||
settings: IndexerSetting[];
|
||||
irc: IndexerIRC;
|
||||
torznab: IndexerTorznab;
|
||||
|
|
19
web/src/types/Irc.d.ts
vendored
19
web/src/types/Irc.d.ts
vendored
|
@ -20,6 +20,8 @@ interface IrcNetwork {
|
|||
channels: IrcChannel[];
|
||||
connected: boolean;
|
||||
connected_since: string;
|
||||
use_proxy: boolean;
|
||||
proxy_id: number;
|
||||
}
|
||||
|
||||
interface IrcNetworkCreate {
|
||||
|
@ -53,23 +55,8 @@ interface IrcChannelWithHealth extends IrcChannel {
|
|||
last_announce: string;
|
||||
}
|
||||
|
||||
interface IrcNetworkWithHealth {
|
||||
id: number;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
server: string;
|
||||
port: number;
|
||||
tls: boolean;
|
||||
pass: string;
|
||||
nick: string;
|
||||
auth: IrcAuth; // optional
|
||||
invite_command: string;
|
||||
use_bouncer: boolean;
|
||||
bouncer_addr: string;
|
||||
bot_mode: boolean;
|
||||
interface IrcNetworkWithHealth extends IrcNetwork {
|
||||
channels: IrcChannelWithHealth[];
|
||||
connected: boolean;
|
||||
connected_since: string;
|
||||
connection_errors: string[];
|
||||
healthy: boolean;
|
||||
}
|
||||
|
|
27
web/src/types/Proxy.d.ts
vendored
Normal file
27
web/src/types/Proxy.d.ts
vendored
Normal file
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* Copyright (c) 2021 - 2024, Ludvig Lundgren and the autobrr contributors.
|
||||
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*/
|
||||
|
||||
interface Proxy {
|
||||
id: number;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
type: ProxyType;
|
||||
addr: string;
|
||||
user?: string;
|
||||
pass?: string;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
interface ProxyCreate {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
type: ProxyType;
|
||||
addr: string;
|
||||
user?: string;
|
||||
pass?: string;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
type ProxyType = "SOCKS5" | "HTTP";
|
Loading…
Add table
Add a link
Reference in a new issue