3 Commits

Author SHA1 Message Date
Dylan Knutson
fb436e1b75 remove domain icons on user pages 2025-07-23 07:24:27 +00:00
Dylan Knutson
c96b1d9cc1 Redesign StatsPage header with improved UI/UX
- Replace old header design with modern card-based layout
- Add color-coded metric cards with FontAwesome icons
- Improve time period selector with pill-style buttons
- Replace custom SVG icons with FontAwesome icons (fa-chart-bar, fa-bolt, fa-database, fa-download, fa-arrow-left)
- Enhance visual hierarchy with proper spacing and gradients
- Fix mobile responsiveness for duration selector (flex-wrap support)
- Simplify CSS by removing redundant properties
- Merge summary and time selector sections for better flow
2025-07-23 07:07:52 +00:00
Dylan Knutson
e027dc9bc4 Convert stats page to React components with simplifications 2025-07-23 06:55:25 +00:00
10 changed files with 503 additions and 228 deletions

View File

@@ -315,11 +315,19 @@ module Domain::DescriptionsHelper
end
sig do
params(post: Domain::Post, link_text: String, visual_style: String).returns(
T::Hash[Symbol, T.untyped],
)
params(
post: Domain::Post,
link_text: String,
visual_style: String,
domain_icon: T::Boolean,
).returns(T::Hash[Symbol, T.untyped])
end
def props_for_post_hover_preview(post, link_text, visual_style)
def props_for_post_hover_preview(
post,
link_text,
visual_style,
domain_icon: true
)
cache_key = [
post,
policy(post),
@@ -336,7 +344,7 @@ module Domain::DescriptionsHelper
postPath: Rails.application.routes.url_helpers.domain_post_path(post),
postThumbnailPath: thumbnail_for_post_path(post),
postThumbnailAlt: "View on #{domain_name_for_model(post)}",
postDomainIcon: domain_model_icon_path(post),
postDomainIcon: domain_icon ? domain_model_icon_path(post) : nil,
}.then do |props|
if creator = post.primary_creator_for_view
props[:creatorName] = creator.name_for_view

View File

