solid_stack_web 0.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c446164425203124cb715d09c520cfa7b2cc03a6abeefb4eb27ca0514327160a
4
- data.tar.gz: ef9abaa3fe16c5ffc1e019dfff0ce97d7541950db9f6f25ae5ce034f4780efb5
3
+ metadata.gz: 3159278d6dd76fc0e54660ab9c11536a63037815771ff04206adca43b800c4ef
4
+ data.tar.gz: 05f47e20a15b2f70cbf216713cb4c18da3e3f569b64eb1a271543489fecb95c1
5
5
  SHA512:
6
- metadata.gz: 30b738bcc2e57c4f81fb03d7b4a74bf88e18a1b69d4b71bc646357703006d6a1b639090a77a11d258427b7ad080f0b93b317cd949ff9b3b3353f77caa3e69066
7
- data.tar.gz: 37f9a4d50e42360411c4213be03ce5c343874314a048b5f09b13b450bfc2ccf3568a2033cf5d4d137ee59723658f9a3cccbcd6353cf7f2f677159bd2b04fba73
6
+ metadata.gz: cdd04812d4cdfa1d3c48ff2a81319f8956b71afee1dded7dbce009797822248e422990f02134fbb4f3c88e6e2e2702c2c91c78d94a6d04e28ebd8d1a2b0022b9
7
+ data.tar.gz: 431b97295f3682b1512b09ece023c280997560d4b4f065ef742411fddc63275c8257f0d34d02f6bb0589a555c8677d0515301cabfc8b0768465fd42ff6599a54
data/README.md CHANGED
@@ -74,6 +74,10 @@ SolidStackWeb.configure do |config|
74
74
 
75
75
  # Maximum results shown by the search feature (default: 25).
76
76
  config.search_results_limit = 25
77
+
78
+ # Show the raw serialized value on the cache entry detail page (default: false).
79
+ # Disable for stores that contain sensitive data.
80
+ config.allow_value_preview = true
77
81
  end
