dispatch_policy 0.3.0 → 0.4.1

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.
@@ -1,18 +1,122 @@
1
1
  /* dispatch_policy: minimal flat dashboard */
2
2
 
3
+ :root {
4
+ /* Light theme (default) */
5
+ --dp-bg: #f6f7fa;
6
+ --dp-surface: #fff;
7
+ --dp-surface-alt: #fafbfd;
8
+ --dp-surface-th: #f1f3f7;
9
+ --dp-fg: #1a1a1a;
10
+ --dp-fg-strong: #374151;
11
+ --dp-fg-muted: #6b7280;
12
+ --dp-border: #e3e6ec;
13
+ --dp-border-soft: #eef0f4;
14
+ --dp-border-input: #cfd5df;
15
+ --dp-link: #1f4ed8;
16
+ --dp-code-bg: #eef2f7;
17
+ --dp-warn: #b45309;
18
+ --dp-paused-bg: #fffbe9;
19
+ --dp-flash-ok-bg: #e8f6ee; --dp-flash-ok-fg: #14532d; --dp-flash-ok-bd: #c7e6d3;
20
+ --dp-flash-err-bg: #fbe7e6; --dp-flash-err-fg: #7a1d1d; --dp-flash-err-bd: #f0c2bf;
21
+ --dp-hint-info-bg: #eff4ff; --dp-hint-info-bd: #1f4ed8;
22
+ --dp-hint-warn-bg: #fff7e6; --dp-hint-warn-bd: #b45309;
23
+ --dp-hint-critical-bg: #fbe7e6; --dp-hint-critical-bd: #b91c1c;
24
+ --dp-btn-ok-bg: #f1faf3; --dp-btn-ok-bd: #2f9e58; --dp-btn-ok-hover: #def0e3;
25
+ --dp-btn-warn-bg: #fdf2f1; --dp-btn-warn-bd: #d05858; --dp-btn-warn-hover: #f7dcd9;
26
+ --dp-btn-hover: #eef1f6;
27
+ }
28
+
29
+ @media (prefers-color-scheme: dark) {
30
+ :root {
31
+ --dp-bg: #0f1116;
32
+ --dp-surface: #1a1d24;
33
+ --dp-surface-alt: #222630;
34
+ --dp-surface-th: #222630;
35
+ --dp-fg: #e7e9ee;
36
+ --dp-fg-strong: #cbd0d9;
37
+ --dp-fg-muted: #9aa0aa;
38
+ --dp-border: #2a2e38;
39
+ --dp-border-soft: #2a2e38;
40
+ --dp-border-input: #3a3f4a;
41
+ --dp-link: #7aa2f7;
42
+ --dp-code-bg: #1d2330;
43
+ --dp-warn: #fbbf24;
44
+ --dp-paused-bg: #3a2f0d;
45
+ --dp-flash-ok-bg: #0f3520; --dp-flash-ok-fg: #86efac; --dp-flash-ok-bd: #1f5236;
46
+ --dp-flash-err-bg: #3a1a1a; --dp-flash-err-fg: #fca5a5; --dp-flash-err-bd: #5b2929;
47
+ --dp-hint-info-bg: #1a2434; --dp-hint-info-bd: #7aa2f7;
48
+ --dp-hint-warn-bg: #332618; --dp-hint-warn-bd: #fbbf24;
49
+ --dp-hint-critical-bg: #3a1a1a; --dp-hint-critical-bd: #f87171;
50
+ --dp-btn-ok-bg: #0f3520; --dp-btn-ok-bd: #2f9e58; --dp-btn-ok-hover: #143f29;
51
+ --dp-btn-warn-bg: #3a1a1a; --dp-btn-warn-bd: #d05858; --dp-btn-warn-hover: #4a2222;
52
+ --dp-btn-hover: #222630;
53
+ }
54
+ }
55
+
56
+ /* Explicit theme overrides win over the media query (same vars, higher specificity). */
57
+ :root[data-theme="light"] {
58
+ --dp-bg: #f6f7fa;
59
+ --dp-surface: #fff;
60
+ --dp-surface-alt: #fafbfd;
61
+ --dp-surface-th: #f1f3f7;
62
+ --dp-fg: #1a1a1a;
63
+ --dp-fg-strong: #374151;
64
+ --dp-fg-muted: #6b7280;
65
+ --dp-border: #e3e6ec;
66
+ --dp-border-soft: #eef0f4;
67
+ --dp-border-input: #cfd5df;
68
+ --dp-link: #1f4ed8;
69
+ --dp-code-bg: #eef2f7;
70
+ --dp-warn: #b45309;
71
+ --dp-paused-bg: #fffbe9;
72
+ --dp-flash-ok-bg: #e8f6ee; --dp-flash-ok-fg: #14532d; --dp-flash-ok-bd: #c7e6d3;
73
+ --dp-flash-err-bg: #fbe7e6; --dp-flash-err-fg: #7a1d1d; --dp-flash-err-bd: #f0c2bf;
74
+ --dp-hint-info-bg: #eff4ff; --dp-hint-info-bd: #1f4ed8;
75
+ --dp-hint-warn-bg: #fff7e6; --dp-hint-warn-bd: #b45309;
76
+ --dp-hint-critical-bg: #fbe7e6; --dp-hint-critical-bd: #b91c1c;
77
+ --dp-btn-ok-bg: #f1faf3; --dp-btn-ok-bd: #2f9e58; --dp-btn-ok-hover: #def0e3;
78
+ --dp-btn-warn-bg: #fdf2f1; --dp-btn-warn-bd: #d05858; --dp-btn-warn-hover: #f7dcd9;
79
+ --dp-btn-hover: #eef1f6;
80
+ }
81
+
82
+ :root[data-theme="dark"] {
83
+ --dp-bg: #0f1116;
84
+ --dp-surface: #1a1d24;
85
+ --dp-surface-alt: #222630;
86
+ --dp-surface-th: #222630;
87
+ --dp-fg: #e7e9ee;
88
+ --dp-fg-strong: #cbd0d9;
89
+ --dp-fg-muted: #9aa0aa;
90
+ --dp-border: #2a2e38;
91
+ --dp-border-soft: #2a2e38;
92
+ --dp-border-input: #3a3f4a;
93
+ --dp-link: #7aa2f7;
94
+ --dp-code-bg: #1d2330;
95
+ --dp-warn: #fbbf24;
96
+ --dp-paused-bg: #3a2f0d;
97
+ --dp-flash-ok-bg: #0f3520; --dp-flash-ok-fg: #86efac; --dp-flash-ok-bd: #1f5236;
98
+ --dp-flash-err-bg: #3a1a1a; --dp-flash-err-fg: #fca5a5; --dp-flash-err-bd: #5b2929;
99
+ --dp-hint-info-bg: #1a2434; --dp-hint-info-bd: #7aa2f7;
100
+ --dp-hint-warn-bg: #332618; --dp-hint-warn-bd: #fbbf24;
101
+ --dp-hint-critical-bg: #3a1a1a; --dp-hint-critical-bd: #f87171;
102
+ --dp-btn-ok-bg: #0f3520; --dp-btn-ok-bd: #2f9e58; --dp-btn-ok-hover: #143f29;
103
+ --dp-btn-warn-bg: #3a1a1a; --dp-btn-warn-bd: #d05858; --dp-btn-warn-hover: #4a2222;
104
+ --dp-btn-hover: #222630;
105
+ }
106
+
3
107
  * { box-sizing: border-box; }
