In Part 1, we laid the foundation by extending Strapi 5’s built-in cron jobs into a persistent, database-backed, and fully manageable background job system.
We created custom content types for cron jobs and their logs, built an execution and error handling mechanism, exposed APIs for triggering jobs, and for viewing their status.
With the backend in place, it’s time to build the frontend to manage the Strapi cron jobs. In this part, we’ll focus on building a Next.js Dashboard that connects to the Strapi APIs we implemented, to control cron jobs.
Tutorial Roadmap
This tutorial is divided into two sections.
- Part 1: Setting up Strapi and Extending Strapi Cron Jobs
- Part 2: Building the Next.js Dashboard for Managing Cron Jobs
Tutorial Objectives
By the end of this tutorial, you will learn to:
- Fetch and display cron jobs and their status in a Next.js application.
- View cron job execution history with pagination.
- Trigger cron jobs and reschedule them from the UI.
- Update cron job settings and toggle their enabled state.
- Build a clean, approachable, and maintainable cron job management interface.
Install Next.js
Create a new Next.js app with App Router and TypeScript enabled:
npx create-next-app@latest strapi-cron-job-front --use-npm # or --use-yarn
Create a Next.js API Route For Cron Job List
Let's create an API route that fetches cron jobs from Strapi and makes them available to our Next.js app.
Create a new file named ./app/api/jobs/route.ts
and add the following:
1// Path: ./app/api/jobs/route.ts
2import { NextResponse } from "next/server";
3
4export async function GET() {
5 const res = await fetch("http://localhost:1337/api/cron-jobs");
6 const json = await res.json();
7
8 const jobs = json.map((job: any) => ({
9 ...job,
10 id: job.documentId,
11 isRunning: job.job_logs?.length > 0,
12 }));
13
14 return NextResponse.json(jobs);
15}
We define a GET
request handler API route that fetches a list of cron jobs from the Strapi backend http://localhost:1337/api/cron-jobs
, which then processes the results to make them more usable for the frontend application.
For each cron job, it adds an id
, taken from its documentId
, and an isRunning
status that is set to true
if the job has any associated job_logs
with status set to running
. Finally, it returns the transformed list of jobs as a JSON
response.
Define Typescript Interfaces for Cron Job and Logs
Let's define the type structures for the job and log data we’ll be working with, to give us reliable typing, improved code readability, and helpful autocomplete support.
Create a file at: ./app/components/types.ts
, and add the following:
1// Path: ./app/components/types.ts
2
3export interface JobSummary {
4 id: string;
5 documentId: string;
6 name: string;
7 displayName: string;
8 description: string;
9 schedule: string;
10 enabled: boolean;
11 isRunning: boolean;
12 runCount: number;
13 lastRunAt: string;
14 nextRunAt: string;
15}
16
17export interface JobDetail extends JobSummary {
18 timeZone?: string;
19 tags?: string[];
20 options: any;
21}
22
23export interface JobLog {
24 id: string;
25 documentId: string;
26 startedAt: string;
27 endedAt?: string;
28 durationMs: number;
29 message: string;
30 manualTrigger: boolean;
31 jobStatus: string;
32 executionId: string;
33 errorStack?: string;
34}
Each of these interfaces gives structure and clarity to the data used in our frontend app:
JobSummary
: Describes the basic information we show in job listings, like the cron job name, schedule, status flags, and key timestamps, like lastRunAt and nextRunAt.JobDetail
: ExtendsJobSummary
to include additional details such as the job time zone, tags, and full configuration options.JobLog
: Represents a single job execution record, including when it started and ended, how long it ran, its status, any error message, and whether it was manually triggered.
List Cron Jobs in Next.js Dashboard
Create JobList
Component
To display jobs on the Next.js frontend, we’ll create a JobList
component and hook it into our main page.
This component will fetch the job summary data from our Strapi backend, update itself on a timer, and render a card for each job with its key details and status.
Create a new file at ./app/components/JobList.tsx
and add the code:
1// Path: ./app/components/JobList.tsx
2
3"use client";
4
5import { useEffect, useState } from "react";
6import { useRouter } from "next/navigation";
7import { JobSummary } from "./types";
8
9async function fetchJobs(): Promise<JobSummary[]> {
10 const res = await fetch("/api/jobs");
11 if (!res.ok) throw new Error("Failed to fetch jobs");
12 return res.json();
13}
14
15export function JobList() {
16 const [jobs, setJobs] = useState<JobSummary[]>([]);
17 const [loading, setLoading] = useState(true);
18 const router = useRouter();
19
20 useEffect(() => {
21 let interval: NodeJS.Timeout;
22
23 async function loadJobs() {
24 try {
25 const jobData = await fetchJobs();
26 setJobs(jobData);
27 } catch (error) {
28 console.error("Error loading jobs:", error);
29 } finally {
30 setLoading(false);
31 }
32 }
33
34 loadJobs();
35 interval = setInterval(loadJobs, 10000);
36
37 return () => clearInterval(interval);
38 }, []);
39
40 if (loading) return <p>Loading jobs...</p>;
41
42 return (
43 <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
44 {jobs.map((job) => (
45 <div
46 key={job.id}
47 className="bg-white shadow-md rounded-xl p-5 border border-gray-200"
48 >
49 <div className="mb-3">
50 <h2 className="text-xl font-semibold">{job.displayName}</h2>
51 <p className="text-sm text-gray-500">{job.description}</p>
52 </div>
53
54 <div className="mb-4 text-sm text-gray-700 space-y-1">
55 <p>
56 <span className="font-medium">Name:</span> {job.name}
57 </p>
58 <p>
59 <span className="font-medium">Schedule:</span> {job.schedule}
60 </p>
61 <p>
62 <span className="font-medium">Status:</span>{" "}
63 <span className={job.enabled ? "text-green-600" : "text-red-600"}>
64 {job.enabled ? "Enabled" : "Disabled"}
65 </span>
66 </p>
67 <p>
68 <span className="font-medium">Run Count:</span> {job.runCount}
69 </p>
70 <p>
71 <span className="font-medium">Last Run:</span>{" "}
72 {job.lastRunAt
73 ? new Date(job.lastRunAt).toLocaleString()
74 : "Never"}
75 </p>
76 <p>
77 <span className="font-medium">Next Run:</span>{" "}
78 {job.nextRunAt
79 ? new Date(job.nextRunAt).toLocaleString()
80 : "Never"}
81 </p>
82 <p>
83 <span className="font-medium">Running:</span>{" "}
84 <span
85 className={job.isRunning ? "text-blue-600" : "text-gray-500"}
86 >
87 {job.isRunning ? "Yes" : "No"}
88 </span>
89 </p>
90 </div>
91
92 <div className="mt-3">
93 <button
94 type="button"
95 className="w-full px-4 py-2 text-white rounded-lg bg-gray-700 hover:bg-gray-800 transition"
96 onClick={() => router.push(`/jobs/${job.id}`)}
97 >
98 View Details
99 </button>
100 </div>
101 </div>
102 ))}
103 </div>
104 );
105}
Here is what the code above does:
- The
JobList
component fetches cron jobs from the/api/jobs
endpoint and displays them as cards. - After fetching the cron jobs, it refreshes every 10 seconds, so we always see the latest status, including which jobs are currently running.
- Each card shows key details like the job’s name, schedule, run count, next and last run times, and status. We can also click View Details to open the job detail page, which we'll add later in this article.
Add JobList
Component to the Homepage
Next, let's modify the home page ./app/page.tsx
to render the job list with the following code:
1// Path: ./app/page.tsx
2import { JobList } from "./components/JobList";
3
4export default function JobsPage() {
5 return (
6 <div className="max-w-6xl mx-auto p-4">
7 <h1 className="text-3xl font-bold mb-6">Jobs List</h1>
8 <JobList />
9 </div>
10 );
11}
Running the application, the job listing page should look like this:
View and Manage Cron Jobs and Logs
In this section, we'll build a fully-functional page that allows us to view job details, manage jobs, enable/disable, reschedule, edit, delete, and review the logs generated for a job.
We'll also build a set of reusable components (JobHeader
, JobInfo
, JobActions
, and JobLogs
) that work together to make jobs manageable and give a quick view of their status and history.
We’ll create the following components:
JobHeader
: A Header component with navigation and a refresh button.JobInfo
: An Info component to display details like the job’s name, schedule, status, and statistics.JobActions
: An Actions component that provides buttons for running, toggling, editing, rescheduling, and deleting jobs.JobLogs
: A Logs component that displays execution history, including status, timestamps, and error messages.
A page that brings all of these together.
Create the JobHeader
Component
Create a new file ./app/components/JobHeader.tsx
and add the following code:
1// Path: ./app/components/JobHeader.tsx
2
3import Link from "next/link";
4
5export default function JobHeader({
6 onRefresh,
7 refreshing,
8}: {
9 onRefresh: () => void;
10 refreshing: boolean;
11}) {
12 return (
13 <>
14 <div className="flex justify-between items-center">
15 <h1 className="text-3xl font-bold">Job Detail</h1>
16 <Link href="/" className="text-blue-600 hover:underline">
17 Back to Jobs
18 </Link>
19 </div>
20 <div className="flex justify-end">
21 <button
22 onClick={onRefresh}
23 className="px-3 py-2 text-sm bg-gray-100 border rounded hover:bg-gray-200"
24 disabled={refreshing}
25 >
26 {refreshing ? "Refreshing..." : "Refresh"}
27 </button>
28 </div>
29 </>
30 );
31}
The `JobHeader ' component provides an easy way to navigate back to the listing page or refresh the page data.
Create the JobInfo
Component
Create a new file ./app/components/JobInfo.tsx
and add the following code:
1// Path ./app/components/JobInfo.tsx
2
3import { JobDetail } from "./types";
4
5export default function JobInfo({ job }: { job: JobDetail }) {
6 return (
7 <div className="bg-white p-6 rounded-xl shadow border">
8 <div className="mb-4 space-y-1">
9 <h2 className="text-2xl font-semibold">{job?.displayName}</h2>
10 <p className="text-gray-500">{job.description}</p>
11 <div className="text-sm text-gray-700 space-y-1">
12 <p>
13 <strong>Name:</strong> {job.name}
14 </p>
15 <p>
16 <strong>Schedule:</strong> {job.schedule}
17 </p>
18 <p>
19 <strong>Time Zone:</strong> {job.timeZone}
20 </p>
21 <p>
22 <strong>Tags:</strong> {job.tags?.join(", ")}
23 </p>
24 <p>
25 <strong>Status:</strong>{" "}
26 <span className={job.enabled ? "text-green-600" : "text-red-600"}>
27 {job.enabled ? "Enabled" : "Disabled"}
28 </span>
29 </p>
30 <p>
31 <strong>Running:</strong>{" "}
32 <span
33 className={job.isRunning ? "text-yellow-600" : "text-gray-600"}
34 >
35 {job.isRunning ? "Yes" : "No"}
36 </span>
37 </p>
38 <p>
39 <strong>Run Count:</strong> {job.runCount}
40 </p>
41 <p>
42 <strong>Last Run:</strong>{" "}
43 {job.lastRunAt ? new Date(job.lastRunAt).toLocaleString() : "Never"}
44 </p>
45 <p>
46 <strong>Next Run:</strong>{" "}
47 {job.nextRunAt
48 ? new Date(job.nextRunAt).toLocaleString()
49 : "Not scheduled"}
50 </p>
51 </div>
52 </div>
53 </div>
54 );
55}
The JobInfo
component above displays key information about the job, such as name, status, run counts, and scheduling details.
Create the JobActions
Component
Create a new file ./app/components/JobActions.tsx
and add the following code:
1// Path: ./app/components/JobActions.tsx
2
3import { useState } from "react";
4import type { JobDetail } from "./types";
5
6type JobActionsProps = {
7 job: JobDetail;
8 onRun: () => void;
9 onToggle: () => void;
10 onDelete: () => void;
11 onReschedule: (newSchedule: string) => void;
12 onEdit: (updates: { displayName?: string; description?: string }) => void;
13};
14
15export default function JobActions({
16 job,
17 onRun,
18 onToggle,
19 onDelete,
20 onReschedule,
21 onEdit,
22}: JobActionsProps) {
23 const [showRescheduleInput, setShowRescheduleInput] = useState(false);
24 const [newSchedule, setNewSchedule] = useState(job?.schedule || "");
25
26 const [showEditInput, setShowEditInput] = useState(false);
27 const [newDisplayName, setNewDisplayName] = useState(job?.displayName || "");
28 const [newDescription, setNewDescription] = useState(job?.description || "");
29
30 const handleReschedule = () => {
31 if (newSchedule.trim()) {
32 onReschedule(newSchedule);
33 setNewSchedule("");
34 setShowRescheduleInput(false);
35 }
36 };
37
38 const handleEdit = () => {
39 onEdit({
40 displayName: newDisplayName.trim(),
41 description: newDescription.trim(),
42 });
43 setShowEditInput(false);
44 };
45
46 return (
47 <div className="space-y-4 mt-6">
48 {showRescheduleInput && (
49 <div className="flex gap-2 items-center">
50 <input
51 type="text"
52 value={newSchedule}
53 onChange={(e) => setNewSchedule(e.target.value)}
54 placeholder="Enter new CRON schedule"
55 className="border rounded p-2 w-full"
56 />
57 <button
58 onClick={handleReschedule}
59 className="bg-green-600 text-white px-3 py-2 rounded hover:bg-green-700"
60 >
61 Save
62 </button>
63 <button
64 onClick={() => {
65 setShowRescheduleInput(false);
66 setNewSchedule(job?.schedule || "");
67 }}
68 className="text-gray-500 px-3 py-2"
69 >
70 Cancel
71 </button>
72 </div>
73 )}
74
75 {showEditInput && (
76 <div className="grid gap-2">
77 <input
78 type="text"
79 value={newDisplayName}
80 onChange={(e) => setNewDisplayName(e.target.value)}
81 placeholder="Display name"
82 className="border rounded p-2"
83 />
84 <input
85 type="text"
86 value={newDescription}
87 onChange={(e) => setNewDescription(e.target.value)}
88 placeholder="Job description"
89 className="border rounded p-2"
90 />
91 <div className="flex gap-2">
92 <button
93 onClick={handleEdit}
94 className="bg-blue-600 text-white px-3 py-2 rounded hover:bg-blue-700"
95 disabled={!job.enabled}
96 >
97 Save
98 </button>
99 <button
100 onClick={() => setShowEditInput(false)}
101 className="text-gray-500 px-3 py-2"
102 >
103 Cancel
104 </button>
105 </div>
106 </div>
107 )}
108
109 <div className="flex gap-2 flex-wrap">
110 <button
111 onClick={onRun}
112 disabled={!job.enabled}
113 className="px-4 py-2 bg-blue-700 text-white rounded hover:bg-blue-800 disabled:opacity-50"
114 >
115 Run Now
116 </button>
117
118 <button
119 onClick={onToggle}
120 className="bg-yellow-500 text-white px-4 py-2 rounded hover:bg-yellow-600"
121 >
122 {job.enabled ? "Disable" : "Enable"}
123 </button>
124
125 <button
126 onClick={() => setShowRescheduleInput((prev) => !prev)}
127 disabled={!job.enabled}
128 className="px-4 py-2 bg-green-700 text-white rounded hover:bg-green-800 disabled:opacity-50"
129 >
130 {showRescheduleInput ? "Hide Reschedule" : "Reschedule"}
131 </button>
132
133 <button
134 onClick={() => setShowEditInput((prev) => !prev)}
135 className="bg-indigo-600 text-white px-4 py-2 rounded hover:bg-indigo-700 disabled:opacity-50"
136 disabled={!job.enabled}
137 >
138 {showEditInput ? "Hide Edit" : "Edit"}
139 </button>
140
141 <button
142 onClick={onDelete}
143 className="bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700"
144 >
145 Delete
146 </button>
147 </div>
148 </div>
149 );
150}
The JobActions
component provides the actual cron job controls:
- Triggers a cron job
- Toggles its enabled state
- Reschedules a cron job
- Edits the name and description of a cron job
- Deletes a cron job
Create the JobLogs
Component
Create a new file ./app/components/JobLogs.tsx
and add the following code:
1// Path: ./app/components/JobLogs.tsx
2
3import type { JobLog } from "./types";
4
5type Props = {
6 logs: JobLog[];
7 loading: boolean;
8 page: number;
9 setPage: (page: number) => void;
10};
11
12export default function JobLogs({ logs, loading, page, setPage }: Props) {
13 return (
14 <div>
15 <h2 className="text-lg font-semibold mb-3">Job Logs</h2>
16
17 {loading ? (
18 <p>Loading logs...</p>
19 ) : logs.length === 0 ? (
20 <p className="text-gray-500">No logs available.</p>
21 ) : (
22 <div className="space-y-4">
23 {logs.map((log) => {
24 const startedAt = new Date(log.startedAt).toLocaleString();
25 const endedAt = log.endedAt
26 ? new Date(log.endedAt).toLocaleString()
27 : "—";
28
29 const statusColor =
30 log.jobStatus === "success"
31 ? "text-green-700 bg-green-100"
32 : log.jobStatus === "failed"
33 ? "text-red-700 bg-red-100"
34 : "text-gray-700 bg-gray-100";
35
36 return (
37 <div
38 key={log.id}
39 className="p-4 rounded-xl border shadow-sm bg-white space-y-2"
40 >
41 <div className="flex justify-between items-center">
42 <div className="text-sm text-gray-500">
43 Started: {startedAt}
44 </div>
45 <div
46 className={`text-xs font-semibold px-2 py-1 rounded ${statusColor}`}
47 >
48 {log.jobStatus}
49 </div>
50 </div>
51
52 <div className="text-sm text-gray-500">Ended: {endedAt}</div>
53
54 <div className="text-sm text-gray-700">
55 Duration:{" "}
56 <span className="font-medium">{log.durationMs}ms</span>
57 </div>
58
59 <div className="text-sm text-gray-800 whitespace-pre-wrap">
60 {log.message || "No message provided."}
61 </div>
62
63 {log.errorStack && (
64 <details className="text-sm text-red-700 bg-red-50 border-l-4 border-red-400 p-2 rounded">
65 <summary className="cursor-pointer font-semibold">
66 View Error Stack
67 </summary>
68 <pre className="whitespace-pre-wrap mt-2 text-xs">
69 {log.errorStack}
70 </pre>
71 </details>
72 )}
73
74 <div className="text-xs text-gray-500 italic">
75 Triggered: {log.manualTrigger ? "Manually" : "Automatically"}{" "}
76 | Execution ID:{" "}
77 <span className="font-mono">{log.executionId}</span>
78 </div>
79 </div>
80 );
81 })}
82 </div>
83 )}
84
85 {/* Pagination Controls */}
86 <div className="flex justify-between items-center mt-4">
87 <button
88 disabled={page === 1}
89 onClick={() => setPage(page - 1)}
90 className="px-3 py-1 rounded bg-gray-200 disabled:opacity-50"
91 >
92 Previous
93 </button>
94 <span className="text-sm text-gray-600">Page {page}</span>
95 <button
96 onClick={() => setPage(page + 1)}
97 className="px-3 py-1 rounded bg-gray-200"
98 >
99 Next
100 </button>
101 </div>
102 </div>
103 );
104}
The JobLogs
component displays a list of execution logs for a specific job. It gives us a quick, clean view of when the job started and ended, how long it took, its status, any messages, and error details, if any. It also allows us to move between pages of logs with pagination controls.
Putting It All Together: The Job Detail Page
In this final code section of the article, we’ll combine all the components we’ve built so far, the Header, Info, Actions, and Logs, into a single cohesive page. This dynamic route page ./app/jobs/[id]/page.tsx
is the core of the job manager. It lets us view detailed information about a specific job, manage its status and settings, and track its execution logs.
Create a new file: ./app/jobs/[id]/page.tsx
.
Here’s the complete page:
1// Path: ./app/jobs/[id]/page.tsx
2
3"use client";
4
5import { useEffect, useState } from "react";
6import { useParams, useRouter } from "next/navigation";
7import JobHeader from "@/app/components/JobHeader";
8import JobInfo from "@/app/components/JobInfo";
9import JobActions from "@/app/components/JobActions";
10import JobLogs from "@/app/components/JobLogs";
11
12import type { JobDetail, JobLog } from "@/app/components/types";
13
14const endpoint = "http://localhost:1337/api";
15
16export default function JobDetailPage() {
17 const { id: jobId } = useParams();
18 const [job, setJob] = useState<JobDetail | null>(null);
19 const [logs, setLogs] = useState<JobLog[]>([]);
20 const [page, setPage] = useState(1);
21 const [pageSize] = useState(10);
22 const [loadingJob, setLoadingJob] = useState(true);
23 const [loadingLogs, setLoadingLogs] = useState(true);
24 const [refreshing, setRefreshing] = useState(false);
25 const router = useRouter();
26
27 const fetchJob = async () => {
28 setLoadingJob(true);
29 const res = await fetch(`${endpoint}/cron-jobs/${jobId}`);
30 const data = await res.json();
31 setJob(data);
32 setLoadingJob(false);
33 };
34
35 const fetchLogs = async (pg = 1) => {
36 setLoadingLogs(true);
37 const res = await fetch(
38 `${endpoint}/job-logs/by-job?jobId=${jobId}&page=${pg}&pageSize=${pageSize}`
39 );
40 const data = await res.json();
41 setLogs(data);
42 setLoadingLogs(false);
43 };
44
45 const refreshAll = async () => {
46 setRefreshing(true);
47 await fetchJob();
48 await fetchLogs(page);
49 setRefreshing(false);
50 };
51
52 useEffect(() => {
53 refreshAll();
54 }, [page]);
55
56 const handleRun = async () => {
57 const res = await fetch(`${endpoint}/cron-jobs/trigger/${job?.name}`, {
58 method: "POST",
59 headers: {
60 "Content-Type": "application/json",
61 },
62 });
63
64 if (!res.ok) {
65 throw new Error(`Failed to run job: ${res.statusText}`);
66 }
67
68 const result = await res.json();
69 // console.log("Job triggered:", result);
70
71 await refreshAll();
72 };
73
74 const handleReschedule = async (newSchedule: string) => {
75 await fetch(`${endpoint}/cron-jobs/reschedule`, {
76 method: "POST",
77 headers: { "Content-Type": "application/json" },
78 body: JSON.stringify({ schedule: newSchedule, name: job?.name }),
79 });
80
81 await refreshAll();
82 };
83
84 const handleEdit = async (payload: any) => {
85 await fetch(`${endpoint}/cron-jobs/update`, {
86 method: "PUT",
87 headers: { "Content-Type": "application/json" },
88 body: JSON.stringify({ ...payload, name: job?.name }),
89 });
90
91 await refreshAll();
92 };
93
94 const handleToggleEnabled = async () => {
95 await fetch(`${endpoint}/cron-jobs/toggle-enabled/${job?.name}`, {
96 method: "POST",
97 headers: { "Content-Type": "application/json" },
98 body: JSON.stringify({ enabled: !job!.enabled }),
99 });
100
101 await refreshAll();
102 };
103
104 const handleDelete = async () => {
105 console.log("job", job);
106 const res = await fetch(`${endpoint}/cron-jobs/${job?.documentId}`, {
107 method: "DELETE",
108 headers: { "Content-Type": "application/json" },
109 });
110
111 console.log("res", await res.status);
112
113 if (res.ok) await router.push("/");
114 };
115
116 return (
117 <div className="max-w-4xl mx-auto p-4 space-y-6">
118 <JobHeader onRefresh={refreshAll} refreshing={refreshing} />
119 {loadingJob ? (
120 <p>Loading job details...</p>
121 ) : job ? (
122 <>
123 <JobInfo job={job} />
124 <JobActions
125 job={job}
126 onRun={handleRun}
127 onToggle={handleToggleEnabled}
128 onReschedule={handleReschedule}
129 onEdit={handleEdit}
130 onDelete={handleDelete}
131 />
132 </>
133 ) : (
134 <p>Job not found.</p>
135 )}
136 <JobLogs
137 logs={logs}
138 loading={loadingLogs}
139 page={page}
140 setPage={setPage}
141 />
142 </div>
143 );
144}
When the page loads, it fetches both the job details and its execution logs from the backend. It displays important info like the job’s schedule, whether it’s enabled, and when it last ran. We get buttons to manually run the job, toggle its enabled state, reschedule it, edit its details, or delete it.
The logs section shows the history of job runs with pagination controls, so we can browse through past executions and see success or failure details.
After performing any action, the page refreshes automatically to keep the information up to date without needing a manual reload.
Here's what the final result of the job page is:
Github Source Code
The complete code for this tutorial can be found on GitHub:
Conclusion
In this article, we built a persistent and fully manageable background job system, combining a Strapi backend with a Next.js frontend for a complete solution.
On the backend, we:
- Created custom content types to save job definitions and execution logs in the database.
- Added utility services and lifecycle hooks to automatically register or clean up jobs when the server starts, ensuring job state stays in sync.
- Implemented error logging so failures are tracked with clear messages and stack traces.
- Built APIs that let external apps or dashboards trigger, enable/disable, reschedule, or delete jobs.
On the frontend, we:
- Built a simple, user-friendly dashboard that shows job statuses, schedules, and run history.
- Added controls to trigger jobs manually, edit settings, toggle status, reschedule, or delete jobs, all from the UI.
- Included pagination, polling, and status indicators for real-time job visibility.
Together, this system gives us full control over our background jobs, persistent, and easy to manage.
Emeka is a skilled software developer and educator in web development, mentoring, and collaborating with teams to deliver business-driven solutions.