78
82
  ```
79
83
 
@@ -94,7 +98,17 @@ Filters are preserved when switching between status tabs (Ready / Scheduled / Ru
94
98
 
95
99
  ## Solid Cache
96
100
 
97
- _Deep cache monitoring coming in v0.5.0. Currently shows entry count and total byte size on the overview dashboard._
101
+ ### Features
102
+
103
+ - **Overview dashboard card** — live entry count (linked to the entry browser), total byte size, and oldest-entry age (`time_ago_in_words` with exact timestamp on hover; hidden when cache is empty)
104
+ - **Cache timeline** — two side-by-side 24-hour bar charts showing entries written per hour and bytes written per hour; each bar has a hover tooltip
105
+ - **Size distribution** — byte-range histogram (< 1 KB → > 1 MB) with entry counts and proportional inline bars; hidden when cache is empty
106
+ - **Largest entries** — top 10 entries by byte size, each linked to the detail page; hidden when cache is empty
107
+ - **Entry browser** — `GET /cache/entries` lists all `SolidCache::Entry` records in a paginated, sortable table; columns: key, byte size, created-at; sortable by any column; key auto-submits search after 4 characters
108
+ - **Key search** — filter entries by key substring; results update automatically after 4 characters
109
+ - **Entry detail page** — `GET /cache/entries/:id` shows the full key, byte size, and created-at; optionally displays the raw serialized value (see `allow_value_preview` below)
110
+ - **Delete entry** — per-row delete button or detail-page button removes a single cache entry
111
+ - **Flush All** — header button deletes every cache entry with a confirmation prompt
98
112
 
99
113
  ---
100
114
 
@@ -122,7 +136,7 @@ _Channel monitoring coming in v0.6.0. Currently shows active message count and d
122
136
  "processes_stale": 0,
123
137
  "slow_jobs": 7
124
138
  },
125
- "cache": { "entries": 1024, "byte_size": 2097152 },
139
+ "cache": { "entries": 1024, "byte_size": 2097152, "oldest_entry": "2026-05-20T10:00:00Z" },
126
140
  "cable": { "messages": 50, "channels": 3 },
127
141
  "generated_at": "2026-05-26T10:00:00Z"
128
142
  }
@@ -22,6 +22,33 @@ a.sqw-stat:hover { box-shadow: 0 3px 8px rgba(0,0,0,.12); text-decoration: none;
22
22
  .sqw-stat__label { font-size: 12px; font-weight: 500; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
23
23
  .sqw-stat__value { font-size: 28px; font-weight: 700; line-height: 1; }
24
24
 
25
+ .sqw-size-grid {
26
+ display: grid;
27
+ grid-template-columns: 1fr 1fr;
28
+ gap: 1.5rem;
29
+ margin-top: 1.5rem;
30
+ }
31
+
32
+ .sqw-size-section { }
33
+
34
+ .sqw-dist-bar-cell {
35
+ display: flex;
36
+ align-items: center;
37
+ gap: 0.5rem;
38
+ min-width: 160px;
39
+ }
40
+
41
+ .sqw-dist-bar {
42
+ height: 8px;
43
+ background: var(--purple);
44
+ border-radius: 4px;
45
+ min-width: 2px;
46
+ opacity: 0.7;
47
+ transition: width 0.3s ease;
48
+ }
49
+
50
+ .sqw-dist-pct { font-size: 12px; white-space: nowrap; }
51
+
25
52
  .sqw-stat--ready .sqw-stat__value { color: var(--success); }
26
53
  .sqw-stat--scheduled .sqw-stat__value { color: var(--info); }
27
54
  .sqw-stat--claimed .sqw-stat__value { color: var(--primary); }
@@ -69,6 +69,7 @@ a.sqw-inline-stat:hover { opacity: 0.7; text-decoration: none; }
69
69
  }
70
70
 
71
71
  .sqw-inline-stat__value { font-size: 28px; font-weight: 700; line-height: 1; }
72
+ .sqw-inline-stat__value--sm { font-size: 16px; }
72
73
 
73
74
  .sqw-inline-stat--ready .sqw-inline-stat__value { color: var(--success); }
74
75
  .sqw-inline-stat--scheduled .sqw-inline-stat__value { color: var(--info); }
@@ -105,8 +106,28 @@ a.sqw-inline-stat:hover { opacity: 0.7; text-decoration: none; }
105
106
  height: 40px;
106
107
  }
107
108
 
108
- .sqw-sparkline--sm {
109
- height: 24px;
109
+ .sqw-sparkline--sm { height: 24px; }
110
+ .sqw-sparkline--lg { height: 64px; }
111
+
112
+ .sqw-timeline-grid {
113
+ display: grid;
114
+ grid-template-columns: 1fr 1fr;
115
+ gap: 0;
116
+ margin-top: 1.5rem;
117
+ border: 1px solid var(--border);
118
+ border-radius: var(--radius);
119
+ background: var(--surface);
120
+ box-shadow: var(--shadow);
121
+ overflow: hidden;
122
+ }
123
+
124
+ .sqw-timeline-chart {
125
+ border-top: none;
126
+ color: var(--purple);
127
+ }
128
+
129
+ .sqw-timeline-chart + .sqw-timeline-chart {
130
+ border-left: 1px solid var(--border);
110
131
  }
111
132
 
112
133
  .sqw-queue-sparkline {
@@ -70,6 +70,48 @@
70
70
  overflow-y: auto;
71
71
  }
72
72
 
73
+ .sqw-detail {
74
+ display: grid;
75
+ grid-template-columns: auto 1fr;
76
+ gap: 0.5rem 1.5rem;
77
+ font-size: 13px;
78
+ margin-bottom: 1.5rem;
79
+ }
80
+ .sqw-detail__row {
81
+ display: contents;
82
+ }
83
+ .sqw-detail__row dt { color: var(--muted); white-space: nowrap; align-self: start; padding-top: 0.15rem; }
84
+ .sqw-detail__row dd { word-break: break-all; }
85
+
86
+ .sqw-value-preview { margin-top: 1rem; }
87
+
88
+ .sqw-value-preview__header {
89
+ display: flex;
90
+ align-items: center;
91
+ gap: 0.5rem;
92
+ margin-bottom: 0.75rem;
93
+ }
94
+ .sqw-value-preview__header .sqw-section-title { margin-bottom: 0; }
95
+
96
+ .sqw-value-pre {
97
+ font-family: ui-monospace, "SFMono-Regular", Menlo, monospace;
98
+ font-size: 12px;
99
+ background: var(--bg);
100
+ border: 1px solid var(--border);
101
+ border-radius: var(--radius);
102
+ padding: 0.75rem;
103
+ overflow-x: auto;
104
+ white-space: pre-wrap;
105
+ word-break: break-word;
106
+ max-height: 500px;
107
+ overflow-y: auto;
108
+ }
109
+
110
+ .sqw-value-truncated { font-size: 12px; margin-top: 0.5rem; }
111
+
112
+ .sqw-link { color: var(--primary); text-decoration: none; }
113
+ .sqw-link:hover { text-decoration: underline; }
114
+
73
115
  .sqw-code-input {
74
116
  font-family: ui-monospace, "SFMono-Regular", Menlo, monospace;
75
117
  font-size: 12px;
@@ -16,7 +16,7 @@ module SolidStackWeb
16
16
  def current_section
17
17
  case controller_name
18
18
  when "jobs", "failed_jobs", "queues", "processes", "history", "scheduled_jobs", "recurring_tasks" then :queue
19
- when "cache" then :cache
19
+ when "cache", "cache_entries" then :cache
20
20
  when "cable" then :cable
21
21
  else :overview
22
22
  end
@@ -0,0 +1,8 @@
1
+ module SolidStackWeb
2
+ class Cache::FlushesController < ApplicationController
3
+ def destroy
4
+ ::SolidCache::Entry.delete_all
5
+ redirect_to cache_entries_path, notice: "All cache entries flushed."
6
+ end
7
+ end
8
+ end
@@ -3,6 +3,8 @@ module SolidStackWeb
3
3
  def index
4
4
  @total_entries = ::SolidCache::Entry.count
5
5
  @total_byte_size = ::SolidCache::Entry.sum(:byte_size)
6
+ @size_stats = CacheSizeStats.new
7
+ @timeline = CacheTimeline.new
6
8
  end
7
9
  end
8
10
  end
@@ -0,0 +1,32 @@
1
+ module SolidStackWeb
2
+ class CacheEntriesController < ApplicationController
3
+ def index
4
+ @search = params[:q].presence
5
+ @sort = resolve_sort
6
+
7
+ scope = ::SolidCache::Entry.all
8
+ scope = scope.where("key LIKE ?", "%#{::ActiveRecord::Base.sanitize_sql_like(@search)}%") if @search
9
+ scope = scope.order(@sort["column"] => @sort["direction"])
10
+
11
+ @pagy, @entries = pagy(scope)
12
+ end
13
+
14
+ def show
15
+ @entry = ::SolidCache::Entry.find(params[:id])
16
+ end
17
+
18
+ def destroy
19
+ ::SolidCache::Entry.find(params[:id]).destroy
20
+ redirect_to cache_entries_path(q: params[:q], column: params[:column], direction: params[:direction]),
21
+ notice: "Cache entry deleted."
22
+ end
23
+
24
+ private
25
+
26
+ def resolve_sort
27
+ column = %w[byte_size created_at key].include?(params[:column]) ? params[:column] : "byte_size"
28
+ direction = %w[asc desc].include?(params[:direction]) ? params[:direction] : "desc"
29
+ { "column" => column, "direction" => direction }
30
+ end
31
+ end
32
+ end
@@ -1,5 +1,13 @@
1
1
  module SolidStackWeb
2
2
  module ApplicationHelper
3
+ def format_cache_value(raw)
4
+ str = raw.to_s.encode("UTF-8", invalid: :replace, undef: :replace, replace: "?")
5
+ parsed = JSON.parse(str)
6
+ { label: "JSON", content: JSON.pretty_generate(parsed) }
7
+ rescue JSON::ParserError, JSON::GeneratorError
8
+ { label: "Text", content: str }
9
+ end
10
+
3
11
  def format_duration(seconds)
4
12
  return "—" if seconds.nil?
5
13
  return "#{(seconds * 1000).round}ms" if seconds < 1
@@ -10,6 +18,30 @@ module SolidStackWeb
10
18
  "#{s / 3600}h #{(s % 3600) / 60}m"
11
19
  end
12
20
 
21
+ def cache_entries_timeline_svg(timeline)
22
+ build_sparkline_svg(
23
+ Struct.new(:buckets, :max).new(timeline.entry_buckets, timeline.entry_max),
24
+ css_class: "sqw-sparkline sqw-sparkline--lg",
25
+ aria_label: "Cache entries written over the last 24 hours"
26
+ ) do |count, i|
27
+ hours_ago = CacheTimeline::HOURS - 1 - i
28
+ hours_ago.zero? ? "#{count} #{"entry".then { |w| count == 1 ? w : "entries" }} this hour" \
29
+ : "#{count} #{"entry".then { |w| count == 1 ? w : "entries" }} #{hours_ago}h ago"
30
+ end
31
+ end
32
+
33
+ def cache_bytes_timeline_svg(timeline)
34
+ build_sparkline_svg(
35
+ Struct.new(:buckets, :max).new(timeline.byte_buckets, timeline.byte_max),
36
+ css_class: "sqw-sparkline sqw-sparkline--lg",
37
+ aria_label: "Cache bytes written over the last 24 hours"
38
+ ) do |bytes, i|
39
+ hours_ago = CacheTimeline::HOURS - 1 - i
40
+ size = number_to_human_size(bytes)
41
+ hours_ago.zero? ? "#{size} written this hour" : "#{size} written #{hours_ago}h ago"
42
+ end
43
+ end
44
+
13
45
  def throughput_sparkline_svg(sparkline)
14
46
  build_sparkline_svg(sparkline, aria_label: "Throughput over the last 12 hours") do |count, i|
15
47
  hours_ago = SolidStackWeb::ThroughputSparkline::HOURS - i
@@ -1,10 +1,12 @@
1
1
  import "@hotwired/turbo"
2
2
  import { Application } from "@hotwired/stimulus"
3
3
  import RefreshController from "solid_stack_web/refresh_controller"
4
+ import SearchController from "solid_stack_web/search_controller"
4
5
  import SelectionController from "solid_stack_web/selection_controller"
5
6
  import SparklineTooltipController from "solid_stack_web/sparkline_tooltip_controller"
6
7
 
7
8
  const application = Application.start()
8
9
  application.register("refresh", RefreshController)
10
+ application.register("search", SearchController)
9
11
  application.register("selection", SelectionController)
10
12
  application.register("sparkline-tooltip", SparklineTooltipController)
@@ -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,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
@@ -2,8 +2,9 @@ module SolidStackWeb
2
2
  class CacheStats
3
3
  def to_h
4
4
  {
5
- entries: ::SolidCache::Entry.count,
6
- byte_size: ::SolidCache::Entry.sum(:byte_size)
5
+ entries: ::SolidCache::Entry.count,
6
+ byte_size: ::SolidCache::Entry.sum(:byte_size),
7
+ oldest_entry: ::SolidCache::Entry.minimum(:created_at)
7
8
  }
8
9
  end
9
10
  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
@@ -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">
@@ -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>
@@ -76,14 +76,20 @@
76
76
  <%= link_to "View Cache →", cache_path, class: "sqw-gem-card__link" %>
77
77
  </div>
78
78
  <div class="sqw-gem-card__body">
79
- <div class="sqw-inline-stat sqw-inline-stat--cache">
79
+ <%= link_to cache_entries_path, class: "sqw-inline-stat sqw-inline-stat--cache" do %>
80
80
  <span class="sqw-inline-stat__label">Entries</span>
81
81
  <span class="sqw-inline-stat__value"><%= @cache_stats[:entries] %></span>
82
- </div>
82
+ <% end %>
83
83
  <div class="sqw-inline-stat sqw-inline-stat--cache">
84
84
  <span class="sqw-inline-stat__label">Size</span>
85
85
  <span class="sqw-inline-stat__value"><%= number_to_human_size(@cache_stats[:byte_size]) %></span>
86
86
  </div>
87
+ <% if @cache_stats[:oldest_entry] %>
88
+ <div class="sqw-inline-stat sqw-inline-stat--cache">
89
+ <span class="sqw-inline-stat__label">Oldest</span>
90
+ <span class="sqw-inline-stat__value sqw-inline-stat__value--sm" title="<%= @cache_stats[:oldest_entry].strftime("%b %d, %Y %H:%M") %>"><%= time_ago_in_words(@cache_stats[:oldest_entry]) %></span>
91
+ </div>
92
+ <% end %>
87
93
  </div>
88
94
  </div>
89
95
 
@@ -10,14 +10,14 @@
10
10
  </div>
11
11
  </div>
12
12
 
13
- <form class="sqw-filters" action="<%= history_path %>" method="get">
13
+ <form class="sqw-filters" action="<%= history_path %>" method="get" data-controller="search">
14
14
  <% if @queue.present? %>
15
15
  <input type="hidden" name="queue" value="<%= @queue %>">
16
16
  <% end %>
17
17
  <input type="hidden" name="period" value="<%= @period %>">
18
18
  <input class="sqw-search-input" type="search" name="q" value="<%= @search %>"
19
- placeholder="Filter by job class…" autocomplete="off" aria-label="Filter by job class">
20
- <button type="submit" class="sqw-btn sqw-btn--muted sqw-btn--sm">Search</button>
19
+ placeholder="Filter by job class…" autocomplete="off" aria-label="Filter by job class"
20
+ data-action="input->search#filter">
21
21
  <% if @search.present? %>
22
22
  <%= link_to "Clear", history_path(queue: @queue, period: @period), class: "sqw-btn sqw-btn--muted sqw-btn--sm" %>
23
23
  <% end %>
@@ -32,13 +32,15 @@
32
32
  <%= turbo_frame_tag "sqw-jobs-filter",
33
33
  data: { turbo_action: "advance", controller: "refresh",
34
34
  refresh_interval_value: SolidStackWeb.default_refresh_interval } do %>
35
- <form class="sqw-filters" action="<%= jobs_path %>" method="get">
35
+ <form class="sqw-filters" action="<%= jobs_path %>" method="get" data-controller="search">
36
36
  <%= hidden_field_tag :status, @status %>
37
37
  <%= hidden_field_tag :period, @period %>
38
38
  <input class="sqw-search-input" type="search" name="q" value="<%= @search %>"
39
- placeholder="Filter by job class…" autocomplete="off" aria-label="Filter by job class">
39
+ placeholder="Filter by job class…" autocomplete="off" aria-label="Filter by job class"
40
+ data-action="input->search#filter">
40
41
  <% if @queue_options.size > 1 %>
41
- <select name="queue" class="sqw-select" aria-label="Filter by queue" onchange="this.form.requestSubmit()">
42
+ <select name="queue" class="sqw-select" aria-label="Filter by queue"
43
+ data-action="change->search#select">
42
44
  <option value="">All queues</option>
43
45
  <% @queue_options.each do |q| %>
44
46
  <option value="<%= q %>" <%= "selected" if @queue == q %>><%= q %></option>
@@ -46,14 +48,14 @@
46
48
  </select>
47
49
  <% end %>
48
50
  <% if @priority_options.size > 1 %>
49
- <select name="priority" class="sqw-select" aria-label="Filter by priority" onchange="this.form.requestSubmit()">
51
+ <select name="priority" class="sqw-select" aria-label="Filter by priority"
52
+ data-action="change->search#select">
50
53
  <option value="">All priorities</option>
51
54
  <% @priority_options.each do |p| %>
52
55
  <option value="<%= p %>" <%= "selected" if @priority.to_s == p.to_s %>>Priority <%= p %></option>
53
56
  <% end %>
54
57
  </select>
55
58
  <% end %>
56
- <button type="submit" class="sqw-btn sqw-btn--muted sqw-btn--sm">Search</button>
57
59
  <% if @search.present? || @queue.present? || @priority.present? %>
58
60
  <%= link_to "Clear", jobs_path(status: @status, period: @period), class: "sqw-btn sqw-btn--muted sqw-btn--sm" %>
59
61
  <% end %>
data/config/importmap.rb CHANGED
@@ -2,5 +2,6 @@ pin "@hotwired/turbo", to: "https://cdn.jsdelivr.net/npm/@hotwired/turbo@8.0.23/
2
2
  pin "@hotwired/stimulus", to: "https://cdn.jsdelivr.net/npm/@hotwired/stimulus@3.2.2/dist/stimulus.js"
3
3
  pin "solid_stack_web", to: "solid_stack_web/application.js"
4
4
  pin "solid_stack_web/refresh_controller", to: "solid_stack_web/refresh_controller.js"
5
+ pin "solid_stack_web/search_controller", to: "solid_stack_web/search_controller.js"
5
6
  pin "solid_stack_web/selection_controller", to: "solid_stack_web/selection_controller.js"
6
7
  pin "solid_stack_web/sparkline_tooltip_controller", to: "solid_stack_web/sparkline_tooltip_controller.js"
data/config/routes.rb CHANGED
@@ -35,5 +35,7 @@ SolidStackWeb::Engine.routes.draw do
35
35
  get "stats", to: "stats#index", as: :stats
36
36
  get "history", to: "history#index", as: :history
37
37
  get "cache", to: "cache#index", as: :cache
38
+ resources :cache_entries, only: [:index, :show, :destroy], path: "cache/entries"
39
+ resource :cache_flush, only: [:destroy], path: "cache/flush", controller: "cache/flushes"
38
40
  get "cable", to: "cable#index", as: :cable
39
41
  end
@@ -1,3 +1,3 @@
1
1
  module SolidStackWeb
2
- VERSION = "0.4.0"
2
+ VERSION = "0.5.0"
3
3
  end
@@ -7,7 +7,7 @@ module SolidStackWeb
7
7
  :alert_webhook_url, :alert_webhook_cooldown,
8
8
  :alert_failure_threshold, :alert_queue_thresholds,
9
9
  :dashboard_refresh_interval, :default_refresh_interval,
10
- :search_results_limit
10
+ :search_results_limit, :allow_value_preview
11
11
 
12
12
  def page_size
13
13
  @page_size || 25
@@ -49,6 +49,10 @@ module SolidStackWeb
49
49
  @search_results_limit || 25
50
50
  end
51
51
 
52
+ def allow_value_preview
53
+ @allow_value_preview || false
54
+ end
55
+
52
56
  def configure
53
57
  yield self
54
58
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solid_stack_web
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chuck Smith
@@ -145,7 +145,9 @@ files:
145
145
  - app/assets/stylesheets/solid_stack_web/application.css
146
146
  - app/controllers/solid_stack_web/application_controller.rb
147
147
  - app/controllers/solid_stack_web/cable_controller.rb
148
+ - app/controllers/solid_stack_web/cache/flushes_controller.rb
148
149
  - app/controllers/solid_stack_web/cache_controller.rb
150
+ - app/controllers/solid_stack_web/cache_entries_controller.rb
149
151
  - app/controllers/solid_stack_web/dashboard_controller.rb
150
152
  - app/controllers/solid_stack_web/failed_jobs/arguments_controller.rb
151
153
  - app/controllers/solid_stack_web/failed_jobs/selections_controller.rb
@@ -164,11 +166,14 @@ files:
164
166
  - app/helpers/solid_stack_web/application_helper.rb
165
167
  - app/javascript/solid_stack_web/application.js
166
168
  - app/javascript/solid_stack_web/refresh_controller.js
169
+ - app/javascript/solid_stack_web/search_controller.js
167
170
  - app/javascript/solid_stack_web/selection_controller.js
168
171
  - app/javascript/solid_stack_web/sparkline_tooltip_controller.js
169
172
  - app/models/solid_stack_web/alert_webhook.rb
170
173
  - app/models/solid_stack_web/cable_stats.rb
174
+ - app/models/solid_stack_web/cache_size_stats.rb
171
175
  - app/models/solid_stack_web/cache_stats.rb
176
+ - app/models/solid_stack_web/cache_timeline.rb
172
177
  - app/models/solid_stack_web/job.rb
173
178
  - app/models/solid_stack_web/queue_depth_sparkline.rb
174
179
  - app/models/solid_stack_web/queue_stats.rb
@@ -176,6 +181,8 @@ files:
176
181
  - app/views/layouts/solid_stack_web/application.html.erb
177
182
  - app/views/solid_stack_web/cable/index.html.erb
178
183
  - app/views/solid_stack_web/cache/index.html.erb
184
+ - app/views/solid_stack_web/cache_entries/index.html.erb
185
+ - app/views/solid_stack_web/cache_entries/show.html.erb
179
186
  - app/views/solid_stack_web/dashboard/index.html.erb
180
187
  - app/views/solid_stack_web/failed_jobs/destroy.turbo_stream.erb
181
188
  - app/views/solid_stack_web/failed_jobs/index.html.erb
@@ -218,7 +225,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
218
225
  - !ruby/object:Gem::Version
219
226
  version: '0'
220
227
  requirements: []
221
- rubygems_version: 4.0.12
228
+ rubygems_version: 4.0.10
222
229
  specification_version: 4
223
230
  summary: A unified Rails engine dashboard for Solid Queue, Solid Cache, and Solid
224
231
  Cable.