diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx new file mode 100644 index 00000000..55a13941 --- /dev/null +++ b/packages/ui/src/App.tsx @@ -0,0 +1,69 @@ +import React, { Suspense } from 'react'; +import { Route, Switch } from 'react-router-dom'; +import { ToastContainer } from 'react-toastify'; +import { ConfirmModal } from './components/ConfirmModal/ConfirmModal'; +import { Header } from './components/Header/Header'; +import { HeaderActions } from './components/HeaderActions/HeaderActions'; +import { Menu } from './components/Menu/Menu'; +import { Title } from './components/Title/Title'; +import { useActiveQueue } from './hooks/useActiveQueue'; +import { useScrollTopOnNav } from './hooks/useScrollTopOnNav'; +import { useStore } from './hooks/useStore'; + +const QueuePageLazy = React.lazy(() => + import('./pages/QueuePage/QueuePage').then(({ QueuePage }) => ({ default: QueuePage })) +); + +const OverviewPageLazy = React.lazy(() => + import('./pages/OverviewPage/OverviewPage').then(({ OverviewPage }) => ({ + default: OverviewPage, + })) +); + +export const App = () => { + useScrollTopOnNav(); + const { state, actions, selectedStatuses, confirmProps } = useStore(); + const activeQueue = useActiveQueue(state.data); + + return ( + <> +
+ + <HeaderActions /> + </Header> + <main> + <div> + {state.loading ? ( + 'Loading...' + ) : ( + <> + <Suspense fallback={() => 'Loading...'}> + <Switch> + <Route + path="/queue/:name" + render={() => ( + <QueuePageLazy + queue={activeQueue || null} + actions={actions} + selectedStatus={selectedStatuses} + /> + )} + /> + + <Route + path="/" + exact + render={() => <OverviewPageLazy queues={state.data?.queues} />} + /> + </Switch> + </Suspense> + <ConfirmModal {...confirmProps} /> + </> + )} + </div> + </main> + <Menu queues={state.data?.queues} selectedStatuses={selectedStatuses} /> + <ToastContainer /> + </> + ); +}; diff --git a/packages/ui/src/components/App.tsx b/packages/ui/src/components/App.tsx deleted file mode 100644 index f60c9ddf..00000000 --- a/packages/ui/src/components/App.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import React from 'react'; -import { Redirect, Route, Switch } from 'react-router-dom'; -import { ToastContainer } from 'react-toastify'; -import { useActiveQueue } from '../hooks/useActiveQueue'; -import { useScrollTopOnNav } from '../hooks/useScrollTopOnNav'; -import { useStore } from '../hooks/useStore'; -import { ConfirmModal } from './ConfirmModal/ConfirmModal'; -import { Header } from './Header/Header'; -import { HeaderActions } from './HeaderActions/HeaderActions'; -import { Menu } from './Menu/Menu'; -import { QueuePage } from './QueuePage/QueuePage'; -import { QueueTitle } from './QueueTitle/QueueTitle'; - -export const App = () => { - useScrollTopOnNav(); - const { state, actions, selectedStatuses, confirmProps } = useStore(); - const activeQueue = useActiveQueue(state.data); - - return ( - <> - <Header> - <QueueTitle queue={activeQueue} /> - <HeaderActions /> - </Header> - <main> - <div> - {state.loading ? ( - 'Loading...' - ) : ( - <> - <Switch> - <Route - path="/queue/:name" - render={() => ( - <QueuePage - queue={activeQueue || null} - actions={actions} - selectedStatus={selectedStatuses} - /> - )} - /> - - <Route path="/" exact> - {!!state.data && - Array.isArray(state.data?.queues) && - state.data.queues.length > 0 && ( - <Redirect to={`/queue/${encodeURIComponent(state.data?.queues[0].name)}`} /> - )} - </Route> - </Switch> - <ConfirmModal {...confirmProps} /> - </> - )} - </div> - </main> - <Menu queues={state.data?.queues} selectedStatuses={selectedStatuses} /> - <ToastContainer /> - </> - ); -}; diff --git a/packages/ui/src/components/Card/Card.module.css b/packages/ui/src/components/Card/Card.module.css new file mode 100644 index 00000000..0dc2e62d --- /dev/null +++ b/packages/ui/src/components/Card/Card.module.css @@ -0,0 +1,8 @@ +.card { + background-color: #fff; + box-shadow: 0 1px 1px 0 rgba(60, 75, 100, 0.14), 0 2px 1px -1px rgba(60, 75, 100, 0.12), + 0 1px 3px 0 rgba(60, 75, 100, 0.2); + border-radius: 0.25rem; + padding: 1em; + display: flex; +} diff --git a/packages/ui/src/components/Card/Card.tsx b/packages/ui/src/components/Card/Card.tsx new file mode 100644 index 00000000..b75e1674 --- /dev/null +++ b/packages/ui/src/components/Card/Card.tsx @@ -0,0 +1,10 @@ +import cn from 'clsx'; +import React, { PropsWithChildren } from 'react'; +import s from './Card.module.css'; + +interface ICardProps { + className?: string; +} +export const Card = ({ children, className }: PropsWithChildren<ICardProps>) => ( + <div className={cn(s.card, className)}>{children}</div> +); diff --git a/packages/ui/src/components/Header/Header.module.css b/packages/ui/src/components/Header/Header.module.css index 77a44924..4c684a34 100644 --- a/packages/ui/src/components/Header/Header.module.css +++ b/packages/ui/src/components/Header/Header.module.css @@ -28,6 +28,7 @@ z-index: 2; display: flex; justify-content: center; + text-decoration: none; } .header > .logo > .img { diff --git a/packages/ui/src/components/Header/Header.tsx b/packages/ui/src/components/Header/Header.tsx index 64c9fd1f..ccf8f3d7 100644 --- a/packages/ui/src/components/Header/Header.tsx +++ b/packages/ui/src/components/Header/Header.tsx @@ -1,5 +1,6 @@ import cn from 'clsx'; import React, { PropsWithChildren } from 'react'; +import { NavLink } from 'react-router-dom'; import { useUIConfig } from '../../hooks/useUIConfig'; import { getStaticPath } from '../../utils/getStaticPath'; import s from './Header.module.css'; @@ -11,7 +12,7 @@ export const Header = ({ children }: PropsWithChildren<any>) => { return ( <header className={s.header}> - <div className={s.logo}> + <NavLink to={'/'} className={s.logo}> {!!logoPath && ( <img src={logoPath} @@ -22,7 +23,7 @@ export const Header = ({ children }: PropsWithChildren<any>) => { /> )} {boardTitle} - </div> + </NavLink> <div className={s.content}>{children}</div> </header> ); diff --git a/packages/ui/src/components/JobCard/JobCard.tsx b/packages/ui/src/components/JobCard/JobCard.tsx index eefa73d6..c83ff2cc 100644 --- a/packages/ui/src/components/JobCard/JobCard.tsx +++ b/packages/ui/src/components/JobCard/JobCard.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { Card } from '../Card/Card'; import { Details } from './Details/Details'; import { JobActions } from './JobActions/JobActions'; import s from './JobCard.module.css'; @@ -23,7 +24,7 @@ interface JobCardProps { const greenStatuses = [STATUSES.active, STATUSES.completed]; export const JobCard = ({ job, status, actions, readOnlyMode, allowRetries }: JobCardProps) => ( - <div className={s.card}> + <Card className={s.card}> <div className={s.sideInfo}> <span title={`#${job.id}`}>#{job.id}</span> <Timeline job={job} status={status} /> @@ -57,5 +58,5 @@ export const JobCard = ({ job, status, actions, readOnlyMode, allowRetries }: Jo )} </div> </div> - </div> + </Card> ); diff --git a/packages/ui/src/components/JobCard/Progress/Progress.tsx b/packages/ui/src/components/JobCard/Progress/Progress.tsx index 55cdbcaf..f89fe95f 100644 --- a/packages/ui/src/components/JobCard/Progress/Progress.tsx +++ b/packages/ui/src/components/JobCard/Progress/Progress.tsx @@ -12,34 +12,32 @@ export const Progress = ({ percentage: number; status: Status; className?: string; -}) => { - return ( - <svg className={cn(s.progress, className)} viewBox="0 0 148 148"> - <circle - cx="70" - cy="70" - r="70" - fill="none" - stroke="#EDF2F7" - strokeWidth="8" - strokeLinecap="round" - style={{ transform: 'translate(4px, 4px)' }} - ></circle> - <circle - cx="70" - cy="70" - r="70" - fill="none" - stroke={status === STATUSES.failed ? '#F56565' : '#48BB78'} - strokeWidth="8" - strokeLinecap="round" - strokeDasharray="600" - strokeDashoffset={600 - ((600 - 160) * percentage) / 100} - style={{ transform: 'translate(4px, -4px) rotate(-90deg)' }} - ></circle> - <text textAnchor="middle" x="74" y="88"> - {percentage}% - </text> - </svg> - ); -}; +}) => ( + <svg className={cn(s.progress, className)} viewBox="0 0 148 148"> + <circle + cx="70" + cy="70" + r="70" + fill="none" + stroke="#E5E7EB" + strokeWidth="8" + strokeLinecap="round" + style={{ transform: 'translate(4px, 4px)' }} + ></circle> + <circle + cx="70" + cy="70" + r="70" + fill="none" + stroke={status === STATUSES.failed ? '#F56565' : '#48BB78'} + strokeWidth="8" + strokeLinecap="round" + strokeDasharray="600" + strokeDashoffset={600 - ((600 - 160) * percentage) / 100} + style={{ transform: 'translate(4px, -4px) rotate(-90deg)' }} + ></circle> + <text textAnchor="middle" x="74" y="88"> + {percentage}% + </text> + </svg> +); diff --git a/packages/ui/src/components/QueueCard/QueueCard.module.css b/packages/ui/src/components/QueueCard/QueueCard.module.css new file mode 100644 index 00000000..990eac7e --- /dev/null +++ b/packages/ui/src/components/QueueCard/QueueCard.module.css @@ -0,0 +1,4 @@ +.queueCard { + flex-direction: column; + gap: 0.5rem; +} diff --git a/packages/ui/src/components/QueueCard/QueueCard.tsx b/packages/ui/src/components/QueueCard/QueueCard.tsx new file mode 100644 index 00000000..97f92600 --- /dev/null +++ b/packages/ui/src/components/QueueCard/QueueCard.tsx @@ -0,0 +1,16 @@ +import { AppQueue } from '@bull-board/api/dist/typings/app'; +import React from 'react'; +import { Card } from '../Card/Card'; +import { QueueStats } from './QueueStats/QueueStats'; +import s from './QueueCard.module.css'; + +interface IQueueCardProps { + queue: AppQueue; +} + +export const QueueCard = ({ queue }: IQueueCardProps) => ( + <Card className={s.queueCard}> + <div>{queue.name}</div> + <QueueStats queue={queue} /> + </Card> +); diff --git a/packages/ui/src/components/QueueCard/QueueStats/QueueStats.module.css b/packages/ui/src/components/QueueCard/QueueStats/QueueStats.module.css new file mode 100644 index 00000000..b243513a --- /dev/null +++ b/packages/ui/src/components/QueueCard/QueueStats/QueueStats.module.css @@ -0,0 +1,47 @@ +.stats { + display: flex; + white-space: nowrap; + gap: 1rem; + align-items: center; +} + +.progressBar { + width: 100%; + display: flex; + overflow: hidden; + height: 1rem; + border-radius: 9999px; + background-color: #e5e7eb; + line-height: 1; + font-size: 0.75rem; + color: #fff; + text-align: center; +} + +.progressBar > div { + padding: 0.125rem; +} + +.progressBar > div + div { + border-left: 2px solid #fff; +} + +.waiting { + background-color: var(--waiting); +} + +.completed { + background-color: var(--completed); +} + +.failed { + background-color: var(--failed); +} + +.active { + background-color: var(--active); +} + +.delayed { + background-color: var(--delayed); +} diff --git a/packages/ui/src/components/QueueCard/QueueStats/QueueStats.tsx b/packages/ui/src/components/QueueCard/QueueStats/QueueStats.tsx new file mode 100644 index 00000000..8ac62a7b --- /dev/null +++ b/packages/ui/src/components/QueueCard/QueueStats/QueueStats.tsx @@ -0,0 +1,39 @@ +import { AppQueue } from '@bull-board/api/dist/typings/app'; +import React from 'react'; +import { queueStatsStatusList } from '../../../constants/queue-stats-status'; +import s from './QueueStats.module.css'; + +interface IQueueStatsProps { + queue: AppQueue; +} + +export const QueueStats = ({ queue }: IQueueStatsProps) => { + const total = queueStatsStatusList.reduce((result, status) => result + queue.counts[status], 0); + + return ( + <div className={s.stats}> + <div className={s.progressBar}> + {queueStatsStatusList + .filter((status) => queue.counts[status] > 0) + .map((status) => { + const value = queue.counts[status]; + + return ( + <div + key={status} + role="progressbar" + style={{ width: `${(value / total) * 100}%` }} + aria-valuenow={value} + aria-valuemin={0} + aria-valuemax={total} + className={s[status]} + > + {value} + </div> + ); + })} + </div> + <div>{total} Jobs</div> + </div> + ); +}; diff --git a/packages/ui/src/components/QueueTitle/QueueTitle.tsx b/packages/ui/src/components/QueueTitle/QueueTitle.tsx deleted file mode 100644 index 7bb0bb77..00000000 --- a/packages/ui/src/components/QueueTitle/QueueTitle.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { AppQueue } from '@bull-board/api/typings/app'; -import React from 'react'; -import s from './QueueTitle.module.css'; - -interface QueueTitleProps { - queue: Pick<AppQueue, 'name' | 'description'> | null; -} -export const QueueTitle = ({ queue }: QueueTitleProps) => ( - <div className={s.queueTitle}> - {!!queue && ( - <> - <h1 className={s.name}>{queue.name}</h1> - {!!queue.description && <p className={s.description}>{queue.description}</p>} - </> - )} - </div> -); diff --git a/packages/ui/src/components/StatusLegend/StatusLegend.module.css b/packages/ui/src/components/StatusLegend/StatusLegend.module.css new file mode 100644 index 00000000..99ef57d0 --- /dev/null +++ b/packages/ui/src/components/StatusLegend/StatusLegend.module.css @@ -0,0 +1,41 @@ +.legend { + display: flex; + list-style: none; + padding: 0; + margin: 0; + text-transform: uppercase; +} + +.legend > li:before { + content: ''; + background-color: var(--item-bg); + border-radius: 50%; + width: 0.5rem; + height: 0.5rem; + display: inline-block; + margin-right: 0.5rem; +} + +.legend > li + li { + margin-left: 2rem; +} + +.waiting { + --item-bg: var(--waiting); +} + +.active { + --item-bg: var(--active); +} + +.failed { + --item-bg: var(--failed); +} + +.completed { + --item-bg: var(--completed); +} + +.delayed { + --item-bg: var(--delayed); +} diff --git a/packages/ui/src/components/StatusLegend/StatusLegend.tsx b/packages/ui/src/components/StatusLegend/StatusLegend.tsx new file mode 100644 index 00000000..464528ee --- /dev/null +++ b/packages/ui/src/components/StatusLegend/StatusLegend.tsx @@ -0,0 +1,13 @@ +import { queueStatsStatusList } from '../../constants/queue-stats-status'; +import React from 'react'; +import s from './StatusLegend.module.css'; + +export const StatusLegend = () => ( + <ul className={s.legend}> + {queueStatsStatusList.map((status) => ( + <li key={status} className={s[status]}> + {status} + </li> + ))} + </ul> +); diff --git a/packages/ui/src/components/QueueTitle/QueueTitle.module.css b/packages/ui/src/components/Title/Title.module.css similarity index 100% rename from packages/ui/src/components/QueueTitle/QueueTitle.module.css rename to packages/ui/src/components/Title/Title.module.css diff --git a/packages/ui/src/components/Title/Title.tsx b/packages/ui/src/components/Title/Title.tsx new file mode 100644 index 00000000..5b71d028 --- /dev/null +++ b/packages/ui/src/components/Title/Title.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import s from './Title.module.css'; + +interface TitleProps { + name?: string; + description?: string; +} + +export const Title = ({ name, description }: TitleProps) => ( + <div className={s.queueTitle}> + {!!name && ( + <> + <h1 className={s.name}>{name}</h1> + {!!description && <p className={s.description}>{description}</p>} + </> + )} + </div> +); diff --git a/packages/ui/src/constants/queue-stats-status.ts b/packages/ui/src/constants/queue-stats-status.ts new file mode 100644 index 00000000..9bba2515 --- /dev/null +++ b/packages/ui/src/constants/queue-stats-status.ts @@ -0,0 +1,9 @@ +import { STATUSES } from '@bull-board/api/dist/src/constants/statuses'; + +export const queueStatsStatusList = [ + STATUSES.active, + STATUSES.waiting, + STATUSES.completed, + STATUSES.failed, + STATUSES.delayed, +]; diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index a4227333..c1421405 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -1,6 +1,12 @@ :root { --menu-width: 260px; --header-height: 80px; + + --failed: #f56565; + --completed: #48bb78; + --waiting: #e3a008; + --active: #1c64f2; + --delayed: #9061f9; } body { diff --git a/packages/ui/src/index.tsx b/packages/ui/src/index.tsx index b3666121..6fd920ab 100644 --- a/packages/ui/src/index.tsx +++ b/packages/ui/src/index.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { render } from 'react-dom'; import { BrowserRouter } from 'react-router-dom'; -import { App } from './components/App'; +import { App } from './App'; import { ApiContext } from './hooks/useApi'; import './index.css'; import { UIConfigContext } from './hooks/useUIConfig'; diff --git a/packages/ui/src/pages/OverviewPage/OverviewPage.module.css b/packages/ui/src/pages/OverviewPage/OverviewPage.module.css new file mode 100644 index 00000000..97f15145 --- /dev/null +++ b/packages/ui/src/pages/OverviewPage/OverviewPage.module.css @@ -0,0 +1,8 @@ +.overview { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + grid-gap: 2rem; + list-style: none; + margin: 2rem 0 0; + padding: 0; +} diff --git a/packages/ui/src/pages/OverviewPage/OverviewPage.tsx b/packages/ui/src/pages/OverviewPage/OverviewPage.tsx new file mode 100644 index 00000000..abbf177f --- /dev/null +++ b/packages/ui/src/pages/OverviewPage/OverviewPage.tsx @@ -0,0 +1,22 @@ +import { AppQueue } from '@bull-board/api/dist/typings/app'; +import React from 'react'; +import { QueueCard } from '../../components/QueueCard/QueueCard'; +import { StatusLegend } from '../../components/StatusLegend/StatusLegend'; +import s from './OverviewPage.module.css'; + +interface IOverviewPageProps { + queues: AppQueue[] | undefined; +} + +export const OverviewPage = (props: IOverviewPageProps) => ( + <section> + <StatusLegend /> + <ul className={s.overview}> + {props.queues?.map((queue) => ( + <li key={queue.name}> + <QueueCard queue={queue} /> + </li> + ))} + </ul> + </section> +); diff --git a/packages/ui/src/components/QueuePage/QueuePage.module.css b/packages/ui/src/pages/QueuePage/QueuePage.module.css similarity index 100% rename from packages/ui/src/components/QueuePage/QueuePage.module.css rename to packages/ui/src/pages/QueuePage/QueuePage.module.css diff --git a/packages/ui/src/components/QueuePage/QueuePage.tsx b/packages/ui/src/pages/QueuePage/QueuePage.tsx similarity index 86% rename from packages/ui/src/components/QueuePage/QueuePage.tsx rename to packages/ui/src/pages/QueuePage/QueuePage.tsx index 464f0abd..3b0cc4de 100644 --- a/packages/ui/src/components/QueuePage/QueuePage.tsx +++ b/packages/ui/src/pages/QueuePage/QueuePage.tsx @@ -1,11 +1,11 @@ +import { AppQueue, JobRetryStatus } from '@bull-board/api/typings/app'; import React from 'react'; +import { JobCard } from '../../components/JobCard/JobCard'; +import { Pagination } from '../../components/Pagination/Pagination'; +import { QueueActions } from '../../components/QueueActions/QueueActions'; +import { StatusMenu } from '../../components/StatusMenu/StatusMenu'; import { Store } from '../../hooks/useStore'; -import { JobCard } from '../JobCard/JobCard'; -import { QueueActions } from '../QueueActions/QueueActions'; -import { StatusMenu } from '../StatusMenu/StatusMenu'; import s from './QueuePage.module.css'; -import { AppQueue, JobRetryStatus } from '@bull-board/api/typings/app'; -import { Pagination } from '../Pagination/Pagination'; export const QueuePage = ({ selectedStatus,