feat(frontend): polish dashboard chart panels

This commit is contained in:
2026-06-21 02:39:41 +03:30
parent 0c15fbfbd2
commit bd7916ed45
2 changed files with 132 additions and 5 deletions

View File

@@ -31,7 +31,9 @@ function BarMetric({ label, value, max, tone = "neutral" }: { label: string; val
); );
} }
function MiniHistogram({ data }: { data: Array<{ label: string; value: number; tone: string }> }) { type ChartPoint = { label: string; value: number; tone: string };
function MiniHistogram({ data }: { data: ChartPoint[] }) {
const max = Math.max(...data.map((item) => item.value), 1); const max = Math.max(...data.map((item) => item.value), 1);
return ( return (
<div className="histogram" aria-label="histogram"> <div className="histogram" aria-label="histogram">
@@ -51,6 +53,18 @@ function MiniHistogram({ data }: { data: Array<{ label: string; value: number; t
); );
} }
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() { export function DashboardPage() {
const [jobs, setJobs] = useState<Job[]>([]); const [jobs, setJobs] = useState<Job[]>([]);
const [events, setEvents] = useState<JobEvent[]>([]); const [events, setEvents] = useState<JobEvent[]>([]);
@@ -110,6 +124,11 @@ export function DashboardPage() {
.map(([type, value]) => ({ label: eventTitle(type), value, tone: type.includes("fail") ? "failed" : type.includes("success") ? "succeeded" : "running" })); .map(([type, value]) => ({ label: eventTitle(type), value, tone: type.includes("fail") ? "failed" : type.includes("success") ? "succeeded" : "running" }));
}, [events]); }, [events]);
const maxPressure = Math.max(...pressureData.map((item) => item.value), 1); 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 ( return (
<div className="page"> <div className="page">
@@ -131,7 +150,7 @@ export function DashboardPage() {
</section> </section>
<section className="dashboard-grid"> <section className="dashboard-grid">
<section className="panel"> <section className="panel chart-panel">
<div className="section-title"> <div className="section-title">
<h3>Recent Jobs</h3> <h3>Recent Jobs</h3>
<Link className="secondary-button" to="/jobs"> <Link className="secondary-button" to="/jobs">
@@ -140,6 +159,11 @@ export function DashboardPage() {
</div> </div>
{recentJobs.length ? ( {recentJobs.length ? (
<> <>
<div className="chart-summary-row">
<span>{recentJobs.length} latest jobs</span>
<strong>{completionRate}% succeeded</strong>
</div>
<StatusStack data={recentJobStatusData} />
<MiniHistogram data={recentJobStatusData} /> <MiniHistogram data={recentJobStatusData} />
<div className="job-tick-chart" aria-label="latest jobs"> <div className="job-tick-chart" aria-label="latest jobs">
{recentJobs.slice(0, 16).map((job) => ( {recentJobs.slice(0, 16).map((job) => (
@@ -152,11 +176,15 @@ export function DashboardPage() {
)} )}
</section> </section>
<section className="panel"> <section className="panel chart-panel">
<div className="section-title"> <div className="section-title">
<h3>Queue Pressure</h3> <h3>Queue Pressure</h3>
<Clock3 size={16} /> <Clock3 size={16} />
</div> </div>
<div className="chart-summary-row">
<span>Active pressure</span>
<strong>{activePressure}</strong>
</div>
<div className="metric-bars"> <div className="metric-bars">
{pressureData.map((item) => ( {pressureData.map((item) => (
<BarMetric key={item.label} label={item.label} max={maxPressure} tone={item.tone} value={item.value} /> <BarMetric key={item.label} label={item.label} max={maxPressure} tone={item.tone} value={item.value} />
@@ -167,12 +195,22 @@ export function DashboardPage() {
</div> </div>
</section> </section>
<section className="panel"> <section className="panel chart-panel">
<div className="section-title"> <div className="section-title">
<h3>Recent Events</h3> <h3>Recent Events</h3>
<RotateCcw size={16} /> <RotateCcw size={16} />
</div> </div>
{eventData.length ? <MiniHistogram data={eventData} /> : <EmptyState title="No events yet" />} {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>
</section> </section>
</div> </div>

View File

@@ -442,6 +442,49 @@ label {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.chart-panel {
overflow: hidden;
position: relative;
}
.chart-panel::before {
background: linear-gradient(90deg, var(--cyan), var(--green), var(--orange), var(--red));
content: "";
height: 3px;
left: 14px;
opacity: 0.78;
position: absolute;
right: 14px;
top: 0;
}
.chart-summary-row {
align-items: center;
background: var(--panel-raised);
border: 1px solid var(--border);
border-radius: 6px;
display: flex;
gap: 12px;
justify-content: space-between;
margin-bottom: 12px;
min-height: 42px;
padding: 9px 10px;
}
.chart-summary-row span {
color: var(--muted);
font-size: 12px;
font-weight: 900;
}
.chart-summary-row strong {
font-size: 14px;
overflow: hidden;
text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
}
.detail-grid { .detail-grid {
display: grid; display: grid;
gap: 16px; gap: 16px;
@@ -645,9 +688,21 @@ label {
display: flex; display: flex;
height: 112px; height: 112px;
overflow: hidden; overflow: hidden;
position: relative;
width: 100%; width: 100%;
} }
.histogram-column::before {
background:
linear-gradient(to top, transparent 24%, rgb(127 139 147 / 18%) 25%, transparent 26%),
linear-gradient(to top, transparent 49%, rgb(127 139 147 / 18%) 50%, transparent 51%),
linear-gradient(to top, transparent 74%, rgb(127 139 147 / 18%) 75%, transparent 76%);
content: "";
inset: 0;
pointer-events: none;
position: absolute;
}
.histogram-column i, .histogram-column i,
.metric-track i { .metric-track i {
background: var(--accent); background: var(--accent);
@@ -656,7 +711,9 @@ label {
.histogram-column i { .histogram-column i {
border-radius: 6px 6px 0 0; border-radius: 6px 6px 0 0;
position: relative;
width: 100%; width: 100%;
z-index: 1;
} }
.histogram-column i.queued, .histogram-column i.queued,
@@ -703,6 +760,38 @@ label {
margin-top: 14px; margin-top: 14px;
} }
.status-stack {
background: var(--panel-raised);
border: 1px solid var(--border);
border-radius: 999px;
display: flex;
gap: 3px;
height: 14px;
margin-bottom: 12px;
overflow: hidden;
padding: 2px;
}
.status-stack i {
background: var(--subtle);
border-radius: 999px;
display: block;
min-width: 6px;
}
.status-stack i.queued,
.status-stack i.running {
background: var(--cyan);
}
.status-stack i.succeeded {
background: var(--green);
}
.status-stack i.failed {
background: var(--red);
}
.job-tick { .job-tick {
background: var(--subtle); background: var(--subtle);
border-radius: 5px; border-radius: 5px;