217 lines
8.1 KiB
Plaintext
217 lines
8.1 KiB
Plaintext
<% content_for :head do %>
|
|
<style type="text/css" data-turbolinks-track>
|
|
.grid-cell {
|
|
padding: 0.25rem 1rem;
|
|
border-right: 1px solid #e2e8f0;
|
|
}
|
|
.grid-cell:last-child {
|
|
border-right: none;
|
|
}
|
|
.grid-row:hover .grid-cell {
|
|
background-color: #f1f5f9;
|
|
transition: background-color 150ms ease-in-out;
|
|
}
|
|
.stats-card {
|
|
border-radius: 0.5rem;
|
|
border: 1px solid #e2e8f0;
|
|
background-color: white;
|
|
padding: 1rem;
|
|
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
|
}
|
|
.stats-grid {
|
|
border-radius: 0.5rem;
|
|
border: 1px solid #e2e8f0;
|
|
background-color: white;
|
|
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
|
overflow: hidden;
|
|
}
|
|
.stats-grid-header {
|
|
background-color: #f8fafc;
|
|
padding: 0.75rem 1rem;
|
|
font-size: 0.875rem;
|
|
font-weight: 500;
|
|
color: #334155;
|
|
cursor: pointer;
|
|
user-select: none;
|
|
}
|
|
.stats-grid-header:hover {
|
|
background-color: #f1f5f9;
|
|
}
|
|
.sort-indicator::after {
|
|
content: "▼";
|
|
font-size: 0.75rem;
|
|
margin-left: 0.25rem;
|
|
opacity: 0.5;
|
|
}
|
|
.sort-indicator.asc::after {
|
|
content: "▲";
|
|
}
|
|
</style>
|
|
<% end %>
|
|
<div class="mx-auto max-w-5xl px-4 mt-6 sm:mt-8">
|
|
<h1 class="text-2xl font-bold text-slate-900 text-center">HTTP Request Log Stats</h1>
|
|
<div class="mt-2 text-center">
|
|
<%= link_to "Back to Index",
|
|
log_entries_path,
|
|
class: "text-blue-600 hover:text-blue-800 transition-colors" %>
|
|
</div>
|
|
<div class="mt-6 text-center">
|
|
<div class="space-x-2">
|
|
<% [10.seconds, 30.seconds, 1.minute, 5.minutes, 30.minutes].each do |time_window| %>
|
|
<% if @time_window == time_window %>
|
|
<span class="rounded-full bg-blue-100 px-3 py-1 text-sm font-medium text-blue-800">
|
|
<%= if time_window < 1.minute
|
|
pluralize(time_window.seconds, "second")
|
|
else
|
|
time_ago_in_words(time_window.ago)
|
|
end %>
|
|
</span>
|
|
<% else %>
|
|
<%= link_to(
|
|
(
|
|
if time_window < 1.minute
|
|
pluralize(time_window.seconds, "second")
|
|
else
|
|
time_ago_in_words(time_window.ago)
|
|
end
|
|
),
|
|
stats_log_entries_path(seconds: time_window.in_seconds),
|
|
class: "rounded-full px-3 py-1 text-sm text-blue-600 hover:text-blue-800 hover:bg-blue-50 transition-colors",
|
|
) %>
|
|
<% end %>
|
|
<% end %>
|
|
</div>
|
|
<div class="stats-card mt-6">
|
|
<div class="text-xl font-bold text-slate-900">
|
|
<%= @last_window_count %> requests
|
|
<span class="text-base font-normal text-slate-600">
|
|
in last
|
|
<%= if @time_window < 1.minute
|
|
pluralize(@time_window.seconds, "second")
|
|
else
|
|
time_ago_in_words(@time_window.ago)
|
|
end %>
|
|
</span>
|
|
</div>
|
|
<div class="mt-1 text-slate-600">
|
|
<span class="font-medium"><%= (@last_window_count.to_f / @time_window.in_seconds).round(1) %></span> requests/sec
|
|
</div>
|
|
<div class="mt-1 text-sm text-slate-600">
|
|
<span class="font-medium"><%= HexUtil.humansize(@last_window_bytes) %></span> transferred •
|
|
<span class="font-medium"><%= HexUtil.humansize(@last_window_bytes / @time_window.in_seconds) %></span>/sec
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="mt-8">
|
|
<h2 class="text-xl font-bold text-slate-900 mb-3">By Content Type</h2>
|
|
<div class="stats-grid">
|
|
<div class="grid grid-cols-[1fr_auto_auto] sortable-table" id="content-type-table">
|
|
<div class="grid-row contents">
|
|
<div class="stats-grid-header sort-indicator" data-column="content_type">Content Type</div>
|
|
<div class="stats-grid-header text-right sort-indicator" data-column="count">Requests</div>
|
|
<div class="stats-grid-header text-right sort-indicator" data-column="bytes">Transferred</div>
|
|
</div>
|
|
<% @content_type_counts
|
|
.sort_by { |_ignore, stats| -stats[:count] }
|
|
.each do |content_type, stats| %>
|
|
<div class="grid-row contents" data-content-type="<%= content_type %>" data-count="<%= stats[:count] %>" data-bytes="<%= stats[:bytes] %>">
|
|
<div class="grid-cell font-medium text-slate-900 text-sm"><%= content_type %></div>
|
|
<div class="grid-cell text-right text-slate-600 text-sm"><%= stats[:count] %></div>
|
|
<div class="grid-cell text-right text-slate-600 font-medium text-sm">
|
|
<%= HexUtil.humansize(stats[:bytes]) %>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="mt-8 mb-8">
|
|
<h2 class="text-xl font-bold text-slate-900 mb-3">By Domain</h2>
|
|
<div class="stats-grid">
|
|
<div class="grid grid-cols-[1fr_auto_auto] sortable-table" id="domain-table">
|
|
<div class="grid-row contents">
|
|
<div class="stats-grid-header sort-indicator" data-column="domain">Domain</div>
|
|
<div class="stats-grid-header text-right sort-indicator" data-column="count">Requests</div>
|
|
<div class="stats-grid-header text-right sort-indicator" data-column="bytes">Transferred</div>
|
|
</div>
|
|
<% @by_domain_counts
|
|
.sort_by { |_ignore, stats| -stats[:bytes] }
|
|
.each do |domain, stats| %>
|
|
<div class="grid-row contents" data-domain="<%= domain %>" data-count="<%= stats[:count] %>" data-bytes="<%= stats[:bytes] %>">
|
|
<div class="grid-cell font-medium text-slate-900 text-sm"><%= domain %></div>
|
|
<div class="grid-cell text-right text-slate-600 text-sm"><%= stats[:count] %></div>
|
|
<div class="grid-cell text-right text-slate-600 font-medium text-sm">
|
|
<%= HexUtil.humansize(stats[:bytes]) %>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<script>
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
// Initialize sortable tables
|
|
document.querySelectorAll('.sortable-table').forEach(table => {
|
|
initSortableTable(table);
|
|
});
|
|
|
|
function initSortableTable(table) {
|
|
const headers = table.querySelectorAll('.stats-grid-header');
|
|
|
|
headers.forEach(header => {
|
|
header.addEventListener('click', function() {
|
|
const column = this.getAttribute('data-column');
|
|
const isAsc = !this.classList.contains('asc');
|
|
|
|
// Reset all headers
|
|
headers.forEach(h => h.classList.remove('asc'));
|
|
|
|
// Set current header state
|
|
if (isAsc) {
|
|
this.classList.add('asc');
|
|
}
|
|
|
|
// Get all rows except the header row
|
|
const rows = Array.from(table.querySelectorAll('.grid-row:not(:first-child)'));
|
|
|
|
// Sort the rows
|
|
rows.sort((a, b) => {
|
|
let aValue, bValue;
|
|
|
|
if (table.id === 'content-type-table') {
|
|
if (column === 'content_type') {
|
|
aValue = a.getAttribute('data-content-type');
|
|
bValue = b.getAttribute('data-content-type');
|
|
return isAsc ? aValue.localeCompare(bValue) : bValue.localeCompare(aValue);
|
|
} else {
|
|
aValue = parseInt(a.getAttribute(`data-${column}`), 10);
|
|
bValue = parseInt(b.getAttribute(`data-${column}`), 10);
|
|
}
|
|
} else if (table.id === 'domain-table') {
|
|
if (column === 'domain') {
|
|
aValue = a.getAttribute('data-domain');
|
|
bValue = b.getAttribute('data-domain');
|
|
return isAsc ? aValue.localeCompare(bValue) : bValue.localeCompare(aValue);
|
|
} else {
|
|
aValue = parseInt(a.getAttribute(`data-${column}`), 10);
|
|
bValue = parseInt(b.getAttribute(`data-${column}`), 10);
|
|
}
|
|
}
|
|
|
|
return isAsc ? aValue - bValue : bValue - aValue;
|
|
});
|
|
|
|
// Remove existing rows
|
|
rows.forEach(row => row.remove());
|
|
|
|
// Append sorted rows
|
|
rows.forEach(row => {
|
|
table.appendChild(row);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
});
|
|
</script>
|