Feature: List releases (#52)

* feat: list releases

* feat: find releases and count
This commit is contained in:
Ludvig Lundgren 2021-12-25 21:44:52 +01:00 committed by GitHub
parent c83ebdc1a4
commit b75c40f6a4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 506 additions and 20 deletions

View file

@ -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, &timestamp); 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, &timestamp, &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) {

View file

@ -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

View file

@ -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)

View file

@ -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) {

View file

@ -188,6 +188,7 @@ export interface Release {
export interface ReleaseFindResponse {
data: Release[];
next_cursor: number;
count: number;
}
export interface ReleaseStats {

View file

@ -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 (
<div className="">
@ -212,6 +213,10 @@ export default function Base() {
<Settings />
</Route>
<Route path="/releases">
<Releases />
</Route>
<Route exact={true} path="/filters">
<Filters />
</Route>

View file

@ -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 (
<main className="-mt-48">
<header className="py-10">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex justify-between">
<h1 className="text-3xl font-bold text-white capitalize">Releases</h1>
</div>
</header>
<div className="px-4 pb-8 mx-auto max-w-7xl sm:px-6 lg:px-8">
<Table />
</div>
</main>
)
}
// 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 (
<label className="flex items-baseline gap-x-2">
<span className="text-gray-700">{render("Header")}: </span>
<select
className="border-gray-300 rounded-md shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
name={id}
id={id}
value={filterValue}
onChange={e => {
setFilter(e.target.value || undefined)
}}
>
<option value="">All</option>
{options.map((option, i) => (
<option key={i} value={option}>
{option}
</option>
))}
</select>
</label>
)
}
// export function StatusPill({ value }: any) {
// const status = value ? value.toLowerCase() : "unknown";
// return (
// <span
// className={
// classNames(
// "px-3 py-1 uppercase leading-wide font-bold text-xs rounded-full shadow-sm",
// status.startsWith("active") ? "bg-green-100 text-green-800" : "",
// status.startsWith("inactive") ? "bg-yellow-100 text-yellow-800" : "",
// status.startsWith("offline") ? "bg-red-100 text-red-800" : "",
// )
// }
// >
// {status}
// </span>
// );
// };
export function StatusPill({ value }: any) {
const statusMap: any = {
"FILTER_APPROVED": <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold uppercase bg-blue-100 text-blue-800 ">Approved</span>,
"FILTER_REJECTED": <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold uppercase bg-red-100 text-red-800">Rejected</span>,
"PUSH_REJECTED": <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold uppercase bg-pink-100 text-pink-800">Rejected</span>,
"PUSH_APPROVED": <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold uppercase bg-green-100 text-green-800">Approved</span>,
"PENDING": <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold uppercase bg-yellow-100 text-yellow-800">PENDING</span>,
"MIXED": <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold uppercase bg-yellow-100 text-yellow-800">MIXED</span>,
}
return (
statusMap[value]
);
};
export function AgeCell({ value, column, row }: any) {
const formatDate = formatDistanceToNowStrict(
new Date(value),
{ addSuffix: true }
)
return (
<div className="text-sm text-gray-500" title={value}>{formatDate}</div>
)
}
export function ReleaseCell({ value, column, row }: any) {
return (
<div className="text-sm font-medium text-gray-900 dark:text-gray-300" title={value}>{value}</div>
)
}
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 <p>Error</p>;
}
if (isLoading) {
return <p>Loading...</p>;
}
// Render the UI for your table
return (
<>
<div className="sm:flex sm:gap-x-2">
{/* <GlobalFilter
preGlobalFilteredRows={preGlobalFilteredRows}
globalFilter={state.globalFilter}
setGlobalFilter={setGlobalFilter}
/> */}
{/* {headerGroups.map((headerGroup: { headers: any[] }) =>
headerGroup.headers.map((column) =>
column.Filter ? (
<div className="mt-2 sm:mt-0" key={column.id}>
{column.render("Filter")}
</div>
) : null
)
)} */}
</div>
{isSuccess ?
<div className="flex flex-col mt-4">
<div className="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div className="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
<div className="overflow-hidden bg-white shadow dark:bg-gray-800 sm:rounded-lg">
<table {...getTableProps()} className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-800">
{headerGroups.map((headerGroup: { getHeaderGroupProps: () => JSX.IntrinsicAttributes & React.ClassAttributes<HTMLTableRowElement> & React.HTMLAttributes<HTMLTableRowElement>; headers: any[] }) => (
<tr {...headerGroup.getHeaderGroupProps()}>
{headerGroup.headers.map(column => (
// Add the sorting props to control sorting. For this example
// we can add them into the header props
<th
scope="col"
className="px-6 py-3 text-xs font-medium tracking-wider text-left text-gray-500 uppercase group"
{...column.getHeaderProps(column.getSortByToggleProps())}
>
<div className="flex items-center justify-between">
{column.render('Header')}
{/* Add a sort direction indicator */}
<span>
{column.isSorted
? column.isSortedDesc
? <SortDownIcon className="w-4 h-4 text-gray-400" />
: <SortUpIcon className="w-4 h-4 text-gray-400" />
: (
<SortIcon className="w-4 h-4 text-gray-400 opacity-0 group-hover:opacity-100" />
)}
</span>
</div>
</th>
))}
</tr>
))}
</thead>
<tbody
{...getTableBodyProps()}
className="divide-y divide-gray-200 dark:divide-gray-700"
>
{page.map((row: any, i: any) => { // new
prepareRow(row)
return (
<tr {...row.getRowProps()}>
{row.cells.map((cell: any) => {
return (
<td
{...cell.getCellProps()}
className="px-6 py-4 whitespace-nowrap"
role="cell"
>
{cell.column.Cell.name === "defaultRenderer"
? <div className="text-sm text-gray-500">{cell.render('Cell')}</div>
: cell.render('Cell')
}
</td>
)
})}
</tr>
)
})}
</tbody>
</table>
{/* Pagination */}
<div className="flex items-center justify-between px-6 py-3 border-t border-gray-200 dark:border-gray-700">
<div className="flex justify-between flex-1 sm:hidden">
<Button onClick={() => previousPage()} disabled={!canPreviousPage}>Previous</Button>
<Button onClick={() => nextPage()} disabled={!canNextPage}>Next</Button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div className="flex items-baseline gap-x-2">
<span className="text-sm text-gray-700">
Page <span className="font-medium">{pageIndex + 1}</span> of <span className="font-medium">{pageOptions.length}</span>
</span>
<label>
<span className="sr-only">Items Per Page</span>
<select
className="block w-full border-gray-300 rounded-md shadow-sm cursor-pointer dark:bg-gray-800 dark:border-gray-800 dark:text-gray-600 dark:hover:text-gray-500 focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50"
value={pageSize}
onChange={e => {
setPageSize(Number(e.target.value))
}}
>
{[5, 10, 20, 50].map(pageSize => (
<option key={pageSize} value={pageSize}>
Show {pageSize}
</option>
))}
</select>
</label>
</div>
<div>
<nav className="relative z-0 inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
<PageButton
className="rounded-l-md"
onClick={() => gotoPage(0)}
disabled={!canPreviousPage}
>
<span className="sr-only">First</span>
<ChevronDoubleLeftIcon className="w-5 h-5 text-gray-400" aria-hidden="true" />
</PageButton>
<PageButton
onClick={() => previousPage()}
disabled={!canPreviousPage}
>
<span className="sr-only">Previous</span>
<ChevronLeftIcon className="w-5 h-5 text-gray-400" aria-hidden="true" />
</PageButton>
<PageButton
onClick={() => nextPage()}
disabled={!canNextPage
}>
<span className="sr-only">Next</span>
<ChevronRightIcon className="w-5 h-5 text-gray-400" aria-hidden="true" />
</PageButton>
<PageButton
className="rounded-r-md"
onClick={() => gotoPage(pageCount - 1)}
disabled={!canNextPage}
>
<span className="sr-only">Last</span>
<ChevronDoubleRightIcon className="w-5 h-5 text-gray-400" aria-hidden="true" />
</PageButton>
</nav>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
: <EmptyListState text="No recent activity" />}
</>
)
}
function SortIcon({ className }: any) {
return (
<svg className={className} stroke="currentColor" fill="currentColor" strokeWidth="0" viewBox="0 0 320 512" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"><path d="M41 288h238c21.4 0 32.1 25.9 17 41L177 448c-9.4 9.4-24.6 9.4-33.9 0L24 329c-15.1-15.1-4.4-41 17-41zm255-105L177 64c-9.4-9.4-24.6-9.4-33.9 0L24 183c-15.1 15.1-4.4 41 17 41h238c21.4 0 32.1-25.9 17-41z"></path></svg>
)
}
function SortUpIcon({ className }: any) {
return (
<svg className={className} stroke="currentColor" fill="currentColor" strokeWidth="0" viewBox="0 0 320 512" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"><path d="M279 224H41c-21.4 0-32.1-25.9-17-41L143 64c9.4-9.4 24.6-9.4 33.9 0l119 119c15.2 15.1 4.5 41-16.9 41z"></path></svg>
)
}
function SortDownIcon({ className }: any) {
return (
<svg className={className} stroke="currentColor" fill="currentColor" strokeWidth="0" viewBox="0 0 320 512" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"><path d="M41 288h238c21.4 0 32.1 25.9 17 41L177 448c-9.4 9.4-24.6 9.4-33.9 0L24 329c-15.1-15.1-4.4-41 17-41z"></path></svg>
)
}
function Button({ children, className, ...rest }: any) {
return (
<button
type="button"
className={
classNames(
"relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50",
className
)}
{...rest}
>
{children}
</button>
)
}
function PageButton({ children, className, ...rest }: any) {
return (
<button
type="button"
className={
classNames(
"relative inline-flex items-center px-2 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm font-medium text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-600",
className
)}
{...rest}
>
{children}
</button>
)
}