dispatch_policy 0.1.0 → 0.3.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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/MIT-LICENSE +16 -17
  3. data/README.md +449 -288
  4. data/app/assets/stylesheets/dispatch_policy/application.css +157 -0
  5. data/app/controllers/dispatch_policy/application_controller.rb +45 -1
  6. data/app/controllers/dispatch_policy/dashboard_controller.rb +91 -0
  7. data/app/controllers/dispatch_policy/partitions_controller.rb +122 -0
  8. data/app/controllers/dispatch_policy/policies_controller.rb +94 -241
  9. data/app/controllers/dispatch_policy/staged_jobs_controller.rb +9 -0
  10. data/app/models/dispatch_policy/adaptive_concurrency_stats.rb +11 -81
  11. data/app/models/dispatch_policy/inflight_job.rb +12 -0
  12. data/app/models/dispatch_policy/partition.rb +21 -0
  13. data/app/models/dispatch_policy/staged_job.rb +4 -97
  14. data/app/models/dispatch_policy/tick_sample.rb +11 -0
  15. data/app/views/dispatch_policy/dashboard/index.html.erb +109 -0
  16. data/app/views/dispatch_policy/partitions/index.html.erb +63 -0
  17. data/app/views/dispatch_policy/partitions/show.html.erb +106 -0
  18. data/app/views/dispatch_policy/policies/index.html.erb +15 -37
  19. data/app/views/dispatch_policy/policies/show.html.erb +140 -216
  20. data/app/views/dispatch_policy/shared/_capacity.html.erb +67 -0
  21. data/app/views/dispatch_policy/shared/_hints.html.erb +13 -0
  22. data/app/views/dispatch_policy/shared/_partition_row.html.erb +12 -0
  23. data/app/views/dispatch_policy/staged_jobs/show.html.erb +31 -0
  24. data/app/views/layouts/dispatch_policy/application.html.erb +95 -238
  25. data/config/routes.rb +18 -2
  26. data/db/migrate/20260501000001_create_dispatch_policy_tables.rb +103 -0
  27. data/lib/dispatch_policy/bypass.rb +23 -0
  28. data/lib/dispatch_policy/config.rb +85 -0
  29. data/lib/dispatch_policy/context.rb +50 -0
  30. data/lib/dispatch_policy/cursor_pagination.rb +121 -0
  31. data/lib/dispatch_policy/decision.rb +22 -0
  32. data/lib/dispatch_policy/engine.rb +4 -27
  33. data/lib/dispatch_policy/forwarder.rb +63 -0
  34. data/lib/dispatch_policy/gate.rb +10 -38
  35. data/lib/dispatch_policy/gates/adaptive_concurrency.rb +99 -97
  36. data/lib/dispatch_policy/gates/concurrency.rb +45 -26
  37. data/lib/dispatch_policy/gates/throttle.rb +65 -37
  38. data/lib/dispatch_policy/inflight_tracker.rb +174 -0
  39. data/lib/dispatch_policy/job_extension.rb +155 -0
  40. data/lib/dispatch_policy/operator_hints.rb +126 -0
  41. data/lib/dispatch_policy/pipeline.rb +48 -0
  42. data/lib/dispatch_policy/policy.rb +62 -47
  43. data/lib/dispatch_policy/policy_dsl.rb +120 -0
  44. data/lib/dispatch_policy/railtie.rb +35 -0
  45. data/lib/dispatch_policy/registry.rb +46 -0
  46. data/lib/dispatch_policy/repository.rb +723 -0
  47. data/lib/dispatch_policy/serializer.rb +36 -0
  48. data/lib/dispatch_policy/tick.rb +263 -172
  49. data/lib/dispatch_policy/tick_loop.rb +59 -26
  50. data/lib/dispatch_policy/version.rb +1 -1
  51. data/lib/dispatch_policy.rb +71 -46
  52. data/lib/generators/dispatch_policy/install/install_generator.rb +70 -0
  53. data/lib/generators/dispatch_policy/install/templates/create_dispatch_policy_tables.rb.tt +95 -0
  54. data/lib/generators/dispatch_policy/install/templates/dispatch_tick_loop_job.rb.tt +53 -0
  55. data/lib/generators/dispatch_policy/install/templates/initializer.rb.tt +11 -0
  56. metadata +101 -43
  57. data/CHANGELOG.md +0 -12
  58. data/app/models/dispatch_policy/partition_inflight_count.rb +0 -42
  59. data/app/models/dispatch_policy/partition_observation.rb +0 -49
  60. data/app/models/dispatch_policy/throttle_bucket.rb +0 -41
  61. data/db/migrate/20260424000001_create_dispatch_policy_tables.rb +0 -80
  62. data/db/migrate/20260424000002_create_adaptive_concurrency_stats.rb +0 -22
  63. data/db/migrate/20260424000003_create_adaptive_concurrency_samples.rb +0 -25
  64. data/db/migrate/20260424000004_rename_samples_to_partition_observations.rb +0 -32
  65. data/lib/dispatch_policy/active_job_perform_all_later_patch.rb +0 -32
  66. data/lib/dispatch_policy/dispatch_context.rb +0 -53
  67. data/lib/dispatch_policy/dispatchable.rb +0 -120
  68. data/lib/dispatch_policy/gates/fair_interleave.rb +0 -32
  69. data/lib/dispatch_policy/gates/global_cap.rb +0 -26
  70. data/lib/dispatch_policy/install_generator.rb +0 -23
