mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-28 19:05:31 +02:00
show created jobs under recurring rule
This commit is contained in:
parent
eda3f3821f
commit
c030c4fa83
7 changed files with 130 additions and 23 deletions
|
|
@ -4,12 +4,15 @@ import { container } from "@/di/container";
|
||||||
import { IListJobsController } from "@/src/interface-adapters/controllers/jobs/list-jobs.controller";
|
import { IListJobsController } from "@/src/interface-adapters/controllers/jobs/list-jobs.controller";
|
||||||
import { IFetchJobController } from "@/src/interface-adapters/controllers/jobs/fetch-job.controller";
|
import { IFetchJobController } from "@/src/interface-adapters/controllers/jobs/fetch-job.controller";
|
||||||
import { authCheck } from "./auth_actions";
|
import { authCheck } from "./auth_actions";
|
||||||
|
import { JobFiltersSchema } from "@/src/application/repositories/jobs.repository.interface";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
const listJobsController = container.resolve<IListJobsController>('listJobsController');
|
const listJobsController = container.resolve<IListJobsController>('listJobsController');
|
||||||
const fetchJobController = container.resolve<IFetchJobController>('fetchJobController');
|
const fetchJobController = container.resolve<IFetchJobController>('fetchJobController');
|
||||||
|
|
||||||
export async function listJobs(request: {
|
export async function listJobs(request: {
|
||||||
projectId: string,
|
projectId: string,
|
||||||
|
filters?: z.infer<typeof JobFiltersSchema>,
|
||||||
cursor?: string,
|
cursor?: string,
|
||||||
limit?: number,
|
limit?: number,
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -19,6 +22,7 @@ export async function listJobs(request: {
|
||||||
caller: 'user',
|
caller: 'user',
|
||||||
userId: user._id,
|
userId: user._id,
|
||||||
projectId: request.projectId,
|
projectId: request.projectId,
|
||||||
|
filters: request.filters,
|
||||||
cursor: request.cursor,
|
cursor: request.cursor,
|
||||||
limit: request.limit,
|
limit: request.limit,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import Link from "next/link";
|
||||||
import { RecurringJobRule } from "@/src/entities/models/recurring-job-rule";
|
import { RecurringJobRule } from "@/src/entities/models/recurring-job-rule";
|
||||||
import { Spinner } from "@heroui/react";
|
import { Spinner } from "@heroui/react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { JobsList } from "@/app/projects/[projectId]/jobs/components/jobs-list";
|
||||||
|
|
||||||
export function RecurringJobRuleView({ projectId, ruleId }: { projectId: string; ruleId: string }) {
|
export function RecurringJobRuleView({ projectId, ruleId }: { projectId: string; ruleId: string }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
@ -263,6 +264,18 @@ export function RecurringJobRuleView({ projectId, ruleId }: { projectId: string;
|
||||||
<div><strong>Rule ID:</strong> <code className="bg-gray-100 dark:bg-gray-700 px-1 rounded">{rule.id}</code></div>
|
<div><strong>Rule ID:</strong> <code className="bg-gray-100 dark:bg-gray-700 px-1 rounded">{rule.id}</code></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Jobs Created by This Rule */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-3">
|
||||||
|
Jobs Created by This Rule
|
||||||
|
</h3>
|
||||||
|
<JobsList
|
||||||
|
projectId={projectId}
|
||||||
|
filters={{ recurringJobRuleId: ruleId }}
|
||||||
|
showTitle={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,19 @@ import { Button } from "@/components/ui/button";
|
||||||
import { Panel } from "@/components/common/panel-common";
|
import { Panel } from "@/components/common/panel-common";
|
||||||
import { listJobs } from "@/app/actions/job_actions";
|
import { listJobs } from "@/app/actions/job_actions";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ListedJobItem } from "@/src/application/repositories/jobs.repository.interface";
|
import { ListedJobItem, JobFilters } from "@/src/application/repositories/jobs.repository.interface";
|
||||||
import { isToday, isThisWeek, isThisMonth } from "@/lib/utils/date";
|
import { isToday, isThisWeek, isThisMonth } from "@/lib/utils/date";
|
||||||
|
|
||||||
type ListedItem = z.infer<typeof ListedJobItem>;
|
type ListedItem = z.infer<typeof ListedJobItem>;
|
||||||
|
|
||||||
export function JobsList({ projectId }: { projectId: string }) {
|
interface JobsListProps {
|
||||||
|
projectId: string;
|
||||||
|
filters?: JobFilters;
|
||||||
|
showTitle?: boolean;
|
||||||
|
customTitle?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JobsList({ projectId, filters, showTitle = true, customTitle }: JobsListProps) {
|
||||||
const [items, setItems] = useState<ListedItem[]>([]);
|
const [items, setItems] = useState<ListedItem[]>([]);
|
||||||
const [cursor, setCursor] = useState<string | null>(null);
|
const [cursor, setCursor] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
|
|
@ -19,14 +26,22 @@ export function JobsList({ projectId }: { projectId: string }) {
|
||||||
const [hasMore, setHasMore] = useState<boolean>(false);
|
const [hasMore, setHasMore] = useState<boolean>(false);
|
||||||
|
|
||||||
const fetchPage = useCallback(async (cursorArg?: string | null) => {
|
const fetchPage = useCallback(async (cursorArg?: string | null) => {
|
||||||
const res = await listJobs({ projectId, cursor: cursorArg ?? undefined, limit: 20 });
|
const res = await listJobs({
|
||||||
|
projectId,
|
||||||
|
filters,
|
||||||
|
cursor: cursorArg ?? undefined,
|
||||||
|
limit: 20
|
||||||
|
});
|
||||||
return res;
|
return res;
|
||||||
}, [projectId]);
|
}, [projectId, filters]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let ignore = false;
|
let ignore = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setItems([]);
|
||||||
|
setCursor(null);
|
||||||
|
setHasMore(false);
|
||||||
const res = await fetchPage(null);
|
const res = await fetchPage(null);
|
||||||
if (ignore) return;
|
if (ignore) return;
|
||||||
setItems(res.items);
|
setItems(res.items);
|
||||||
|
|
@ -35,7 +50,7 @@ export function JobsList({ projectId }: { projectId: string }) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
})();
|
})();
|
||||||
return () => { ignore = true; };
|
return () => { ignore = true; };
|
||||||
}, [fetchPage]);
|
}, [fetchPage, filters]);
|
||||||
|
|
||||||
const loadMore = useCallback(async () => {
|
const loadMore = useCallback(async () => {
|
||||||
if (!cursor) return;
|
if (!cursor) return;
|
||||||
|
|
@ -111,14 +126,21 @@ export function JobsList({ projectId }: { projectId: string }) {
|
||||||
return (
|
return (
|
||||||
<Panel
|
<Panel
|
||||||
title={
|
title={
|
||||||
<div className="flex items-center gap-3">
|
showTitle ? (
|
||||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
<div className="flex items-center gap-3">
|
||||||
JOBS
|
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{customTitle || "JOBS"}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : null
|
||||||
}
|
}
|
||||||
rightActions={
|
rightActions={
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
{filters && items.length > 0 && (
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{items.length} job{items.length !== 1 ? 's' : ''} found
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* Reserved for future actions */}
|
{/* Reserved for future actions */}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
@ -132,7 +154,9 @@ export function JobsList({ projectId }: { projectId: string }) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!loading && items.length === 0 && (
|
{!loading && items.length === 0 && (
|
||||||
<p className="mt-4 text-center">No jobs yet.</p>
|
<p className="mt-4 text-center">
|
||||||
|
{filters ? "No jobs found matching the current filters." : "No jobs yet."}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
{!loading && items.length > 0 && (
|
{!loading && items.length > 0 && (
|
||||||
<div className="flex flex-col gap-8">
|
<div className="flex flex-col gap-8">
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,31 @@ export const ListedJobItem = Job.pick({
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for filtering jobs when listing.
|
||||||
|
* This schema is designed to be extensible for future filtering criteria.
|
||||||
|
*/
|
||||||
|
export const JobFiltersSchema = z.object({
|
||||||
|
// Filter by job status
|
||||||
|
status: z.enum(["pending", "running", "completed", "failed"]).optional(),
|
||||||
|
|
||||||
|
// Filter by recurring job rule ID
|
||||||
|
recurringJobRuleId: z.string().optional(),
|
||||||
|
|
||||||
|
// Filter by composio trigger deployment ID
|
||||||
|
composioTriggerDeploymentId: z.string().optional(),
|
||||||
|
|
||||||
|
// Filter by date range
|
||||||
|
createdAfter: z.string().datetime().optional(),
|
||||||
|
createdBefore: z.string().datetime().optional(),
|
||||||
|
|
||||||
|
// Extensible: add more filters here as needed
|
||||||
|
// Example: errorMessage: z.string().optional(),
|
||||||
|
// Example: priority: z.enum(["low", "medium", "high"]).optional(),
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
export type JobFilters = z.infer<typeof JobFiltersSchema>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schema for updating an existing job.
|
* Schema for updating an existing job.
|
||||||
* Defines the fields that can be updated for a job.
|
* Defines the fields that can be updated for a job.
|
||||||
|
|
@ -102,12 +127,18 @@ export interface IJobsRepository {
|
||||||
release(id: string): Promise<void>;
|
release(id: string): Promise<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lists jobs for a specific project with pagination.
|
* Lists jobs for a specific project with optional filtering and pagination.
|
||||||
*
|
*
|
||||||
* @param projectId - The unique identifier of the project
|
* @param projectId - The unique identifier of the project
|
||||||
|
* @param filters - Optional filters to apply to the job list
|
||||||
* @param cursor - Optional cursor for pagination
|
* @param cursor - Optional cursor for pagination
|
||||||
* @param limit - Maximum number of jobs to return (default: 50)
|
* @param limit - Maximum number of jobs to return (default: 50)
|
||||||
* @returns Promise resolving to a paginated list of jobs
|
* @returns Promise resolving to a paginated list of jobs
|
||||||
*/
|
*/
|
||||||
list(projectId: string, cursor?: string, limit?: number): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ListedJobItem>>>>;
|
list(
|
||||||
|
projectId: string,
|
||||||
|
filters?: JobFilters,
|
||||||
|
cursor?: string,
|
||||||
|
limit?: number
|
||||||
|
): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ListedJobItem>>>>;
|
||||||
}
|
}
|
||||||
|
|
@ -2,7 +2,7 @@ import { BadRequestError, NotFoundError } from '@/src/entities/errors/common';
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { IUsageQuotaPolicy } from '../../policies/usage-quota.policy.interface';
|
import { IUsageQuotaPolicy } from '../../policies/usage-quota.policy.interface';
|
||||||
import { IProjectActionAuthorizationPolicy } from '../../policies/project-action-authorization.policy';
|
import { IProjectActionAuthorizationPolicy } from '../../policies/project-action-authorization.policy';
|
||||||
import { IJobsRepository, ListedJobItem } from '../../repositories/jobs.repository.interface';
|
import { IJobsRepository, ListedJobItem, JobFilters, JobFiltersSchema } from '../../repositories/jobs.repository.interface';
|
||||||
import { Job } from '@/src/entities/models/job';
|
import { Job } from '@/src/entities/models/job';
|
||||||
import { PaginatedList } from '@/src/entities/common/paginated-list';
|
import { PaginatedList } from '@/src/entities/common/paginated-list';
|
||||||
|
|
||||||
|
|
@ -11,6 +11,7 @@ const inputSchema = z.object({
|
||||||
userId: z.string().optional(),
|
userId: z.string().optional(),
|
||||||
apiKey: z.string().optional(),
|
apiKey: z.string().optional(),
|
||||||
projectId: z.string(),
|
projectId: z.string(),
|
||||||
|
filters: JobFiltersSchema.optional(),
|
||||||
cursor: z.string().optional(),
|
cursor: z.string().optional(),
|
||||||
limit: z.number().optional(),
|
limit: z.number().optional(),
|
||||||
});
|
});
|
||||||
|
|
@ -54,6 +55,6 @@ export class ListJobsUseCase implements IListJobsUseCase {
|
||||||
await this.usageQuotaPolicy.assertAndConsume(projectId);
|
await this.usageQuotaPolicy.assertAndConsume(projectId);
|
||||||
|
|
||||||
// fetch jobs for project
|
// fetch jobs for project
|
||||||
return await this.jobsRepository.list(projectId, request.cursor, limit);
|
return await this.jobsRepository.list(projectId, request.filters, request.cursor, limit);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ObjectId } from "mongodb";
|
import { ObjectId } from "mongodb";
|
||||||
import { db } from "@/app/lib/mongodb";
|
import { db } from "@/app/lib/mongodb";
|
||||||
import { CreateJobSchema, IJobsRepository, ListedJobItem, UpdateJobSchema } from "@/src/application/repositories/jobs.repository.interface";
|
import { CreateJobSchema, IJobsRepository, ListedJobItem, UpdateJobSchema, JobFilters } from "@/src/application/repositories/jobs.repository.interface";
|
||||||
import { Job } from "@/src/entities/models/job";
|
import { Job } from "@/src/entities/models/job";
|
||||||
import { JobAcquisitionError } from "@/src/entities/errors/job-errors";
|
import { JobAcquisitionError } from "@/src/entities/errors/job-errors";
|
||||||
import { NotFoundError } from "@/src/entities/errors/common";
|
import { NotFoundError } from "@/src/entities/errors/common";
|
||||||
|
|
@ -198,11 +198,43 @@ export class MongoDBJobsRepository implements IJobsRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lists jobs for a specific project with pagination.
|
* Lists jobs for a specific project with optional filtering and pagination.
|
||||||
*/
|
*/
|
||||||
async list(projectId: string, cursor?: string, limit: number = 50): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ListedJobItem>>>> {
|
async list(
|
||||||
|
projectId: string,
|
||||||
|
filters?: JobFilters,
|
||||||
|
cursor?: string,
|
||||||
|
limit: number = 50
|
||||||
|
): Promise<z.infer<ReturnType<typeof PaginatedList<typeof ListedJobItem>>>> {
|
||||||
const query: any = { projectId };
|
const query: any = { projectId };
|
||||||
|
|
||||||
|
const _limit = Math.min(limit, 50);
|
||||||
|
|
||||||
|
// Apply filters if provided
|
||||||
|
if (filters) {
|
||||||
|
if (filters.status) {
|
||||||
|
query.status = filters.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.recurringJobRuleId) {
|
||||||
|
query["reason.type"] = "recurring_job_rule";
|
||||||
|
query["reason.ruleId"] = filters.recurringJobRuleId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.composioTriggerDeploymentId) {
|
||||||
|
query["reason.type"] = "composio_trigger";
|
||||||
|
query["reason.triggerDeploymentId"] = filters.composioTriggerDeploymentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.createdAfter) {
|
||||||
|
query.createdAt = { ...query.createdAt, $gte: filters.createdAfter };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.createdBefore) {
|
||||||
|
query.createdAt = { ...query.createdAt, $lte: filters.createdBefore };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (cursor) {
|
if (cursor) {
|
||||||
query._id = { $lt: new ObjectId(cursor) };
|
query._id = { $lt: new ObjectId(cursor) };
|
||||||
}
|
}
|
||||||
|
|
@ -210,7 +242,7 @@ export class MongoDBJobsRepository implements IJobsRepository {
|
||||||
const results = await this.collection
|
const results = await this.collection
|
||||||
.find(query)
|
.find(query)
|
||||||
.sort({ _id: -1 })
|
.sort({ _id: -1 })
|
||||||
.limit(limit + 1) // Fetch one extra to determine if there's a next page
|
.limit(_limit + 1) // Fetch one extra to determine if there's a next page
|
||||||
.project<z.infer<typeof ListedJobItem> & { _id: ObjectId }>({
|
.project<z.infer<typeof ListedJobItem> & { _id: ObjectId }>({
|
||||||
_id: 1,
|
_id: 1,
|
||||||
projectId: 1,
|
projectId: 1,
|
||||||
|
|
@ -221,8 +253,8 @@ export class MongoDBJobsRepository implements IJobsRepository {
|
||||||
})
|
})
|
||||||
.toArray();
|
.toArray();
|
||||||
|
|
||||||
const hasNextPage = results.length > limit;
|
const hasNextPage = results.length > _limit;
|
||||||
const items = results.slice(0, limit).map(doc => {
|
const items = results.slice(0, _limit).map(doc => {
|
||||||
const { _id, ...rest } = doc;
|
const { _id, ...rest } = doc;
|
||||||
return {
|
return {
|
||||||
...rest,
|
...rest,
|
||||||
|
|
@ -232,7 +264,7 @@ export class MongoDBJobsRepository implements IJobsRepository {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items,
|
items,
|
||||||
nextCursor: hasNextPage ? results[limit - 1]._id.toString() : null,
|
nextCursor: hasNextPage ? results[_limit - 1]._id.toString() : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,14 @@ import z from "zod";
|
||||||
import { IListJobsUseCase } from "@/src/application/use-cases/jobs/list-jobs.use-case";
|
import { IListJobsUseCase } from "@/src/application/use-cases/jobs/list-jobs.use-case";
|
||||||
import { Job } from "@/src/entities/models/job";
|
import { Job } from "@/src/entities/models/job";
|
||||||
import { PaginatedList } from "@/src/entities/common/paginated-list";
|
import { PaginatedList } from "@/src/entities/common/paginated-list";
|
||||||
import { ListedJobItem } from "@/src/application/repositories/jobs.repository.interface";
|
import { JobFiltersSchema, ListedJobItem } from "@/src/application/repositories/jobs.repository.interface";
|
||||||
|
|
||||||
const inputSchema = z.object({
|
const inputSchema = z.object({
|
||||||
caller: z.enum(["user", "api"]),
|
caller: z.enum(["user", "api"]),
|
||||||
userId: z.string().optional(),
|
userId: z.string().optional(),
|
||||||
apiKey: z.string().optional(),
|
apiKey: z.string().optional(),
|
||||||
projectId: z.string(),
|
projectId: z.string(),
|
||||||
|
filters: JobFiltersSchema.optional(),
|
||||||
cursor: z.string().optional(),
|
cursor: z.string().optional(),
|
||||||
limit: z.number().optional(),
|
limit: z.number().optional(),
|
||||||
});
|
});
|
||||||
|
|
@ -35,7 +36,7 @@ export class ListJobsController implements IListJobsController {
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new BadRequestError(`Invalid request: ${JSON.stringify(result.error)}`);
|
throw new BadRequestError(`Invalid request: ${JSON.stringify(result.error)}`);
|
||||||
}
|
}
|
||||||
const { caller, userId, apiKey, projectId, cursor, limit } = result.data;
|
const { caller, userId, apiKey, projectId, filters, cursor, limit } = result.data;
|
||||||
|
|
||||||
// execute use case
|
// execute use case
|
||||||
return await this.listJobsUseCase.execute({
|
return await this.listJobsUseCase.execute({
|
||||||
|
|
@ -43,6 +44,7 @@ export class ListJobsController implements IListJobsController {
|
||||||
userId,
|
userId,
|
||||||
apiKey,
|
apiKey,
|
||||||
projectId,
|
projectId,
|
||||||
|
filters,
|
||||||
cursor,
|
cursor,
|
||||||
limit,
|
limit,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue