From 246e3ddc26cd10c49e84a8cc97d6c27fe4203762 Mon Sep 17 00:00:00 2001 From: Ludvig Lundgren Date: Sat, 19 Feb 2022 20:00:48 +0100 Subject: [PATCH] feat(web): releases list filtering (#136) --- internal/database/release.go | 60 ++++++- internal/domain/release.go | 18 +- internal/http/release.go | 53 +++++- internal/release/service.go | 9 +- web/src/api/APIClient.ts | 24 +++ web/src/domain/constants.ts | 17 +- web/src/screens/Releases.tsx | 326 ++++++++++++++++++++++++++++++++--- 7 files changed, 459 insertions(+), 48 deletions(-) diff --git a/internal/database/release.go b/internal/database/release.go index 74a3b7c..590672a 100644 --- a/internal/database/release.go +++ b/internal/database/release.go @@ -84,14 +84,14 @@ func (repo *ReleaseRepo) StoreReleaseActionStatus(ctx context.Context, a *domain return nil } -func (repo *ReleaseRepo) Find(ctx context.Context, params domain.QueryParams) ([]domain.Release, int64, int64, error) { +func (repo *ReleaseRepo) Find(ctx context.Context, params domain.ReleaseQueryParams) ([]domain.Release, int64, int64, error) { //r.db.lock.RLock() //defer r.db.lock.RUnlock() queryBuilder := sq. - Select("id", "filter_status", "rejections", "indexer", "filter", "protocol", "title", "torrent_name", "size", "timestamp", "COUNT() OVER() AS total_count"). - From("release"). - OrderBy("timestamp DESC") + Select("r.id", "r.filter_status", "r.rejections", "r.indexer", "r.filter", "r.protocol", "r.title", "r.torrent_name", "r.size", "r.timestamp", "COUNT() OVER() AS total_count"). + From("release r"). + OrderBy("r.timestamp DESC") if params.Limit > 0 { queryBuilder = queryBuilder.Limit(params.Limit) @@ -104,18 +104,22 @@ func (repo *ReleaseRepo) Find(ctx context.Context, params domain.QueryParams) ([ } if params.Cursor > 0 { - queryBuilder = queryBuilder.Where(sq.Lt{"id": params.Cursor}) + queryBuilder = queryBuilder.Where(sq.Lt{"r.id": params.Cursor}) } - if params.Filter != nil { + if params.Filters.Indexers != nil { filter := sq.And{} - for k, v := range params.Filter { - filter = append(filter, sq.Eq{k: v}) + for _, v := range params.Filters.Indexers { + filter = append(filter, sq.Eq{"r.indexer": v}) } queryBuilder = queryBuilder.Where(filter) } + if params.Filters.PushStatus != "" { + queryBuilder = queryBuilder.InnerJoin("release_action_status ras ON r.id = ras.release_id").Where(sq.Eq{"ras.status": params.Filters.PushStatus}) + } + query, args, err := queryBuilder.ToSql() log.Trace().Str("database", "release.find").Msgf("query: '%v', args: '%v'", query, args) @@ -171,6 +175,46 @@ func (repo *ReleaseRepo) Find(ctx context.Context, params domain.QueryParams) ([ return res, nextCursor, countItems, nil } +func (repo *ReleaseRepo) GetIndexerOptions(ctx context.Context) ([]string, error) { + //r.db.lock.RLock() + //defer r.db.lock.RUnlock() + + query := ` + SELECT DISTINCT indexer FROM "release" + UNION + SELECT DISTINCT identifier indexer FROM indexer;` + + log.Trace().Str("database", "release.get_indexers").Msgf("query: '%v'", query) + + res := make([]string, 0) + + rows, err := repo.db.handler.QueryContext(ctx, query) + if err != nil { + log.Error().Stack().Err(err).Msg("error fetching indexer list") + return res, err + } + + defer rows.Close() + + if err := rows.Err(); err != nil { + log.Error().Stack().Err(err) + return res, err + } + + for rows.Next() { + var indexer string + + if err := rows.Scan(&indexer); err != nil { + log.Error().Stack().Err(err).Msg("release.find: error scanning data to struct") + return res, err + } + + res = append(res, indexer) + } + + return res, nil +} + func (repo *ReleaseRepo) GetActionStatusByReleaseID(ctx context.Context, releaseID int64) ([]domain.ReleaseActionStatus, error) { //r.db.lock.RLock() //defer r.db.lock.RUnlock() diff --git a/internal/domain/release.go b/internal/domain/release.go index 513dbd3..fb9dc98 100644 --- a/internal/domain/release.go +++ b/internal/domain/release.go @@ -26,7 +26,8 @@ import ( type ReleaseRepo interface { Store(ctx context.Context, release *Release) (*Release, error) - Find(ctx context.Context, params QueryParams) (res []Release, nextCursor int64, count int64, err error) + Find(ctx context.Context, params ReleaseQueryParams) (res []Release, nextCursor int64, count int64, err error) + GetIndexerOptions(ctx context.Context) ([]string, error) GetActionStatusByReleaseID(ctx context.Context, releaseID int64) ([]ReleaseActionStatus, error) Stats(ctx context.Context) (*ReleaseStats, error) StoreReleaseActionStatus(ctx context.Context, actionStatus *ReleaseActionStatus) error @@ -1485,11 +1486,14 @@ const ( ReleaseImplementationIRC ReleaseImplementation = "IRC" ) -type QueryParams struct { - Limit uint64 - Offset uint64 - Cursor uint64 - Sort map[string]string - Filter map[string]string +type ReleaseQueryParams struct { + Limit uint64 + Offset uint64 + Cursor uint64 + Sort map[string]string + Filters struct { + Indexers []string + PushStatus string + } Search string } diff --git a/internal/http/release.go b/internal/http/release.go index f4d9f9d..4cc347a 100644 --- a/internal/http/release.go +++ b/internal/http/release.go @@ -3,6 +3,7 @@ package http import ( "context" "net/http" + "net/url" "strconv" "github.com/autobrr/autobrr/internal/domain" @@ -10,7 +11,8 @@ import ( ) type releaseService interface { - Find(ctx context.Context, query domain.QueryParams) (res []domain.Release, nextCursor int64, count int64, err error) + Find(ctx context.Context, query domain.ReleaseQueryParams) (res []domain.Release, nextCursor int64, count int64, err error) + GetIndexerOptions(ctx context.Context) ([]string, error) Stats(ctx context.Context) (*domain.ReleaseStats, error) } @@ -29,6 +31,7 @@ func newReleaseHandler(encoder encoder, service releaseService) *releaseHandler func (h releaseHandler) Routes(r chi.Router) { r.Get("/", h.findReleases) r.Get("/stats", h.getStats) + r.Get("/indexers", h.getIndexerOptions) } func (h releaseHandler) findReleases(w http.ResponseWriter, r *http.Request) { @@ -40,6 +43,7 @@ func (h releaseHandler) findReleases(w http.ResponseWriter, r *http.Request) { "code": "BAD_REQUEST_PARAMS", "message": "limit parameter is invalid", }, http.StatusBadRequest) + return } if limit == 0 { limit = 20 @@ -52,23 +56,44 @@ func (h releaseHandler) findReleases(w http.ResponseWriter, r *http.Request) { "code": "BAD_REQUEST_PARAMS", "message": "offset parameter is invalid", }, http.StatusBadRequest) + return } cursorP := r.URL.Query().Get("cursor") - cursor, err := strconv.Atoi(cursorP) - if err != nil && cursorP != "" { - h.encoder.StatusResponse(r.Context(), w, map[string]interface{}{ - "code": "BAD_REQUEST_PARAMS", - "message": "cursor parameter is invalid", - }, http.StatusBadRequest) + cursor := 0 + if cursorP != "" { + cursor, err = strconv.Atoi(cursorP) + if err != nil && cursorP != "" { + h.encoder.StatusResponse(r.Context(), w, map[string]interface{}{ + "code": "BAD_REQUEST_PARAMS", + "message": "cursor parameter is invalid", + }, http.StatusBadRequest) + } + return } - query := domain.QueryParams{ + u, err := url.Parse(r.URL.String()) + if err != nil { + h.encoder.StatusResponse(r.Context(), w, map[string]interface{}{ + "code": "BAD_REQUEST_PARAMS", + "message": "indexer parameter is invalid", + }, http.StatusBadRequest) + return + } + vals := u.Query() + indexer := vals["indexer"] + + pushStatus := r.URL.Query().Get("push_status") + + query := domain.ReleaseQueryParams{ Limit: uint64(limit), Offset: uint64(offset), Cursor: uint64(cursor), Sort: nil, - //Filter: "", + Filters: struct { + Indexers []string + PushStatus string + }{Indexers: indexer, PushStatus: pushStatus}, } releases, nextCursor, count, err := h.service.Find(r.Context(), query) @@ -90,6 +115,16 @@ func (h releaseHandler) findReleases(w http.ResponseWriter, r *http.Request) { h.encoder.StatusResponse(r.Context(), w, ret, http.StatusOK) } +func (h releaseHandler) getIndexerOptions(w http.ResponseWriter, r *http.Request) { + stats, err := h.service.GetIndexerOptions(r.Context()) + if err != nil { + h.encoder.StatusNotFound(r.Context(), w) + return + } + + h.encoder.StatusResponse(r.Context(), w, stats, http.StatusOK) +} + func (h releaseHandler) getStats(w http.ResponseWriter, r *http.Request) { stats, err := h.service.Stats(r.Context()) diff --git a/internal/release/service.go b/internal/release/service.go index 2e2af5d..023b020 100644 --- a/internal/release/service.go +++ b/internal/release/service.go @@ -10,7 +10,8 @@ import ( ) type Service interface { - Find(ctx context.Context, query domain.QueryParams) (res []domain.Release, nextCursor int64, count int64, err error) + Find(ctx context.Context, query domain.ReleaseQueryParams) (res []domain.Release, nextCursor int64, count int64, err error) + GetIndexerOptions(ctx context.Context) ([]string, error) Stats(ctx context.Context) (*domain.ReleaseStats, error) Store(ctx context.Context, release *domain.Release) error StoreReleaseActionStatus(ctx context.Context, actionStatus *domain.ReleaseActionStatus) error @@ -29,7 +30,7 @@ func NewService(repo domain.ReleaseRepo, actionService action.Service) Service { } } -func (s *service) Find(ctx context.Context, query domain.QueryParams) (res []domain.Release, nextCursor int64, count int64, err error) { +func (s *service) Find(ctx context.Context, query domain.ReleaseQueryParams) (res []domain.Release, nextCursor int64, count int64, err error) { res, nextCursor, count, err = s.repo.Find(ctx, query) if err != nil { return @@ -38,6 +39,10 @@ func (s *service) Find(ctx context.Context, query domain.QueryParams) (res []dom return } +func (s *service) GetIndexerOptions(ctx context.Context) ([]string, error) { + return s.repo.GetIndexerOptions(ctx) +} + func (s *service) Stats(ctx context.Context) (*domain.ReleaseStats, error) { stats, err := s.repo.Stats(ctx) if err != nil { diff --git a/web/src/api/APIClient.ts b/web/src/api/APIClient.ts index 1249f4b..5294988 100644 --- a/web/src/api/APIClient.ts +++ b/web/src/api/APIClient.ts @@ -97,6 +97,30 @@ export const APIClient = { }, release: { find: (query?: string) => appClient.Get(`api/release${query}`), + findQuery: (offset?: number, limit?: number, filters?: any[]) => { + let queryString = "?" + + if (offset != 0) { + queryString += `offset=${offset}` + } + if (limit != 0) { + queryString += `&limit=${limit}` + } + if (filters && filters?.length > 0) { + filters?.map((filter) => { + if (filter.id === "indexer" && filter.value != "") { + queryString += `&indexer=${filter.value}` + } + // using action_status instead of push_status because thats the column accessor + if (filter.id === "action_status" && filter.value != "") { + queryString += `&push_status=${filter.value}` + } + }) + } + + return appClient.Get(`api/release${queryString}`) + }, + indexerOptions: () => appClient.Get(`api/release/indexers`), stats: () => appClient.Get("api/release/stats") } }; \ No newline at end of file diff --git a/web/src/domain/constants.ts b/web/src/domain/constants.ts index b4180a9..00b3c49 100644 --- a/web/src/domain/constants.ts +++ b/web/src/domain/constants.ts @@ -210,4 +210,19 @@ export const ActionTypeNameMap = { "RADARR": "Radarr", "SONARR": "Sonarr", "LIDARR": "Lidarr", -}; \ No newline at end of file +}; + +export const PushStatusOptions: any[] = [ + { + label: "Rejected", + value: "PUSH_REJECTED", + }, + { + label: "Approved", + value: "PUSH_APPROVED" + }, + { + label: "Error", + value: "PUSH_ERROR" + }, +]; \ No newline at end of file diff --git a/web/src/screens/Releases.tsx b/web/src/screens/Releases.tsx index fc07004..f37dcc6 100644 --- a/web/src/screens/Releases.tsx +++ b/web/src/screens/Releases.tsx @@ -1,7 +1,7 @@ import * as React from "react"; import { useQuery } from "react-query"; import { formatDistanceToNowStrict } from "date-fns"; -import { useTable, useSortBy, usePagination, Column } from "react-table"; +import { useTable, useSortBy, usePagination, useAsyncDebounce, useFilters, Column } from "react-table"; import { ClockIcon, BanIcon, @@ -12,13 +12,18 @@ import { ChevronLeftIcon, ChevronRightIcon, ChevronDoubleRightIcon, - CheckIcon + CheckIcon, + ChevronDownIcon, } from "@heroicons/react/solid"; import { APIClient } from "../api/APIClient"; import { EmptyListState } from "../components/emptystates"; import { classNames, simplifyDate } from "../utils"; +import { Fragment } from "react"; +import { Listbox, Transition } from "@headlessui/react"; +import { PushStatusOptions } from "../domain/constants"; + export function Releases() { return (
@@ -34,6 +39,33 @@ export function Releases() { ) } +// // Define a default UI for filtering +// function GlobalFilter({ +// preGlobalFilteredRows, +// globalFilter, +// setGlobalFilter, +// }: any) { +// const count = preGlobalFilteredRows.length +// const [value, setValue] = React.useState(globalFilter) +// const onChange = useAsyncDebounce(value => { +// setGlobalFilter(value || undefined) +// }, 200) + +// return ( +// +// Search:{' '} +// { +// setValue(e.target.value); +// onChange(e.target.value); +// }} +// placeholder={`${count} records...`} +// /> +// +// ) +// } + // This is a custom filter UI for selecting // a unique option from a list export function SelectColumnFilter({ @@ -46,11 +78,16 @@ export function SelectColumnFilter({ preFilteredRows.forEach((row: { values: { [x: string]: unknown } }) => { options.add(row.values[id]) }) + return [...options.values()] }, [id, preFilteredRows]) + + const opts = ["PUSH_REJECTED"] // Render a multi-select box return ( +
+ +
+ ) +} + +// This is a custom filter UI for selecting +// a unique option from a list +export function IndexerSelectColumnFilter({ + column: { filterValue, setFilter, id }, +}: any) { + const { data, isSuccess } = useQuery( + ['release_indexers'], + () => APIClient.release.indexerOptions(), + { + keepPreviousData: true, + staleTime: Infinity, + } + ); + + const opts = isSuccess && data?.map(i => ({ value: i, label: i})) as any[] + + // Render a multi-select box + return ( +
+
+ +
+ + {filterValue ? filterValue : "Indexer"} + + + + + + + `cursor-default select-none relative py-2 pl-10 pr-4 ${ + active ? 'text-gray-500 dark:text-gray-200 bg-gray-300 dark:bg-gray-900' : 'text-gray-900 dark:text-gray-400' + }` + } + value={undefined} + > + {({ selected }) => ( + <> + + All + + {selected ? ( + + + ) : null} + + )} + + {isSuccess && data?.map((indexer, idx) => ( + + `cursor-default select-none relative py-2 pl-10 pr-4 ${ + active ? 'text-gray-500 dark:text-gray-200 bg-gray-300 dark:bg-gray-900' : 'text-gray-900 dark:text-gray-400' + }` + } + value={indexer} + > + {({ selected }) => ( + <> + + {indexer} + + {selected ? ( + + + ) : null} + + )} + + ))} + + +
+
+
+
+ ) +} + +export function PushStatusSelectColumnFilter({ + column: { filterValue, setFilter, id }, +}: any) { + return ( +
+ +
+ +
+ + {filterValue ? PushStatusOptions.find((o) => o.value === filterValue && o.value)!.label : "Push status"} + + + + + + + `cursor-default select-none relative py-2 pl-10 pr-4 ${ + active ? 'text-gray-500 dark:text-gray-200 bg-gray-300 dark:bg-gray-900' : 'text-gray-900 dark:text-gray-400' + }` + } + value={undefined} + > + {({ selected }) => ( + <> + + All + + {selected ? ( + + + ) : null} + + )} + + {PushStatusOptions.map((status, idx) => ( + + `cursor-default select-none relative py-2 pl-10 pr-4 ${ + active ? 'text-gray-500 dark:text-gray-200 bg-gray-300 dark:bg-gray-900' : 'text-gray-900 dark:text-gray-400' + }` + } + value={status.value} + > + {({ selected }) => ( + <> + + {status.label} + + {selected ? ( + + + ) : null} + + )} + + ))} + + +
+
+
+
) } @@ -164,11 +399,13 @@ const initialState = { queryPageIndex: 0, queryPageSize: 10, totalCount: null, + queryFilters: [] }; const PAGE_CHANGED = 'PAGE_CHANGED'; const PAGE_SIZE_CHANGED = 'PAGE_SIZE_CHANGED'; const TOTAL_COUNT_CHANGED = 'TOTAL_COUNT_CHANGED'; +const FILTER_CHANGED = 'FILTER_CHANGED'; const reducer = (state: any, { type, payload }: any) => { switch (type) { @@ -187,6 +424,12 @@ const reducer = (state: any, { type, payload }: any) => { ...state, totalCount: payload, }; + + case FILTER_CHANGED: + return { + ...state, + queryFilters: payload, + }; default: throw new Error(`Unhandled action type: ${type}`); } @@ -213,28 +456,38 @@ function Table() { Header: "Actions", accessor: 'action_status', Cell: ReleaseStatusCell, + Filter: PushStatusSelectColumnFilter, // new }, { Header: "Indexer", accessor: 'indexer', Cell: IndexerCell, - Filter: SelectColumnFilter, // new - filter: 'includes', + Filter: IndexerSelectColumnFilter, // new + filter: 'equal', + // filter: 'includes', }, ] as Column[], []) - const [{ queryPageIndex, queryPageSize, totalCount }, dispatch] = + const [{ queryPageIndex, queryPageSize, totalCount, queryFilters }, dispatch] = React.useReducer(reducer, initialState); const { isLoading, error, data, isSuccess } = useQuery( - ['releases', queryPageIndex, queryPageSize], - () => APIClient.release.find(`?offset=${queryPageIndex * queryPageSize}&limit=${queryPageSize}`), + ['releases', queryPageIndex, queryPageSize, queryFilters], + // () => APIClient.release.find(`?offset=${queryPageIndex * queryPageSize}&limit=${queryPageSize}${filterIndexer && `&indexer=${filterIndexer}`}`), + () => APIClient.release.findQuery(queryPageIndex * queryPageSize, queryPageSize, queryFilters), { keepPreviousData: true, staleTime: Infinity, } ); + // const initialFilters = React.useMemo(() => [ + // { + // id: "indexer", + // value: "", + // } + // ], []) + // Use the state and functions returned from useTable to build your UI const { getTableProps, @@ -254,21 +507,28 @@ function Table() { previousPage, setPageSize, - state: { pageIndex, pageSize }, + state: { pageIndex, pageSize, globalFilter, filters }, // preGlobalFilteredRows, // setGlobalFilter, + // preFilteredRows, } = useTable({ columns, data: data && isSuccess ? data.data : [], initialState: { pageIndex: queryPageIndex, pageSize: queryPageSize, + filters: [] + // filters: initialFilters }, manualPagination: true, + manualFilters: true, manualSortBy: true, pageCount: isSuccess ? Math.ceil(totalCount / queryPageSize) : 0, + autoResetSortBy: false, + autoResetExpanded: false, + autoResetPage: false }, - // useFilters, // useFilters! + useFilters, // useFilters! // useGlobalFilter, useSortBy, usePagination, // new @@ -292,6 +552,11 @@ function Table() { } }, [data?.count]); + React.useEffect(() => { + dispatch({ type: FILTER_CHANGED, payload: filters }); + }, [filters]); + + if (error) { return

Error

; } @@ -305,6 +570,25 @@ function Table() { <> {isSuccess && data ? (
+ {/* */} +
+ + {headerGroups.map((headerGroup: { headers: any[] }) => + headerGroup.headers.map((column) => + column.Filter ? ( +
+ {column.render("Filter")} +
+ ) : null + ) + )} +
+
@@ -388,7 +672,7 @@ function Table() { Page {pageIndex + 1} of {pageOptions.length}