feat(releases): delete older than x (#924)

* feat: delete releases older than x

* check timestamp

* incomplete front end changes

commiting changes from codespace to not lose them

* change to dropdown with options

* using int comparisons to avoid nightmares

* Revert "using int comparisons to avoid nightmares"

This reverts commit dc55966a73e9f6ad79ed28c3a3e0dbe0e35448a6.

* suggestions by stacksmash76

come back to discord @stacksmash76

* Curves - a touch of warmth in our pixel realm

* replace inline css with tailwind

* remove unnecessary comment

* align label with dropdown
changed first paragraph to something more sensible

* change font weight for duration label

* padding changes

* nitpicky

* merged divs where possible

* small adjustments for light theme

* attempt to fix for postgres

* refactor: split into component and add confirmation modal

also restyle component

* fix: go fmt

---------

Co-authored-by: ze0s <43699394+zze0s@users.noreply.github.com>
This commit is contained in:
soup 2023-05-21 18:39:28 +02:00 committed by GitHub
parent 1f76aa38f4
commit f774831d76
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 182 additions and 54 deletions

View file

@ -18,6 +18,7 @@ import (
sq "github.com/Masterminds/squirrel" sq "github.com/Masterminds/squirrel"
"github.com/lib/pq" "github.com/lib/pq"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"github.com/rs/zerolog/log"
) )
type ReleaseRepo struct { type ReleaseRepo struct {
@ -585,6 +586,40 @@ func (repo *ReleaseRepo) Delete(ctx context.Context) error {
return nil return nil
} }
func (repo *ReleaseRepo) DeleteOlder(ctx context.Context, duration int) error {
tx, err := repo.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
olderThanTimestamp := time.Now().UTC().Add(-time.Duration(duration) * time.Hour)
log.Debug().Msgf("Deleting releases older than: %v", olderThanTimestamp)
result, err := tx.ExecContext(ctx, `DELETE FROM "release" WHERE timestamp < $1`, olderThanTimestamp)
if err != nil {
return errors.Wrap(err, "error executing query")
}
deletedRows, err := result.RowsAffected()
if err != nil {
return errors.Wrap(err, "error fetching rows affected")
}
log.Debug().Msgf("Deleted %d rows from release table", deletedRows)
_, err = tx.ExecContext(ctx, `DELETE FROM release_action_status WHERE release_id NOT IN (SELECT id FROM "release")`)
if err != nil {
return errors.Wrap(err, "error executing query")
}
if err := tx.Commit(); err != nil {
return errors.Wrap(err, "error commit transaction delete")
}
return nil
}
func (repo *ReleaseRepo) CanDownloadShow(ctx context.Context, title string, season int, episode int) (bool, error) { func (repo *ReleaseRepo) CanDownloadShow(ctx context.Context, title string, season int, episode int) (bool, error) {
// TODO support non season episode shows // TODO support non season episode shows
// if rls.Day > 0 { // if rls.Day > 0 {

View file

@ -34,6 +34,7 @@ type ReleaseRepo interface {
GetIndexerOptions(ctx context.Context) ([]string, error) GetIndexerOptions(ctx context.Context) ([]string, error)
Stats(ctx context.Context) (*ReleaseStats, error) Stats(ctx context.Context) (*ReleaseStats, error)
Delete(ctx context.Context) error Delete(ctx context.Context) error
DeleteOlder(ctx context.Context, duration int) error
CanDownloadShow(ctx context.Context, title string, season int, episode int) (bool, error) CanDownloadShow(ctx context.Context, title string, season int, episode int) (bool, error)
GetActionStatus(ctx context.Context, req *GetReleaseActionStatusRequest) (*ReleaseActionStatus, error) GetActionStatus(ctx context.Context, req *GetReleaseActionStatusRequest) (*ReleaseActionStatus, error)

View file

@ -20,6 +20,7 @@ type releaseService interface {
GetIndexerOptions(ctx context.Context) ([]string, error) GetIndexerOptions(ctx context.Context) ([]string, error)
Stats(ctx context.Context) (*domain.ReleaseStats, error) Stats(ctx context.Context) (*domain.ReleaseStats, error)
Delete(ctx context.Context) error Delete(ctx context.Context) error
DeleteOlder(ctx context.Context, duration int) error
Retry(ctx context.Context, req *domain.ReleaseActionRetryReq) error Retry(ctx context.Context, req *domain.ReleaseActionRetryReq) error
} }
@ -41,6 +42,7 @@ func (h releaseHandler) Routes(r chi.Router) {
r.Get("/stats", h.getStats) r.Get("/stats", h.getStats)
r.Get("/indexers", h.getIndexerOptions) r.Get("/indexers", h.getIndexerOptions)
r.Delete("/all", h.deleteReleases) r.Delete("/all", h.deleteReleases)
r.Delete("/older-than/{duration}", h.deleteOlder)
r.Route("/{releaseId}", func(r chi.Router) { r.Route("/{releaseId}", func(r chi.Router) {
r.Post("/actions/{actionStatusId}/retry", h.retryAction) r.Post("/actions/{actionStatusId}/retry", h.retryAction)
@ -193,6 +195,28 @@ func (h releaseHandler) deleteReleases(w http.ResponseWriter, r *http.Request) {
h.encoder.NoContent(w) h.encoder.NoContent(w)
} }
func (h releaseHandler) deleteOlder(w http.ResponseWriter, r *http.Request) {
durationStr := chi.URLParam(r, "duration")
duration, err := strconv.Atoi(durationStr)
if err != nil {
h.encoder.StatusResponse(w, http.StatusBadRequest, map[string]interface{}{
"code": "BAD_REQUEST_PARAMS",
"message": "Invalid duration",
})
return
}
if err := h.service.DeleteOlder(r.Context(), duration); err != nil {
h.encoder.StatusResponse(w, http.StatusInternalServerError, map[string]interface{}{
"code": "INTERNAL_SERVER_ERROR",
"message": err.Error(),
})
return
}
h.encoder.NoContent(w)
}
func (h releaseHandler) retryAction(w http.ResponseWriter, r *http.Request) { func (h releaseHandler) retryAction(w http.ResponseWriter, r *http.Request) {
var ( var (
req *domain.ReleaseActionRetryReq req *domain.ReleaseActionRetryReq

View file

@ -26,7 +26,7 @@ type Service interface {
Store(ctx context.Context, release *domain.Release) error Store(ctx context.Context, release *domain.Release) error
StoreReleaseActionStatus(ctx context.Context, actionStatus *domain.ReleaseActionStatus) error StoreReleaseActionStatus(ctx context.Context, actionStatus *domain.ReleaseActionStatus) error
Delete(ctx context.Context) error Delete(ctx context.Context) error
DeleteOlder(ctx context.Context, duration int) error
Process(release *domain.Release) Process(release *domain.Release)
ProcessMultiple(releases []*domain.Release) ProcessMultiple(releases []*domain.Release)
Retry(ctx context.Context, req *domain.ReleaseActionRetryReq) error Retry(ctx context.Context, req *domain.ReleaseActionRetryReq) error
@ -95,6 +95,10 @@ func (s *service) Delete(ctx context.Context) error {
return s.repo.Delete(ctx) return s.repo.Delete(ctx)
} }
func (s *service) DeleteOlder(ctx context.Context, duration int) error {
return s.repo.DeleteOlder(ctx, duration)
}
func (s *service) Process(release *domain.Release) { func (s *service) Process(release *domain.Release) {
if release == nil { if release == nil {
return return

View file

@ -196,6 +196,7 @@ export const APIClient = {
indexerOptions: () => appClient.Get<string[]>("api/release/indexers"), indexerOptions: () => appClient.Get<string[]>("api/release/indexers"),
stats: () => appClient.Get<ReleaseStats>("api/release/stats"), stats: () => appClient.Get<ReleaseStats>("api/release/stats"),
delete: () => appClient.Delete("api/release/all"), delete: () => appClient.Delete("api/release/all"),
deleteOlder: (duration: number) => appClient.Delete(`api/release/older-than/${duration}`),
replayAction: (releaseId: number, actionId: number) => appClient.Post(`api/release/${releaseId}/actions/${actionId}/retry`) replayAction: (releaseId: number, actionId: number) => appClient.Post(`api/release/${releaseId}/actions/${actionId}/retry`)
}, },
updates: { updates: {

View file

@ -3,84 +3,147 @@
* SPDX-License-Identifier: GPL-2.0-or-later * SPDX-License-Identifier: GPL-2.0-or-later
*/ */
import { useRef } from "react"; import React, { useRef, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { APIClient } from "@api/APIClient"; import { APIClient } from "@api/APIClient";
import Toast from "@components/notifications/Toast"; import Toast from "@components/notifications/Toast";
import { releaseKeys } from "@screens/releases/ReleaseTable";
import { useToggle } from "@hooks/hooks"; import { useToggle } from "@hooks/hooks";
import { DeleteModal } from "@components/modals"; import { DeleteModal } from "@components/modals";
import { releaseKeys } from "@screens/releases/ReleaseTable";
function ReleaseSettings() { function ReleaseSettings() {
const [deleteModalIsOpen, toggleDeleteModal] = useToggle(false);
const queryClient = useQueryClient();
const deleteMutation = useMutation({
mutationFn: APIClient.release.delete,
onSuccess: () => {
toast.custom((t) => (
<Toast type="success" body={"All releases were deleted"} t={t}/>
));
// Invalidate filters just in case, most likely not necessary but can't hurt.
queryClient.invalidateQueries({ queryKey: releaseKeys.lists() });
}
});
const deleteAction = () => deleteMutation.mutate();
const cancelModalButtonRef = useRef(null);
return ( return (
<form <div className="lg:col-span-9">
className="lg:col-span-9"
action="#"
method="POST"
>
<DeleteModal
isOpen={deleteModalIsOpen}
toggle={toggleDeleteModal}
buttonRef={cancelModalButtonRef}
deleteAction={deleteAction}
title={"Delete all releases"}
text="Are you sure you want to delete all releases? This action cannot be undone."
/>
<div className="py-6 px-4 sm:p-6 lg:pb-8"> <div className="py-6 px-4 sm:p-6 lg:pb-8">
<div> <div>
<h2 className="text-lg leading-6 font-medium text-gray-900 dark:text-white"> <h2 className="text-lg leading-6 font-medium text-gray-900 dark:text-white">
Releases Releases
</h2> </h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400"> <p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Release settings. Reset state. Manage release history.
</p> </p>
</div> </div>
</div> </div>
<div className="pb-6 divide-y divide-gray-200 dark:divide-gray-700"> <div className="py-6 px-4">
<div className="px-4 py-5 sm:p-0"> <div className="border border-red-500 rounded">
<div className="px-4 py-5 sm:p-6"> <div className="py-6 px-4 sm:p-6 lg:pb-8">
<div> <div>
<h3 style={{ textAlign: "center" }} className="text-lg leading-6 font-medium text-gray-900 dark:text-white"> <h2 className="text-lg leading-6 font-medium text-gray-900 dark:text-white">Danger zone</h2>
Danger Zone <p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
</h3> This will clear release history in your database
<p style={{ textAlign: "center" }} className="mt-1 text-sm text-gray-900 dark:text-white">This will clear all release history in your database.</p> </p>
</div> </div>
<div className="flex justify-between items-center p-2 mt-2 max-w-sm m-auto"> </div>
<div className="py-6 px-4 sm:p-6 lg:pb-8">
<DeleteReleases />
</div>
</div>
</div>
</div>
);
}
const getDurationLabel = (durationValue: number): string => {
const durationOptions: Record<number, string> = {
0: "all time",
1: "1 hour",
12: "12 hours",
24: "1 day",
168: "1 week",
720: "1 month",
2160: "3 months",
4320: "6 months",
8760: "1 year"
};
return durationOptions[durationValue] || "Invalid duration";
};
function DeleteReleases() {
const queryClient = useQueryClient();
const [duration, setDuration] = useState<string>("");
const [parsedDuration, setParsedDuration] = useState<number>(0);
const cancelModalButtonRef = useRef<HTMLInputElement | null>(null);
const [deleteModalIsOpen, toggleDeleteModal] = useToggle(false);
const deleteOlderMutation = useMutation({
mutationFn: (duration: number) => APIClient.release.deleteOlder(duration),
onSuccess: () => {
if (parsedDuration === 0) {
toast.custom((t) => (
<Toast type="success" body={"All releases were deleted."} t={t} />
));
} else {
toast.custom((t) => (
<Toast type="success" body={`Releases older than ${getDurationLabel(parsedDuration)} were deleted.`} t={t} />
));
}
// Invalidate filters just in case, most likely not necessary but can't hurt.
queryClient.invalidateQueries({ queryKey: releaseKeys.lists() });
}
});
const deleteOlderReleases = () => {
if (isNaN(parsedDuration) || parsedDuration < 0) {
toast.custom((t) => <Toast type="error" body={"Please select a valid duration."} t={t} />);
return;
}
deleteOlderMutation.mutate(parsedDuration);
};
return (
<div className="flex justify-between items-center rounded-md shadow-sm">
<DeleteModal
isOpen={deleteModalIsOpen}
toggle={toggleDeleteModal}
buttonRef={cancelModalButtonRef}
deleteAction={deleteOlderReleases}
title="Remove releases"
text={`Are you sure you want to remove releases older than ${getDurationLabel(parsedDuration)}? This action cannot be undone.`}
/>
<label htmlFor="duration" className="flex flex-col">
<p className="text-sm font-medium text-gray-900 dark:text-white">Delete</p>
<p className="text-sm text-gray-500 dark:text-gray-400">Delete releases older than select duration</p>
</label>
<div>
<select
name="duration"
id="duration"
className="focus:outline-none focus:ring-1 focus:ring-offset-0 focus:ring-blue-500 dark:focus:ring-blue-500 rounded-md sm:text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
value={duration}
onChange={(e) => {
const parsedDuration = parseInt(e.target.value, 10);
setParsedDuration(parsedDuration);
setDuration(e.target.value);
}}
>
<option value="">Select duration</option>
<option value="1">1 hour</option>
<option value="12">12 hours</option>
<option value="24">1 day</option>
<option value="168">1 week</option>
<option value="720">1 month</option>
<option value="2160">3 months</option>
<option value="4320">6 months</option>
<option value="8760">1 year</option>
<option value="0">Delete everything</option>
</select>
<button <button
type="button" type="button"
onClick={toggleDeleteModal} onClick={toggleDeleteModal}
className="w-full inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-red-700 hover:text-red-900 dark:text-white bg-red-100 dark:bg-red-800 hover:bg-red-200 dark:hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:text-sm" className="ml-2 inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-red-700 hover:text-red-800 dark:text-white bg-red-200 dark:bg-red-700 hover:bg-red-300 dark:hover:bg-red-800 focus:outline-none focus:ring-1 focus:ring-inset focus:ring-red-600"
> >
Delete all releases Delete
</button> </button>
</div> </div>
</div> </div>
</div>
</div>
</form>
); );
} }