@@ -12,7 +12,7 @@ interface PostHoverPreviewWrapperProps {
postPath: string;
postThumbnailPath: string;
postThumbnailAlt: string;
postDomainIcon: string;
postDomainIcon?: string;
creatorName?: string;
creatorAvatarPath?: string;
}
@@ -43,11 +43,13 @@ export const PostHoverPreviewWrapper: React.FC<
href={postPath}
className={anchorClassNamesForVisualStyle(visualStyle, true)}
>
{postDomainIcon && (
<img
src={postDomainIcon}
alt={postThumbnailAlt}
className={iconClassNamesForSize('small')}
/>
className={iconClassNamesForSize('small')}
/>
)}
{visualStyle === 'description-section-link' && (
<img
src={postDomainIcon}

View File

@@ -0,0 +1,157 @@
import * as React from 'react';
export interface TableCell {
value: string;
sortKey: string | number;
}
export interface TableData {
id: string;
cells: TableCell[];
}
export interface TableHeader {
label: string;
key: string;
align: 'left' | 'right';
}
interface SortableTableProps {
headers: TableHeader[];
data: TableData[];
defaultSortKey: string;
defaultSortOrder: 'asc' | 'desc';
}
type SortOrder = 'asc' | 'desc';
export const SortableTable: React.FC<SortableTableProps> = ({
headers,
data,
defaultSortKey,
defaultSortOrder,
}) => {
const [sortKey, setSortKey] = React.useState<string>(defaultSortKey);
const [sortOrder, setSortOrder] = React.useState<SortOrder>(defaultSortOrder);
const handleSort = (headerKey: string) => {
if (sortKey === headerKey) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
} else {
setSortKey(headerKey);
setSortOrder('desc');
}
};
const sortedData = React.useMemo(() => {
const headerIndex = headers.findIndex(h => h.key === sortKey);
if (headerIndex === -1) return data;
return [...data].sort((a, b) => {
const aValue = a.cells[headerIndex]?.sortKey;
const bValue = b.cells[headerIndex]?.sortKey;
if (typeof aValue === 'string' && typeof bValue === 'string') {
const comparison = aValue.localeCompare(bValue);
return sortOrder === 'asc' ? comparison : -comparison;
} else if (typeof aValue === 'number' && typeof bValue === 'number') {
const comparison = aValue - bValue;
return sortOrder === 'asc' ? comparison : -comparison;
}
return 0;
});
}, [data, sortKey, sortOrder, headers]);
const gridStyle: React.CSSProperties = {
borderRadius: '0.5rem',
border: '1px solid #e2e8f0',
backgroundColor: 'white',
boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
overflow: 'hidden',
};
const containerStyle: React.CSSProperties = {
display: 'grid',
gridTemplateColumns: '1fr auto auto',
};
const headerStyle: React.CSSProperties = {
backgroundColor: '#f8fafc',
padding: '0.75rem 1rem',
fontSize: '0.875rem',
fontWeight: 500,
color: '#334155',
cursor: 'pointer',
userSelect: 'none',
};
const cellStyle: React.CSSProperties = {
padding: '0.25rem 1rem',
borderRight: '1px solid #e2e8f0',
};
const getSortIndicator = (headerKey: string) => {
if (sortKey !== headerKey) {
return <span style={{ opacity: 0.5, marginLeft: '0.25rem', fontSize: '0.75rem' }}></span>;
}
return (
<span style={{ marginLeft: '0.25rem', fontSize: '0.75rem' }}>
{sortOrder === 'asc' ? '▲' : '▼'}
</span>
);
};
return (
<div style={gridStyle}>
<div style={containerStyle}>
{/* Header Row */}
<div className="contents">
{headers.map((header, index) => (
<div
key={header.key}
style={{
...headerStyle,
textAlign: header.align,
...(index === headers.length - 1 ? { borderRight: 'none' } : {}),
}}
onClick={() => handleSort(header.key)}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = '#f1f5f9';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = '#f8fafc';
}}
>
{header.label}
{getSortIndicator(header.key)}
</div>
))}
</div>
{/* Data Rows */}
{sortedData.map((row) => (
<div key={row.id} className="contents group">
{row.cells.map((cell, cellIndex) => (
<div
key={cellIndex}
style={{
...cellStyle,
textAlign: headers[cellIndex].align,
fontSize: '0.875rem',
fontWeight: cellIndex === 0 ? 500 : 400,
color: cellIndex === 0 ? '#0f172a' : '#64748b',
...(cellIndex === row.cells.length - 1 ? { borderRight: 'none' } : {}),
}}
className="group-hover:bg-slate-50 transition-colors duration-150"
>
{cell.value}
</div>
))}
</div>
))}
</div>
</div>
);
};
export default SortableTable;

View File

@@ -0,0 +1,45 @@
import * as React from 'react';
interface StatsCardProps {
requestCount: number;
timeWindow: string;
requestsPerSecond: string;
totalBytes: string;
bytesPerSecond: string;
}
export const StatsCard: React.FC<StatsCardProps> = ({
requestCount,
timeWindow,
requestsPerSecond,
totalBytes,
bytesPerSecond,
}) => {
const cardStyle: React.CSSProperties = {
borderRadius: '0.5rem',
border: '1px solid #e2e8f0',
backgroundColor: 'white',
padding: '1rem',
boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
};
return (
<div className="mt-6" style={cardStyle}>
<div className="text-xl font-bold text-slate-900">
{requestCount} requests
<span className="text-base font-normal text-slate-600">
{' '}in last {timeWindow}
</span>
</div>
<div className="mt-1 text-slate-600">
<span className="font-medium">{requestsPerSecond}</span> requests/sec
</div>
<div className="mt-1 text-sm text-slate-600">
<span className="font-medium">{totalBytes}</span> transferred {' '}
<span className="font-medium">{bytesPerSecond}</span>/sec
</div>
</div>
);
};
export default StatsCard;

