219 lines
8.6 KiB
TypeScript
219 lines
8.6 KiB
TypeScript
import { AlertTriangle, BriefcaseBusiness, Clock3, RotateCcw } from "lucide-react";
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { Link } from "react-router-dom";
|
|
import { toast } from "sonner";
|
|
|
|
import { api } from "../api";
|
|
import { eventTitle } from "../constants";
|
|
import { EmptyState } from "../components/EmptyState";
|
|
import { StatCard } from "../components/StatCard";
|
|
import { StatusIcon } from "../components/StatusIcon";
|
|
import type { Health, Job, JobEvent, JobStats, JobStatus } from "../types";
|
|
|
|
const emptyStats: JobStats = {
|
|
total: 0,
|
|
by_status: { queued: 0, running: 0, succeeded: 0, failed: 0 },
|
|
overdue_running: 0,
|
|
retries_pending: 0,
|
|
oldest_queued_at: null
|
|
};
|
|
|
|
function BarMetric({ label, value, max, tone = "neutral" }: { label: string; value: number; max: number; tone?: string }) {
|
|
const width = max > 0 ? Math.max(4, Math.round((value / max) * 100)) : 0;
|
|
return (
|
|
<div className="metric-bar-row">
|
|
<span>{label}</span>
|
|
<div className="metric-track">
|
|
<i className={tone} style={{ width: `${width}%` }} />
|
|
</div>
|
|
<strong>{value}</strong>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type ChartPoint = { label: string; value: number; tone: string };
|
|
|
|
function MiniHistogram({ data }: { data: ChartPoint[] }) {
|
|
const max = Math.max(...data.map((item) => item.value), 1);
|
|
return (
|
|
<div className="histogram" aria-label="histogram">
|
|
{data.map((item) => {
|
|
const height = item.value > 0 ? Math.max(12, Math.round((item.value / max) * 100)) : 4;
|
|
return (
|
|
<div className="histogram-item" key={item.label}>
|
|
<div className="histogram-column">
|
|
<i className={item.tone} style={{ height: `${height}%` }} />
|
|
</div>
|
|
<span>{item.label}</span>
|
|
<strong>{item.value}</strong>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StatusStack({ data }: { data: ChartPoint[] }) {
|
|
const total = data.reduce((sum, item) => sum + item.value, 0);
|
|
return (
|
|
<div className="status-stack" aria-label="status distribution">
|
|
{data.map((item) => {
|
|
const width = total > 0 ? Math.max(6, Math.round((item.value / total) * 100)) : 25;
|
|
return <i className={item.tone} key={item.label} style={{ width: `${width}%` }} title={`${item.label}: ${item.value}`} />;
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function DashboardPage() {
|
|
const [jobs, setJobs] = useState<Job[]>([]);
|
|
const [events, setEvents] = useState<JobEvent[]>([]);
|
|
const [stats, setStats] = useState<JobStats>(emptyStats);
|
|
const [health, setHealth] = useState<Health | null>(null);
|
|
|
|
const refresh = useCallback(async () => {
|
|
const [jobsData, statsData, eventsData, healthData] = await Promise.all([
|
|
api.listJobs(),
|
|
api.getStats(),
|
|
api.listEvents({ limit: 50 }),
|
|
api.health()
|
|
]);
|
|
setJobs(jobsData);
|
|
setStats({ ...emptyStats, ...statsData, by_status: { ...emptyStats.by_status, ...statsData.by_status } });
|
|
setEvents(eventsData.results);
|
|
setHealth(healthData);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void refresh().catch((caught) => toast.error(caught instanceof Error ? caught.message : String(caught)));
|
|
const id = window.setInterval(() => void refresh().catch(() => undefined), 2000);
|
|
return () => window.clearInterval(id);
|
|
}, [refresh]);
|
|
|
|
const recentJobs = useMemo(() => jobs.slice(0, 20), [jobs]);
|
|
const recentJobStatusData = useMemo(() => {
|
|
const counts: Record<JobStatus, number> = { queued: 0, running: 0, succeeded: 0, failed: 0 };
|
|
recentJobs.forEach((job) => {
|
|
counts[job.status] += 1;
|
|
});
|
|
return [
|
|
{ label: "Queued", value: counts.queued, tone: "queued" },
|
|
{ label: "Running", value: counts.running, tone: "running" },
|
|
{ label: "Succeeded", value: counts.succeeded, tone: "succeeded" },
|
|
{ label: "Failed", value: counts.failed, tone: "failed" }
|
|
];
|
|
}, [recentJobs]);
|
|
const pressureData = useMemo(
|
|
() => [
|
|
{ label: "Queued", value: stats.by_status.queued, tone: "queued" },
|
|
{ label: "Running", value: stats.by_status.running, tone: "running" },
|
|
{ label: "Retry backoff", value: stats.retries_pending, tone: "orange" },
|
|
{ label: "Overdue locks", value: stats.overdue_running, tone: "failed" },
|
|
{ label: "Failed", value: stats.by_status.failed, tone: "failed" }
|
|
],
|
|
[stats]
|
|
);
|
|
const eventData = useMemo(() => {
|
|
const counts = events.reduce<Record<string, number>>((accumulator, event) => {
|
|
accumulator[event.type] = (accumulator[event.type] ?? 0) + 1;
|
|
return accumulator;
|
|
}, {});
|
|
return Object.entries(counts)
|
|
.sort((a, b) => b[1] - a[1])
|
|
.slice(0, 6)
|
|
.map(([type, value]) => ({ label: eventTitle(type), value, tone: type.includes("fail") ? "failed" : type.includes("success") ? "succeeded" : "running" }));
|
|
}, [events]);
|
|
const maxPressure = Math.max(...pressureData.map((item) => item.value), 1);
|
|
const activePressure = stats.by_status.queued + stats.by_status.running + stats.retries_pending + stats.overdue_running;
|
|
const completionRate = recentJobs.length
|
|
? Math.round((recentJobs.filter((job) => job.status === "succeeded").length / recentJobs.length) * 100)
|
|
: 0;
|
|
const topEvent = eventData.at(0);
|
|
|
|
return (
|
|
<div className="page">
|
|
<header className="page-header">
|
|
<div>
|
|
<span className="eyebrow">Overview</span>
|
|
<h2>Dashboard</h2>
|
|
</div>
|
|
<span className={`connection ${health?.ok ? "open" : "closed"}`}>DB {health?.database ?? "unknown"}</span>
|
|
</header>
|
|
|
|
<section className="stats-grid">
|
|
<StatCard label="Total jobs" value={stats.total} icon={<BriefcaseBusiness size={16} />} />
|
|
<StatCard label="Queued" value={stats.by_status.queued} tone="queued" icon={<StatusIcon status="queued" />} />
|
|
<StatCard label="Running" value={stats.by_status.running} tone="running" icon={<StatusIcon status="running" />} />
|
|
<StatCard label="Succeeded" value={stats.by_status.succeeded} tone="succeeded" icon={<StatusIcon status="succeeded" />} />
|
|
<StatCard label="Failed" value={stats.by_status.failed} tone="failed" icon={<StatusIcon status="failed" />} />
|
|
<StatCard label="Overdue locks" value={stats.overdue_running} tone="failed" icon={<AlertTriangle size={16} />} />
|
|
</section>
|
|
|
|
<section className="dashboard-grid">
|
|
<section className="panel chart-panel">
|
|
<div className="section-title">
|
|
<h3>Recent Jobs</h3>
|
|
<Link className="secondary-button" to="/jobs">
|
|
View jobs
|
|
</Link>
|
|
</div>
|
|
{recentJobs.length ? (
|
|
<>
|
|
<div className="chart-summary-row">
|
|
<span>{recentJobs.length} latest jobs</span>
|
|
<strong>{completionRate}% succeeded</strong>
|
|
</div>
|
|
<StatusStack data={recentJobStatusData} />
|
|
<MiniHistogram data={recentJobStatusData} />
|
|
<div className="job-tick-chart" aria-label="latest jobs">
|
|
{recentJobs.slice(0, 16).map((job) => (
|
|
<Link className={`job-tick ${job.status}`} key={job.id} title={`${job.type} ${job.status}`} to={`/jobs/${job.id}`} />
|
|
))}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<EmptyState title="No jobs yet" />
|
|
)}
|
|
</section>
|
|
|
|
<section className="panel chart-panel">
|
|
<div className="section-title">
|
|
<h3>Queue Pressure</h3>
|
|
<Clock3 size={16} />
|
|
</div>
|
|
<div className="chart-summary-row">
|
|
<span>Active pressure</span>
|
|
<strong>{activePressure}</strong>
|
|
</div>
|
|
<div className="metric-bars">
|
|
{pressureData.map((item) => (
|
|
<BarMetric key={item.label} label={item.label} max={maxPressure} tone={item.tone} value={item.value} />
|
|
))}
|
|
</div>
|
|
<div className="dashboard-footnote">
|
|
<span>{stats.oldest_queued_at ? "Oldest queued job is waiting" : "No queued backlog"}</span>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="panel chart-panel">
|
|
<div className="section-title">
|
|
<h3>Recent Events</h3>
|
|
<RotateCcw size={16} />
|
|
</div>
|
|
{eventData.length ? (
|
|
<>
|
|
<div className="chart-summary-row">
|
|
<span>{events.length} latest events</span>
|
|
<strong>{topEvent ? topEvent.label : "No events"}</strong>
|
|
</div>
|
|
<MiniHistogram data={eventData} />
|
|
</>
|
|
) : (
|
|
<EmptyState title="No events yet" />
|
|
)}
|
|
</section>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|