feat(frontend): chart dashboard queue activity
This commit is contained in:
@@ -5,12 +5,10 @@ import { toast } from "sonner";
|
|||||||
|
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import { eventTitle } from "../constants";
|
import { eventTitle } from "../constants";
|
||||||
import { DateTime } from "../components/DateTime";
|
|
||||||
import { EmptyState } from "../components/EmptyState";
|
import { EmptyState } from "../components/EmptyState";
|
||||||
import { StatCard } from "../components/StatCard";
|
import { StatCard } from "../components/StatCard";
|
||||||
import { StatusBadge } from "../components/StatusBadge";
|
|
||||||
import { StatusIcon } from "../components/StatusIcon";
|
import { StatusIcon } from "../components/StatusIcon";
|
||||||
import type { Health, Job, JobEvent, JobStats } from "../types";
|
import type { Health, Job, JobEvent, JobStats, JobStatus } from "../types";
|
||||||
|
|
||||||
const emptyStats: JobStats = {
|
const emptyStats: JobStats = {
|
||||||
total: 0,
|
total: 0,
|
||||||
@@ -20,6 +18,39 @@ const emptyStats: JobStats = {
|
|||||||
oldest_queued_at: null
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MiniHistogram({ data }: { data: Array<{ label: string; value: number; tone: string }> }) {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const [jobs, setJobs] = useState<Job[]>([]);
|
const [jobs, setJobs] = useState<Job[]>([]);
|
||||||
const [events, setEvents] = useState<JobEvent[]>([]);
|
const [events, setEvents] = useState<JobEvent[]>([]);
|
||||||
@@ -30,7 +61,7 @@ export function DashboardPage() {
|
|||||||
const [jobsData, statsData, eventsData, healthData] = await Promise.all([
|
const [jobsData, statsData, eventsData, healthData] = await Promise.all([
|
||||||
api.listJobs(),
|
api.listJobs(),
|
||||||
api.getStats(),
|
api.getStats(),
|
||||||
api.listEvents({ limit: 8 }),
|
api.listEvents({ limit: 50 }),
|
||||||
api.health()
|
api.health()
|
||||||
]);
|
]);
|
||||||
setJobs(jobsData);
|
setJobs(jobsData);
|
||||||
@@ -45,7 +76,40 @@ export function DashboardPage() {
|
|||||||
return () => window.clearInterval(id);
|
return () => window.clearInterval(id);
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
const recentJobs = useMemo(() => jobs.slice(0, 6), [jobs]);
|
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);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
@@ -74,16 +138,18 @@ export function DashboardPage() {
|
|||||||
View jobs
|
View jobs
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="compact-list">
|
{recentJobs.length ? (
|
||||||
{recentJobs.map((job) => (
|
<>
|
||||||
<Link className="compact-row" key={job.id} to={`/jobs/${job.id}`}>
|
<MiniHistogram data={recentJobStatusData} />
|
||||||
<StatusBadge status={job.status} />
|
<div className="job-tick-chart" aria-label="latest jobs">
|
||||||
<span>{job.type}</span>
|
{recentJobs.slice(0, 16).map((job) => (
|
||||||
<DateTime value={job.created_at} />
|
<Link className={`job-tick ${job.status}`} key={job.id} title={`${job.type} ${job.status}`} to={`/jobs/${job.id}`} />
|
||||||
</Link>
|
))}
|
||||||
))}
|
</div>
|
||||||
{!recentJobs.length && <EmptyState title="No jobs yet" />}
|
</>
|
||||||
</div>
|
) : (
|
||||||
|
<EmptyState title="No jobs yet" />
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="panel">
|
<section className="panel">
|
||||||
@@ -91,22 +157,13 @@ export function DashboardPage() {
|
|||||||
<h3>Queue Pressure</h3>
|
<h3>Queue Pressure</h3>
|
||||||
<Clock3 size={16} />
|
<Clock3 size={16} />
|
||||||
</div>
|
</div>
|
||||||
<div className="compact-list">
|
<div className="metric-bars">
|
||||||
<div className="compact-row">
|
{pressureData.map((item) => (
|
||||||
<strong>Retries pending</strong>
|
<BarMetric key={item.label} label={item.label} max={maxPressure} tone={item.tone} value={item.value} />
|
||||||
<span>{stats.retries_pending}</span>
|
))}
|
||||||
<span className="muted-text">backoff</span>
|
</div>
|
||||||
</div>
|
<div className="dashboard-footnote">
|
||||||
<div className="compact-row">
|
<span>{stats.oldest_queued_at ? "Oldest queued job is waiting" : "No queued backlog"}</span>
|
||||||
<strong>Oldest queued</strong>
|
|
||||||
<span>{stats.oldest_queued_at ? "waiting" : "none"}</span>
|
|
||||||
<DateTime value={stats.oldest_queued_at} />
|
|
||||||
</div>
|
|
||||||
<div className="compact-row">
|
|
||||||
<strong>Failed jobs</strong>
|
|
||||||
<span>{stats.by_status.failed}</span>
|
|
||||||
<span className="muted-text">retryable</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -115,16 +172,7 @@ export function DashboardPage() {
|
|||||||
<h3>Recent Events</h3>
|
<h3>Recent Events</h3>
|
||||||
<RotateCcw size={16} />
|
<RotateCcw size={16} />
|
||||||
</div>
|
</div>
|
||||||
<div className="compact-list">
|
{eventData.length ? <MiniHistogram data={eventData} /> : <EmptyState title="No events yet" />}
|
||||||
{events.map((event) => (
|
|
||||||
<Link className="compact-row" key={event.id} to={`/jobs/${event.job}`}>
|
|
||||||
<strong>{eventTitle(event.type)}</strong>
|
|
||||||
<span>{event.attempt ? `attempt ${event.attempt}` : "new"}</span>
|
|
||||||
<DateTime value={event.created_at} />
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
{!events.length && <EmptyState title="No events yet" />}
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -611,6 +611,145 @@ label {
|
|||||||
background: var(--green);
|
background: var(--green);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.histogram {
|
||||||
|
align-items: end;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(72px, 1fr));
|
||||||
|
min-height: 178px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.histogram-item {
|
||||||
|
align-items: center;
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
justify-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.histogram-column {
|
||||||
|
align-items: end;
|
||||||
|
background: var(--panel-raised);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
height: 112px;
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.histogram-column i,
|
||||||
|
.metric-track i {
|
||||||
|
background: var(--accent);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.histogram-column i {
|
||||||
|
border-radius: 6px 6px 0 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.histogram-column i.queued,
|
||||||
|
.histogram-column i.running,
|
||||||
|
.metric-track i.queued,
|
||||||
|
.metric-track i.running {
|
||||||
|
background: var(--cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.histogram-column i.succeeded,
|
||||||
|
.metric-track i.succeeded {
|
||||||
|
background: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.histogram-column i.failed,
|
||||||
|
.metric-track i.failed {
|
||||||
|
background: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.histogram-column i.orange,
|
||||||
|
.metric-track i.orange {
|
||||||
|
background: var(--orange);
|
||||||
|
}
|
||||||
|
|
||||||
|
.histogram-item span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 900;
|
||||||
|
overflow: hidden;
|
||||||
|
text-align: center;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.histogram-item strong {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-tick-chart {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
grid-template-columns: repeat(16, minmax(8px, 1fr));
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-tick {
|
||||||
|
background: var(--subtle);
|
||||||
|
border-radius: 5px;
|
||||||
|
display: block;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-tick.running {
|
||||||
|
background: var(--cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-tick.succeeded {
|
||||||
|
background: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-tick.failed {
|
||||||
|
background: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-bars {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-bar-row {
|
||||||
|
align-items: center;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
grid-template-columns: minmax(100px, 130px) minmax(120px, 1fr) 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-bar-row span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-track {
|
||||||
|
background: var(--panel-raised);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
height: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-track i {
|
||||||
|
border-radius: inherit;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-footnote {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.bar-list,
|
.bar-list,
|
||||||
.compact-list,
|
.compact-list,
|
||||||
.modal-form {
|
.modal-form {
|
||||||
|
|||||||
Reference in New Issue
Block a user