4
108
 
5
109
  body {
6
110
  margin: 0;
7
111
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
8
112
  font-size: 14px;
9
- color: #1a1a1a;
10
- background: #f6f7fa;
113
+ color: var(--dp-fg);
114
+ background: var(--dp-bg);
11
115
  }
12
116
 
13
- a, a:visited { color: #1f4ed8; text-decoration: none; }
117
+ a, a:visited { color: var(--dp-link); text-decoration: none; }
14
118
  a:hover { text-decoration: underline; }
15
- code { font-family: "SFMono-Regular", Menlo, monospace; font-size: 13px; background: #eef2f7; padding: 1px 4px; border-radius: 3px; }
119
+ code { font-family: "SFMono-Regular", Menlo, monospace; font-size: 13px; background: var(--dp-code-bg); padding: 1px 4px; border-radius: 3px; }
16
120
 
17
121
  .dp-header {
18
122
  display: flex;
@@ -24,25 +128,57 @@ code { font-family: "SFMono-Regular", Menlo, monospace; font-size: 13px; backgro
24
128
  border-bottom: 1px solid #0e1218;
25
129
  }
26
130
  .dp-header a { color: #f6f7fa; }
27
- .dp-logo { font-weight: 600; font-size: 16px; letter-spacing: 0.4px; }
131
+ .dp-logo {
132
+ display: inline-flex;
133
+ align-items: center;
134
+ gap: 10px;
135
+ text-decoration: none;
136
+ }
137
+ .dp-logo svg { display: block; width: 44px; height: 44px; flex: 0 0 auto; }
138
+ .dp-logo-text {
139
+ font-family: ui-monospace, "JetBrains Mono", "SF Mono", Menlo, Consolas, monospace;
140
+ font-weight: 700;
141
+ font-size: 15px;
142
+ letter-spacing: -0.02em;
143
+ }
144
+ .dp-logo-sep { color: #9ca3af; }
28
145
  .dp-nav { flex: 1; }
29
146
  .dp-nav a { margin-left: 22px; opacity: 0.85; }
30
147
  .dp-nav a:hover { opacity: 1; text-decoration: none; }
31
148
 
32
- .dp-refresh {
149
+ /* Header controls: auto-refresh and theme — same visual pattern. */
150
+ .dp-controls {
151
+ display: flex; align-items: center; gap: 16px;
152
+ }
153
+ .dp-control {
33
154
  display: flex; align-items: center; gap: 6px;
34
155
  font-size: 12px; color: rgba(246, 247, 250, 0.7);
35
156
  }
36
- .dp-refresh-label {
157
+ .dp-control-label {
37
158
  margin-right: 4px; text-transform: uppercase; letter-spacing: 0.6px; font-size: 10.5px;
38
159
  }
39
- .dp-refresh-btn {
160
+ .dp-control-btn {
40
161
  background: transparent; color: rgba(246, 247, 250, 0.85);
41
162
  border: 1px solid rgba(246, 247, 250, 0.25);
42
163
  padding: 3px 9px; border-radius: 3px;
43
164
  font-size: 12px; cursor: pointer;
44
165
  font-variant-numeric: tabular-nums;
45
166
  }
167
+ .dp-control-btn:hover { background: rgba(246, 247, 250, 0.08); }
168
+ .dp-control-btn.dp-control-active {
169
+ background: rgba(246, 247, 250, 0.92); color: #1d2330;
170
+ border-color: rgba(246, 247, 250, 0.92); font-weight: 600;
171
+ }
172
+
173
+ /* Backwards-compat aliases for the auto-refresh classes used by JS / templates. */
174
+ .dp-refresh { display: flex; align-items: center; gap: 6px;
175
+ font-size: 12px; color: rgba(246, 247, 250, 0.7); }
176
+ .dp-refresh-label { margin-right: 4px; text-transform: uppercase; letter-spacing: 0.6px; font-size: 10.5px; }
177
+ .dp-refresh-btn { background: transparent; color: rgba(246, 247, 250, 0.85);
178
+ border: 1px solid rgba(246, 247, 250, 0.25);
179
+ padding: 3px 9px; border-radius: 3px;
180
+ font-size: 12px; cursor: pointer;
181
+ font-variant-numeric: tabular-nums; }
46
182
  .dp-refresh-btn:hover { background: rgba(246, 247, 250, 0.08); }
47
183
  .dp-refresh-btn.dp-refresh-active {
48
184
  background: rgba(246, 247, 250, 0.92); color: #1d2330;
@@ -53,56 +189,57 @@ code { font-family: "SFMono-Regular", Menlo, monospace; font-size: 13px; backgro
53
189
  .dp-footer {
54
190
  display: flex; gap: 24px; justify-content: space-between;
55
191
  max-width: 1200px; margin: 0 auto; padding: 12px 28px 24px;
56
- color: #6b7280; font-size: 12px;
192
+ color: var(--dp-fg-muted); font-size: 12px;
57
193
  }
58
194
 
59
195
  h1 { font-size: 22px; margin: 0 0 18px; font-weight: 600; }
60
- h2 { font-size: 15px; margin: 24px 0 10px; font-weight: 600; color: #374151; text-transform: uppercase; letter-spacing: 0.6px; }
196
+ h2 { font-size: 15px; margin: 24px 0 10px; font-weight: 600; color: var(--dp-fg-strong); text-transform: uppercase; letter-spacing: 0.6px; }
61
197
 
62
198
  .dp-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-bottom: 18px; }
63
199
  .dp-stat {
64
- background: #fff; border: 1px solid #e3e6ec; border-radius: 6px;
200
+ background: var(--dp-surface); border: 1px solid var(--dp-border); border-radius: 6px;
65
201
  padding: 14px 16px; display: flex; flex-direction: column; gap: 6px;
66
202
  }
67
- .dp-stat-label { color: #6b7280; font-size: 11px; text-transform: uppercase; letter-spacing: 0.6px; }
203
+ .dp-stat-label { color: var(--dp-fg-muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.6px; }
68
204
  .dp-stat-value { font-size: 22px; font-weight: 600; }
69
205
 
70
- .dp-section { background: #fff; border: 1px solid #e3e6ec; border-radius: 6px; padding: 16px 20px; margin: 14px 0; }
206
+ .dp-section { background: var(--dp-surface); border: 1px solid var(--dp-border); border-radius: 6px; padding: 16px 20px; margin: 14px 0; }
71
207
 
72
208
  .dp-table { width: 100%; border-collapse: collapse; font-size: 13px; }
73
- .dp-table th, .dp-table td { text-align: left; padding: 8px 10px; border-bottom: 1px solid #eef0f4; }
74
- .dp-table th { background: #f1f3f7; font-weight: 600; color: #374151; }
75
- .dp-table tr:hover td { background: #fafbfd; }
209
+ .dp-table th, .dp-table td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--dp-border-soft); }
210
+ .dp-table th { background: var(--dp-surface-th); font-weight: 600; color: var(--dp-fg-strong); }
211
+ .dp-table tr:hover td { background: var(--dp-surface-alt); }
76
212
  .dp-num { text-align: right; font-variant-numeric: tabular-nums; }
77
213
 
78
- .dp-empty { color: #6b7280; font-style: italic; padding: 6px 0; }
214
+ .dp-empty { color: var(--dp-fg-muted); font-style: italic; padding: 6px 0; }
79
215
 
80
216
  .dp-flash { padding: 10px 16px; border-radius: 0; font-size: 13px; }
81
- .dp-flash-ok { background: #e8f6ee; color: #14532d; border-bottom: 1px solid #c7e6d3; }
82
- .dp-flash-err { background: #fbe7e6; color: #7a1d1d; border-bottom: 1px solid #f0c2bf; }
217
+ .dp-flash-ok { background: var(--dp-flash-ok-bg); color: var(--dp-flash-ok-fg); border-bottom: 1px solid var(--dp-flash-ok-bd); }
218
+ .dp-flash-err { background: var(--dp-flash-err-bg); color: var(--dp-flash-err-fg); border-bottom: 1px solid var(--dp-flash-err-bd); }
83
219
 
84
- .dp-warn { color: #b45309; }
85
- .dp-row-paused td { background: #fffbe9; }
220
+ .dp-warn { color: var(--dp-warn); }
221
+ .dp-row-paused td { background: var(--dp-paused-bg); }
86
222
 
87
223
  .dp-list { margin: 0; padding-left: 20px; }
88
224
  .dp-list li { margin: 3px 0; }
89
225
 
90
- .dp-link { color: #1f4ed8; }
226
+ .dp-link { color: var(--dp-link); }
91
227
 
92
228
  .dp-form-inline { display: inline-block; margin-right: 6px; }
93
229
  .dp-btn {
94
- background: #fff; border: 1px solid #cfd5df; padding: 6px 12px;
95
- font-size: 13px; border-radius: 4px; color: #1a1a1a; cursor: pointer;
230
+ background: var(--dp-surface); border: 1px solid var(--dp-border-input); padding: 6px 12px;
231
+ font-size: 13px; border-radius: 4px; color: var(--dp-fg); cursor: pointer;
96
232
  }
97
- .dp-btn:hover { background: #eef1f6; }
98
- .dp-btn-ok { border-color: #2f9e58; color: #14532d; background: #f1faf3; }
99
- .dp-btn-ok:hover { background: #def0e3; }
100
- .dp-btn-warn { border-color: #d05858; color: #7a1d1d; background: #fdf2f1; }
101
- .dp-btn-warn:hover { background: #f7dcd9; }
233
+ .dp-btn:hover { background: var(--dp-btn-hover); }
234
+ .dp-btn-ok { border-color: var(--dp-btn-ok-bd); color: var(--dp-flash-ok-fg); background: var(--dp-btn-ok-bg); }
235
+ .dp-btn-ok:hover { background: var(--dp-btn-ok-hover); }
236
+ .dp-btn-warn { border-color: var(--dp-btn-warn-bd); color: var(--dp-flash-err-fg); background: var(--dp-btn-warn-bg); }
237
+ .dp-btn-warn:hover { background: var(--dp-btn-warn-hover); }
102
238
 
103
239
  .dp-input {
104
- padding: 6px 10px; border: 1px solid #cfd5df; border-radius: 4px;
240
+ padding: 6px 10px; border: 1px solid var(--dp-border-input); border-radius: 4px;
105
241
  font-size: 13px; min-width: 240px;
242
+ background: var(--dp-surface); color: var(--dp-fg);
106
243
  }
107
244
  .dp-search-form { margin-bottom: 14px; }
108
245
 
@@ -113,14 +250,14 @@ h2 { font-size: 15px; margin: 24px 0 10px; font-weight: 600; color: #374151; tex
113
250
  }
114
251
 
115
252
  .dp-hint {
116
- font-size: 12.5px; color: #6b7280; margin-top: 8px;
117
- border-left: 3px solid #cfd5df; padding: 6px 12px; background: #fafbfd;
253
+ font-size: 12.5px; color: var(--dp-fg-muted); margin-top: 8px;
254
+ border-left: 3px solid var(--dp-border-input); padding: 6px 12px; background: var(--dp-surface-alt);
118
255
  border-radius: 0 4px 4px 0;
119
256
  }
120
- .dp-hint code { background: #eef2f7; }
257
+ .dp-hint code { background: var(--dp-code-bg); }
121
258
 
122
259
  .dp-spark {
123
- margin-top: 10px; font-size: 13px; color: #374151;
260
+ margin-top: 10px; font-size: 13px; color: var(--dp-fg-strong);
124
261
  }
125
262
  .dp-spark code {
126
263
  font-family: "SFMono-Regular", Menlo, monospace; font-size: 16px;
@@ -130,28 +267,28 @@ h2 { font-size: 15px; margin: 24px 0 10px; font-weight: 600; color: #374151; tex
130
267
  .dp-hint-list { padding-left: 0; list-style: none; }
131
268
  .dp-hint-list li {
132
269
  margin: 8px 0; padding: 10px 14px;
133
- border-left: 4px solid #cfd5df; background: #fafbfd;
270
+ border-left: 4px solid var(--dp-border-input); background: var(--dp-surface-alt);
134
271
  border-radius: 0 4px 4px 0;
135
272
  font-size: 13px; line-height: 1.5;
136
273
  }
137
- .dp-hint-list li.dp-hint-info { border-left-color: #1f4ed8; background: #eff4ff; }
138
- .dp-hint-list li.dp-hint-warn { border-left-color: #b45309; background: #fff7e6; }
139
- .dp-hint-list li.dp-hint-critical { border-left-color: #b91c1c; background: #fbe7e6; }
274
+ .dp-hint-list li.dp-hint-info { border-left-color: var(--dp-hint-info-bd); background: var(--dp-hint-info-bg); }
275
+ .dp-hint-list li.dp-hint-warn { border-left-color: var(--dp-hint-warn-bd); background: var(--dp-hint-warn-bg); }
276
+ .dp-hint-list li.dp-hint-critical { border-left-color: var(--dp-hint-critical-bd); background: var(--dp-hint-critical-bg); }
140
277
  .dp-hint-badge {
141
278
  display: inline-block; margin-right: 8px; padding: 1px 7px;
142
279
  font-size: 10.5px; font-weight: 700; letter-spacing: 0.5px;
143
- border-radius: 3px; color: #fff; background: #6b7280;
280
+ border-radius: 3px; color: #fff; background: var(--dp-fg-muted);
144
281
  vertical-align: 1px;
145
282
  }
146
- .dp-hint-info .dp-hint-badge { background: #1f4ed8; }
147
- .dp-hint-warn .dp-hint-badge { background: #b45309; }
148
- .dp-hint-critical .dp-hint-badge { background: #b91c1c; }
283
+ .dp-hint-info .dp-hint-badge { background: var(--dp-hint-info-bd); }
284
+ .dp-hint-warn .dp-hint-badge { background: var(--dp-hint-warn-bd); }
285
+ .dp-hint-critical .dp-hint-badge { background: var(--dp-hint-critical-bd); }
149
286
 
150
287
  .dp-pagination {
151
288
  display: flex; justify-content: space-between; align-items: center;
152
289
  padding: 14px 0; gap: 12px; flex-wrap: wrap;
153
290
  }
154
- .dp-pagination-info { color: #6b7280; font-size: 13px; font-variant-numeric: tabular-nums; }
291
+ .dp-pagination-info { color: var(--dp-fg-muted); font-size: 13px; font-variant-numeric: tabular-nums; }
155
292
  .dp-pagination-nav { display: flex; gap: 6px; }
156
293
  .dp-pagination-nav .dp-btn { padding: 4px 10px; font-size: 12.5px; text-decoration: none; }
157
294
  .dp-btn-disabled { opacity: 0.4; cursor: default; pointer-events: none; }
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DispatchPolicy
4
+ # Serves vendored static assets at content-addressed URLs so browsers
5
+ # can cache them forever. The digest is part of the URL, not a query
6
+ # string, so caches keyed on path alone still bust on upgrade.
7
+ class AssetsController < ApplicationController
8
+ skip_forgery_protection
9
+
10
+ def turbo
11
+ serve(Assets::TURBO_BODY, Assets::TURBO_DIGEST, "application/javascript")
12
+ end
13
+
14
+ def logo
15
+ serve(Assets::LOGO_SMALL_BODY, Assets::LOGO_SMALL_DIGEST, "image/svg+xml")
16
+ end
17
+
18
+ private
19
+
20
+ def serve(body, digest, content_type)
21
+ if params[:digest] != digest
22
+ head :not_found
23
+ return
24
+ end
25
+
26
+ response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
27
+ response.headers["ETag"] = %("#{digest}")
28
+ send_data body, type: content_type, disposition: "inline"
29
+ end
30
+ end
31
+ end
@@ -4,14 +4,27 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width,initial-scale=1">
6
6
  <title>dispatch_policy</title>
7
+ <link rel="icon" type="image/svg+xml" href="<%= logo_asset_path(digest: DispatchPolicy::Assets::LOGO_SMALL_DIGEST) %>">
7
8
  <%= csrf_meta_tags %>
8
9
  <%# Same-URL Turbo.visit (used by the auto-refresh) is treated as a "page %>
9
10
  <%# refresh"; with these two meta tags Turbo morphs the body in place and %>
10
11
  <%# preserves scroll position. %>
11
12
  <meta name="turbo-refresh-method" content="morph">
12
13
  <meta name="turbo-refresh-scroll" content="preserve">
14
+ <%# Apply the stored theme synchronously before the stylesheet renders, %>
15
+ <%# otherwise users with explicit dark mode would see a light-mode flash. %>
16
+ <script>
17
+ (function () {
18
+ try {
19
+ var t = localStorage.getItem("dispatch_policy:theme");
20
+ if (t === "dark" || t === "light") {
21
+ document.documentElement.setAttribute("data-theme", t);
22
+ }
23
+ } catch (e) { /* localStorage unavailable; fall through to auto */ }
24
+ })();
25
+ </script>
13
26
  <style><%= DispatchPolicy::Engine.root.join("app/assets/stylesheets/dispatch_policy/application.css").read.html_safe %></style>
14
- <script src="https://cdn.jsdelivr.net/npm/@hotwired/turbo@8.0.4/dist/turbo.es2017-umd.min.js"></script>
27
+ <script src="<%= turbo_asset_path(digest: DispatchPolicy::Assets::TURBO_DIGEST) %>"></script>
15
28
  <script>
16
29
  (function () {
17
30
  var KEY = "dispatch_policy:refresh-interval";
@@ -90,24 +103,87 @@
90
103
  }
91
104
  });
92
105
  })();
106
+
107
+ // Theme controls (auto / light / dark). Persists to localStorage so
108
+ // it survives across sessions; the early script in <head> applies
109
+ // the stored theme before paint to avoid FOUC.
110
+ (function () {
111
+ var KEY = "dispatch_policy:theme";
112
+
113
+ function getTheme() {
114
+ try { return localStorage.getItem(KEY) || "auto"; }
115
+ catch (e) { return "auto"; }
116
+ }
117
+
118
+ function applyTheme(theme) {
119
+ if (theme === "light" || theme === "dark") {
120
+ document.documentElement.setAttribute("data-theme", theme);
121
+ } else {
122
+ document.documentElement.removeAttribute("data-theme");
123
+ }
124
+ }
125
+
126
+ function setTheme(theme) {
127
+ try { localStorage.setItem(KEY, theme); } catch (e) { /* ignore */ }
128
+ applyTheme(theme);
129
+ syncControls();
130
+ }
131
+
132
+ function syncControls() {
133
+ var current = getTheme();
134
+ document.querySelectorAll("[data-dp-theme]").forEach(function (btn) {
135
+ if (btn.getAttribute("data-dp-theme") === current) {
136
+ btn.classList.add("dp-control-active");
137
+ } else {
138
+ btn.classList.remove("dp-control-active");
139
+ }
140
+ });
141
+ }
142
+
143
+ function bindControls() {
144
+ document.querySelectorAll("[data-dp-theme]").forEach(function (btn) {
145
+ if (btn.dataset.bound) return;
146
+ btn.dataset.bound = "1";
147
+ btn.addEventListener("click", function (e) {
148
+ e.preventDefault();
149
+ setTheme(btn.getAttribute("data-dp-theme"));
150
+ });
151
+ });
152
+ syncControls();
153
+ }
154
+
155
+ document.addEventListener("DOMContentLoaded", bindControls);
156
+ document.addEventListener("turbo:load", bindControls);
157
+ })();
93
158
  </script>
94
159
  </head>
95
160
  <body>
96
161
  <header class="dp-header">
97
162
  <div class="dp-brand">
98
- <%= link_to "dispatch_policy", root_path, class: "dp-logo" %>
163
+ <%= link_to root_path, class: "dp-logo" do %>
164
+ <%= DispatchPolicy::Assets::LOGO_LARGE_BODY.html_safe %>
165
+ <span class="dp-logo-text">dispatch<span class="dp-logo-sep">_</span>policy</span>
166
+ <% end %>
99
167
  </div>
100
168
  <nav class="dp-nav">
101
169
  <%= link_to "Dashboard", root_path %>
102
170
  <%= link_to "Policies", policies_path %>
103
171
  <%= link_to "Partitions", partitions_path %>
104
172
  </nav>
105
- <div class="dp-refresh">
106
- <span class="dp-refresh-label">Auto-refresh</span>
107
- <button type="button" class="dp-refresh-btn" data-dp-refresh="0">off</button>
108
- <button type="button" class="dp-refresh-btn" data-dp-refresh="2">2s</button>
109
- <button type="button" class="dp-refresh-btn" data-dp-refresh="5">5s</button>
110
- <button type="button" class="dp-refresh-btn" data-dp-refresh="10">10s</button>
173
+ <div class="dp-controls">
174
+ <div class="dp-refresh">
175
+ <span class="dp-refresh-label">Auto-refresh</span>
176
+ <button type="button" class="dp-refresh-btn" data-dp-refresh="0">off</button>
177
+ <button type="button" class="dp-refresh-btn" data-dp-refresh="2">2s</button>
178
+ <button type="button" class="dp-refresh-btn" data-dp-refresh="5">5s</button>
179
+ <button type="button" class="dp-refresh-btn" data-dp-refresh="10">10s</button>
180
+ </div>
181
+ <div class="dp-control">
182
+ <span class="dp-control-label">Theme</span>
183
+ <button type="button" class="dp-control-btn" data-dp-theme="auto">auto</button>
184
+ <button type="button" class="dp-control-btn" data-dp-theme="light">light</button>
185
+ <button type="button" class="dp-control-btn" data-dp-theme="dark">dark</button>
186
+ </div>
111
187
  </div>
112
188
  </header>
113
189
  <% if flash[:notice] %><div class="dp-flash dp-flash-ok"><%= flash[:notice] %></div><% end %>
data/config/routes.rb CHANGED
@@ -19,4 +19,7 @@ DispatchPolicy::Engine.routes.draw do
19
19
  end
20
20
 
21
21
  resources :staged_jobs, only: %i[show]
22
+
23
+ get "assets/turbo-:digest.js", to: "assets#turbo", as: :turbo_asset
24
+ get "assets/logo-:digest.svg", to: "assets#logo", as: :logo_asset
22
25
  end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest/sha1"
4
+ require "pathname"
5
+
6
+ module DispatchPolicy
7
+ # Vendored static assets served by AssetsController. Bodies are read
8
+ # once at boot and the digest is embedded in the URL so the response
9
+ # can be marked `Cache-Control: immutable` — bumping the vendored file
10
+ # produces a new digest and the host's browsers refetch automatically.
11
+ #
12
+ # To upgrade Turbo (current: 8.0.4), overwrite the file from the same
13
+ # CDN/version pair the rest of the Hotwire ecosystem uses:
14
+ #
15
+ # curl -fsSL https://cdn.jsdelivr.net/npm/@hotwired/turbo@<VERSION>/dist/turbo.es2017-umd.min.js \
16
+ # -o app/assets/javascripts/dispatch_policy/turbo.es2017-umd.min.js
17
+ #
18
+ # No other code change is required — TURBO_DIGEST is content-addressed.
19
+ module Assets
20
+ JS_ROOT = Pathname.new(File.expand_path("../../app/assets/javascripts/dispatch_policy", __dir__))
21
+ IMAGE_ROOT = Pathname.new(File.expand_path("../../app/assets/images/dispatch_policy", __dir__))
22
+
23
+ TURBO_BODY = JS_ROOT.join("turbo.es2017-umd.min.js").read.freeze
24
+ TURBO_DIGEST = Digest::SHA1.hexdigest(TURBO_BODY)[0, 12].freeze
25
+
26
+ # The "large" mark (≥ 48px) is used in the admin header — three
27
+ # chevrons with the rightmost one carrying state color via
28
+ # `currentColor`. The "small" mark (≤ 32px) is used as the SVG
29
+ # favicon, where the lanes get lost at downsampling. Both are
30
+ # themable: wrapping with `style="color: …"` swaps the state color
31
+ # (ok/info/neutral/warn/error).
32
+ LOGO_LARGE_BODY = IMAGE_ROOT.join("logo-large.svg").read.freeze
33
+ LOGO_LARGE_DIGEST = Digest::SHA1.hexdigest(LOGO_LARGE_BODY)[0, 12].freeze
34
+
35
+ LOGO_SMALL_BODY = IMAGE_ROOT.join("logo-small.svg").read.freeze
36
+ LOGO_SMALL_DIGEST = Digest::SHA1.hexdigest(LOGO_SMALL_BODY)[0, 12].freeze
37
+ end
38
+ end
@@ -5,8 +5,9 @@ require "rails/engine"
5
5
  module DispatchPolicy
6
6
  # Mounted by the host app. Views, controllers, and AR models live under
7
7
  # `app/`; the layout inlines the engine CSS by reading
8
- # `app/assets/stylesheets/dispatch_policy/application.css` at render time,
9
- # so no asset pipeline integration is required.
8
+ # `app/assets/stylesheets/dispatch_policy/application.css` at render
9
+ # time, and serves the vendored Turbo bundle through `AssetsController`
10
+ # at a content-addressed URL — no asset pipeline integration required.
10
11
  class Engine < ::Rails::Engine
11
12
  isolate_namespace DispatchPolicy
12
13
  end
@@ -245,14 +245,27 @@ module DispatchPolicy
245
245
  if half_life_seconds && half_life_seconds.to_f.positive?
246
246
  # decay constant τ such that exp(-Δt/τ) halves every half_life:
247
247
  # τ = half_life / ln(2). NULLIF guards a degenerate τ=0.
248
+ #
249
+ # The GREATEST(..., -700) clamp keeps `exp()` from raising
250
+ # `value out of range: underflow` when a partition has been
251
+ # idle for many half-lives. Postgres throws around
252
+ # `exp(-746)` on double precision; -700 still yields a finite
253
+ # ~9.86e-305, which is effectively zero for the EWMA. Without
254
+ # the clamp, a partition idle long enough for Δt/τ to exceed
255
+ # ~746 breaks every subsequent admission UPDATE on it: Tick
256
+ # rolls back the whole TX, the staged rows return, and the
257
+ # partition never drains.
248
258
  decay_idx = params.size + 1
249
259
  admitted_idx_for_ewma = 3
250
260
  decay_tau = half_life_seconds.to_f / Math.log(2)
251
261
  params << decay_tau
252
262
  decay_sql = <<~SQL.squish
253
263
  decayed_admits = decayed_admits *
254
- exp(- COALESCE(EXTRACT(EPOCH FROM (now() - decayed_admits_at)), 0)
255
- / NULLIF($#{decay_idx}::double precision, 0))
264
+ exp(GREATEST(
265
+ - COALESCE(EXTRACT(EPOCH FROM (now() - decayed_admits_at)), 0)
266
+ / NULLIF($#{decay_idx}::double precision, 0),
267
+ -700
268
+ ))
256
269
  + $#{admitted_idx_for_ewma},
257
270
  decayed_admits_at = now(),
258
271
  SQL
@@ -336,6 +349,16 @@ module DispatchPolicy
336
349
  values_sql << "($#{base + 1}, $#{base + 2}, $#{base + 3}, now(), now())"
337
350
  params.push(row[:policy_name], row[:partition_key], row[:active_job_id])
338
351
  end
352
+ # ON CONFLICT (active_job_id) DO NOTHING covers two paths that
353
+ # the around_perform tracker exercises on its own:
354
+ # 1) the around_perform inflight insert runs even when the row
355
+ # was already pre-inserted by Tick (concurrency-gated policies);
356
+ # 2) a stale row that survived a crash gets re-inserted by the
357
+ # around_perform without colliding while the sweeper is still
358
+ # catching up.
359
+ # Admission proper can no longer collide here: Tick regenerates
360
+ # active_job_id before this insert, so each admission contributes a
361
+ # fresh UUID.
339
362
  connection.exec_query(
340
363
  <<~SQL.squish,
341
364
  INSERT INTO #{INFLIGHT_TABLE}
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
4
+
3
5
  module DispatchPolicy
4
6
  # One pass of admission for a single policy.
5
7
  #
@@ -219,6 +221,39 @@ module DispatchPolicy
219
221
  # scheduled in the future, or another tick raced us to them).
220
222
  next if rows.empty?
221
223
 
224
+ # Decouple the active_job_id we hand to the adapter from the
225
+ # staged payload's job_id. Adapters that use active_job_id as
226
+ # the PK of their jobs table (good_job, solid_queue) would
227
+ # otherwise collide when a residual row from a previous
228
+ # admission of the same job still exists — most commonly a
229
+ # retry-restage whose original adapter row has not been
230
+ # finalized yet. The collision raises RecordNotUnique inside
231
+ # the admission TX, rolls everything back, and the staged
232
+ # row keeps re-colliding on every subsequent tick.
233
+ #
234
+ # The staged-side identity is staged_jobs.id; active_job_id
235
+ # only needs to be unique at adapter-insert time. We mutate
236
+ # the row's job_data in place so both the inflight pre-insert
237
+ # below and Forwarder.dispatch (via Serializer.deserialize)
238
+ # observe the new id.
239
+ #
240
+ # Logs the (staged_job_id, original_active_job_id, new_active_job_id)
241
+ # mapping at debug level so operators can grep-bridge the two
242
+ # identities when troubleshooting — `perform_later` returns the
243
+ # original; the adapter row and the worker logs use the new one.
244
+ logger = DispatchPolicy.config.logger
245
+ rows.each do |row|
246
+ old_aj_id = row["job_data"]["job_id"]
247
+ new_aj_id = SecureRandom.uuid
248
+ row["job_data"]["job_id"] = new_aj_id
249
+
250
+ logger&.debug(
251
+ "[dispatch_policy] admit staged_id=#{row['id']} " \
252
+ "policy=#{@policy_name} partition=#{partition['partition_key']} " \
253
+ "active_job_id: #{old_aj_id} -> #{new_aj_id}"
254
+ )
255
+ end
256
+
222
257
  # Pre-insert an inflight row per admitted job so the concurrency
223
258
  # gate sees them immediately. With a concurrency gate, use its
224
259
  # (coarser) partition key so the gate's COUNT(*) keeps aggregating
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DispatchPolicy
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.1"
5
5
  end
@@ -26,6 +26,7 @@ require_relative "dispatch_policy/tick"
26
26
  require_relative "dispatch_policy/tick_loop"
27
27
  require_relative "dispatch_policy/job_extension"
28
28
  require_relative "dispatch_policy/operator_hints"
29
+ require_relative "dispatch_policy/assets"
29
30
 
30
31
  module DispatchPolicy
31
32
  class Error < StandardError; end