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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +139 -0
- data/README.md +76 -26
- data/app/assets/images/dispatch_policy/logo-large.svg +9 -0
- data/app/assets/images/dispatch_policy/logo-small.svg +7 -0
- data/app/assets/javascripts/dispatch_policy/turbo.es2017-umd.min.js +35 -0
- data/app/assets/stylesheets/dispatch_policy/application.css +180 -43
- data/app/controllers/dispatch_policy/assets_controller.rb +31 -0
- data/app/views/layouts/dispatch_policy/application.html.erb +84 -8
- data/config/routes.rb +3 -0
- data/lib/dispatch_policy/assets.rb +38 -0
- data/lib/dispatch_policy/engine.rb +3 -2
- data/lib/dispatch_policy/repository.rb +25 -2
- data/lib/dispatch_policy/tick.rb +35 -0
- data/lib/dispatch_policy/version.rb +1 -1
- data/lib/dispatch_policy.rb +1 -0
- metadata +35 -1
|
@@ -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:
|
|
10
|
-
background:
|
|
113
|
+
color: var(--dp-fg);
|
|
114
|
+
background: var(--dp-bg);
|
|
11
115
|
}
|
|
12
116
|
|
|
13
|
-
a, a:visited { color:
|
|
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:
|
|
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 {
|
|
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
|
-
|
|
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-
|
|
157
|
+
.dp-control-label {
|
|
37
158
|
margin-right: 4px; text-transform: uppercase; letter-spacing: 0.6px; font-size: 10.5px;
|
|
38
159
|
}
|
|
39
|
-
.dp-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
74
|
-
.dp-table th { background:
|
|
75
|
-
.dp-table tr:hover td { background:
|
|
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:
|
|
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
|
|
82
|
-
.dp-flash-err { background:
|
|
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:
|
|
85
|
-
.dp-row-paused td { background:
|
|
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:
|
|
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:
|
|
95
|
-
font-size: 13px; border-radius: 4px; color:
|
|
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:
|
|
98
|
-
.dp-btn-ok
|
|
99
|
-
.dp-btn-ok:hover
|
|
100
|
-
.dp-btn-warn { border-color:
|
|
101
|
-
.dp-btn-warn:hover { background:
|
|
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
|
|
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:
|
|
117
|
-
border-left: 3px solid
|
|
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:
|
|
257
|
+
.dp-hint code { background: var(--dp-code-bg); }
|
|
121
258
|
|
|
122
259
|
.dp-spark {
|
|
123
|
-
margin-top: 10px; font-size: 13px; color:
|
|
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
|
|
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:
|
|
138
|
-
.dp-hint-list li.dp-hint-warn { border-left-color:
|
|
139
|
-
.dp-hint-list li.dp-hint-critical { border-left-color:
|
|
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:
|
|
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:
|
|
147
|
-
.dp-hint-warn .dp-hint-badge { background:
|
|
148
|
-
.dp-hint-critical .dp-hint-badge { background:
|
|
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:
|
|
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="
|
|
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
|
|
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-
|
|
106
|
-
<
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
@@ -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
|
|
9
|
-
#
|
|
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(
|
|
255
|
-
|
|
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}
|
data/lib/dispatch_policy/tick.rb
CHANGED
|
@@ -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
|
data/lib/dispatch_policy.rb
CHANGED
|
@@ -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
|