kopia lustrzana https://github.com/Stopka/fedisearch
Added node search page
rodzic
39e21960ff
commit
84a0460596
|
@ -0,0 +1,11 @@
|
||||||
|
module.exports = {
|
||||||
|
async redirects() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/',
|
||||||
|
destination: '/feeds',
|
||||||
|
permanent: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
|
@ -35,7 +35,7 @@ const Layout: React.FC<{ matomoConfig: UserOptions, children: React.ReactNode }>
|
||||||
</header>
|
</header>
|
||||||
<nav>
|
<nav>
|
||||||
<ul>
|
<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"
|
<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"
|
className="svg-inline--fa fa-user fa-w-14" role="img" xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 448 512">
|
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"/>
|
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>
|
</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={(
|
<NavItem path={'/stats'} label={'Index stats'} icon={(
|
||||||
|
|
||||||
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="chart-pie"
|
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="chart-pie"
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { StatsRequest, StatsRequestSortBy } from '../types/StatsRequest'
|
import { Sort } from '../types/Sort'
|
||||||
|
|
||||||
const SortToggle: React.FC<{
|
const SortToggle: React.FC<{
|
||||||
onToggle:(StatsRequestSortBy)=>void,
|
onToggle:(StatsRequestSortBy)=>void,
|
||||||
field:StatsRequestSortBy,
|
field:string,
|
||||||
sort: StatsRequest
|
sort: Sort
|
||||||
}> = ({ onToggle, field, sort, children }) => {
|
}> = ({ onToggle, field, sort, children }) => {
|
||||||
return (
|
return (
|
||||||
<a className={'sort-toggle'} href={'#'} onClick={() => onToggle(field)}>
|
<a className={'sort-toggle'} href={'#'} onClick={() => onToggle(field)}>
|
||||||
|
|
|
@ -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
|
|
@ -11,7 +11,7 @@ import { GetServerSideProps, InferGetServerSidePropsType } from 'next'
|
||||||
|
|
||||||
let source = axios.CancelToken.source()
|
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 [query, setQuery] = useState('')
|
||||||
const [submitted, setSubmitted] = useState(null)
|
const [submitted, setSubmitted] = useState(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
@ -108,6 +108,7 @@ const Home:React.FC<InferGetServerSidePropsType<typeof getServerSideProps>> = ({
|
||||||
<Head>
|
<Head>
|
||||||
<title>{siteTitle}</title>
|
<title>{siteTitle}</title>
|
||||||
</Head>
|
</Head>
|
||||||
|
<h1>Search people</h1>
|
||||||
<form onSubmit={handleSearchSubmit}>
|
<form onSubmit={handleSearchSubmit}>
|
||||||
<label htmlFor={'query'}>Search on fediverse</label>
|
<label htmlFor={'query'}>Search on fediverse</label>
|
||||||
<input
|
<input
|
||||||
|
@ -164,4 +165,4 @@ export const getServerSideProps:GetServerSideProps = async (context) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Home
|
export default Feeds
|
|
@ -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
|
|
@ -8,8 +8,10 @@ import axios from 'axios'
|
||||||
import { StatsResponse, statsResponseSchema } from '../types/StatsResponse'
|
import { StatsResponse, statsResponseSchema } from '../types/StatsResponse'
|
||||||
import SoftwareBadge from '../components/badges/SoftwareBadge'
|
import SoftwareBadge from '../components/badges/SoftwareBadge'
|
||||||
import ProgressBar from '../components/ProgressBar'
|
import ProgressBar from '../components/ProgressBar'
|
||||||
import { StatsRequest, StatsRequestSortBy } from '../types/StatsRequest'
|
import { StatsRequestSortBy } from '../types/StatsRequest'
|
||||||
import SortToggle from '../components/SortToggle'
|
import SortToggle from '../components/SortToggle'
|
||||||
|
import getMatomo from '../lib/getMatomo'
|
||||||
|
import { Sort } from '../types/Sort'
|
||||||
|
|
||||||
let source = axios.CancelToken.source()
|
let source = axios.CancelToken.source()
|
||||||
|
|
||||||
|
@ -17,14 +19,25 @@ const Stats: React.FC<InferGetServerSidePropsType<typeof getServerSideProps>> =
|
||||||
const [loading, setLoading] = useState<boolean>(true)
|
const [loading, setLoading] = useState<boolean>(true)
|
||||||
const [loaded, setLoaded] = useState<boolean>(false)
|
const [loaded, setLoaded] = useState<boolean>(false)
|
||||||
const [stats, setStats] = useState<StatsResponse | null>(null)
|
const [stats, setStats] = useState<StatsResponse | null>(null)
|
||||||
const [sort, setSort] = useState<StatsRequest>({
|
const [sort, setSort] = useState<Sort>({
|
||||||
sortBy: 'nodeCount', sortWay: 'desc'
|
sortBy: 'nodeCount', sortWay: 'desc'
|
||||||
})
|
})
|
||||||
|
|
||||||
const toggleSort = (sortBy: StatsRequestSortBy) => {
|
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({
|
setSort({
|
||||||
sortBy: sortBy,
|
sortBy: sortBy,
|
||||||
sortWay: sort.sortBy === sortBy && sort.sortWay === 'asc' ? 'desc' : 'asc'
|
sortWay: sortWay
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -17,6 +17,7 @@
|
||||||
table {
|
table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
|
||||||
img {
|
img {
|
||||||
max-width: 1em;
|
max-width: 1em;
|
||||||
|
@ -29,7 +30,6 @@ table {
|
||||||
border: 1px solid var(--front-bg-color);
|
border: 1px solid var(--front-bg-color);
|
||||||
border-radius: 0.3em;
|
border-radius: 0.3em;
|
||||||
padding: 0.3em;
|
padding: 0.3em;
|
||||||
word-break: break-word;
|
|
||||||
|
|
||||||
&.number-cell {
|
&.number-cell {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
|
@ -223,6 +223,8 @@ form {
|
||||||
fill: var(--main-bg-color);
|
fill: var(--main-bg-color);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
margin-bottom: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
nav {
|
nav {
|
||||||
|
@ -344,13 +346,11 @@ img {
|
||||||
grid-column: main / end;
|
grid-column: main / end;
|
||||||
grid-row: start;
|
grid-row: start;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
word-break: break-word;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.address {
|
.address {
|
||||||
grid-column: main / end;
|
grid-column: main / end;
|
||||||
grid-row: address;
|
grid-row: address;
|
||||||
word-break: break-word;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.badges {
|
.badges {
|
||||||
|
@ -400,7 +400,6 @@ img {
|
||||||
border: 1px solid var(--main-bg-color);
|
border: 1px solid var(--main-bg-color);
|
||||||
border-radius: 0.3em;
|
border-radius: 0.3em;
|
||||||
padding: 0.3em;
|
padding: 0.3em;
|
||||||
word-break: break-word;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
th {
|
th {
|
||||||
|
@ -411,7 +410,6 @@ img {
|
||||||
.description {
|
.description {
|
||||||
grid-column: start/end;
|
grid-column: start/end;
|
||||||
grid-row: span 1;
|
grid-row: span 1;
|
||||||
word-break: break-word;
|
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin: 0 0 1em 0;
|
margin: 0 0 1em 0;
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -0,0 +1,4 @@
|
||||||
|
export type Sort = {
|
||||||
|
sortBy: string,
|
||||||
|
sortWay: 'asc' | 'desc'
|
||||||
|
}
|
Ładowanie…
Reference in New Issue