Added node search page

main
Štěpán Škorpil 2022-02-01 21:54:48 +01:00
rodzic 39e21960ff
commit 84a0460596
11 zmienionych plików z 428 dodań i 14 usunięć

Wyświetl plik

@ -0,0 +1,11 @@
module.exports = {
async redirects() {
return [
{
source: '/',
destination: '/feeds',
permanent: true,
},
]
},
}

Wyświetl plik

@ -35,7 +35,7 @@ const Layout: React.FC<{ matomoConfig: UserOptions, children: React.ReactNode }>
</header>
<nav>
<ul>
<NavItem path={'/'} label={'Search people'} icon={(
<NavItem path={'/feeds'} label={'Search people'} icon={(
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="user"
className="svg-inline--fa fa-user fa-w-14" role="img" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 448 512">
@ -43,6 +43,14 @@ const Layout: React.FC<{ matomoConfig: UserOptions, children: React.ReactNode }>
d="M224 256c70.7 0 128-57.3 128-128S294.7 0 224 0 96 57.3 96 128s57.3 128 128 128zm89.6 32h-16.7c-22.2 10.2-46.9 16-72.9 16s-50.6-5.8-72.9-16h-16.7C60.2 288 0 348.2 0 422.4V464c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48v-41.6c0-74.2-60.2-134.4-134.4-134.4z"/>
</svg>
)} />
<NavItem path={'/nodes'} label={'Search servers'} icon={(
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="server"
className="svg-inline--fa fa-server fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512">
<path fill="currentColor"
d="M480 160H32c-17.673 0-32-14.327-32-32V64c0-17.673 14.327-32 32-32h448c17.673 0 32 14.327 32 32v64c0 17.673-14.327 32-32 32zm-48-88c-13.255 0-24 10.745-24 24s10.745 24 24 24 24-10.745 24-24-10.745-24-24-24zm-64 0c-13.255 0-24 10.745-24 24s10.745 24 24 24 24-10.745 24-24-10.745-24-24-24zm112 248H32c-17.673 0-32-14.327-32-32v-64c0-17.673 14.327-32 32-32h448c17.673 0 32 14.327 32 32v64c0 17.673-14.327 32-32 32zm-48-88c-13.255 0-24 10.745-24 24s10.745 24 24 24 24-10.745 24-24-10.745-24-24-24zm-64 0c-13.255 0-24 10.745-24 24s10.745 24 24 24 24-10.745 24-24-10.745-24-24-24zm112 248H32c-17.673 0-32-14.327-32-32v-64c0-17.673 14.327-32 32-32h448c17.673 0 32 14.327 32 32v64c0 17.673-14.327 32-32 32zm-48-88c-13.255 0-24 10.745-24 24s10.745 24 24 24 24-10.745 24-24-10.745-24-24-24zm-64 0c-13.255 0-24 10.745-24 24s10.745 24 24 24 24-10.745 24-24-10.745-24-24-24z"/>
</svg>
)} />
<NavItem path={'/stats'} label={'Index stats'} icon={(
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="chart-pie"

Wyświetl plik

@ -1,10 +1,10 @@
import React from 'react'
import { StatsRequest, StatsRequestSortBy } from '../types/StatsRequest'
import { Sort } from '../types/Sort'
const SortToggle: React.FC<{
onToggle:(StatsRequestSortBy)=>void,
field:StatsRequestSortBy,
sort: StatsRequest
field:string,
sort: Sort
}> = ({ onToggle, field, sort, children }) => {
return (
<a className={'sort-toggle'} href={'#'} onClick={() => onToggle(field)}>

Wyświetl plik

@ -0,0 +1,64 @@
import prisma from '../../lib/prisma'
import { pageLimit } from '../../lib/pageLimit'
import { NextApiRequest, NextApiResponse } from 'next'
import { nodeRequestSchema } from '../../types/NodeRequest'
import { NodeResponse } from '../../types/NodeResponse'
const handleFeedSearch = async (req: NextApiRequest, res: NextApiResponse<NodeResponse>): Promise<void> => {
console.info('Searching nodes', { query: req.query })
const nodeRequest = nodeRequestSchema.parse(req.query)
const phrases = (nodeRequest.search ?? '').trim().split(/[\s+]+/)
nodeRequest.sortBy = nodeRequest.sortBy ?? 'refreshedAt'
nodeRequest.sortWay = nodeRequest.sortWay ?? 'desc'
const order = {}
order[nodeRequest.sortBy] = nodeRequest.sortWay
const nodes = await prisma.node.findMany({
where: {
AND: phrases.map(phrase => {
return {
OR: [
{
domain: {
contains: phrase,
mode: 'insensitive'
}
},
{
softwareName: {
contains: phrase,
mode: 'insensitive'
}
}
]
}
}),
NOT: {
softwareName: null
}
},
take: pageLimit + 1,
skip: (nodeRequest.page ?? 0) * pageLimit,
orderBy: [order]
})
res.status(200)
.json({
hasMore: typeof nodes[pageLimit] !== 'undefined',
nodes: nodes.slice(0, pageLimit).map(node => {
return {
softwareName: node.softwareName,
softwareVersion: node.softwareVersion,
totalUserCount: node.totalUserCount,
monthActiveUserCount: node.monthActiveUserCount,
halfYearActiveUserCount: node.halfYearActiveUserCount,
statusesCount: node.statusesCount,
openRegistrations: node.openRegistrations,
refreshedAt: node.refreshedAt ? node.refreshedAt.toISOString() : null,
domain: node.domain
}
})
})
}
export default handleFeedSearch

Wyświetl plik

@ -11,7 +11,7 @@ import { GetServerSideProps, InferGetServerSidePropsType } from 'next'
let source = axios.CancelToken.source()
const Home:React.FC<InferGetServerSidePropsType<typeof getServerSideProps>> = ({ matomoConfig }) => {
const Feeds:React.FC<InferGetServerSidePropsType<typeof getServerSideProps>> = ({ matomoConfig }) => {
const [query, setQuery] = useState('')
const [submitted, setSubmitted] = useState(null)
const [loading, setLoading] = useState(false)
@ -108,6 +108,7 @@ const Home:React.FC<InferGetServerSidePropsType<typeof getServerSideProps>> = ({
<Head>
<title>{siteTitle}</title>
</Head>
<h1>Search people</h1>
<form onSubmit={handleSearchSubmit}>
<label htmlFor={'query'}>Search on fediverse</label>
<input
@ -164,4 +165,4 @@ export const getServerSideProps:GetServerSideProps = async (context) => {
}
}
export default Home
export default Feeds

Wyświetl plik

@ -0,0 +1,262 @@
import Head from 'next/head'
import React, { useEffect, useState } from 'react'
import axios from 'axios'
import Loader from '../components/Loader'
import Layout, { siteTitle } from '../components/Layout'
import { matomoConfig } from '../lib/matomoConfig'
import getMatomo from '../lib/getMatomo'
import { GetServerSideProps, InferGetServerSidePropsType } from 'next'
import { nodeResponseSchema } from '../types/NodeResponse'
import SoftwareBadge from '../components/badges/SoftwareBadge'
import SortToggle from '../components/SortToggle'
import { StatsRequestSortBy } from '../types/StatsRequest'
import { Sort } from '../types/Sort'
let source = axios.CancelToken.source()
const Nodes:React.FC<InferGetServerSidePropsType<typeof getServerSideProps>> = ({ matomoConfig }) => {
const [query, setQuery] = useState('')
const [submitted, setSubmitted] = useState(null)
const [loading, setLoading] = useState(false)
const [results, setResults] = useState([])
const [page, setPage] = useState(0)
const [hasMore, setHasMore] = useState(false)
const [loaded, setLoaded] = useState(false)
const [sort, setSort] = useState<Sort>({
sortBy: 'refreshedAt', sortWay: 'desc'
})
const search = async () => {
setLoading(true)
try {
console.info('Retrieving results', { query, page })
source = axios.CancelToken.source()
const response = await axios.get('/api/node', {
params: { search: query, page, sortBy: sort.sortBy, sortWay: sort.sortWay },
cancelToken: source.token
})
const responseData = await nodeResponseSchema.parseAsync(response.data)
setHasMore(responseData.hasMore)
setResults([
...(page > 0 ? results : []),
...responseData.nodes
])
setLoaded(true)
} catch (e) {
console.warn('Search failed', e)
setLoaded(true)
}
setLoading(false)
}
const loadNewQueryResults = () => {
console.info('Cancelling searches')
source.cancel('New query on the way')
setResults([])
setHasMore(false)
setLoaded(false)
console.info('Loading new query search', { query, page })
setLoading(true)
setTimeout(search)
getMatomo(matomoConfig).trackEvent({
category: 'nodes',
action: 'new-search'
})
}
const loadNextPageResults = () => {
setHasMore(false)
if (page === 0) {
return
}
console.info('Loading next page', { query, page })
setTimeout(search)
getMatomo(matomoConfig).trackEvent({
category: 'nodes',
action: 'next-page',
customDimensions: [
{
value: page.toString(),
id: 1
}
]
})
}
const handleQueryChange = (event) => {
const value = event.target.value
console.info('Query changed', { query: value })
setQuery(value)
setPage(0)
}
const handleSearchSubmit = event => {
event.preventDefault()
setQuery(query)
setSubmitted(new Date())
setPage(0)
}
const handleLoadMore = event => {
event.preventDefault()
setPage(page + 1)
}
const toggleSort = (sortBy: StatsRequestSortBy) => {
const sortWay = sort.sortBy === sortBy && sort.sortWay === 'asc' ? 'desc' : 'asc'
getMatomo(matomoConfig).trackEvent({
category: 'nodes',
action: 'sort',
customDimensions: [
{
value: `${sortBy} ${sortWay}`,
id: 2
}
]
})
setSort({
sortBy: sortBy,
sortWay: sortWay
})
}
useEffect(loadNewQueryResults, [query, submitted, sort])
useEffect(loadNextPageResults, [page])
return (
<Layout matomoConfig={matomoConfig}>
<Head>
<title>{siteTitle}</title>
</Head>
<h1>Search servers</h1>
<form onSubmit={handleSearchSubmit}>
<label htmlFor={'query'}>Search on fediverse</label>
<input
name={'query'}
id={'query'}
type={'search'}
onChange={handleQueryChange}
onBlur={handleQueryChange}
value={query}
placeholder={'Search on fediverse'}
autoFocus={true}
/>
<button type={'submit'}>
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="search"
className="svg-inline--fa fa-search fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512">
<path fill="currentColor"
d="M505 442.7L405.3 343c-4.5-4.5-10.6-7-17-7H372c27.6-35.3 44-79.7 44-128C416 93.1 322.9 0 208 0S0 93.1 0 208s93.1 208 208 208c48.3 0 92.7-16.4 128-44v16.3c0 6.4 2.5 12.5 7 17l99.7 99.7c9.4 9.4 24.6 9.4 33.9 0l28.3-28.3c9.4-9.4 9.4-24.6.1-34zM208 336c-70.7 0-128-57.2-128-128 0-70.7 57.2-128 128-128 70.7 0 128 57.2 128 128 0 70.7-57.2 128-128 128z" />
<title>Search</title>
</svg>
<span>Search</span>
</button>
</form>
<Loader loading={loading} showBottom={true}>
{
loaded
? (
<table>
<thead>
<tr>
<th rowSpan={2}>
<SortToggle onToggle={toggleSort} field={'domain'} sort={sort}>
Domain
</SortToggle>
</th>
<th rowSpan={2}>
<SortToggle onToggle={toggleSort} field={'softwareName'} sort={sort}>
Software
</SortToggle>
</th>
<th colSpan={3}>User count</th>
<th rowSpan={2} className={'number-cell'}>
<SortToggle onToggle={toggleSort} field={'statusesCount'} sort={sort}>
Statuses
</SortToggle>
</th>
<th rowSpan={2}>
<SortToggle onToggle={toggleSort} field={'openRegistrations'} sort={sort}>
Registrations
</SortToggle>
</th>
<th rowSpan={2}>
<SortToggle onToggle={toggleSort} field={'refreshedAt'} sort={sort}>
Last refreshed
</SortToggle>
</th>
</tr>
<tr>
<th className={'number-cell'}>
<SortToggle onToggle={toggleSort} field={'totalUserCount'} sort={sort}>
Total
</SortToggle>
</th>
<th className={'number-cell'}>
<SortToggle onToggle={toggleSort} field={'monthActiveUserCount'} sort={sort}>
Month active
</SortToggle>
</th>
<th className={'number-cell'}>
<SortToggle onToggle={toggleSort} field={'halfYearActiveUserCount'} sort={sort}>
Half year active
</SortToggle>
</th>
</tr>
</thead>
<tbody>
{results.length
? results.map((node, index) => {
return (
<tr key={index}>
<td>{node.domain}</td>
<td>
<div title={'Name'}><SoftwareBadge softwareName={node.softwareName}/></div>
<div title={'Version'}>{node.softwareVersion ?? ''}</div></td>
<td className={'number-cell'}>{node.totalUserCount ?? '?'}</td>
<td className={'number-cell'}>{node.monthActiveUserCount ?? '?'}</td>
<td className={'number-cell'}>{node.halfYearActiveUserCount ?? '?'}</td>
<td className={'number-cell'}>{node.statusesCount ?? '?'}</td>
<td>{node.openRegistrations === null ? '?' : (node.openRegistrations ? 'Opened' : 'Closed')}</td>
<td>{node.refreshedAt ? (new Date(node.refreshedAt)).toLocaleDateString() : 'Never'}</td>
</tr>
)
})
: (
<tr>
<td colSpan={9}>No servers found</td>
</tr>
)}
</tbody>
</table>
)
: ''
}
</Loader>
{hasMore && !loading
? (
<button className={'next-page'} onClick={handleLoadMore}>
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="angle-double-down"
className="svg-inline--fa fa-angle-double-down fa-w-10" role="img"
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512">
<path fill="currentColor"
d="M143 256.3L7 120.3c-9.4-9.4-9.4-24.6 0-33.9l22.6-22.6c9.4-9.4 24.6-9.4 33.9 0l96.4 96.4 96.4-96.4c9.4-9.4 24.6-9.4 33.9 0L313 86.3c9.4 9.4 9.4 24.6 0 33.9l-136 136c-9.4 9.5-24.6 9.5-34 .1zm34 192l136-136c9.4-9.4 9.4-24.6 0-33.9l-22.6-22.6c-9.4-9.4-24.6-9.4-33.9 0L160 352.1l-96.4-96.4c-9.4-9.4-24.6-9.4-33.9 0L7 278.3c-9.4 9.4-9.4 24.6 0 33.9l136 136c9.4 9.5 24.6 9.5 34 .1z"/>
</svg>
<span>Load more</span>
</button>
)
: ''}
</Layout>
)
}
export const getServerSideProps:GetServerSideProps = async (context) => {
console.info('Loading matomo config', matomoConfig)
return {
props: {
matomoConfig
}
}
}
export default Nodes

Wyświetl plik

@ -8,8 +8,10 @@ import axios from 'axios'
import { StatsResponse, statsResponseSchema } from '../types/StatsResponse'
import SoftwareBadge from '../components/badges/SoftwareBadge'
import ProgressBar from '../components/ProgressBar'
import { StatsRequest, StatsRequestSortBy } from '../types/StatsRequest'
import { StatsRequestSortBy } from '../types/StatsRequest'
import SortToggle from '../components/SortToggle'
import getMatomo from '../lib/getMatomo'
import { Sort } from '../types/Sort'
let source = axios.CancelToken.source()
@ -17,14 +19,25 @@ const Stats: React.FC<InferGetServerSidePropsType<typeof getServerSideProps>> =
const [loading, setLoading] = useState<boolean>(true)
const [loaded, setLoaded] = useState<boolean>(false)
const [stats, setStats] = useState<StatsResponse | null>(null)
const [sort, setSort] = useState<StatsRequest>({
const [sort, setSort] = useState<Sort>({
sortBy: 'nodeCount', sortWay: 'desc'
})
const toggleSort = (sortBy: StatsRequestSortBy) => {
const sortWay = sort.sortBy === sortBy && sort.sortWay === 'asc' ? 'desc' : 'asc'
getMatomo(matomoConfig).trackEvent({
category: 'stats',
action: 'sort',
customDimensions: [
{
value: `${sortBy} ${sortWay}`,
id: 2
}
]
})
setSort({
sortBy: sortBy,
sortWay: sort.sortBy === sortBy && sort.sortWay === 'asc' ? 'desc' : 'asc'
sortWay: sortWay
})
}

Wyświetl plik

@ -17,6 +17,7 @@
table {
width: 100%;
text-align: left;
margin-bottom: 1em;
img {
max-width: 1em;
@ -29,7 +30,6 @@ table {
border: 1px solid var(--front-bg-color);
border-radius: 0.3em;
padding: 0.3em;
word-break: break-word;
&.number-cell {
text-align: right;
@ -223,6 +223,8 @@ form {
fill: var(--main-bg-color);
}
}
margin-bottom: 1em;
}
nav {
@ -344,13 +346,11 @@ img {
grid-column: main / end;
grid-row: start;
margin: 0;
word-break: break-word;
}
.address {
grid-column: main / end;
grid-row: address;
word-break: break-word;
}
.badges {
@ -400,7 +400,6 @@ img {
border: 1px solid var(--main-bg-color);
border-radius: 0.3em;
padding: 0.3em;
word-break: break-word;
}
th {
@ -411,7 +410,6 @@ img {
.description {
grid-column: start/end;
grid-row: span 1;
word-break: break-word;
p {
margin: 0 0 1em 0;

Wyświetl plik

@ -0,0 +1,32 @@
import { z } from 'zod'
import { preserveUndefined, stringToInt, transform } from '../lib/transform'
export const statsRequestSortBySchema = z.enum([
'softwareName',
'softwareVersion',
'totalUserCount',
'monthActiveUserCount',
'halfYearActiveUserCount',
'statusesCount',
'openRegistrations',
'refreshedAt',
'domain'
])
export const statsRequestSortWaySchema = z.enum([
'asc',
'desc'
])
export const nodeRequestSchema = z.object({
sortBy: z.optional(statsRequestSortBySchema),
sortWay: z.optional(statsRequestSortWaySchema),
search: z.string().optional(),
page: transform(
z.string().optional(),
preserveUndefined(stringToInt),
z.number().gte(0).optional()
)
})
export type NodeRequest = z.infer<typeof nodeRequestSchema>

Wyświetl plik

@ -0,0 +1,21 @@
import { z } from 'zod'
export const nodeResponseItemSchema = z.object({
softwareName: z.string().nullable(),
softwareVersion: z.string().nullable(),
totalUserCount: z.number().nullable(),
monthActiveUserCount: z.number().nullable(),
halfYearActiveUserCount: z.number().nullable(),
statusesCount: z.number().nullable(),
openRegistrations: z.boolean().nullable(),
refreshedAt: z.string().nullable(),
domain: z.string()
})
export const nodeResponseSchema = z.object({
hasMore: z.boolean(),
nodes: z.array(nodeResponseItemSchema)
})
export type NodeResponse = z.infer<typeof nodeResponseSchema>
export type NodeResponseItem = z.infer<typeof nodeResponseItemSchema>

Wyświetl plik

@ -0,0 +1,4 @@
export type Sort = {
sortBy: string,
sortWay: 'asc' | 'desc'
}