Compare commits
3 Commits
572c61cebb
...
fb436e1b75
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb436e1b75 | ||
|
|
c96b1d9cc1 | ||
|
|
e027dc9bc4 |
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
157
app/javascript/bundles/Main/components/SortableTable.tsx
Normal file
157
app/javascript/bundles/Main/components/SortableTable.tsx
Normal 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;
|
||||
45
app/javascript/bundles/Main/components/StatsCard.tsx
Normal file
45
app/javascript/bundles/Main/components/StatsCard.tsx
Normal 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;
|
||||
201
app/javascript/bundles/Main/components/StatsPage.tsx
Normal file
201
app/javascript/bundles/Main/components/StatsPage.tsx
Normal 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;
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
) %>
|
||||
|
||||
Reference in New Issue
Block a user