@@ -0,0 +1,109 @@
1
+ <section class="dp-stats">
2
+ <div class="dp-stat"><span class="dp-stat-label">Staged</span><span class="dp-stat-value"><%= format_count(@totals[:staged]) %></span></div>
3
+ <div class="dp-stat"><span class="dp-stat-label">Partitions</span><span class="dp-stat-value"><%= format_count(@totals[:partitions]) %></span></div>
4
+ <div class="dp-stat"><span class="dp-stat-label">Active</span><span class="dp-stat-value"><%= format_count(@totals[:active_parts]) %></span></div>
5
+ <div class="dp-stat"><span class="dp-stat-label">Paused</span><span class="dp-stat-value <%= "dp-warn" if @totals[:paused_parts].positive? %>"><%= format_count(@totals[:paused_parts]) %></span></div>
6
+ <div class="dp-stat"><span class="dp-stat-label">In flight</span><span class="dp-stat-value"><%= format_count(@totals[:in_flight]) %></span></div>
7
+ </section>
8
+
9
+ <%= render "dispatch_policy/shared/hints", hints: @hints %>
10
+ <%= render "dispatch_policy/shared/capacity",
11
+ capacity: @capacity,
12
+ pending_trend: @pending_trend,
13
+ pending_buckets: @pending_buckets %>
14
+
15
+ <section class="dp-section">
16
+ <h2>Throughput</h2>
17
+ <table class="dp-table">
18
+ <thead>
19
+ <tr><th>Window</th><th class="dp-num">Jobs admitted</th><th class="dp-num">Ticks</th><th class="dp-num">Avg tick</th><th class="dp-num">Max tick</th><th class="dp-num">Forward fail</th></tr>
20
+ </thead>
21
+ <tbody>
22
+ <% @windows.each do |label, m| %>
23
+ <% fail_pct = m[:jobs_admitted].positive? ? (m[:forward_failures].to_f / m[:jobs_admitted] * 100) : 0.0 %>
24
+ <tr>
25
+ <td><strong><%= label %></strong></td>
26
+ <td class="dp-num"><%= format_count(m[:jobs_admitted]) %></td>
27
+ <td class="dp-num"><%= format_count(m[:ticks]) %></td>
28
+ <td class="dp-num"><%= format_duration_ms(m[:avg_duration_ms]) %></td>
29
+ <td class="dp-num"><%= format_duration_ms(m[:max_duration_ms]) %></td>
30
+ <td class="dp-num <%= "dp-warn" if fail_pct >= 1.0 %>">
31
+ <%= format_count(m[:forward_failures]) %>
32
+ <% if m[:jobs_admitted].positive? && m[:forward_failures].positive? %>
33
+ <span style="color:#9ca3af; font-size:11px;">(<%= format("%.2f%%", fail_pct) %>)</span>
34
+ <% end %>
35
+ </td>
36
+ </tr>
37
+ <% end %>
38
+ </tbody>
39
+ </table>
40
+ </section>
41
+
42
+ <section class="dp-section">
43
+ <h2>Round-trip across active partitions</h2>
44
+ <% if @round_trip[:active_partitions].zero? %>
45
+ <p class="dp-empty">No active partitions with pending jobs.</p>
46
+ <% else %>
47
+ <div class="dp-stats">
48
+ <div class="dp-stat"><span class="dp-stat-label">Active w/ pending</span><span class="dp-stat-value"><%= format_count(@round_trip[:active_partitions]) %></span></div>
49
+ <div class="dp-stat"><span class="dp-stat-label">Never checked</span><span class="dp-stat-value <%= "dp-warn" if @round_trip[:never_checked].positive? %>"><%= format_count(@round_trip[:never_checked]) %></span></div>
50
+ <div class="dp-stat"><span class="dp-stat-label">In backoff</span><span class="dp-stat-value"><%= format_count(@round_trip[:in_backoff]) %></span></div>
51
+ <div class="dp-stat"><span class="dp-stat-label">Oldest age</span><span class="dp-stat-value"><%= format_duration_seconds(@round_trip[:oldest_age_seconds]) %></span></div>
52
+ <div class="dp-stat"><span class="dp-stat-label">P50 age</span><span class="dp-stat-value"><%= format_duration_seconds(@round_trip[:p50_age_seconds]) %></span></div>
53
+ <div class="dp-stat"><span class="dp-stat-label">P95 age</span><span class="dp-stat-value"><%= format_duration_seconds(@round_trip[:p95_age_seconds]) %></span></div>
54
+ </div>
55
+ <p class="dp-hint">
56
+ If <strong>oldest age</strong> is bigger than your tolerable latency for the slowest partition, increase
57
+ <code>partition_batch_size</code> or shard ticks per <code>policy_name</code>.
58
+ If <strong>never checked</strong> stays positive across refreshes, the tick is not keeping up — same remedy.
59
+ </p>
60
+ <% end %>
61
+ </section>
62
+
63
+ <section class="dp-section">
64
+ <h2>Policies</h2>
65
+ <% if @policies.empty? %>
66
+ <p class="dp-empty">No policies have produced staged jobs yet.</p>
67
+ <% else %>
68
+ <table class="dp-table">
69
+ <thead>
70
+ <tr>
71
+ <th>Name</th>
72
+ <th class="dp-num">Pending</th>
73
+ <th class="dp-num">In flight</th>
74
+ <th class="dp-num">Admit/min (1m)</th>
75
+ <th class="dp-num">Ticks (1m)</th>
76
+ <th class="dp-num">Avg tick</th>
77
+ <th class="dp-num">P95 age</th>
78
+ <th class="dp-num">Backoff</th>
79
+ <th>Top denial</th>
80
+ <th>Last admit</th>
81
+ <th></th>
82
+ </tr>
83
+ </thead>
84
+ <tbody>
85
+ <% @policies.each do |p| %>
86
+ <tr>
87
+ <td><%= link_to p[:name], policy_path(p[:name]), class: "dp-link" %></td>
88
+ <td class="dp-num"><%= format_count(p[:pending]) %></td>
89
+ <td class="dp-num"><%= format_count(p[:in_flight]) %></td>
90
+ <td class="dp-num"><%= format_count(p[:admitted_1m]) %></td>
91
+ <td class="dp-num"><%= format_count(p[:ticks_1m]) %></td>
92
+ <td class="dp-num"><%= format_duration_ms(p[:avg_tick_ms_1m]) %></td>
93
+ <td class="dp-num"><%= format_duration_seconds(p[:p95_age_seconds]) %></td>
94
+ <td class="dp-num <%= "dp-warn" if p[:in_backoff].to_i.positive? %>"><%= format_count(p[:in_backoff]) %></td>
95
+ <td>
96
+ <% if p[:top_denial_reason] %>
97
+ <code><%= p[:top_denial_reason] %></code> ×<%= p[:top_denial_count] %>
98
+ <% else %>
99
+
100
+ <% end %>
101
+ </td>
102
+ <td><%= format_time(p[:last_admit_at]) %></td>
103
+ <td><%= link_to "Partitions →", partitions_path(policy: p[:name]), class: "dp-link" %></td>
104
+ </tr>
105
+ <% end %>
106
+ </tbody>
107
+ </table>
108
+ <% end %>
109
+ </section>
@@ -0,0 +1,63 @@
1
+ <h1>
2
+ Partitions<% if @policy %> · <code><%= @policy %></code><% end %><% if @shard %> · shard <code><%= @shard %></code><% end %>
3
+ <small style="font-weight:400; color:#6b7280; font-size:14px;">
4
+ <%= format_count(@total) %> match<%= @total == 1 ? "" : "es" %>
5
+ </small>
6
+ </h1>
7
+
8
+ <%= form_with url: partitions_path, method: :get, local: true, class: "dp-search-form" do |f| %>
9
+ <%= f.hidden_field :policy, value: @policy if @policy.present? %>
10
+ <% if @shards.size > 1 %>
11
+ <%= f.select :shard, [["all shards", ""]] + @shards.map { |s| [s, s] }, { selected: @shard }, class: "dp-input" %>
12
+ <% elsif @shards.size == 1 %>
13
+ <%= f.hidden_field :shard, value: @shard if @shard.present? %>
14
+ <% end %>
15
+ <%= f.text_field :q, value: @query, placeholder: "search partition_key…", class: "dp-input" %>
16
+ <%= f.select :sort,
17
+ DispatchPolicy::CursorPagination::SORTS.map { |k, defn| ["sort: #{defn[:label]}", k] },
18
+ { selected: @sort },
19
+ class: "dp-input" %>
20
+ <label style="font-size:12.5px; margin: 0 6px;">
21
+ <%= f.check_box :only_pending, { checked: @only_pending }, "1", "0" %>
22
+ only pending&gt;0
23
+ </label>
24
+ <%= f.submit "Apply", class: "dp-btn" %>
25
+ <% end %>
26
+
27
+ <% if @partitions.any? %>
28
+ <table class="dp-table">
29
+ <thead>
30
+ <tr>
31
+ <th>Policy</th><th>Shard</th><th>Queue</th><th>Partition key</th><th>Status</th>
32
+ <th class="dp-num">Pending</th><th class="dp-num">Lifetime</th>
33
+ <th>Next eligible</th><th>Last admit</th><th>Last enq</th>
34
+ </tr>
35
+ </thead>
36
+ <tbody>
37
+ <% @partitions.each do |p| %>
38
+ <%= render "dispatch_policy/shared/partition_row", partition: p %>
39
+ <% end %>
40
+ </tbody>
41
+ </table>
42
+
43
+ <div class="dp-pagination">
44
+ <span class="dp-pagination-info">
45
+ <%= format_count(@partitions.size) %> shown
46
+ <% if @cursor %> · paged via cursor — use the browser back button to go back <% end %>
47
+ </span>
48
+ <span class="dp-pagination-nav">
49
+ <% if @cursor %>
50
+ <%= link_to "« first", partitions_path(pagination_params(cursor: nil)), class: "dp-btn" %>
51
+ <% else %>
52
+ <span class="dp-btn dp-btn-disabled">« first</span>
53
+ <% end %>
54
+ <% if @next_cursor %>
55
+ <%= link_to "next ›", partitions_path(pagination_params(cursor: @next_cursor)), class: "dp-btn" %>
56
+ <% else %>
57
+ <span class="dp-btn dp-btn-disabled">next ›</span>
58
+ <% end %>
59
+ </span>
60
+ </div>
61
+ <% else %>
62
+ <p class="dp-empty">No partitions match.</p>
63
+ <% end %>
@@ -0,0 +1,106 @@
1
+ <h1>
2
+ <%= link_to "← all partitions", partitions_path(policy: @partition.policy_name), class: "dp-link" %>
3
+ /
4
+ <code><%= @partition.partition_key %></code>
5
+ </h1>
6
+
7
+ <% in_backoff = @partition.next_eligible_at && @partition.next_eligible_at > Time.current %>
8
+ <% age_seconds = @partition.last_checked_at && (Time.current - @partition.last_checked_at) %>
9
+ <% half_life = (DispatchPolicy.registry.fetch(@partition.policy_name)&.fairness_half_life_seconds || DispatchPolicy.config.fairness_half_life_seconds).to_f %>
10
+ <%
11
+ decayed_now =
12
+ if @partition.decayed_admits_at && half_life.positive?
13
+ tau = half_life / Math.log(2)
14
+ elapsed = [Time.current.to_f - @partition.decayed_admits_at.to_f, 0.0].max
15
+ @partition.decayed_admits.to_f * Math.exp(-elapsed / tau)
16
+ else
17
+ @partition.decayed_admits.to_f
18
+ end
19
+ admits_per_min_estimate = decayed_now * (Math.log(2) / half_life) * 60.0 if half_life.positive?
20
+ %>
21
+
22
+ <section class="dp-stats">
23
+ <div class="dp-stat"><span class="dp-stat-label">Policy</span><span class="dp-stat-value"><%= @partition.policy_name %></span></div>
24
+ <div class="dp-stat"><span class="dp-stat-label">Shard</span><span class="dp-stat-value"><code><%= @partition.shard %></code></span></div>
25
+ <div class="dp-stat"><span class="dp-stat-label">Queue</span><span class="dp-stat-value"><%= @partition.queue_name || "—" %></span></div>
26
+ <div class="dp-stat"><span class="dp-stat-label">Status</span><span class="dp-stat-value <%= "dp-warn" if @partition.paused? %>"><%= @partition.status %></span></div>
27
+ <div class="dp-stat"><span class="dp-stat-label">Pending</span><span class="dp-stat-value"><%= format_count(@partition.pending_count) %></span></div>
28
+ <div class="dp-stat"><span class="dp-stat-label">Lifetime admitted</span><span class="dp-stat-value"><%= format_count(@partition.total_admitted) %></span></div>
29
+ <div class="dp-stat"><span class="dp-stat-label">Round-trip age</span><span class="dp-stat-value"><%= age_seconds ? format_duration_seconds(age_seconds) : "never" %></span></div>
30
+ <div class="dp-stat"><span class="dp-stat-label">Backoff</span><span class="dp-stat-value <%= "dp-warn" if in_backoff %>"><%= in_backoff ? "until #{format_time(@partition.next_eligible_at)}" : "—" %></span></div>
31
+ <div class="dp-stat">
32
+ <span class="dp-stat-label">Recent admits (EWMA)</span>
33
+ <span class="dp-stat-value"><%= format("%.2f", decayed_now) %></span>
34
+ </div>
35
+ <% if admits_per_min_estimate %>
36
+ <div class="dp-stat">
37
+ <span class="dp-stat-label">≈ admits/min</span>
38
+ <span class="dp-stat-value"><%= format("%.1f", admits_per_min_estimate) %></span>
39
+ </div>
40
+ <% end %>
41
+ </section>
42
+
43
+ <section class="dp-hint">
44
+ <strong>Recent admits (EWMA)</strong> is an exponentially weighted count of admissions for this partition,
45
+ with a half-life of <%= half_life.to_i %>s. The Tick reorders claimed partitions by this value ASC, so the
46
+ least-recently-active ones get first crack at the tick budget. The "≈ admits/min" line reads it as the
47
+ steady-state admission rate.
48
+ </section>
49
+
50
+ <section class="dp-section">
51
+ <h2>Timing</h2>
52
+ <ul class="dp-list">
53
+ <li>Last checked: <%= format_time(@partition.last_checked_at) %></li>
54
+ <li>Last admit: <%= format_time(@partition.last_admit_at) %></li>
55
+ <li>Last enqueued: <%= format_time(@partition.last_enqueued_at) %></li>
56
+ <li>Next eligible: <%= format_time(@partition.next_eligible_at) %></li>
57
+ <li>Context updated: <%= format_time(@partition.context_updated_at) %></li>
58
+ </ul>
59
+ </section>
60
+
61
+ <section class="dp-section">
62
+ <h2>Context (refreshed on each enqueue)</h2>
63
+ <pre class="dp-json"><%= JSON.pretty_generate(@partition.context || {}) %></pre>
64
+ </section>
65
+
66
+ <section class="dp-section">
67
+ <h2>Gate state</h2>
68
+ <pre class="dp-json"><%= JSON.pretty_generate(@partition.gate_state || {}) %></pre>
69
+ </section>
70
+
71
+ <section class="dp-section">
72
+ <h2>Recent staged jobs (max 50)</h2>
73
+ <% if @recent_jobs.any? %>
74
+ <table class="dp-table">
75
+ <thead><tr><th>id</th><th>Job class</th><th>Scheduled</th><th>Enqueued</th><th></th></tr></thead>
76
+ <tbody>
77
+ <% @recent_jobs.each do |j| %>
78
+ <tr>
79
+ <td><%= j.id %></td>
80
+ <td><code><%= j.job_class %></code></td>
81
+ <td><%= format_time(j.scheduled_at) %></td>
82
+ <td><%= format_time(j.enqueued_at) %></td>
83
+ <td><%= link_to "inspect", staged_job_path(j), class: "dp-link" %></td>
84
+ </tr>
85
+ <% end %>
86
+ </tbody>
87
+ </table>
88
+ <% else %>
89
+ <p class="dp-empty">No staged jobs in this partition.</p>
90
+ <% end %>
91
+ </section>
92
+
93
+ <section class="dp-section">
94
+ <h2>Actions</h2>
95
+ <%= button_to "Force admit 1", admit_partition_path(@partition, count: 1), class: "dp-btn", method: :post, form: { class: "dp-form-inline" } %>
96
+ <%= button_to "Force admit 10", admit_partition_path(@partition, count: 10), class: "dp-btn", method: :post, form: { class: "dp-form-inline" } %>
97
+ <%= button_to "Drain partition", drain_partition_path(@partition),
98
+ class: "dp-btn dp-btn-warn",
99
+ method: :post,
100
+ form: { class: "dp-form-inline",
101
+ onsubmit: "return confirm('Force-admit every staged job in this partition, bypassing all gates? This sends them to the real adapter immediately.');" } %>
102
+ <p class="dp-hint">
103
+ <strong>Drain</strong> empties the partition by sending every staged job to the real adapter without consulting throttle/concurrency gates.
104
+ Capped at 10,000 jobs per click — click again for more.
105
+ </p>
106
+ </section>
@@ -1,49 +1,27 @@
1
- <h1>DispatchPolicy</h1>
2
- <p class="muted">Admission-control policies registered in this app.</p>
1
+ <h1>Policies</h1>
3
2
 
