hanikamu-rate-limit 0.3.1 → 0.3.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ad248e2bab333b2d4ddd9fdf854f0856c68862ef9fc57dbdd80410fa821f82c0
4
- data.tar.gz: 447847241345d838d88be7f5b908501fe4d72e720a6595f8c5dc1c8404a44fa3
3
+ metadata.gz: 9febd701c4dea4feea383f710cda6de80d26385f5e85b20971a2c5ed6825f5b9
4
+ data.tar.gz: 0d29be734df15391fd08a02eca7deddd46c754af3c4129683013ca9eed28c8dc
5
5
  SHA512:
6
- metadata.gz: 8b4d7091043ef333f33ba67a2375cc36e615c830cd5e90ca6a1862d988394bcc6bb30fd64781390b1d0adf7799acfd95638431fac8822eb056382c79a68b8956
7
- data.tar.gz: f056f4d0d29ab755e1262a3559f9e2514b0cfc5dad8546c27454330b003abca255863a04dfecde700ca85fffd06e2ec7da9f9dc1f6e3cbf3960b2e291d47e4b9
6
+ metadata.gz: 249ac5c8f437ea501a643e5a87c8d3263d80e3917ebada5140893b12ffb71804d78f44d8fcd4ee733fe04080b37834a0662b28a07b22e0c7ddd0bf81ff5321ce
7
+ data.tar.gz: eaa4bdc88f9704766dd1d5332c39d3450d654b440f53cb5316ec6686ed2e851f5e15047eddbfab5436b4ac74d13267dd508e0ba5e9c9f4cc4227528261d95031
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.2 - 2026-02-17
4
+
5
+ ### Fixed
6
+
7
+ - **Short-reset overrides now visible in the dashboard.** Overrides with a very short reset (e.g. 2 seconds) previously expired before the SSE interval could push them, so the pill always showed "None". Expired overrides are now returned with `active: false` and displayed as a dimmed pill with a relative timestamp ("Registered 10s ago").
8
+ - **Override pill no longer flickers** on SSE updates. The update path now patches the existing DOM element in-place instead of replacing `innerHTML`, and the "Registered" prefix slides in/out with a CSS transition.
9
+ - Expired override pill uses neutral grey styling instead of the red accent colour.
10
+
11
+ ### Changed
12
+
13
+ - Extracted `OverrideHelpers` module from `LimitPresenter` to keep class size under RuboCop limits.
14
+ - Refactored `resolve_override` into `live_override_from` and `stored_override_from` for clarity.
15
+ - Override snapshot hashes now always include an `"active"` boolean key.
16
+ - Added `override_active?`, `override_updated_at`, and `override_age_label` to `LimitPresenter`.
17
+ - Added footer with gem version and GitHub link to the dashboard.
18
+
3
19
  ## 0.3.1 - 2026-02-17
4
20
 
5
21
  ### Fixed
data/README.md CHANGED
@@ -33,7 +33,7 @@ Requires Ruby 4.0 or later.
33
33
 
34
34
  ```ruby
35
35
  # Gemfile
36
- gem "hanikamu-rate-limit", "~> 0.2"
36
+ gem "hanikamu-rate-limit", "~> 0.3"
37
37
  ```
38
38
 
