feat(feeds): add force run (#1243)

* feat(feeds): add force run

* fix: simplify ForceRun

* add confirmation modal

* handle errors by using the test func

* require user input to run

* make sure to reschedule next job after forcerun

* refactor modal centering with grid

* refactor: Simplify startJob and forceRun logic

- Refactor `startJob` to accept a `runImmediately` flag. This flag controls whether the job should be run immediately or scheduled for later. This change simplifies the `ForceRun` function by allowing it to call `startJob` with `runImmediately` set to `true`.

- Remove redundant checks in `ForceRun` related to feed type. These checks are handled in `startJob`.

BREAKING CHANGE: The `startJob` function now requires a second argument, `runImmediately`. This change affects all calls to `startJob`.

* fix(web) Invalidate queries after forceRun

* refactor(feeds): init and test run

---------

Co-authored-by: ze0s <43699394+zze0s@users.noreply.github.com>
This commit is contained in:
soup 2023-11-18 21:54:53 +01:00 committed by GitHub
parent ff70a341ad
commit 2bd1a68a94
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 318 additions and 38 deletions

View file

@ -36,7 +36,7 @@ type NewznabJob struct {
JobID int JobID int
} }
func NewNewznabJob(feed *domain.Feed, name string, indexerIdentifier string, log zerolog.Logger, url string, client newznab.Client, repo domain.FeedRepo, cacheRepo domain.FeedCacheRepo, releaseSvc release.Service) *NewznabJob { func NewNewznabJob(feed *domain.Feed, name string, indexerIdentifier string, log zerolog.Logger, url string, client newznab.Client, repo domain.FeedRepo, cacheRepo domain.FeedCacheRepo, releaseSvc release.Service) FeedJob {
return &NewznabJob{ return &NewznabJob{
Feed: feed, Feed: feed,
Name: name, Name: name,
@ -53,7 +53,7 @@ func NewNewznabJob(feed *domain.Feed, name string, indexerIdentifier string, log
func (j *NewznabJob) Run() { func (j *NewznabJob) Run() {
ctx := context.Background() ctx := context.Background()
if err := j.process(ctx); err != nil { if err := j.RunE(ctx); err != nil {
j.Log.Err(err).Int("attempts", j.attempts).Msg("newznab process error") j.Log.Err(err).Int("attempts", j.attempts).Msg("newznab process error")
j.errors = append(j.errors, err) j.errors = append(j.errors, err)
@ -63,6 +63,15 @@ func (j *NewznabJob) Run() {
j.errors = j.errors[:0] j.errors = j.errors[:0]
} }
func (j *NewznabJob) RunE(ctx context.Context) error {
if err := j.process(ctx); err != nil {
j.Log.Err(err).Msg("newznab process error")
return err
}
return nil
}
func (j *NewznabJob) process(ctx context.Context) error { func (j *NewznabJob) process(ctx context.Context) error {
// get feed // get feed
items, err := j.getFeed(ctx) items, err := j.getFeed(ctx)

View file

@ -41,7 +41,7 @@ type RSSJob struct {
JobID int JobID int
} }
func NewRSSJob(feed *domain.Feed, name string, indexerIdentifier string, log zerolog.Logger, url string, repo domain.FeedRepo, cacheRepo domain.FeedCacheRepo, releaseSvc release.Service, timeout time.Duration) *RSSJob { func NewRSSJob(feed *domain.Feed, name string, indexerIdentifier string, log zerolog.Logger, url string, repo domain.FeedRepo, cacheRepo domain.FeedCacheRepo, releaseSvc release.Service, timeout time.Duration) FeedJob {
return &RSSJob{ return &RSSJob{
Feed: feed, Feed: feed,
Name: name, Name: name,
@ -58,15 +58,23 @@ func NewRSSJob(feed *domain.Feed, name string, indexerIdentifier string, log zer
func (j *RSSJob) Run() { func (j *RSSJob) Run() {
ctx := context.Background() ctx := context.Background()
if err := j.process(ctx); err != nil { if err := j.RunE(ctx); err != nil {
j.Log.Error().Err(err).Int("attempts", j.attempts).Msg("rss feed process error") j.Log.Err(err).Int("attempts", j.attempts).Msg("rss feed process error")
j.errors = append(j.errors, err) j.errors = append(j.errors, err)
return
} }
j.attempts = 0 j.attempts = 0
j.errors = []error{} j.errors = j.errors[:0]
}
func (j *RSSJob) RunE(ctx context.Context) error {
if err := j.process(ctx); err != nil {
j.Log.Err(err).Msg("rss feed process error")
return err
}
return nil
} }
func (j *RSSJob) process(ctx context.Context) error { func (j *RSSJob) process(ctx context.Context) error {

View file

@ -36,6 +36,7 @@ type Service interface {
DeleteFeedCache(ctx context.Context, id int) error DeleteFeedCache(ctx context.Context, id int) error
GetLastRunData(ctx context.Context, id int) (string, error) GetLastRunData(ctx context.Context, id int) (string, error)
DeleteFeedCacheStale(ctx context.Context) error DeleteFeedCacheStale(ctx context.Context) error
ForceRun(ctx context.Context, id int) error
Start() error Start() error
} }
@ -310,6 +311,12 @@ func (s *service) start() error {
for _, feed := range feeds { for _, feed := range feeds {
feed := feed feed := feed
if !feed.Enabled {
s.log.Trace().Msgf("feed disabled, skipping... %s", feed.Name)
continue
}
if err := s.startJob(&feed); err != nil { if err := s.startJob(&feed); err != nil {
s.log.Error().Err(err).Msgf("failed to initialize feed job: %s", feed.Name) s.log.Error().Err(err).Msgf("failed to initialize feed job: %s", feed.Name)
continue continue
@ -339,18 +346,7 @@ func (s *service) restartJob(f *domain.Feed) error {
return nil return nil
} }
func newFeedInstance(f *domain.Feed) feedInstance {
func (s *service) startJob(f *domain.Feed) error {
// if it's not enabled we should not start it
if !f.Enabled {
return nil
}
// get torznab_url from settings
if f.URL == "" {
return errors.New("no URL provided for feed: %s", f.Name)
}
// cron schedule to run every X minutes // cron schedule to run every X minutes
fi := feedInstance{ fi := feedInstance{
Feed: f, Feed: f,
@ -363,8 +359,12 @@ func (s *service) startJob(f *domain.Feed) error {
Timeout: time.Duration(f.Timeout) * time.Second, Timeout: time.Duration(f.Timeout) * time.Second,
} }
return fi
}
func (s *service) initializeFeedJob(fi feedInstance) (FeedJob, error) {
var err error var err error
var job cron.Job var job FeedJob
switch fi.Implementation { switch fi.Implementation {
case string(domain.FeedTypeTorznab): case string(domain.FeedTypeTorznab):
@ -377,15 +377,46 @@ func (s *service) startJob(f *domain.Feed) error {
job, err = s.createRSSJob(fi) job, err = s.createRSSJob(fi)
default: default:
return errors.New("unsupported feed type: %s", fi.Implementation) return nil, errors.New("unsupported feed type: %s", fi.Implementation)
} }
if err != nil { if err != nil {
s.log.Error().Err(err).Msgf("failed to initialize %s feed", fi.Implementation) s.log.Error().Err(err).Msgf("failed to initialize %s feed", fi.Implementation)
return err return nil, err
} }
identifierKey := feedKey{f.ID}.ToString() return job, nil
}
func (s *service) startJob(f *domain.Feed) error {
// if it's not enabled we should not start it
if !f.Enabled {
return errors.New("feed %s not enabled", f.Name)
}
// get url from settings
if f.URL == "" {
return errors.New("no URL provided for feed: %s", f.Name)
}
fi := newFeedInstance(f)
job, err := s.initializeFeedJob(fi)
if err != nil {
return errors.Wrap(err, "initialize job %s failed", f.Indexer)
}
if err := s.scheduleJob(fi, job); err != nil {
return errors.Wrap(err, "schedule job %s failed", f.Indexer)
}
s.log.Debug().Msgf("successfully started feed: %s", f.Name)
return nil
}
func (s *service) scheduleJob(fi feedInstance, job cron.Job) error {
identifierKey := feedKey{fi.Feed.ID}.ToString()
// schedule job // schedule job
id, err := s.scheduler.ScheduleJob(job, fi.CronSchedule, identifierKey) id, err := s.scheduler.ScheduleJob(job, fi.CronSchedule, identifierKey)
@ -396,12 +427,10 @@ func (s *service) startJob(f *domain.Feed) error {
// add to job map // add to job map
s.jobs[identifierKey] = id s.jobs[identifierKey] = id
s.log.Debug().Msgf("successfully started feed: %s", f.Name)
return nil return nil
} }
func (s *service) createTorznabJob(f feedInstance) (cron.Job, error) { func (s *service) createTorznabJob(f feedInstance) (FeedJob, error) {
s.log.Debug().Msgf("create torznab job: %s", f.Name) s.log.Debug().Msgf("create torznab job: %s", f.Name)
if f.URL == "" { if f.URL == "" {
@ -424,8 +453,8 @@ func (s *service) createTorznabJob(f feedInstance) (cron.Job, error) {
return job, nil return job, nil
} }
func (s *service) createNewznabJob(f feedInstance) (cron.Job, error) { func (s *service) createNewznabJob(f feedInstance) (FeedJob, error) {
s.log.Debug().Msgf("add newznab job: %s", f.Name) s.log.Debug().Msgf("create newznab job: %s", f.Name)
if f.URL == "" { if f.URL == "" {
return nil, errors.New("newznab feed requires URL") return nil, errors.New("newznab feed requires URL")
@ -443,8 +472,8 @@ func (s *service) createNewznabJob(f feedInstance) (cron.Job, error) {
return job, nil return job, nil
} }
func (s *service) createRSSJob(f feedInstance) (cron.Job, error) { func (s *service) createRSSJob(f feedInstance) (FeedJob, error) {
s.log.Debug().Msgf("add rss job: %s", f.Name) s.log.Debug().Msgf("create rss job: %s", f.Name)
if f.URL == "" { if f.URL == "" {
return nil, errors.New("rss feed requires URL") return nil, errors.New("rss feed requires URL")
@ -507,3 +536,25 @@ func (s *service) GetLastRunData(ctx context.Context, id int) (string, error) {
return feed, nil return feed, nil
} }
func (s *service) ForceRun(ctx context.Context, id int) error {
feed, err := s.FindByID(ctx, id)
if err != nil {
return err
}
fi := newFeedInstance(feed)
job, err := s.initializeFeedJob(fi)
if err != nil {
s.log.Error().Err(err).Msg("failed to initialize feed job")
return err
}
if err := job.RunE(ctx); err != nil {
s.log.Error().Err(err).Msg("failed to refresh feed")
return err
}
return nil
}

View file

@ -37,7 +37,12 @@ type TorznabJob struct {
JobID int JobID int
} }
func NewTorznabJob(feed *domain.Feed, name string, indexerIdentifier string, log zerolog.Logger, url string, client torznab.Client, repo domain.FeedRepo, cacheRepo domain.FeedCacheRepo, releaseSvc release.Service) *TorznabJob { type FeedJob interface {
Run()
RunE(ctx context.Context) error
}
func NewTorznabJob(feed *domain.Feed, name string, indexerIdentifier string, log zerolog.Logger, url string, client torznab.Client, repo domain.FeedRepo, cacheRepo domain.FeedCacheRepo, releaseSvc release.Service) FeedJob {
return &TorznabJob{ return &TorznabJob{
Feed: feed, Feed: feed,
Name: name, Name: name,
@ -54,7 +59,7 @@ func NewTorznabJob(feed *domain.Feed, name string, indexerIdentifier string, log
func (j *TorznabJob) Run() { func (j *TorznabJob) Run() {
ctx := context.Background() ctx := context.Background()
if err := j.process(ctx); err != nil { if err := j.RunE(ctx); err != nil {
j.Log.Err(err).Int("attempts", j.attempts).Msg("torznab process error") j.Log.Err(err).Int("attempts", j.attempts).Msg("torznab process error")
j.errors = append(j.errors, err) j.errors = append(j.errors, err)
@ -64,6 +69,15 @@ func (j *TorznabJob) Run() {
j.errors = j.errors[:0] j.errors = j.errors[:0]
} }
func (j *TorznabJob) RunE(ctx context.Context) error {
if err := j.process(ctx); err != nil {
j.Log.Err(err).Int("attempts", j.attempts).Msg("torznab process error")
return err
}
return nil
}
func (j *TorznabJob) process(ctx context.Context) error { func (j *TorznabJob) process(ctx context.Context) error {
// get feed // get feed
items, err := j.getFeed(ctx) items, err := j.getFeed(ctx)

View file

@ -23,6 +23,7 @@ type feedService interface {
ToggleEnabled(ctx context.Context, id int, enabled bool) error ToggleEnabled(ctx context.Context, id int, enabled bool) error
Test(ctx context.Context, feed *domain.Feed) error Test(ctx context.Context, feed *domain.Feed) error
GetLastRunData(ctx context.Context, id int) (string, error) GetLastRunData(ctx context.Context, id int) (string, error)
ForceRun(ctx context.Context, id int) error
} }
type feedHandler struct { type feedHandler struct {
@ -48,6 +49,7 @@ func (h feedHandler) Routes(r chi.Router) {
r.Delete("/cache", h.deleteCache) r.Delete("/cache", h.deleteCache)
r.Patch("/enabled", h.toggleEnabled) r.Patch("/enabled", h.toggleEnabled)
r.Get("/latest", h.latestRun) r.Get("/latest", h.latestRun)
r.Post("/forcerun", h.forceRun)
}) })
} }
@ -122,6 +124,24 @@ func (h feedHandler) update(w http.ResponseWriter, r *http.Request) {
h.encoder.StatusResponse(w, http.StatusCreated, data) h.encoder.StatusResponse(w, http.StatusCreated, data)
} }
func (h feedHandler) forceRun(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
feedID := chi.URLParam(r, "feedID")
id, err := strconv.Atoi(feedID)
if err != nil {
h.encoder.Error(w, err)
return
}
if err := h.service.ForceRun(ctx, id); err != nil {
h.encoder.Error(w, err)
return
}
h.encoder.StatusResponse(w, http.StatusNoContent, nil)
}
func (h feedHandler) toggleEnabled(w http.ResponseWriter, r *http.Request) { func (h feedHandler) toggleEnabled(w http.ResponseWriter, r *http.Request) {
var ( var (
ctx = r.Context() ctx = r.Context()

View file

@ -224,6 +224,7 @@ export const APIClient = {
update: (feed: Feed) => appClient.Put(`api/feeds/${feed.id}`, { update: (feed: Feed) => appClient.Put(`api/feeds/${feed.id}`, {
body: feed body: feed
}), }),
forceRun: (id: number) => appClient.Post(`api/feeds/${id}/forcerun`),
delete: (id: number) => appClient.Delete(`api/feeds/${id}`), delete: (id: number) => appClient.Delete(`api/feeds/${id}`),
deleteCache: (id: number) => appClient.Delete(`api/feeds/${id}/cache`), deleteCache: (id: number) => appClient.Delete(`api/feeds/${id}/cache`),
test: (feed: Feed) => appClient.Post("api/feeds/test", { test: (feed: Feed) => appClient.Post("api/feeds/test", {

View file

@ -3,7 +3,8 @@
* SPDX-License-Identifier: GPL-2.0-or-later * SPDX-License-Identifier: GPL-2.0-or-later
*/ */
import { FC, Fragment, MutableRefObject } from "react";
import { FC, Fragment, MutableRefObject, useState } from "react";
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid"; import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
@ -18,13 +19,24 @@ interface ModalLowerProps {
isOpen: boolean; isOpen: boolean;
isLoading: boolean; isLoading: boolean;
toggle: () => void; toggle: () => void;
deleteAction: () => void; deleteAction?: () => void;
forceRunAction?: () => void;
} }
interface DeleteModalProps extends ModalUpperProps, ModalLowerProps { interface DeleteModalProps extends ModalUpperProps, ModalLowerProps {
buttonRef: MutableRefObject<HTMLElement | null> | undefined; buttonRef: MutableRefObject<HTMLElement | null> | undefined;
} }
interface ForceRunModalProps {
isOpen: boolean;
isLoading: boolean;
toggle: () => void;
buttonRef: MutableRefObject<HTMLElement | null> | undefined;
forceRunAction: () => void;
title: string;
text: string;
}
const ModalUpper = ({ title, text }: ModalUpperProps) => ( const ModalUpper = ({ title, text }: ModalUpperProps) => (
<div className="bg-white dark:bg-gray-800 px-4 pt-5 pb-4 sm:p-6 sm:pb-4"> <div className="bg-white dark:bg-gray-800 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start"> <div className="sm:flex sm:items-start">
@ -55,7 +67,7 @@ const ModalLower = ({ isOpen, isLoading, toggle, deleteAction }: ModalLowerProps
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
if (isOpen) { if (isOpen) {
deleteAction(); deleteAction?.();
toggle(); toggle();
} }
}} }}
@ -121,3 +133,116 @@ export const DeleteModal: FC<DeleteModalProps> = (props: DeleteModalProps) => (
</Dialog> </Dialog>
</Transition.Root> </Transition.Root>
); );
export const ForceRunModal: FC<ForceRunModalProps> = (props: ForceRunModalProps) => {
const [inputValue, setInputValue] = useState("");
const isInputCorrect = inputValue.trim().toLowerCase() === "i understand";
// A function to reset the input and handle any necessary cleanup
const resetAndClose = () => {
setInputValue("");
props.toggle();
};
// The handleClose function will be passed to the onClose prop of the Dialog
const handleClose = () => {
setTimeout(() => {
resetAndClose();
}, 200);
};
const handleForceRun = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (props.isOpen && isInputCorrect) {
props.forceRunAction();
props.toggle();
// Delay the reset of the input until after the transition finishes
setTimeout(() => {
setInputValue("");
}, 400);
}
};
// When the 'Cancel' button is clicked
const handleCancel = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
resetAndClose();
};
return (
<Transition.Root show={props.isOpen} as={Fragment}>
<Dialog
as="div"
static
className="fixed z-10 inset-0 overflow-y-auto"
initialFocus={props.buttonRef}
open={props.isOpen}
onClose={handleClose}
>
<div className="grid place-items-center min-h-screen">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Dialog.Overlay className="fixed inset-0 bg-gray-700/60 dark:bg-black/60 transition-opacity" />
</Transition.Child>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<div className="inline-block align-bottom border border-transparent dark:border-gray-700 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<ModalUpper title={props.title} text={props.text} />
<div className="bg-gray-50 dark:bg-gray-800 px-4 py-3 sm:px-6 flex justify-center">
<input
type="text"
className="w-96 shadow-sm sm:text-sm rounded-md border py-2.5 focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500 border-gray-400 dark:border-gray-700 bg-gray-100 dark:bg-gray-900 dark:text-gray-100"
placeholder="Type 'I understand' to enable the button"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
/>
</div>
<div className="bg-gray-50 dark:bg-gray-800 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
{props.isLoading ? (
<SectionLoader $size="small" />
) : (
<>
<button
type="button"
disabled={!isInputCorrect}
className={`w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 ${
isInputCorrect ? "bg-red-600 text-white hover:bg-red-700" : "bg-gray-300"
} text-base font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:ml-3 sm:w-auto sm:text-sm`}
onClick={handleForceRun}
>
Force Run
</button>
<button
type="button"
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 dark:border-gray-600 shadow-sm px-4 py-2 bg-white dark:bg-gray-700 text-base 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 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
onClick={handleCancel}
>
Cancel
</button>
</>
)}
</div>
</div>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
);
};

View file

@ -141,7 +141,7 @@ function SlideOver<DataType extends FormikValues>({
Remove Remove
</button> </button>
)} )}
<div> <div className="flex">
{!!values && extraButtons !== undefined && ( {!!values && extraButtons !== undefined && (
extraButtons(values) extraButtons(values)
)} )}
@ -154,7 +154,7 @@ function SlideOver<DataType extends FormikValues>({
? "text-green-500 border-green-500 bg-green-50" ? "text-green-500 border-green-500 bg-green-50"
: isTestError : isTestError
? "text-red-500 border-red-500 bg-red-50" ? "text-red-500 border-red-500 bg-red-50"
: "border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-400 bg-white dark:bg-gray-700 hover:bg-gray-50 focus:border-rose-700 active:bg-rose-700", : "border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:border-rose-700 active:bg-rose-700",
isTesting ? "cursor-not-allowed" : "", isTesting ? "cursor-not-allowed" : "",
"mr-2 inline-flex items-center px-4 py-2 border font-medium rounded-md shadow-sm text-sm transition ease-in-out duration-150 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-blue-500" "mr-2 inline-flex items-center px-4 py-2 border font-medium rounded-md shadow-sm text-sm transition ease-in-out duration-150 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-blue-500"
)} )}

View file

@ -78,6 +78,8 @@ export function FeedUpdateForm({ isOpen, toggle, feed }: UpdateProps) {
setIsSuccessfulTest(false); setIsSuccessfulTest(false);
}, },
onSuccess: () => { onSuccess: () => {
toast.custom((t) => <Toast type="success" body={`${feed.name} test OK!`} t={t} />);
sleep(1000) sleep(1000)
.then(() => { .then(() => {
setIsTesting(false); setIsTesting(false);

View file

@ -12,6 +12,7 @@ import {
DocumentTextIcon, DocumentTextIcon,
EllipsisHorizontalIcon, EllipsisHorizontalIcon,
PencilSquareIcon, PencilSquareIcon,
ForwardIcon,
TrashIcon TrashIcon
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
@ -19,7 +20,7 @@ import { APIClient } from "@api/APIClient";
import { useToggle } from "@hooks/hooks"; import { useToggle } from "@hooks/hooks";
import { baseUrl, classNames, IsEmptyDate, simplifyDate } from "@utils"; import { baseUrl, classNames, IsEmptyDate, simplifyDate } from "@utils";
import Toast from "@components/notifications/Toast"; import Toast from "@components/notifications/Toast";
import { DeleteModal } from "@components/modals"; import { DeleteModal, ForceRunModal } from "@components/modals";
import { FeedUpdateForm } from "@forms/settings/FeedForms"; import { FeedUpdateForm } from "@forms/settings/FeedForms";
import { EmptySimple } from "@components/emptystates"; import { EmptySimple } from "@components/emptystates";
import { ImplementationBadges } from "./Indexer"; import { ImplementationBadges } from "./Indexer";
@ -230,6 +231,7 @@ const FeedItemDropdown = ({
const [deleteModalIsOpen, toggleDeleteModal] = useToggle(false); const [deleteModalIsOpen, toggleDeleteModal] = useToggle(false);
const [deleteCacheModalIsOpen, toggleDeleteCacheModal] = useToggle(false); const [deleteCacheModalIsOpen, toggleDeleteCacheModal] = useToggle(false);
const [forceRunModalIsOpen, toggleForceRunModal] = useToggle(false);
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: (id: number) => APIClient.feeds.delete(id), mutationFn: (id: number) => APIClient.feeds.delete(id),
@ -248,6 +250,22 @@ const FeedItemDropdown = ({
} }
}); });
const forceRunMutation = useMutation({
mutationFn: (id: number) => APIClient.feeds.forceRun(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: feedKeys.lists() });
toast.custom((t) => <Toast type="success" body={`Feed ${feed?.name} was force run successfully.`} t={t} />);
toggleForceRunModal();
},
onError: (error: any) => {
toast.custom((t) => <Toast type="error" body={`Failed to force run ${feed?.name}. Error: ${error.message}`} t={t} />, {
duration: 10000
});
toggleForceRunModal();
}
});
return ( return (
<Menu as="div"> <Menu as="div">
<DeleteModal <DeleteModal
@ -273,6 +291,18 @@ const FeedItemDropdown = ({
title={`Remove feed cache: ${feed.name}`} title={`Remove feed cache: ${feed.name}`}
text="Are you sure you want to remove the feed cache? This action cannot be undone." text="Are you sure you want to remove the feed cache? This action cannot be undone."
/> />
<ForceRunModal
isOpen={forceRunModalIsOpen}
isLoading={forceRunMutation.isLoading}
toggle={toggleForceRunModal}
buttonRef={cancelModalButtonRef}
forceRunAction={() => {
forceRunMutation.mutate(feed.id);
toggleForceRunModal();
}}
title={`Force run feed: ${feed.name}`}
text={`Are you sure you want to force run the ${feed.name} feed? Respecting RSS interval rules is crucial to avoid potential IP bans.`}
/>
<Menu.Button className="px-4 py-2"> <Menu.Button className="px-4 py-2">
<EllipsisHorizontalIcon <EllipsisHorizontalIcon
className="w-5 h-5 text-gray-700 hover:text-gray-900 dark:text-gray-100 dark:hover:text-gray-400" className="w-5 h-5 text-gray-700 hover:text-gray-900 dark:text-gray-100 dark:hover:text-gray-400"
@ -334,6 +364,26 @@ const FeedItemDropdown = ({
</Menu.Item> </Menu.Item>
</div> </div>
<div className="px-1 py-1"> <div className="px-1 py-1">
<Menu.Item>
{({ active }) => (
<button
onClick={() => toggleForceRunModal()}
className={classNames(
active ? "bg-blue-600 text-white" : "text-gray-900 dark:text-gray-300",
"font-medium group flex rounded-md items-center w-full px-2 py-2 text-sm"
)}
>
<ForwardIcon
className={classNames(
active ? "text-white" : "text-blue-500",
"w-5 h-5 mr-2"
)}
aria-hidden="true"
/>
Force run
</button>
)}
</Menu.Item>
<Menu.Item> <Menu.Item>
<ExternalLink <ExternalLink
href={`${baseUrl()}api/feeds/${feed.id}/latest`} href={`${baseUrl()}api/feeds/${feed.id}/latest`}