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

F/277 display flow tree #872

Open
wants to merge 15 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/api/src/handlers/job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,20 @@ import { BaseAdapter } from '../queueAdapters/base';
import { formatJob } from './queues';

async function getJobState(
_req: BullBoardRequest,
req: BullBoardRequest,
job: QueueJob,
queue: BaseAdapter
): Promise<ControllerHandlerReturnType> {
const { jobId } = req.params;
const status = await job.getState();
const jobTree = await queue.getJobTree(jobId);

return {
status: 200,
body: {
job: formatJob(job, queue),
status,
jobTree: jobTree ?? [],
},
};
}
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 @@ -32,6 +32,7 @@ export const formatJob = (job: QueueJob, queue: BaseAdapter): AppJob => {
name: queue.format('name', jobProps, jobProps.name || ''),
returnValue: queue.format('returnValue', jobProps.returnvalue),
isFailed: !!jobProps.failedReason || (Array.isArray(stacktrace) && stacktrace.length > 0),
parent: jobProps.parent,
};
};

Expand Down
3 changes: 3 additions & 0 deletions packages/api/src/queueAdapters/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
JobCleanStatus,
JobCounts,
JobStatus,
JobTreeNode,
QueueAdapterOptions,
QueueJob,
QueueJobOptions,
Expand Down Expand Up @@ -56,6 +57,8 @@ export abstract class BaseAdapter {

public abstract getJob(id: string): Promise<QueueJob | undefined | null>;

public abstract getJobTree(id: string): Promise<JobTreeNode[]>;

public abstract getJobCounts(): Promise<JobCounts>;

public abstract getJobs(
Expand Down
6 changes: 6 additions & 0 deletions packages/api/src/queueAdapters/bull.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
JobCleanStatus,
JobCounts,
JobStatus,
JobTreeNode,
QueueAdapterOptions,
QueueJobOptions,
Status,
Expand Down Expand Up @@ -40,6 +41,11 @@ export class BullAdapter extends BaseAdapter {
return this.queue.getJob(id).then((job) => job && this.alignJobData(job));
}

public getJobTree(): Promise<JobTreeNode[]> {
// Bull doesn't support Flow, so an empty array is returned
return Promise.resolve([]);
}

public getJobs(jobStatuses: JobStatus<'bull'>[], start?: number, end?: number): Promise<Job[]> {
return this.queue.getJobs(jobStatuses, start, end).then((jobs) => jobs.map(this.alignJobData));
}
Expand Down
33 changes: 32 additions & 1 deletion packages/api/src/queueAdapters/bullMQ.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { Job, Queue } from 'bullmq';
import { FlowProducer, Job, JobNode, Queue } from 'bullmq';
import {
JobCleanStatus,
JobCounts,
JobStatus,
JobTreeNode,
QueueAdapterOptions,
QueueJobOptions,
Status,
Expand Down Expand Up @@ -42,6 +43,36 @@ export class BullMQAdapter extends BaseAdapter {
return this.queue.getJob(id);
}

public async getJobTree(id: string): Promise<JobTreeNode[]> {
const client = await this.queue.client;
const flow = new FlowProducer({ connection: client });
const tree = await flow.getFlow({
queueName: this.getName(),
id,
});

if (!tree || !tree.children) {
return [];
}

const mapTree = async (node: JobNode): Promise<JobTreeNode> => {
const newTreeNode: JobTreeNode = {
name: node.job.name,
queueName: node.job.queueName,
id: node.job.id ?? '',
status: await this.queue.getJobState(node.job.id ?? ''),
};

if (node.children && node.children.length > 0) {
newTreeNode.jobTree = await Promise.all(node.children.map(mapTree));
}

return newTreeNode;
};

return Promise.all(tree.children?.map(mapTree));
}

public getJobs(jobStatuses: JobStatus[], start?: number, end?: number): Promise<Job[]> {
return this.queue.getJobs(jobStatuses, start, end);
}
Expand Down
13 changes: 13 additions & 0 deletions packages/api/typings/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,18 @@ export interface QueueJobJson {
returnvalue: any;
opts: any;
parentKey?: string;
parent?: {
id: string;
queueKey: string;
};
}

export interface JobTreeNode {
id: string;
name: string;
status: Status | 'unknown';
queueName: string;
jobTree?: JobTreeNode[];
}

export interface QueueJobOptions {
Expand Down Expand Up @@ -113,6 +125,7 @@ export interface AppJob {
data: QueueJobJson['data'];
returnValue: QueueJobJson['returnvalue'];
isFailed: boolean;
parent: QueueJobJson['parent'];
}

export type QueueType = 'bull' | 'bullmq';
Expand Down
3 changes: 2 additions & 1 deletion packages/api/typings/responses.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AppJob, AppQueue, Status } from './app';
import { AppJob, AppQueue, JobTreeNode, Status } from './app';

export interface GetQueuesResponse {
queues: AppQueue[];
Expand All @@ -7,4 +7,5 @@ export interface GetQueuesResponse {
export interface GetJobResponse {
job: AppJob;
status: Status;
jobTree: JobTreeNode[];
}
2 changes: 0 additions & 2 deletions packages/ui/src/components/JobCard/JobCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,6 @@ export const JobCard = ({
</Button>
)}
</div>

<Collapsible.Content asChild={true}>
<div className={s.details}>
<div className={s.sideInfo}>
Expand Down Expand Up @@ -102,7 +101,6 @@ export const JobCard = ({

<div className={s.content}>
<Details status={status} job={job} actions={actions} />

<Progress
progress={job.progress}
status={
Expand Down
105 changes: 105 additions & 0 deletions packages/ui/src/components/JobTree/JobTree.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
.nodeName {
font-weight: 700;
text-decoration: none;
color: var(--text-color);
&:hover {
text-decoration: underline;
}
}

.nodeQueue {
font-weight: 300;
text-decoration: none;
color: var(--text-color);
&:hover {
text-decoration: underline;
}
}

.nodeStatus {
font-weight: 300;
font-size: 0.7rem;
font-family: 'Courier New', Courier, monospace;
color: var(--text-muted);
}

.nodeSubHeader {
display: flex;
flex-direction: row;
gap: 1em;
}

.parentJob {
font-size: 1.25em;
}

.parentNodeContainer {
padding: 0;
}

/*
* Credit:
* https://stackoverflow.com/a/14424029/1017055
*/
.parentNodeContainer ul {
padding: 0;
margin: 0;
list-style-type: none;
position: relative;
}
.parentNodeContainer li {
list-style-type: none;
border-left: 2px solid var(--separator-color);
margin-left: 2em;
}

.parentNodeContainer li > div {
padding-left: 1em;
position: relative;
padding-top: 0.5em;
}

.parentNodeContainer li div::before {
content: '';
position: absolute;
top: 0;
left: -2px;
bottom: 55%;
width: 0.75em;
border: 2px solid var(--separator-color);
border-top: 0 none transparent;
border-right: 0 none transparent;
}

.parentNodeContainer ul > li:last-child {
border-left: 2px solid transparent;
}

.parentNodeContainer > div::before {
content: '';
position: absolute;
top: 0;
left: -2px;
bottom: 55%;
width: 5em;
border: 0 none transparent;
border-top: 0 none transparent;
border-right: 0 none transparent;
}

.parentNodeContainer li.parentNodeContainer {
border: 0 none transparent;
}

.parentNode {
border-left: none !important;
padding-left: 0;
}

.parentNode > div {
padding-left: 0;
}

.parentNode > div::before {
display: none;
}
54 changes: 54 additions & 0 deletions packages/ui/src/components/JobTree/JobTree.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { AppJob, JobTreeNode } from '@bull-board/api/typings/app';
import s from './JobTree.module.css';
import { links } from '../../utils/links';

export function JobTree({ jobTree, job }: { job: AppJob; jobTree: JobTreeNode[] }) {
const queueName = job.parent?.queueKey.split(':')[1];
return (
<div className={s.parentNodeContainer}>
<ul>
<li className={s.parentNode}>
<div>
{job.parent && queueName ? (
<Link to={links.jobPage(queueName, job.parent.id)} className={s.nodeName}>
[parent]
</Link>
) : (
<p className={s.parentJob}>{job.parent ? job.name : `${job.name} (root)`}</p>
)}
</div>
</li>
{jobTree.length > 0 && (
<li>
<JobTreeNodes jobTree={jobTree} />
</li>
)}
</ul>
</div>
);
}

export function JobTreeNodes({ jobTree }: { jobTree: JobTreeNode[] }) {
return (
<ul className={s.node}>
{jobTree.map((job) => (
<li key={job.id}>
<div style={{ display: 'flex', gap: '4px', alignItems: 'top' }}>
<span className={s.nodeStatus}>{job.status.toUpperCase()}</span>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<Link to={`/queue/${job.queueName}/${job.id}`} className={s.nodeName}>
{job.name}
</Link>
<Link to={`/queue/${job.queueName}`} className={s.nodeQueue}>
{job.queueName}
</Link>
</div>
</div>
{job.jobTree && job.jobTree.length > 0 && <JobTreeNodes jobTree={job.jobTree} />}
</li>
))}
</ul>
);
}
21 changes: 15 additions & 6 deletions packages/ui/src/hooks/useJob.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AppJob, JobRetryStatus } from '@bull-board/api/typings/app';
import { AppJob, JobRetryStatus, JobTreeNode } from '@bull-board/api/typings/app';
import { useTranslation } from 'react-i18next';
import { create } from 'zustand';
import { JobActions, Status } from '../../typings/app';
Expand All @@ -15,14 +15,17 @@ export type JobState = {
job: AppJob | null;
status: Status;
loading: boolean;
updateJob(job: AppJob, status: Status): void;
jobTree: JobTreeNode[];
updateJob(job: AppJob, status: Status, tree: JobTreeNode[]): void;
};

const useQueuesStore = create<JobState>((set) => ({
job: null,
status: 'latest',
loading: true,
updateJob: (job: AppJob, status: Status) => set(() => ({ job, status, loading: false })),
jobTree: [],
updateJob: (job: AppJob, status: Status, jobTree: JobTreeNode[]) =>
set(() => ({ job, status, loading: false, jobTree })),
}));

export function useJob(): Omit<JobState, 'updateJob'> & { actions: JobActions } {
Expand All @@ -42,14 +45,19 @@ export function useJob(): Omit<JobState, 'updateJob'> & { actions: JobActions }
})
);

const { job, status, loading, updateJob: setState } = useQueuesStore((state) => state);
const { job, status, jobTree, loading, updateJob: setState } = useQueuesStore((state) => state);
const { openConfirm } = useConfirm();

const getJob = () =>
api.getJob(activeQueueName, activeJobId).then(({ job, status }) => setState(job, status));
api
.getJob(activeQueueName, activeJobId)
.then(({ job, status, jobTree }) => setState(job, status, jobTree));

const pollJob = () =>
useInterval(getJob, pollingInterval > 0 ? pollingInterval * 1000 : null, [activeQueueName]);
useInterval(getJob, pollingInterval > 0 ? pollingInterval * 1000 : null, [
activeQueueName,
jobTree,
]);

const withConfirmAndUpdate = getConfirmFor(activeJobId ? getJob : updateQueues, openConfirm);

Expand Down Expand Up @@ -82,6 +90,7 @@ export function useJob(): Omit<JobState, 'updateJob'> & { actions: JobActions }

return {
job,
jobTree,
status,
loading,
actions: {
Expand Down
Loading
Loading