mirror of
https://github.com/idanoo/autobrr
synced 2025-07-23 16:59:12 +00:00
* refactor(APIClient): updated the newly added findQuery function to use URLSearchParams instead of manually crafting the URI string itself. * refactor: moved duplicate dashboard/release code to a separate folder: components/data-table. * refactor(SlideOver): added proper typings to the SlideOver component and added a sanity check to prevent passing of null/undefined values to the child component before rendering. * refactor: removed the redundant Network and Channel typings and updated relevant typings to match the backend. adapted relevant code to match these changes. * fix(ChannelsFieldArray): fixed a bug where it was unable to add a new irc network due to the validation object being initialized as non-empty (formik requires that successful validated entries return empty objects) * refactor(screens/settings/Irc): replaced incorrect typings, sanitized potentially null values and cleaned up the code. * fix: included changes should fix issue #158 as well. * feat: send chan empty array
This commit is contained in:
parent
5a45851677
commit
9ea29d02a2
23 changed files with 974 additions and 1187 deletions
|
@ -396,6 +396,7 @@ func (s *service) GetNetworksWithHealth(ctx context.Context) ([]domain.IrcNetwor
|
||||||
InviteCommand: n.InviteCommand,
|
InviteCommand: n.InviteCommand,
|
||||||
NickServ: n.NickServ,
|
NickServ: n.NickServ,
|
||||||
Connected: false,
|
Connected: false,
|
||||||
|
Channels: []domain.ChannelWithHealth{},
|
||||||
}
|
}
|
||||||
|
|
||||||
handler, ok := s.handlers[handlerKey{n.Server, n.NickServ.Account}]
|
handler, ok := s.handlers[handlerKey{n.Server, n.NickServ.Account}]
|
||||||
|
|
|
@ -87,9 +87,9 @@ export const APIClient = {
|
||||||
delete: (id: number) => appClient.Delete(`api/indexer/${id}`),
|
delete: (id: number) => appClient.Delete(`api/indexer/${id}`),
|
||||||
},
|
},
|
||||||
irc: {
|
irc: {
|
||||||
getNetworks: () => appClient.Get<IrcNetwork[]>("api/irc"),
|
getNetworks: () => appClient.Get<IrcNetworkWithHealth[]>("api/irc"),
|
||||||
createNetwork: (network: Network) => appClient.Post("api/irc", network),
|
createNetwork: (network: IrcNetwork) => appClient.Post("api/irc", network),
|
||||||
updateNetwork: (network: Network) => appClient.Put(`api/irc/network/${network.id}`, network),
|
updateNetwork: (network: IrcNetwork) => appClient.Put(`api/irc/network/${network.id}`, network),
|
||||||
deleteNetwork: (id: number) => appClient.Delete(`api/irc/network/${id}`),
|
deleteNetwork: (id: number) => appClient.Delete(`api/irc/network/${id}`),
|
||||||
},
|
},
|
||||||
events: {
|
events: {
|
||||||
|
@ -97,28 +97,25 @@ export const APIClient = {
|
||||||
},
|
},
|
||||||
release: {
|
release: {
|
||||||
find: (query?: string) => appClient.Get<ReleaseFindResponse>(`api/release${query}`),
|
find: (query?: string) => appClient.Get<ReleaseFindResponse>(`api/release${query}`),
|
||||||
findQuery: (offset?: number, limit?: number, filters?: any[]) => {
|
findQuery: (offset?: number, limit?: number, filters?: Array<ReleaseFilter>) => {
|
||||||
let queryString = "?"
|
const params = new URLSearchParams();
|
||||||
|
if (offset !== undefined)
|
||||||
|
params.append("offset", offset.toString());
|
||||||
|
|
||||||
if (offset != 0) {
|
if (limit !== undefined)
|
||||||
queryString += `offset=${offset}`
|
params.append("limit", limit.toString());
|
||||||
}
|
|
||||||
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<ReleaseFindResponse>(`api/release${queryString}`)
|
filters?.forEach((filter) => {
|
||||||
|
if (!filter.value)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (filter.id == "indexer")
|
||||||
|
params.append("indexer", filter.value);
|
||||||
|
else if (filter.id === "action_status")
|
||||||
|
params.append("push_status", filter.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
return appClient.Get<ReleaseFindResponse>(`api/release?${params.toString()}`)
|
||||||
},
|
},
|
||||||
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")
|
||||||
|
|
17
web/src/components/Icons.tsx
Normal file
17
web/src/components/Icons.tsx
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
interface IconProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SortIcon = ({ className }: IconProps) => (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const SortUpIcon = ({ className }: IconProps) => (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const SortDownIcon = ({ className }: IconProps) => (
|
||||||
|
<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>
|
||||||
|
);
|
34
web/src/components/data-table/Buttons.tsx
Normal file
34
web/src/components/data-table/Buttons.tsx
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
import { classNames } from "../../utils"
|
||||||
|
|
||||||
|
interface ButtonProps {
|
||||||
|
className?: string;
|
||||||
|
children: any;
|
||||||
|
[rest: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Button = ({ children, className, ...rest }: ButtonProps) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={classNames(
|
||||||
|
className ?? "",
|
||||||
|
"relative inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-800 text-sm font-medium rounded-md text-gray-700 dark:text-gray-500 bg-white dark:bg-gray-800 hover:bg-gray-50"
|
||||||
|
)}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
export const PageButton = ({ children, className, ...rest }: ButtonProps) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={classNames(
|
||||||
|
className ?? "",
|
||||||
|
"relative inline-flex items-center px-2 py-2 border border-gray-300 dark:border-gray-700 text-sm font-medium text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-600"
|
||||||
|
)}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
76
web/src/components/data-table/Cells.tsx
Normal file
76
web/src/components/data-table/Cells.tsx
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import { formatDistanceToNowStrict } from "date-fns";
|
||||||
|
import { CheckIcon } from "@heroicons/react/solid";
|
||||||
|
import { ClockIcon, BanIcon, ExclamationCircleIcon } from "@heroicons/react/outline";
|
||||||
|
|
||||||
|
import { classNames, simplifyDate } from "../../utils";
|
||||||
|
|
||||||
|
interface CellProps {
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AgeCell = ({ value }: CellProps) => (
|
||||||
|
<div className="text-sm text-gray-500" title={value}>
|
||||||
|
{formatDistanceToNowStrict(new Date(value), { addSuffix: true })}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ReleaseCell = ({ value }: CellProps) => (
|
||||||
|
<div className="text-sm font-medium text-gray-900 dark:text-gray-300" title={value}>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IndexerCell = ({ value }: CellProps) => (
|
||||||
|
<div className="text-sm font-medium text-gray-900 dark:text-gray-500" title={value}>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface ReleaseStatusCellProps {
|
||||||
|
value: ReleaseActionStatus[];
|
||||||
|
column: any;
|
||||||
|
row: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatusCellMapEntry {
|
||||||
|
colors: string;
|
||||||
|
icon: React.ReactElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StatusCellMap: Record<string, StatusCellMapEntry> = {
|
||||||
|
"PUSH_ERROR": {
|
||||||
|
colors: "bg-pink-100 text-pink-800 hover:bg-pink-300",
|
||||||
|
icon: <ExclamationCircleIcon className="h-5 w-5" aria-hidden="true" />
|
||||||
|
},
|
||||||
|
"PUSH_REJECTED": {
|
||||||
|
colors: "bg-blue-200 dark:bg-blue-100 text-blue-400 dark:text-blue-800 hover:bg-blue-300 dark:hover:bg-blue-400",
|
||||||
|
icon: <BanIcon className="h-5 w-5" aria-hidden="true" />
|
||||||
|
|
||||||
|
},
|
||||||
|
"PUSH_APPROVED": {
|
||||||
|
colors: "bg-green-100 text-green-800 hover:bg-green-300",
|
||||||
|
icon: <CheckIcon className="h-5 w-5" aria-hidden="true" />
|
||||||
|
},
|
||||||
|
"PENDING": {
|
||||||
|
colors: "bg-yellow-100 text-yellow-800 hover:bg-yellow-200",
|
||||||
|
icon: <ClockIcon className="h-5 w-5" aria-hidden="true" />
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ReleaseStatusCell = ({ value }: ReleaseStatusCellProps) => (
|
||||||
|
<div className="flex text-sm font-medium text-gray-900 dark:text-gray-300">
|
||||||
|
{value.map((v, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
title={`action: ${v.action}, type: ${v.type}, status: ${v.status}, time: ${simplifyDate(v.timestamp)}, rejections: ${v?.rejections}`}
|
||||||
|
className={classNames(
|
||||||
|
StatusCellMap[v.status].colors,
|
||||||
|
"mr-1 inline-flex items-center rounded text-xs font-semibold uppercase cursor-pointer"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{StatusCellMap[v.status].icon}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
2
web/src/components/data-table/index.tsx
Normal file
2
web/src/components/data-table/index.tsx
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
export * from "./Buttons";
|
||||||
|
export * from "./Cells";
|
|
@ -33,7 +33,7 @@ interface EmptyListStateProps {
|
||||||
export function EmptyListState({ text, buttonText, buttonOnClick }: EmptyListStateProps) {
|
export function EmptyListState({ text, buttonText, buttonOnClick }: EmptyListStateProps) {
|
||||||
return (
|
return (
|
||||||
<div className="px-4 py-12 flex flex-col items-center">
|
<div className="px-4 py-12 flex flex-col items-center">
|
||||||
<p className="text-center text-gray-500 dark:text-white">{text}</p>
|
<p className="text-center text-gray-800 dark:text-white">{text}</p>
|
||||||
{buttonText && buttonOnClick && (
|
{buttonText && buttonOnClick && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
@ -7,22 +7,31 @@ import { useToggle } from "../../hooks/hooks";
|
||||||
import { DeleteModal } from "../modals";
|
import { DeleteModal } from "../modals";
|
||||||
import { classNames } from "../../utils";
|
import { classNames } from "../../utils";
|
||||||
|
|
||||||
interface SlideOverProps {
|
interface SlideOverProps<DataType> {
|
||||||
title: string;
|
title: string;
|
||||||
initialValues: any;
|
initialValues: DataType;
|
||||||
validate?: any;
|
validate?: (values?: any) => void;
|
||||||
onSubmit: any;
|
onSubmit: (values?: DataType) => void;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
toggle: any;
|
toggle: () => void;
|
||||||
children?: (values: any) => React.ReactNode;
|
children?: (values: DataType) => React.ReactNode;
|
||||||
deleteAction?: any
|
deleteAction?: () => void;
|
||||||
type: "CREATE" | "UPDATE";
|
type: "CREATE" | "UPDATE";
|
||||||
}
|
}
|
||||||
|
|
||||||
function SlideOver({ title, initialValues, validate, onSubmit, deleteAction, isOpen, toggle, type, children }: SlideOverProps) {
|
function SlideOver<DataType>({
|
||||||
const [deleteModalIsOpen, toggleDeleteModal] = useToggle(false)
|
title,
|
||||||
|
initialValues,
|
||||||
const cancelModalButtonRef = useRef(null)
|
validate,
|
||||||
|
onSubmit,
|
||||||
|
deleteAction,
|
||||||
|
isOpen,
|
||||||
|
toggle,
|
||||||
|
type,
|
||||||
|
children
|
||||||
|
}: SlideOverProps<DataType>): React.ReactElement {
|
||||||
|
const cancelModalButtonRef = useRef(null);
|
||||||
|
const [deleteModalIsOpen, toggleDeleteModal] = useToggle(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Transition.Root show={isOpen} as={Fragment}>
|
<Transition.Root show={isOpen} as={Fragment}>
|
||||||
|
@ -84,7 +93,9 @@ function SlideOver({ title, initialValues, validate, onSubmit, deleteAction, isO
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{children !== undefined && children(values)}
|
{!!values && children !== undefined ? (
|
||||||
|
children(values)
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-shrink-0 px-4 border-t border-gray-200 dark:border-gray-700 py-5 sm:px-6">
|
<div className="flex-shrink-0 px-4 border-t border-gray-200 dark:border-gray-700 py-5 sm:px-6">
|
||||||
|
|
|
@ -70,7 +70,8 @@ export function IndexerAddForm({ isOpen, toggle }: AddProps) {
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const mutation = useMutation((indexer: Indexer) => APIClient.indexers.create(indexer), {
|
const mutation = useMutation(
|
||||||
|
(indexer: Indexer) => APIClient.indexers.create(indexer), {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries(['indexer']);
|
queryClient.invalidateQueries(['indexer']);
|
||||||
toast.custom((t) => <Toast type="success" body="Indexer was added" t={t} />)
|
toast.custom((t) => <Toast type="success" body="Indexer was added" t={t} />)
|
||||||
|
@ -83,7 +84,7 @@ export function IndexerAddForm({ isOpen, toggle }: AddProps) {
|
||||||
})
|
})
|
||||||
|
|
||||||
const ircMutation = useMutation(
|
const ircMutation = useMutation(
|
||||||
(network: Network) => APIClient.irc.createNetwork(network)
|
(network: IrcNetwork) => APIClient.irc.createNetwork(network)
|
||||||
);
|
);
|
||||||
|
|
||||||
const onSubmit = (formData: any) => {
|
const onSubmit = (formData: any) => {
|
||||||
|
@ -91,22 +92,32 @@ export function IndexerAddForm({ isOpen, toggle }: AddProps) {
|
||||||
if (!ind)
|
if (!ind)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
const channels: Channel[] = [];
|
const channels: IrcChannel[] = [];
|
||||||
if (ind.irc.channels.length) {
|
if (ind.irc.channels.length) {
|
||||||
ind.irc.channels.forEach(element => {
|
ind.irc.channels.forEach(element => {
|
||||||
channels.push({ name: element, password: "" });
|
channels.push({
|
||||||
|
id: 0,
|
||||||
|
enabled: true,
|
||||||
|
name: element,
|
||||||
|
password: "",
|
||||||
|
detached: false,
|
||||||
|
monitoring: false
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const network: Network = {
|
const network: IrcNetwork = {
|
||||||
|
id: 0,
|
||||||
name: ind.irc.network,
|
name: ind.irc.network,
|
||||||
|
pass: "",
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
connected: false,
|
||||||
|
connected_since: 0,
|
||||||
server: ind.irc.server,
|
server: ind.irc.server,
|
||||||
port: ind.irc.port,
|
port: ind.irc.port,
|
||||||
tls: ind.irc.tls,
|
tls: ind.irc.tls,
|
||||||
nickserv: formData.irc.nickserv,
|
nickserv: formData.irc.nickserv,
|
||||||
invite_command: formData.irc.invite_command,
|
invite_command: formData.irc.invite_command,
|
||||||
settings: formData.irc.settings,
|
|
||||||
channels: channels,
|
channels: channels,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -16,14 +16,17 @@ import {
|
||||||
import { SlideOver } from "../../components/panels";
|
import { SlideOver } from "../../components/panels";
|
||||||
import Toast from '../../components/notifications/Toast';
|
import Toast from '../../components/notifications/Toast';
|
||||||
|
|
||||||
function ChannelsFieldArray({ values }: any) {
|
interface ChannelsFieldArrayProps {
|
||||||
return (
|
channels: IrcChannel[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChannelsFieldArray = ({ channels }: ChannelsFieldArrayProps) => (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<FieldArray name="channels">
|
<FieldArray name="channels">
|
||||||
{({ remove, push }) => (
|
{({ remove, push }) => (
|
||||||
<div className="flex flex-col border-2 border-dashed dark:border-gray-700 p-4">
|
<div className="flex flex-col border-2 border-dashed dark:border-gray-700 p-4">
|
||||||
{values && values.channels.length > 0 ? (
|
{channels && channels.length > 0 ? (
|
||||||
values.channels.map((_channel: Channel, index: number) => (
|
channels.map((_channel: IrcChannel, index: number) => (
|
||||||
<div key={index} className="flex justify-between">
|
<div key={index} className="flex justify-between">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<Field name={`channels.${index}.name`}>
|
<Field name={`channels.${index}.name`}>
|
||||||
|
@ -79,11 +82,23 @@ function ChannelsFieldArray({ values }: any) {
|
||||||
)}
|
)}
|
||||||
</FieldArray>
|
</FieldArray>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
|
|
||||||
|
interface IrcNetworkAddFormValues {
|
||||||
|
name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
server : string;
|
||||||
|
port: number;
|
||||||
|
tls: boolean;
|
||||||
|
pass: string;
|
||||||
|
nickserv: NickServ;
|
||||||
|
channels: IrcChannel[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IrcNetworkAddForm({ isOpen, toggle }: any) {
|
export function IrcNetworkAddForm({ isOpen, toggle }: any) {
|
||||||
const mutation = useMutation((network: Network) => APIClient.irc.createNetwork(network), {
|
const mutation = useMutation(
|
||||||
|
(network: IrcNetwork) => APIClient.irc.createNetwork(network),
|
||||||
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries(['networks']);
|
queryClient.invalidateQueries(['networks']);
|
||||||
toast.custom((t) => <Toast type="success" body="IRC Network added" t={t} />)
|
toast.custom((t) => <Toast type="success" body="IRC Network added" t={t} />)
|
||||||
|
@ -92,49 +107,39 @@ export function IrcNetworkAddForm({ isOpen, toggle }: any) {
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast.custom((t) => <Toast type="error" body="IRC Network could not be added" t={t} />)
|
toast.custom((t) => <Toast type="error" body="IRC Network could not be added" t={t} />)
|
||||||
},
|
},
|
||||||
})
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const onSubmit = (data: any) => {
|
const onSubmit = (data: any) => {
|
||||||
// easy way to split textarea lines into array of strings for each newline.
|
// easy way to split textarea lines into array of strings for each newline.
|
||||||
// parse on the field didn't really work.
|
// parse on the field didn't really work.
|
||||||
const cmds = (
|
data.connect_commands = (
|
||||||
data.connect_commands && data.connect_commands.length > 0 ?
|
data.connect_commands && data.connect_commands.length > 0 ?
|
||||||
data.connect_commands.replace(/\r\n/g, "\n").split("\n") :
|
data.connect_commands.replace(/\r\n/g, "\n").split("\n") :
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
data.connect_commands = cmds;
|
|
||||||
console.log("formated", data);
|
|
||||||
|
|
||||||
mutation.mutate(data);
|
mutation.mutate(data);
|
||||||
};
|
};
|
||||||
|
|
||||||
const validate = (values: any) => {
|
const validate = (values: IrcNetworkAddFormValues) => {
|
||||||
const errors = {
|
const errors = {} as any;
|
||||||
nickserv: {
|
if (!values.name)
|
||||||
account: null,
|
|
||||||
}
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
if (!values.name) {
|
|
||||||
errors.name = "Required";
|
errors.name = "Required";
|
||||||
}
|
|
||||||
|
|
||||||
if (!values.port) {
|
if (!values.port)
|
||||||
errors.port = "Required";
|
errors.port = "Required";
|
||||||
}
|
|
||||||
|
|
||||||
if (!values.server) {
|
if (!values.server)
|
||||||
errors.server = "Required";
|
errors.server = "Required";
|
||||||
}
|
|
||||||
|
|
||||||
if (!values.nickserv?.account) {
|
if (!values.nickserv || !values.nickserv.account)
|
||||||
errors.nickserv.account = "Required";
|
errors.nickserv = { account: "Required" };
|
||||||
}
|
|
||||||
|
|
||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialValues = {
|
const initialValues: IrcNetworkAddFormValues = {
|
||||||
name: "",
|
name: "",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
server: "",
|
server: "",
|
||||||
|
@ -145,7 +150,7 @@ export function IrcNetworkAddForm({ isOpen, toggle }: any) {
|
||||||
account: ""
|
account: ""
|
||||||
},
|
},
|
||||||
channels: [],
|
channels: [],
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SlideOver
|
<SlideOver
|
||||||
|
@ -184,15 +189,38 @@ export function IrcNetworkAddForm({ isOpen, toggle }: any) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ChannelsFieldArray values={values} />
|
<ChannelsFieldArray channels={values.channels} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</SlideOver>
|
</SlideOver>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IrcNetworkUpdateForm({ isOpen, toggle, network }: any) {
|
interface IrcNetworkUpdateFormValues {
|
||||||
const mutation = useMutation((network: Network) => APIClient.irc.updateNetwork(network), {
|
id: number;
|
||||||
|
name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
server: string;
|
||||||
|
port: number;
|
||||||
|
tls: boolean;
|
||||||
|
nickserv?: NickServ;
|
||||||
|
pass: string;
|
||||||
|
invite_command: string;
|
||||||
|
channels: Array<IrcChannel>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IrcNetworkUpdateFormProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
toggle: () => void;
|
||||||
|
network: IrcNetwork;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IrcNetworkUpdateForm({
|
||||||
|
isOpen,
|
||||||
|
toggle,
|
||||||
|
network
|
||||||
|
}: IrcNetworkUpdateFormProps) {
|
||||||
|
const mutation = useMutation((network: IrcNetwork) => APIClient.irc.updateNetwork(network), {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries(['networks']);
|
queryClient.invalidateQueries(['networks']);
|
||||||
toast.custom((t) => <Toast type="success" body={`${network.name} was updated successfully`} t={t} />)
|
toast.custom((t) => <Toast type="success" body={`${network.name} was updated successfully`} t={t} />)
|
||||||
|
@ -246,7 +274,7 @@ export function IrcNetworkUpdateForm({ isOpen, toggle, network }: any) {
|
||||||
deleteMutation.mutate(network.id)
|
deleteMutation.mutate(network.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialValues = {
|
const initialValues: IrcNetworkUpdateFormValues = {
|
||||||
id: network.id,
|
id: network.id,
|
||||||
name: network.name,
|
name: network.name,
|
||||||
enabled: network.enabled,
|
enabled: network.enabled,
|
||||||
|
@ -255,8 +283,8 @@ export function IrcNetworkUpdateForm({ isOpen, toggle, network }: any) {
|
||||||
tls: network.tls,
|
tls: network.tls,
|
||||||
nickserv: network.nickserv,
|
nickserv: network.nickserv,
|
||||||
pass: network.pass,
|
pass: network.pass,
|
||||||
invite_command: network.invite_command,
|
channels: network.channels,
|
||||||
channels: network.channels
|
invite_command: network.invite_command
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -297,7 +325,7 @@ export function IrcNetworkUpdateForm({ isOpen, toggle, network }: any) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ChannelsFieldArray values={values} />
|
<ChannelsFieldArray channels={values.channels} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</SlideOver>
|
</SlideOver>
|
||||||
|
|
|
@ -8,8 +8,8 @@ import { ChevronDownIcon, MenuIcon, XIcon } from "@heroicons/react/outline";
|
||||||
import Settings from "./Settings";
|
import Settings from "./Settings";
|
||||||
|
|
||||||
import { Logs } from "./Logs";
|
import { Logs } from "./Logs";
|
||||||
import { Releases } from "./Releases";
|
import { Releases } from "./releases";
|
||||||
import { Dashboard } from "./Dashboard";
|
import { Dashboard } from "./dashboard";
|
||||||
import { FilterDetails, Filters } from "./filters";
|
import { FilterDetails, Filters } from "./filters";
|
||||||
import { AuthContext } from '../utils/Context';
|
import { AuthContext } from '../utils/Context';
|
||||||
|
|
||||||
|
|
|
@ -39,7 +39,7 @@ export const Logs = () => {
|
||||||
className="h-5 w-5 text-yellow-400"
|
className="h-5 w-5 text-yellow-400"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<p className="ml-2 text-sm text-gray-500 dark:text-gray-400">This only shows new logs, no history</p>
|
<p className="ml-2 text-sm text-gray-800 dark:text-gray-400">This only shows new logs, no history</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
|
@ -1,781 +0,0 @@
|
||||||
import * as React from "react";
|
|
||||||
import { useQuery } from "react-query";
|
|
||||||
import { formatDistanceToNowStrict } from "date-fns";
|
|
||||||
import { useTable, useSortBy, usePagination, useAsyncDebounce, useFilters, Column } from "react-table";
|
|
||||||
import {
|
|
||||||
ClockIcon,
|
|
||||||
BanIcon,
|
|
||||||
ExclamationCircleIcon
|
|
||||||
} from "@heroicons/react/outline";
|
|
||||||
import {
|
|
||||||
ChevronDoubleLeftIcon,
|
|
||||||
ChevronLeftIcon,
|
|
||||||
ChevronRightIcon,
|
|
||||||
ChevronDoubleRightIcon,
|
|
||||||
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 (
|
|
||||||
<main>
|
|
||||||
<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-black dark: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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// // 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 (
|
|
||||||
// <span>
|
|
||||||
// Search:{' '}
|
|
||||||
// <input
|
|
||||||
// value={value || ""}
|
|
||||||
// onChange={e => {
|
|
||||||
// setValue(e.target.value);
|
|
||||||
// onChange(e.target.value);
|
|
||||||
// }}
|
|
||||||
// placeholder={`${count} records...`}
|
|
||||||
// />
|
|
||||||
// </span>
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
|
|
||||||
// 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])
|
|
||||||
|
|
||||||
const opts = ["PUSH_REJECTED"]
|
|
||||||
|
|
||||||
// Render a multi-select box
|
|
||||||
return (
|
|
||||||
<div className="mb-6">
|
|
||||||
|
|
||||||
<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>
|
|
||||||
{opts.map((option, i: number) => (
|
|
||||||
<option key={i} value={option}>
|
|
||||||
{option}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 (
|
|
||||||
<div className="mr-3">
|
|
||||||
<div className="w-48">
|
|
||||||
<Listbox
|
|
||||||
refName={id}
|
|
||||||
value={filterValue}
|
|
||||||
onChange={setFilter}
|
|
||||||
>
|
|
||||||
<div className="relative mt-1">
|
|
||||||
<Listbox.Button className="relative w-full py-2 pl-3 pr-10 text-left bg-white dark:bg-gray-800 rounded-lg shadow-md cursor-default focus:outline-none focus-visible:ring-2 focus-visible:ring-opacity-75 focus-visible:ring-white focus-visible:ring-offset-orange-300 focus-visible:ring-offset-2 focus-visible:border-indigo-500 dark:text-gray-400 sm:text-sm">
|
|
||||||
<span className="block truncate">{filterValue ? filterValue : "Indexer"}</span>
|
|
||||||
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
|
||||||
<ChevronDownIcon
|
|
||||||
className="w-5 h-5 ml-2 -mr-1 text-gray-600 hover:text-gray-600"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</Listbox.Button>
|
|
||||||
<Transition
|
|
||||||
as={Fragment}
|
|
||||||
leave="transition ease-in duration-100"
|
|
||||||
leaveFrom="opacity-100"
|
|
||||||
leaveTo="opacity-0"
|
|
||||||
>
|
|
||||||
<Listbox.Options className="absolute w-full py-1 mt-1 overflow-auto text-base bg-white dark:bg-gray-800 rounded-md shadow-lg max-h-60 ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
|
|
||||||
<Listbox.Option
|
|
||||||
key={0}
|
|
||||||
className={({ active }) =>
|
|
||||||
`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 }) => (
|
|
||||||
<>
|
|
||||||
<span
|
|
||||||
className={`block truncate ${
|
|
||||||
selected ? 'font-medium' : 'font-normal'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
All
|
|
||||||
</span>
|
|
||||||
{selected ? (
|
|
||||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-gray-500 dark:text-gray-400">
|
|
||||||
<CheckIcon className="w-5 h-5" aria-hidden="true" />
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Listbox.Option>
|
|
||||||
{isSuccess && data?.map((indexer, idx) => (
|
|
||||||
<Listbox.Option
|
|
||||||
key={idx}
|
|
||||||
className={({ active }) =>
|
|
||||||
`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 }) => (
|
|
||||||
<>
|
|
||||||
<span
|
|
||||||
className={`block truncate ${
|
|
||||||
selected ? 'font-medium' : 'font-normal'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{indexer}
|
|
||||||
</span>
|
|
||||||
{selected ? (
|
|
||||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-gray-500 dark:text-gray-400">
|
|
||||||
<CheckIcon className="w-5 h-5" aria-hidden="true" />
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Listbox.Option>
|
|
||||||
))}
|
|
||||||
</Listbox.Options>
|
|
||||||
</Transition>
|
|
||||||
</div>
|
|
||||||
</Listbox>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PushStatusSelectColumnFilter({
|
|
||||||
column: { filterValue, setFilter, id },
|
|
||||||
}: any) {
|
|
||||||
return (
|
|
||||||
<div className="mr-3">
|
|
||||||
|
|
||||||
<div className="w-48">
|
|
||||||
<Listbox
|
|
||||||
refName={id}
|
|
||||||
value={filterValue}
|
|
||||||
onChange={setFilter}
|
|
||||||
>
|
|
||||||
<div className="relative mt-1">
|
|
||||||
<Listbox.Button className="relative w-full py-2 pl-3 pr-10 text-left bg-white dark:bg-gray-800 rounded-lg shadow-md cursor-default focus:outline-none focus-visible:ring-2 focus-visible:ring-opacity-75 focus-visible:ring-white focus-visible:ring-offset-orange-300 focus-visible:ring-offset-2 focus-visible:border-indigo-500 dark:text-gray-400 sm:text-sm">
|
|
||||||
<span className="block truncate">{filterValue ? PushStatusOptions.find((o) => o.value === filterValue && o.value)!.label : "Push status"}</span>
|
|
||||||
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
|
||||||
<ChevronDownIcon
|
|
||||||
className="w-5 h-5 ml-2 -mr-1 text-gray-600 hover:text-gray-600"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</Listbox.Button>
|
|
||||||
<Transition
|
|
||||||
as={Fragment}
|
|
||||||
leave="transition ease-in duration-100"
|
|
||||||
leaveFrom="opacity-100"
|
|
||||||
leaveTo="opacity-0"
|
|
||||||
>
|
|
||||||
<Listbox.Options className="absolute w-full py-1 mt-1 overflow-auto text-base bg-white dark:bg-gray-800 rounded-md shadow-lg max-h-60 ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
|
|
||||||
<Listbox.Option
|
|
||||||
key={0}
|
|
||||||
className={({ active }) =>
|
|
||||||
`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 }) => (
|
|
||||||
<>
|
|
||||||
<span
|
|
||||||
className={`block truncate ${
|
|
||||||
selected ? 'font-medium' : 'font-normal'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
All
|
|
||||||
</span>
|
|
||||||
{selected ? (
|
|
||||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-gray-500 dark:text-gray-400">
|
|
||||||
<CheckIcon className="w-5 h-5" aria-hidden="true" />
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Listbox.Option>
|
|
||||||
{PushStatusOptions.map((status, idx) => (
|
|
||||||
<Listbox.Option
|
|
||||||
key={idx}
|
|
||||||
className={({ active }) =>
|
|
||||||
`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 }) => (
|
|
||||||
<>
|
|
||||||
<span
|
|
||||||
className={`block truncate ${
|
|
||||||
selected ? 'font-medium' : 'font-normal'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{status.label}
|
|
||||||
</span>
|
|
||||||
{selected ? (
|
|
||||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-gray-500 dark:text-gray-400">
|
|
||||||
<CheckIcon className="w-5 h-5" aria-hidden="true" />
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Listbox.Option>
|
|
||||||
))}
|
|
||||||
</Listbox.Options>
|
|
||||||
</Transition>
|
|
||||||
</div>
|
|
||||||
</Listbox>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 }: 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 }: any) {
|
|
||||||
return (
|
|
||||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-300" title={value}>{value}</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ReleaseStatusCellProps {
|
|
||||||
value: ReleaseActionStatus[];
|
|
||||||
column: any;
|
|
||||||
row: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ReleaseStatusCell({ value }: ReleaseStatusCellProps) {
|
|
||||||
const statusMap: any = {
|
|
||||||
"PUSH_ERROR": <span className="mr-1 inline-flex items-center rounded text-xs font-semibold uppercase bg-pink-100 text-pink-800 hover:bg-pink-300 cursor-pointer">
|
|
||||||
<ExclamationCircleIcon className="h-5 w-5" aria-hidden="true" />
|
|
||||||
</span>,
|
|
||||||
"PUSH_REJECTED": <span className="mr-1 inline-flex items-center rounded text-xs font-semibold uppercase bg-blue-200 dark:bg-blue-100 text-blue-400 dark:text-blue-800 hover:bg-blue-300 dark:hover:bg-blue-400 cursor-pointer">
|
|
||||||
<BanIcon className="h-5 w-5" aria-hidden="true" />
|
|
||||||
</span>,
|
|
||||||
"PUSH_APPROVED": <span className="mr-1 inline-flex items-center rounded text-xs font-semibold uppercase bg-green-100 text-green-800 hover:bg-green-300 cursor-pointer">
|
|
||||||
<CheckIcon className="h-5 w-5" aria-hidden="true" />
|
|
||||||
</span>,
|
|
||||||
"PENDING": <span className="mr-1 inline-flex items-center rounded text-xs font-semibold uppercase bg-yellow-100 text-yellow-800 hover:bg-yellow-200 cursor-pointer">
|
|
||||||
<ClockIcon className="h-5 w-5" aria-hidden="true" />
|
|
||||||
</span>,
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="flex text-sm font-medium text-gray-900 dark:text-gray-300">
|
|
||||||
{value.map((v, idx) => <div key={idx} title={`action: ${v.action}, type: ${v.type}, status: ${v.status}, time: ${simplifyDate(v.timestamp)}, rejections: ${v?.rejections}`}>{statusMap[v.status]}</div>)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function IndexerCell({ value }: any) {
|
|
||||||
return (
|
|
||||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-500" title={value}>{value}</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
case PAGE_CHANGED:
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
queryPageIndex: payload,
|
|
||||||
};
|
|
||||||
case PAGE_SIZE_CHANGED:
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
queryPageSize: payload,
|
|
||||||
};
|
|
||||||
case TOTAL_COUNT_CHANGED:
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
totalCount: payload,
|
|
||||||
};
|
|
||||||
|
|
||||||
case FILTER_CHANGED:
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
queryFilters: 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: "Actions",
|
|
||||||
accessor: 'action_status',
|
|
||||||
Cell: ReleaseStatusCell,
|
|
||||||
Filter: PushStatusSelectColumnFilter, // new
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Header: "Indexer",
|
|
||||||
accessor: 'indexer',
|
|
||||||
Cell: IndexerCell,
|
|
||||||
Filter: IndexerSelectColumnFilter, // new
|
|
||||||
filter: 'equal',
|
|
||||||
// filter: 'includes',
|
|
||||||
},
|
|
||||||
] as Column<Release>[], [])
|
|
||||||
|
|
||||||
const [{ queryPageIndex, queryPageSize, totalCount, queryFilters }, dispatch] =
|
|
||||||
React.useReducer(reducer, initialState);
|
|
||||||
|
|
||||||
const { isLoading, error, data, isSuccess } = useQuery(
|
|
||||||
['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,
|
|
||||||
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, 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!
|
|
||||||
// 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]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
dispatch({ type: FILTER_CHANGED, payload: filters });
|
|
||||||
}, [filters]);
|
|
||||||
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return <p>Error</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <p>Loading...</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render the UI for your table
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{isSuccess && data ? (
|
|
||||||
<div className="flex flex-col">
|
|
||||||
{/* <GlobalFilter
|
|
||||||
preGlobalFilteredRows={preGlobalFilteredRows}
|
|
||||||
globalFilter={globalFilter}
|
|
||||||
setGlobalFilter={setGlobalFilter}
|
|
||||||
preFilteredRows={preFilteredRows}
|
|
||||||
/> */}
|
|
||||||
<div className="flex mb-6">
|
|
||||||
|
|
||||||
{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>
|
|
||||||
|
|
||||||
<div className="overflow-hidden bg-white shadow-lg 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) => {
|
|
||||||
const { key: rowKey, ...rowRest } = headerGroup.getHeaderGroupProps();
|
|
||||||
return (
|
|
||||||
<tr key={rowKey} {...rowRest}>
|
|
||||||
{headerGroup.headers.map((column) => {
|
|
||||||
const { key: columnKey, ...columnRest } = column.getHeaderProps(column.getSortByToggleProps());
|
|
||||||
return (
|
|
||||||
// Add the sorting props to control sorting. For this example
|
|
||||||
// we can add them into the header props
|
|
||||||
<th
|
|
||||||
key={`${rowKey}-${columnKey}`}
|
|
||||||
scope="col"
|
|
||||||
className="px-6 py-3 text-xs font-medium tracking-wider text-left text-gray-500 uppercase group"
|
|
||||||
{...columnRest}
|
|
||||||
>
|
|
||||||
<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) => { // new
|
|
||||||
prepareRow(row)
|
|
||||||
const { key: bodyRowKey, ...bodyRowRest } = row.getRowProps();
|
|
||||||
return (
|
|
||||||
<tr key={bodyRowKey} {...bodyRowRest}>
|
|
||||||
{row.cells.map((cell: any) => {
|
|
||||||
const { key: cellRowKey, ...cellRowRest } = cell.getCellProps();
|
|
||||||
return (
|
|
||||||
<td
|
|
||||||
key={cellRowKey}
|
|
||||||
className="px-6 py-4 whitespace-nowrap"
|
|
||||||
role="cell"
|
|
||||||
{...cellRowRest}
|
|
||||||
>
|
|
||||||
{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 bg-gray-700">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 text-gray-400 dark:text-gray-500 dark:bg-gray-700">First</span>
|
|
||||||
<ChevronDoubleLeftIcon className="w-4 h-4 text-gray-400 dark:text-gray-500" aria-hidden="true" />
|
|
||||||
</PageButton>
|
|
||||||
<PageButton
|
|
||||||
onClick={() => previousPage()}
|
|
||||||
disabled={!canPreviousPage}
|
|
||||||
>
|
|
||||||
<span className="sr-only text-gray-400 dark:text-gray-500 dark:bg-gray-700">Previous</span>
|
|
||||||
<ChevronLeftIcon className="w-4 h-4 text-gray-400 dark:text-gray-500" aria-hidden="true" />
|
|
||||||
</PageButton>
|
|
||||||
<PageButton
|
|
||||||
onClick={() => nextPage()}
|
|
||||||
disabled={!canNextPage}>
|
|
||||||
<span className="sr-only text-gray-400 dark:text-gray-500 dark:bg-gray-700">Next</span>
|
|
||||||
<ChevronRightIcon className="w-4 h-4 text-gray-400 dark:text-gray-500" aria-hidden="true" />
|
|
||||||
</PageButton>
|
|
||||||
<PageButton
|
|
||||||
className="rounded-r-md"
|
|
||||||
onClick={() => gotoPage(pageCount - 1)}
|
|
||||||
disabled={!canNextPage}
|
|
||||||
>
|
|
||||||
<span className="sr-only text-gray-400 dark:text-gray-500 dark:bg-gray-700">Last</span>
|
|
||||||
<ChevronDoubleRightIcon className="w-4 h-4 text-gray-400 dark:text-gray-500" aria-hidden="true" />
|
|
||||||
</PageButton>
|
|
||||||
</nav>
|
|
||||||
</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 dark:border-gray-800 text-sm font-medium rounded-md text-gray-700 dark:text-gray-500 bg-white dark:bg-gray-800 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-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>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,11 +1,11 @@
|
||||||
import {CogIcon, DownloadIcon, KeyIcon} from '@heroicons/react/outline'
|
import {CogIcon, DownloadIcon, KeyIcon} from '@heroicons/react/outline'
|
||||||
import {NavLink, Route, Switch as RouteSwitch, useLocation, useRouteMatch} from "react-router-dom";
|
import {NavLink, Route, Switch as RouteSwitch, useLocation, useRouteMatch} from "react-router-dom";
|
||||||
|
|
||||||
|
import { classNames } from "../utils";
|
||||||
import IndexerSettings from "./settings/Indexer";
|
import IndexerSettings from "./settings/Indexer";
|
||||||
import IrcSettings from "./settings/Irc";
|
import { IrcSettings } from "./settings/Irc";
|
||||||
import ApplicationSettings from "./settings/Application";
|
import ApplicationSettings from "./settings/Application";
|
||||||
import DownloadClientSettings from "./settings/DownloadClient";
|
import DownloadClientSettings from "./settings/DownloadClient";
|
||||||
import {classNames} from "../utils";
|
|
||||||
import { RegexPlayground } from './settings/RegexPlayground';
|
import { RegexPlayground } from './settings/RegexPlayground';
|
||||||
|
|
||||||
const subNavigation = [
|
const subNavigation = [
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useQuery } from "react-query";
|
import { useQuery } from "react-query";
|
||||||
import formatDistanceToNowStrict from "date-fns/formatDistanceToNowStrict";
|
|
||||||
import {
|
import {
|
||||||
useTable,
|
useTable,
|
||||||
useFilters,
|
useFilters,
|
||||||
|
@ -9,65 +8,15 @@ import {
|
||||||
usePagination
|
usePagination
|
||||||
} from "react-table";
|
} from "react-table";
|
||||||
|
|
||||||
import { APIClient } from "../api/APIClient";
|
import { APIClient } from "../../api/APIClient";
|
||||||
import { EmptyListState } from "../components/emptystates";
|
import { EmptyListState } from "../../components/emptystates";
|
||||||
import { ReleaseStatusCell } from "./Releases";
|
|
||||||
|
|
||||||
export function Dashboard() {
|
import * as Icons from "../../components/Icons";
|
||||||
return (
|
import * as DataTable from "../../components/data-table";
|
||||||
<main className="py-10">
|
|
||||||
<div className="px-4 pb-8 mx-auto max-w-7xl sm:px-6 lg:px-8">
|
|
||||||
<Stats />
|
|
||||||
<DataTable />
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const StatsItem = ({ name, stat }: any) => (
|
|
||||||
<div
|
|
||||||
className="relative px-4 pt-5 pb-2 overflow-hidden bg-white rounded-lg shadow-lg dark:bg-gray-800 sm:pt-6 sm:px-6"
|
|
||||||
title="All time"
|
|
||||||
>
|
|
||||||
<dt>
|
|
||||||
<p className="pb-1 text-sm font-medium text-gray-500 truncate">{name}</p>
|
|
||||||
</dt>
|
|
||||||
|
|
||||||
<dd className="flex items-baseline pb-6 sm:pb-7">
|
|
||||||
<p className="text-2xl font-semibold text-gray-900 dark:text-gray-200">{stat}</p>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
function Stats() {
|
|
||||||
const { isLoading, data } = useQuery(
|
|
||||||
'dash_release_stats',
|
|
||||||
() => APIClient.release.stats(),
|
|
||||||
{ refetchOnWindowFocus: false }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isLoading)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h3 className="text-2xl font-medium leading-6 text-gray-900 dark:text-gray-200">
|
|
||||||
Stats
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<dl className="grid grid-cols-1 gap-5 mt-5 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
<StatsItem name="Filtered Releases" stat={data?.filtered_count} />
|
|
||||||
{/* <StatsItem name="Filter Rejected Releases" stat={data?.filter_rejected_count} /> */}
|
|
||||||
<StatsItem name="Rejected Pushes" stat={data?.push_rejected_count} />
|
|
||||||
<StatsItem name="Approved Pushes" stat={data?.push_approved_count} />
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// This is a custom filter UI for selecting
|
// This is a custom filter UI for selecting
|
||||||
// a unique option from a list
|
// a unique option from a list
|
||||||
export function SelectColumnFilter({
|
function SelectColumnFilter({
|
||||||
column: { filterValue, setFilter, preFilteredRows, id, render },
|
column: { filterValue, setFilter, preFilteredRows, id, render },
|
||||||
}: any) {
|
}: any) {
|
||||||
// Calculate the options for filtering
|
// Calculate the options for filtering
|
||||||
|
@ -104,42 +53,6 @@ export function SelectColumnFilter({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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 }: 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 }: any) {
|
|
||||||
return (
|
|
||||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-300">{value}</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function IndexerCell({ value }: any) {
|
|
||||||
return (
|
|
||||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-500" title={value}>{value}</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Table({ columns, data }: any) {
|
function Table({ columns, data }: any) {
|
||||||
// Use the state and functions returned from useTable to build your UI
|
// Use the state and functions returned from useTable to build your UI
|
||||||
const {
|
const {
|
||||||
|
@ -148,29 +61,12 @@ function Table({ columns, data }: any) {
|
||||||
headerGroups,
|
headerGroups,
|
||||||
prepareRow,
|
prepareRow,
|
||||||
page, // Instead of using 'rows', we'll use page,
|
page, // Instead of using 'rows', we'll use page,
|
||||||
// which has only the rows for the active page
|
} = useTable(
|
||||||
|
{ columns, data },
|
||||||
// The rest of these things are super handy, too ;)
|
useFilters,
|
||||||
// canPreviousPage,
|
|
||||||
// canNextPage,
|
|
||||||
// pageOptions,
|
|
||||||
// pageCount,
|
|
||||||
// gotoPage,
|
|
||||||
// nextPage,
|
|
||||||
// previousPage,
|
|
||||||
// setPageSize,
|
|
||||||
|
|
||||||
// state,
|
|
||||||
// preGlobalFilteredRows,
|
|
||||||
// setGlobalFilter,
|
|
||||||
} = useTable({
|
|
||||||
columns,
|
|
||||||
data,
|
|
||||||
},
|
|
||||||
useFilters, // useFilters!
|
|
||||||
useGlobalFilter,
|
useGlobalFilter,
|
||||||
useSortBy,
|
useSortBy,
|
||||||
usePagination, // new
|
usePagination
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!page.length)
|
if (!page.length)
|
||||||
|
@ -205,12 +101,12 @@ function Table({ columns, data }: any) {
|
||||||
<span>
|
<span>
|
||||||
{column.isSorted ? (
|
{column.isSorted ? (
|
||||||
column.isSortedDesc ? (
|
column.isSortedDesc ? (
|
||||||
<SortDownIcon className="w-4 h-4 text-gray-400" />
|
<Icons.SortDownIcon className="w-4 h-4 text-gray-400" />
|
||||||
) : (
|
) : (
|
||||||
<SortUpIcon className="w-4 h-4 text-gray-400" />
|
<Icons.SortUpIcon className="w-4 h-4 text-gray-400" />
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<SortIcon className="w-4 h-4 text-gray-400 opacity-0 group-hover:opacity-100" />
|
<Icons.SortIcon className="w-4 h-4 text-gray-400 opacity-0 group-hover:opacity-100" />
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
@ -258,46 +154,28 @@ function Table({ columns, data }: any) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SortIcon({ className }: any) {
|
export const ActivityTable = () => {
|
||||||
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 DataTable() {
|
|
||||||
const columns = React.useMemo(() => [
|
const columns = React.useMemo(() => [
|
||||||
{
|
{
|
||||||
Header: "Age",
|
Header: "Age",
|
||||||
accessor: 'timestamp',
|
accessor: 'timestamp',
|
||||||
Cell: AgeCell,
|
Cell: DataTable.AgeCell,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Header: "Release",
|
Header: "Release",
|
||||||
accessor: 'torrent_name',
|
accessor: 'torrent_name',
|
||||||
Cell: ReleaseCell,
|
Cell: DataTable.ReleaseCell,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Header: "Actions",
|
Header: "Actions",
|
||||||
accessor: 'action_status',
|
accessor: 'action_status',
|
||||||
Cell: ReleaseStatusCell,
|
Cell: DataTable.ReleaseStatusCell,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Header: "Indexer",
|
Header: "Indexer",
|
||||||
accessor: 'indexer',
|
accessor: 'indexer',
|
||||||
Cell: IndexerCell,
|
Cell: DataTable.IndexerCell,
|
||||||
Filter: SelectColumnFilter, // new
|
Filter: SelectColumnFilter,
|
||||||
filter: 'includes',
|
filter: 'includes',
|
||||||
},
|
},
|
||||||
], [])
|
], [])
|
48
web/src/screens/dashboard/Stats.tsx
Normal file
48
web/src/screens/dashboard/Stats.tsx
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
import { useQuery } from "react-query";
|
||||||
|
import { APIClient } from "../../api/APIClient";
|
||||||
|
|
||||||
|
interface StatsItemProps {
|
||||||
|
name: string;
|
||||||
|
value?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StatsItem = ({ name, value }: StatsItemProps) => (
|
||||||
|
<div
|
||||||
|
className="relative px-4 pt-5 pb-2 overflow-hidden bg-white rounded-lg shadow-lg dark:bg-gray-800 sm:pt-6 sm:px-6"
|
||||||
|
title="All time"
|
||||||
|
>
|
||||||
|
<dt>
|
||||||
|
<p className="pb-1 text-sm font-medium text-gray-500 truncate">{name}</p>
|
||||||
|
</dt>
|
||||||
|
|
||||||
|
<dd className="flex items-baseline pb-6 sm:pb-7">
|
||||||
|
<p className="text-2xl font-semibold text-gray-900 dark:text-gray-200">{value}</p>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const Stats = () => {
|
||||||
|
const { isLoading, data } = useQuery(
|
||||||
|
"dash_release_stats",
|
||||||
|
() => APIClient.release.stats(),
|
||||||
|
{ refetchOnWindowFocus: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-2xl font-medium leading-6 text-gray-900 dark:text-gray-200">
|
||||||
|
Stats
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<dl className="grid grid-cols-1 gap-5 mt-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<StatsItem name="Filtered Releases" value={data?.filtered_count} />
|
||||||
|
{/* <StatsItem name="Filter Rejected Releases" stat={data?.filter_rejected_count} /> */}
|
||||||
|
<StatsItem name="Rejected Pushes" value={data?.push_rejected_count} />
|
||||||
|
<StatsItem name="Approved Pushes" value={data?.push_approved_count} />
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
11
web/src/screens/dashboard/index.tsx
Normal file
11
web/src/screens/dashboard/index.tsx
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
import { Stats } from "./Stats";
|
||||||
|
import { ActivityTable } from "./ActivityTable";
|
||||||
|
|
||||||
|
export const Dashboard = () => (
|
||||||
|
<main className="py-10">
|
||||||
|
<div className="px-4 pb-8 mx-auto max-w-7xl sm:px-6 lg:px-8">
|
||||||
|
<Stats />
|
||||||
|
<ActivityTable />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
142
web/src/screens/releases/Filters.tsx
Normal file
142
web/src/screens/releases/Filters.tsx
Normal file
|
@ -0,0 +1,142 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import { useQuery } from "react-query";
|
||||||
|
import { Listbox, Transition } from "@headlessui/react";
|
||||||
|
import {
|
||||||
|
CheckIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
|
} from "@heroicons/react/solid";
|
||||||
|
|
||||||
|
import { APIClient } from "../../api/APIClient";
|
||||||
|
import { classNames } from "../../utils";
|
||||||
|
import { PushStatusOptions } from "../../domain/constants";
|
||||||
|
|
||||||
|
interface ListboxFilterProps {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
currentValue: string;
|
||||||
|
onChange: (newValue: string) => void;
|
||||||
|
children: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ListboxFilter = ({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
currentValue,
|
||||||
|
onChange,
|
||||||
|
children
|
||||||
|
}: ListboxFilterProps) => (
|
||||||
|
<div className="w-48">
|
||||||
|
<Listbox
|
||||||
|
refName={id}
|
||||||
|
value={currentValue}
|
||||||
|
onChange={onChange}
|
||||||
|
>
|
||||||
|
<div className="relative mt-1">
|
||||||
|
<Listbox.Button className="relative w-full py-2 pl-3 pr-10 text-left bg-white dark:bg-gray-800 rounded-lg shadow-md cursor-default dark:text-gray-400 sm:text-sm">
|
||||||
|
<span className="block truncate">{label}</span>
|
||||||
|
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||||
|
<ChevronDownIcon
|
||||||
|
className="w-5 h-5 ml-2 -mr-1 text-gray-600 hover:text-gray-600"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</Listbox.Button>
|
||||||
|
<Transition
|
||||||
|
as={React.Fragment}
|
||||||
|
leave="transition ease-in duration-100"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
|
<Listbox.Options
|
||||||
|
className="absolute w-full mt-1 overflow-auto text-base bg-white dark:bg-gray-800 rounded-md shadow-lg max-h-60 border border-opacity-5 border-black dark:border-gray-700 dark:border-opacity-40 focus:outline-none sm:text-sm"
|
||||||
|
>
|
||||||
|
<FilterOption label="All" />
|
||||||
|
{children}
|
||||||
|
</Listbox.Options>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</Listbox>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// a unique option from a list
|
||||||
|
export const IndexerSelectColumnFilter = ({
|
||||||
|
column: { filterValue, setFilter, id }
|
||||||
|
}: any) => {
|
||||||
|
const { data, isSuccess } = useQuery(
|
||||||
|
"release_indexers",
|
||||||
|
() => APIClient.release.indexerOptions(),
|
||||||
|
{
|
||||||
|
keepPreviousData: true,
|
||||||
|
staleTime: Infinity,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Render a multi-select box
|
||||||
|
return (
|
||||||
|
<ListboxFilter
|
||||||
|
id={id}
|
||||||
|
label={filterValue ?? "Indexer"}
|
||||||
|
currentValue={filterValue}
|
||||||
|
onChange={setFilter}
|
||||||
|
>
|
||||||
|
{isSuccess && data?.map((indexer, idx) => (
|
||||||
|
<FilterOption key={idx} label={indexer} value={indexer} />
|
||||||
|
))}
|
||||||
|
</ListboxFilter>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilterOptionProps {
|
||||||
|
label: string;
|
||||||
|
value?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FilterOption = ({ label, value }: FilterOptionProps) => (
|
||||||
|
<Listbox.Option
|
||||||
|
className={({ active }) => classNames(
|
||||||
|
"cursor-pointer select-none relative py-2 pl-10 pr-4",
|
||||||
|
active ? 'text-black dark:text-gray-200 bg-gray-100 dark:bg-gray-900' : 'text-gray-700 dark:text-gray-400'
|
||||||
|
)}
|
||||||
|
value={value}
|
||||||
|
>
|
||||||
|
{({ selected }) => (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className={classNames(
|
||||||
|
"block truncate",
|
||||||
|
selected ? "font-medium text-black dark:text-white" : "font-normal"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{selected ? (
|
||||||
|
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-gray-500 dark:text-gray-400">
|
||||||
|
<CheckIcon className="w-5 h-5" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Listbox.Option>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const PushStatusSelectColumnFilter = ({
|
||||||
|
column: { filterValue, setFilter, id }
|
||||||
|
}: any) => (
|
||||||
|
<div className="mr-3">
|
||||||
|
<ListboxFilter
|
||||||
|
id={id}
|
||||||
|
label={
|
||||||
|
filterValue
|
||||||
|
? PushStatusOptions.find((o) => o.value === filterValue && o.value)?.label
|
||||||
|
: "Push status"
|
||||||
|
}
|
||||||
|
currentValue={filterValue}
|
||||||
|
onChange={setFilter}
|
||||||
|
>
|
||||||
|
{PushStatusOptions.map((status, idx) => (
|
||||||
|
<FilterOption key={idx} value={status.value} label={status.label} />
|
||||||
|
))}
|
||||||
|
</ListboxFilter>
|
||||||
|
</div>
|
||||||
|
);
|
319
web/src/screens/releases/ReleaseTable.tsx
Normal file
319
web/src/screens/releases/ReleaseTable.tsx
Normal file
|
@ -0,0 +1,319 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import { useQuery } from "react-query";
|
||||||
|
import {
|
||||||
|
useTable,
|
||||||
|
useSortBy,
|
||||||
|
usePagination,
|
||||||
|
useFilters,
|
||||||
|
Column
|
||||||
|
} from "react-table";
|
||||||
|
import {
|
||||||
|
ChevronDoubleLeftIcon,
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
ChevronDoubleRightIcon
|
||||||
|
} from "@heroicons/react/solid";
|
||||||
|
|
||||||
|
import { APIClient } from "../../api/APIClient";
|
||||||
|
import { EmptyListState } from "../../components/emptystates";
|
||||||
|
|
||||||
|
import * as Icons from "../../components/Icons";
|
||||||
|
import * as DataTable from "../../components/data-table";
|
||||||
|
|
||||||
|
import {
|
||||||
|
IndexerSelectColumnFilter,
|
||||||
|
PushStatusSelectColumnFilter
|
||||||
|
} from "./Filters";
|
||||||
|
|
||||||
|
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 TableReducer = (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 };
|
||||||
|
case FILTER_CHANGED:
|
||||||
|
return { ...state, queryFilters: payload };
|
||||||
|
default:
|
||||||
|
throw new Error(`Unhandled action type: ${type}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ReleaseTable = () => {
|
||||||
|
const columns = React.useMemo(() => [
|
||||||
|
{
|
||||||
|
Header: "Age",
|
||||||
|
accessor: 'timestamp',
|
||||||
|
Cell: DataTable.AgeCell,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: "Release",
|
||||||
|
accessor: 'torrent_name',
|
||||||
|
Cell: DataTable.ReleaseCell,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: "Actions",
|
||||||
|
accessor: 'action_status',
|
||||||
|
Cell: DataTable.ReleaseStatusCell,
|
||||||
|
Filter: PushStatusSelectColumnFilter,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: "Indexer",
|
||||||
|
accessor: 'indexer',
|
||||||
|
Cell: DataTable.IndexerCell,
|
||||||
|
Filter: IndexerSelectColumnFilter,
|
||||||
|
filter: 'equal',
|
||||||
|
},
|
||||||
|
] as Column<Release>[], [])
|
||||||
|
|
||||||
|
const [{ queryPageIndex, queryPageSize, totalCount, queryFilters }, dispatch] =
|
||||||
|
React.useReducer(TableReducer, initialState);
|
||||||
|
|
||||||
|
const { isLoading, error, data, isSuccess } = useQuery(
|
||||||
|
['releases', queryPageIndex, queryPageSize, queryFilters],
|
||||||
|
() => APIClient.release.findQuery(queryPageIndex * queryPageSize, queryPageSize, queryFilters),
|
||||||
|
{
|
||||||
|
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, filters }
|
||||||
|
} = useTable(
|
||||||
|
{
|
||||||
|
columns,
|
||||||
|
data: data && isSuccess ? data.data : [],
|
||||||
|
initialState: {
|
||||||
|
pageIndex: queryPageIndex,
|
||||||
|
pageSize: queryPageSize,
|
||||||
|
filters: []
|
||||||
|
},
|
||||||
|
manualPagination: true,
|
||||||
|
manualFilters: true,
|
||||||
|
manualSortBy: true,
|
||||||
|
pageCount: isSuccess ? Math.ceil(totalCount / queryPageSize) : 0,
|
||||||
|
autoResetSortBy: false,
|
||||||
|
autoResetExpanded: false,
|
||||||
|
autoResetPage: false
|
||||||
|
},
|
||||||
|
useFilters,
|
||||||
|
useSortBy,
|
||||||
|
usePagination,
|
||||||
|
);
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
dispatch({ type: FILTER_CHANGED, payload: filters });
|
||||||
|
}, [filters]);
|
||||||
|
|
||||||
|
if (error)
|
||||||
|
return <p>Error</p>;
|
||||||
|
|
||||||
|
if (isLoading)
|
||||||
|
return <p>Loading...</p>;
|
||||||
|
|
||||||
|
if (!data)
|
||||||
|
return <EmptyListState text="No recent activity" />
|
||||||
|
|
||||||
|
// Render the UI for your table
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="flex mb-6">
|
||||||
|
{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>
|
||||||
|
|
||||||
|
<div className="overflow-hidden bg-white shadow-lg 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) => {
|
||||||
|
const { key: rowKey, ...rowRest } = headerGroup.getHeaderGroupProps();
|
||||||
|
return (
|
||||||
|
<tr key={rowKey} {...rowRest}>
|
||||||
|
{headerGroup.headers.map((column) => {
|
||||||
|
const { key: columnKey, ...columnRest } = column.getHeaderProps(column.getSortByToggleProps());
|
||||||
|
return (
|
||||||
|
// Add the sorting props to control sorting. For this example
|
||||||
|
// we can add them into the header props
|
||||||
|
<th
|
||||||
|
key={`${rowKey}-${columnKey}`}
|
||||||
|
scope="col"
|
||||||
|
className="px-6 py-3 text-xs font-medium tracking-wider text-left text-gray-500 uppercase group"
|
||||||
|
{...columnRest}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
{column.render('Header')}
|
||||||
|
{/* Add a sort direction indicator */}
|
||||||
|
<span>
|
||||||
|
{column.isSorted ? (
|
||||||
|
column.isSortedDesc ? (
|
||||||
|
<Icons.SortDownIcon className="w-4 h-4 text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<Icons.SortUpIcon className="w-4 h-4 text-gray-400" />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Icons.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) => {
|
||||||
|
prepareRow(row);
|
||||||
|
|
||||||
|
const { key: bodyRowKey, ...bodyRowRest } = row.getRowProps();
|
||||||
|
return (
|
||||||
|
<tr key={bodyRowKey} {...bodyRowRest}>
|
||||||
|
{row.cells.map((cell: any) => {
|
||||||
|
const { key: cellRowKey, ...cellRowRest } = cell.getCellProps();
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={cellRowKey}
|
||||||
|
className="px-6 py-4 whitespace-nowrap"
|
||||||
|
role="cell"
|
||||||
|
{...cellRowRest}
|
||||||
|
>
|
||||||
|
{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">
|
||||||
|
<DataTable.Button onClick={() => previousPage()} disabled={!canPreviousPage}>Previous</DataTable.Button>
|
||||||
|
<DataTable.Button onClick={() => nextPage()} disabled={!canNextPage}>Next</DataTable.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 bg-gray-700">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">
|
||||||
|
<DataTable.PageButton
|
||||||
|
className="rounded-l-md"
|
||||||
|
onClick={() => gotoPage(0)}
|
||||||
|
disabled={!canPreviousPage}
|
||||||
|
>
|
||||||
|
<span className="sr-only text-gray-400 dark:text-gray-500 dark:bg-gray-700">First</span>
|
||||||
|
<ChevronDoubleLeftIcon className="w-4 h-4 text-gray-400 dark:text-gray-500" aria-hidden="true" />
|
||||||
|
</DataTable.PageButton>
|
||||||
|
<DataTable.PageButton
|
||||||
|
onClick={() => previousPage()}
|
||||||
|
disabled={!canPreviousPage}
|
||||||
|
>
|
||||||
|
<span className="sr-only text-gray-400 dark:text-gray-500 dark:bg-gray-700">Previous</span>
|
||||||
|
<ChevronLeftIcon className="w-4 h-4 text-gray-400 dark:text-gray-500" aria-hidden="true" />
|
||||||
|
</DataTable.PageButton>
|
||||||
|
<DataTable.PageButton
|
||||||
|
onClick={() => nextPage()}
|
||||||
|
disabled={!canNextPage}>
|
||||||
|
<span className="sr-only text-gray-400 dark:text-gray-500 dark:bg-gray-700">Next</span>
|
||||||
|
<ChevronRightIcon className="w-4 h-4 text-gray-400 dark:text-gray-500" aria-hidden="true" />
|
||||||
|
</DataTable.PageButton>
|
||||||
|
<DataTable.PageButton
|
||||||
|
className="rounded-r-md"
|
||||||
|
onClick={() => gotoPage(pageCount - 1)}
|
||||||
|
disabled={!canNextPage}
|
||||||
|
>
|
||||||
|
<span className="sr-only text-gray-400 dark:text-gray-500 dark:bg-gray-700">Last</span>
|
||||||
|
<ChevronDoubleRightIcon className="w-4 h-4 text-gray-400 dark:text-gray-500" aria-hidden="true" />
|
||||||
|
</DataTable.PageButton>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
14
web/src/screens/releases/index.tsx
Normal file
14
web/src/screens/releases/index.tsx
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
import { ReleaseTable } from "./ReleaseTable";
|
||||||
|
|
||||||
|
export const Releases = () => (
|
||||||
|
<main>
|
||||||
|
<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-black dark:text-white capitalize">Releases</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="px-4 pb-8 mx-auto max-w-7xl sm:px-6 lg:px-8">
|
||||||
|
<ReleaseTable />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
|
@ -1,34 +1,22 @@
|
||||||
import { useQuery } from "react-query";
|
import { useQuery } from "react-query";
|
||||||
import { formatDistanceToNowStrict, formatISO9075 } from "date-fns";
|
|
||||||
|
|
||||||
import { APIClient } from "../../api/APIClient";
|
import {
|
||||||
|
simplifyDate,
|
||||||
|
IsEmptyDate
|
||||||
|
} from "../../utils";
|
||||||
|
import {
|
||||||
|
IrcNetworkAddForm,
|
||||||
|
IrcNetworkUpdateForm
|
||||||
|
} from "../../forms";
|
||||||
import { useToggle } from "../../hooks/hooks";
|
import { useToggle } from "../../hooks/hooks";
|
||||||
|
import { APIClient } from "../../api/APIClient";
|
||||||
import { EmptySimple } from "../../components/emptystates";
|
import { EmptySimple } from "../../components/emptystates";
|
||||||
import { IrcNetworkAddForm, IrcNetworkUpdateForm } from "../../forms";
|
|
||||||
|
|
||||||
|
export const IrcSettings = () => {
|
||||||
function IsEmptyDate(date: string) {
|
|
||||||
if (date !== "0001-01-01T00:00:00Z") {
|
|
||||||
return formatDistanceToNowStrict(
|
|
||||||
new Date(date),
|
|
||||||
{ addSuffix: true }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return "n/a"
|
|
||||||
}
|
|
||||||
|
|
||||||
function simplifyDate(date: string) {
|
|
||||||
if (date !== "0001-01-01T00:00:00Z") {
|
|
||||||
return formatISO9075(new Date(date))
|
|
||||||
}
|
|
||||||
return "n/a"
|
|
||||||
}
|
|
||||||
|
|
||||||
function IrcSettings() {
|
|
||||||
const [addNetworkIsOpen, toggleAddNetwork] = useToggle(false)
|
const [addNetworkIsOpen, toggleAddNetwork] = useToggle(false)
|
||||||
|
|
||||||
const { data } = useQuery(
|
const { data } = useQuery(
|
||||||
'networks',
|
"networks",
|
||||||
APIClient.irc.getNetworks,
|
APIClient.irc.getNetworks,
|
||||||
{ refetchOnWindowFocus: false }
|
{ refetchOnWindowFocus: false }
|
||||||
);
|
);
|
||||||
|
@ -56,7 +44,7 @@ function IrcSettings() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{data && data.length > 0 ?
|
{data && data.length > 0 ? (
|
||||||
<section className="mt-6 light:bg-white dark:bg-gray-800 light:shadow sm:rounded-md">
|
<section className="mt-6 light:bg-white dark:bg-gray-800 light:shadow sm:rounded-md">
|
||||||
<ol className="min-w-full">
|
<ol className="min-w-full">
|
||||||
<li className="grid grid-cols-12 gap-4 border-b border-gray-200 dark:border-gray-700">
|
<li className="grid grid-cols-12 gap-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
@ -66,23 +54,23 @@ function IrcSettings() {
|
||||||
<div className="col-span-4 px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Nick</div>
|
<div className="col-span-4 px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Nick</div>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
{data && data.map((network: IrcNetwork, idx) => (
|
{data && data.map((network, idx) => (
|
||||||
<LiItem key={idx} idx={idx} network={network} />
|
<ListItem key={idx} idx={idx} network={network} />
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
</section>
|
</section>
|
||||||
: <EmptySimple title="No networks" subtitle="Add a new network" buttonText="New network" buttonAction={toggleAddNetwork} />}
|
) : <EmptySimple title="No networks" subtitle="Add a new network" buttonText="New network" buttonAction={toggleAddNetwork} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LiItemProps {
|
interface ListItemProps {
|
||||||
idx: number;
|
idx: number;
|
||||||
network: IrcNetwork;
|
network: IrcNetworkWithHealth;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LiItem = ({ idx, network }: LiItemProps) => {
|
const ListItem = ({ idx, network }: ListItemProps) => {
|
||||||
const [updateIsOpen, toggleUpdate] = useToggle(false)
|
const [updateIsOpen, toggleUpdate] = useToggle(false)
|
||||||
const [edit, toggleEdit] = useToggle(false);
|
const [edit, toggleEdit] = useToggle(false);
|
||||||
|
|
||||||
|
@ -98,8 +86,8 @@ const LiItem = ({ idx, network }: LiItemProps) => {
|
||||||
network.enabled ? (
|
network.enabled ? (
|
||||||
network.connected ? (
|
network.connected ? (
|
||||||
<span className="mr-3 flex h-3 w-3 relative" title={`Connected since: ${simplifyDate(network.connected_since)}`}>
|
<span className="mr-3 flex h-3 w-3 relative" title={`Connected since: ${simplifyDate(network.connected_since)}`}>
|
||||||
<span className="animate-ping inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
<span className="animate-ping inline-flex h-full w-full rounded-full bg-green-400 opacity-75"/>
|
||||||
<span className="inline-flex absolute rounded-full h-3 w-3 bg-green-500"></span>
|
<span className="inline-flex absolute rounded-full h-3 w-3 bg-green-500"/>
|
||||||
</span>
|
</span>
|
||||||
) : <span className="mr-3 flex h-3 w-3 rounded-full opacity-75 bg-red-400" />
|
) : <span className="mr-3 flex h-3 w-3 rounded-full opacity-75 bg-red-400" />
|
||||||
) : <span className="mr-3 flex h-3 w-3 rounded-full opacity-75 bg-gray-500" />
|
) : <span className="mr-3 flex h-3 w-3 rounded-full opacity-75 bg-gray-500" />
|
||||||
|
@ -109,7 +97,9 @@ const LiItem = ({ idx, network }: LiItemProps) => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="col-span-4 flex justify-between items-center sm:px-6 text-sm text-gray-500 dark:text-gray-400 cursor-pointer" onClick={toggleEdit}>{network.server}:{network.port} {network.tls && <span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-300 text-green-800 dark:text-green-900">TLS</span>}</div>
|
<div className="col-span-4 flex justify-between items-center sm:px-6 text-sm text-gray-500 dark:text-gray-400 cursor-pointer" onClick={toggleEdit}>{network.server}:{network.port} {network.tls && <span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-300 text-green-800 dark:text-green-900">TLS</span>}</div>
|
||||||
|
{network.nickserv && network.nickserv.account ? (
|
||||||
<div className="col-span-4 items-center sm:px-6 text-sm text-gray-500 dark:text-gray-400 cursor-pointer" onClick={toggleEdit}>{network.nickserv.account}</div>
|
<div className="col-span-4 items-center sm:px-6 text-sm text-gray-500 dark:text-gray-400 cursor-pointer" onClick={toggleEdit}>{network.nickserv.account}</div>
|
||||||
|
) : null}
|
||||||
<div className="col-span-1 text-sm text-gray-500 dark:text-gray-400">
|
<div className="col-span-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
<span className="text-indigo-600 dark:text-gray-300 hover:text-indigo-900 cursor-pointer" onClick={toggleUpdate}>
|
<span className="text-indigo-600 dark:text-gray-300 hover:text-indigo-900 cursor-pointer" onClick={toggleUpdate}>
|
||||||
Edit
|
Edit
|
||||||
|
@ -119,6 +109,7 @@ const LiItem = ({ idx, network }: LiItemProps) => {
|
||||||
{edit && (
|
{edit && (
|
||||||
<div className="px-4 py-4 flex border-b border-x-0 dark:border-gray-600 dark:bg-gray-700">
|
<div className="px-4 py-4 flex border-b border-x-0 dark:border-gray-600 dark:bg-gray-700">
|
||||||
<div className="min-w-full">
|
<div className="min-w-full">
|
||||||
|
{network.channels.length > 0 ? (
|
||||||
<ol>
|
<ol>
|
||||||
<li className="grid grid-cols-12 gap-4 border-b border-gray-200 dark:border-gray-700">
|
<li className="grid grid-cols-12 gap-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
<div className="col-span-4 px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Channel</div>
|
<div className="col-span-4 px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Channel</div>
|
||||||
|
@ -134,8 +125,8 @@ const LiItem = ({ idx, network }: LiItemProps) => {
|
||||||
network.enabled ? (
|
network.enabled ? (
|
||||||
c.monitoring ? (
|
c.monitoring ? (
|
||||||
<span className="mr-3 flex h-3 w-3 relative" title="monitoring">
|
<span className="mr-3 flex h-3 w-3 relative" title="monitoring">
|
||||||
<span className="animate-ping inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
<span className="animate-ping inline-flex h-full w-full rounded-full bg-green-400 opacity-75"/>
|
||||||
<span className="inline-flex absolute rounded-full h-3 w-3 bg-green-500"></span>
|
<span className="inline-flex absolute rounded-full h-3 w-3 bg-green-500"/>
|
||||||
</span>
|
</span>
|
||||||
) : <span className="mr-3 flex h-3 w-3 rounded-full opacity-75 bg-red-400" />
|
) : <span className="mr-3 flex h-3 w-3 rounded-full opacity-75 bg-red-400" />
|
||||||
) : <span className="mr-3 flex h-3 w-3 rounded-full opacity-75 bg-gray-500" />
|
) : <span className="mr-3 flex h-3 w-3 rounded-full opacity-75 bg-gray-500" />
|
||||||
|
@ -153,11 +144,10 @@ const LiItem = ({ idx, network }: LiItemProps) => {
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
|
) : <div className="flex text-center justify-center py-4 dark:text-gray-500"><p>No channels!</p></div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default IrcSettings;
|
|
||||||
|
|
58
web/src/types/Irc.d.ts
vendored
58
web/src/types/Irc.d.ts
vendored
|
@ -2,65 +2,49 @@ interface IrcNetwork {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
addr: string;
|
|
||||||
server: string;
|
server: string;
|
||||||
port: string;
|
port: number;
|
||||||
nick: string;
|
|
||||||
username: string;
|
|
||||||
realname: string;
|
|
||||||
pass: string;
|
|
||||||
connected: boolean;
|
|
||||||
connected_since: string;
|
|
||||||
tls: boolean;
|
tls: boolean;
|
||||||
nickserv: {
|
pass: string;
|
||||||
account: string;
|
invite_command: string;
|
||||||
}
|
nickserv?: NickServ; // optional
|
||||||
channels: IrcNetworkChannel[];
|
channels: IrcChannel[];
|
||||||
|
connected: boolean;
|
||||||
|
connected_since: Time;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IrcNetworkChannel {
|
interface IrcChannel {
|
||||||
id: number;
|
id: number;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
name: string;
|
name: string;
|
||||||
password: string;
|
password: string;
|
||||||
detached: boolean;
|
detached: boolean;
|
||||||
monitoring: boolean;
|
monitoring: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IrcChannelWithHealth extends IrcChannel {
|
||||||
monitoring_since: string;
|
monitoring_since: string;
|
||||||
last_announce: string;
|
last_announce: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NickServ {
|
interface IrcNetworkWithHealth {
|
||||||
account: string;
|
id: number;
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Network {
|
|
||||||
id?: number;
|
|
||||||
name: string;
|
name: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
server: string;
|
server: string;
|
||||||
port: number;
|
port: number;
|
||||||
tls: boolean;
|
tls: boolean;
|
||||||
|
pass: string;
|
||||||
invite_command: string;
|
invite_command: string;
|
||||||
nickserv: {
|
nickserv?: NickServ; // optional
|
||||||
account: string;
|
channels: IrcChannelWithHealth[];
|
||||||
password: string;
|
connected: boolean;
|
||||||
}
|
connected_since: string;
|
||||||
channels: Channel[];
|
|
||||||
settings: object;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Channel {
|
interface NickServ {
|
||||||
name: string;
|
account?: string; // optional
|
||||||
password: string;
|
password?: string; // optional
|
||||||
}
|
|
||||||
|
|
||||||
interface SASL {
|
|
||||||
mechanism: string;
|
|
||||||
plain: {
|
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Config {
|
interface Config {
|
||||||
|
|
5
web/src/types/Release.d.ts
vendored
5
web/src/types/Release.d.ts
vendored
|
@ -34,3 +34,8 @@ interface ReleaseStats {
|
||||||
push_approved_count: number;
|
push_approved_count: number;
|
||||||
push_rejected_count: number;
|
push_rejected_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ReleaseFilter {
|
||||||
|
id: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue