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'
|
import React, { ReactNode } from 'react'
|
||||||
|
|
||||||
const Loader:React.FC<{ children: ReactNode, loading: boolean, hideContent?:boolean }> = ({ hideContent, children, loading }) => {
|
const Loader:React.FC<{ children: ReactNode, loading: boolean, hideContent?:boolean, table?:number, showTop?:boolean, showBottom?:boolean }> = ({ showTop, showBottom, hideContent, children, table, loading }) => {
|
||||||
return (
|
const className = 'loader' + (loading ? ' -loading' : '')
|
||||||
<div className={'loader' + (loading ? ' -loading' : '')}>
|
const loaderVisual = (
|
||||||
<div className={'loader-content'}>
|
|
||||||
{ hideContent && loading ? '' : children}
|
|
||||||
</div>
|
|
||||||
<div className={'loader-visualisation'}>
|
<div className={'loader-visualisation'}>
|
||||||
{loading
|
|
||||||
? (
|
|
||||||
<>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className='loader-graphics' width="34" height="34">
|
<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="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" />
|
<path className="train" d="M 31.677259,17.003529 A 14.680208,14.680199 0 0 1 20.796571,31.183505" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>Loading...</span>
|
<span>Loading...</span>
|
||||||
</>
|
</div>
|
||||||
|
)
|
||||||
|
if (table) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{showTop && loading
|
||||||
|
? (
|
||||||
|
<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>
|
</div>
|
||||||
|
{showBottom && loading ? loaderVisual : ''}
|
||||||
</div>
|
</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 { NextApiRequest, NextApiResponse } from 'next'
|
||||||
import { StatsResponse, StatsResponseSoftware } from '../../types/StatsResponse'
|
import { StatsResponse, StatsResponseSoftware } from '../../types/StatsResponse'
|
||||||
import { cache } from '../../lib/cache'
|
import { cache } from '../../lib/cache'
|
||||||
|
import { statsRequestSchema } from '../../types/StatsRequest'
|
||||||
|
|
||||||
interface StatsItem {
|
interface StatsItem {
|
||||||
softwarename: string | null,
|
softwarename: string | null,
|
||||||
|
@ -14,9 +15,13 @@ interface StatsItem {
|
||||||
const CACHE_KEY = 'stats'
|
const CACHE_KEY = 'stats'
|
||||||
|
|
||||||
const handleGetStats = async (req: NextApiRequest, res: NextApiResponse<StatsResponse>): Promise<void> => {
|
const handleGetStats = async (req: NextApiRequest, res: NextApiResponse<StatsResponse>): Promise<void> => {
|
||||||
if (!cache.has(CACHE_KEY)) {
|
const query = await statsRequestSchema.parseAsync(req.query)
|
||||||
console.info('Retrieving new stats')
|
query.sortBy = query.sortBy ?? 'nodeCount'
|
||||||
const data = await prisma.$queryRaw<StatsItem[]>`
|
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,
|
select n."softwareName" as softwarename,
|
||||||
count(n.id) as nodecount,
|
count(n.id) as nodecount,
|
||||||
(
|
(
|
||||||
|
@ -40,9 +45,9 @@ const handleGetStats = async (req: NextApiRequest, res: NextApiResponse<StatsRes
|
||||||
from "Node" n
|
from "Node" n
|
||||||
group by n."softwareName"
|
group by n."softwareName"
|
||||||
having count(n.id) > 1
|
having count(n.id) > 1
|
||||||
order by nodecount desc;
|
order by ${query.sortBy.toLowerCase() + ' ' + query.sortWay.toUpperCase()};
|
||||||
`
|
`)
|
||||||
cache.set<StatsResponse>(CACHE_KEY, {
|
cache.set<StatsResponse>(cacheKey, {
|
||||||
softwares: data.map(
|
softwares: data.map(
|
||||||
(item: StatsItem): StatsResponseSoftware => {
|
(item: StatsItem): StatsResponseSoftware => {
|
||||||
return {
|
return {
|
||||||
|
@ -56,9 +61,9 @@ const handleGetStats = async (req: NextApiRequest, res: NextApiResponse<StatsRes
|
||||||
)
|
)
|
||||||
}, parseInt(process.env.STATS_CACHE_MINUTES ?? '60') * 60 * 1000)
|
}, 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)
|
res.status(200)
|
||||||
.json(cache.get<StatsResponse>(CACHE_KEY))
|
.json(cache.get<StatsResponse>(cacheKey))
|
||||||
}
|
}
|
||||||
|
|
||||||
export default handleGetStats
|
export default handleGetStats
|
||||||
|
|
|
@ -131,7 +131,7 @@ const Home:React.FC<InferGetServerSidePropsType<typeof getServerSideProps>> = ({
|
||||||
<span>Search</span>
|
<span>Search</span>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<Loader loading={loading}>
|
<Loader loading={loading} showBottom={true}>
|
||||||
{
|
{
|
||||||
loaded
|
loaded
|
||||||
? <Results feeds={results}/>
|
? <Results feeds={results}/>
|
||||||
|
|
|
@ -7,64 +7,146 @@ import Loader from '../components/Loader'
|
||||||
import axios from 'axios'
|
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 { StatsRequest, StatsRequestSortBy } from '../types/StatsRequest'
|
||||||
|
import SortToggle from '../components/SortToggle'
|
||||||
|
|
||||||
|
let source = axios.CancelToken.source()
|
||||||
|
|
||||||
const Stats: React.FC<InferGetServerSidePropsType<typeof getServerSideProps>> = ({ matomoConfig }) => {
|
const Stats: React.FC<InferGetServerSidePropsType<typeof getServerSideProps>> = ({ matomoConfig }) => {
|
||||||
const [loading, setLoading] = useState<boolean>(true)
|
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 {
|
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)
|
const stats = await statsResponseSchema.parseAsync(response.data)
|
||||||
setStats(stats)
|
setStats(stats)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStats(null)
|
setStats(null)
|
||||||
console.log(err)
|
console.log(err)
|
||||||
}
|
}
|
||||||
|
setLoaded(true)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadStats = async () => {
|
||||||
|
console.info('Cancelling retrivals')
|
||||||
|
source.cancel('New query on the way')
|
||||||
|
setTimeout(retrieveStats)
|
||||||
|
}
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadStats()
|
loadStats()
|
||||||
}, [])
|
}, [sort])
|
||||||
const summary = {
|
const sum = {
|
||||||
nodeCount: 0,
|
nodeCount: 0,
|
||||||
accountCount: 0,
|
accountCount: 0,
|
||||||
channelCount: 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 (
|
return (
|
||||||
<Layout matomoConfig={matomoConfig}>
|
<Layout matomoConfig={matomoConfig}>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{'Stats | ' + siteTitle}</title>
|
<title>{'Stats | ' + siteTitle}</title>
|
||||||
</Head>
|
</Head>
|
||||||
<h1>Index stats</h1>
|
<h1>Index stats</h1>
|
||||||
<Loader loading={loading} hideContent={true}>
|
|
||||||
{stats === null
|
|
||||||
? (<p>Failed to load stats data!</p>)
|
|
||||||
: (
|
|
||||||
<>
|
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Software</th>
|
<th>
|
||||||
<th className={'number-cell'}>Instance count</th>
|
<SortToggle onToggle={toggleSort} field={'softwareName'} sort={sort}>
|
||||||
<th className={'number-cell'}>Account count</th>
|
Software name
|
||||||
<th className={'number-cell'}>Channel count</th>
|
</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>
|
</tr>
|
||||||
</thead>
|
</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>
|
<tbody>
|
||||||
{
|
{
|
||||||
stats.softwares.map((software, index) => {
|
stats.softwares.map((software, index) => {
|
||||||
summary.nodeCount += software.nodeCount
|
return software.name !== null
|
||||||
summary.accountCount += software.accountCount
|
? (
|
||||||
summary.channelCount += software.channelCount
|
|
||||||
return (
|
|
||||||
<tr key={index}>
|
<tr key={index}>
|
||||||
<td>{software.name !== null
|
<td>
|
||||||
? <SoftwareBadge softwareName={software.name}/>
|
<SoftwareBadge softwareName={software.name}/>
|
||||||
: <em>Not recognized</em>}</td>
|
</td>
|
||||||
<td className={'number-cell'}>{software.nodeCount}</td>
|
<td className={'number-cell'}>
|
||||||
<td className={'number-cell'}>{software.accountCount}</td>
|
<span>{software.nodeCount}</span>
|
||||||
<td className={'number-cell'}>{software.channelCount}</td>
|
<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>
|
</tr>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
@ -73,16 +155,16 @@ const Stats: React.FC<InferGetServerSidePropsType<typeof getServerSideProps>> =
|
||||||
<tfoot>
|
<tfoot>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Summary</th>
|
<th>Summary</th>
|
||||||
<th className={'number-cell'}>{summary.nodeCount}</th>
|
<th className={'number-cell'}>{sum.nodeCount}</th>
|
||||||
<th className={'number-cell'}>{summary.accountCount}</th>
|
<th className={'number-cell'}>{sum.accountCount}</th>
|
||||||
<th className={'number-cell'}>{summary.channelCount}</th>
|
<th className={'number-cell'}>{sum.channelCount}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
</Loader>
|
</Loader>
|
||||||
|
</table>
|
||||||
</Layout>
|
</Layout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -34,6 +34,22 @@ table {
|
||||||
&.number-cell {
|
&.number-cell {
|
||||||
text-align: right;
|
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 {
|
th {
|
||||||
|
@ -238,6 +254,7 @@ nav {
|
||||||
&.active {
|
&.active {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: var(--main-fg-color);
|
color: var(--main-fg-color);
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
max-width: 1.5em;
|
max-width: 1.5em;
|
||||||
max-height: 1.5em;
|
max-height: 1.5em;
|
||||||
|
@ -253,6 +270,8 @@ nav {
|
||||||
}
|
}
|
||||||
|
|
||||||
.loader {
|
.loader {
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
.loader-visualisation {
|
.loader-visualisation {
|
||||||
margin: 1em 0;
|
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