← back to Omega Watches 2

src/app/(dashboard)/jobs/page.tsx

352 lines

"use client";

import { useEffect, useState, useMemo } from "react";
import { api } from "@/lib/api-client";
import { formatDateTime } from "@/lib/utils";
import { JobStatusChart } from "@/components/charts";

interface Job {
  run_id: number;
  source_id: string;
  source_name: string;
  source_display_name: string;
  job_type: string;
  status: string;
  records_fetched: number;
  records_parsed: number;
  records_inserted: number;
  records_updated: number;
  records_quarantined: number;
  records_deduplicated: number;
  duration_ms: number | null;
  started_at: string;
  completed_at: string | null;
  parser_version: string;
  error_message: string | null;
}

const STATUS_TAB = ["all", "completed", "running", "pending", "failed"] as const;

const statusColor: Record<string, string> = {
  completed: "badge-success",
  running: "badge-info",
  pending: "badge-warning",
  failed: "badge-error",
  cancelled: "badge-error",
};

export default function JobRunsPage() {
  const [jobs, setJobs] = useState<Job[]>([]);
  const [sources, setSources] = useState<any[]>([]);
  const [dailyStats, setDailyStats] = useState<any[]>([]);
  const [error, setError] = useState("");
  const [statusFilter, setStatusFilter] = useState<string>("all");
  const [sourceFilter, setSourceFilter] = useState("");
  const [expanded, setExpanded] = useState<number | null>(null);
  const [triggering, setTriggering] = useState(false);
  const [triggerSource, setTriggerSource] = useState("");
  const [triggerMsg, setTriggerMsg] = useState("");

  useEffect(() => {
    loadAll();
    const interval = setInterval(() => loadJobs(), 15000);
    return () => clearInterval(interval);
  }, []);

  async function loadAll() {
    try {
      const [j, s, ds] = await Promise.all([
        api.getJobs({ limit: 200 }),
        api.getSources(),
        api.getJobsDailyStats().catch(() => []),
      ]);
      setJobs(j);
      setSources(s);
      setDailyStats(ds);
    } catch (e: any) {
      setError(e.message);
    }
  }

  async function loadJobs() {
    try {
      const j = await api.getJobs({ limit: 200 });
      setJobs(j);
    } catch {}
  }

  async function triggerRun() {
    if (!triggerSource) return;
    setTriggering(true);
    setTriggerMsg("");
    try {
      const result = await api.runJob(triggerSource);
      setTriggerMsg(`Job #${result.run_id} queued for ${triggerSource}`);
      setTimeout(loadJobs, 1000);
    } catch (e: any) {
      setTriggerMsg(`Error: ${e.message}`);
    } finally {
      setTriggering(false);
    }
  }

  // Summary stats
  const stats = useMemo(() => {
    const total = jobs.length;
    const completed = jobs.filter((j) => j.status === "completed").length;
    const failed = jobs.filter((j) => j.status === "failed").length;
    const running = jobs.filter((j) => j.status === "running").length;
    const totalFetched = jobs.reduce((a, j) => a + (j.records_fetched || 0), 0);
    const totalInserted = jobs.reduce((a, j) => a + (j.records_inserted || 0), 0);
    const totalQuarantined = jobs.reduce((a, j) => a + (j.records_quarantined || 0), 0);
    const avgDuration = jobs.filter((j) => j.duration_ms).length > 0
      ? Math.round(jobs.filter((j) => j.duration_ms).reduce((a, j) => a + (j.duration_ms || 0), 0) / jobs.filter((j) => j.duration_ms).length)
      : 0;
    const successRate = total > 0 ? Math.round((completed / total) * 100) : 0;

    return { total, completed, failed, running, totalFetched, totalInserted, totalQuarantined, avgDuration, successRate };
  }, [jobs]);

  // Filtered jobs
  const filtered = useMemo(() => {
    return jobs.filter((j) => {
      if (statusFilter !== "all" && j.status !== statusFilter) return false;
      if (sourceFilter && j.source_name !== sourceFilter) return false;
      return true;
    });
  }, [jobs, statusFilter, sourceFilter]);

  const uniqueSources = [...new Set(jobs.map((j) => j.source_name))].sort();
  const activeSources = sources.filter((s) => s.is_active);

  return (
    <div className="space-y-6">
      <div className="flex items-center justify-between">
        <h1 className="text-xl font-bold text-gray-100">Collector Job Runs</h1>
        <div className="flex gap-2">
          <button onClick={loadJobs} className="btn-secondary text-xs">
            Refresh
          </button>
        </div>
      </div>

      {error && <div className="rounded bg-red-900/30 p-3 text-red-300">{error}</div>}

      {/* Summary KPIs */}
      <div className="grid gap-4 md:grid-cols-5">
        <div className="card border-navy-700 text-center">
          <div className="text-3xl font-bold text-gray-200">{stats.total}</div>
          <div className="text-xs text-gray-500">Total Runs</div>
        </div>
        <div className="card border-navy-700 text-center">
          <div className={`text-3xl font-bold ${stats.successRate >= 90 ? "text-green-400" : stats.successRate >= 70 ? "text-yellow-400" : "text-red-400"}`}>
            {stats.successRate}%
          </div>
          <div className="text-xs text-gray-500">Success Rate</div>
        </div>
        <div className="card border-navy-700 text-center">
          <div className="text-3xl font-bold text-blue-400">{stats.totalFetched.toLocaleString()}</div>
          <div className="text-xs text-gray-500">Records Fetched</div>
        </div>
        <div className="card border-navy-700 text-center">
          <div className="text-3xl font-bold text-green-400">{stats.totalInserted.toLocaleString()}</div>
          <div className="text-xs text-gray-500">Records Inserted</div>
        </div>
        <div className="card border-navy-700 text-center">
          <div className={`text-3xl font-bold ${stats.totalQuarantined > 0 ? "text-yellow-400" : "text-gray-400"}`}>
            {stats.totalQuarantined}
          </div>
          <div className="text-xs text-gray-500">Quarantined</div>
        </div>
      </div>

      {/* Daily Job Status Chart */}
      {dailyStats.length > 0 && (
        <JobStatusChart data={dailyStats} title="Daily Job Status (30 Days)" />
      )}

      {/* Trigger Run Panel */}
      <div className="card border-navy-700">
        <h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400">
          Trigger New Run
        </h2>
        <div className="flex items-center gap-3">
          <select
            className="input w-64"
            value={triggerSource}
            onChange={(e) => setTriggerSource(e.target.value)}
          >
            <option value="">Select source...</option>
            {activeSources.map((s) => (
              <option key={s.name} value={s.name}>
                {s.display_name} ({s.access_method})
              </option>
            ))}
          </select>
          <button
            onClick={triggerRun}
            disabled={!triggerSource || triggering}
            className="btn-primary text-xs disabled:opacity-50"
          >
            {triggering ? "Queuing..." : "Run Incremental"}
          </button>
          {triggerMsg && (
            <span className={`text-xs ${triggerMsg.startsWith("Error") ? "text-red-400" : "text-green-400"}`}>
              {triggerMsg}
            </span>
          )}
        </div>
      </div>

      {/* Filters */}
      <div className="flex items-center gap-3">
        {/* Status tabs */}
        <div className="flex rounded-lg border border-navy-700 bg-navy-900 p-0.5">
          {STATUS_TAB.map((tab) => (
            <button
              key={tab}
              onClick={() => setStatusFilter(tab)}
              className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
                statusFilter === tab
                  ? "bg-omega-500 text-white"
                  : "text-gray-400 hover:text-gray-200"
              }`}
            >
              {tab === "all" ? "All" : tab.charAt(0).toUpperCase() + tab.slice(1)}
              {tab !== "all" && (
                <span className="ml-1 text-[10px] opacity-70">
                  ({jobs.filter((j) => j.status === tab).length})
                </span>
              )}
            </button>
          ))}
        </div>

        <select
          className="input w-48"
          value={sourceFilter}
          onChange={(e) => setSourceFilter(e.target.value)}
        >
          <option value="">All Sources</option>
          {uniqueSources.map((s) => (
            <option key={s} value={s}>{s}</option>
          ))}
        </select>

        <span className="text-xs text-gray-500">
          Showing {filtered.length} of {jobs.length} runs
        </span>
      </div>

      {/* Jobs Table */}
      <div className="card border-navy-700 p-0">
        <div className="overflow-x-auto">
          <table className="w-full text-sm">
            <thead>
              <tr className="border-b border-navy-700 text-left text-xs uppercase text-gray-500">
                <th className="p-3">Run</th>
                <th className="p-3">Source</th>
                <th className="p-3">Type</th>
                <th className="p-3">Status</th>
                <th className="p-3 text-right">Fetched</th>
                <th className="p-3 text-right">Inserted</th>
                <th className="p-3 text-right">Quarantined</th>
                <th className="p-3 text-right">Duration</th>
                <th className="p-3">Started</th>
                <th className="p-3">Parser</th>
              </tr>
            </thead>
            <tbody>
              {filtered.length === 0 ? (
                <tr>
                  <td colSpan={10} className="p-6 text-center text-gray-500">
                    {jobs.length === 0
                      ? "No job runs yet. Configure collectors and trigger a run."
                      : "No runs match the current filters."}
                  </td>
                </tr>
              ) : (
                filtered.map((j) => (
                  <>
                    <tr
                      key={j.run_id}
                      className={`cursor-pointer border-b border-navy-800 transition-colors hover:bg-navy-900 ${
                        expanded === j.run_id ? "bg-navy-900" : ""
                      }`}
                      onClick={() => setExpanded(expanded === j.run_id ? null : j.run_id)}
                    >
                      <td className="p-3 font-mono text-xs text-omega-400">#{j.run_id}</td>
                      <td className="p-3 font-medium text-gray-200">{j.source_display_name}</td>
                      <td className="p-3">
                        <span className={`badge ${j.job_type === "full" ? "badge-warning" : "badge-info"}`}>
                          {j.job_type}
                        </span>
                      </td>
                      <td className="p-3">
                        <span className={`badge ${statusColor[j.status] || "badge-info"}`}>
                          {j.status}
                        </span>
                      </td>
                      <td className="p-3 text-right text-gray-400">{j.records_fetched || 0}</td>
                      <td className="p-3 text-right text-gray-400">{j.records_inserted || 0}</td>
                      <td className="p-3 text-right">
                        <span className={j.records_quarantined > 0 ? "text-yellow-400 font-medium" : "text-gray-400"}>
                          {j.records_quarantined || 0}
                        </span>
                      </td>
                      <td className="p-3 text-right text-gray-400">
                        {j.duration_ms ? `${(j.duration_ms / 1000).toFixed(1)}s` : "-"}
                      </td>
                      <td className="p-3 text-xs text-gray-500">
                        {j.started_at ? formatDateTime(j.started_at) : "-"}
                      </td>
                      <td className="p-3 font-mono text-xs text-gray-500">{j.parser_version}</td>
                    </tr>
                    {expanded === j.run_id && (
                      <tr key={`${j.run_id}-detail`} className="border-b border-navy-800 bg-navy-900/50">
                        <td colSpan={10} className="p-4">
                          <div className="grid gap-4 md:grid-cols-4">
                            <div className="rounded bg-navy-900 p-3">
                              <div className="text-xs text-gray-500">Records Parsed</div>
                              <div className="mt-1 text-lg font-bold text-gray-200">{j.records_parsed || 0}</div>
                            </div>
                            <div className="rounded bg-navy-900 p-3">
                              <div className="text-xs text-gray-500">Records Updated</div>
                              <div className="mt-1 text-lg font-bold text-blue-400">{j.records_updated || 0}</div>
                            </div>
                            <div className="rounded bg-navy-900 p-3">
                              <div className="text-xs text-gray-500">Deduplicated</div>
                              <div className="mt-1 text-lg font-bold text-purple-400">{j.records_deduplicated || 0}</div>
                            </div>
                            <div className="rounded bg-navy-900 p-3">
                              <div className="text-xs text-gray-500">Completed At</div>
                              <div className="mt-1 text-sm text-gray-300">
                                {j.completed_at ? formatDateTime(j.completed_at) : "In progress..."}
                              </div>
                            </div>
                          </div>
                          {j.error_message && (
                            <div className="mt-3 rounded bg-red-900/20 p-3">
                              <div className="text-xs font-medium text-red-400">Error</div>
                              <pre className="mt-1 whitespace-pre-wrap text-xs text-red-300">{j.error_message}</pre>
                            </div>
                          )}
                          <div className="mt-3 flex items-center gap-4 text-xs text-gray-500">
                            <span>Source ID: {j.source_id?.substring(0, 8)}...</span>
                            <span>Parser: {j.parser_version}</span>
                            {j.duration_ms && <span>Avg {((j.records_fetched || 0) / (j.duration_ms / 1000)).toFixed(0)} records/sec</span>}
                          </div>
                        </td>
                      </tr>
                    )}
                  </>
                ))
              )}
            </tbody>
          </table>
        </div>
      </div>
    </div>
  );
}