39
39
  ```bash
@@ -333,22 +333,30 @@ module Hanikamu
333
333
  end
334
334
 
335
335
  def resolve_override(live_remaining, live_ttl, stored_meta)
336
- # Try live override first
337
- if live_remaining && live_ttl&.positive?
338
- remaining = Integer(live_remaining, exception: false)
339
- if remaining
340
- return { "remaining" => remaining, "reset" => live_ttl,
341
- "updated_at" => Time.now.to_i }
342
- end
343
- end
344
- # Fall back to stored meta
336
+ live_override_from(live_remaining, live_ttl) ||
337
+ stored_override_from(stored_meta)
338
+ end
339
+
340
+ def live_override_from(remaining_raw, ttl)
341
+ return nil unless remaining_raw && ttl&.positive?
342
+
343
+ remaining = Integer(remaining_raw, exception: false)
344
+ return nil unless remaining
345
+
346
+ { "remaining" => remaining, "reset" => ttl,
347
+ "updated_at" => Time.now.to_i, "active" => true }
348
+ end
349
+
350
+ def stored_override_from(stored_meta)
345
351
  return nil if stored_meta.nil? || stored_meta.empty?
346
352
 
347
353
  remaining_reset = compute_remaining_reset(stored_meta)
348
- return nil unless remaining_reset
354
+ updated_at = stored_meta["updated_at"].to_i
355
+ active = !remaining_reset.nil?
349
356
 
350
- { "remaining" => stored_meta["remaining"].to_i, "reset" => remaining_reset,
351
- "updated_at" => stored_meta["updated_at"].to_i }
357
+ { "remaining" => stored_meta["remaining"].to_i,
358
+ "reset" => remaining_reset || stored_meta["reset"].to_i,
359
+ "updated_at" => updated_at, "active" => active }
352
360
  end
353
361
 
354
362
  def parse_override_history(raw, window, bucket_seconds)
@@ -407,7 +415,7 @@ module Hanikamu
407
415
  remaining = Integer(remaining_value, exception: false)
408
416
  return nil if remaining.nil?
409
417
 
410
- { "remaining" => remaining, "reset" => ttl, "updated_at" => Time.now.to_i }
418
+ { "remaining" => remaining, "reset" => ttl, "updated_at" => Time.now.to_i, "active" => true }
411
419
  end
412
420
 
413
421
  def stored_override(registry)
@@ -415,10 +423,16 @@ module Hanikamu
415
423
  return nil if meta.empty?
416
424
 
417
425
  remaining_reset = compute_remaining_reset(meta)
418
- return nil unless remaining_reset
426
+ updated_at = meta["updated_at"].to_i
419
427
 
420
- { "remaining" => meta["remaining"].to_i, "reset" => remaining_reset,
421
- "updated_at" => meta["updated_at"].to_i }
428
+ if remaining_reset
429
+ { "remaining" => meta["remaining"].to_i, "reset" => remaining_reset,
430
+ "updated_at" => updated_at, "active" => true }
431
+ else
432
+ { "remaining" => meta["remaining"].to_i,
433
+ "reset" => meta["reset"].to_i,
434
+ "updated_at" => updated_at, "active" => false }
435
+ end
422
436
  end
423
437
 
424
438
  def compute_remaining_reset(meta)
@@ -156,6 +156,21 @@
156
156
  color: var(--accent);
157
157
  font-size: 12px;
158
158
  font-weight: 600;
159
+ transition: opacity 0.3s ease, border-color 0.3s ease;
160
+ }
161
+
162
+ .pill-age {
163
+ display: inline-flex;
164
+ overflow: hidden;
165
+ max-width: 0;
166
+ opacity: 0;
167
+ transition: max-width 0.4s ease, opacity 0.3s ease;
168
+ white-space: nowrap;
169
+ }
170
+
171
+ .pill--expired .pill-age {
172
+ max-width: 250px;
173
+ opacity: 1;
159
174
  }
160
175
 
161
176
  .stats {
@@ -278,6 +293,17 @@
278
293
  font-weight: 600;
279
294
  }
280
295
 
296
+ .pill--expired {
297
+ opacity: 0.55;
298
+ border: 1px dashed var(--line);
299
+ background: rgba(161, 161, 170, 0.10);
300
+ color: var(--muted);
301
+ }
302
+
303
+ .pill--expired strong {
304
+ color: var(--muted);
305
+ }
306
+
281
307
  .empty {
282
308
  padding: 40px 20px;
283
309
  background: var(--card);
@@ -287,6 +313,23 @@
287
313
  color: var(--muted);
288
314
  }
289
315
 
316
+ .page-footer {
317
+ text-align: center;
318
+ padding: 32px 0 16px;
319
+ color: #6b7280;
320
+ font-size: 0.8rem;
321
+ }
322
+
323
+ .page-footer a {
324
+ color: #6b7280;
325
+ text-decoration: none;
326
+ }
327
+
328
+ .page-footer a:hover {
329
+ color: #374151;
330
+ text-decoration: underline;
331
+ }
332
+
290
333
  @media (max-width: 720px) {
291
334
  header {
292
335
  padding-top: 24px;
@@ -415,7 +458,7 @@
415
458
  <div class="override">
416
459
  <span>Override</span>
417
460
  <% if limit.override? %>
418
- <span class="pill">Remaining <strong><%= limit.override_remaining %></strong> | Reset <strong><%= limit.override_reset %>s</strong></span>
461
+ <span class="pill<%= ' pill--expired' unless limit.override_active? %>"><span class="pill-age">Registered&nbsp;<strong data-override-age><%= limit.override_age_label unless limit.override_active? %></strong>&nbsp;— </span>Remaining <strong data-override-remaining><%= limit.override_remaining %></strong> | Reset <strong data-override-reset><%= limit.override_reset %>s</strong></span>
419
462
  <% else %>
420
463
  <strong>None</strong>
421
464
  <% end %>
@@ -432,6 +475,14 @@
432
475
  const grid = document.getElementById("limits-grid");
433
476
  const emptyState = document.getElementById("empty-state");
434
477
 
478
+ const timeAgo = (unixTs) => {
479
+ const seconds = Math.max(0, Math.floor(Date.now() / 1000) - Number(unixTs || 0));
480
+ if (seconds < 60) return `${seconds}s ago`;
481
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
482
+ if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
483
+ return `${Math.floor(seconds / 86400)}d ago`;
484
+ };
485
+
435
486
  const escapeHtml = (value) => {
436
487
  const str = String(value ?? "");
437
488
  return str
@@ -742,7 +793,9 @@
742
793
  </div>
743
794
  <div class="override">
744
795
  <span>Override</span>
745
- ${override ? `<span class="pill">Remaining <strong>${escapeHtml(override.remaining)}</strong> | Reset <strong>${escapeHtml(override.reset)}s</strong></span>` : "<strong>None</strong>"}
796
+ ${override
797
+ ? `<span class="pill${override.active ? '' : ' pill--expired'}"><span class="pill-age">Registered&nbsp;<strong data-override-age>${override.active ? '' : timeAgo(override.updated_at)}</strong>&nbsp;— </span>Remaining <strong data-override-remaining>${escapeHtml(override.remaining)}</strong> | Reset <strong data-override-reset>${escapeHtml(override.reset)}s</strong></span>`
798
+ : "<strong>None</strong>"}
746
799
  </div>
747
800
  </article>
748
801
  `;
