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.
- checksums.yaml +4 -4
- data/README.md +117 -26
- data/app/assets/stylesheets/solid_stack_web/_03_stats.css +27 -0
- data/app/assets/stylesheets/solid_stack_web/_04_table.css +4 -0
- data/app/assets/stylesheets/solid_stack_web/_07_dashboard.css +85 -12
- data/app/assets/stylesheets/solid_stack_web/_09_detail.css +42 -0
- data/app/controllers/solid_stack_web/application_controller.rb +1 -1
- data/app/controllers/solid_stack_web/cache/flushes_controller.rb +8 -0
- data/app/controllers/solid_stack_web/cache_controller.rb +2 -0
- data/app/controllers/solid_stack_web/cache_entries_controller.rb +32 -0
- data/app/controllers/solid_stack_web/dashboard_controller.rb +4 -16
- data/app/controllers/solid_stack_web/metrics_controller.rb +15 -0
- data/app/controllers/solid_stack_web/queues_controller.rb +4 -0
- data/app/controllers/solid_stack_web/stats_controller.rb +39 -0
- data/app/helpers/solid_stack_web/application_helper.rb +90 -2
- data/app/javascript/solid_stack_web/application.js +7 -1
- data/app/javascript/solid_stack_web/refresh_controller.js +52 -0
- data/app/javascript/solid_stack_web/search_controller.js +16 -0
- data/app/javascript/solid_stack_web/sparkline_tooltip_controller.js +23 -0
- data/app/models/solid_stack_web/alert_webhook.rb +67 -0
- data/app/models/solid_stack_web/cable_stats.rb +10 -0
- data/app/models/solid_stack_web/cache_size_stats.rb +33 -0
- data/app/models/solid_stack_web/cache_stats.rb +11 -0
- data/app/models/solid_stack_web/cache_timeline.rb +40 -0
- data/app/models/solid_stack_web/queue_depth_sparkline.rb +30 -0
- data/app/models/solid_stack_web/queue_stats.rb +34 -0
- data/app/models/solid_stack_web/throughput_sparkline.rb +23 -0
- data/app/views/layouts/solid_stack_web/application.html.erb +13 -0
- data/app/views/solid_stack_web/cache/index.html.erb +84 -2
- data/app/views/solid_stack_web/cache_entries/index.html.erb +66 -0
- data/app/views/solid_stack_web/cache_entries/show.html.erb +48 -0
- data/app/views/solid_stack_web/dashboard/index.html.erb +45 -4
- data/app/views/solid_stack_web/history/index.html.erb +6 -3
- data/app/views/solid_stack_web/jobs/index.html.erb +11 -7
- data/app/views/solid_stack_web/processes/index.html.erb +3 -0
- data/app/views/solid_stack_web/queues/index.html.erb +6 -1
- data/app/views/solid_stack_web/stats/index.html.erb +48 -0
- data/config/importmap.rb +5 -0
- data/config/routes.rb +4 -0
- data/lib/solid_stack_web/version.rb +1 -1
- data/lib/solid_stack_web.rb +41 -1
- 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("
|
|
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,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,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
|
-
|
|
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
|
-
|
|
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 “#{@search}”.".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>
|