From b75c40f6a45e4636eeb344c3947b7a18964ca063 Mon Sep 17 00:00:00 2001 From: Ludvig Lundgren Date: Sat, 25 Dec 2021 21:44:52 +0100 Subject: [PATCH] Feature: List releases (#52) * feat: list releases * feat: find releases and count --- internal/database/release.go | 25 +- internal/domain/release.go | 3 +- internal/http/release.go | 16 +- internal/release/service.go | 10 +- web/src/domain/interfaces.ts | 1 + web/src/screens/Base.tsx | 7 +- web/src/screens/Releases.tsx | 464 +++++++++++++++++++++++++++++++++++ 7 files changed, 506 insertions(+), 20 deletions(-) create mode 100644 web/src/screens/Releases.tsx diff --git a/internal/database/release.go b/internal/database/release.go index 6e34be9..fd77d39 100644 --- a/internal/database/release.go +++ b/internal/database/release.go @@ -76,9 +76,12 @@ func (repo *ReleaseRepo) UpdatePushStatusRejected(ctx context.Context, id int64, return nil } -func (repo *ReleaseRepo) Find(ctx context.Context, params domain.QueryParams) ([]domain.Release, int64, error) { +func (repo *ReleaseRepo) Find(ctx context.Context, params domain.QueryParams) ([]domain.Release, int64, int64, error) { - queryBuilder := sq.Select("id", "filter_status", "push_status", "rejections", "indexer", "filter", "protocol", "title", "torrent_name", "size", "timestamp").From("release").OrderBy("timestamp DESC") + queryBuilder := sq. + Select("id", "filter_status", "push_status", "rejections", "indexer", "filter", "protocol", "title", "torrent_name", "size", "timestamp", "COUNT() OVER() AS total_count"). + From("release"). + OrderBy("timestamp DESC") if params.Limit > 0 { queryBuilder = queryBuilder.Limit(params.Limit) @@ -86,8 +89,11 @@ func (repo *ReleaseRepo) Find(ctx context.Context, params domain.QueryParams) ([ queryBuilder = queryBuilder.Limit(20) } + if params.Offset > 0 { + queryBuilder = queryBuilder.Offset(params.Offset) + } + if params.Cursor > 0 { - //queryBuilder = queryBuilder.Where(sq.Gt{"id": params.Cursor}) queryBuilder = queryBuilder.Where(sq.Lt{"id": params.Cursor}) } @@ -108,26 +114,27 @@ func (repo *ReleaseRepo) Find(ctx context.Context, params domain.QueryParams) ([ rows, err := repo.db.QueryContext(ctx, query, args...) if err != nil { log.Error().Stack().Err(err).Msg("error fetching releases") - //return - return res, 0, nil + return res, 0, 0, nil } defer rows.Close() if err := rows.Err(); err != nil { log.Error().Stack().Err(err) - return res, 0, err + return res, 0, 0, err } + var countItems int64 = 0 + for rows.Next() { var rls domain.Release var indexer, filter sql.NullString var timestamp string - if err := rows.Scan(&rls.ID, &rls.FilterStatus, &rls.PushStatus, pq.Array(&rls.Rejections), &indexer, &filter, &rls.Protocol, &rls.Title, &rls.TorrentName, &rls.Size, ×tamp); err != nil { + if err := rows.Scan(&rls.ID, &rls.FilterStatus, &rls.PushStatus, pq.Array(&rls.Rejections), &indexer, &filter, &rls.Protocol, &rls.Title, &rls.TorrentName, &rls.Size, ×tamp, &countItems); err != nil { log.Error().Stack().Err(err).Msg("release.find: error scanning data to struct") - return res, 0, err + return res, 0, 0, err } rls.Indexer = indexer.String @@ -146,7 +153,7 @@ func (repo *ReleaseRepo) Find(ctx context.Context, params domain.QueryParams) ([ //nextCursor, _ = strconv.ParseInt(lastID, 10, 64) } - return res, nextCursor, nil + return res, nextCursor, countItems, nil } func (repo *ReleaseRepo) Stats(ctx context.Context) (*domain.ReleaseStats, error) { diff --git a/internal/domain/release.go b/internal/domain/release.go index 8a5ebbd..c791ec8 100644 --- a/internal/domain/release.go +++ b/internal/domain/release.go @@ -18,7 +18,7 @@ import ( type ReleaseRepo interface { Store(ctx context.Context, release *Release) (*Release, error) - Find(ctx context.Context, params QueryParams) (res []Release, nextCursor int64, err error) + Find(ctx context.Context, params QueryParams) (res []Release, nextCursor int64, count int64, err error) Stats(ctx context.Context) (*ReleaseStats, error) UpdatePushStatus(ctx context.Context, id int64, status ReleasePushStatus) error UpdatePushStatusRejected(ctx context.Context, id int64, rejections string) error @@ -913,6 +913,7 @@ const ( type QueryParams struct { Limit uint64 + Offset uint64 Cursor uint64 Sort map[string]string Filter map[string]string diff --git a/internal/http/release.go b/internal/http/release.go index 24c0b8e..f4d9f9d 100644 --- a/internal/http/release.go +++ b/internal/http/release.go @@ -10,7 +10,7 @@ import ( ) type releaseService interface { - Find(ctx context.Context, query domain.QueryParams) (res []domain.Release, nextCursor int64, err error) + Find(ctx context.Context, query domain.QueryParams) (res []domain.Release, nextCursor int64, count int64, err error) Stats(ctx context.Context) (*domain.ReleaseStats, error) } @@ -45,6 +45,15 @@ func (h releaseHandler) findReleases(w http.ResponseWriter, r *http.Request) { limit = 20 } + offsetP := r.URL.Query().Get("offset") + offset, err := strconv.Atoi(offsetP) + if err != nil && offsetP != "" { + h.encoder.StatusResponse(r.Context(), w, map[string]interface{}{ + "code": "BAD_REQUEST_PARAMS", + "message": "offset parameter is invalid", + }, http.StatusBadRequest) + } + cursorP := r.URL.Query().Get("cursor") cursor, err := strconv.Atoi(cursorP) if err != nil && cursorP != "" { @@ -56,12 +65,13 @@ func (h releaseHandler) findReleases(w http.ResponseWriter, r *http.Request) { query := domain.QueryParams{ Limit: uint64(limit), + Offset: uint64(offset), Cursor: uint64(cursor), Sort: nil, //Filter: "", } - releases, nextCursor, err := h.service.Find(r.Context(), query) + releases, nextCursor, count, err := h.service.Find(r.Context(), query) if err != nil { h.encoder.StatusNotFound(r.Context(), w) return @@ -70,9 +80,11 @@ func (h releaseHandler) findReleases(w http.ResponseWriter, r *http.Request) { ret := struct { Data []domain.Release `json:"data"` NextCursor int64 `json:"next_cursor"` + Count int64 `json:"count"` }{ Data: releases, NextCursor: nextCursor, + Count: count, } h.encoder.StatusResponse(r.Context(), w, ret, http.StatusOK) diff --git a/internal/release/service.go b/internal/release/service.go index c453798..eb7f2e4 100644 --- a/internal/release/service.go +++ b/internal/release/service.go @@ -11,7 +11,7 @@ import ( ) type Service interface { - Find(ctx context.Context, query domain.QueryParams) (res []domain.Release, nextCursor int64, err error) + Find(ctx context.Context, query domain.QueryParams) (res []domain.Release, nextCursor int64, count int64, err error) Stats(ctx context.Context) (*domain.ReleaseStats, error) Store(ctx context.Context, release *domain.Release) error UpdatePushStatus(ctx context.Context, id int64, status domain.ReleasePushStatus) error @@ -31,16 +31,12 @@ func NewService(repo domain.ReleaseRepo, actionService action.Service) Service { } } -func (s *service) Find(ctx context.Context, query domain.QueryParams) (res []domain.Release, nextCursor int64, err error) { - //releases, err := s.repo.Find(ctx, query) - res, nextCursor, err = s.repo.Find(ctx, query) +func (s *service) Find(ctx context.Context, query domain.QueryParams) (res []domain.Release, nextCursor int64, count int64, err error) { + res, nextCursor, count, err = s.repo.Find(ctx, query) if err != nil { - //return nil, err return } return - - //return releases, nil } func (s *service) Stats(ctx context.Context) (*domain.ReleaseStats, error) { diff --git a/web/src/domain/interfaces.ts b/web/src/domain/interfaces.ts index 8fabcaf..c86409a 100644 --- a/web/src/domain/interfaces.ts +++ b/web/src/domain/interfaces.ts @@ -188,6 +188,7 @@ export interface Release { export interface ReleaseFindResponse { data: Release[]; next_cursor: number; + count: number; } export interface ReleaseStats { diff --git a/web/src/screens/Base.tsx b/web/src/screens/Base.tsx index a49cd87..bb184ee 100644 --- a/web/src/screens/Base.tsx +++ b/web/src/screens/Base.tsx @@ -6,6 +6,7 @@ import Settings from "./Settings"; import { Dashboard } from "./Dashboard"; import { FilterDetails, Filters } from "./filters"; import Logs from './Logs'; +import { Releases } from "./Releases"; import logo from '../logo.png'; function classNames(...classes: string[]) { @@ -13,7 +14,7 @@ function classNames(...classes: string[]) { } export default function Base() { - const nav = [{ name: 'Dashboard', path: "/" }, { name: 'Filters', path: "/filters" }, { name: "Settings", path: "/settings" }, { name: "Logs", path: "/logs" }] + const nav = [{ name: 'Dashboard', path: "/" }, { name: 'Filters', path: "/filters" }, { name: 'Releases', path: "/releases" }, { name: "Settings", path: "/settings" }, { name: "Logs", path: "/logs" }] return (
@@ -212,6 +213,10 @@ export default function Base() { + + + + diff --git a/web/src/screens/Releases.tsx b/web/src/screens/Releases.tsx new file mode 100644 index 0000000..433299a --- /dev/null +++ b/web/src/screens/Releases.tsx @@ -0,0 +1,464 @@ +import { ChevronDoubleLeftIcon, ChevronLeftIcon, ChevronRightIcon, ChevronDoubleRightIcon } from "@heroicons/react/solid" +import { formatDistanceToNowStrict } from "date-fns" +import React from "react" +import { useQuery } from "react-query" +import { useTable, useSortBy, usePagination } from "react-table" +import APIClient from "../api/APIClient" +import { EmptyListState } from "../components/emptystates" +import { classNames } from "../utils" + +export function Releases() { + return ( +
+ +
+
+

Releases

+
+
+
+ + + + ) +} + +// This is a custom filter UI for selecting +// a unique option from a list +export function SelectColumnFilter({ + column: { filterValue, setFilter, preFilteredRows, id, render }, +}: any) { + // Calculate the options for filtering + // using the preFilteredRows + const options = React.useMemo(() => { + const options: any = new Set() + preFilteredRows.forEach((row: { values: { [x: string]: unknown } }) => { + options.add(row.values[id]) + }) + return [...options.values()] + }, [id, preFilteredRows]) + + // Render a multi-select box + return ( + + ) +} + +// export function StatusPill({ value }: any) { + +// const status = value ? value.toLowerCase() : "unknown"; + +// return ( +// +// {status} +// +// ); +// }; + +export function StatusPill({ value }: any) { + + const statusMap: any = { + "FILTER_APPROVED": Approved, + "FILTER_REJECTED": Rejected, + "PUSH_REJECTED": Rejected, + "PUSH_APPROVED": Approved, + "PENDING": PENDING, + "MIXED": MIXED, + } + + return ( + statusMap[value] + ); +}; + +export function AgeCell({ value, column, row }: any) { + + const formatDate = formatDistanceToNowStrict( + new Date(value), + { addSuffix: true } + ) + + return ( +
{formatDate}
+ ) +} + +export function ReleaseCell({ value, column, row }: any) { + return ( +
{value}
+ ) +} + +const initialState = { + queryPageIndex: 0, + queryPageSize: 10, + totalCount: null, +}; + +const PAGE_CHANGED = 'PAGE_CHANGED'; +const PAGE_SIZE_CHANGED = 'PAGE_SIZE_CHANGED'; +const TOTAL_COUNT_CHANGED = 'TOTAL_COUNT_CHANGED'; + +const reducer = (state: any, { type, payload }: any) => { + switch (type) { + case PAGE_CHANGED: + return { + ...state, + queryPageIndex: payload, + }; + case PAGE_SIZE_CHANGED: + return { + ...state, + queryPageSize: payload, + }; + case TOTAL_COUNT_CHANGED: + return { + ...state, + totalCount: payload, + }; + default: + throw new Error(`Unhandled action type: ${type}`); + } +}; + +function Table() { + const columns = React.useMemo(() => [ + { + Header: "Age", + accessor: 'timestamp', + Cell: AgeCell, + }, + { + Header: "Release", + accessor: 'torrent_name', + Cell: ReleaseCell, + }, + // { + // Header: "Filter Status", + // accessor: 'filter_status', + // Cell: StatusPill, + // }, + { + Header: "Push Status", + accessor: 'push_status', + Cell: StatusPill, + }, + { + Header: "Indexer", + accessor: 'indexer', + Filter: SelectColumnFilter, // new + filter: 'includes', + }, + ], []) + + const [{ queryPageIndex, queryPageSize, totalCount }, dispatch] = + React.useReducer(reducer, initialState); + + const { isLoading, error, data, isSuccess } = useQuery( + ['releases', queryPageIndex, queryPageSize], + () => APIClient.release.find(`?offset=${queryPageIndex * queryPageSize}&limit=${queryPageSize}`), + { + keepPreviousData: true, + staleTime: Infinity, + } + ); + + // Use the state and functions returned from useTable to build your UI + const { + getTableProps, + getTableBodyProps, + headerGroups, + prepareRow, + page, // Instead of using 'rows', we'll use page, + // which has only the rows for the active page + + // The rest of these things are super handy, too ;) + canPreviousPage, + canNextPage, + pageOptions, + pageCount, + gotoPage, + nextPage, + previousPage, + setPageSize, + + state: { pageIndex, pageSize }, + // preGlobalFilteredRows, + // setGlobalFilter, + } = useTable({ + columns, + data: isSuccess ? data.data : [], + initialState: { + pageIndex: queryPageIndex, + pageSize: queryPageSize, + }, + manualPagination: true, + manualSortBy: true, + pageCount: isSuccess ? Math.ceil(totalCount / queryPageSize) : 0, + }, + // useFilters, // useFilters! + // useGlobalFilter, + useSortBy, + usePagination, // new + ) + + React.useEffect(() => { + dispatch({ type: PAGE_CHANGED, payload: pageIndex }); + }, [pageIndex]); + + React.useEffect(() => { + dispatch({ type: PAGE_SIZE_CHANGED, payload: pageSize }); + gotoPage(0); + }, [pageSize, gotoPage]); + + React.useEffect(() => { + if (data?.count) { + dispatch({ + type: TOTAL_COUNT_CHANGED, + payload: data.count, + }); + } + }, [data?.count]); + + if (error) { + return

Error

; + } + + if (isLoading) { + return

Loading...

; + } + + // Render the UI for your table + return ( + <> +
+ {/* */} + {/* {headerGroups.map((headerGroup: { headers: any[] }) => + headerGroup.headers.map((column) => + column.Filter ? ( +
+ {column.render("Filter")} +
+ ) : null + ) + )} */} +
+ {isSuccess ? +
+
+
+
+
+ + {headerGroups.map((headerGroup: { getHeaderGroupProps: () => JSX.IntrinsicAttributes & React.ClassAttributes & React.HTMLAttributes; headers: any[] }) => ( + + {headerGroup.headers.map(column => ( + // Add the sorting props to control sorting. For this example + // we can add them into the header props + + ))} + + ))} + + + {page.map((row: any, i: any) => { // new + prepareRow(row) + return ( + + {row.cells.map((cell: any) => { + return ( + + ) + })} + + ) + })} + +
+
+ {column.render('Header')} + {/* Add a sort direction indicator */} + + {column.isSorted + ? column.isSortedDesc + ? + : + : ( + + )} + +
+
+ {cell.column.Cell.name === "defaultRenderer" + ?
{cell.render('Cell')}
+ : cell.render('Cell') + } +
+ + + {/* Pagination */} +
+
+ + +
+
+
+ + Page {pageIndex + 1} of {pageOptions.length} + + +
+
+ +
+
+
+ + +
+
+ + + : } + + ) +} + +function SortIcon({ className }: any) { + return ( + + ) +} + +function SortUpIcon({ className }: any) { + return ( + + ) +} + +function SortDownIcon({ className }: any) { + return ( + + ) +} + +function Button({ children, className, ...rest }: any) { + return ( + + ) +} + +function PageButton({ children, className, ...rest }: any) { + return ( + + ) +} \ No newline at end of file