Skip to content

Commit bd456f2

Browse files
committed
Added a performance statistic dashboard to the admin area
1 parent f5eee20 commit bd456f2

6 files changed

Lines changed: 328 additions & 0 deletions

File tree

app/assets/stylesheets/components/admin.css

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,3 +464,107 @@ textarea.form-input {
464464
flex-direction: column;
465465
}
466466
}
467+
468+
/* Performance Dashboard */
469+
.perf-date-form {
470+
margin-bottom: var(--spacing-6);
471+
}
472+
473+
.perf-date-fields {
474+
display: flex;
475+
align-items: flex-end;
476+
gap: var(--spacing-4);
477+
margin-bottom: var(--spacing-3);
478+
}
479+
480+
.perf-date-fields label {
481+
display: flex;
482+
flex-direction: column;
483+
gap: var(--spacing-1);
484+
font-size: var(--font-size-sm);
485+
font-weight: var(--font-weight-medium);
486+
color: var(--color-text-secondary);
487+
}
488+
489+
.perf-date-fields input[type="date"] {
490+
padding: var(--spacing-2) var(--spacing-3);
491+
border: var(--border-width) solid var(--color-border);
492+
border-radius: var(--border-radius-md);
493+
background: var(--color-bg-input);
494+
font-size: var(--font-size-sm);
495+
color: var(--color-text-primary);
496+
}
497+
498+
.perf-date-summary {
499+
font-size: var(--font-size-sm);
500+
color: var(--color-text-muted);
501+
}
502+
503+
.perf-summary-cards {
504+
display: grid;
505+
grid-template-columns: repeat(2, 1fr);
506+
gap: var(--spacing-4);
507+
margin-bottom: var(--spacing-6);
508+
}
509+
510+
.perf-card {
511+
background: var(--color-bg-card);
512+
border: var(--border-width) solid var(--color-border);
513+
border-radius: var(--border-radius-lg);
514+
overflow: hidden;
515+
}
516+
517+
.perf-card-header {
518+
padding: var(--spacing-3) var(--spacing-4);
519+
font-weight: var(--font-weight-semibold);
520+
font-size: var(--font-size-sm);
521+
color: var(--color-text-secondary);
522+
background: var(--color-bg-table-header);
523+
border-bottom: var(--border-width) solid var(--color-border);
524+
}
525+
526+
.perf-card-body {
527+
padding: var(--spacing-4);
528+
}
529+
530+
.perf-stat-row {
531+
display: flex;
532+
gap: var(--spacing-4);
533+
}
534+
535+
.perf-stat {
536+
flex: 1;
537+
text-align: center;
538+
padding: var(--spacing-3);
539+
background: var(--color-bg-hover);
540+
border-radius: var(--border-radius-md);
541+
}
542+
543+
.perf-histograms {
544+
display: grid;
545+
grid-template-columns: repeat(2, 1fr);
546+
gap: var(--spacing-4);
547+
margin-bottom: var(--spacing-6);
548+
}
549+
550+
.perf-histogram-col h3 {
551+
font-size: var(--font-size-base);
552+
font-weight: var(--font-weight-semibold);
553+
margin-bottom: var(--spacing-3);
554+
color: var(--color-text-primary);
555+
}
556+
557+
@media (max-width: 768px) {
558+
.perf-date-fields {
559+
flex-direction: column;
560+
align-items: stretch;
561+
}
562+
563+
.perf-summary-cards {
564+
grid-template-columns: 1fr;
565+
}
566+
567+
.perf-histograms {
568+
grid-template-columns: 1fr;
569+
}
570+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# frozen_string_literal: true
2+
3+
class Admin::PageLoadStatsController < Admin::BaseController
4+
def active_admin_section
5+
:page_load_stats
6+
end
7+
8+
def index
9+
@to = parse_date(params[:to]) || Date.current
10+
@from = parse_date(params[:from]) || @to - 6.days
11+
12+
@overall = PageLoadStat.overall_stats(@from, @to)
13+
@per_action = PageLoadStat.per_action_stats(@from, @to)
14+
@total_count = @overall.values.sum { |v| v[:count] }
15+
end
16+
17+
private
18+
19+
def parse_date(value)
20+
Date.iso8601(value.to_s)
21+
rescue ArgumentError
22+
nil
23+
end
24+
end

app/models/page_load_stat.rb

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,70 @@
11
class PageLoadStat < ApplicationRecord
2+
HISTOGRAM_BINS = [
3+
{ index: 0, label: "0-50ms", min: 0, max: 50 },
4+
{ index: 1, label: "50-100ms", min: 50, max: 100 },
5+
{ index: 2, label: "100-200ms", min: 100, max: 200 },
6+
{ index: 3, label: "200-500ms", min: 200, max: 500 },
7+
{ index: 4, label: "500ms-1s", min: 500, max: 1000 },
8+
{ index: 5, label: "1s+", min: 1000, max: nil }
9+
].freeze
10+
11+
scope :in_range, ->(from, to) { where(created_at: from.beginning_of_day..to.end_of_day) }
12+
13+
def self.overall_stats(from, to)
14+
rows = in_range(from, to)
15+
.select(
16+
"is_turbo",
17+
"COUNT(*) AS request_count",
18+
"percentile_cont(0.95) WITHIN GROUP (ORDER BY render_time) AS p95",
19+
"percentile_cont(0.99) WITHIN GROUP (ORDER BY render_time) AS p99",
20+
histogram_case_sql("bin")
21+
)
22+
.group("is_turbo", "bin")
23+
.order("is_turbo", "bin")
24+
25+
grouped = { false => { p95: 0, p99: 0, count: 0, histogram: empty_histogram },
26+
true => { p95: 0, p99: 0, count: 0, histogram: empty_histogram } }
27+
28+
rows.each do |row|
29+
turbo = row.is_turbo
30+
bin = row[:bin].to_i
31+
grouped[turbo][:p95] = row[:p95].to_f
32+
grouped[turbo][:p99] = row[:p99].to_f
33+
grouped[turbo][:count] += row[:request_count].to_i
34+
grouped[turbo][:histogram][bin] = row[:request_count].to_i
35+
end
36+
37+
grouped
38+
end
39+
40+
def self.per_action_stats(from, to)
41+
in_range(from, to)
42+
.select(
43+
"controller",
44+
"action",
45+
"COUNT(*) AS request_count",
46+
"percentile_cont(0.95) WITHIN GROUP (ORDER BY render_time) AS p95",
47+
"percentile_cont(0.99) WITHIN GROUP (ORDER BY render_time) AS p99",
48+
"stddev(render_time) AS stddev"
49+
)
50+
.group("controller", "action")
51+
.order(Arel.sql("percentile_cont(0.95) WITHIN GROUP (ORDER BY render_time) DESC"))
52+
end
53+
54+
def self.histogram_case_sql(as_name)
55+
Arel.sql(<<~SQL.squish)
56+
CASE
57+
WHEN render_time >= 0 AND render_time < 50 THEN 0
58+
WHEN render_time >= 50 AND render_time < 100 THEN 1
59+
WHEN render_time >= 100 AND render_time < 200 THEN 2
60+
WHEN render_time >= 200 AND render_time < 500 THEN 3
61+
WHEN render_time >= 500 AND render_time < 1000 THEN 4
62+
ELSE 5
63+
END AS #{as_name}
64+
SQL
65+
end
66+
67+
def self.empty_histogram
68+
HISTOGRAM_BINS.each_with_object({}) { |bin, h| h[bin[:index]] = 0 }
69+
end
270
end
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
- content_for :title, "Performance"
2+
3+
.admin-merge-container
4+
h1 Performance
5+
6+
/ Date range form
7+
form.perf-date-form action=admin_page_load_stats_path method="get"
8+
.perf-date-fields
9+
label
10+
span From
11+
input type="date" name="from" value="#{@from.iso8601}"
12+
label
13+
span To
14+
input type="date" name="to" value="#{@to.iso8601}"
15+
button.button.button-secondary type="submit" Apply
16+
p.perf-date-summary
17+
strong> #{number_with_delimiter(@total_count)}
18+
| requests from #{@from.strftime('%b %d, %Y')} to #{@to.strftime('%b %d, %Y')}
19+
20+
/ Summary cards
21+
.perf-summary-cards
22+
- { false => "Normal", true => "Turbo" }.each do |turbo, label|
23+
- data = @overall[turbo]
24+
.perf-card
25+
.perf-card-header= label
26+
.perf-card-body
27+
.perf-stat-row
28+
.perf-stat
29+
.stat-value= data[:p95].positive? ? "#{data[:p95].round(1)}ms" : ""
30+
.stat-label P95
31+
.perf-stat
32+
.stat-value= data[:p99].positive? ? "#{data[:p99].round(1)}ms" : ""
33+
.stat-label P99
34+
.perf-stat
35+
.stat-value= number_with_delimiter(data[:count])
36+
.stat-label Requests
37+
38+
/ Histograms
39+
.perf-histograms
40+
- bins = PageLoadStat::HISTOGRAM_BINS
41+
- { false => "normal", true => "turbo" }.each do |turbo, key|
42+
.perf-histogram-col
43+
h3= turbo ? "Turbo Requests" : "Normal Requests"
44+
- hist = @overall[turbo][:histogram]
45+
- chart_data = bins.map { |b| { label: b[:label], count: hist[b[:index]] } }
46+
div id="perf-histogram-#{key}" data-histogram=chart_data.to_json
47+
48+
/ Per-action table
49+
h2 Per-Action Breakdown
50+
.merge-log-table
51+
table
52+
thead
53+
tr
54+
th Controller
55+
th Action
56+
th Requests
57+
th P95 (ms)
58+
th P99 (ms)
59+
th StdDev (ms)
60+
tbody
61+
- if @per_action.empty?
62+
tr
63+
td colspan="6" .empty-state No data for this range.
64+
- else
65+
- @per_action.each do |row|
66+
tr
67+
td= row[:controller]
68+
td= row[:action]
69+
td= number_with_delimiter(row[:request_count].to_i)
70+
td= row[:p95].to_f.round(1)
71+
td= row[:p99].to_f.round(1)
72+
td= row[:stddev] ? row[:stddev].to_f.round(1) : ""
73+
74+
script[src="https://cdn.jsdelivr.net/npm/vega@5.25.0"]
75+
script[src="https://cdn.jsdelivr.net/npm/vega-embed@6.22.2"]
76+
script
77+
|
78+
(function() {
79+
var bins = document.querySelectorAll("[data-histogram]");
80+
bins.forEach(function(el) {
81+
var data = JSON.parse(el.dataset.histogram);
82+
var spec = {
83+
"$schema": "https://vega.github.io/schema/vega/v5.json",
84+
width: Math.max(280, el.parentElement.getBoundingClientRect().width - 40),
85+
height: 200,
86+
padding: 10,
87+
autosize: { type: "fit", contains: "padding" },
88+
data: [{ name: "hist", values: data }],
89+
scales: [
90+
{
91+
name: "x",
92+
type: "band",
93+
domain: { data: "hist", field: "label" },
94+
range: "width",
95+
padding: 0.2
96+
},
97+
{
98+
name: "y",
99+
type: "linear",
100+
domain: { data: "hist", field: "count" },
101+
nice: true,
102+
range: "height"
103+
}
104+
],
105+
axes: [
106+
{ orient: "bottom", scale: "x", labelAngle: -30, labelAlign: "right" },
107+
{ orient: "left", scale: "y" }
108+
],
109+
marks: [
110+
{
111+
type: "rect",
112+
from: { data: "hist" },
113+
encode: {
114+
enter: {
115+
x: { scale: "x", field: "label" },
116+
width: { scale: "x", band: 1 },
117+
y: { scale: "y", field: "count" },
118+
y2: { scale: "y", value: 0 },
119+
fill: { value: "#4e79a7" },
120+
tooltip: { signal: "datum.label + ': ' + datum.count + ' requests'" }
121+
}
122+
}
123+
}
124+
]
125+
};
126+
vegaEmbed(el, spec, { actions: false, tooltip: true });
127+
});
128+
})();

app/views/layouts/admin.html.slim

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818
= link_to admin_imap_sync_states_path, class: admin_nav_link_class(:imap_sync) do
1919
i.fas.fa-sync
2020
span IMAP Sync
21+
= link_to admin_page_load_stats_path, class: admin_nav_link_class(:page_load_stats) do
22+
i.fas.fa-stopwatch
23+
span Performance
2124
= link_to admin_pg_hero.root_path, class: admin_nav_link_class(:pghero) do
2225
i.fas.fa-database
2326
span PgHero

config/routes.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
post :preview
1818
end
1919
end
20+
resources :page_load_stats, only: [:index]
2021
mount PgHero::Engine, at: "/pghero" if defined?(PgHero)
2122
end
2223
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html

0 commit comments

Comments
 (0)