solid_stack_web 0.3.0 → 0.5.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +117 -26
  3. data/app/assets/stylesheets/solid_stack_web/_03_stats.css +27 -0
  4. data/app/assets/stylesheets/solid_stack_web/_04_table.css +4 -0
  5. data/app/assets/stylesheets/solid_stack_web/_07_dashboard.css +85 -12
  6. data/app/assets/stylesheets/solid_stack_web/_09_detail.css +42 -0
  7. data/app/controllers/solid_stack_web/application_controller.rb +1 -1
  8. data/app/controllers/solid_stack_web/cache/flushes_controller.rb +8 -0
  9. data/app/controllers/solid_stack_web/cache_controller.rb +2 -0
  10. data/app/controllers/solid_stack_web/cache_entries_controller.rb +32 -0
  11. data/app/controllers/solid_stack_web/dashboard_controller.rb +4 -16
  12. data/app/controllers/solid_stack_web/metrics_controller.rb +15 -0
  13. data/app/controllers/solid_stack_web/queues_controller.rb +4 -0
  14. data/app/controllers/solid_stack_web/stats_controller.rb +39 -0
  15. data/app/helpers/solid_stack_web/application_helper.rb +90 -2
  16. data/app/javascript/solid_stack_web/application.js +7 -1
  17. data/app/javascript/solid_stack_web/refresh_controller.js +52 -0
  18. data/app/javascript/solid_stack_web/search_controller.js +16 -0
  19. data/app/javascript/solid_stack_web/sparkline_tooltip_controller.js +23 -0
  20. data/app/models/solid_stack_web/alert_webhook.rb +67 -0
  21. data/app/models/solid_stack_web/cable_stats.rb +10 -0
  22. data/app/models/solid_stack_web/cache_size_stats.rb +33 -0
  23. data/app/models/solid_stack_web/cache_stats.rb +11 -0
  24. data/app/models/solid_stack_web/cache_timeline.rb +40 -0
  25. data/app/models/solid_stack_web/queue_depth_sparkline.rb +30 -0
  26. data/app/models/solid_stack_web/queue_stats.rb +34 -0
  27. data/app/models/solid_stack_web/throughput_sparkline.rb +23 -0
  28. data/app/views/layouts/solid_stack_web/application.html.erb +13 -0
  29. data/app/views/solid_stack_web/cache/index.html.erb +84 -2
  30. data/app/views/solid_stack_web/cache_entries/index.html.erb +66 -0
  31. data/app/views/solid_stack_web/cache_entries/show.html.erb +48 -0
  32. data/app/views/solid_stack_web/dashboard/index.html.erb +45 -4
  33. data/app/views/solid_stack_web/history/index.html.erb +6 -3
  34. data/app/views/solid_stack_web/jobs/index.html.erb +11 -7
  35. data/app/views/solid_stack_web/processes/index.html.erb +3 -0
  36. data/app/views/solid_stack_web/queues/index.html.erb +6 -1
  37. data/app/views/solid_stack_web/stats/index.html.erb +48 -0
  38. data/config/importmap.rb +5 -0
  39. data/config/routes.rb +4 -0
  40. data/lib/solid_stack_web/version.rb +1 -1
  41. data/lib/solid_stack_web.rb +41 -1
  42. metadata +19 -1
@@ -1,6 +1,12 @@
1
1
  import "@hotwired/turbo"
2
2
  import { Application } from "@hotwired/stimulus"
3
+ import RefreshController from "solid_stack_web/refresh_controller"
4
+ import SearchController from "solid_stack_web/search_controller"
3
5
  import SelectionController from "solid_stack_web/selection_controller"
6
+ import SparklineTooltipController from "solid_stack_web/sparkline_tooltip_controller"
4
7
 
5
8
  const application = Application.start()
