kopia lustrzana https://github.com/Stopka/fedisearch
Added sorting and graph to stats
rodzic
633ab39739
commit
73811f9e2e
|
@ -1,24 +1,44 @@
|
|||
import React, { ReactNode } from 'react'
|
||||
|
||||
const Loader:React.FC<{ children: ReactNode, loading: boolean, hideContent?:boolean }> = ({ hideContent, children, loading }) => {
|
||||
return (
|
||||
<div className={'loader' + (loading ? ' -loading' : '')}>
|
||||
<div className={'loader-content'}>
|
||||
{ hideContent && loading ? '' : children}
|
||||
</div>
|
||||
<div className={'loader-visualisation'}>
|
||||
{loading
|
||||
const Loader:React.FC<{ children: ReactNode, loading: boolean, hideContent?:boolean, table?:number, showTop?:boolean, showBottom?:boolean }> = ({ showTop, showBottom, hideContent, children, table, loading }) => {
|
||||
const className = 'loader' + (loading ? ' -loading' : '')
|
||||
const loaderVisual = (
|
||||
<div className={'loader-visualisation'}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className='loader-graphics' width="34" height="34">
|
||||
<path className="rail" d="m 16.977523,0.24095147 c -9.2629169,0 -16.73280045,7.51449143 -16.73280045,16.77740253 0,9.262912 7.46988355,16.777403 16.73280045,16.777403 9.262917,0 16.777413,-7.514491 16.777413,-16.777403 0,-9.2629111 -7.514496,-16.77740253 -16.777413,-16.77740253 z m 0,4.14972823 c 6.966927,0 12.627682,5.6607523 12.627682,12.6276743 0,6.966923 -5.660755,12.583053 -12.627682,12.583053 -6.966937,0 -12.5830596,-5.61613 -12.5830596,-12.583053 0,-6.966922 5.6161226,-12.6276743 12.5830596,-12.6276743 z" />
|
||||
<path className="train" d="M 31.677259,17.003529 A 14.680208,14.680199 0 0 1 20.796571,31.183505" />
|
||||
</svg>
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
)
|
||||
if (table) {
|
||||
return (
|
||||
<>
|
||||
{showTop && loading
|
||||
? (
|
||||
<>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className='loader-graphics' width="34" height="34">
|
||||
<path className="rail" d="m 16.977523,0.24095147 c -9.2629169,0 -16.73280045,7.51449143 -16.73280045,16.77740253 0,9.262912 7.46988355,16.777403 16.73280045,16.777403 9.262917,0 16.777413,-7.514491 16.777413,-16.777403 0,-9.2629111 -7.514496,-16.77740253 -16.777413,-16.77740253 z m 0,4.14972823 c 6.966927,0 12.627682,5.6607523 12.627682,12.6276743 0,6.966923 -5.660755,12.583053 -12.627682,12.583053 -6.966937,0 -12.5830596,-5.61613 -12.5830596,-12.583053 0,-6.966922 5.6161226,-12.6276743 12.5830596,-12.6276743 z" />
|
||||
<path className="train" d="M 31.677259,17.003529 A 14.680208,14.680199 0 0 1 20.796571,31.183505" />
|
||||
</svg>
|
||||
<span>Loading...</span>
|
||||
</>
|
||||
<tr className={className}>
|
||||
<td colSpan={table}>{loaderVisual}</td>
|
||||
</tr>
|
||||
)
|
||||
: ''}
|
||||
{ hideContent && loading ? '' : children}
|
||||
{showBottom && loading
|
||||
? (
|
||||
<tr className={className}>
|
||||
<td colSpan={table}>{loaderVisual}</td>
|
||||
</tr>
|
||||
)
|
||||
: ''}
|
||||
</>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className={className}>
|
||||
{showTop && loading ? loaderVisual : ''}
|
||||
<div className={'loader-content'}>
|
||||
{hideContent && loading ? '' : children}
|
||||
</div>
|
||||
{showBottom && loading ? loaderVisual : ''}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
import React from 'react'
|
||||
|
||||
const ProgressBar: React.FC<{ percents: number, way?: 'left' | 'right' | 'top' | 'bottom', color?:string }> = ({ percents, way, color }) => {
|
||||
way = way ?? 'right'
|
||||
percents = Math.round(percents)
|
||||
color = color ?? 'var(--accent-color)'
|
||||
return (
|
||||
<div className={'progressbar'} style={{
|
||||
background: `linear-gradient(to ${way}, ${color} ${percents}%, transparent ${percents}%`
|
||||
}}/>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProgressBar
|
|
@ -0,0 +1,38 @@
|
|||
import React from 'react'
|
||||
import { StatsRequest, StatsRequestSortBy } from '../types/StatsRequest'
|
||||
|
||||
const SortToggle: React.FC<{
|
||||
onToggle:(StatsRequestSortBy)=>void,
|
||||
field:StatsRequestSortBy,
|
||||
sort: StatsRequest
|
||||
}> = ({ onToggle, field, sort, children }) => {
|
||||
return (
|
||||
<a className={'sort-toggle'} href={'#'} onClick={() => onToggle(field)}>
|
||||
<span>{children}</span>
|
||||
{sort.sortBy === field && sort.sortWay === 'asc'
|
||||
? (
|
||||
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="sort-up"
|
||||
className="sort-icon svg-inline--fa fa-sort-up fa-w-10" role="img" xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 320 512">
|
||||
<path fill="currentColor"
|
||||
d="M279 224H41c-21.4 0-32.1-25.9-17-41L143 64c9.4-9.4 24.6-9.4 33.9 0l119 119c15.2 15.1 4.5 41-16.9 41z"/>
|
||||
</svg>
|
||||
)
|
||||
: ''
|
||||
}
|
||||
{sort.sortBy === field && sort.sortWay === 'desc'
|
||||
? (
|
||||
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="sort-down"
|
||||
className="sort-icon svg-inline--fa fa-sort-down fa-w-10" role="img" xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 320 512">
|
||||
<path fill="currentColor"
|
||||
d="M41 288h238c21.4 0 32.1 25.9 17 41L177 448c-9.4 9.4-24.6 9.4-33.9 0L24 329c-15.1-15.1-4.4-41 17-41z"/>
|
||||
</svg>
|
||||
)
|
||||
: ''
|
||||
}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export default SortToggle
|
|
@ -2,6 +2,7 @@ import prisma from '../../lib/prisma'
|
|||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { StatsResponse, StatsResponseSoftware } from '../../types/StatsResponse'
|
||||
import { cache } from '../../lib/cache'
|
||||
import { statsRequestSchema } from '../../types/StatsRequest'
|
||||
|
||||
interface StatsItem {
|
||||
softwarename: string | null,
|
||||
|
@ -14,9 +15,13 @@ interface StatsItem {
|
|||
const CACHE_KEY = 'stats'
|
||||
|
||||
const handleGetStats = async (req: NextApiRequest, res: NextApiResponse<StatsResponse>): Promise<void> => {
|
||||
if (!cache.has(CACHE_KEY)) {
|
||||
console.info('Retrieving new stats')
|
||||
const data = await prisma.$queryRaw<StatsItem[]>`
|
||||
const query = await statsRequestSchema.parseAsync(req.query)
|
||||
query.sortBy = query.sortBy ?? 'nodeCount'
|
||||
query.sortWay = query.sortWay ?? 'desc'
|
||||
const cacheKey = `${CACHE_KEY}_${query.sortWay}_${query.sortBy}`
|
||||
if (!cache.has(cacheKey)) {
|
||||
console.info('Retrieving new stats', { cacheKey, query })
|
||||
const data = await prisma.$queryRawUnsafe<StatsItem[]>(`
|
||||
select n."softwareName" as softwarename,
|
||||
count(n.id) as nodecount,
|
||||
(
|
||||
|
@ -40,9 +45,9 @@ const handleGetStats = async (req: NextApiRequest, res: NextApiResponse<StatsRes
|
|||
from "Node" n
|
||||
group by n."softwareName"
|
||||
having count(n.id) > 1
|
||||
order by nodecount desc;
|
||||
`
|
||||
cache.set<StatsResponse>(CACHE_KEY, {
|
||||
order by ${query.sortBy.toLowerCase() + ' ' + query.sortWay.toUpperCase()};
|
||||
`)
|
||||
cache.set<StatsResponse>(cacheKey, {
|
||||
softwares: data.map(
|
||||
(item: StatsItem): StatsResponseSoftware => {
|
||||
return {
|
||||
|
@ -56,9 +61,9 @@ const handleGetStats = async (req: NextApiRequest, res: NextApiResponse<StatsRes
|
|||
)
|
||||
}, parseInt(process.env.STATS_CACHE_MINUTES ?? '60') * 60 * 1000)
|
||||
}
|
||||
console.info('Returning stats from cache')
|
||||
console.info('Returning stats from cache', { cacheKey, query })
|
||||
res.status(200)
|
||||
.json(cache.get<StatsResponse>(CACHE_KEY))
|
||||
.json(cache.get<StatsResponse>(cacheKey))
|
||||
}
|
||||
|
||||
export default handleGetStats
|
||||
|
|
|
@ -131,7 +131,7 @@ const Home:React.FC<InferGetServerSidePropsType<typeof getServerSideProps>> = ({
|
|||
<span>Search</span>
|
||||
</button>
|
||||
</form>
|
||||
<Loader loading={loading}>
|
||||
<Loader loading={loading} showBottom={true}>
|
||||
{
|
||||
loaded
|
||||
? <Results feeds={results}/>
|
||||
|
|
|
@ -7,82 +7,164 @@ import Loader from '../components/Loader'
|
|||
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 SortToggle from '../components/SortToggle'
|
||||
|
||||
let source = axios.CancelToken.source()
|
||||
|
||||
const Stats: React.FC<InferGetServerSidePropsType<typeof getServerSideProps>> = ({ matomoConfig }) => {
|
||||
const [loading, setLoading] = useState<boolean>(true)
|
||||
const [stats, setStats] = useState<StatsResponse|null>(null)
|
||||
const [loaded, setLoaded] = useState<boolean>(false)
|
||||
const [stats, setStats] = useState<StatsResponse | null>(null)
|
||||
const [sort, setSort] = useState<StatsRequest>({
|
||||
sortBy: 'nodeCount', sortWay: 'desc'
|
||||
})
|
||||
|
||||
const loadStats = async () => {
|
||||
const toggleSort = (sortBy: StatsRequestSortBy) => {
|
||||
setSort({
|
||||
sortBy: sortBy,
|
||||
sortWay: sort.sortBy === sortBy && sort.sortWay === 'asc' ? 'desc' : 'asc'
|
||||
})
|
||||
}
|
||||
|
||||
const retrieveStats = async () => {
|
||||
console.info('Retrieving stats', { sort })
|
||||
source = axios.CancelToken.source()
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await axios.get('/api/stats')
|
||||
const response = await axios.get('/api/stats', {
|
||||
params: sort,
|
||||
cancelToken: source.token
|
||||
})
|
||||
const stats = await statsResponseSchema.parseAsync(response.data)
|
||||
setStats(stats)
|
||||
} catch (err) {
|
||||
setStats(null)
|
||||
console.log(err)
|
||||
}
|
||||
setLoaded(true)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const loadStats = async () => {
|
||||
console.info('Cancelling retrivals')
|
||||
source.cancel('New query on the way')
|
||||
setTimeout(retrieveStats)
|
||||
}
|
||||
useEffect(() => {
|
||||
loadStats()
|
||||
}, [])
|
||||
const summary = {
|
||||
}, [sort])
|
||||
const sum = {
|
||||
nodeCount: 0,
|
||||
accountCount: 0,
|
||||
channelCount: 0
|
||||
}
|
||||
const max = {
|
||||
nodeCount: 0,
|
||||
accountCount: 0,
|
||||
channelCount: 0
|
||||
}
|
||||
const softwares = stats === null ? [] : stats.softwares
|
||||
softwares.forEach(software => {
|
||||
if (software.name === null) {
|
||||
return
|
||||
}
|
||||
sum.nodeCount += software.nodeCount
|
||||
sum.accountCount += software.accountCount
|
||||
sum.channelCount += software.channelCount
|
||||
max.nodeCount = Math.max(software.nodeCount, max.nodeCount)
|
||||
max.accountCount = Math.max(software.accountCount, max.accountCount)
|
||||
max.channelCount = Math.max(software.channelCount, max.channelCount)
|
||||
})
|
||||
return (
|
||||
<Layout matomoConfig={matomoConfig}>
|
||||
<Head>
|
||||
<title>{'Stats | ' + siteTitle}</title>
|
||||
</Head>
|
||||
<h1>Index stats</h1>
|
||||
<Loader loading={loading} hideContent={true}>
|
||||
{stats === null
|
||||
? (<p>Failed to load stats data!</p>)
|
||||
: (
|
||||
<>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Software</th>
|
||||
<th className={'number-cell'}>Instance count</th>
|
||||
<th className={'number-cell'}>Account count</th>
|
||||
<th className={'number-cell'}>Channel count</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{
|
||||
stats.softwares.map((software, index) => {
|
||||
summary.nodeCount += software.nodeCount
|
||||
summary.accountCount += software.accountCount
|
||||
summary.channelCount += software.channelCount
|
||||
return (
|
||||
<tr key={index}>
|
||||
<td>{software.name !== null
|
||||
? <SoftwareBadge softwareName={software.name}/>
|
||||
: <em>Not recognized</em>}</td>
|
||||
<td className={'number-cell'}>{software.nodeCount}</td>
|
||||
<td className={'number-cell'}>{software.accountCount}</td>
|
||||
<td className={'number-cell'}>{software.channelCount}</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<th>Summary</th>
|
||||
<th className={'number-cell'}>{summary.nodeCount}</th>
|
||||
<th className={'number-cell'}>{summary.accountCount}</th>
|
||||
<th className={'number-cell'}>{summary.channelCount}</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</Loader>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<SortToggle onToggle={toggleSort} field={'softwareName'} sort={sort}>
|
||||
Software name
|
||||
</SortToggle>
|
||||
</th>
|
||||
<th className={'number-cell'}>
|
||||
<SortToggle onToggle={toggleSort} field={'nodeCount'} sort={sort}>
|
||||
Instance count
|
||||
</SortToggle>
|
||||
</th>
|
||||
<th className={'number-cell'}>
|
||||
<SortToggle onToggle={toggleSort} field={'accountCount'} sort={sort}>
|
||||
Account count
|
||||
</SortToggle>
|
||||
</th>
|
||||
<th className={'number-cell'}>
|
||||
<SortToggle onToggle={toggleSort} field={'channelCount'} sort={sort}>
|
||||
Channel count
|
||||
</SortToggle>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<Loader loading={loading} hideContent={!loaded} table={4} showTop={true}>
|
||||
{stats === null
|
||||
? (<tr><td colSpan={4}><em>Failed to load stats data!</em></td></tr>)
|
||||
: (
|
||||
<>
|
||||
<tbody>
|
||||
{
|
||||
stats.softwares.map((software, index) => {
|
||||
return software.name !== null
|
||||
? (
|
||||
<tr key={index}>
|
||||
<td>
|
||||
<SoftwareBadge softwareName={software.name}/>
|
||||
</td>
|
||||
<td className={'number-cell'}>
|
||||
<span>{software.nodeCount}</span>
|
||||
<ProgressBar way={'left'}
|
||||
percents={100 * software.nodeCount / max.nodeCount}/>
|
||||
</td>
|
||||
<td className={'number-cell'}>
|
||||
<span>{software.accountCount}</span>
|
||||
<ProgressBar way={'left'}
|
||||
percents={100 * software.accountCount / max.accountCount}/>
|
||||
</td>
|
||||
<td className={'number-cell'}>
|
||||
<span>{software.channelCount}</span>
|
||||
<ProgressBar way={'left'}
|
||||
percents={100 * software.channelCount / max.channelCount}/>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
: (
|
||||
<tr key={index}>
|
||||
<td><em>Not recognized</em></td>
|
||||
<td className={'number-cell'}><span>{software.nodeCount}</span></td>
|
||||
<td className={'number-cell'}><span>{software.accountCount}</span>
|
||||
</td>
|
||||
<td className={'number-cell'}><span>{software.channelCount}</span>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<th>Summary</th>
|
||||
<th className={'number-cell'}>{sum.nodeCount}</th>
|
||||
<th className={'number-cell'}>{sum.accountCount}</th>
|
||||
<th className={'number-cell'}>{sum.channelCount}</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</Loader>
|
||||
</table>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -34,6 +34,22 @@ table {
|
|||
&.number-cell {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.progressbar {
|
||||
background-color: var(--accent-color);
|
||||
height: 2px;
|
||||
width: 100%;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.sort-toggle{
|
||||
svg{
|
||||
max-width: 1em;
|
||||
max-height: 1em;
|
||||
margin-left: 0.5em;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
th {
|
||||
|
@ -238,6 +254,7 @@ nav {
|
|||
&.active {
|
||||
font-weight: bold;
|
||||
color: var(--main-fg-color);
|
||||
|
||||
svg {
|
||||
max-width: 1.5em;
|
||||
max-height: 1.5em;
|
||||
|
@ -253,6 +270,8 @@ nav {
|
|||
}
|
||||
|
||||
.loader {
|
||||
text-align: center;
|
||||
|
||||
.loader-visualisation {
|
||||
margin: 1em 0;
|
||||
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
import { z } from 'zod'
|
||||
export const statsRequestSortBySchema = z.enum(['nodeCount', 'accountCount', 'channelCount', 'softwareName'])
|
||||
export const statsRequestSortWaySchema = z.enum(['asc', 'desc'])
|
||||
export const statsRequestSchema = z.object({
|
||||
sortBy: z.optional(statsRequestSortBySchema),
|
||||
sortWay: z.optional(statsRequestSortWaySchema)
|
||||
})
|
||||
|
||||
export type StatsRequest = z.infer<typeof statsRequestSchema>
|
||||
export type StatsRequestSortWay = z.infer<typeof statsRequestSortWaySchema>
|
||||
export type StatsRequestSortBy = z.infer<typeof statsRequestSortBySchema>
|
Ładowanie…
Reference in New Issue