@@ -800,9 +853,28 @@
800
853
 
801
854
  const overrideEl = card.querySelector('.override');
802
855
  if (overrideEl) {
803
- overrideEl.innerHTML = `<span>Override</span> ${override
804
- ? `<span class="pill">Remaining <strong>${escapeHtml(override.remaining)}</strong> | Reset <strong>${escapeHtml(override.reset)}s</strong></span>`
805
- : "<strong>None</strong>"}`;
856
+ if (override) {
857
+ let pillEl = overrideEl.querySelector('.pill');
858
+ if (!pillEl) {
859
+ overrideEl.innerHTML = `<span>Override</span> <span class="pill${override.active ? '' : ' pill--expired'}"><span class="pill-age">Registered&nbsp;<strong data-override-age>${override.active ? '' : timeAgo(override.updated_at)}</strong>&nbsp;— </span>Remaining <strong data-override-remaining>${escapeHtml(override.remaining)}</strong> | Reset <strong data-override-reset>${escapeHtml(override.reset)}s</strong></span>`;
860
+ } else {
861
+ pillEl.classList.toggle('pill--expired', !override.active);
862
+ const ageEl = pillEl.querySelector('[data-override-age]');
863
+ if (!override.active) {
864
+ if (ageEl) ageEl.textContent = timeAgo(override.updated_at);
865
+ } else {
866
+ if (ageEl) ageEl.textContent = '';
867
+ }
868
+ const remEl = pillEl.querySelector('[data-override-remaining]');
869
+ if (remEl) remEl.textContent = override.remaining;
870
+ const resetEl = pillEl.querySelector('[data-override-reset]');
871
+ if (resetEl) resetEl.textContent = `${override.reset}s`;
872
+ }
873
+ } else {
874
+ if (overrideEl.querySelector('.pill')) {
875
+ overrideEl.innerHTML = '<span>Override</span> <strong>None</strong>';
876
+ }
877
+ }
806
878
  }