6
- application.register("selection", SelectionController)
9
+ application.register("refresh", RefreshController)
10
+ application.register("search", SearchController)
11
+ application.register("selection", SelectionController)
12
+ application.register("sparkline-tooltip", SparklineTooltipController)
@@ -0,0 +1,52 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static values = { interval: { type: Number, default: 5000 } }
5
+
6
+ initialize() {
7
+ this._onVisibilityChange = this._onVisibilityChange.bind(this)
8
+ }
9
+
10
+ connect() {
11
+ document.addEventListener("visibilitychange", this._onVisibilityChange)
12
+ this._schedule()
13
+ }
14
+
15
+ disconnect() {
16
+ clearTimeout(this._timer)
17
+ document.removeEventListener("visibilitychange", this._onVisibilityChange)
18
+ }
19
+
20
+ _schedule() {
21
+ this._timer = setTimeout(() => this._reload(), this.intervalValue)
22
+ }
23
+
24
+ async _reload() {
25
+ clearTimeout(this._timer)
26
+ const hasSelection = this.element.querySelector("input[type='checkbox']:checked")
27
+ if (!document.hidden && !hasSelection) {
28
+ try {
29
+ const response = await fetch(window.location.href, {
30
+ headers: { "Turbo-Frame": this.element.id, Accept: "text/html" }
31
+ })
32
+ if (response.ok) {
33
+ const html = await response.text()
34
+ const doc = new DOMParser().parseFromString(html, "text/html")
35
+ const frame = doc.querySelector(`turbo-frame#${this.element.id}`)
36
+ if (frame && this.element.isConnected) this.element.innerHTML = frame.innerHTML
37
+ }
38
+ } catch {
39
+ // network error — skip this tick
40
+ }
41
+ }
42
+ if (this.element.isConnected) this._schedule()
43
+ }
44
+
45
+ _onVisibilityChange() {
46
+ if (document.hidden) {
47
+ clearTimeout(this._timer)
48
+ } else if (!this.element.querySelector("input[type='checkbox']:checked")) {
49
+ this._reload()
50
+ }
51
+ }
52
+ }
@@ -0,0 +1,16 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ filter({ target }) {
5
+ clearTimeout(this._timer)
6
+ const len = target.value.length
7
+ if (len >= 4 || len === 0) {
8
+ this._timer = setTimeout(() => target.form.requestSubmit(), 300)
9
+ }
10
+ }
11
+
12
+ select({ target }) {
13
+ clearTimeout(this._timer)
14
+ target.form.requestSubmit()
15
+ }
16
+ }
@@ -0,0 +1,23 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["bar", "tip"]
5
+
6
+ connect() {
7
+ this._tip = this.tipTarget
8
+ }
9
+
10
+ show({ currentTarget }) {
11
+ const label = currentTarget.dataset.tip
12
+ if (!label) return
13
+ const rect = currentTarget.getBoundingClientRect()
14
+ this._tip.textContent = label
15
+ this._tip.style.left = `${rect.left + rect.width / 2}px`
16
+ this._tip.style.top = `${rect.top - 6}px`
17
+ this._tip.hidden = false
18
+ }
19
+
20
+ hide() {
21
+ this._tip.hidden = true
22
+ }
23
+ }
@@ -0,0 +1,67 @@
1
+ require "net/http"
2
+ require "json"
3
+
4
+ module SolidStackWeb
5
+ class AlertWebhook
6
+ COOLDOWN_CACHE_KEY = "solid_stack_web/alert_webhook/cooldown"
7
+
8
+ def self.check(queue_stats)
9
+ new(queue_stats).check
10
+ end
11
+
12
+ def initialize(queue_stats)
13
+ @queue_stats = queue_stats
14
+ end
15
+
16
+ def check
17
+ return unless SolidStackWeb.alert_webhook_url
18
+ return if on_cooldown?
19
+
20
+ alerts = build_alerts
21
+ return if alerts.empty?
22
+
23
+ deliver(alerts)
24
+ set_cooldown
25
+ end
26
+
27
+ private
28
+
29
+ def build_alerts
30
+ alerts = []
31
+
32
+ if (threshold = SolidStackWeb.alert_failure_threshold)
33
+ count = @queue_stats[:failed]
34
+ alerts << { type: "failed_jobs", count: count, threshold: threshold } if count >= threshold
35
+ end
36
+
37
+ SolidStackWeb.alert_queue_thresholds.each do |queue_name, threshold|
38
+ count = ::SolidQueue::ReadyExecution.where(queue_name: queue_name.to_s).count
39
+ alerts << { type: "queue_depth", queue: queue_name.to_s, count: count, threshold: threshold } if count >= threshold
40
+ end
41
+
42
+ alerts
43
+ end
44
+
45
+ def deliver(alerts)
46
+ payload = { alerts: alerts, generated_at: Time.current.iso8601 }
47
+ uri = URI(SolidStackWeb.alert_webhook_url)
48
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
49
+ open_timeout: 5, read_timeout: 5) do |http|
50
+ request = Net::HTTP::Post.new(uri, "Content-Type" => "application/json")
51
+ request.body = payload.to_json
52
+ http.request(request)
53
+ end
54
+ rescue StandardError
55
+ # webhook delivery failures must not affect the response
56
+ end
57
+
58
+ def on_cooldown?
59
+ Rails.cache.read(COOLDOWN_CACHE_KEY).present?
60
+ end
61
+
62
+ def set_cooldown
63
+ Rails.cache.write(COOLDOWN_CACHE_KEY, Time.current.iso8601,
64
+ expires_in: SolidStackWeb.alert_webhook_cooldown)
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,10 @@
1
+ module SolidStackWeb
2
+ class CableStats
3
+ def to_h
4
+ {
5
+ messages: ::SolidCable::Message.count,
6
+ channels: ::SolidCable::Message.distinct.count(:channel)
7
+ }
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,33 @@
1
+ module SolidStackWeb
2
+ class CacheSizeStats
3
+ BUCKETS = [
4
+ { label: "< 1 KB", min: 0, max: 1_024 },
5
+ { label: "1 – 10 KB", min: 1_024, max: 10_240 },
6
+ { label: "10 – 100 KB", min: 10_240, max: 102_400 },
7
+ { label: "100 KB – 1 MB", min: 102_400, max: 1_048_576 },
8
+ { label: "> 1 MB", min: 1_048_576, max: nil }
9
+ ].freeze
10
+
11
+ TOP_N = 10
12
+
13
+ def top_entries
14
+ @top_entries ||= ::SolidCache::Entry
15
+ .select(:id, :key, :byte_size)
16
+ .order(byte_size: :desc)
17
+ .limit(TOP_N)
18
+ end
19
+
20
+ def buckets
21
+ @buckets ||= BUCKETS.map do |b|
22
+ scope = ::SolidCache::Entry.all
23
+ scope = scope.where("byte_size >= ?", b[:min]) if b[:min] > 0
24
+ scope = scope.where("byte_size < ?", b[:max]) if b[:max]
25
+ { label: b[:label], count: scope.count }
26
+ end
27
+ end
28
+
29
+ def total
30
+ @total ||= ::SolidCache::Entry.count
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,11 @@
1
+ module SolidStackWeb
2
+ class CacheStats
3
+ def to_h
4
+ {
5
+ entries: ::SolidCache::Entry.count,
6
+ byte_size: ::SolidCache::Entry.sum(:byte_size),
7
+ oldest_entry: ::SolidCache::Entry.minimum(:created_at)
8
+ }
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,40 @@
1
+ module SolidStackWeb
2
+ class CacheTimeline
3
+ HOURS = 24
4
+
5
+ def entry_buckets
6
+ @entry_buckets ||= build_buckets { |rows, from, to| rows.count { |t, _| t >= from && t < to } }
7
+ end
8
+
9
+ def byte_buckets
10
+ @byte_buckets ||= build_buckets { |rows, from, to| rows.sum { |t, b| t >= from && t < to ? b : 0 } }
11
+ end
12
+
13
+ def entry_max
14
+ entry_buckets.max || 0
15
+ end
16
+
17
+ def byte_max
18
+ byte_buckets.max || 0
19
+ end
20
+
21
+ private
22
+
23
+ def rows
24
+ @rows ||= begin
25
+ origin = Time.current - HOURS.hours
26
+ ::SolidCache::Entry.where(created_at: origin..Time.current).pluck(:created_at, :byte_size)
27
+ end
28
+ end
29
+
30
+ def build_buckets(&block)
31
+ now = Time.current
32
+ origin = now - HOURS.hours
33
+ HOURS.times.map do |i|
34
+ from = origin + i.hours
35
+ to = origin + (i + 1).hours
36
+ block.call(rows, from, to)
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,30 @@
1
+ module SolidStackWeb
2
+ class QueueDepthSparkline
3
+ HOURS = 12
4
+
5
+ def initialize(queue_name)
6
+ @queue_name = queue_name
7
+ end
8
+
9
+ def buckets
10
+ @buckets ||= begin
11
+ now = Time.current
12
+ origin = now - HOURS.hours
13
+
14
+ jobs = ::SolidQueue::Job
15
+ .where(queue_name: @queue_name)
16
+ .where("created_at <= ? AND (finished_at IS NULL OR finished_at >= ?)", now, origin)
17
+ .pluck(:created_at, :finished_at)
18
+
19
+ HOURS.times.map do |i|
20
+ snapshot = origin + (i + 1).hours
21
+ jobs.count { |created_at, finished_at| created_at <= snapshot && (finished_at.nil? || finished_at > snapshot) }
22
+ end
23
+ end
24
+ end
25
+
26
+ def max
27
+ buckets.max || 0
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,34 @@
1
+ module SolidStackWeb
2
+ class QueueStats
3
+ def to_h
4
+ finished_24h = finished_since(24.hours.ago)
5
+ stats = {
6
+ ready: ::SolidQueue::ReadyExecution.count,
7
+ scheduled: ::SolidQueue::ScheduledExecution.count,
8
+ claimed: ::SolidQueue::ClaimedExecution.count,
9
+ blocked: ::SolidQueue::BlockedExecution.count,
10
+ failed: ::SolidQueue::FailedExecution.count,
11
+ done_1h: finished_since(1.hour.ago).count,
12
+ done_24h: finished_24h.count,
13
+ processes_healthy: ::SolidQueue::Process.where("last_heartbeat_at > ?", 5.minutes.ago).count,
14
+ processes_stale: ::SolidQueue::Process.where("last_heartbeat_at <= ? OR last_heartbeat_at IS NULL", 5.minutes.ago).count
15
+ }
16
+ add_slow_jobs(stats, finished_24h)
17
+ stats
18
+ end
19
+
20
+ private
21
+
22
+ def finished_since(time)
23
+ ::SolidQueue::Job.where.not(finished_at: nil).where("finished_at >= ?", time)
24
+ end
25
+
26
+ def add_slow_jobs(stats, finished_24h)
27
+ threshold = SolidStackWeb.slow_job_threshold
28
+ return unless threshold
29
+
30
+ stats[:slow_jobs] = finished_24h.select(:created_at, :finished_at)
31
+ .count { |j| (j.finished_at - j.created_at) > threshold }
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,23 @@
1
+ module SolidStackWeb
2
+ class ThroughputSparkline
3
+ HOURS = 12
4
+
5
+ def buckets
6
+ @buckets ||= begin
7
+ now = Time.current
8
+ origin = now - HOURS.hours
9
+ times = ::SolidQueue::Job.where(finished_at: origin..now).pluck(:finished_at)
10
+
11
+ HOURS.times.map do |i|
12
+ from = origin + i.hours
13
+ to = origin + (i + 1).hours
14
+ times.count { |t| t >= from && t < to }
15
+ end
16
+ end
17
+ end
18
+
19
+ def max
20
+ buckets.max || 0
21
+ end
22
+ end
23
+ end
@@ -24,6 +24,17 @@
24
24
  </div>