View File

@@ -0,0 +1,201 @@
import * as React from 'react';
import { StatsCard } from './StatsCard';
import { SortableTable, TableData } from './SortableTable';
interface StatsPageProps {
timeWindow: number; // in seconds
lastWindowCount: number;
lastWindowBytes: number;
requestsPerSecond: string;
totalBytesFormatted: string;
bytesPerSecondFormatted: string;
timeWindowFormatted: string;
contentTypeCounts: Array<{
content_type: string;
count: number;
bytes: number;
countFormatted: string;
bytesFormatted: string;
}>;
byDomainCounts: Array<{
domain: string;
count: number;
bytes: number;
countFormatted: string;
bytesFormatted: string;
}>;
availableTimeWindows: Array<{
seconds: number;
label: string;
active: boolean;
path: string;
}>;
}
export const StatsPage: React.FC<StatsPageProps> = ({
lastWindowCount,
requestsPerSecond,
totalBytesFormatted,
bytesPerSecondFormatted,
timeWindowFormatted,
contentTypeCounts,
byDomainCounts,
availableTimeWindows,
}) => {
const contentTypeData: TableData[] = contentTypeCounts.map(item => ({
id: item.content_type,
cells: [
{ value: item.content_type, sortKey: item.content_type },
{ value: item.countFormatted, sortKey: item.count },
{ value: item.bytesFormatted, sortKey: item.bytes }
]
}));
const domainData: TableData[] = byDomainCounts.map(item => ({
id: item.domain,
cells: [
{ value: item.domain, sortKey: item.domain },
{ value: item.countFormatted, sortKey: item.count },
{ value: item.bytesFormatted, sortKey: item.bytes }
]
}));
return (
<div className="mx-auto max-w-7xl px-4 mt-8">
{/* Header Section */}
<div className="bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden">
{/* Top Bar */}
<div className="bg-slate-50 px-6 py-4 border-b border-slate-200">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-slate-900">HTTP Request Analytics</h1>
<a
href="/log_entries"
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-slate-600 hover:text-slate-900 hover:bg-white border border-slate-300 rounded-lg transition-colors hover:shadow-sm"
>
<i className="fas fa-arrow-left" />
Back to Log Entries
</a>
</div>
</div>
{/* Stats Summary */}
<div className="px-6 py-6 bg-gradient-to-br from-blue-50 to-indigo-50 border-t border-slate-200">
<div className="text-center mb-4">
<h3 className="text-lg font-semibold text-slate-900 mb-1">Summary for {timeWindowFormatted}</h3>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Total Requests */}
<div className="bg-white rounded-lg p-4 shadow-sm border border-slate-200">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide">Total Requests</p>
<p className="text-2xl font-bold text-slate-900 mt-1">{lastWindowCount.toLocaleString()}</p>
</div>
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
<i className="fas fa-chart-bar text-blue-600" />
</div>
</div>
</div>
{/* Requests per Second */}
<div className="bg-white rounded-lg p-4 shadow-sm border border-slate-200">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide">Requests/sec</p>
<p className="text-2xl font-bold text-slate-900 mt-1">{requestsPerSecond}</p>
</div>
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
<i className="fas fa-bolt text-green-600" />
</div>
</div>
</div>
{/* Total Data */}
<div className="bg-white rounded-lg p-4 shadow-sm border border-slate-200">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide">Total Data</p>
<p className="text-2xl font-bold text-slate-900 mt-1">{totalBytesFormatted}</p>
</div>
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
<i className="fas fa-database text-purple-600" />
</div>
</div>
</div>
{/* Data per Second */}
<div className="bg-white rounded-lg p-4 shadow-sm border border-slate-200">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide">Data/sec</p>
<p className="text-2xl font-bold text-slate-900 mt-1">{bytesPerSecondFormatted}</p>
</div>
<div className="w-10 h-10 bg-orange-100 rounded-lg flex items-center justify-center">
<i className="fas fa-download text-orange-600" />
</div>
</div>
</div>
</div>
</div>
{/* Time Window Selector */}
<div className="px-4 py-4 bg-gradient-to-br from-blue-50 to-indigo-50">
<div className="text-center">
<div className="inline-flex flex-wrap justify-center bg-white/70 backdrop-blur-sm rounded-lg p-1 shadow-md border border-white/50 gap-1">
{availableTimeWindows.map((timeWindowOption, index) => (
<React.Fragment key={timeWindowOption.seconds}>
{timeWindowOption.active ? (
<span className="px-4 py-2 text-sm font-semibold text-white bg-blue-600 rounded-md shadow-sm">
{timeWindowOption.label}
</span>
) : (
<a
href={timeWindowOption.path}
className="px-4 py-2 text-sm font-medium text-slate-600 hover:text-slate-900 hover:bg-white hover:shadow-sm rounded-md transition-colors border border-transparent hover:border-slate-200"
>
{timeWindowOption.label}
</a>
)}
</React.Fragment>
))}
</div>
</div>
</div>
</div>
{/* Tables Grid - 2 columns */}
<div className="my-8 grid grid-cols-1 lg:grid-cols-2 gap-8">
<div>
<h2 className="text-xl font-bold text-slate-900 mb-3">By Content Type</h2>
<SortableTable
headers={[
{ label: 'Content Type', key: 'content_type', align: 'left' },
{ label: 'Requests', key: 'count', align: 'right' },
{ label: 'Transferred', key: 'bytes', align: 'right' }
]}
data={contentTypeData}
defaultSortKey="count"
defaultSortOrder="desc"
/>
</div>
<div>
<h2 className="text-xl font-bold text-slate-900 mb-3">By Domain</h2>
<SortableTable
headers={[
{ label: 'Domain', key: 'domain', align: 'left' },
{ label: 'Requests', key: 'count', align: 'right' },
{ label: 'Transferred', key: 'bytes', align: 'right' }
]}
data={domainData}
defaultSortKey="bytes"
defaultSortOrder="desc"
/>
</div>
</div>
</div>
);
};
export default StatsPage;

