SaaS Cron Job Dashboard in 2026: Job History, Retry UI, Status Monitoring, and Alerting
Build a SaaS admin dashboard for scheduled jobs: job execution history, real-time status, manual retry, failure alerting, and duration tracking with PostgreSQL and React.
SaaS Cron Job Dashboard in 2026: Job History, Retry UI, Status Monitoring, and Alerting
Scheduled jobs are invisible by default โ they either run and nobody notices, or they silently fail for days before someone notices the digest emails stopped. A job dashboard changes this: engineers can see every execution, how long it took, what it produced, and manually retry failures without touching the server.
This post builds a complete job monitoring dashboard: PostgreSQL execution history schema, an admin UI with status filters and duration charts, manual retry button, failure email alerts, and a lightweight health check endpoint.
Database Schema
CREATE TYPE job_status AS ENUM ('running', 'completed', 'failed', 'skipped');
CREATE TABLE job_definitions (
id TEXT PRIMARY KEY, -- e.g. 'weekly-digest'
display_name TEXT NOT NULL,
description TEXT,
schedule TEXT NOT NULL, -- cron expression
timezone TEXT NOT NULL DEFAULT 'UTC',
timeout_seconds INTEGER NOT NULL DEFAULT 300,
enabled BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE job_executions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
job_id TEXT NOT NULL REFERENCES job_definitions(id),
period_key TEXT NOT NULL, -- e.g. '2027-W08', '2027-03-01'
execution_id TEXT NOT NULL, -- AWS request ID or random UUID
status job_status NOT NULL DEFAULT 'running',
trigger TEXT NOT NULL DEFAULT 'scheduled', -- 'scheduled' | 'manual'
triggered_by UUID REFERENCES users(id), -- non-null for manual triggers
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ,
duration_ms INTEGER GENERATED ALWAYS AS (
EXTRACT(EPOCH FROM (completed_at - started_at)) * 1000
) STORED,
result JSONB, -- { sent: 42, failed: 3 }
error_message TEXT,
metadata JSONB,
UNIQUE (job_id, period_key) -- One execution per period
);
CREATE INDEX idx_job_exec_job_id ON job_executions(job_id, started_at DESC);
CREATE INDEX idx_job_exec_status ON job_executions(status, started_at DESC);
CREATE INDEX idx_job_exec_started_at ON job_executions(started_at DESC);
-- Seed job definitions
INSERT INTO job_definitions (id, display_name, description, schedule, timezone)
VALUES
('weekly-digest', 'Weekly Digest Email', 'Sends weekly activity digest to all active workspaces', 'cron(0 9 ? * MON *)', 'America/New_York'),
('payment-retry', 'Payment Retry', 'Retries failed subscription payments', 'rate(1 hour)', 'UTC'),
('session-cleanup', 'Session Cleanup', 'Deletes expired sessions from database', 'cron(0 3 * * ? *)', 'UTC'),
('usage-report', 'Usage Metering Report', 'Aggregates hourly usage events to daily totals', 'cron(5 0 * * ? *)', 'UTC');
Admin API: Job History
// app/api/admin/jobs/route.ts
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { z } from "zod";
const QuerySchema = z.object({
jobId: z.string().optional(),
status: z.enum(["running", "completed", "failed", "skipped"]).optional(),
from: z.string().datetime().optional(),
to: z.string().datetime().optional(),
page: z.coerce.number().min(1).default(1),
limit: z.coerce.number().min(1).max(100).default(50),
});
export async function GET(req: NextRequest) {
const session = await auth();
if (!session || session.user.role !== "admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const params = QuerySchema.safeParse(
Object.fromEntries(req.nextUrl.searchParams)
);
if (!params.success) return NextResponse.json({ error: "Invalid params" }, { status: 400 });
const { jobId, status, from, to, page, limit } = params.data;
const where = {
...(jobId && { jobId }),
...(status && { status }),
...((from || to) && {
startedAt: {
...(from && { gte: new Date(from) }),
...(to && { lte: new Date(to) }),
},
}),
};
const [executions, total, definitions] = await Promise.all([
db.jobExecution.findMany({
where,
orderBy: { startedAt: "desc" },
skip: (page - 1) * limit,
take: limit,
include: {
job: { select: { displayName: true, schedule: true, timezone: true } },
triggeredByUser: { select: { name: true, email: true } },
},
}),
db.jobExecution.count({ where }),
db.jobDefinition.findMany({ orderBy: { displayName: "asc" } }),
]);
return NextResponse.json({
executions,
definitions,
pagination: { page, limit, total, pages: Math.ceil(total / limit) },
});
}
๐ SaaS MVP in 8 Weeks โ Seriously
We have launched 50+ SaaS platforms. Multi-tenant architecture, Stripe billing, auth, role-based access, and cloud deployment โ all handled by one senior team.
- Week 1โ2: Architecture design + wireframes
- Week 3โ6: Core features built + tested
- Week 7โ8: Launch-ready on AWS/Vercel with CI/CD
- Post-launch: Maintenance plans from month 3
Admin API: Manual Trigger
// app/api/admin/jobs/[jobId]/trigger/route.ts
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { triggerJob } from "@/lib/jobs/trigger";
import { logFromServerAction, AUDIT_ACTIONS } from "@/lib/audit/logger";
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ jobId: string }> }
) {
const session = await auth();
if (!session || session.user.role !== "admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { jobId } = await params;
const definition = await db.jobDefinition.findUnique({ where: { id: jobId } });
if (!definition) return NextResponse.json({ error: "Job not found" }, { status: 404 });
if (!definition.enabled) return NextResponse.json({ error: "Job is disabled" }, { status: 400 });
// Create execution record
const execution = await db.jobExecution.create({
data: {
jobId,
periodKey: `manual-${Date.now()}`, // Manual runs get unique period keys
executionId: crypto.randomUUID(),
status: "running",
trigger: "manual",
triggeredBy: session.user.id,
},
});
// Log audit event
await logFromServerAction({
workspaceId: session.user.workspaceId ?? "",
actor: { type: "user", id: session.user.id, email: session.user.email },
action: "job.triggered",
category: "system",
resource: { type: "job", id: jobId, name: definition.displayName },
});
// Trigger async (Lambda invoke or queue message)
triggerJob(jobId, execution.id).catch(console.error);
return NextResponse.json({ executionId: execution.id, status: "triggered" });
}
Job Dashboard UI
// app/admin/jobs/page.tsx
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { db } from "@/lib/db";
import { JobTable } from "@/components/admin/JobTable";
import { JobStats } from "@/components/admin/JobStats";
export default async function JobDashboardPage() {
const session = await auth();
if (!session || session.user.role !== "admin") redirect("/dashboard");
const [definitions, recentExecutions, stats] = await Promise.all([
db.jobDefinition.findMany({ orderBy: { displayName: "asc" } }),
db.jobExecution.findMany({
take: 100,
orderBy: { startedAt: "desc" },
include: { job: { select: { displayName: true } } },
}),
// Last 24h stats per job
db.$queryRaw<{
job_id: string;
total: bigint;
failed: bigint;
avg_duration_ms: number;
last_run: Date;
last_status: string;
}[]>`
SELECT
job_id,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE status = 'failed') AS failed,
AVG(duration_ms) AS avg_duration_ms,
MAX(started_at) AS last_run,
(ARRAY_AGG(status ORDER BY started_at DESC))[1] AS last_status
FROM job_executions
WHERE started_at >= now() - INTERVAL '24 hours'
GROUP BY job_id
`,
]);
return (
<div className="space-y-8 p-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Scheduled Jobs</h1>
<p className="text-sm text-gray-500 mt-1">Monitor and manage background jobs</p>
</div>
<JobStats stats={stats} definitions={definitions} />
<JobTable executions={recentExecutions} definitions={definitions} />
</div>
);
}
// components/admin/JobTable.tsx
"use client";
import { useState, useTransition } from "react";
import { formatDistanceToNow } from "date-fns";
import { Play, RefreshCw, CheckCircle, XCircle, Clock, SkipForward } from "lucide-react";
const STATUS_CONFIG = {
completed: { icon: CheckCircle, color: "text-green-500", bg: "bg-green-50", label: "Completed" },
failed: { icon: XCircle, color: "text-red-500", bg: "bg-red-50", label: "Failed" },
running: { icon: RefreshCw, color: "text-blue-500", bg: "bg-blue-50", label: "Running" },
skipped: { icon: SkipForward, color: "text-gray-400", bg: "bg-gray-50", label: "Skipped" },
} as const;
export function JobTable({ executions, definitions }: {
executions: any[];
definitions: any[];
}) {
const [expandedId, setExpandedId] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const [triggeringJob, setTriggeringJob] = useState<string | null>(null);
const handleTrigger = async (jobId: string) => {
setTriggeringJob(jobId);
startTransition(async () => {
try {
await fetch(`/api/admin/jobs/${jobId}/trigger`, { method: "POST" });
// Refresh the page to show the new execution
window.location.reload();
} finally {
setTriggeringJob(null);
}
});
};
return (
<div className="space-y-6">
{/* Per-job summary cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{definitions.map((def) => {
const lastExec = executions.find((e) => e.jobId === def.id);
const config = lastExec ? STATUS_CONFIG[lastExec.status as keyof typeof STATUS_CONFIG] : null;
const StatusIcon = config?.icon ?? Clock;
return (
<div key={def.id} className="bg-white border border-gray-200 rounded-xl p-4">
<div className="flex items-start justify-between mb-3">
<div>
<p className="text-sm font-medium text-gray-900">{def.displayName}</p>
<p className="text-xs text-gray-400 mt-0.5 font-mono">{def.schedule}</p>
</div>
{config && (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${config.bg} ${config.color}`}>
<StatusIcon className="h-3 w-3" />
{config.label}
</span>
)}
</div>
{lastExec && (
<p className="text-xs text-gray-400">
Last run {formatDistanceToNow(new Date(lastExec.startedAt), { addSuffix: true })}
{lastExec.durationMs && ` ยท ${(lastExec.durationMs / 1000).toFixed(1)}s`}
</p>
)}
<button
onClick={() => handleTrigger(def.id)}
disabled={isPending && triggeringJob === def.id}
className="mt-3 w-full flex items-center justify-center gap-1.5 px-3 py-1.5 border border-gray-200 rounded-lg text-xs font-medium text-gray-600 hover:bg-gray-50 disabled:opacity-50"
>
<Play className="h-3 w-3" />
{isPending && triggeringJob === def.id ? "Triggering..." : "Run now"}
</button>
</div>
);
})}
</div>
{/* Execution history table */}
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-gray-100">
<h3 className="text-sm font-semibold text-gray-900">Execution History</h3>
</div>
<div className="divide-y divide-gray-50">
{executions.map((exec) => {
const config = STATUS_CONFIG[exec.status as keyof typeof STATUS_CONFIG];
const StatusIcon = config.icon;
const isExpanded = expandedId === exec.id;
return (
<div key={exec.id}>
<button
onClick={() => setExpandedId(isExpanded ? null : exec.id)}
className="w-full flex items-center gap-4 px-4 py-3 hover:bg-gray-50 text-left"
>
<StatusIcon className={`h-4 w-4 flex-shrink-0 ${config.color} ${exec.status === "running" ? "animate-spin" : ""}`} />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900">{exec.job?.displayName}</p>
<p className="text-xs text-gray-400">
{exec.trigger === "manual" ? `Manual by ${exec.triggeredByUser?.name}` : "Scheduled"}
{" ยท "}
{formatDistanceToNow(new Date(exec.startedAt), { addSuffix: true })}
</p>
</div>
{exec.durationMs != null && (
<span className="text-xs text-gray-400 flex-shrink-0">
{exec.durationMs < 1000
? `${exec.durationMs}ms`
: `${(exec.durationMs / 1000).toFixed(1)}s`}
</span>
)}
</button>
{isExpanded && (
<div className="px-12 py-3 bg-gray-50 border-t border-gray-100">
{exec.result && (
<pre className="text-xs text-gray-600 bg-white border border-gray-100 rounded p-3 overflow-auto">
{JSON.stringify(exec.result, null, 2)}
</pre>
)}
{exec.errorMessage && (
<p className="text-xs text-red-600 bg-red-50 border border-red-100 rounded p-3 font-mono">
{exec.errorMessage}
</p>
)}
<p className="text-xs text-gray-400 mt-2">
Execution ID: <code className="font-mono">{exec.executionId}</code>
</p>
</div>
)}
</div>
);
})}
</div>
</div>
</div>
);
}
๐ก The Difference Between a SaaS Demo and a SaaS Business
Anyone can build a demo. We build SaaS products that handle real load, real users, and real payments โ with architecture that does not need to be rewritten at 1,000 users.
- Multi-tenant PostgreSQL with row-level security
- Stripe subscriptions, usage billing, annual plans
- SOC2-ready infrastructure from day one
- We own zero equity โ you own everything
Failure Alerting
// lib/jobs/alerting.ts
import { Resend } from "resend";
import { db } from "@/lib/db";
const resend = new Resend(process.env.RESEND_API_KEY!);
export async function alertJobFailure(
jobId: string,
executionId: string,
error: string
) {
const [definition, consecutiveFailures] = await Promise.all([
db.jobDefinition.findUnique({ where: { id: jobId } }),
db.jobExecution.count({
where: {
jobId,
status: "failed",
startedAt: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000) },
},
}),
]);
if (!definition) return;
// Only alert on first failure or every 3rd consecutive failure
if (consecutiveFailures > 1 && consecutiveFailures % 3 !== 0) return;
const admins = await db.user.findMany({
where: { role: "admin" },
select: { email: true, name: true },
});
if (admins.length === 0) return;
await resend.emails.send({
from: "Viprasol Alerts <alerts@viprasol.com>",
to: admins.map((a) => a.email),
subject: `โ ๏ธ Job Failed: ${definition.displayName}`,
html: `
<h2>Scheduled Job Failure</h2>
<p><strong>Job:</strong> ${definition.displayName} (${jobId})</p>
<p><strong>Execution ID:</strong> ${executionId}</p>
<p><strong>Failures in last 24h:</strong> ${consecutiveFailures}</p>
<p><strong>Error:</strong></p>
<pre style="background:#fee2e2;padding:12px;border-radius:6px;">${error}</pre>
<p><a href="${process.env.APP_URL}/admin/jobs">View Job Dashboard</a></p>
`,
});
}
Health Check Endpoint
// app/api/health/jobs/route.ts
// For uptime monitors (e.g., Better Uptime, UptimeRobot)
// Returns 200 if all critical jobs ran recently, 503 if any are overdue
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
const CRITICAL_JOBS = [
{ id: "weekly-digest", maxAgeHours: 168 }, // Must run at least weekly
{ id: "payment-retry", maxAgeHours: 2 }, // Must run at least every 2h
{ id: "session-cleanup", maxAgeHours: 26 }, // Must run at least daily
];
export async function GET() {
const results = await Promise.all(
CRITICAL_JOBS.map(async ({ id, maxAgeHours }) => {
const lastSuccess = await db.jobExecution.findFirst({
where: { jobId: id, status: "completed" },
orderBy: { startedAt: "desc" },
select: { startedAt: true },
});
const lastRunAge = lastSuccess
? (Date.now() - lastSuccess.startedAt.getTime()) / (1000 * 60 * 60)
: Infinity;
return {
id,
healthy: lastRunAge <= maxAgeHours,
lastRunHoursAgo: Math.round(lastRunAge * 10) / 10,
maxAgeHours,
};
})
);
const allHealthy = results.every((r) => r.healthy);
return NextResponse.json(
{ healthy: allHealthy, jobs: results, checkedAt: new Date().toISOString() },
{ status: allHealthy ? 200 : 503 }
);
}
Cost and Timeline
| Component | Timeline | Cost (USD) |
|---|---|---|
| DB schema + execution recording | 0.5โ1 day | $400โ$800 |
| Job history API | 0.5 day | $300โ$500 |
| Admin dashboard UI | 1โ2 days | $800โ$1,600 |
| Manual trigger + audit log | 0.5 day | $300โ$500 |
| Failure alerting | 0.5 day | $300โ$500 |
| Health check endpoint | 0.5 day | $200โ$400 |
| Full job dashboard | 1โ2 weeks | $5,000โ$8,000 |
See Also
- AWS Lambda Scheduled โ EventBridge Scheduler for the actual job execution
- SaaS Audit Trail โ Auditing manual job triggers
- SaaS Activity Feed โ Surfacing job events to users
- Next.js Authentication Patterns โ Protecting the admin dashboard
Working With Viprasol
We build job monitoring dashboards for SaaS products โ turning invisible background jobs into observable, manageable systems. Our team has shipped job dashboards that reduced time-to-detect job failures from days to minutes.
What we deliver:
- PostgreSQL execution history schema with duration computation
- Admin dashboard with per-job status cards and execution history
- Manual trigger with audit logging
- Failure alerting (first failure + every 3rd consecutive)
- Health check endpoint for uptime monitoring integration
Explore our SaaS development services or contact us to build your job monitoring dashboard.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Building a SaaS Product?
We've helped launch 50+ SaaS platforms. Let's build yours โ fast.
Free consultation โข No commitment โข Response within 24 hours
Add AI automation to your SaaS product?
Viprasol builds custom AI agent crews that plug into any SaaS workflow โ automating repetitive tasks, qualifying leads, and responding across every channel your customers use.