25
25
  </header>
26
26
 
27
+ <% if current_section == :cache %>
28
+ <nav class="sqw-subnav">
29
+ <div class="sqw-subnav__inner">
30
+ <%= link_to "Overview", cache_path,
31
+ class: "sqw-subnav__link#{" sqw-subnav__link--active" if controller_name == "cache"}" %>
32
+ <%= link_to "Entries", cache_entries_path,
33
+ class: "sqw-subnav__link#{" sqw-subnav__link--active" if controller_name == "cache_entries"}" %>
34
+ </div>
35
+ </nav>
36
+ <% end %>
37
+
27
38
  <% if current_section == :queue %>
28
39
  <nav class="sqw-subnav">
29
40
  <div class="sqw-subnav__inner">
@@ -35,6 +46,8 @@
35
46
  class: "sqw-subnav__link#{" sqw-subnav__link--active" if controller_name == "queues"}" %>
36
47
  <%= link_to "Recurring", recurring_tasks_path,
37
48
  class: "sqw-subnav__link#{" sqw-subnav__link--active" if controller_name == "recurring_tasks"}" %>
49
+ <%= link_to "Stats", stats_path,
50
+ class: "sqw-subnav__link#{" sqw-subnav__link--active" if controller_name == "stats"}" %>
38
51
  <%= link_to "History", history_path,