4
- <div class="summary">
5
- <div class="summary-item">
6
- <div class="label">Active partitions</div>
7
- <div class="value"><%= @active_partitions %></div>
8
- </div>
9
- <div class="summary-item">
10
- <div class="label">Expired leases</div>
11
- <div class="value"><%= @expired_leases %></div>
12
- </div>
13
- </div>
14
-
15
- <h2>Policies</h2>
16
- <% if @policies.empty? %>
17
- <p class="muted">No policies registered yet.</p>
3
+ <% if @rows.empty? %>
4
+ <p class="dp-empty">No policies registered or observed.</p>
18
5
  <% else %>
19
- <table>
6
+ <table class="dp-table">
20
7
  <thead>
21
8
  <tr>
22
- <th>Policy</th>
23
- <th>Job class</th>
24
- <th>Pending</th>
25
- <th>Admitted</th>
26
- <th>Completed (24h)</th>
27
- <th>Oldest pending</th>
9
+ <th>Name</th><th class="dp-num">Pending</th><th class="dp-num">In flight</th>
10
+ <th class="dp-num">Partitions</th><th class="dp-num">Paused</th><th>Registered</th><th></th>
28
11
  </tr>
29
12
  </thead>
30
13
  <tbody>
31
- <% @policies.each do |p| %>
14
+ <% @rows.each do |row| %>
32
15
  <tr>
