position user search bar results
This commit is contained in:
14
.prettierrc
14
.prettierrc
@@ -4,6 +4,10 @@
|
|||||||
"trailingComma": "all",
|
"trailingComma": "all",
|
||||||
"arrowParens": "always",
|
"arrowParens": "always",
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
|
"semi": true,
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"bracketSameLine": false,
|
||||||
|
"printWidth": 80,
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"prettier-plugin-tailwindcss",
|
"prettier-plugin-tailwindcss",
|
||||||
"@prettier/plugin-ruby",
|
"@prettier/plugin-ruby",
|
||||||
@@ -11,5 +15,13 @@
|
|||||||
"@4az/prettier-plugin-html-erb"
|
"@4az/prettier-plugin-html-erb"
|
||||||
],
|
],
|
||||||
"xmlQuoteAttributes": "double",
|
"xmlQuoteAttributes": "double",
|
||||||
"xmlWhitespaceSensitivity": "ignore"
|
"xmlWhitespaceSensitivity": "ignore",
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": ["*.ts", "*.tsx"],
|
||||||
|
"options": {
|
||||||
|
"parser": "typescript"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,11 +29,11 @@ export default function ListItem({
|
|||||||
subtext,
|
subtext,
|
||||||
domainIcon,
|
domainIcon,
|
||||||
}: PropTypes) {
|
}: PropTypes) {
|
||||||
|
const groupHoverClassName = 'group-hover:text-slate-200';
|
||||||
const iconClassName = ['ml-2'];
|
const iconClassName = ['ml-2'];
|
||||||
const textClassName = [
|
const textClassName = [
|
||||||
COMMON_LIST_ELEM_CLASSES,
|
COMMON_LIST_ELEM_CLASSES,
|
||||||
'relative flex items-center justify-between',
|
'group flex items-center justify-between',
|
||||||
'border-t-0',
|
|
||||||
isLast && 'rounded-b-lg',
|
isLast && 'rounded-b-lg',
|
||||||
style === 'item' && selected && 'bg-slate-700 text-slate-100',
|
style === 'item' && selected && 'bg-slate-700 text-slate-100',
|
||||||
style === 'info' && 'text-slate-500 italic',
|
style === 'info' && 'text-slate-500 italic',
|
||||||
@@ -66,12 +66,21 @@ export default function ListItem({
|
|||||||
</div>
|
</div>
|
||||||
<div className="inline-block flex-grow pl-1">{value}</div>
|
<div className="inline-block flex-grow pl-1">{value}</div>
|
||||||
{subtext && (
|
{subtext && (
|
||||||
<div className="vertical-align-middle float-right inline-block pl-1 text-sm italic text-slate-500">
|
<div
|
||||||
|
className={[
|
||||||
|
'vertical-align-middle float-right inline-block pl-1 text-sm italic',
|
||||||
|
!selected && 'text-slate-500',
|
||||||
|
selected && 'text-slate-300',
|
||||||
|
groupHoverClassName,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
>
|
||||||
{subtext}
|
{subtext}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{domainIcon && (
|
{domainIcon && (
|
||||||
<img src={domainIcon} alt="domain icon" className="inline w-6 pl-1" />
|
<img src={domainIcon} alt="domain icon" className="inline w-6" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export const SortableTable: React.FC<SortableTableProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const sortedData = React.useMemo(() => {
|
const sortedData = React.useMemo(() => {
|
||||||
const headerIndex = headers.findIndex(h => h.key === sortKey);
|
const headerIndex = headers.findIndex((h) => h.key === sortKey);
|
||||||
if (headerIndex === -1) return data;
|
if (headerIndex === -1) return data;
|
||||||
|
|
||||||
return [...data].sort((a, b) => {
|
return [...data].sort((a, b) => {
|
||||||
@@ -92,7 +92,13 @@ export const SortableTable: React.FC<SortableTableProps> = ({
|
|||||||
|
|
||||||
const getSortIndicator = (headerKey: string) => {
|
const getSortIndicator = (headerKey: string) => {
|
||||||
if (sortKey !== headerKey) {
|
if (sortKey !== headerKey) {
|
||||||
return <span style={{ opacity: 0.5, marginLeft: '0.25rem', fontSize: '0.75rem' }}>▼</span>;
|
return (
|
||||||
|
<span
|
||||||
|
style={{ opacity: 0.5, marginLeft: '0.25rem', fontSize: '0.75rem' }}
|
||||||
|
>
|
||||||
|
▼
|
||||||
|
</span>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span style={{ marginLeft: '0.25rem', fontSize: '0.75rem' }}>
|
<span style={{ marginLeft: '0.25rem', fontSize: '0.75rem' }}>
|
||||||
@@ -112,7 +118,9 @@ export const SortableTable: React.FC<SortableTableProps> = ({
|
|||||||
style={{
|
style={{
|
||||||
...headerStyle,
|
...headerStyle,
|
||||||
textAlign: header.align,
|
textAlign: header.align,
|
||||||
...(index === headers.length - 1 ? { borderRight: 'none' } : {}),
|
...(index === headers.length - 1
|
||||||
|
? { borderRight: 'none' }
|
||||||
|
: {}),
|
||||||
}}
|
}}
|
||||||
onClick={() => handleSort(header.key)}
|
onClick={() => handleSort(header.key)}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
@@ -130,7 +138,7 @@ export const SortableTable: React.FC<SortableTableProps> = ({
|
|||||||
|
|
||||||
{/* Data Rows */}
|
{/* Data Rows */}
|
||||||
{sortedData.map((row) => (
|
{sortedData.map((row) => (
|
||||||
<div key={row.id} className="contents group">
|
<div key={row.id} className="group contents">
|
||||||
{row.cells.map((cell, cellIndex) => (
|
{row.cells.map((cell, cellIndex) => (
|
||||||
<div
|
<div
|
||||||
key={cellIndex}
|
key={cellIndex}
|
||||||
@@ -140,9 +148,11 @@ export const SortableTable: React.FC<SortableTableProps> = ({
|
|||||||
fontSize: '0.875rem',
|
fontSize: '0.875rem',
|
||||||
fontWeight: cellIndex === 0 ? 500 : 400,
|
fontWeight: cellIndex === 0 ? 500 : 400,
|
||||||
color: cellIndex === 0 ? '#0f172a' : '#64748b',
|
color: cellIndex === 0 ? '#0f172a' : '#64748b',
|
||||||
...(cellIndex === row.cells.length - 1 ? { borderRight: 'none' } : {}),
|
...(cellIndex === row.cells.length - 1
|
||||||
|
? { borderRight: 'none' }
|
||||||
|
: {}),
|
||||||
}}
|
}}
|
||||||
className="group-hover:bg-slate-50 transition-colors duration-150"
|
className="transition-colors duration-150 group-hover:bg-slate-50"
|
||||||
>
|
>
|
||||||
{cell.value}
|
{cell.value}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ export const StatsCard: React.FC<StatsCardProps> = ({
|
|||||||
<div className="text-xl font-bold text-slate-900">
|
<div className="text-xl font-bold text-slate-900">
|
||||||
{requestCount} requests
|
{requestCount} requests
|
||||||
<span className="text-base font-normal text-slate-600">
|
<span className="text-base font-normal text-slate-600">
|
||||||
{' '}in last {timeWindow}
|
{' '}
|
||||||
|
in last {timeWindow}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-slate-600">
|
<div className="mt-1 text-slate-600">
|
||||||
|
|||||||
@@ -42,35 +42,37 @@ export const StatsPage: React.FC<StatsPageProps> = ({
|
|||||||
byDomainCounts,
|
byDomainCounts,
|
||||||
availableTimeWindows,
|
availableTimeWindows,
|
||||||
}) => {
|
}) => {
|
||||||
const contentTypeData: TableData[] = contentTypeCounts.map(item => ({
|
const contentTypeData: TableData[] = contentTypeCounts.map((item) => ({
|
||||||
id: item.content_type,
|
id: item.content_type,
|
||||||
cells: [
|
cells: [
|
||||||
{ value: item.content_type, sortKey: item.content_type },
|
{ value: item.content_type, sortKey: item.content_type },
|
||||||
{ value: item.countFormatted, sortKey: item.count },
|
{ value: item.countFormatted, sortKey: item.count },
|
||||||
{ value: item.bytesFormatted, sortKey: item.bytes }
|
{ value: item.bytesFormatted, sortKey: item.bytes },
|
||||||
]
|
],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const domainData: TableData[] = byDomainCounts.map(item => ({
|
const domainData: TableData[] = byDomainCounts.map((item) => ({
|
||||||
id: item.domain,
|
id: item.domain,
|
||||||
cells: [
|
cells: [
|
||||||
{ value: item.domain, sortKey: item.domain },
|
{ value: item.domain, sortKey: item.domain },
|
||||||
{ value: item.countFormatted, sortKey: item.count },
|
{ value: item.countFormatted, sortKey: item.count },
|
||||||
{ value: item.bytesFormatted, sortKey: item.bytes }
|
{ value: item.bytesFormatted, sortKey: item.bytes },
|
||||||
]
|
],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-7xl px-4 mt-8">
|
<div className="mx-auto mt-8 max-w-7xl px-4">
|
||||||
{/* Header Section */}
|
{/* Header Section */}
|
||||||
<div className="bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden">
|
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
{/* Top Bar */}
|
{/* Top Bar */}
|
||||||
<div className="bg-slate-50 px-6 py-4 border-b border-slate-200">
|
<div className="border-b border-slate-200 bg-slate-50 px-6 py-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold text-slate-900">HTTP Request Analytics</h1>
|
<h1 className="text-2xl font-bold text-slate-900">
|
||||||
|
HTTP Request Analytics
|
||||||
|
</h1>
|
||||||
<a
|
<a
|
||||||
href="/log_entries"
|
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"
|
className="inline-flex items-center gap-2 rounded-lg border border-slate-300 px-4 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-white hover:text-slate-900 hover:shadow-sm"
|
||||||
>
|
>
|
||||||
<i className="fas fa-arrow-left" />
|
<i className="fas fa-arrow-left" />
|
||||||
Back to Log Entries
|
Back to Log Entries
|
||||||
@@ -79,59 +81,77 @@ export const StatsPage: React.FC<StatsPageProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Summary */}
|
{/* 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="border-t border-slate-200 bg-gradient-to-br from-blue-50 to-indigo-50 px-6 py-6">
|
||||||
<div className="text-center mb-4">
|
<div className="mb-4 text-center">
|
||||||
<h3 className="text-lg font-semibold text-slate-900 mb-1">Summary for {timeWindowFormatted}</h3>
|
<h3 className="mb-1 text-lg font-semibold text-slate-900">
|
||||||
|
Summary for {timeWindowFormatted}
|
||||||
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
{/* Total Requests */}
|
{/* Total Requests */}
|
||||||
<div className="bg-white rounded-lg p-4 shadow-sm border border-slate-200">
|
<div className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide">Total Requests</p>
|
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||||
<p className="text-2xl font-bold text-slate-900 mt-1">{lastWindowCount.toLocaleString()}</p>
|
Total Requests
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-2xl font-bold text-slate-900">
|
||||||
|
{lastWindowCount.toLocaleString()}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100">
|
||||||
<i className="fas fa-chart-bar text-blue-600" />
|
<i className="fas fa-chart-bar text-blue-600" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Requests per Second */}
|
{/* Requests per Second */}
|
||||||
<div className="bg-white rounded-lg p-4 shadow-sm border border-slate-200">
|
<div className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide">Requests/sec</p>
|
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||||
<p className="text-2xl font-bold text-slate-900 mt-1">{requestsPerSecond}</p>
|
Requests/sec
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-2xl font-bold text-slate-900">
|
||||||
|
{requestsPerSecond}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-green-100">
|
||||||
<i className="fas fa-bolt text-green-600" />
|
<i className="fas fa-bolt text-green-600" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Total Data */}
|
{/* Total Data */}
|
||||||
<div className="bg-white rounded-lg p-4 shadow-sm border border-slate-200">
|
<div className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide">Total Data</p>
|
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||||
<p className="text-2xl font-bold text-slate-900 mt-1">{totalBytesFormatted}</p>
|
Total Data
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-2xl font-bold text-slate-900">
|
||||||
|
{totalBytesFormatted}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-100">
|
||||||
<i className="fas fa-database text-purple-600" />
|
<i className="fas fa-database text-purple-600" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Data per Second */}
|
{/* Data per Second */}
|
||||||
<div className="bg-white rounded-lg p-4 shadow-sm border border-slate-200">
|
<div className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide">Data/sec</p>
|
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||||
<p className="text-2xl font-bold text-slate-900 mt-1">{bytesPerSecondFormatted}</p>
|
Data/sec
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-2xl font-bold text-slate-900">
|
||||||
|
{bytesPerSecondFormatted}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-10 h-10 bg-orange-100 rounded-lg flex items-center justify-center">
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-orange-100">
|
||||||
<i className="fas fa-download text-orange-600" />
|
<i className="fas fa-download text-orange-600" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -140,19 +160,19 @@ export const StatsPage: React.FC<StatsPageProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Time Window Selector */}
|
{/* Time Window Selector */}
|
||||||
<div className="px-4 py-4 bg-gradient-to-br from-blue-50 to-indigo-50">
|
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 px-4 py-4">
|
||||||
<div className="text-center">
|
<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">
|
<div className="inline-flex flex-wrap justify-center gap-1 rounded-lg border border-white/50 bg-white/70 p-1 shadow-md backdrop-blur-sm">
|
||||||
{availableTimeWindows.map((timeWindowOption, index) => (
|
{availableTimeWindows.map((timeWindowOption, index) => (
|
||||||
<React.Fragment key={timeWindowOption.seconds}>
|
<React.Fragment key={timeWindowOption.seconds}>
|
||||||
{timeWindowOption.active ? (
|
{timeWindowOption.active ? (
|
||||||
<span className="px-4 py-2 text-sm font-semibold text-white bg-blue-600 rounded-md shadow-sm">
|
<span className="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm">
|
||||||
{timeWindowOption.label}
|
{timeWindowOption.label}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<a
|
<a
|
||||||
href={timeWindowOption.path}
|
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"
|
className="rounded-md border border-transparent px-4 py-2 text-sm font-medium text-slate-600 transition-colors hover:border-slate-200 hover:bg-white hover:text-slate-900 hover:shadow-sm"
|
||||||
>
|
>
|
||||||
{timeWindowOption.label}
|
{timeWindowOption.label}
|
||||||
</a>
|
</a>
|
||||||
@@ -165,14 +185,16 @@ export const StatsPage: React.FC<StatsPageProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tables Grid - 2 columns */}
|
{/* Tables Grid - 2 columns */}
|
||||||
<div className="my-8 grid grid-cols-1 lg:grid-cols-2 gap-8">
|
<div className="my-8 grid grid-cols-1 gap-8 lg:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-bold text-slate-900 mb-3">By Content Type</h2>
|
<h2 className="mb-3 text-xl font-bold text-slate-900">
|
||||||
|
By Content Type
|
||||||
|
</h2>
|
||||||
<SortableTable
|
<SortableTable
|
||||||
headers={[
|
headers={[
|
||||||
{ label: 'Content Type', key: 'content_type', align: 'left' },
|
{ label: 'Content Type', key: 'content_type', align: 'left' },
|
||||||
{ label: 'Requests', key: 'count', align: 'right' },
|
{ label: 'Requests', key: 'count', align: 'right' },
|
||||||
{ label: 'Transferred', key: 'bytes', align: 'right' }
|
{ label: 'Transferred', key: 'bytes', align: 'right' },
|
||||||
]}
|
]}
|
||||||
data={contentTypeData}
|
data={contentTypeData}
|
||||||
defaultSortKey="count"
|
defaultSortKey="count"
|
||||||
@@ -181,12 +203,12 @@ export const StatsPage: React.FC<StatsPageProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-bold text-slate-900 mb-3">By Domain</h2>
|
<h2 className="mb-3 text-xl font-bold text-slate-900">By Domain</h2>
|
||||||
<SortableTable
|
<SortableTable
|
||||||
headers={[
|
headers={[
|
||||||
{ label: 'Domain', key: 'domain', align: 'left' },
|
{ label: 'Domain', key: 'domain', align: 'left' },
|
||||||
{ label: 'Requests', key: 'count', align: 'right' },
|
{ label: 'Requests', key: 'count', align: 'right' },
|
||||||
{ label: 'Transferred', key: 'bytes', align: 'right' }
|
{ label: 'Transferred', key: 'bytes', align: 'right' },
|
||||||
]}
|
]}
|
||||||
data={domainData}
|
data={domainData}
|
||||||
defaultSortKey="bytes"
|
defaultSortKey="bytes"
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import * as React from 'react';
|
|||||||
import { useCallback, useRef, useState } from 'react';
|
import { useCallback, useRef, useState } from 'react';
|
||||||
import Icon from './Icon';
|
import Icon from './Icon';
|
||||||
import ListItem from './ListItem';
|
import ListItem from './ListItem';
|
||||||
import Trie, { TrieNode } from '../lib/Trie';
|
|
||||||
|
|
||||||
// 1. Group related constants
|
// 1. Group related constants
|
||||||
const CONFIG = {
|
const CONFIG = {
|
||||||
@@ -18,7 +17,7 @@ const STYLES = {
|
|||||||
],
|
],
|
||||||
SVG_BASE_CLASSNAME: `stroke-slate-500 fill-slate-500`,
|
SVG_BASE_CLASSNAME: `stroke-slate-500 fill-slate-500`,
|
||||||
SVG_FOCUSABLE_CLASSNAME: `stroke-slate-500 fill-slate-500 group-focus-within:stroke-slate-800 group-focus-within:fill-slate-800`,
|
SVG_FOCUSABLE_CLASSNAME: `stroke-slate-500 fill-slate-500 group-focus-within:stroke-slate-800 group-focus-within:fill-slate-800`,
|
||||||
INPUT_CLASSNAME: `text-slate-500 group-focus-within:text-slate-800 placeholder-slate-500 group-focus-within:placeholder-slate-800 placeholder:font-extralight`,
|
INPUT_CLASSNAME: `text-slate-500 focus:text-slate-800 placeholder-slate-500 group-focus-within:placeholder-slate-800 placeholder:font-extralight`,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// 2. Simplify logging
|
// 2. Simplify logging
|
||||||
@@ -46,10 +45,6 @@ interface ServerResponse {
|
|||||||
users: User[];
|
users: User[];
|
||||||
}
|
}
|
||||||
|
|
||||||
type TrieValue = [number, string];
|
|
||||||
type TrieType = Trie<TrieValue>;
|
|
||||||
type TrieNodeType = TrieNode<TrieValue>;
|
|
||||||
|
|
||||||
export default function UserSearchBar({ isServerRendered }: PropTypes) {
|
export default function UserSearchBar({ isServerRendered }: PropTypes) {
|
||||||
isServerRendered = !!isServerRendered;
|
isServerRendered = !!isServerRendered;
|
||||||
const [pendingRequest, setPendingRequest] = useState<AbortController | null>(
|
const [pendingRequest, setPendingRequest] = useState<AbortController | null>(
|
||||||
@@ -119,7 +114,6 @@ export default function UserSearchBar({ isServerRendered }: PropTypes) {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!err.message.includes('aborted')) {
|
if (!err.message.includes('aborted')) {
|
||||||
log.error('error loading user trie: ', err);
|
|
||||||
setState((s) => ({
|
setState((s) => ({
|
||||||
...s,
|
...s,
|
||||||
errorMessage: `error loading users: ` + err.message,
|
errorMessage: `error loading users: ` + err.message,
|
||||||
@@ -148,7 +142,6 @@ export default function UserSearchBar({ isServerRendered }: PropTypes) {
|
|||||||
|
|
||||||
const searchForUserDebounced = useCallback(
|
const searchForUserDebounced = useCallback(
|
||||||
debounce(async (userName) => {
|
debounce(async (userName) => {
|
||||||
log.info('sending search for ', userName);
|
|
||||||
setState((s) => ({ ...s, typingSettled: true }));
|
setState((s) => ({ ...s, typingSettled: true }));
|
||||||
searchForUser(userName);
|
searchForUser(userName);
|
||||||
}, 250),
|
}, 250),
|
||||||
@@ -158,7 +151,6 @@ export default function UserSearchBar({ isServerRendered }: PropTypes) {
|
|||||||
function invokeIdx(idx) {
|
function invokeIdx(idx) {
|
||||||
const user = state.userList[idx];
|
const user = state.userList[idx];
|
||||||
if (user) {
|
if (user) {
|
||||||
log.info('selecting user: ', user);
|
|
||||||
setState((s) => ({ ...s, userName: user.name }));
|
setState((s) => ({ ...s, userName: user.name }));
|
||||||
inputRef.current.value = user.name;
|
inputRef.current.value = user.name;
|
||||||
window.location.href = user.show_path;
|
window.location.href = user.show_path;
|
||||||
@@ -185,9 +177,7 @@ export default function UserSearchBar({ isServerRendered }: PropTypes) {
|
|||||||
|
|
||||||
function UserSearchBarItems() {
|
function UserSearchBarItems() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="divide-y divide-slate-300 overflow-hidden border border-slate-300 bg-slate-50 shadow-lg sm:rounded-xl">
|
||||||
className={`${anyShown || 'border-b-0'} divide-y divide-inherit rounded-b-lg border border-t-0 border-inherit`}
|
|
||||||
>
|
|
||||||
{visibility.error ? (
|
{visibility.error ? (
|
||||||
<ListItem
|
<ListItem
|
||||||
key="error"
|
key="error"
|
||||||
@@ -267,7 +257,7 @@ export default function UserSearchBar({ isServerRendered }: PropTypes) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
'group mx-auto w-full p-2 transition-colors duration-1000 sm:rounded-xl',
|
'relative mx-auto w-full p-2 transition-colors duration-1000 sm:rounded-xl',
|
||||||
'focus-within:border-slate-400 sm:max-w-md',
|
'focus-within:border-slate-400 sm:max-w-md',
|
||||||
'border-slate-300 bg-slate-50 p-2 shadow-lg',
|
'border-slate-300 bg-slate-50 p-2 shadow-lg',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
@@ -290,7 +280,6 @@ export default function UserSearchBar({ isServerRendered }: PropTypes) {
|
|||||||
STYLES.INPUT_CLASSNAME,
|
STYLES.INPUT_CLASSNAME,
|
||||||
'rounded-lg outline-none',
|
'rounded-lg outline-none',
|
||||||
'bg-slate-50 placeholder:italic',
|
'bg-slate-50 placeholder:italic',
|
||||||
anyShown && 'rounded-b-none',
|
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ')}
|
.join(' ')}
|
||||||
@@ -306,7 +295,11 @@ export default function UserSearchBar({ isServerRendered }: PropTypes) {
|
|||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
{anyShown && (
|
||||||
|
<div className="absolute left-0 right-0 top-full z-50 mt-1">
|
||||||
<UserSearchBarItems />
|
<UserSearchBarItems />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
interface SerializedTrie<T> {
|
|
||||||
// terminal node?
|
|
||||||
t: 1 | 0;
|
|
||||||
// value of the node
|
|
||||||
v: T;
|
|
||||||
// optional children
|
|
||||||
c?: { [s: string]: SerializedTrie<T> };
|
|
||||||
}
|
|
||||||
|
|
||||||
export class TrieNode<T> {
|
|
||||||
public terminal: boolean;
|
|
||||||
public value: T;
|
|
||||||
public children: Map<string, TrieNode<T>>;
|
|
||||||
public serialized: SerializedTrie<T>;
|
|
||||||
|
|
||||||
constructor(ser: SerializedTrie<T>) {
|
|
||||||
this.terminal = ser.t == 1;
|
|
||||||
this.value = ser.v;
|
|
||||||
this.children = new Map();
|
|
||||||
this.serialized = ser;
|
|
||||||
|
|
||||||
if (ser.c != null) {
|
|
||||||
for (const [key, value] of Object.entries(ser.c)) {
|
|
||||||
this.children.set(key, new TrieNode(value));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default class Trie<T> {
|
|
||||||
public root: TrieNode<T>;
|
|
||||||
constructor(ser: SerializedTrie<T>) {
|
|
||||||
this.root = new TrieNode(ser);
|
|
||||||
}
|
|
||||||
|
|
||||||
public nodeForPrefix(key: string): {
|
|
||||||
chain: string[];
|
|
||||||
node: TrieNode<T> | null;
|
|
||||||
} {
|
|
||||||
let chain = [];
|
|
||||||
let node = this.root;
|
|
||||||
let remaining = key;
|
|
||||||
while (node && remaining.length > 0) {
|
|
||||||
let exactChild = null;
|
|
||||||
console.log('remaining: ', remaining);
|
|
||||||
|
|
||||||
for (const [childKey, child] of node.children.entries()) {
|
|
||||||
if (remaining.startsWith(childKey)) {
|
|
||||||
console.log('exact match for: ', childKey);
|
|
||||||
exactChild = child;
|
|
||||||
chain.push(childKey);
|
|
||||||
remaining = remaining.slice(childKey.length);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// if an exact match was found, continue iterating
|
|
||||||
if (exactChild) {
|
|
||||||
node = exactChild;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('looking for partial match for ', remaining);
|
|
||||||
for (const [childKey, child] of node.children.entries()) {
|
|
||||||
const startsWith = childKey.startsWith(remaining);
|
|
||||||
console.log(
|
|
||||||
'test ',
|
|
||||||
childKey,
|
|
||||||
' against ',
|
|
||||||
remaining,
|
|
||||||
': ',
|
|
||||||
startsWith,
|
|
||||||
' ',
|
|
||||||
child.serialized,
|
|
||||||
);
|
|
||||||
if (startsWith) {
|
|
||||||
console.log('partial match for: ', remaining, ': ', child.serialized);
|
|
||||||
chain.push(childKey);
|
|
||||||
return { chain, node: child };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('did not find partial, bailing!');
|
|
||||||
return { chain, node: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
// // return remaining.length === 0 && node && node.terminal ? node : null;
|
|
||||||
console.log('returning child ', node, ' for remaining ', remaining);
|
|
||||||
return { chain, node };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
function buildUsersTrie(users) {
|
|
||||||
const rootNode = new trie();
|
|
||||||
users.forEach(([id, name]) => {
|
|
||||||
rootNode.insert(name.toLowerCase(), [id, name]);
|
|
||||||
});
|
|
||||||
return JSON.stringify(rootNode.serialize());
|
|
||||||
}
|
|
||||||
class trie_node {
|
|
||||||
constructor() {
|
|
||||||
this.terminal = false;
|
|
||||||
this.children = new Map();
|
|
||||||
}
|
|
||||||
serialize() {
|
|
||||||
const { terminal, value, children } = this;
|
|
||||||
let mapped = {};
|
|
||||||
let numChildren = 0;
|
|
||||||
Object.keys(Object.fromEntries(children)).forEach((childKey) => {
|
|
||||||
numChildren += 1;
|
|
||||||
mapped[childKey] = children.get(childKey).serialize();
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
t: this.terminal ? 1 : 0,
|
|
||||||
v: value,
|
|
||||||
c: numChildren > 0 ? mapped : undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
class trie {
|
|
||||||
constructor() {
|
|
||||||
this.root = new trie_node();
|
|
||||||
this.elements = 0;
|
|
||||||
}
|
|
||||||
serialize() {
|
|
||||||
return this.root.serialize();
|
|
||||||
}
|
|
||||||
get length() {
|
|
||||||
return this.elements;
|
|
||||||
}
|
|
||||||
get(key) {
|
|
||||||
const node = this.getNode(key);
|
|
||||||
if (node) {
|
|
||||||
return node.value;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
contains(key) {
|
|
||||||
const node = this.getNode(key);
|
|
||||||
return !!node;
|
|
||||||
}
|
|
||||||
insert(key, value) {
|
|
||||||
let node = this.root;
|
|
||||||
let remaining = key;
|
|
||||||
while (remaining.length > 0) {
|
|
||||||
let child = null;
|
|
||||||
for (const childKey of node.children.keys()) {
|
|
||||||
const prefix = this.commonPrefix(remaining, childKey);
|
|
||||||
if (!prefix.length) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (prefix.length === childKey.length) {
|
|
||||||
// enter child node
|
|
||||||
child = node.children.get(childKey);
|
|
||||||
remaining = remaining.slice(childKey.length);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
// split the child
|
|
||||||
child = new trie_node();
|
|
||||||
child.children.set(childKey.slice(prefix.length), node.children.get(childKey));
|
|
||||||
node.children.delete(childKey);
|
|
||||||
node.children.set(prefix, child);
|
|
||||||
remaining = remaining.slice(prefix.length);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!child && remaining.length) {
|
|
||||||
child = new trie_node();
|
|
||||||
node.children.set(remaining, child);
|
|
||||||
remaining = "";
|
|
||||||
}
|
|
||||||
node = child;
|
|
||||||
}
|
|
||||||
if (!node.terminal) {
|
|
||||||
node.terminal = true;
|
|
||||||
this.elements += 1;
|
|
||||||
}
|
|
||||||
node.value = value;
|
|
||||||
}
|
|
||||||
remove(key) {
|
|
||||||
const node = this.getNode(key);
|
|
||||||
if (node) {
|
|
||||||
node.terminal = false;
|
|
||||||
this.elements -= 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
map(prefix, func) {
|
|
||||||
const mapped = [];
|
|
||||||
const node = this.getNode(prefix);
|
|
||||||
const stack = [];
|
|
||||||
if (node) {
|
|
||||||
stack.push([prefix, node]);
|
|
||||||
}
|
|
||||||
while (stack.length) {
|
|
||||||
const [key, node] = stack.pop();
|
|
||||||
if (node.terminal) {
|
|
||||||
mapped.push(func(key, node.value));
|
|
||||||
}
|
|
||||||
for (const c of node.children.keys()) {
|
|
||||||
stack.push([key + c, node.children.get(c)]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return mapped;
|
|
||||||
}
|
|
||||||
getNode(key) {
|
|
||||||
let node = this.root;
|
|
||||||
let remaining = key;
|
|
||||||
while (node && remaining.length > 0) {
|
|
||||||
let child = null;
|
|
||||||
for (let i = 1; i <= remaining.length; i += 1) {
|
|
||||||
child = node.children.get(remaining.slice(0, i));
|
|
||||||
if (child) {
|
|
||||||
remaining = remaining.slice(i);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
node = child;
|
|
||||||
}
|
|
||||||
return remaining.length === 0 && node && node.terminal ? node : null;
|
|
||||||
}
|
|
||||||
commonPrefix(a, b) {
|
|
||||||
const shortest = Math.min(a.length, b.length);
|
|
||||||
let i = 0;
|
|
||||||
for (; i < shortest; i += 1) {
|
|
||||||
if (a[i] !== b[i]) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return a.slice(0, i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
type UserRow = [number, string];
|
|
||||||
|
|
||||||
function buildUsersTrie(users: UserRow[]): string {
|
|
||||||
const rootNode = new trie<[number, string]>();
|
|
||||||
users.forEach(([id, name]) => {
|
|
||||||
rootNode.insert(name.toLowerCase(), [id, name]);
|
|
||||||
});
|
|
||||||
return JSON.stringify(rootNode.serialize());
|
|
||||||
}
|
|
||||||
|
|
||||||
class trie_node<T> {
|
|
||||||
public terminal: boolean;
|
|
||||||
public value: T;
|
|
||||||
public children: Map<string, trie_node<T>>;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.terminal = false;
|
|
||||||
this.children = new Map();
|
|
||||||
}
|
|
||||||
|
|
||||||
public serialize(): Object {
|
|
||||||
const { terminal, value, children } = this;
|
|
||||||
let mapped = {};
|
|
||||||
let numChildren = 0;
|
|
||||||
Object.keys(Object.fromEntries(children)).forEach((childKey) => {
|
|
||||||
numChildren += 1;
|
|
||||||
mapped[childKey] = children.get(childKey).serialize();
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
t: this.terminal ? 1 : 0,
|
|
||||||
v: value,
|
|
||||||
c: numChildren > 0 ? mapped : undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class trie<T> {
|
|
||||||
public root: trie_node<T>;
|
|
||||||
public elements: number;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.root = new trie_node<T>();
|
|
||||||
this.elements = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
public serialize(): Object {
|
|
||||||
return this.root.serialize();
|
|
||||||
}
|
|
||||||
|
|
||||||
public get length(): number {
|
|
||||||
return this.elements;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get(key: string): T | null {
|
|
||||||
const node = this.getNode(key);
|
|
||||||
if (node) {
|
|
||||||
return node.value;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public contains(key: string): boolean {
|
|
||||||
const node = this.getNode(key);
|
|
||||||
return !!node;
|
|
||||||
}
|
|
||||||
|
|
||||||
public insert(key: string, value: T): void {
|
|
||||||
let node = this.root;
|
|
||||||
let remaining = key;
|
|
||||||
while (remaining.length > 0) {
|
|
||||||
let child: trie_node<T> = null;
|
|
||||||
for (const childKey of node.children.keys()) {
|
|
||||||
const prefix = this.commonPrefix(remaining, childKey);
|
|
||||||
if (!prefix.length) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (prefix.length === childKey.length) {
|
|
||||||
// enter child node
|
|
||||||
child = node.children.get(childKey);
|
|
||||||
remaining = remaining.slice(childKey.length);
|
|
||||||
break;
|
|
||||||
} else {
|
|
||||||
// split the child
|
|
||||||
child = new trie_node<T>();
|
|
||||||
child.children.set(
|
|
||||||
childKey.slice(prefix.length),
|
|
||||||
node.children.get(childKey)
|
|
||||||
);
|
|
||||||
node.children.delete(childKey);
|
|
||||||
node.children.set(prefix, child);
|
|
||||||
remaining = remaining.slice(prefix.length);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!child && remaining.length) {
|
|
||||||
child = new trie_node<T>();
|
|
||||||
node.children.set(remaining, child);
|
|
||||||
remaining = "";
|
|
||||||
}
|
|
||||||
node = child;
|
|
||||||
}
|
|
||||||
if (!node.terminal) {
|
|
||||||
node.terminal = true;
|
|
||||||
this.elements += 1;
|
|
||||||
}
|
|
||||||
node.value = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
public remove(key: string): void {
|
|
||||||
const node = this.getNode(key);
|
|
||||||
if (node) {
|
|
||||||
node.terminal = false;
|
|
||||||
this.elements -= 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public map<U>(prefix: string, func: (key: string, value: T) => U): U[] {
|
|
||||||
const mapped = [];
|
|
||||||
const node = this.getNode(prefix);
|
|
||||||
const stack: [string, trie_node<T>][] = [];
|
|
||||||
if (node) {
|
|
||||||
stack.push([prefix, node]);
|
|
||||||
}
|
|
||||||
while (stack.length) {
|
|
||||||
const [key, node] = stack.pop();
|
|
||||||
if (node.terminal) {
|
|
||||||
mapped.push(func(key, node.value));
|
|
||||||
}
|
|
||||||
for (const c of node.children.keys()) {
|
|
||||||
stack.push([key + c, node.children.get(c)]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return mapped;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getNode(key: string): trie_node<T> | null {
|
|
||||||
let node = this.root;
|
|
||||||
let remaining = key;
|
|
||||||
while (node && remaining.length > 0) {
|
|
||||||
let child = null;
|
|
||||||
for (let i = 1; i <= remaining.length; i += 1) {
|
|
||||||
child = node.children.get(remaining.slice(0, i));
|
|
||||||
if (child) {
|
|
||||||
remaining = remaining.slice(i);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
node = child;
|
|
||||||
}
|
|
||||||
return remaining.length === 0 && node && node.terminal ? node : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private commonPrefix(a: string, b: string): string {
|
|
||||||
const shortest = Math.min(a.length, b.length);
|
|
||||||
let i = 0;
|
|
||||||
for (; i < shortest; i += 1) {
|
|
||||||
if (a[i] !== b[i]) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return a.slice(0, i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user