39
52
  class: "sqw-subnav__link#{" sqw-subnav__link--active" if controller_name == "history"}" %>
40
53
  <%= link_to "Processes", processes_path,
@@ -3,12 +3,94 @@
3
3
  </div>
4
4
 
5
5
  <div class="sqw-stats-grid">
6
- <div class="sqw-stat sqw-stat--cache">
6
+ <%= link_to cache_entries_path, class: "sqw-stat sqw-stat--cache" do %>
7
7
  <span class="sqw-stat__label">Total Entries</span>
8
8
  <span class="sqw-stat__value"><%= @total_entries %></span>
9
- </div>
9
+ <% end %>
10
10
  <div class="sqw-stat sqw-stat--cache">
11
11
  <span class="sqw-stat__label">Total Size</span>
12
12
  <span class="sqw-stat__value"><%= number_to_human_size(@total_byte_size) %></span>
13
13
  </div>
14
14
  </div>
15
+
16
+ <% if @total_entries > 0 %>
17
+ <div class="sqw-size-grid">
18
+ <section class="sqw-size-section">
19
+ <h2 class="sqw-section-title">Size Distribution</h2>
20
+ <table class="sqw-table">
21
+ <thead>
22
+ <tr>
23
+ <th>Range</th>
24
+ <th>Entries</th>
25
+ <th></th>
26
+ </tr>
27
+ </thead>
28
+ <tbody>
29
+ <% @size_stats.buckets.each do |bucket| %>
30
+ <% pct = @size_stats.total > 0 ? (bucket[:count].to_f / @size_stats.total * 100).round(1) : 0 %>
31
+ <tr>
32
+ <td class="sqw-muted"><%=bucket[:label] %></td>
33
+ <td><%= bucket[:count] %></td>
34
+ <td>
35
+ <div class="sqw-dist-bar-cell">
36
+ <div class="sqw-dist-bar" style="width: <%= pct %>%"></div>
37
+ <span class="sqw-dist-pct sqw-muted"><%= pct %>%</span>
38
+ </div>
39
+ </td>
40
+ </tr>
41
+ <% end %>
42
+ </tbody>
43
+ </table>
44
+ </section>
45
+
46
+ <section class="sqw-size-section">
47
+ <h2 class="sqw-section-title">Largest Entries</h2>
48
+ <table class="sqw-table">
49
+ <thead>
50
+ <tr>
51
+ <th>Key</th>
52
+ <th>Size</th>
53
+ </tr>
54
+ </thead>
55
+ <tbody>
56
+ <% @size_stats.top_entries.each do |entry| %>
57
+ <tr>
58
+ <td class="sqw-monospace sqw-truncate" title="<%= entry.key %>">
59
+ <%= link_to entry.key, cache_entry_path(entry), class: "sqw-link" %>
60
+ </td>
61
+ <td><%= number_to_human_size(entry.byte_size) %></td>
62
+ </tr>
63
+ <% end %>
64
+ </tbody>
65
+ </table>
66
+ </section>
67
+ </div>
68
+ <% end %>
69
+
70
+ <div class="sqw-timeline-grid" data-controller="sparkline-tooltip">
71
+ <div class="sqw-sparkline-wrap sqw-timeline-chart">
72
+ <span class="sqw-sparkline-label">Entries written — last 24 hours</span>
73
+ <div class="sqw-sparkline-positioner">
74
+ <%= cache_entries_timeline_svg(@timeline) %>
75
+ <div class="sqw-sparkline-tip" data-sparkline-tooltip-target="tip" hidden></div>
76
+ </div>
77
+ <div class="sqw-sparkline-axis">
78
+ <span>24h ago</span>
79
+ <span>12h ago</span>
80
+ <span>now</span>
81
+ </div>
82
+ </div>
83
+
84
+ <div class="sqw-sparkline-wrap sqw-timeline-chart">
85
+ <span class="sqw-sparkline-label">Bytes written — last 24 hours</span>
86
+ <div class="sqw-sparkline-positioner">
87
+ <%= cache_bytes_timeline_svg(@timeline) %>
88
+ <div class="sqw-sparkline-tip" data-sparkline-tooltip-target="tip" hidden></div>
89
+ </div>
90
+ <div class="sqw-sparkline-axis">
91
+ <span>24h ago</span>
92
+ <span>12h ago</span>
93
+ <span>now</span>
94
+ </div>
95
+ </div>
96
+ </div>
@@ -0,0 +1,66 @@
1
+ <div class="sqw-page-header sqw-page-header--split">
2
+ <h1 class="sqw-page-title">Cache Entries</h1>
3
+ <div class="sqw-header-actions">
4
+ <%= button_to "Flush All",
5
+ cache_flush_path,
6
+ method: :delete,
7
+ class: "sqw-btn sqw-btn--danger sqw-btn--sm",
8
+ data: { turbo_confirm: "Delete all cache entries? This cannot be undone." } %>
9
+ </div>
10
+ </div>
11
+
12
+ <form class="sqw-filters" action="<%= cache_entries_path %>" method="get" data-controller="search">
13
+ <%= hidden_field_tag :column, @sort["column"] %>
14
+ <%= hidden_field_tag :direction, @sort["direction"] %>
15
+ <input class="sqw-search-input" type="search" name="q" value="<%= @search %>"
16
+ placeholder="Filter by key…" autocomplete="off" aria-label="Filter by key"
17
+ data-action="input->search#filter">
18
+ <% if @search.present? %>
19
+ <%= link_to "Clear", cache_entries_path(column: @sort["column"], direction: @sort["direction"]),
20
+ class: "sqw-btn sqw-btn--muted sqw-btn--sm" %>
21
+ <% end %>
22
+ </form>
23
+
24
+ <% if @entries.any? %>
25
+ <table class="sqw-table">
26
+ <thead>
27
+ <tr>
28
+ <% [["key", "Key"], ["byte_size", "Size"], ["created_at", "Created"]].each do |col, label| %>
29
+ <th>
30
+ <% next_dir = (@sort["column"] == col && @sort["direction"] == "desc") ? "asc" : "desc" %>
31
+ <%= link_to cache_entries_path(q: @search, column: col, direction: next_dir) do %>
32
+ <%= label %>
33
+ <% if @sort["column"] == col %>
34
+ <span class="sqw-sort-indicator"><%= @sort["direction"] == "desc" ? "↓" : "↑" %></span>
35
+ <% end %>
36
+ <% end %>
37
+ </th>
38
+ <% end %>
39
+ <th></th>
40
+ </tr>
41
+ </thead>
42
+ <tbody>
43
+ <% @entries.each do |entry| %>
44
+ <tr id="cache_entry_<%= entry.id %>">
45
+ <td class="sqw-monospace sqw-truncate" title="<%= entry.key %>">
46
+ <%= link_to entry.key, cache_entry_path(entry), class: "sqw-link" %>
47
+ </td>
48
+ <td><%= number_to_human_size(entry.byte_size) %></td>
49
+ <td class="sqw-muted"><%= entry.created_at.strftime("%b %d %H:%M") %></td>
50
+ <td class="sqw-actions">
51
+ <%= button_to "Delete",
52
+ cache_entry_path(entry, q: @search, column: @sort["column"], direction: @sort["direction"]),
53
+ method: :delete,
54
+ class: "sqw-btn sqw-btn--danger sqw-btn--sm",
55
+ data: { turbo_confirm: "Delete this cache entry?" } %>
56
+ </td>
57
+ </tr>
58
+ <% end %>
59
+ </tbody>
60
+ </table>
61
+ <%== pagy_nav(@pagy) if @pagy.pages > 1 %>
62
+ <% else %>
63
+ <div class="sqw-empty">
64
+ <p><%= @search.present? ? "No entries matching &ldquo;#{@search}&rdquo;.".html_safe : "No cache entries." %></p>
65
+ </div>
66
+ <% end %>
@@ -0,0 +1,48 @@
1
+ <div class="sqw-page-header sqw-page-header--split">
2
+ <h1 class="sqw-page-title sqw-truncate" title="<%= @entry.key %>"><%= @entry.key %></h1>
3
+ <div class="sqw-header-actions">
4
+ <%= button_to "Delete",
5
+ cache_entry_path(@entry),
6
+ method: :delete,
7
+ class: "sqw-btn sqw-btn--danger sqw-btn--sm",
8
+ data: { turbo_confirm: "Delete this cache entry?" } %>
9
+ <%= link_to "← Entries", cache_entries_path, class: "sqw-btn sqw-btn--muted sqw-btn--sm" %>
10
+ </div>
11
+ </div>
12
+
13
+ <dl class="sqw-detail">
14
+ <div class="sqw-detail__row">
15
+ <dt>Key</dt>
16
+ <dd class="sqw-monospace"><%= @entry.key %></dd>
17
+ </div>
18
+ <div class="sqw-detail__row">
19
+ <dt>Size</dt>
20
+ <dd><%= number_to_human_size(@entry.byte_size) %></dd>
21
+ </div>
22
+ <div class="sqw-detail__row">
23
+ <dt>Created</dt>
24
+ <dd class="sqw-muted"><%= @entry.created_at.strftime("%b %d, %Y %H:%M:%S %Z") %></dd>
25
+ </div>
26
+ </dl>
27
+
28
+ <section class="sqw-value-preview">
29
+ <div class="sqw-value-preview__header">
30
+ <h2 class="sqw-section-title">Value</h2>
31
+ <% if SolidStackWeb.allow_value_preview %>
32
+ <% formatted = format_cache_value(@entry.value) %>
33
+ <span class="sqw-badge sqw-badge--queue"><%= formatted[:label] %></span>
34
+ <% end %>
35
+ </div>
36
+ <% if SolidStackWeb.allow_value_preview %>
37
+ <% content = formatted[:content] %>
38
+ <% truncated = content.length > 4096 %>
39
+ <pre class="sqw-value-pre"><%= truncated ? content[0, 4096] : content %></pre>
40
+ <% if truncated %>
41
+ <p class="sqw-muted sqw-value-truncated">Showing first 4 KB of <%= number_to_human_size(@entry.byte_size) %> total.</p>
42
+ <% end %>
43
+ <% else %>
44
+ <div class="sqw-empty">
45
+ <p>Value preview is disabled. Set <code>config.allow_value_preview = true</code> in your initializer to enable.</p>
46
+ </div>
47
+ <% end %>
48
+ </section>