View File

@@ -7,6 +7,7 @@ import { UserHoverPreviewWrapper } from '../bundles/Main/components/UserHoverPre
import { TrackedObjectsChart } from '../bundles/Main/components/TrackedObjectsChart';
import { initCollapsibleSections } from '../bundles/UI/collapsibleSections';
import { IpAddressInput } from '../bundles/UI/components';
import { StatsPage } from '../bundles/Main/components/StatsPage';
// This is how react_on_rails can see the components in the browser.
ReactOnRails.register({
@@ -16,6 +17,7 @@ ReactOnRails.register({
UserHoverPreviewWrapper,
TrackedObjectsChart,
IpAddressInput,
StatsPage,
});
// Initialize collapsible sections

View File

@@ -3,12 +3,13 @@
<%# description-section-link (smaller and has a border, for use in description section) %>
<% visual_style = local_assigns[:visual_style] || "sky-link" %>
<% link_text = local_assigns[:link_text] || post.title_for_view %>
<% domain_icon = local_assigns[:domain_icon].nil? ? true : local_assigns[:domain_icon] %>
<%=
react_component(
"PostHoverPreviewWrapper",
{
prerender: false,
props: props_for_post_hover_preview(post, link_text, visual_style),
props: props_for_post_hover_preview(post, link_text, visual_style, domain_icon:),
html_options: {
class: link_classes_for_visual_style(visual_style)
}

View File

@@ -8,17 +8,18 @@
<% recent_posts = user.posts.limit(5).to_a %>
<% if recent_posts.any? %>
<% recent_posts.each do |post| %>
<div class="flex items-center px-4 py-2 gap-1">
<span class="grow truncate">
<div class="grid grid-cols-[1fr_auto] items-center px-4 py-2 gap-1 group">
<span class="min-w-0 truncate group-hover:overflow-visible group-hover:whitespace-nowrap group-hover:z-10 group-hover:relative">
<%= render(
partial: "domain/has_description_html/inline_link_domain_post",
locals: {
post: post,
visual_style: "sky-link"
visual_style: "sky-link",
domain_icon: false
}
) %>
</span>
<span class="whitespace-nowrap text-slate-500">
<span class="whitespace-nowrap text-slate-500 ml-2">
<% if posted_at = post.posted_at %>
<%= time_ago_in_words(posted_at) %> ago
<% else %>

View File

@@ -9,9 +9,16 @@
</h2>
<% if fav_posts.any? %>
<% fav_posts.each do |post| %>
<div class="flex flex-col px-4 py-2">
<span class="flex gap-2">
<%= render "domain/has_description_html/inline_link_domain_post", post: post, visual_style: "sky-link" %>
<div class="flex flex-col px-4 py-2 group">
<span class="flex gap-2 group-hover:flex-grow-1">
<%= render(
partial: "domain/has_description_html/inline_link_domain_post",
locals: {
post: post,
visual_style: "sky-link",
domain_icon: false
}
) %>
<span class="whitespace-nowrap flex-grow text-right text-slate-500">
<% if (faved_at = post.user_post_fav&.faved_at) && (time = faved_at.time) %>
<%= time_ago_in_words(faved_at.time) %> ago

View File

@@ -1,216 +1,67 @@
<% content_for :head do %>
<style type="text/css" data-turbolinks-track>
.grid-cell {
padding: 0.25rem 1rem;
border-right: 1px solid #e2e8f0;
<%
# Prepare time windows data for React component
time_windows = [10.seconds, 30.seconds, 1.minute, 5.minutes, 30.minutes].map do |time_window|
{
seconds: time_window.in_seconds,
label: (
if time_window < 1.minute
pluralize(time_window.seconds, "second")
else
time_ago_in_words(time_window.ago)
end
),
active: @time_window == time_window,
path: stats_log_entries_path(seconds: time_window.in_seconds)
}
.grid-cell:last-child {
border-right: none;
end
# Prepare content type data with server-side formatting
content_type_data = @content_type_counts.map do |content_type, stats|
{
content_type: content_type,
count: stats[:count],
bytes: stats[:bytes],
countFormatted: stats[:count].to_s,
bytesFormatted: HexUtil.humansize(stats[:bytes])
}
.grid-row:hover .grid-cell {
background-color: #f1f5f9;
transition: background-color 150ms ease-in-out;
end
# Prepare domain data with server-side formatting
domain_data = @by_domain_counts.map do |domain, stats|
{
domain: domain,
count: stats[:count],
bytes: stats[:bytes],
countFormatted: stats[:count].to_s,
bytesFormatted: HexUtil.humansize(stats[:bytes])
}
.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);
end
# Pre-calculate formatted values
time_window_formatted = if @time_window < 1.minute
pluralize(@time_window.seconds, "second")
else
time_ago_in_words(@time_window.ago)
end
requests_per_second = (@last_window_count.to_f / @time_window.in_seconds).round(1).to_s
bytes_per_second_formatted = HexUtil.humansize(@last_window_bytes / @time_window.in_seconds)
%>
<%= react_component(
"StatsPage",
{
prerender: false,
props: {
timeWindow: @time_window.in_seconds,
lastWindowCount: @last_window_count,
lastWindowBytes: @last_window_bytes,
requestsPerSecond: requests_per_second,
totalBytesFormatted: HexUtil.humansize(@last_window_bytes),
bytesPerSecondFormatted: bytes_per_second_formatted,
timeWindowFormatted: time_window_formatted,
contentTypeCounts: content_type_data,
byDomainCounts: domain_data,
availableTimeWindows: time_windows
}
.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>
}
) %>