solid_stack_web 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +4 -0
- data/app/assets/stylesheets/solid_stack_web/_01_base.css +3 -2
- data/app/assets/stylesheets/solid_stack_web/_02_layout.css +28 -0
- data/app/assets/stylesheets/solid_stack_web/_04_table.css +13 -1
- data/app/assets/stylesheets/solid_stack_web/_10_responsive.css +54 -0
- data/app/assets/stylesheets/solid_stack_web/_12_dark_mode.css +34 -0
- data/app/controllers/solid_stack_web/failed_jobs/selections_controller.rb +4 -3
- data/app/controllers/solid_stack_web/failed_jobs_controller.rb +2 -1
- data/app/controllers/solid_stack_web/jobs/selections_controller.rb +2 -2
- data/app/controllers/solid_stack_web/jobs_controller.rb +4 -2
- data/app/helpers/solid_stack_web/application_helper.rb +13 -0
- data/app/javascript/solid_stack_web/application.js +5 -1
- data/app/javascript/solid_stack_web/theme_controller.js +26 -0
- data/app/javascript/solid_stack_web/timestamp_controller.js +59 -0
- data/app/views/layouts/solid_stack_web/application.html.erb +8 -4
- data/app/views/solid_stack_web/cable/index.html.erb +8 -2
- data/app/views/solid_stack_web/cable_messages/index.html.erb +9 -3
- data/app/views/solid_stack_web/cache_entries/index.html.erb +8 -2
- data/app/views/solid_stack_web/cache_entries/show.html.erb +1 -1
- data/app/views/solid_stack_web/dashboard/index.html.erb +2 -2
- data/app/views/solid_stack_web/failed_jobs/destroy.turbo_stream.erb +3 -0
- data/app/views/solid_stack_web/failed_jobs/index.html.erb +3 -2
- data/app/views/solid_stack_web/failed_jobs/show.html.erb +1 -1
- data/app/views/solid_stack_web/history/index.html.erb +8 -2
- data/app/views/solid_stack_web/jobs/_empty.html.erb +19 -2
- data/app/views/solid_stack_web/jobs/destroy.turbo_stream.erb +3 -0
- data/app/views/solid_stack_web/jobs/index.html.erb +2 -2
- data/app/views/solid_stack_web/jobs/show.html.erb +4 -4
- data/app/views/solid_stack_web/processes/index.html.erb +3 -2
- data/app/views/solid_stack_web/queues/index.html.erb +2 -1
- data/app/views/solid_stack_web/queues/show.html.erb +1 -1
- data/app/views/solid_stack_web/recurring_tasks/index.html.erb +5 -5
- data/app/views/solid_stack_web/scheduled_jobs/update.turbo_stream.erb +1 -1
- data/app/views/solid_stack_web/shared/_flash.html.erb +1 -0
- data/app/views/solid_stack_web/stats/index.html.erb +2 -1
- data/config/importmap.rb +2 -0
- data/lib/solid_stack_web/version.rb +1 -1
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8466d9dd8464ac19cb839fa2a2396b28472564503cf07779368ba9fb784e1d5d
|
|
4
|
+
data.tar.gz: b4b3720aa4fb16a595d1670cc6f0f59c99da6ab19fab81aa52dc52119bca6edf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: edb56bb1198b125201acc8bffb09a364c487890eab1eb6f18a6fc5759a3786e8748fcda239a83ef6e232064d538dc0d439f7c07775d022e42384de665b793644
|
|
7
|
+
data.tar.gz: e0f469d95f9d9fb03e1654110f7e9751859f735aa3402407f022eb75d2c4c6ad1546d398692324a6bb9c079b6f767fc901aa1e714f0d9e1c27a22b613ca2641b
|
data/README.md
CHANGED
|
@@ -48,6 +48,10 @@ The dashboard will be available at `/solid_stack` (or whatever path you choose).
|
|
|
48
48
|
- **Job history view** — paginated list of all finished jobs with class name, queue, duration, and finished-at time; filterable by queue (click a badge), class substring, and time period; CSV export respects active filters
|
|
49
49
|
- **Auto-refresh** — dashboard, jobs, processes, and history views poll automatically; pauses when the tab is hidden or a checkbox is checked; intervals configurable via `dashboard_refresh_interval` and `default_refresh_interval`
|
|
50
50
|
- **Turbo Stream** job discard — removes the row inline without a full page reload
|
|
51
|
+
- **Dark mode** — toggle button in the header switches between light and dark palettes; preference persisted in `localStorage`; respects `prefers-color-scheme` on first visit
|
|
52
|
+
- **Responsive layout** — stats cards, tables, and two-column grids adapt to narrow viewports; tables scroll horizontally rather than overflow; split page headers stack on small screens
|
|
53
|
+
- **Empty-state improvements** — all list views show a contextual title and an actionable hint; search empty states include a "Clear search" link; filters-active history view offers "Clear filters"; processes and recurring tasks explain the next step
|
|
54
|
+
- **Inline notifications** — bulk and single-job actions surface a flash notice; Turbo Stream discard responses inject the message inline without a full page reload; bulk actions report the affected count ("3 jobs discarded")
|
|
51
55
|
|
|
52
56
|
### Configuration
|
|
53
57
|
|
|
@@ -17,6 +17,27 @@
|
|
|
17
17
|
height: 52px;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
.sqw-header__inner .sqw-nav { flex: 1; }
|
|
21
|
+
|
|
22
|
+
.sqw-theme-toggle {
|
|
23
|
+
display: flex;
|
|
24
|
+
align-items: center;
|
|
25
|
+
justify-content: center;
|
|
26
|
+
width: 32px;
|
|
27
|
+
height: 32px;
|
|
28
|
+
padding: 0;
|
|
29
|
+
background: none;
|
|
30
|
+
border: 1px solid var(--border);
|
|
31
|
+
border-radius: var(--radius);
|
|
32
|
+
cursor: pointer;
|
|
33
|
+
font-size: 16px;
|
|
34
|
+
color: var(--text);
|
|
35
|
+
line-height: 1;
|
|
36
|
+
flex-shrink: 0;
|
|
37
|
+
transition: background 0.1s, border-color 0.1s;
|
|
38
|
+
}
|
|
39
|
+
.sqw-theme-toggle:hover { background: var(--bg); }
|
|
40
|
+
|
|
20
41
|
.sqw-header__logo {
|
|
21
42
|
font-weight: 700;
|
|
22
43
|
font-size: 16px;
|
|
@@ -71,6 +92,13 @@
|
|
|
71
92
|
}
|
|
72
93
|
|
|
73
94
|
.sqw-page-header { margin-bottom: 1.25rem; }
|
|
95
|
+
.sqw-page-header--split {
|
|
96
|
+
display: flex;
|
|
97
|
+
align-items: center;
|
|
98
|
+
justify-content: space-between;
|
|
99
|
+
gap: 0.75rem;
|
|
100
|
+
flex-wrap: wrap;
|
|
101
|
+
}
|
|
74
102
|
.sqw-page-title { font-size: 20px; font-weight: 600; }
|
|
75
103
|
.sqw-page-title-row { display: flex; align-items: center; gap: 0.5rem; }
|
|
76
104
|
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
.sqw-table tbody tr:last-child td { border-bottom: none; }
|
|
28
|
-
.sqw-table tbody tr:hover { background:
|
|
28
|
+
.sqw-table tbody tr:hover { background: var(--surface-hover); }
|
|
29
29
|
|
|
30
30
|
.sqw-actions { text-align: right; white-space: nowrap; }
|
|
31
31
|
.sqw-actions form { display: inline; }
|
|
@@ -42,3 +42,15 @@
|
|
|
42
42
|
text-align: center;
|
|
43
43
|
color: var(--muted);
|
|
44
44
|
}
|
|
45
|
+
|
|
46
|
+
.sqw-empty__title {
|
|
47
|
+
font-size: 15px;
|
|
48
|
+
font-weight: 600;
|
|
49
|
+
color: var(--text);
|
|
50
|
+
margin-bottom: 0.375rem;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.sqw-empty__hint {
|
|
54
|
+
font-size: 13px;
|
|
55
|
+
margin-top: 0.375rem;
|
|
56
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
@media (max-width: 768px) {
|
|
2
|
+
.sqw-size-grid { grid-template-columns: 1fr; }
|
|
3
|
+
.sqw-timeline-grid:not(.sqw-timeline-grid--single) { grid-template-columns: 1fr; }
|
|
4
|
+
.sqw-timeline-chart + .sqw-timeline-chart { border-left: none; border-top: 1px solid var(--border); }
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
@media (max-width: 640px) {
|
|
8
|
+
.sqw-main {
|
|
9
|
+
padding: 1rem;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.sqw-page-header--split {
|
|
13
|
+
flex-direction: column;
|
|
14
|
+
align-items: flex-start;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.sqw-table {
|
|
18
|
+
display: block;
|
|
19
|
+
overflow-x: auto;
|
|
20
|
+
border-radius: 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.sqw-stats-grid {
|
|
24
|
+
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.sqw-gem-grid {
|
|
28
|
+
grid-template-columns: 1fr;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.sqw-truncate {
|
|
32
|
+
max-width: 160px;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.sqw-period-filter {
|
|
36
|
+
margin-left: 0;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
@media (max-width: 576px) {
|
|
41
|
+
.sqw-header__inner {
|
|
42
|
+
padding: 0 1rem;
|
|
43
|
+
gap: 1rem;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.sqw-subnav__inner {
|
|
47
|
+
padding: 0 1rem;
|
|
48
|
+
height: auto;
|
|
49
|
+
min-height: 36px;
|
|
50
|
+
flex-wrap: wrap;
|
|
51
|
+
padding-top: 0.25rem;
|
|
52
|
+
padding-bottom: 0.25rem;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[data-theme="dark"] {
|
|
2
|
+
--bg: #0d1117;
|
|
3
|
+
--surface: #161b22;
|
|
4
|
+
--border: #30363d;
|
|
5
|
+
--text: #e6edf3;
|
|
6
|
+
--muted: #8b949e;
|
|
7
|
+
--primary: #58a6ff;
|
|
8
|
+
--danger: #f85149;
|
|
9
|
+
--warning: #d29922;
|
|
10
|
+
--success: #3fb950;
|
|
11
|
+
--info: #39c5cf;
|
|
12
|
+
--purple: #bc8cff;
|
|
13
|
+
--shadow: 0 1px 3px rgba(0,0,0,.4);
|
|
14
|
+
--surface-hover: #1c2128;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
[data-theme="dark"] .sqw-badge--ready { background: rgba(63,185,80,.15); color: var(--success); }
|
|
18
|
+
[data-theme="dark"] .sqw-badge--scheduled { background: rgba(57,197,207,.15); color: var(--info); }
|
|
19
|
+
[data-theme="dark"] .sqw-badge--claimed { background: rgba(88,166,255,.15); color: var(--primary); }
|
|
20
|
+
[data-theme="dark"] .sqw-badge--blocked { background: rgba(210,153,34,.15); color: var(--warning); }
|
|
21
|
+
[data-theme="dark"] .sqw-badge--failed { background: rgba(248,81,73,.15); color: var(--danger); }
|
|
22
|
+
[data-theme="dark"] .sqw-badge--paused { background: rgba(139,148,158,.15); color: var(--muted); }
|
|
23
|
+
[data-theme="dark"] .sqw-badge--queue { background: rgba(139,148,158,.15); color: var(--muted); }
|
|
24
|
+
[data-theme="dark"] .sqw-badge--worker { background: rgba(88,166,255,.15); color: var(--primary); }
|
|
25
|
+
[data-theme="dark"] .sqw-badge--supervisor { background: rgba(63,185,80,.15); color: var(--success); }
|
|
26
|
+
[data-theme="dark"] .sqw-badge--dispatcher { background: rgba(210,153,34,.15); color: var(--warning); }
|
|
27
|
+
|
|
28
|
+
[data-theme="dark"] .sqw-flash--notice { background: #1b3a2b; color: #3fb950; border-color: #2d6a4f; }
|
|
29
|
+
[data-theme="dark"] .sqw-flash--alert { background: #3d1118; color: #f85149; border-color: #6a2030; }
|
|
30
|
+
|
|
31
|
+
[data-theme="dark"] .sqw-theme-toggle {
|
|
32
|
+
border-color: var(--border);
|
|
33
|
+
color: var(--text);
|
|
34
|
+
}
|
|
@@ -4,16 +4,17 @@ module SolidStackWeb
|
|
|
4
4
|
before_action :set_ids
|
|
5
5
|
|
|
6
6
|
def create
|
|
7
|
+
count = @ids.size
|
|
7
8
|
SolidQueue::FailedExecution.where(id: @ids).each(&:retry)
|
|
8
|
-
redirect_to failed_jobs_path
|
|
9
|
+
redirect_to failed_jobs_path, notice: "#{count} #{count == 1 ? "job" : "jobs"} retried."
|
|
9
10
|
rescue => e
|
|
10
11
|
redirect_to failed_jobs_path, alert: "Could not retry jobs: #{e.message}"
|
|
11
12
|
end
|
|
12
13
|
|
|
13
14
|
def destroy
|
|
14
15
|
job_ids = SolidQueue::FailedExecution.where(id: @ids).pluck(:job_id)
|
|
15
|
-
SolidQueue::Job.where(id: job_ids).destroy_all
|
|
16
|
-
redirect_to failed_jobs_path
|
|
16
|
+
count = SolidQueue::Job.where(id: job_ids).destroy_all.size
|
|
17
|
+
redirect_to failed_jobs_path, notice: "#{count} #{count == 1 ? "job" : "jobs"} discarded."
|
|
17
18
|
rescue => e
|
|
18
19
|
redirect_to failed_jobs_path, alert: "Could not discard jobs: #{e.message}"
|
|
19
20
|
end
|
|
@@ -25,6 +25,7 @@ module SolidStackWeb
|
|
|
25
25
|
@execution = ::SolidQueue::FailedExecution.find(params[:id])
|
|
26
26
|
@execution.job.destroy!
|
|
27
27
|
@executions_remain = ::SolidQueue::FailedExecution.exists?
|
|
28
|
+
@notice = "Job discarded."
|
|
28
29
|
|
|
29
30
|
respond_to do |format|
|
|
30
31
|
format.html { redirect_to failed_jobs_path }
|
|
@@ -35,7 +36,7 @@ module SolidStackWeb
|
|
|
35
36
|
def retry
|
|
36
37
|
execution = ::SolidQueue::FailedExecution.find(params[:id])
|
|
37
38
|
execution.retry
|
|
38
|
-
redirect_to failed_jobs_path
|
|
39
|
+
redirect_to failed_jobs_path, notice: "Job retried."
|
|
39
40
|
end
|
|
40
41
|
|
|
41
42
|
private
|
|
@@ -7,7 +7,7 @@ module SolidStackWeb
|
|
|
7
7
|
|
|
8
8
|
ids = Array(params[:job_ids]).map(&:to_i).reject(&:zero?)
|
|
9
9
|
job_ids = Job::EXECUTION_MODELS[status].where(id: ids).pluck(:job_id)
|
|
10
|
-
SolidQueue::Job.where(id: job_ids).destroy_all
|
|
10
|
+
count = SolidQueue::Job.where(id: job_ids).destroy_all.size
|
|
11
11
|
|
|
12
12
|
redirect_to jobs_path(
|
|
13
13
|
status: status,
|
|
@@ -15,7 +15,7 @@ module SolidStackWeb
|
|
|
15
15
|
queue: params[:queue].presence,
|
|
16
16
|
period: params[:period].presence_in(PERIOD_DURATIONS.keys),
|
|
17
17
|
priority: params[:priority].presence
|
|
18
|
-
)
|
|
18
|
+
), notice: "#{count} #{count == 1 ? "job" : "jobs"} discarded."
|
|
19
19
|
rescue ArgumentError => e
|
|
20
20
|
redirect_to jobs_path(status: params[:status]), alert: e.message
|
|
21
21
|
end
|
|
@@ -30,6 +30,7 @@ module SolidStackWeb
|
|
|
30
30
|
@execution = Job::EXECUTION_MODELS[@status].find(params[:id])
|
|
31
31
|
@execution.job.destroy!
|
|
32
32
|
@executions_remain = Job::EXECUTION_MODELS[@status].exists?
|
|
33
|
+
@notice = "Job discarded."
|
|
33
34
|
|
|
34
35
|
respond_to do |format|
|
|
35
36
|
format.html { redirect_to jobs_path(status: @status, q: @search, queue: @queue, period: @period, priority: @priority) }
|
|
@@ -37,8 +38,9 @@ module SolidStackWeb
|
|
|
37
38
|
end
|
|
38
39
|
else
|
|
39
40
|
job_ids = filtered_scope.pluck(:job_id)
|
|
40
|
-
SolidQueue::Job.where(id: job_ids).destroy_all
|
|
41
|
-
redirect_to jobs_path(status: @status, q: @search, queue: @queue, period: @period, priority: @priority)
|
|
41
|
+
count = SolidQueue::Job.where(id: job_ids).destroy_all.size
|
|
42
|
+
redirect_to jobs_path(status: @status, q: @search, queue: @queue, period: @period, priority: @priority),
|
|
43
|
+
notice: "#{count} #{count == 1 ? "job" : "jobs"} discarded."
|
|
42
44
|
end
|
|
43
45
|
end
|
|
44
46
|
|
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
module SolidStackWeb
|
|
2
2
|
module ApplicationHelper
|
|
3
|
+
def local_time(time, format: :short, placeholder: "—")
|
|
4
|
+
return placeholder if time.nil?
|
|
5
|
+
|
|
6
|
+
iso = time.utc.iso8601
|
|
7
|
+
fallback = case format
|
|
8
|
+
when :long then time.utc.strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
9
|
+
when :relative then "#{time_ago_in_words(time)} ago"
|
|
10
|
+
else time.utc.strftime("%b %-d %H:%M UTC")
|
|
11
|
+
end
|
|
12
|
+
tag.time(fallback, datetime: iso,
|
|
13
|
+
data: { controller: "timestamp", timestamp_format_value: format })
|
|
14
|
+
end
|
|
15
|
+
|
|
3
16
|
def format_cache_value(raw)
|
|
4
17
|
str = raw.to_s.encode("UTF-8", invalid: :replace, undef: :replace, replace: "?")
|
|
5
18
|
parsed = JSON.parse(str)
|
|
@@ -4,9 +4,13 @@ import RefreshController from "solid_stack_web/refresh_controller"
|
|
|
4
4
|
import SearchController from "solid_stack_web/search_controller"
|
|
5
5
|
import SelectionController from "solid_stack_web/selection_controller"
|
|
6
6
|
import SparklineTooltipController from "solid_stack_web/sparkline_tooltip_controller"
|
|
7
|
+
import ThemeController from "solid_stack_web/theme_controller"
|
|
8
|
+
import TimestampController from "solid_stack_web/timestamp_controller"
|
|
7
9
|
|
|
8
10
|
const application = Application.start()
|
|
9
11
|
application.register("refresh", RefreshController)
|
|
10
12
|
application.register("search", SearchController)
|
|
11
13
|
application.register("selection", SelectionController)
|
|
12
|
-
application.register("sparkline-tooltip", SparklineTooltipController)
|
|
14
|
+
application.register("sparkline-tooltip", SparklineTooltipController)
|
|
15
|
+
application.register("theme", ThemeController)
|
|
16
|
+
application.register("timestamp", TimestampController)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static targets = ["toggle"]
|
|
5
|
+
|
|
6
|
+
connect() {
|
|
7
|
+
const saved = localStorage.getItem("sqw-theme")
|
|
8
|
+
const preferred = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
|
|
9
|
+
this.apply(saved || preferred)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
toggle() {
|
|
13
|
+
const current = document.documentElement.getAttribute("data-theme") || "light"
|
|
14
|
+
const next = current === "dark" ? "light" : "dark"
|
|
15
|
+
localStorage.setItem("sqw-theme", next)
|
|
16
|
+
this.apply(next)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
apply(theme) {
|
|
20
|
+
document.documentElement.setAttribute("data-theme", theme)
|
|
21
|
+
if (this.hasToggleTarget) {
|
|
22
|
+
this.toggleTarget.textContent = theme === "dark" ? "☀" : "☽"
|
|
23
|
+
this.toggleTarget.setAttribute("aria-label", theme === "dark" ? "Switch to light mode" : "Switch to dark mode")
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static values = { format: { type: String, default: "short" } }
|
|
5
|
+
|
|
6
|
+
connect() {
|
|
7
|
+
const dt = this.element.getAttribute("datetime")
|
|
8
|
+
if (!dt) return
|
|
9
|
+
const date = new Date(dt)
|
|
10
|
+
if (isNaN(date.getTime())) return
|
|
11
|
+
this.element.textContent = this.formatDate(date)
|
|
12
|
+
if (this.formatValue === "relative") {
|
|
13
|
+
this.element.title = this.fullFormat(date)
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
formatDate(date) {
|
|
18
|
+
switch (this.formatValue) {
|
|
19
|
+
case "long":
|
|
20
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
21
|
+
year: "numeric", month: "2-digit", day: "2-digit",
|
|
22
|
+
hour: "2-digit", minute: "2-digit", second: "2-digit"
|
|
23
|
+
}).format(date)
|
|
24
|
+
case "relative":
|
|
25
|
+
return this.relativeFormat(date)
|
|
26
|
+
default:
|
|
27
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
28
|
+
month: "short", day: "numeric",
|
|
29
|
+
hour: "2-digit", minute: "2-digit"
|
|
30
|
+
}).format(date)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
fullFormat(date) {
|
|
35
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
36
|
+
year: "numeric", month: "short", day: "numeric",
|
|
37
|
+
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
|
38
|
+
timeZoneName: "short"
|
|
39
|
+
}).format(date)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
relativeFormat(date) {
|
|
43
|
+
const diff = date.getTime() - Date.now()
|
|
44
|
+
const absDiff = Math.abs(diff)
|
|
45
|
+
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" })
|
|
46
|
+
|
|
47
|
+
const seconds = Math.floor(absDiff / 1000)
|
|
48
|
+
if (seconds < 60) return rtf.format(diff < 0 ? -seconds : seconds, "second")
|
|
49
|
+
|
|
50
|
+
const minutes = Math.floor(absDiff / 60000)
|
|
51
|
+
if (minutes < 60) return rtf.format(diff < 0 ? -minutes : minutes, "minute")
|
|
52
|
+
|
|
53
|
+
const hours = Math.floor(absDiff / 3600000)
|
|
54
|
+
if (hours < 24) return rtf.format(diff < 0 ? -hours : hours, "hour")
|
|
55
|
+
|
|
56
|
+
const days = Math.floor(absDiff / 86400000)
|
|
57
|
+
return rtf.format(diff < 0 ? -days : days, "day")
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
<%= inline_styles %>
|
|
11
11
|
<%= javascript_importmap_tags "solid_stack_web" %>
|
|
12
12
|
</head>
|
|
13
|
-
<body>
|
|
13
|
+
<body data-controller="theme">
|
|
14
14
|
<header class="sqw-header">
|
|
15
15
|
<div class="sqw-header__inner">
|
|
16
16
|
<%= link_to "Solid Stack", root_path, class: "sqw-header__logo" %>
|
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
<%= link_to "Cable", cable_path,
|
|
23
23
|
class: "sqw-nav__link#{" sqw-nav__link--active" if current_section == :cable}" %>
|
|
24
24
|
</nav>
|
|
25
|
+
<button class="sqw-theme-toggle" aria-label="Switch to dark mode"
|
|
26
|
+
data-theme-target="toggle" data-action="theme#toggle">☽</button>
|
|
25
27
|
</div>
|
|
26
28
|
</header>
|
|
27
29
|
|
|
@@ -67,9 +69,11 @@
|
|
|
67
69
|
<% end %>
|
|
68
70
|
|
|
69
71
|
<main class="sqw-main">
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
72
|
+
<div id="sqw-flash-container">
|
|
73
|
+
<% flash.each do |type, message| %>
|
|
74
|
+
<div class="sqw-flash sqw-flash--<%= type %>"><%= message %></div>
|
|
75
|
+
<% end %>
|
|
76
|
+
</div>
|
|
73
77
|
<%= yield %>
|
|
74
78
|
</main>
|
|
75
79
|
</body>
|
|
@@ -62,13 +62,19 @@
|
|
|
62
62
|
<%= link_to channel[:channel], cable_channel_messages_path(channel[:channel_hash]), class: "sqw-link" %>
|
|
63
63
|
</td>
|
|
64
64
|
<td><%= channel[:message_count] %></td>
|
|
65
|
-
<td class="sqw-muted"><%= channel[:last_message_at]
|
|
65
|
+
<td class="sqw-muted"><%= local_time(channel[:last_message_at]) %></td>
|
|
66
66
|
</tr>
|
|
67
67
|
<% end %>
|
|
68
68
|
</tbody>
|
|
69
69
|
</table>
|
|
70
70
|
<% else %>
|
|
71
71
|
<div class="sqw-empty">
|
|
72
|
-
|
|
72
|
+
<% if @search.present? %>
|
|
73
|
+
<p class="sqw-empty__title">No channels matching “<%= @search %>”</p>
|
|
74
|
+
<p class="sqw-empty__hint"><%= link_to "Clear search", cable_path %></p>
|
|
75
|
+
<% else %>
|
|
76
|
+
<p class="sqw-empty__title">No cable messages</p>
|
|
77
|
+
<p class="sqw-empty__hint">Messages will appear here once clients connect and broadcast over Action Cable.</p>
|
|
78
|
+
<% end %>
|
|
73
79
|
</div>
|
|
74
80
|
<% end %>
|
|
@@ -35,8 +35,8 @@
|
|
|
35
35
|
<td class="sqw-monospace sqw-truncate" title="<%= message.payload %>">
|
|
36
36
|
<%= truncate(message.payload.to_s, length: 120) %>
|
|
37
37
|
</td>
|
|
38
|
-
<td class="sqw-muted"
|
|
39
|
-
<%=
|
|
38
|
+
<td class="sqw-muted">
|
|
39
|
+
<%= local_time(message.created_at, format: :relative) %>
|
|
40
40
|
</td>
|
|
41
41
|
</tr>
|
|
42
42
|
<% end %>
|
|
@@ -45,6 +45,12 @@
|
|
|
45
45
|
<%== @pagy.series_nav if @pagy.pages > 1 %>
|
|
46
46
|
<% else %>
|
|
47
47
|
<div class="sqw-empty">
|
|
48
|
-
|
|
48
|
+
<% if @search.present? %>
|
|
49
|
+
<p class="sqw-empty__title">No messages matching “<%= @search %>”</p>
|
|
50
|
+
<p class="sqw-empty__hint"><%= link_to "Clear search", cable_channel_messages_path(params[:channel_hash]) %></p>
|
|
51
|
+
<% else %>
|
|
52
|
+
<p class="sqw-empty__title">No messages for this channel</p>
|
|
53
|
+
<p class="sqw-empty__hint">Messages may have been purged or the channel has gone quiet. <%= link_to "Back to channels →", cable_path %></p>
|
|
54
|
+
<% end %>
|
|
49
55
|
</div>
|
|
50
56
|
<% end %>
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
<%= link_to entry.key, cache_entry_path(entry), class: "sqw-link" %>
|
|
47
47
|
</td>
|
|
48
48
|
<td><%= number_to_human_size(entry.byte_size) %></td>
|
|
49
|
-
<td class="sqw-muted"><%= entry.created_at
|
|
49
|
+
<td class="sqw-muted"><%= local_time(entry.created_at) %></td>
|
|
50
50
|
<td class="sqw-actions">
|
|
51
51
|
<%= button_to "Delete",
|
|
52
52
|
cache_entry_path(entry, q: @search, column: @sort["column"], direction: @sort["direction"]),
|
|
@@ -61,6 +61,12 @@
|
|
|
61
61
|
<%== @pagy.series_nav if @pagy.pages > 1 %>
|
|
62
62
|
<% else %>
|
|
63
63
|
<div class="sqw-empty">
|
|
64
|
-
|
|
64
|
+
<% if @search.present? %>
|
|
65
|
+
<p class="sqw-empty__title">No entries matching “<%= @search %>”</p>
|
|
66
|
+
<p class="sqw-empty__hint"><%= link_to "Clear search", cache_entries_path %></p>
|
|
67
|
+
<% else %>
|
|
68
|
+
<p class="sqw-empty__title">No cache entries</p>
|
|
69
|
+
<p class="sqw-empty__hint">Entries will appear here once your application writes to the cache.</p>
|
|
70
|
+
<% end %>
|
|
65
71
|
</div>
|
|
66
72
|
<% end %>
|
|
@@ -87,7 +87,7 @@
|
|
|
87
87
|
<% if @cache_stats[:oldest_entry] %>
|
|
88
88
|
<div class="sqw-inline-stat sqw-inline-stat--cache">
|
|
89
89
|
<span class="sqw-inline-stat__label">Oldest</span>
|
|
90
|
-
<span class="sqw-inline-stat__value sqw-inline-stat__value--sm"
|
|
90
|
+
<span class="sqw-inline-stat__value sqw-inline-stat__value--sm"><%= local_time(@cache_stats[:oldest_entry], format: :relative) %></span>
|
|
91
91
|
</div>
|
|
92
92
|
<% end %>
|
|
93
93
|
</div>
|
|
@@ -114,7 +114,7 @@
|
|
|
114
114
|
<% if @cable_stats[:oldest_message] %>
|
|
115
115
|
<div class="sqw-inline-stat sqw-inline-stat--cable">
|
|
116
116
|
<span class="sqw-inline-stat__label">Oldest</span>
|
|
117
|
-
<span class="sqw-inline-stat__value sqw-inline-stat__value--sm"
|
|
117
|
+
<span class="sqw-inline-stat__value sqw-inline-stat__value--sm"><%= local_time(@cable_stats[:oldest_message], format: :relative) %></span>
|
|
118
118
|
</div>
|
|
119
119
|
<% end %>
|
|
120
120
|
</div>
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
<td class="sqw-monospace"><%= link_to execution.job.class_name, failed_job_path(execution) %></td>
|
|
51
51
|
<td><span class="sqw-badge sqw-badge--queue"><%= execution.job.queue_name %></span></td>
|
|
52
52
|
<td class="sqw-muted sqw-truncate" title="<%= execution.exception_class %>: <%= execution.message %>"><%= execution.exception_class %></td>
|
|
53
|
-
<td class="sqw-muted"><%= execution.created_at
|
|
53
|
+
<td class="sqw-muted"><%= local_time(execution.created_at) %></td>
|
|
54
54
|
<td class="sqw-actions">
|
|
55
55
|
<%= button_to "Retry", retry_failed_job_path(execution),
|
|
56
56
|
method: :post, class: "sqw-btn sqw-btn--sm" %>
|
|
@@ -66,7 +66,8 @@
|
|
|
66
66
|
</div>
|
|
67
67
|
<% else %>
|
|
68
68
|
<div class="sqw-empty">
|
|
69
|
-
<p>No failed jobs
|
|
69
|
+
<p class="sqw-empty__title">No failed jobs</p>
|
|
70
|
+
<p class="sqw-empty__hint">All clear — your jobs are running without errors.</p>
|
|
70
71
|
</div>
|
|
71
72
|
<% end %>
|
|
72
73
|
</div>
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
<dd class="sqw-monospace sqw-truncate" title="<%= @execution.job.active_job_id %>"><%= @execution.job.active_job_id.presence || "—" %></dd>
|
|
30
30
|
|
|
31
31
|
<dt>Failed At</dt>
|
|
32
|
-
<dd class="sqw-monospace"><%= @execution.created_at
|
|
32
|
+
<dd class="sqw-monospace"><%= local_time(@execution.created_at, format: :long) %></dd>
|
|
33
33
|
|
|
34
34
|
<dt>Error</dt>
|
|
35
35
|
<dd class="sqw-monospace"><%= @execution.exception_class %></dd>
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
class: "sqw-badge sqw-badge--queue" %>
|
|
62
62
|
</td>
|
|
63
63
|
<td class="sqw-monospace"><%= format_duration(job.finished_at - job.created_at) %></td>
|
|
64
|
-
<td class="sqw-muted"><%= job.finished_at
|
|
64
|
+
<td class="sqw-muted"><%= local_time(job.finished_at) %></td>
|
|
65
65
|
</tr>
|
|
66
66
|
<% end %>
|
|
67
67
|
</tbody>
|
|
@@ -70,7 +70,13 @@
|
|
|
70
70
|
</div>
|
|
71
71
|
<% else %>
|
|
72
72
|
<div class="sqw-empty">
|
|
73
|
-
|
|
73
|
+
<% if @search.present? || @queue.present? || @period.present? %>
|
|
74
|
+
<p class="sqw-empty__title">No finished jobs match your filters</p>
|
|
75
|
+
<p class="sqw-empty__hint"><%= link_to "Clear filters", history_path %></p>
|
|
76
|
+
<% else %>
|
|
77
|
+
<p class="sqw-empty__title">No finished jobs yet</p>
|
|
78
|
+
<p class="sqw-empty__hint">Completed jobs will appear here once workers process them.</p>
|
|
79
|
+
<% end %>
|
|
74
80
|
</div>
|
|
75
81
|
<% end %>
|
|
76
82
|
<% end %>
|
|
@@ -1,3 +1,20 @@
|
|
|
1
1
|
<div id="sqw-empty" class="sqw-empty">
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
<% if @search.present? %>
|
|
3
|
+
<p class="sqw-empty__title">No <strong><%= @status %></strong> jobs matching “<%= @search %>”</p>
|
|
4
|
+
<p class="sqw-empty__hint"><%= link_to "Clear search", jobs_path(status: @status) %></p>
|
|
5
|
+
<% else %>
|
|
6
|
+
<p class="sqw-empty__title">No <strong><%= @status %></strong> jobs</p>
|
|
7
|
+
<p class="sqw-empty__hint">
|
|
8
|
+
<% case @status %>
|
|
9
|
+
<% when "ready" %>
|
|
10
|
+
Jobs will appear here once they are enqueued and ready to be picked up.
|
|
11
|
+
<% when "scheduled" %>
|
|
12
|
+
No jobs are scheduled to run in the future.
|
|
13
|
+
<% when "claimed" %>
|
|
14
|
+
No jobs are currently being processed by a worker.
|
|
15
|
+
<% when "blocked" %>
|
|
16
|
+
No jobs are blocked by concurrency controls.
|
|
17
|
+
<% end %>
|
|
18
|
+
</p>
|
|
19
|
+
<% end %>
|
|
20
|
+
</div>
|
|
@@ -121,9 +121,9 @@
|
|
|
121
121
|
<td class="sqw-monospace"><%= link_to execution.job.class_name, job_path(execution.id, status: @status), data: { turbo_frame: "_top" } %></td>
|
|
122
122
|
<td><span class="sqw-badge sqw-badge--queue"><%= execution.job.queue_name %></span></td>
|
|
123
123
|
<td><%= execution.job.priority %></td>
|
|
124
|
-
<td class="sqw-muted"><%= execution.created_at
|
|
124
|
+
<td class="sqw-muted"><%= local_time(execution.created_at) %></td>
|
|
125
125
|
<% if @status == "scheduled" %>
|
|
126
|
-
<td id="scheduled_at_<%= execution.id %>" class="sqw-muted"><%= execution.scheduled_at
|
|
126
|
+
<td id="scheduled_at_<%= execution.id %>" class="sqw-muted"><%= local_time(execution.scheduled_at) %></td>
|
|
127
127
|
<% end %>
|
|
128
128
|
<td class="sqw-actions">
|
|
129
129
|
<% if @status == "scheduled" %>
|
|
@@ -36,17 +36,17 @@
|
|
|
36
36
|
|
|
37
37
|
<% if @status == "blocked" %>
|
|
38
38
|
<dt>Blocked Until</dt>
|
|
39
|
-
<dd class="sqw-monospace"><%= @execution.expires_at
|
|
39
|
+
<dd class="sqw-monospace"><%= local_time(@execution.expires_at, format: :long) %></dd>
|
|
40
40
|
<% end %>
|
|
41
41
|
|
|
42
42
|
<dt>Enqueued At</dt>
|
|
43
|
-
<dd class="sqw-monospace"><%= @execution.job.created_at
|
|
43
|
+
<dd class="sqw-monospace"><%= local_time(@execution.job.created_at, format: :long) %></dd>
|
|
44
44
|
|
|
45
45
|
<dt>Scheduled At</dt>
|
|
46
|
-
<dd class="sqw-monospace"><%= @execution.job.scheduled_at
|
|
46
|
+
<dd class="sqw-monospace"><%= local_time(@execution.job.scheduled_at, format: :long) %></dd>
|
|
47
47
|
|
|
48
48
|
<dt>Finished At</dt>
|
|
49
|
-
<dd class="sqw-monospace"><%= @execution.job.finished_at
|
|
49
|
+
<dd class="sqw-monospace"><%= local_time(@execution.job.finished_at, format: :long) %></dd>
|
|
50
50
|
</dl>
|
|
51
51
|
</div>
|
|
52
52
|
|
|
@@ -22,14 +22,15 @@
|
|
|
22
22
|
<td class="sqw-monospace"><%= process.name %></td>
|
|
23
23
|
<td class="sqw-monospace"><%= process.pid %></td>
|
|
24
24
|
<td class="sqw-muted"><%= process.hostname %></td>
|
|
25
|
-
<td class="sqw-muted"><%= process.last_heartbeat_at
|
|
25
|
+
<td class="sqw-muted"><%= local_time(process.last_heartbeat_at) %></td>
|
|
26
26
|
</tr>
|
|
27
27
|
<% end %>
|
|
28
28
|
</tbody>
|
|
29
29
|
</table>
|
|
30
30
|
<% else %>
|
|
31
31
|
<div class="sqw-empty">
|
|
32
|
-
<p>No active processes
|
|
32
|
+
<p class="sqw-empty__title">No active processes</p>
|
|
33
|
+
<p class="sqw-empty__hint">Start a Solid Queue worker to begin processing jobs.</p>
|
|
33
34
|
</div>
|
|
34
35
|
<% end %>
|
|
35
36
|
<% end %>
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
</table>
|
|
45
45
|
<% else %>
|
|
46
46
|
<div class="sqw-empty">
|
|
47
|
-
<p>No queues with ready jobs
|
|
47
|
+
<p class="sqw-empty__title">No queues with ready jobs</p>
|
|
48
|
+
<p class="sqw-empty__hint">Workers are idle or all jobs are in another state. <%= link_to "Check job statuses →", jobs_path %></p>
|
|
48
49
|
</div>
|
|
49
50
|
<% end %>
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
data: { turbo_frame: "_top" } %>
|
|
50
50
|
</td>
|
|
51
51
|
<td><%= execution.job.priority %></td>
|
|
52
|
-
<td class="sqw-muted"><%= execution.created_at
|
|
52
|
+
<td class="sqw-muted"><%= local_time(execution.created_at) %></td>
|
|
53
53
|
<td class="sqw-actions">
|
|
54
54
|
<%= button_to "Discard", job_path(execution, status: "ready", queue: @queue_name),
|
|
55
55
|
method: :delete, class: "sqw-btn sqw-btn--danger sqw-btn--sm",
|
|
@@ -36,12 +36,11 @@
|
|
|
36
36
|
</td>
|
|
37
37
|
<td><span class="sqw-badge sqw-badge--queue"><%= task.queue_name.presence || "default" %></span></td>
|
|
38
38
|
<td class="sqw-muted sqw-monospace">
|
|
39
|
-
<% next_run = begin; task.next_time
|
|
40
|
-
<%= next_run
|
|
39
|
+
<% next_run = begin; task.next_time; rescue; nil; end %>
|
|
40
|
+
<%= local_time(next_run) %>
|
|
41
41
|
</td>
|
|
42
42
|
<td class="sqw-muted sqw-monospace">
|
|
43
|
-
|
|
44
|
-
<%= last_run ? last_run.strftime("%b %d %H:%M") : "—" %>
|
|
43
|
+
<%= local_time(task.last_enqueued_time) %>
|
|
45
44
|
</td>
|
|
46
45
|
<td>
|
|
47
46
|
<% if task.static? %>
|
|
@@ -62,6 +61,7 @@
|
|
|
62
61
|
</table>
|
|
63
62
|
<% else %>
|
|
64
63
|
<div class="sqw-empty">
|
|
65
|
-
<p>No recurring tasks configured
|
|
64
|
+
<p class="sqw-empty__title">No recurring tasks configured</p>
|
|
65
|
+
<p class="sqw-empty__hint">Define recurring tasks in your Solid Queue configuration to see them here.</p>
|
|
66
66
|
</div>
|
|
67
67
|
<% end %>
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
<% else %>
|
|
4
4
|
<%= turbo_stream.replace "scheduled_at_#{@execution.id}" do %>
|
|
5
5
|
<td id="scheduled_at_<%= @execution.id %>" class="sqw-muted">
|
|
6
|
-
<%= @execution.scheduled_at
|
|
6
|
+
<%= local_time(@execution.scheduled_at) %>
|
|
7
7
|
</td>
|
|
8
8
|
<% end %>
|
|
9
9
|
<% end %>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<div class="sqw-flash sqw-flash--<%= type %>"><%= message %></div>
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
</table>
|
|
44
44
|
<% else %>
|
|
45
45
|
<div class="sqw-empty">
|
|
46
|
-
<p>No finished jobs yet
|
|
46
|
+
<p class="sqw-empty__title">No finished jobs yet</p>
|
|
47
|
+
<p class="sqw-empty__hint">Performance stats appear here once jobs complete. <%= link_to "View active jobs →", jobs_path %></p>
|
|
47
48
|
</div>
|
|
48
49
|
<% end %>
|
data/config/importmap.rb
CHANGED
|
@@ -5,3 +5,5 @@ pin "solid_stack_web/refresh_controller", to: "solid_stack_web/refresh_controlle
|
|
|
5
5
|
pin "solid_stack_web/search_controller", to: "solid_stack_web/search_controller.js"
|
|
6
6
|
pin "solid_stack_web/selection_controller", to: "solid_stack_web/selection_controller.js"
|
|
7
7
|
pin "solid_stack_web/sparkline_tooltip_controller", to: "solid_stack_web/sparkline_tooltip_controller.js"
|
|
8
|
+
pin "solid_stack_web/theme_controller", to: "solid_stack_web/theme_controller.js"
|
|
9
|
+
pin "solid_stack_web/timestamp_controller", to: "solid_stack_web/timestamp_controller.js"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: solid_stack_web
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Chuck Smith
|
|
@@ -142,6 +142,8 @@ files:
|
|
|
142
142
|
- app/assets/stylesheets/solid_stack_web/_07_dashboard.css
|
|
143
143
|
- app/assets/stylesheets/solid_stack_web/_08_filters.css
|
|
144
144
|
- app/assets/stylesheets/solid_stack_web/_09_detail.css
|
|
145
|
+
- app/assets/stylesheets/solid_stack_web/_10_responsive.css
|
|
146
|
+
- app/assets/stylesheets/solid_stack_web/_12_dark_mode.css
|
|
145
147
|
- app/assets/stylesheets/solid_stack_web/application.css
|
|
146
148
|
- app/controllers/solid_stack_web/application_controller.rb
|
|
147
149
|
- app/controllers/solid_stack_web/cable/channel_purges_controller.rb
|
|
@@ -172,6 +174,8 @@ files:
|
|
|
172
174
|
- app/javascript/solid_stack_web/search_controller.js
|
|
173
175
|
- app/javascript/solid_stack_web/selection_controller.js
|
|
174
176
|
- app/javascript/solid_stack_web/sparkline_tooltip_controller.js
|
|
177
|
+
- app/javascript/solid_stack_web/theme_controller.js
|
|
178
|
+
- app/javascript/solid_stack_web/timestamp_controller.js
|
|
175
179
|
- app/models/solid_stack_web/alert_webhook.rb
|
|
176
180
|
- app/models/solid_stack_web/cable_stats.rb
|
|
177
181
|
- app/models/solid_stack_web/cable_timeline.rb
|
|
@@ -202,6 +206,7 @@ files:
|
|
|
202
206
|
- app/views/solid_stack_web/queues/show.html.erb
|
|
203
207
|
- app/views/solid_stack_web/recurring_tasks/index.html.erb
|
|
204
208
|
- app/views/solid_stack_web/scheduled_jobs/update.turbo_stream.erb
|
|
209
|
+
- app/views/solid_stack_web/shared/_flash.html.erb
|
|
205
210
|
- app/views/solid_stack_web/stats/index.html.erb
|
|
206
211
|
- config/importmap.rb
|
|
207
212
|
- config/routes.rb
|