33
- <td><%= link_to p[:name], policy_path(policy_name: p[:name]) %></td>
34
- <td><code><%= p[:job_class].name %></code></td>
35
- <td><%= p[:pending_count] %></td>
36
- <td><%= p[:admitted_count] %></td>
37
- <td><%= p[:completed_24h] %></td>
16
+ <td><%= link_to row[:name], policy_path(row[:name]), class: "dp-link" %></td>
17
+ <td class="dp-num"><%= format_count(row[:pending]) %></td>
18
+ <td class="dp-num"><%= format_count(row[:in_flight]) %></td>
19
+ <td class="dp-num"><%= format_count(row[:partitions]) %></td>
20
+ <td class="dp-num"><%= row[:paused_count].positive? ? content_tag(:span, format_count(row[:paused_count]), class: "dp-warn") : 0 %></td>
21
+ <td><%= row[:registered] ? "yes" : content_tag(:span, "no (orphan)", class: "dp-warn") %></td>
38
22
  <td>
39
- <% if p[:oldest_pending] %>
40
- <% stale = p[:oldest_pending] < Time.current - p[:stale_threshold] %>
41
- <span class="<%= 'stale' if stale %>">
42
- <%= time_ago_in_words(p[:oldest_pending]) %> ago
43
- </span>
44
- <% else %>
45
- <span class="muted">—</span>
46
- <% end %>
23
+ <%= button_to "Pause", pause_policy_path(row[:name]), class: "dp-btn", method: :post, form: { class: "dp-form-inline" } %>
24
+ <%= button_to "Resume", resume_policy_path(row[:name]), class: "dp-btn dp-btn-ok", method: :post, form: { class: "dp-form-inline" } %>
47
25
  </td>
48
26
  </tr>
49
27
  <% end %>