feat(frontend): polish dashboard chart panels
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user