cogworker 0.1.0 → 0.2.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/lib/cogworker/attempts.rb +47 -0
- data/lib/cogworker/basic_fetch.rb +25 -2
- data/lib/cogworker/heartbeat.rb +4 -2
- data/lib/cogworker/manager.rb +4 -0
- data/lib/cogworker/periodic/ticker.rb +16 -1
- data/lib/cogworker/process.rb +13 -5
- data/lib/cogworker/processor.rb +4 -0
- data/lib/cogworker/queue.rb +17 -0
- data/lib/cogworker/redis_keys.rb +4 -0
- data/lib/cogworker/throughput.rb +51 -0
- data/lib/cogworker/version.rb +1 -1
- data/lib/cogworker/web/assets/nocturne/fonts/inter-latin.woff2 +0 -0
- data/lib/cogworker/web/assets/nocturne/styles.css +582 -0
- data/lib/cogworker/web/assets/phosphor/Phosphor.woff2 +0 -0
- data/lib/cogworker/web/assets/phosphor/style.css +4627 -0
- data/lib/cogworker/web/layout.rb +232 -141
- data/lib/cogworker/web/routes/history.rb +86 -32
- data/lib/cogworker/web/routes/jobs.rb +469 -0
- data/lib/cogworker/web/routes/overview.rb +746 -0
- data/lib/cogworker/web/routes/schedules.rb +197 -0
- data/lib/cogworker/web/routes/workers.rb +227 -0
- data/lib/cogworker/web.rb +4 -3
- metadata +11 -9
- data/lib/cogworker/web/assets/tailwind.css +0 -1
- data/lib/cogworker/web/routes/busy.rb +0 -99
- data/lib/cogworker/web/routes/dead.rb +0 -93
- data/lib/cogworker/web/routes/periodic.rb +0 -67
- data/lib/cogworker/web/routes/queues.rb +0 -101
- data/lib/cogworker/web/routes/retries.rb +0 -89
- data/lib/cogworker/web/routes/scheduled.rb +0 -49
- data/lib/cogworker/web/routes/stats.rb +0 -298
data/lib/cogworker/web/layout.rb
CHANGED
|
@@ -9,42 +9,30 @@ module Cogworker
|
|
|
9
9
|
# extension contract (an extension is free to build its own HTML, or
|
|
10
10
|
# use `erb`/`Views` the same way these do).
|
|
11
11
|
module Layout
|
|
12
|
-
BUILT_IN_TABS = { '
|
|
13
|
-
'
|
|
14
|
-
'History' => 'history', 'Stats' => 'stats' }.freeze
|
|
12
|
+
BUILT_IN_TABS = { 'Overview' => 'overview', 'Jobs' => 'jobs', 'Schedules' => 'schedules',
|
|
13
|
+
'Workers' => 'workers', 'History' => 'history' }.freeze
|
|
15
14
|
# Vendored under lib/cogworker/web/assets/ (served locally by
|
|
16
15
|
# `Rack::Static`, wired up in `Web.build_app`) rather than fetched
|
|
17
|
-
# from a CDN, so the Web UI works fully offline. `
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
16
|
+
# from a CDN, so the Web UI works fully offline. `NOCTURNE_CSS_HREF`/
|
|
17
|
+
# `PHOSPHOR_CSS_HREF` are the "Relay" concept's design system
|
|
18
|
+
# (tokens + component classes) and its icon font — both self-hosted,
|
|
19
|
+
# same policy as htmx/AG Grid/Chart.js.
|
|
20
|
+
#
|
|
21
|
+
# No Tailwind stylesheet here any more — every built-in tab (Overview/
|
|
22
|
+
# Jobs/Schedules/Workers, then History/Stats last, since both leaned
|
|
23
|
+
# on hand-written Tailwind utility classes for their AG Grid/Chart.js
|
|
24
|
+
# layout) has been migrated onto these nocturne classes/tokens, and
|
|
25
|
+
# the vendored `assets/tailwind.css` file itself was removed with it —
|
|
26
|
+
# nothing in this gem references it any more.
|
|
22
27
|
HTMX_SRC = 'assets/htmx.min.js'
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
BADGE_VARIANTS = {
|
|
33
|
-
success: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
|
|
34
|
-
warning: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
|
|
35
|
-
danger: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
|
|
36
|
-
default: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200'
|
|
37
|
-
}.freeze
|
|
38
|
-
|
|
39
|
-
# Shared with `Routes::Stats::CARD_ACCENTS` (its big card grid on the
|
|
40
|
-
# actual Stats tab) — a single source of truth for which color each of
|
|
41
|
-
# the 6 job counters gets, whether rendered as a big card there or as
|
|
42
|
-
# a compact chip in `stats_bar` everywhere else.
|
|
43
|
-
JOB_STAT_ACCENTS = {
|
|
44
|
-
'Enqueued' => 'text-indigo-600 dark:text-indigo-400', 'Processed' => 'text-green-600 dark:text-green-400',
|
|
45
|
-
'Failed' => 'text-red-600 dark:text-red-400', 'Retries' => 'text-amber-600 dark:text-amber-400',
|
|
46
|
-
'Scheduled' => 'text-sky-600 dark:text-sky-400', 'Dead' => 'text-gray-500 dark:text-gray-400'
|
|
47
|
-
}.freeze
|
|
28
|
+
NOCTURNE_CSS_HREF = 'assets/nocturne/styles.css'
|
|
29
|
+
PHOSPHOR_CSS_HREF = 'assets/phosphor/style.css'
|
|
30
|
+
|
|
31
|
+
BUTTON_VARIANTS = { default: 'btn-secondary', primary: 'btn-primary', warning: 'btn-warning',
|
|
32
|
+
danger: 'btn-danger', success: 'btn-success' }.freeze
|
|
33
|
+
|
|
34
|
+
BADGE_VARIANTS = { success: 'tag-success', warning: 'tag-warning', danger: 'tag-danger',
|
|
35
|
+
default: 'tag-neutral' }.freeze
|
|
48
36
|
|
|
49
37
|
module_function
|
|
50
38
|
|
|
@@ -52,7 +40,7 @@ module Cogworker
|
|
|
52
40
|
# every `interval` and swaps the response into this same element's
|
|
53
41
|
# innerHTML. The route behind `path` only needs to answer both "full
|
|
54
42
|
# page" (a normal GET) and "just this fragment" (`Action#hx_request?`
|
|
55
|
-
# true) — see e.g. Routes::
|
|
43
|
+
# true) — see e.g. Routes::Workers.
|
|
56
44
|
# The `[window.cogworkerLiveUpdate]` event filter (htmx polling
|
|
57
45
|
# syntax) makes every self-polling tab respect the global live-update
|
|
58
46
|
# toggle (see `live_update_script`/`live_toggle_button` below) without
|
|
@@ -74,29 +62,50 @@ module Cogworker
|
|
|
74
62
|
"#{script_name}/#{relative}"
|
|
75
63
|
end
|
|
76
64
|
|
|
77
|
-
# `
|
|
78
|
-
#
|
|
79
|
-
#
|
|
80
|
-
#
|
|
81
|
-
#
|
|
82
|
-
|
|
65
|
+
# `body`'s own `min-width: max-content` (not a hand-picked pixel
|
|
66
|
+
# number) lets the browser compute the page's own minimum width from
|
|
67
|
+
# whatever's actually inside it — right now that's the header (see
|
|
68
|
+
# its own comment below), but it's self-adjusting: it'd grow or
|
|
69
|
+
# shrink again on its own if the header's content ever changes, no
|
|
70
|
+
# number to update by hand. Real payoff for `main` specifically: as
|
|
71
|
+
# the other direct child of this `display: flex; flex-direction:
|
|
72
|
+
# column` body, `main`'s default `align-items: stretch` cross-size
|
|
73
|
+
# already tracks *whatever width body ends up being* — so once
|
|
74
|
+
# body's own min-width is driven by the header, `main` stretches to
|
|
75
|
+
# match it too, rather than independently shrinking to the viewport
|
|
76
|
+
# while the header overflows past it (a real bug once: the page grew
|
|
77
|
+
# a horizontal scrollbar for the header, but `main`'s own content
|
|
78
|
+
# kept getting squeezed narrower against the viewport instead of
|
|
79
|
+
# riding along at the same width). The other direction of the same
|
|
80
|
+
# bug also hit once `main` was riding along correctly: `min-width:
|
|
81
|
+
# max-content` on `body` takes the *max* of every child's own
|
|
82
|
+
# max-content contribution, so `#cw-main`'s own (a wide table/card
|
|
83
|
+
# grid, capped at 1480px by its own `max-width`) was *also* feeding
|
|
84
|
+
# into it — and being bigger than the header's true ~1090px minimum,
|
|
85
|
+
# it won, flooring the whole page at 1480px instead: the header sat
|
|
86
|
+
# frozen, not tracking the window at all, for the entire 1090–1480px
|
|
87
|
+
# range, only reacting once you crossed below 1480. `.cw-main`'s own
|
|
88
|
+
# `contain: inline-size` (styles.css) is what excludes it from that
|
|
89
|
+
# calculation, leaving the header as the only real contributor.
|
|
90
|
+
def wrap(title, body, script_name: '', extra_head: '')
|
|
83
91
|
<<~HTML
|
|
84
92
|
<!doctype html>
|
|
85
|
-
<html
|
|
93
|
+
<html>
|
|
86
94
|
<head>
|
|
87
95
|
<meta charset="utf-8">
|
|
88
96
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
89
97
|
<title>#{h(title)} · Cogworker</title>
|
|
90
|
-
<link rel="stylesheet" href="#{path(script_name,
|
|
98
|
+
<link rel="stylesheet" href="#{path(script_name, NOCTURNE_CSS_HREF)}">
|
|
99
|
+
<link rel="stylesheet" href="#{path(script_name, PHOSPHOR_CSS_HREF)}">
|
|
91
100
|
<script src="#{path(script_name, HTMX_SRC)}"></script>
|
|
92
101
|
#{time_script}
|
|
93
102
|
#{live_update_script}
|
|
103
|
+
#{wide_layout_script}
|
|
94
104
|
#{extra_head}
|
|
95
105
|
</head>
|
|
96
|
-
<body
|
|
106
|
+
<body style="min-height: 100vh; display: flex; flex-direction: column; min-width: max-content;">
|
|
97
107
|
#{header(title, script_name)}
|
|
98
|
-
|
|
99
|
-
<main class="w-full px-4 sm:px-6 lg:px-8 py-8">
|
|
108
|
+
<main id="cw-main" class="cw-main" style="flex: 1; padding: 22px; display: flex; flex-direction: column; gap: 20px;">
|
|
100
109
|
#{body}
|
|
101
110
|
</main>
|
|
102
111
|
</body>
|
|
@@ -104,53 +113,125 @@ module Cogworker
|
|
|
104
113
|
HTML
|
|
105
114
|
end
|
|
106
115
|
|
|
107
|
-
|
|
116
|
+
# Behaves like a desktop app's toolbar: never wraps onto a second
|
|
117
|
+
# line, no matter how narrow the window — below its natural content
|
|
118
|
+
# width, the *page* scrolls horizontally instead (`wrap` above's
|
|
119
|
+
# `min-width: max-content` on `body` is what turns this row's own
|
|
120
|
+
# unshrinkable width into the *page's* scrollable minimum, `main`
|
|
121
|
+
# included). What needs to be explicit here is `flex-wrap: nowrap` at
|
|
122
|
+
# every level that could otherwise wrap (this row, `.nav`, the
|
|
123
|
+
# right-hand action group), and — the easy-to-miss half of it —
|
|
124
|
+
# `white-space: nowrap` on every label that could otherwise
|
|
125
|
+
# line-break internally (`.btn` in styles.css covers the buttons;
|
|
126
|
+
# nav links already get it from `nav_link` below). Without the
|
|
127
|
+
# latter, flexbox's own "never shrink a row below its content's
|
|
128
|
+
# minimum size" protection still holds, but that minimum is computed
|
|
129
|
+
# from the *longest word*, not the whole label, once wrapping is
|
|
130
|
+
# allowed — so a button like "Pause intake" still gets squeezed down
|
|
131
|
+
# and its label splits into two lines ("Pause"/"intake") instead of
|
|
132
|
+
# the row ever overflowing. This was caught by hand — a fixed pixel
|
|
133
|
+
# `min-width` was tried first as a shortcut for both this and body's
|
|
134
|
+
# own min-width above, then dropped for the CSS-computed `max-
|
|
135
|
+
# content`/`white-space: nowrap` combination once the real causes
|
|
136
|
+
# turned out to make a hardcoded number unnecessary either way.
|
|
137
|
+
def header(title, script_name)
|
|
138
|
+
links = BUILT_IN_TABS.merge(Web.tabs).map do |label, p|
|
|
139
|
+
nav_link(label, p, script_name, active: label == title)
|
|
140
|
+
end.join
|
|
108
141
|
<<~HTML
|
|
109
|
-
<
|
|
110
|
-
<
|
|
111
|
-
|
|
142
|
+
<header style="display: flex; align-items: center; gap: 14px; padding: 12px 22px; background: linear-gradient(180deg, color-mix(in srgb, var(--color-text) 4%, var(--color-bg)), var(--color-bg)); box-shadow: inset 0 -1px 0 var(--color-divider); position: sticky; top: 0; z-index: 5;">
|
|
143
|
+
<span style="font-family: var(--font-heading); font-weight: var(--font-heading-weight); font-size: 18px; letter-spacing: -0.02em; white-space: nowrap;">Cogworker</span>
|
|
144
|
+
<nav class="nav" style="padding: 0; gap: 2px; flex-wrap: nowrap;">#{links}</nav>
|
|
145
|
+
<div style="margin-left: auto; display: flex; align-items: center; gap: 14px; flex-wrap: nowrap;">
|
|
146
|
+
#{cluster_bar(script_name)}
|
|
147
|
+
#{pause_intake_button(script_name)}
|
|
148
|
+
#{resume_intake_button(script_name)}
|
|
149
|
+
#{wide_layout_toggle_button}
|
|
150
|
+
#{live_toggle_button}
|
|
112
151
|
</div>
|
|
113
|
-
</
|
|
152
|
+
</header>
|
|
114
153
|
HTML
|
|
115
154
|
end
|
|
116
155
|
|
|
117
|
-
#
|
|
118
|
-
#
|
|
119
|
-
#
|
|
120
|
-
#
|
|
121
|
-
#
|
|
122
|
-
#
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
# every route needing to compute it itself.
|
|
126
|
-
def stats_bar(script_name)
|
|
127
|
-
poll_div('global-stats-bar', script_name, 'stats/bar', stats_bar_content)
|
|
156
|
+
# `production · N workers` in the "Relay" concept mock, minus the
|
|
157
|
+
# fabricated environment name (Cogworker has no such concept) — just
|
|
158
|
+
# the real worker count (`ProcessSet`) and a status dot colored by
|
|
159
|
+
# whether any of them are actually taking work right now, refreshed
|
|
160
|
+
# via a tiny dedicated fragment route (`Routes::Workers`'s own `GET
|
|
161
|
+
# /workers/summary`).
|
|
162
|
+
def cluster_bar(script_name)
|
|
163
|
+
poll_div('cluster-bar', script_name, 'workers/summary', cluster_bar_content)
|
|
128
164
|
end
|
|
129
165
|
|
|
130
|
-
def
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
items = values.map { |label, value| stat_chip(label, value) }.join
|
|
137
|
-
%(<div class="flex flex-wrap gap-2">#{items}</div>)
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
# Each counter its own bordered pill (not just plain text separated by
|
|
141
|
-
# a gap) — border-only in dark mode (no fill) so it reads as an
|
|
142
|
-
# outline against the page background rather than a lighter box
|
|
143
|
-
# sitting on top of it; a solid `bg-white` still works fine in light
|
|
144
|
-
# mode, where the surrounding strip is itself already light.
|
|
145
|
-
def stat_chip(label, value)
|
|
166
|
+
def cluster_bar_content
|
|
167
|
+
processes = Cogworker::ProcessSet.new.to_a
|
|
168
|
+
active = processes.any? { |p| ![true, 'true'].include?(p['quiet']) }
|
|
169
|
+
dot_glow = active ? ' box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-success) 22%, transparent);' : ''
|
|
170
|
+
dot_color = active ? 'var(--color-success)' : 'var(--color-neutral-600)'
|
|
171
|
+
count = processes.size
|
|
146
172
|
<<~HTML.strip
|
|
147
|
-
<span
|
|
148
|
-
<span
|
|
149
|
-
|
|
173
|
+
<span style="display: inline-flex; align-items: center; gap: 7px; font-size: 13px; color: var(--color-neutral-400); white-space: nowrap;">
|
|
174
|
+
<span style="width: 7px; height: 7px; border-radius: 50%; background: #{dot_color};#{dot_glow}"></span>
|
|
175
|
+
#{count} #{count == 1 ? 'worker' : 'workers'}
|
|
150
176
|
</span>
|
|
151
177
|
HTML
|
|
152
178
|
end
|
|
153
179
|
|
|
180
|
+
# Cluster-wide "quiet every process" (`Routes::Workers`'s own `POST
|
|
181
|
+
# /workers/pause_all`) — the mock's "Pause intake" button. Unlike real
|
|
182
|
+
# Sidekiq OSS (where quiet is a one-way trip back to a fresh process),
|
|
183
|
+
# `Manager#quiet` here is a plain in-memory flag, so `resume_intake_
|
|
184
|
+
# button` below is a genuine, symmetric undo rather than a full
|
|
185
|
+
# process restart.
|
|
186
|
+
def pause_intake_button(script_name)
|
|
187
|
+
action = path(script_name, 'workers/pause_all')
|
|
188
|
+
action_button(action, 'Pause intake', hx_target: '#cluster-bar', icon: 'pause')
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# The undo for the button above (`POST /workers/resume_all`) — both
|
|
192
|
+
# buttons are always shown side by side rather than toggling one for
|
|
193
|
+
# the other, since `#cluster-bar` only reports an aggregate "any
|
|
194
|
+
# process active?" dot, not enough to know which single action applies
|
|
195
|
+
# cluster-wide when processes are in a mixed state.
|
|
196
|
+
def resume_intake_button(script_name)
|
|
197
|
+
action = path(script_name, 'workers/resume_all')
|
|
198
|
+
action_button(action, 'Resume intake', hx_target: '#cluster-bar', variant: :success, icon: 'play')
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# `.nav a`/`.nav a[aria-current='page']` (styles.css) already turn a
|
|
202
|
+
# link accent-colored on hover or when active — only the active
|
|
203
|
+
# pill's background tint needs to be added here per-link, so this
|
|
204
|
+
# stays in sync with the design system's own hover/focus treatment
|
|
205
|
+
# instead of duplicating it.
|
|
206
|
+
def nav_link(label, relative_path, script_name, active:)
|
|
207
|
+
bg = active ? ' background: color-mix(in srgb, var(--color-accent) 14%, transparent);' : ''
|
|
208
|
+
attrs = active ? ' aria-current="page"' : ''
|
|
209
|
+
%(<a href="#{path(script_name,
|
|
210
|
+
relative_path)}"#{attrs} style="display: inline-block; padding: 6px 11px; border-radius: var(--radius-md); white-space: nowrap;#{bg}">#{h(label)}</a>)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# One global on/off switch for every auto-refreshing tab (the
|
|
214
|
+
# htmx-polled Workers/Overview tabs via `poll_div`'s event filter, and
|
|
215
|
+
# History's own AG Grid data refresh) — persisted in `localStorage` so
|
|
216
|
+
# it survives navigating between tabs/reloading the page.
|
|
217
|
+
def live_toggle_button
|
|
218
|
+
%(<button type="button" data-cw-live-toggle class="btn btn-secondary" onclick="window.cogworkerSetLiveUpdate(!window.cogworkerLiveUpdate)"
|
|
219
|
+
aria-pressed="true" title="Toggle live auto-refresh"
|
|
220
|
+
style="flex-shrink: 0; font-size: 13px;">⏸ Live</button>)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Global fixed-width/full-width switch for `<main>` (`#cw-main`, the
|
|
224
|
+
# `.cw-main`/`.cw-main--wide` pair in styles.css) — same persisted-in-
|
|
225
|
+
# `localStorage`, applies-on-every-page shape as `live_toggle_button`/
|
|
226
|
+
# `live_update_script` above, just flipping a layout class instead of
|
|
227
|
+
# gating polling. Defaults to fixed width (matching the design as
|
|
228
|
+
# exported) when nothing's stored yet.
|
|
229
|
+
def wide_layout_toggle_button
|
|
230
|
+
%(<button type="button" data-cw-wide-toggle class="btn btn-secondary" onclick="window.cogworkerSetWideLayout(!window.cogworkerWideLayout)"
|
|
231
|
+
aria-pressed="false" title="Toggle fixed-width/full-width layout"
|
|
232
|
+
style="flex-shrink: 0; font-size: 13px;">⛶ Full width</button>)
|
|
233
|
+
end
|
|
234
|
+
|
|
154
235
|
# Every `time_tag` renders a UTC fallback (readable with JS disabled,
|
|
155
236
|
# or before this runs) plus a `datetime=` attribute; this rewrites
|
|
156
237
|
# each one's visible text to the *browser's local* time, in
|
|
@@ -225,36 +306,10 @@ module Cogworker
|
|
|
225
306
|
%(<time datetime="#{time.utc.iso8601}" data-cw-time>#{h(time.utc.strftime(Web.time_format))}</time>)
|
|
226
307
|
end
|
|
227
308
|
|
|
228
|
-
def header(title, script_name)
|
|
229
|
-
links = BUILT_IN_TABS.merge(Web.tabs).map do |label, p|
|
|
230
|
-
nav_link(label, p, script_name, active: label == title)
|
|
231
|
-
end.join
|
|
232
|
-
<<~HTML
|
|
233
|
-
<header class="border-b border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900">
|
|
234
|
-
<div class="w-full px-4 sm:px-6 lg:px-8">
|
|
235
|
-
<div class="flex items-center h-14 gap-1">
|
|
236
|
-
<span class="font-semibold tracking-tight mr-4">⚙️ Cogworker</span>
|
|
237
|
-
<nav class="flex gap-1 overflow-x-auto">#{links}</nav>
|
|
238
|
-
#{live_toggle_button}
|
|
239
|
-
</div>
|
|
240
|
-
</div>
|
|
241
|
-
</header>
|
|
242
|
-
HTML
|
|
243
|
-
end
|
|
244
|
-
|
|
245
309
|
# One global on/off switch for every auto-refreshing tab (the
|
|
246
|
-
# htmx-polled
|
|
310
|
+
# htmx-polled Workers/Overview tabs via `poll_div`'s event filter, and
|
|
247
311
|
# History's own AG Grid data refresh) — persisted in `localStorage` so
|
|
248
312
|
# it survives navigating between tabs/reloading the page.
|
|
249
|
-
def live_toggle_button
|
|
250
|
-
%(<button type="button" data-cw-live-toggle onclick="window.cogworkerSetLiveUpdate(!window.cogworkerLiveUpdate)"
|
|
251
|
-
aria-pressed="true" title="Toggle live auto-refresh"
|
|
252
|
-
class="ml-auto flex-shrink-0 px-3 py-1.5 rounded-md text-sm font-medium bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-200">⏸ Live</button>)
|
|
253
|
-
end
|
|
254
|
-
|
|
255
|
-
# Runs before the rest of the page so `window.cogworkerLiveUpdate` is
|
|
256
|
-
# already set by the time htmx evaluates its first `every ... [...]`
|
|
257
|
-
# poll filter or a route's own refresh script checks it.
|
|
258
313
|
def live_update_script
|
|
259
314
|
<<~HTML
|
|
260
315
|
<script>
|
|
@@ -282,56 +337,88 @@ module Cogworker
|
|
|
282
337
|
HTML
|
|
283
338
|
end
|
|
284
339
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
340
|
+
# Companion to `wide_layout_toggle_button` above — same persisted-
|
|
341
|
+
# in-`localStorage`, apply-on-`DOMContentLoaded`-and-every-toggle
|
|
342
|
+
# shape as `live_update_script`, just toggling `#cw-main`'s
|
|
343
|
+
# `cw-main--wide` class (styles.css) instead of a polling flag.
|
|
344
|
+
# Applied directly here (not deferred to a `poll_div`/htmx swap)
|
|
345
|
+
# since `#cw-main` itself is never one of those fragments — it's the
|
|
346
|
+
# element every fragment lives *inside*.
|
|
347
|
+
def wide_layout_script
|
|
348
|
+
<<~HTML
|
|
349
|
+
<script>
|
|
350
|
+
(function () {
|
|
351
|
+
function readStored() {
|
|
352
|
+
try { return localStorage.getItem('cogworkerWideLayout') === 'true'; } catch (e) { return false; }
|
|
353
|
+
}
|
|
354
|
+
window.cogworkerWideLayout = readStored();
|
|
355
|
+
window.cogworkerSetWideLayout = function (on) {
|
|
356
|
+
window.cogworkerWideLayout = on;
|
|
357
|
+
try { localStorage.setItem('cogworkerWideLayout', on ? 'true' : 'false'); } catch (e) {}
|
|
358
|
+
var main = document.getElementById('cw-main');
|
|
359
|
+
if (main) main.classList.toggle('cw-main--wide', on);
|
|
360
|
+
document.querySelectorAll('[data-cw-wide-toggle]').forEach(function (btn) {
|
|
361
|
+
btn.textContent = on ? '⛶ Fixed width' : '⛶ Full width';
|
|
362
|
+
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
|
|
363
|
+
});
|
|
364
|
+
};
|
|
365
|
+
document.addEventListener('DOMContentLoaded', function () {
|
|
366
|
+
window.cogworkerSetWideLayout(window.cogworkerWideLayout);
|
|
367
|
+
});
|
|
368
|
+
})();
|
|
369
|
+
</script>
|
|
370
|
+
HTML
|
|
293
371
|
end
|
|
294
372
|
|
|
295
373
|
def h(str)
|
|
296
374
|
CGI.escapeHTML(str.to_s)
|
|
297
375
|
end
|
|
298
376
|
|
|
299
|
-
|
|
300
|
-
|
|
377
|
+
# `wrapped: false` skips this table's own bordered/shadowed card —
|
|
378
|
+
# for a caller that's already putting it inside one of its own (e.g.
|
|
379
|
+
# a `<section>` alongside an `<h4>`, matching Overview's Throughput/
|
|
380
|
+
# Redis cards), where the default wrapper would just nest one bordered
|
|
381
|
+
# box inside another.
|
|
382
|
+
def table(headers, rows, empty_message: 'Nothing here.', wrapped: true)
|
|
383
|
+
return %(<p class="text-muted" style="font-size: 13px; font-style: italic;">#{h(empty_message)}</p>) if rows.empty?
|
|
301
384
|
|
|
302
|
-
head = headers.map
|
|
303
|
-
%(<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">#{h(c)}</th>)
|
|
304
|
-
end.join
|
|
385
|
+
head = headers.map { |c| %(<th>#{h(c)}</th>) }.join
|
|
305
386
|
body = rows.map do |row|
|
|
306
|
-
cells = row.map { |cell| %(<td
|
|
307
|
-
%(<tr
|
|
387
|
+
cells = row.map { |cell| %(<td>#{cell}</td>) }.join
|
|
388
|
+
%(<tr>#{cells}</tr>)
|
|
308
389
|
end.join
|
|
309
|
-
<<~HTML
|
|
310
|
-
<
|
|
311
|
-
<
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
</table>
|
|
315
|
-
</div>
|
|
390
|
+
inner = <<~HTML
|
|
391
|
+
<table class="table">
|
|
392
|
+
<thead><tr>#{head}</tr></thead>
|
|
393
|
+
<tbody>#{body}</tbody>
|
|
394
|
+
</table>
|
|
316
395
|
HTML
|
|
396
|
+
return inner unless wrapped
|
|
397
|
+
|
|
398
|
+
%(<div style="background: var(--color-surface); border-radius: var(--radius-md); box-shadow: var(--shadow-sm); padding: 8px 6px 4px; overflow-x: auto;">#{inner}</div>)
|
|
317
399
|
end
|
|
318
400
|
|
|
319
401
|
def section(heading, body)
|
|
320
|
-
%(<
|
|
402
|
+
%(<h3 style="font-family: var(--font-heading); font-weight: var(--font-heading-weight); font-size: 17px; margin: var(--space-8) 0 var(--space-3);">#{h(heading)}</h3>#{body})
|
|
321
403
|
end
|
|
322
404
|
|
|
323
|
-
# A one-button `<form>`, wired for both htmx (`hx-post`/`hx-target`)
|
|
324
|
-
# and a plain, JS-less POST fallback to the same `action` URL.
|
|
325
405
|
def badge(label, variant: :default)
|
|
326
|
-
%(<span class="
|
|
406
|
+
%(<span class="tag #{BADGE_VARIANTS.fetch(variant)}">#{h(label)}</span>)
|
|
327
407
|
end
|
|
328
408
|
|
|
329
|
-
|
|
330
|
-
|
|
409
|
+
# A one-button `<form>`, wired for both htmx (`hx-post`/`hx-target`)
|
|
410
|
+
# and a plain, JS-less POST fallback to the same `action` URL.
|
|
411
|
+
# `icon:` (a Phosphor icon name, e.g. `'arrow-clockwise'`) is optional
|
|
412
|
+
# — the "Relay" concept mock only puts icons on a page's few
|
|
413
|
+
# prominent/standalone actions (a detail panel's own buttons, the
|
|
414
|
+
# header's "Pause intake"), never on the small, repeated actions
|
|
415
|
+
# packed into a dense table row, so most call sites omit it.
|
|
416
|
+
def form_button(action, hidden_name, hidden_value, label, hx_target:, variant: :default, icon: nil)
|
|
417
|
+
classes = "btn #{BUTTON_VARIANTS.fetch(variant)}"
|
|
331
418
|
<<~HTML
|
|
332
|
-
<form
|
|
419
|
+
<form style="display: inline;" hx-post="#{action}" hx-target="#{hx_target}" hx-swap="innerHTML" method="post" action="#{action}">
|
|
333
420
|
<input type="hidden" name="#{hidden_name}" value="#{h(hidden_value)}">
|
|
334
|
-
<button type="submit" class="#{classes}">#{h(label)}</button>
|
|
421
|
+
<button type="submit" class="#{classes}" style="font-size: 13px; padding: 4px 10px;">#{icon_tag(icon)}#{h(label)}</button>
|
|
335
422
|
</form>
|
|
336
423
|
HTML
|
|
337
424
|
end
|
|
@@ -339,14 +426,18 @@ module Cogworker
|
|
|
339
426
|
# Same wiring as `form_button`, minus the hidden identifying field —
|
|
340
427
|
# for a whole-collection action (e.g. "delete all") that doesn't
|
|
341
428
|
# target one particular row.
|
|
342
|
-
def action_button(action, label, hx_target:, variant: :default)
|
|
343
|
-
classes = "
|
|
429
|
+
def action_button(action, label, hx_target:, variant: :default, icon: nil)
|
|
430
|
+
classes = "btn #{BUTTON_VARIANTS.fetch(variant)}"
|
|
344
431
|
<<~HTML
|
|
345
|
-
<form
|
|
346
|
-
<button type="submit" class="#{classes}">#{h(label)}</button>
|
|
432
|
+
<form style="display: inline;" hx-post="#{action}" hx-target="#{hx_target}" hx-swap="innerHTML" method="post" action="#{action}">
|
|
433
|
+
<button type="submit" class="#{classes}" style="font-size: 13px; padding: 4px 10px;">#{icon_tag(icon)}#{h(label)}</button>
|
|
347
434
|
</form>
|
|
348
435
|
HTML
|
|
349
436
|
end
|
|
437
|
+
|
|
438
|
+
def icon_tag(name)
|
|
439
|
+
name ? %(<i class="ph ph-#{name}"></i>) : ''
|
|
440
|
+
end
|
|
350
441
|
end
|
|
351
442
|
end
|
|
352
443
|
end
|