diff --git a/internal/database/release.go b/internal/database/release.go index f0e3fc2..8e60f8b 100644 --- a/internal/database/release.go +++ b/internal/database/release.go @@ -582,7 +582,27 @@ func (repo *ReleaseRepo) Delete(ctx context.Context, req *domain.DeleteReleaseRe return errors.Wrap(err, "could not start transaction") } - defer tx.Rollback() + defer func() { + var txErr error + if p := recover(); p != nil { + txErr = tx.Rollback() + if txErr != nil { + repo.log.Error().Err(txErr).Msg("error rolling back transaction") + } + repo.log.Error().Msgf("something went terribly wrong panic: %v", p) + } else if err != nil { + txErr = tx.Rollback() + if txErr != nil { + repo.log.Error().Err(txErr).Msg("error rolling back transaction") + } + } else { + // All good, commit + txErr = tx.Commit() + if txErr != nil { + repo.log.Error().Err(txErr).Msg("error committing transaction") + } + } + }() qb := repo.db.squirrel.Delete("release") @@ -599,16 +619,30 @@ func (repo *ReleaseRepo) Delete(ctx context.Context, req *domain.DeleteReleaseRe } } - query, args, err := qb.ToSql() - if err != nil { - return errors.Wrap(err, "error executing query") + if len(req.Indexers) > 0 { + qb = qb.Where(sq.Eq{"indexer": req.Indexers}) } - repo.log.Debug().Str("repo", "release").Str("query", query).Msgf("release.delete: args: %v", args) + if len(req.ReleaseStatuses) > 0 { + subQuery := sq.Select("release_id").From("release_action_status").Where(sq.Eq{"status": req.ReleaseStatuses}) + subQueryText, subQueryArgs, err := subQuery.ToSql() + if err != nil { + return errors.Wrap(err, "error building subquery") + } + qb = qb.Where("id IN ("+subQueryText+")", subQueryArgs...) + } + + query, args, err := qb.ToSql() + if err != nil { + return errors.Wrap(err, "error building SQL query") + } + + repo.log.Trace().Str("query", query).Interface("args", args).Msg("Executing combined delete query") result, err := tx.ExecContext(ctx, query, args...) if err != nil { - return errors.Wrap(err, "error executing query") + repo.log.Error().Err(err).Str("query", query).Interface("args", args).Msg("Error executing combined delete query") + return errors.Wrap(err, "error executing delete query") } deletedRows, err := result.RowsAffected() @@ -616,16 +650,20 @@ func (repo *ReleaseRepo) Delete(ctx context.Context, req *domain.DeleteReleaseRe return errors.Wrap(err, "error fetching rows affected") } - _, err = tx.ExecContext(ctx, `DELETE FROM release_action_status WHERE release_id NOT IN (SELECT id FROM "release")`) + repo.log.Debug().Msgf("deleted %d rows from release table", deletedRows) + + // clean up orphaned rows + orphanedResult, 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") + deletedRowsOrphaned, err := orphanedResult.RowsAffected() + if err != nil { + return errors.Wrap(err, "error fetching rows affected") } - repo.log.Debug().Msgf("deleted %d rows from release table", deletedRows) + repo.log.Debug().Msgf("deleted %d orphaned rows from release table", deletedRowsOrphaned) return nil } diff --git a/internal/domain/release.go b/internal/domain/release.go index 21e773c..39fb0c0 100644 --- a/internal/domain/release.go +++ b/internal/domain/release.go @@ -124,7 +124,9 @@ type ReleaseActionStatus struct { } type DeleteReleaseRequest struct { - OlderThan int + OlderThan int + Indexers []string + ReleaseStatuses []string } func NewReleaseActionStatus(action *Action, release *Release) *ReleaseActionStatus { diff --git a/internal/http/release.go b/internal/http/release.go index 9aabff8..492c61b 100644 --- a/internal/http/release.go +++ b/internal/http/release.go @@ -211,6 +211,31 @@ func (h releaseHandler) deleteReleases(w http.ResponseWriter, r *http.Request) { req.OlderThan = duration } + indexers := r.URL.Query()["indexer"] + if len(indexers) > 0 { + req.Indexers = indexers + } + + releaseStatuses := r.URL.Query()["releaseStatus"] + validStatuses := map[string]bool{ + "PUSH_APPROVED": true, + "PUSH_REJECTED": true, + "PUSH_ERROR": true, + } + var filteredStatuses []string + for _, status := range releaseStatuses { + if _, valid := validStatuses[status]; valid { + filteredStatuses = append(filteredStatuses, status) + } else { + h.encoder.StatusResponse(w, http.StatusBadRequest, map[string]interface{}{ + "code": "INVALID_RELEASE_STATUS", + "message": "releaseStatus contains invalid value", + }) + return + } + } + req.ReleaseStatuses = filteredStatuses + if err := h.service.Delete(r.Context(), &req); err != nil { h.encoder.Error(w, err) return diff --git a/web/src/api/APIClient.ts b/web/src/api/APIClient.ts index fa1ffb0..b87784e 100644 --- a/web/src/api/APIClient.ts +++ b/web/src/api/APIClient.ts @@ -15,6 +15,14 @@ interface HttpConfig { queryString?: Record; } +interface DeleteParams { + olderThan?: number; + indexers?: string[]; + releaseStatuses?: string[]; +} + +type QueryStringParams = Record; + // See https://stackoverflow.com/a/62969380 function encodeRFC3986URIComponent(str: string): string { return encodeURIComponent(str).replace( @@ -338,9 +346,22 @@ export const APIClient = { }, indexerOptions: () => appClient.Get("api/release/indexers"), stats: () => appClient.Get("api/release/stats"), - delete: (olderThan: number) => appClient.Delete("api/release", { - queryString: { olderThan } - }), + delete: (params: DeleteParams) => { + const queryString: QueryStringParams = {}; + if (params.olderThan !== undefined) { + queryString.olderThan = params.olderThan.toString(); + } + if (params.indexers && params.indexers.length > 0) { + queryString.indexer = params.indexers; + } + if (params.releaseStatuses && params.releaseStatuses.length > 0) { + queryString.releaseStatus = params.releaseStatuses; + } + + return appClient.Delete("api/release", { + queryString + }); + }, replayAction: (releaseId: number, actionId: number) => appClient.Post( `api/release/${releaseId}/actions/${actionId}/retry` ) diff --git a/web/src/components/inputs/select.tsx b/web/src/components/inputs/select.tsx index 59a664f..818b1f9 100644 --- a/web/src/components/inputs/select.tsx +++ b/web/src/components/inputs/select.tsx @@ -476,3 +476,85 @@ export const SelectWide = ({ ); }; + +export const AgeSelect = ({ + duration, + setDuration, + setParsedDuration, + columns = 6 +}: { + duration: string; + setDuration: (value: string) => void; + setParsedDuration: (value: number) => void; + columns?: number; +}) => { + const options = [ + { value: '1', label: '1 hour' }, + { value: '12', label: '12 hours' }, + { value: '24', label: '1 day' }, + { value: '168', label: '1 week' }, + { value: '720', label: '1 month' }, + { value: '2160', label: '3 months' }, + { value: '4320', label: '6 months' }, + { value: '8760', label: '1 year' }, + { value: '0', label: 'Delete everything' } + ]; + + return ( +
+ { + const parsedValue = parseInt(value, 10); + setParsedDuration(parsedValue); + setDuration(value); + }}> + {({ open }) => ( + <> +
+ + + {duration ? options.find(opt => opt.value === duration)?.label : 'Select...'} + + + + + + + {options.map((option) => ( + + `relative cursor-default select-none py-2 pl-3 pr-9 ${selected ? "font-bold text-black dark:text-white bg-gray-300 dark:bg-gray-950" : active ? "text-black dark:text-gray-100 font-normal bg-gray-200 dark:bg-gray-800" : "text-gray-700 dark:text-gray-300 font-normal" + }` + } + value={option.value} + > + {({ selected }) => ( + <> + {option.label} + {selected && ( + + + )} + + )} + + ))} + + +
+ + )} +
+
+ ); +}; + + diff --git a/web/src/screens/settings/Releases.tsx b/web/src/screens/settings/Releases.tsx index 93e57cd..09b6a1e 100644 --- a/web/src/screens/settings/Releases.tsx +++ b/web/src/screens/settings/Releases.tsx @@ -4,8 +4,10 @@ */ import { useRef, useState } from "react"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query"; import { toast } from "react-hot-toast"; +import { MultiSelect as RMSC } from "react-multi-select-component"; +import { AgeSelect } from "@components/inputs" import { APIClient } from "@api/APIClient"; import { ReleaseKeys } from "@api/query_keys"; @@ -20,19 +22,11 @@ const ReleaseSettings = () => ( description="Manage release history." >
-
-
-

