Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Group queues in Menu #867

Merged
merged 8 commits into from
Jan 5, 2025
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;
}