Back to Blog

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.

Viprasol Tech Team
March 2, 2027
13 min read

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

ComponentTimelineCost (USD)
DB schema + execution recording0.5โ€“1 day$400โ€“$800
Job history API0.5 day$300โ€“$500
Admin dashboard UI1โ€“2 days$800โ€“$1,600
Manual trigger + audit log0.5 day$300โ€“$500
Failure alerting0.5 day$300โ€“$500
Health check endpoint0.5 day$200โ€“$400
Full job dashboard1โ€“2 weeks$5,000โ€“$8,000

See Also


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.

Share this article:

About the Author

V

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.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Building a SaaS Product?

We've helped launch 50+ SaaS platforms. Let's build yours โ€” fast.

Free consultation โ€ข No commitment โ€ข Response within 24 hours

Viprasol ยท AI Agent Systems

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.