Danger zone

-

- This will clear release history in your database -

-
-
-
+ ); @@ -53,38 +47,65 @@ const getDurationLabel = (durationValue: number): string => { return durationOptions[durationValue] || "Invalid duration"; }; +interface Indexer { + label: string; + value: string; +} + +interface ReleaseStatus { + label: string; + value: string; +} + function DeleteReleases() { const queryClient = useQueryClient(); const [duration, setDuration] = useState(""); - const [parsedDuration, setParsedDuration] = useState(0); + const [parsedDuration, setParsedDuration] = useState(); + const [indexers, setIndexers] = useState([]); + const [releaseStatuses, setReleaseStatuses] = useState([]); const cancelModalButtonRef = useRef(null); const [deleteModalIsOpen, toggleDeleteModal] = useToggle(false); + const { data: indexerOptions } = useQuery({ + queryKey: ['indexers'], + queryFn: () => APIClient.indexers.getAll(), + select: data => data.map(indexer => ({ + identifier: indexer.identifier, + name: indexer.name + })), + }); + + const releaseStatusOptions = [ + { label: "Approved", value: "PUSH_APPROVED" }, + { label: "Rejected", value: "PUSH_REJECTED" }, + { label: "Errored", value: "PUSH_ERROR" } + ]; + const deleteOlderMutation = useMutation({ - mutationFn: (olderThan: number) => APIClient.release.delete(olderThan), + mutationFn: (params: { olderThan: number, indexers: string[], releaseStatuses: string[] }) => + APIClient.release.delete(params), onSuccess: () => { if (parsedDuration === 0) { toast.custom((t) => ( - + )); } else { toast.custom((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) => ); + if (parsedDuration === undefined || isNaN(parsedDuration) || parsedDuration < 0) { + toast.custom((t) => ); return; } - deleteOlderMutation.mutate(parsedDuration); + deleteOlderMutation.mutate({ olderThan: parsedDuration, indexers: indexers.map(i => i.value), releaseStatuses: releaseStatuses.map(rs => rs.value) }); }; return ( @@ -96,46 +117,79 @@ function DeleteReleases() { 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.`} + text={`You are about to ${parsedDuration ? `permanently delete all release history records older than ${getDurationLabel(parsedDuration)} for ` : 'delete all release history records for '}${indexers.length ? 'the chosen indexers' : 'all indexers'}${releaseStatuses.length ? ` and with the following release statuses: ${releaseStatuses.map(status => status.label).join(', ')}` : ''}.`} /> +
+
+

Delete release history

+

+ Select the criteria below to permanently delete release history records that are older than the chosen age and optionally match the selected indexers and release statuses: +

    +
  • + Older than (e.g., 6 months - all records older than 6 months will be deleted) - Required +
  • +
  • Indexers - Optional (if none selected, applies to all indexers)
  • +
  • Release statuses - Optional (if none selected, applies to all release statuses)
  • +
+

+ Warning: If no indexers or release statuses are selected, all release history records older than the selected age will be permanently deleted, regardless of indexer or status. +

+

+
- -
- - +
+ {[ + { + label: ( + <> + Older than: + * + + ), + content: + }, + { + label: 'Indexers:', + content: ({ value: option.identifier, label: option.name })) || []} value={indexers} onChange={setIndexers} labelledBy="Select indexers" /> + }, + { + label: 'Release statuses:', + content: + } + ].map((item, index) => ( +
+

{item.label}

+ {item.content} +
+ ))} + + +
+ ); + } export default ReleaseSettings;