Skip to content

Commit 0b780e7

Browse files
committed
Redesign queue details with actions and history
Add status badges for paused, partial, and terminating states. Include pause/resume, stop, and edit buttons in the header for quick access. Display queue execution history in a chart alongside stats.
1 parent 579877e commit 0b780e7

9 files changed

Lines changed: 1091 additions & 448 deletions

File tree

assets/js/app.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import JobHistoryChart from "./hooks/job_history_chart";
99
import JobsChart from "./hooks/jobs_chart";
1010
import HistoryBack from "./hooks/history_back";
1111
import Instantiator from "./hooks/instantiator";
12+
import QueueDetailChart from "./hooks/queue_detail_chart";
1213
import Refresher from "./hooks/refresher";
1314
import Relativize from "./hooks/relativize";
1415
import Shortcuts from "./hooks/shortcuts";
@@ -27,6 +28,7 @@ const hooks = {
2728
JobsChart,
2829
HistoryBack,
2930
Instantiator,
31+
QueueDetailChart,
3032
QueueSparkline,
3133
Refresher,
3234
Relativize,
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import {
2+
BarController,
3+
BarElement,
4+
CategoryScale,
5+
Chart,
6+
LinearScale,
7+
Tooltip,
8+
} from "chart.js"
9+
10+
import { CYAN, GRAY } from "../lib/colors"
11+
12+
Chart.register(BarController, BarElement, CategoryScale, LinearScale, Tooltip)
13+
14+
const formatTime = (timestamp) => {
15+
const date = new Date(timestamp)
16+
return date.toLocaleString("en-US", {
17+
hour: "numeric",
18+
minute: "2-digit",
19+
hour12: true,
20+
})
21+
}
22+
23+
const QueueDetailChart = {
24+
mounted() {
25+
const canvas = document.createElement("canvas")
26+
this.el.appendChild(canvas)
27+
28+
this.chart = new Chart(canvas, {
29+
type: "bar",
30+
data: {
31+
labels: [],
32+
datasets: [
33+
{
34+
data: [],
35+
backgroundColor: CYAN,
36+
borderRadius: 2,
37+
barPercentage: 0.9,
38+
categoryPercentage: 0.9,
39+
},
40+
],
41+
},
42+
options: {
43+
animation: false,
44+
maintainAspectRatio: false,
45+
responsive: true,
46+
plugins: {
47+
legend: { display: false },
48+
tooltip: {
49+
callbacks: {
50+
title: (context) => formatTime(parseInt(context[0].label, 10)),
51+
label: (context) => {
52+
const count = context.raw
53+
const label = count === 1 ? "job" : "jobs"
54+
return `${count} ${label}`
55+
},
56+
},
57+
},
58+
},
59+
scales: {
60+
x: {
61+
display: true,
62+
grid: { display: false },
63+
ticks: {
64+
maxRotation: 0,
65+
autoSkip: true,
66+
maxTicksLimit: 8,
67+
callback: function (value) {
68+
const timestamp = parseInt(this.getLabelForValue(value), 10)
69+
const date = new Date(timestamp)
70+
return date.toLocaleTimeString("en-US", {
71+
hour: "numeric",
72+
minute: "2-digit",
73+
hour12: true,
74+
})
75+
},
76+
},
77+
},
78+
y: {
79+
display: true,
80+
beginAtZero: true,
81+
ticks: {
82+
stepSize: 1,
83+
callback: (value) => (Number.isInteger(value) ? value : null),
84+
},
85+
},
86+
},
87+
},
88+
})
89+
90+
this.handleEvent("queue-history", ({ history }) => {
91+
this.data = history
92+
93+
this.chart.data.labels = history.map((point) => point.timestamp)
94+
this.chart.data.datasets[0].data = history.map((point) => point.count)
95+
this.chart.update()
96+
})
97+
},
98+
99+
destroyed() {
100+
if (this.chart) {
101+
this.chart.destroy()
102+
}
103+
},
104+
}
105+
106+
export default QueueDetailChart

lib/oban/web/components/core.ex

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,11 +246,12 @@ defmodule Oban.Web.Components.Core do
246246
A status badge with icon that expands to show label on hover.
247247
"""
248248
attr :icon, :string, required: true
249+
attr :id, :string, default: nil
249250
attr :label, :string, required: true
250251

251252
def status_badge(assigns) do
252253
~H"""
253-
<div class="group flex items-center cursor-default select-none">
254+
<div id={@id} class="group flex items-center cursor-default select-none">
254255
<span class="inline-flex items-center justify-center h-9 pl-2.5 pr-2.5 group-hover:pr-4 rounded-full text-sm font-medium bg-violet-100 text-violet-700 dark:bg-violet-700/70 dark:text-violet-200 transition-all duration-200">
255256
<.badge_icon name={@icon} />
256257
<span class="max-w-0 overflow-hidden group-hover:max-w-24 group-hover:ml-1.5 transition-all duration-200 whitespace-nowrap">
@@ -287,6 +288,15 @@ defmodule Oban.Web.Components.Core do
287288
defp badge_icon(%{name: "link"} = assigns),
288289
do: ~H[<Icons.link class="h-4 w-4 shrink-0" />]
289290

291+
defp badge_icon(%{name: "power"} = assigns),
292+
do: ~H[<Icons.power class="h-4 w-4 shrink-0" />]
293+
294+
defp badge_icon(%{name: "pause_circle"} = assigns),
295+
do: ~H[<Icons.pause_circle class="h-4 w-4 shrink-0" />]
296+
297+
defp badge_icon(%{name: "play_pause_circle"} = assigns),
298+
do: ~H[<Icons.play_pause_circle class="h-4 w-4 shrink-0" />]
299+
290300
@doc """
291301
An icon-only button that expands to show label on hover. Supports disabled state.
292302
"""
@@ -369,4 +379,105 @@ defmodule Oban.Web.Components.Core do
369379
defp button_icon(%{name: "play_circle"} = assigns), do: ~H[<Icons.play_circle class={@class} />]
370380
defp button_icon(%{name: "trash"} = assigns), do: ~H[<Icons.trash class={@class} />]
371381
defp button_icon(%{name: "x_circle"} = assigns), do: ~H[<Icons.x_circle class={@class} />]
382+
383+
# Sparkline
384+
385+
attr :id, :string, required: true
386+
attr :history, :map, required: true
387+
attr :max_value, :integer, default: nil
388+
attr :bar_width, :integer, default: 4
389+
attr :count, :integer, default: 60
390+
attr :gap, :integer, default: 1
391+
attr :height, :integer, default: 16
392+
attr :class, :string, default: nil
393+
394+
def sparkline(assigns) do
395+
history = assigns.history
396+
count = assigns.count
397+
bar_width = assigns.bar_width
398+
gap = assigns.gap
399+
height = assigns.height
400+
max_index = count - 1
401+
402+
max_value =
403+
if assigns.max_value do
404+
max(assigns.max_value, 1)
405+
else
406+
history
407+
|> Map.values()
408+
|> Enum.reduce(1, fn %{count: c}, acc -> max(c, acc) end)
409+
end
410+
411+
now = System.system_time(:millisecond)
412+
413+
{bars, tooltip_data} =
414+
for slot <- 0..max_index, reduce: {[], []} do
415+
{bars_acc, tool_acc} ->
416+
index = max_index - slot
417+
timestamp = now - index * 5 * 1000
418+
x = slot * (bar_width + gap)
419+
420+
case Map.get(history, index) do
421+
%{count: c} ->
422+
bar_height = min(c / max_value, 1.0) * height
423+
bar = %{x: x, height: max(bar_height, 0)}
424+
tooltip = %{timestamp: timestamp, count: c}
425+
426+
{[bar | bars_acc], [tooltip | tool_acc]}
427+
428+
nil ->
429+
tooltip = %{timestamp: timestamp, count: 0}
430+
431+
{bars_acc, [tooltip | tool_acc]}
432+
end
433+
end
434+
435+
bars = Enum.reverse(bars)
436+
tooltip_data = Enum.reverse(tooltip_data)
437+
438+
placeholders =
439+
for slot <- 0..max_index do
440+
%{x: slot * (bar_width + gap)}
441+
end
442+
443+
width = count * (bar_width + gap)
444+
445+
assigns =
446+
assigns
447+
|> assign(bars: bars, placeholders: placeholders, width: width)
448+
|> assign(tooltip_data: tooltip_data)
449+
450+
~H"""
451+
<svg
452+
id={@id}
453+
width={@width}
454+
height={@height}
455+
viewBox={"0 0 #{@width} #{@height}"}
456+
class={["flex-shrink-0", @class]}
457+
phx-hook="QueueSparkline"
458+
data-tooltip={Oban.JSON.encode!(@tooltip_data)}
459+
data-bar-width={@bar_width}
460+
>
461+
<rect
462+
:for={placeholder <- @placeholders}
463+
x={placeholder.x}
464+
y={@height - 2}
465+
width={@bar_width}
466+
height="2"
467+
fill="#e5e7eb"
468+
class="dark:fill-gray-700"
469+
rx="0.5"
470+
/>
471+
<rect
472+
:for={bar <- @bars}
473+
x={bar.x}
474+
y={@height - bar.height}
475+
width={@bar_width}
476+
height={bar.height}
477+
fill="#22d3ee"
478+
rx="1"
479+
/>
480+
</svg>
481+
"""
482+
end
372483
end

0 commit comments

Comments
 (0)