Skip to content

Commit

Permalink
Feature: Group queues in Menu (#867)
Browse files Browse the repository at this point in the history
* feat: add delimiter property to queue adapter and app queue interfaces

This change enhances the flexibility of queue configurations by allowing the specification of a delimiter.

* feat(Menu): implement hierarchical queue display and enhance styling

- Introduced a new `QueueTree` component to render queues in a hierarchical structure based on a delimiter.
- Updated the `Menu` component to utilize the new `QueueTree` for displaying queues.
- Enhanced CSS styles for the menu, including padding adjustments and added margin for nested levels.

* refactor(example.ts): update queue names and add delimiter support (hierarchy support)

* feat(menu): added two queue examples to demonestrate grouping in the menu component

* style(Menu): update CSS for improved layout and padding

* fix(example.ts): added an example with a different delimiter

* refactor: renamed variable

* perf(Menu, toTree): improved toTree to use arrays instead of objects for improved performance

---------

Co-authored-by: ahmad anwar <[email protected]>
  • Loading branch information
ahnwarez and ahnwarez authored Jan 5, 2025
1 parent 6a2edb4 commit 094fcc1
Show file tree
Hide file tree
Showing 7 changed files with 125 additions and 32 deletions.
11 changes: 9 additions & 2 deletions example.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@ const run = async () => {
const app = express();

const exampleBull = createQueue3('ExampleBull');
const exampleBullMq = createQueueMQ('ExampleBullMQ');
const exampleBullMq = createQueueMQ('Examples.BullMQ');
const newRegistration = createQueueMQ('Notifications.User.NewRegistration');
const resetPassword = createQueueMQ('Notifications:User:ResetPassword');
const flow = new FlowProducer({ connection: redisOptions });

setupBullProcessor(exampleBull); // needed only for example proposes
Expand Down Expand Up @@ -135,7 +137,12 @@ const run = async () => {
serverAdapter.setBasePath('/ui');

createBullBoard({
queues: [new BullMQAdapter(exampleBullMq), new BullAdapter(exampleBull)],
queues: [
new BullMQAdapter(exampleBullMq, { delimiter: '.' }),
new BullAdapter(exampleBull, { delimiter: '.' }),
new BullMQAdapter(newRegistration, { delimiter: '.' }),
new BullMQAdapter(resetPassword, { delimiter: ':' }),
],
serverAdapter,
});

Expand Down
1 change: 1 addition & 0 deletions packages/api/src/handlers/queues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ async function getAppQueues(
allowCompletedRetries: queue.allowCompletedRetries,
isPaused,
type: queue.type,
delimiter: queue.delimiter,
};
})
);
Expand Down
2 changes: 2 additions & 0 deletions packages/api/src/queueAdapters/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export abstract class BaseAdapter {
public readonly allowRetries: boolean;
public readonly allowCompletedRetries: boolean;
public readonly prefix: string;
public readonly delimiter: string;
public readonly description: string;
public readonly type: QueueType;
private formatters = new Map<FormatterField, (data: any) => any>();
Expand All @@ -27,6 +28,7 @@ export abstract class BaseAdapter {
this.allowRetries = this.readOnlyMode ? false : options.allowRetries !== false;
this.allowCompletedRetries = this.allowRetries && options.allowCompletedRetries !== false;
this.prefix = options.prefix || '';
this.delimiter = options.delimiter || '';
this.description = options.description || '';
this.type = type;
}
Expand Down
2 changes: 2 additions & 0 deletions packages/api/typings/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export interface QueueAdapterOptions {
allowRetries: boolean;
prefix: string;
description: string;
delimiter: string;
}

export type BullBoardQueues = Map<string, BaseAdapter>;
Expand Down Expand Up @@ -117,6 +118,7 @@ export interface AppJob {
export type QueueType = 'bull' | 'bullmq';

export interface AppQueue {
delimiter: string;
name: string;
description?: string;
counts: Record<Status, number>;
Expand Down
17 changes: 13 additions & 4 deletions packages/ui/src/components/Menu/Menu.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,27 @@

.menu {
list-style: none;
padding: 0;
padding: 0.5rem 0;
}

.menu li + li {
border-top: 1px solid hsl(206, 9%, 25%);
.menuLevel {
margin-left: 0.5rem;
}

.menuLevel details {
padding: 0.5rem 1rem;
cursor: pointer;
}

.menuLevel details summary {
padding: 0.5rem 0;
}

.menu a {
color: inherit;
text-decoration: none;
display: block;
padding: 1rem 1.25rem;
padding: 0.5rem 1rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
Expand Down
75 changes: 49 additions & 26 deletions packages/ui/src/components/Menu/Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,22 @@ import { useQueues } from './../../hooks/useQueues';
import { links } from '../../utils/links';
import { SearchIcon } from '../Icons/Search';
import s from './Menu.module.css';
import { AppQueueTreeNode, toTree } from '../../utils/toTree';

export const Menu = () => {
const { t } = useTranslation();
const { queues } = useQueues();

const selectedStatuses = useSelectedStatuses();
const [searchTerm, setSearchTerm] = useState('');

const tree = toTree(
queues?.filter((queue) =>
queue.name?.toLowerCase().includes(searchTerm?.toLowerCase() as string)
) || []
);

return (
<aside className={s.aside}>
<div className={s.secondary}>{t('MENU.QUEUES')}</div>

{(queues?.length || 0) > 5 && (
<div className={s.searchWrapper}>
<SearchIcon />
Expand All @@ -33,30 +37,49 @@ export const Menu = () => {
</div>
)}
<nav>
{!!queues && (
<ul className={s.menu}>
{queues
.filter(({ name }) =>
name?.toLowerCase().includes(searchTerm?.toLowerCase() as string)
)
.map(({ name: queueName, isPaused }) => (
<li key={queueName}>
<NavLink
to={links.queuePage(queueName, selectedStatuses)}
activeClassName={s.active}
title={queueName}
>
{queueName}{' '}
{isPaused && <span className={s.isPaused}>[ {t('MENU.PAUSED')} ]</span>}
</NavLink>
</li>
))}
</ul>
)}
<QueueTree tree={tree} />
</nav>
<a className={cn(s.appVersion, s.secondary)} target="_blank" rel="noreferrer"
href="https://github.com/felixmosh/bull-board/releases"
>{process.env.APP_VERSION}</a>
<a
className={cn(s.appVersion, s.secondary)}
target="_blank"
rel="noreferrer"
href="https://github.com/felixmosh/bull-board/releases"
>
{process.env.APP_VERSION}
</a>
</aside>
);
};

function QueueTree({ tree }: { tree: AppQueueTreeNode }) {
const { t } = useTranslation();
const selectedStatuses = useSelectedStatuses();

if (!tree.children.length) return null;

return (
<div className={s.menuLevel}>
{tree.children.map((node) => {
const isLeafNode = !node.children.length;

return isLeafNode ? (
<div key={node.name} className={s.menu}>
<NavLink
to={links.queuePage(node.name, selectedStatuses)}
activeClassName={s.active}
title={node.name}
>
{node.name}
{node.queue?.isPaused && <span className={s.isPaused}>[ {t('MENU.PAUSED')} ]</span>}
</NavLink>
</div>
) : (
<details key={node.name} className={s.menu} open>
<summary>{node.name}</summary>
<QueueTree tree={node} />
</details>
);
})}
</div>
);
}
49 changes: 49 additions & 0 deletions packages/ui/src/utils/toTree.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { AppQueue } from '@bull-board/api/typings/app';

export interface AppQueueTreeNode {
name: string;
queue?: AppQueue;
children: AppQueueTreeNode[];
}

export function toTree(queues: AppQueue[]): AppQueueTreeNode {
const root: AppQueueTreeNode = {
name: 'root',
children: [],
};

queues.forEach((queue) => {
if (!queue.delimiter) {
// If no delimiter, add as direct child to root
root.children.push({
name: queue.name,
queue,
children: [],
});
return;
}

const parts = queue.name.split(queue.delimiter);
let currentLevel = root.children;
let currentPath = '';

parts.forEach((part, index) => {
currentPath = currentPath ? `${currentPath}${queue.delimiter}${part}` : part;
let node = currentLevel.find((n) => n.name === part);

if (!node) {
node = {
name: part,
children: [],
// Only set queue data if we're at the leaf node
...(index === parts.length - 1 ? { queue } : {}),
};
currentLevel.push(node);
}

currentLevel = node.children;
});
});

return root;
}

0 comments on commit 094fcc1

Please sign in to comment.