807
879
  });
808
880
  }
@@ -827,5 +899,9 @@
827
899
  // This prevents thread starvation on servers with few threads.
828
900
  window.addEventListener("beforeunload", () => source.close());
829
901
  </script>
902
+
903
+ <footer class="page-footer">
904
+ <a href="https://github.com/Hanikamu/hanikamu-rate-limit" target="_blank" rel="noopener noreferrer">hanikamu-rate-limit</a> v<%= Hanikamu::RateLimit::VERSION %>
905
+ </footer>
830
906
  </body>
831
907
  </html>
@@ -1,9 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "override_helpers"
4
+
3
5
  module Hanikamu
4
6
  module RateLimit
5
7
  module UI
6
8
  class LimitPresenter
9
+ include OverrideHelpers
10
+
7
11
  EMPTY_HISTORY = { "allowed" => [], "blocked" => [], "buckets" => [] }.freeze
8
12
  EMPTY_LIFETIME = { "allowed" => 0, "blocked" => 0 }.freeze
9
13
 
@@ -110,24 +114,6 @@ module Hanikamu
110
114
  lifetime.fetch("blocked", 0)
111
115
  end
112
116
 
113
- # ── Override ─────────────────────────────────────────────
114
-
115
- def override
116
- data["override"]
117
- end
118
-
119
- def override?
120
- !override.nil?
121
- end
122
-
123
- def override_remaining
124
- override&.fetch("remaining", nil)
125
- end
126
-
127
- def override_reset
128
- override&.fetch("reset", nil)
129
- end
130
-
131
117
  # ── Metrics ──────────────────────────────────────────────
132
118
 
133
119
  def metrics_enabled?
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hanikamu
4
+ module RateLimit
5
+ module UI
6
+ # Shared helpers for override presentation logic.
7
+ module OverrideHelpers
8
+ TIME_THRESHOLDS = [
9
+ [60, ->(s) { "#{s}s ago" }],
10
+ [3600, ->(s) { "#{s / 60}m ago" }],
11
+ [86_400, ->(s) { "#{s / 3600}h ago" }]
12
+ ].freeze
13
+
14
+ def override
15
+ data["override"]
16
+ end
17
+
18
+ def override?
19
+ !override.nil?
20
+ end
21
+
22
+ def override_active?
23
+ override&.fetch("active", false) == true
24
+ end
25
+
26
+ def override_remaining
27
+ override&.fetch("remaining", nil)
28
+ end
29
+
30
+ def override_reset
31
+ override&.fetch("reset", nil)
32
+ end
33
+
34
+ def override_updated_at
35
+ override&.fetch("updated_at", nil)
36
+ end
37
+
38
+ def override_age_label
39
+ ts = override_updated_at
40
+ return nil unless ts
41
+
42
+ seconds = [Time.now.to_i - ts, 0].max
43
+ time_ago_in_words(seconds)
44
+ end
45
+
46
+ private
47
+
48
+ def time_ago_in_words(seconds)
49
+ TIME_THRESHOLDS.each do |threshold, formatter|
50
+ return formatter.call(seconds) if seconds < threshold
51
+ end
52
+ "#{seconds / 86_400}d ago"
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Hanikamu
4
4
  module RateLimit
5
- VERSION = "0.3.1"
5
+ VERSION = "0.3.2"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hanikamu-rate-limit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nicolai Seerup
@@ -95,6 +95,7 @@ files:
95
95
  - lib/hanikamu/rate_limit/ui/engine.rb
96
96
  - lib/hanikamu/rate_limit/ui/presenters/dashboard_presenter.rb
97
97
  - lib/hanikamu/rate_limit/ui/presenters/limit_presenter.rb
98
+ - lib/hanikamu/rate_limit/ui/presenters/override_helpers.rb
98
99
  - lib/hanikamu/rate_limit/version.rb
99
100
  homepage: https://github.com/Hanikamu/hanikamu-rate-limit
100
101
  licenses: